Browse Source

Update query

Include string matching operators.
master
TheoryOfNekomata 7 months ago
parent
commit
49d8f3f5d1
7 changed files with 125 additions and 59 deletions
  1. +4
    -0
      README.md
  2. +3
    -1
      packages/core/src/backend/common.ts
  3. +4
    -2
      packages/core/src/backend/data-source/common.ts
  4. +97
    -42
      packages/core/src/backend/data-source/queries.ts
  5. +7
    -4
      packages/core/src/backend/servers/http/handlers/resource.ts
  6. +2
    -2
      packages/core/src/client/index.ts
  7. +8
    -8
      packages/core/test/features/query.test.ts

+ 4
- 0
README.md View File

@@ -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

+ 3
- 1
packages/core/src/backend/common.ts View File

@@ -47,7 +47,7 @@ export type RequestDecorator = (req: RequestContext) => RequestContext | Promise
export type ParamRequestDecorator<Params extends Array<unknown> = []> = (...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<Schema extends BaseSchema = BaseSchema> {
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';

+ 4
- 2
packages/core/src/backend/data-source/common.ts View File

@@ -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<unknown>;
getTotalCount?(query?: Query): Promise<TotalCount>;
@@ -36,3 +36,5 @@ export interface ResourceIdConfig<IdSchema extends v.BaseSchema> {
export interface GenerationStrategy {
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>;
}

export * from './queries';

+ 97
- 42
packages/core/src/backend/data-source/queries.ts View File

@@ -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<string, ProcessEntry>;
//coerceValues: CoerceValues;
//stringSearch: StringSearch;
}

const normalizeRhs = (rhs: string, coerceValues?: ProcessEntry) => {
const normalizeRhs = (lhs: string, rhs: string, processEntriesMap?: Record<string, ProcessEntry>) => {
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<string, string>;
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,
],
}
)),


+ 7
- 4
packages/core/src/backend/servers/http/handlers/resource.ts View File

@@ -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<typeof resource.schema>[];
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(


+ 2
- 2
packages/core/src/client/index.ts View File

@@ -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;


+ 8
- 8
packages/core/test/features/query.test.ts View File

@@ -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: [


Loading…
Cancel
Save