diff --git a/README.md b/README.md index 18bd5f5..333a022 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/core/src/backend/data-source/common.ts b/packages/core/src/backend/data-source/common.ts index 6489ebe..ab255cd 100644 --- a/packages/core/src/backend/data-source/common.ts +++ b/packages/core/src/backend/data-source/common.ts @@ -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; getTotalCount?(query?: Query): Promise; diff --git a/packages/core/src/backend/data-source/queries.ts b/packages/core/src/backend/data-source/queries.ts index df83512..a0bf9ee 100644 --- a/packages/core/src/backend/data-source/queries.ts +++ b/packages/core/src/backend/data-source/queries.ts @@ -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>; 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 }; diff --git a/packages/core/test/features/query.test.ts b/packages/core/test/features/query.test.ts index 5c48e63..c0f26a7 100644 --- a/packages/core/test/features/query.test.ts +++ b/packages/core/test/features/query.test.ts @@ -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()); }); });