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 | 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 | - The HTTP QUERY Method | ||||
@@ -1,6 +1,6 @@ | |||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Resource} from '../../common'; | import {Resource} from '../../common'; | ||||
import {DataSourceQuery} from './queries'; | |||||
import {DataSourceQueryExpression} from './queries'; | |||||
type IsCreated = boolean; | type IsCreated = boolean; | ||||
@@ -11,7 +11,7 @@ type DeleteResult = unknown; | |||||
export interface DataSource< | export interface DataSource< | ||||
ItemData extends object = object, | ItemData extends object = object, | ||||
ID extends unknown = unknown, | ID extends unknown = unknown, | ||||
Query extends DataSourceQuery = DataSourceQuery, | |||||
Query extends DataSourceQueryExpression = DataSourceQueryExpression, | |||||
> { | > { | ||||
initialize(): Promise<unknown>; | initialize(): Promise<unknown>; | ||||
getTotalCount?(query?: Query): Promise<TotalCount>; | 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 type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; | ||||
export interface DataSourceOperatorQuery { | |||||
export interface DataSourceQueryExpression { | |||||
lhs: string; | lhs: string; | ||||
operator: DataSourceQueryOperator; | 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 { | 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 { | 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 | // 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', () => { | it('returns a data source query collection from search params', () => { | ||||
const q = new URLSearchParams(); | const q = new URLSearchParams(); | ||||
const collection = convertToDataSourceQueryCollection(q); | 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({ | 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({ | 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({ | const q = new URLSearchParams({ | ||||
attr: '.lt.foo' | |||||
attr: 'foo' | |||||
}); | }); | ||||
const collection = convertToDataSourceQueryCollection(q); | 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); | 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([ | const q = new URLSearchParams([ | ||||
['attr', 'foo'], | ['attr', 'foo'], | ||||
['attr', 'bar'], | ['attr', 'bar'], | ||||
]); | ]); | ||||
const collection = convertToDataSourceQueryCollection(q); | 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()); | |||||
}); | }); | ||||
}); | }); |