From 49d8f3f5d16b3a37dad8e87cb2abf9e48a1efa36 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 13 May 2024 13:51:35 +0800 Subject: [PATCH] Update query Include string matching operators. --- README.md | 4 + packages/core/src/backend/common.ts | 4 +- .../core/src/backend/data-source/common.ts | 6 +- .../core/src/backend/data-source/queries.ts | 139 ++++++++++++------ .../backend/servers/http/handlers/resource.ts | 11 +- packages/core/src/client/index.ts | 4 +- packages/core/test/features/query.test.ts | 16 +- 7 files changed, 125 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 333a022..552eea5 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,7 @@ See [docs folder](./docs) for more details. https://url.spec.whatwg.org/#urlencoded-parsing https://url.spec.whatwg.org/#interface-urlsearchparams + +- 303 See Other + + https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index e3d5618..bdc79cc 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -47,7 +47,7 @@ export type RequestDecorator = (req: RequestContext) => RequestContext | Promise export type ParamRequestDecorator = []> = (...args: Params) => RequestDecorator; // TODO put this in HTTP -export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'QUERY'; export interface AllowedMiddlewareSpecification { method: Method; @@ -71,3 +71,5 @@ export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); return allowedMethods.join(','); } + +export * from './data-source'; diff --git a/packages/core/src/backend/data-source/common.ts b/packages/core/src/backend/data-source/common.ts index ab255cd..7346876 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 {DataSourceQueryExpression} from './queries'; +import {DataSourceQuery} 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 DataSourceQueryExpression = DataSourceQueryExpression, + Query extends DataSourceQuery = DataSourceQuery, > { initialize(): Promise; getTotalCount?(query?: Query): Promise; @@ -36,3 +36,5 @@ export interface ResourceIdConfig { export interface GenerationStrategy { (dataSource: DataSource, ...args: unknown[]): Promise; } + +export * from './queries'; diff --git a/packages/core/src/backend/data-source/queries.ts b/packages/core/src/backend/data-source/queries.ts index 1763508..f84ea44 100644 --- a/packages/core/src/backend/data-source/queries.ts +++ b/packages/core/src/backend/data-source/queries.ts @@ -1,49 +1,60 @@ -export const DIRECTIVE_MAP = { - '=': 'equals', - '!=': 'not-equals', - '>=': 'greater-than-equal', - '<=': 'less-than-equal', - '>': 'greater-than', - '<': 'less-than', -}; - -export const DATA_SOURCE_QUERY_OPERATORS = Object.keys(DIRECTIVE_MAP) as (keyof typeof DIRECTIVE_MAP)[]; - -export type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; - -export interface DataSourceQueryExpression { +const DATA_SOURCE_QUERY_OPERATORS = [ + '=', + '!=', + '>=', + '<=', + '>', + '<', + 'LIKE', + 'ILIKE', + 'REGEXP' +] as const; + +type DataSourceQueryOperator = typeof DATA_SOURCE_QUERY_OPERATORS[number]; + +interface DataSourceQueryExpression { lhs: string; operator: DataSourceQueryOperator; - rhs: string | number | boolean; + rhs: string | number | boolean | RegExp; } -export interface DataSourceQueryOrGrouping { +interface DataSourceQueryOrGrouping { type: 'or'; expressions: DataSourceQueryExpression[]; } -export interface DataSourceQueryAndGrouping { +interface DataSourceQueryAndGrouping { type: 'and'; expressions: DataSourceQueryOrGrouping[]; } +export type DataSourceQuery = DataSourceQueryAndGrouping; + interface ProcessEntryBase { type: string; } -export interface ProcessEntryString extends ProcessEntryBase { +const AVAILABLE_MATCH_TYPES = [ + 'startsWith', + 'endsWith', + 'includes', + 'regexp', +] as const; + +type MatchType = typeof AVAILABLE_MATCH_TYPES[number]; + +interface ProcessEntryString extends ProcessEntryBase { type: 'string'; - startsWith?: boolean; - endsWith?: boolean; - caseInsensitive?: boolean; - includes?: boolean; + matchType?: MatchType; + caseInsensitiveMatch?: boolean; } -export interface ProcessEntryNumber extends ProcessEntryBase { +interface ProcessEntryNumber extends ProcessEntryBase { type: 'number'; + decimal?: boolean; } -export interface ProcessEntryBoolean extends ProcessEntryBase { +interface ProcessEntryBoolean extends ProcessEntryBase { type: 'boolean'; truthyStrings?: string[]; } @@ -52,13 +63,19 @@ export type ProcessEntry = ProcessEntryString | ProcessEntryNumber | ProcessEntr interface ConvertToDataSourceQueryCollectionOptions { processEntries: Record; - //coerceValues: CoerceValues; - //stringSearch: StringSearch; } -const normalizeRhs = (rhs: string, coerceValues?: ProcessEntry) => { +const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record) => { + const coerceValues = processEntriesMap?.[lhs] ?? { + type: 'string' + }; + if (coerceValues?.type === 'number') { - return Number(rhs); + return { + lhs, + operator: '=', + rhs: Number(rhs) + } as DataSourceQueryExpression; } if (coerceValues?.type === 'boolean') { @@ -67,15 +84,61 @@ const normalizeRhs = (rhs: string, coerceValues?: ProcessEntry) => { 'true', ]; - return truthyStrings.includes(rhs); + return { + lhs, + operator: '=', + rhs: truthyStrings.includes(rhs) + } as DataSourceQueryExpression; + } + + if (coerceValues?.type === 'string') { + switch (coerceValues?.matchType) { + case 'startsWith': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `%${rhs}`, + } as DataSourceQueryExpression; + } + case 'endsWith': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `${rhs}%`, + } as DataSourceQueryExpression; + } + case 'includes': { + return { + lhs, + operator: coerceValues.caseInsensitiveMatch ? 'ILIKE' : 'LIKE', + rhs: `%${rhs}%`, + } as DataSourceQueryExpression; + } + case 'regexp': { + return { + lhs, + operator: 'REGEXP', + rhs: new RegExp(rhs, coerceValues.caseInsensitiveMatch ? 'i' : ''), + } as DataSourceQueryExpression; + } + default: + break; + } + + return { + lhs, + operator: '=', + rhs, + } as DataSourceQueryExpression; } - return rhs?.toString() ?? ''; + const unknownCoerceValues = coerceValues as unknown as Record; + throw new Error(`Invalid coercion type: ${unknownCoerceValues.type}`); // 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 = ( +export const convertToDataSourceQuery = ( q: URLSearchParams, options = {} as ConvertToDataSourceQueryCollectionOptions, ): DataSourceQueryAndGrouping => { @@ -87,7 +150,7 @@ export const convertToDataSourceQueryCollection = ( expressions: [], }; const existingLhs = existingOr.expressions.find((ex) => ex.lhs === key); - const rhs = normalizeRhs(value, options.processEntries?.[key]); + const newExpression = normalizeRhs(key, value, options.processEntries); if (typeof existingLhs === 'undefined') { return { @@ -97,11 +160,7 @@ export const convertToDataSourceQueryCollection = ( { type: 'or', expressions: [ - { - lhs: key, - operator: '=', - rhs, - }, + newExpression, ], }, ], @@ -117,11 +176,7 @@ export const convertToDataSourceQueryCollection = ( ...existingOr, expressions: [ ...(existingOr.expressions ?? []), - { - lhs: key, - operator: '=', - rhs, - }, + newExpression, ], } )), diff --git a/packages/core/src/backend/servers/http/handlers/resource.ts b/packages/core/src/backend/servers/http/handlers/resource.ts index 69afa34..a193e57 100644 --- a/packages/core/src/backend/servers/http/handlers/resource.ts +++ b/packages/core/src/backend/servers/http/handlers/resource.ts @@ -1,6 +1,6 @@ import { constants } from 'http2'; import * as v from 'valibot'; -import {Middleware} from '../../../common'; +import {Middleware, convertToDataSourceQuery} from '../../../common'; import {ErrorPlainResponse, PlainResponse} from '../response'; import assert from 'assert'; import { @@ -10,16 +10,19 @@ import { PatchContentType, } from '../../../../common'; +// TODO add handleQueryCollection() + export const handleGetCollection: Middleware = async (req, res) => { const { query, resource, backend } = req; let data: v.Output[]; let totalItemCount: number | undefined; try { - // TODO querying mechanism - data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource + // check which attributes have specifics on the queries (e.g. fuzzy search on strings) + const dataSourceQuery = convertToDataSourceQuery(query); + data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource if (backend!.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { - totalItemCount = await resource.dataSource.getTotalCount(query); + totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); } } catch (cause) { throw new ErrorPlainResponse( diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 73cdd1e..9fe20ae 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -18,7 +18,7 @@ export interface ClientState { export interface ClientBuilder { language(languageCode: ClientState['language']['name']): this; charset(charset: ClientState['charset']['name']): this; - mediaTyoe(mediaType: ClientState['mediaType']['name']): this; + mediaType(mediaType: ClientState['mediaType']['name']): this; } export interface CreateClientParams { @@ -34,7 +34,7 @@ export const createClient = (params: CreateClientParams) => { }; return { - mediaTyoe(mediaTypeName) { + mediaType(mediaTypeName) { const mediaType = clientState.app.mediaTypes.get(mediaTypeName); clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; return this; diff --git a/packages/core/test/features/query.test.ts b/packages/core/test/features/query.test.ts index 1281595..7166193 100644 --- a/packages/core/test/features/query.test.ts +++ b/packages/core/test/features/query.test.ts @@ -1,10 +1,10 @@ import {describe, expect, it} from 'vitest'; -import { convertToDataSourceQueryCollection } from '../../src/backend'; +import { convertToDataSourceQuery } from '../../src/backend'; describe('query', () => { it('returns a data source query collection from search params', () => { const q = new URLSearchParams(); - const collection = convertToDataSourceQueryCollection(q); + const collection = convertToDataSourceQuery(q); expect(collection.expressions).toBeInstanceOf(Array); }); @@ -13,7 +13,7 @@ describe('query', () => { attr: '2', attr2: '2', }); - const collection = convertToDataSourceQueryCollection( + const collection = convertToDataSourceQuery( q, { processEntries: { @@ -57,7 +57,7 @@ describe('query', () => { attr3: 'true', attr4: 'false', }); - const collection = convertToDataSourceQueryCollection( + const collection = convertToDataSourceQuery( q, { processEntries: { @@ -121,7 +121,7 @@ describe('query', () => { const q = new URLSearchParams({ attr: 'foo' }); - const collection = convertToDataSourceQueryCollection(q); + const collection = convertToDataSourceQuery(q); expect(collection).toEqual({ type: 'and', expressions: [ @@ -144,7 +144,7 @@ describe('query', () => { ['attr', 'foo'], ['attr2', 'bar'], ]); - const collection = convertToDataSourceQueryCollection(q); + const collection = convertToDataSourceQuery(q); expect(collection).toEqual({ type: 'and', expressions: [ @@ -177,7 +177,7 @@ describe('query', () => { ['attr', 'foo'], ['attr', 'bar'], ]); - const collection = convertToDataSourceQueryCollection(q); + const collection = convertToDataSourceQuery(q); expect(collection).toEqual({ type: 'and', expressions: [ @@ -208,7 +208,7 @@ describe('query', () => { ['attr', 'bar'], ['attr2', 'baz'], ]); - const collection = convertToDataSourceQueryCollection(q); + const collection = convertToDataSourceQuery(q); expect(collection).toEqual({ type: 'and', expressions: [