Remove directives, stick to simple querying via equality.master
@@ -64,9 +64,9 @@ See [docs folder](./docs) for more details. | |||
https://www.rfc-editor.org/rfc/rfc6902 | |||
- HTTP SEARCH Method | |||
- ~~HTTP SEARCH Method~~ | |||
https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-00.html | |||
~~https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-00.html~~ | |||
- The HTTP QUERY Method | |||
@@ -1,6 +1,6 @@ | |||
import * as v from 'valibot'; | |||
import {Resource} from '../../common'; | |||
import {DataSourceQuery} from './queries'; | |||
import {DataSourceQueryExpression} from './queries'; | |||
type IsCreated = boolean; | |||
@@ -11,7 +11,7 @@ type DeleteResult = unknown; | |||
export interface DataSource< | |||
ItemData extends object = object, | |||
ID extends unknown = unknown, | |||
Query extends DataSourceQuery = DataSourceQuery, | |||
Query extends DataSourceQueryExpression = DataSourceQueryExpression, | |||
> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Query): Promise<TotalCount>; | |||
@@ -11,110 +11,107 @@ export const DATA_SOURCE_QUERY_OPERATORS = Object.keys(DIRECTIVE_MAP) as (keyof | |||
export type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | |||
export interface DataSourceOperatorQuery { | |||
export interface DataSourceQueryExpression { | |||
lhs: string; | |||
operator: DataSourceQueryOperator; | |||
rhs: string; | |||
rhs: string | number | boolean; | |||
} | |||
export interface DataSourceFunctionQuery { | |||
name: string; | |||
args: string[]; | |||
export interface DataSourceQueryOrGrouping { | |||
type: 'or'; | |||
expressions: DataSourceQueryExpression[]; | |||
} | |||
export type DataSourceQuery = DataSourceOperatorQuery | DataSourceFunctionQuery; | |||
export interface DataSourceQueryAndGrouping { | |||
type: 'and'; | |||
expressions: DataSourceQueryOrGrouping[]; | |||
} | |||
export const COERCE_VALUES_TYPES = [ | |||
'number', | |||
'string', | |||
'boolean', | |||
] as const; | |||
export type CoerceValuesType = typeof COERCE_VALUES_TYPES[number]; | |||
type CoerceValues = Partial<Record<CoerceValuesType, string[]>>; | |||
interface ConvertToDataSourceQueryCollectionOptions { | |||
coerceValues: CoerceValues; | |||
} | |||
const parseDirectives = (valueRaw: string) => { | |||
const fragments = valueRaw.split('.'); | |||
const normalizeRhs = (lhs: string, rhs: string, coerceValues: CoerceValues) => { | |||
if (coerceValues?.number?.includes(lhs)) { | |||
return Number(rhs); | |||
} | |||
if (!( | |||
fragments.length > 1 && fragments[0].length === 0 | |||
)) { | |||
return { | |||
operator: '=', | |||
rhs: valueRaw, | |||
}; | |||
if (coerceValues?.boolean?.includes(lhs)) { | |||
return rhs.trim().toLowerCase() === 'true'; | |||
} | |||
const theOperator = fragments.slice(1, -1).reduce( | |||
(theState, f) => { | |||
const whichDirective = Object.entries(DIRECTIVE_MAP).find( | |||
([, match]) => { | |||
const remainingChars = match.split('').reduce( | |||
(rem, matchChar) => { | |||
if (matchChar === rem.charAt(0)) { | |||
return rem.slice(1); | |||
} | |||
return rem; | |||
return rhs?.toString() ?? ''; | |||
// this will be sent to the data source, e.g. the SQL query | |||
// we can also make this function act as a "sanitizer" | |||
} | |||
export const convertToDataSourceQueryCollection = ( | |||
q: URLSearchParams, | |||
options = {} as ConvertToDataSourceQueryCollectionOptions, | |||
): DataSourceQueryAndGrouping => { | |||
return Array.from(q.entries()).reduce( | |||
(queries, [key, value]) => { | |||
const existingOr = queries.expressions | |||
.find((ex) => ex.expressions.some((ex2) => ex2.lhs === key)) ?? { | |||
type: 'or', | |||
expressions: [], | |||
}; | |||
const existingLhs = existingOr.expressions.find((ex) => ex.lhs === key); | |||
const rhs = normalizeRhs(key, value, options.coerceValues); | |||
if (typeof existingLhs === 'undefined') { | |||
return { | |||
...queries, | |||
expressions: [ | |||
...queries.expressions, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: key, | |||
operator: '=', | |||
rhs, | |||
}, | |||
], | |||
}, | |||
f.toLowerCase() | |||
); | |||
return remainingChars.length <= 0; | |||
}, | |||
); | |||
const selectedOperator = whichDirective?.[0] ?? theState.operator; | |||
if (selectedOperator === '=') { | |||
if (theState.operator === '>') { | |||
return { | |||
...theState, | |||
operator: '>=', | |||
currentToken: `${theState.currentToken}${f}`, | |||
}; | |||
} | |||
if (theState.operator === '<') { | |||
return { | |||
...theState, | |||
operator: '<=', | |||
currentToken: `${theState.currentToken}${f}`, | |||
}; | |||
} | |||
], | |||
}; | |||
} | |||
return { | |||
...theState, | |||
operator: selectedOperator, | |||
currentToken: `${theState.currentToken}${f}`, | |||
...queries, | |||
expressions: queries.expressions.map((ex) => ( | |||
ex.expressions.some((ex2) => ex2.lhs !== key) | |||
? ex | |||
: { | |||
...existingOr, | |||
expressions: [ | |||
...(existingOr.expressions ?? []), | |||
{ | |||
lhs: key, | |||
operator: '=', | |||
rhs, | |||
}, | |||
], | |||
} | |||
)), | |||
}; | |||
}, | |||
{ | |||
operator: undefined as (string | undefined), | |||
currentToken: '', | |||
}, | |||
); | |||
type: 'and', | |||
expressions: [], | |||
} as DataSourceQueryAndGrouping | |||
) | |||
return { | |||
operator: theOperator.operator ?? '=', | |||
rhs: fragments.at(-1), | |||
}; | |||
}; | |||
export const convertToDataSourceQueryCollection = ( | |||
q: URLSearchParams, | |||
options = {} as ConvertToDataSourceQueryCollectionOptions, | |||
): DataSourceQuery[] => { | |||
return Array.from(q.entries()) | |||
.reduce( | |||
(queries, [key, value]) => { | |||
const s = parseDirectives(value); | |||
return [ | |||
...queries, | |||
{ | |||
lhs: key, | |||
operator: s.operator, | |||
rhs: JSON.stringify(s.rhs), | |||
}, | |||
]; | |||
}, | |||
[] as DataSourceQuery[] | |||
); | |||
// all the queries are to be treated as "AND", as suggested by the & character to separate the query param entries | |||
}; |
@@ -5,178 +5,252 @@ describe('query', () => { | |||
it('returns a data source query collection from search params', () => { | |||
const q = new URLSearchParams(); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toBeInstanceOf(Array); | |||
expect(collection.expressions).toBeInstanceOf(Array); | |||
}); | |||
it('returns an equal query', () => { | |||
it('coerces a numeric value', () => { | |||
const q = new URLSearchParams({ | |||
attr: 'foo' | |||
attr: '2', | |||
attr2: '2', | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
const collection = convertToDataSourceQueryCollection( | |||
q, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: '"foo"', | |||
coerceValues: { | |||
number: ['attr'], | |||
}, | |||
}, | |||
]); | |||
}); | |||
it('returns an equal expression query', () => { | |||
const q = new URLSearchParams({ | |||
attr: '.eq.foo' | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 2, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: '2', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: '"foo"', | |||
}, | |||
]); | |||
}); | |||
it('returns a not equal expression query', () => { | |||
it('coerces a boolean value', () => { | |||
const q = new URLSearchParams({ | |||
attr: '.neq.foo' | |||
attr: 'true', | |||
attr2: 'false', | |||
attr3: 'true', | |||
attr4: 'false', | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
const collection = convertToDataSourceQueryCollection( | |||
q, | |||
{ | |||
lhs: 'attr', | |||
operator: '!=', | |||
rhs: '"foo"', | |||
coerceValues: { | |||
boolean: ['attr', 'attr2'], | |||
}, | |||
}, | |||
]); | |||
}); | |||
it('returns a greater than expression query', () => { | |||
const q = new URLSearchParams({ | |||
attr: '.gt.foo' | |||
); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: true, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: false, | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr3', | |||
operator: '=', | |||
rhs: 'true', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr4', | |||
operator: '=', | |||
rhs: 'false', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '>', | |||
rhs: '"foo"', | |||
}, | |||
]); | |||
}); | |||
it('returns a less than expression query', () => { | |||
it('returns an equal query', () => { | |||
const q = new URLSearchParams({ | |||
attr: '.lt.foo' | |||
attr: 'foo' | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '<', | |||
rhs: '"foo"', | |||
}, | |||
]); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it.only('returns a greater than or equal expression query', () => { | |||
// expect( | |||
// convertToDataSourceQueryCollection( | |||
// new URLSearchParams({ | |||
// attr: '.gt.eq.foo' | |||
// }) | |||
// ) | |||
// ).toEqual([ | |||
// { | |||
// lhs: 'attr', | |||
// operator: '>=', | |||
// rhs: '"foo"', | |||
// }, | |||
// ]); | |||
expect( | |||
convertToDataSourceQueryCollection( | |||
new URLSearchParams({ | |||
attr: '.gte.foo' | |||
}) | |||
) | |||
).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '>=', | |||
rhs: '"foo"', | |||
}, | |||
]); | |||
expect( | |||
convertToDataSourceQueryCollection( | |||
new URLSearchParams({ | |||
attr: '.neq.test' | |||
}) | |||
) | |||
).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '!=', | |||
rhs: '"test"', | |||
}, | |||
it('returns an AND operator query', () => { | |||
const q = new URLSearchParams([ | |||
['attr', 'foo'], | |||
['attr2', 'bar'], | |||
]); | |||
// expect( | |||
// convertToDataSourceQueryCollection( | |||
// new URLSearchParams({ | |||
// attr: '.gte.foo..test' | |||
// }) | |||
// ) | |||
// ).toEqual([ | |||
// { | |||
// lhs: 'attr', | |||
// operator: '>=', | |||
// rhs: '"foo.test"', | |||
// }, | |||
// ]); | |||
}); | |||
it('returns a less than or equal expression query', () => { | |||
const q = new URLSearchParams({ | |||
attr: '.lt.eq.foo' | |||
}); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '<=', | |||
rhs: '"foo"', | |||
}, | |||
]); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: 'bar', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
}); | |||
it('returns an AND operator query', () => { | |||
it('returns an OR operator query', () => { | |||
const q = new URLSearchParams([ | |||
['attr', 'foo'], | |||
['attr', 'bar'], | |||
]); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual([ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: '"foo"', | |||
}, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: '"bar"', | |||
}, | |||
]); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'bar', | |||
} | |||
] | |||
}, | |||
], | |||
}); | |||
}); | |||
it.skip('returns a query', () => { | |||
// * is not percent-encoded | |||
// . is not percent-encoded (used for form data such as input text dir...) | |||
// | |||
const q = new URLSearchParams({ | |||
'.foo': 'foo', | |||
it('returns an query with appropriate grouping', () => { | |||
const q = new URLSearchParams([ | |||
['attr3', 'quux'], | |||
['attr', 'foo'], | |||
['attr4', 'quuux'], | |||
['attr', 'bar'], | |||
['attr2', 'baz'], | |||
]); | |||
const collection = convertToDataSourceQueryCollection(q); | |||
expect(collection).toEqual({ | |||
type: 'and', | |||
expressions: [ | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr3', | |||
operator: '=', | |||
rhs: 'quux', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'foo', | |||
}, | |||
{ | |||
lhs: 'attr', | |||
operator: '=', | |||
rhs: 'bar', | |||
} | |||
] | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr4', | |||
operator: '=', | |||
rhs: 'quuux', | |||
}, | |||
], | |||
}, | |||
{ | |||
type: 'or', | |||
expressions: [ | |||
{ | |||
lhs: 'attr2', | |||
operator: '=', | |||
rhs: 'baz', | |||
}, | |||
], | |||
}, | |||
], | |||
}); | |||
console.log(q.toString()); | |||
}); | |||
}); |