From f324940d5795b41e8c6fc113defb0beb7ab03a0a Mon Sep 17 00:00:00 2001 From: David Luecke Date: Tue, 20 Dec 2022 08:26:14 -0800 Subject: [PATCH] feat(schema): Allow to add additional operators to the query syntax (#2941) --- docs/api/schema/schema.md | 91 ++++++++++++++++-------- docs/api/schema/typebox.md | 39 +++++++--- packages/schema/src/json-schema.ts | 49 ++++++++----- packages/schema/test/json-schema.test.ts | 62 +++++++++++----- packages/typebox/src/index.ts | 45 ++++++++---- packages/typebox/test/index.test.ts | 51 ++++++++----- 6 files changed, 233 insertions(+), 104 deletions(-) diff --git a/docs/api/schema/schema.md b/docs/api/schema/schema.md index 98842394e1..68dc7c2f18 100644 --- a/docs/api/schema/schema.md +++ b/docs/api/schema/schema.md @@ -141,6 +141,69 @@ export type Message = FromSchema< Schema ships with a few helpers to automatically create schemas that comply with the [Feathers query syntax](../databases/querying.md) (like `$gt`, `$ne` etc.): +### querySyntax + +`querySyntax(schema.properties, extensions)` initializes all properties the additional query syntax properties `$limit`, `$skip`, `$select` and `$sort`. `$select` and `$sort` will be typed so they only allow existing schema properties. + +```ts +import { querySyntax } from '@feathersjs/schema' +import type { FromSchema } from '@feathersjs/schema' + +export const userQuerySchema = { + $id: 'UserQuery', + type: 'object', + additionalProperties: false, + properties: { + ...querySyntax(userSchema.properties) + } +} as const + +export type UserQuery = FromSchema + +const userQuery: UserQuery = { + $limit: 10, + $select: ['email', 'id'], + $sort: { + email: 1 + } +} +``` + +Additional properties like `$ilike` can be added to the query syntax like this: + +```ts +import { querySyntax } from '@feathersjs/schema' +import type { FromSchema } from '@feathersjs/schema' + +export const userQuerySchema = { + $id: 'UserQuery', + type: 'object', + additionalProperties: false, + properties: { + ...querySyntax(userSchema.properties, { + email: { + $ilike: { + type: 'string' + } + } + } as const) + } +} as const + +export type UserQuery = FromSchema + +const userQuery: UserQuery = { + $limit: 10, + $select: ['email', 'id'], + $sort: { + email: 1 + }, + email: { + $ilike: '%@example.com' + } +} +``` + ### queryProperty `queryProperty` helper takes a definition for a single property and returns a schema that allows the default query operators. This helper supports the operators listed, below. Learn what each one means in the [common query operator](/api/databases/querying#operators) documentation. @@ -182,34 +245,6 @@ You can learn how it works, [here](https://github.com/feathersjs/feathers/blob/d `queryProperties(schema.properties)` takes the all properties of a schema and converts them into query schema properties (using `queryProperty`) -### querySyntax - -`querySyntax(schema.properties)` initializes all properties the additional query syntax properties `$limit`, `$skip`, `$select` and `$sort`. `$select` and `$sort` will be typed so they only allow existing schema properties. - -```ts -import { querySyntax } from '@feathersjs/schema' -import type { FromSchema } from '@feathersjs/schema' - -export const userQuerySchema = { - $id: 'UserQuery', - type: 'object', - additionalProperties: false, - properties: { - ...querySyntax(userSchema.properties) - } -} as const - -export type UserQuery = FromSchema - -const userQuery: UserQuery = { - $limit: 10, - $select: ['email', 'id'], - $sort: { - email: 1 - } -} -``` - ## Validators The following functions are available to get a [validator function](./validators.md) from a JSON schema definition. diff --git a/docs/api/schema/typebox.md b/docs/api/schema/typebox.md index b4eaf27b67..101606fd1b 100644 --- a/docs/api/schema/typebox.md +++ b/docs/api/schema/typebox.md @@ -1,7 +1,3 @@ ---- -outline: deep ---- - # TypeBox `@feathersjs/typebox` allows to define JSON schemas with [TypeBox](https://github.com/sinclairzx81/typebox), a JSON schema type builder with static type resolution for TypeScript. @@ -1482,7 +1478,7 @@ type MessageData = Static ### querySyntax -`querySyntax(definition)` returns a schema to validate the [Feathers query syntax](../databases/querying.md) for all properties in a TypeBox definition. +`querySyntax(definition, extensions, options)` returns a schema to validate the [Feathers query syntax](../databases/querying.md) for all properties in a TypeBox definition. ```ts import { querySyntax } from '@feathersjs/typebox' @@ -1496,7 +1492,7 @@ const messageQuerySchema = querySyntax(messageQueryProperties) type MessageQuery = Static ``` -To allow additional query parameters for properties you can create a union type: +To allow additional query parameters like `$ilike`, `$regex` etc. for properties you can pass an object with the property names and additional types: ```ts import { querySyntax } from '@feathersjs/typebox' @@ -1505,14 +1501,35 @@ import { querySyntax } from '@feathersjs/typebox' const messageQueryProperties = Type.Pick(messageSchema, ['id', 'text', 'createdAt', 'userId'], { additionalProperties: false }) -const messageQuerySchema = Type.Union( +const messageQuerySchema = Type.Intersect( + [ + // This will additioanlly allow querying for `{ name: { $ilike: 'Dav%' } }` + querySyntax(messageQueryProperties, { + name: { + $ilike: Type.String() + } + }), + // Add additional query properties here + Type.Object({}) + ], + { additionalProperties: false } +) +``` + +To allow additional query properties outside of the query syntax use the intersection type: + +```ts +import { querySyntax } from '@feathersjs/typebox' + +// Schema for allowed query properties +const messageQueryProperties = Type.Pick(messageSchema, ['id', 'text', 'createdAt', 'userId'], { + additionalProperties: false +}) +const messageQuerySchema = Type.Intersect( [ querySyntax(messageQueryProperties), - // Allow to also query for `{ name: { $ilike: '%something' } }` Type.Object({ - name: Type.Object({ - $ilike: Type.String() - }) + isActive: Type.Boolean() }) ], { additionalProperties: false } diff --git a/packages/schema/src/json-schema.ts b/packages/schema/src/json-schema.ts index c60d280ebc..5c871c84e6 100644 --- a/packages/schema/src/json-schema.ts +++ b/packages/schema/src/json-schema.ts @@ -63,7 +63,7 @@ export const getDataValidator = ( } } -export type PropertyQuery = { +export type PropertyQuery = { anyOf: [ D, { @@ -83,7 +83,7 @@ export type PropertyQuery = { type: 'array' items: D } - } + } & X } ] } @@ -92,9 +92,13 @@ export type PropertyQuery = { * Create a Feathers query syntax compatible JSON schema definition for a property definition. * * @param def The property definition (e.g. `{ type: 'string' }`) + * @param extensions Additional properties to add to the query property schema * @returns A JSON schema definition for the Feathers query syntax for this property. */ -export const queryProperty = (def: T) => { +export const queryProperty = ( + def: T, + extensions: X = {} as X +) => { const definition = _.omit(def, 'default') return { anyOf: [ @@ -115,50 +119,59 @@ export const queryProperty = (def: T) => { $nin: { type: 'array', items: definition - } + }, + ...extensions } } ] } as const } -export const SUPPORTED_TYPES = ['string', 'number', 'integer', 'boolean', 'null'] - /** * Creates Feathers a query syntax compatible JSON schema for multiple properties. * * @param definitions A map of property definitions + * @param extensions Additional properties to add to the query property schema * @returns The JSON schema definition for the Feathers query syntax for multiple properties */ -export const queryProperties = (definitions: T) => +export const queryProperties = < + T extends { [key: string]: JSONSchema }, + X extends { [K in keyof T]?: { [key: string]: JSONSchema } } +>( + definitions: T, + extensions: X = {} as X +) => Object.keys(definitions).reduce((res, key) => { const result = res as any const definition = definitions[key] - const { type, $ref } = definition as any + const { $ref } = definition as any - if ($ref || !SUPPORTED_TYPES.includes(type)) { - throw new Error( - `Can not create query syntax schema for property '${key}'. Only types ${SUPPORTED_TYPES.join( - ', ' - )} are allowed.` - ) + if ($ref) { + throw new Error(`Can not create query syntax schema for reference property '${key}'`) } - result[key] = queryProperty(definition) + result[key] = queryProperty(definition as JSONSchemaDefinition, extensions[key as keyof T]) return result - }, {} as { [K in keyof T]: PropertyQuery }) + }, {} as { [K in keyof T]: PropertyQuery }) /** * Creates a JSON schema for the complete Feathers query syntax including `$limit`, $skip` * and `$sort` and `$select` for the allowed properties. * * @param definition The property definitions to create the query syntax schema for + * @param extensions Additional properties to add to the query property schema * @returns A JSON schema for the complete query syntax */ -export const querySyntax = (definition: T) => { +export const querySyntax = < + T extends { [key: string]: JSONSchema }, + X extends { [K in keyof T]?: { [key: string]: JSONSchema } } +>( + definition: T, + extensions: X = {} as X +) => { const keys = Object.keys(definition) - const props = queryProperties(definition) + const props = queryProperties(definition, extensions) return { $limit: { diff --git a/packages/schema/test/json-schema.test.ts b/packages/schema/test/json-schema.test.ts index 34f789db99..2e4450d925 100644 --- a/packages/schema/test/json-schema.test.ts +++ b/packages/schema/test/json-schema.test.ts @@ -1,5 +1,6 @@ import Ajv from 'ajv' import assert from 'assert' +import { FromSchema } from '../src' import { queryProperties, querySyntax } from '../src/json-schema' describe('@feathersjs/schema/json-schema', () => { @@ -8,25 +9,11 @@ describe('@feathersjs/schema/json-schema', () => { () => queryProperties({ something: { - type: 'object' + $ref: 'something' } }), { - message: - "Can not create query syntax schema for property 'something'. Only types string, number, integer, boolean, null are allowed." - } - ) - - assert.throws( - () => - queryProperties({ - otherThing: { - type: 'array' - } - }), - { - message: - "Can not create query syntax schema for property 'otherThing'. Only types string, number, integer, boolean, null are allowed." + message: "Can not create query syntax schema for reference property 'something'" } ) }) @@ -39,4 +26,47 @@ describe('@feathersjs/schema/json-schema', () => { new Ajv().compile(schema) }) + + it('querySyntax with extensions', async () => { + const schema = { + name: { + type: 'string' + }, + age: { + type: 'number' + } + } as const + + const querySchema = { + type: 'object', + properties: querySyntax(schema, { + name: { + $ilike: { + type: 'string' + } + }, + age: { + $value: { + type: 'null' + } + } + } as const) + } as const + + type Query = FromSchema + + const q: Query = { + name: { + $ilike: 'hello' + }, + age: { + $value: null, + $gte: 42 + } + } + + const validator = new Ajv({ strict: false }).compile(schema) + + assert.ok(validator(q)) + }) }) diff --git a/packages/typebox/src/index.ts b/packages/typebox/src/index.ts index da9c1815bf..7165fe0d52 100644 --- a/packages/typebox/src/index.ts +++ b/packages/typebox/src/index.ts @@ -1,5 +1,5 @@ import { Type, TObject, TInteger, TOptional, TSchema, TIntersect, ObjectOptions } from '@sinclair/typebox' -import { jsonSchema, Validator, DataValidatorMap, Ajv, SUPPORTED_TYPES } from '@feathersjs/schema' +import { jsonSchema, Validator, DataValidatorMap, Ajv } from '@feathersjs/schema' export * from '@sinclair/typebox' export * from './default-schemas' @@ -77,9 +77,13 @@ export function sortDefinition(schema: T) { * including operators like `$gt`, `$lt` etc. for a single property * * @param def The property definition + * @param extension Additional properties to add to the property query * @returns The Feathers query syntax schema */ -export const queryProperty = (def: T) => +export const queryProperty = ( + def: T, + extension: X = {} as X +) => Type.Optional( Type.Union([ def, @@ -91,38 +95,44 @@ export const queryProperty = (def: T) => $lte: def, $ne: def, $in: Type.Array(def), - $nin: Type.Array(def) + $nin: Type.Array(def), + ...extension }), { additionalProperties: false } ) ]) ) -type QueryProperty = ReturnType> +type QueryProperty = ReturnType< + typeof queryProperty +> /** * Creates a Feathers query syntax schema for the properties defined in `definition`. * * @param definition The properties to create the Feathers query syntax schema for + * @param extensions Additional properties to add to a property query * @returns The Feathers query syntax schema */ -export const queryProperties = (definition: T) => { +export const queryProperties = < + T extends TObject, + X extends { [K in keyof T['properties']]?: { [key: string]: TSchema } } +>( + definition: T, + extensions: X = {} as X +) => { const properties = Object.keys(definition.properties).reduce((res, key) => { const result = res as any const value = definition.properties[key] - if (value.$ref || !SUPPORTED_TYPES.includes(value.type)) { - throw new Error( - `Can not create query syntax schema for property '${key}'. Only types ${SUPPORTED_TYPES.join( - ', ' - )} are allowed.` - ) + if (value.$ref) { + throw new Error(`Can not create query syntax schema for reference property '${key}'`) } - result[key] = queryProperty(value) + result[key] = queryProperty(value, extensions[key]) return result - }, {} as { [K in keyof T['properties']]: QueryProperty }) + }, {} as { [K in keyof T['properties']]: QueryProperty }) return Type.Optional(Type.Object(properties, { additionalProperties: false })) } @@ -132,14 +142,19 @@ export const queryProperties = (definition: T) => { * and `$sort` and `$select` for the allowed properties. * * @param type The properties to create the query syntax for + * @param extensions Additional properties to add to the query syntax * @param options Options for the TypeBox object schema * @returns A TypeBox object representing the complete Feathers query syntax for the given properties */ -export const querySyntax = ( +export const querySyntax = < + T extends TObject | TIntersect, + X extends { [K in keyof T['properties']]?: { [key: string]: TSchema } } +>( type: T, + extensions: X = {} as X, options: ObjectOptions = { additionalProperties: false } ) => { - const propertySchema = queryProperties(type) + const propertySchema = queryProperties(type, extensions) return Type.Intersect( [ diff --git a/packages/typebox/test/index.test.ts b/packages/typebox/test/index.test.ts index 33aaabd640..5cc19f58ac 100644 --- a/packages/typebox/test/index.test.ts +++ b/packages/typebox/test/index.test.ts @@ -44,25 +44,11 @@ describe('@feathersjs/schema/typebox', () => { () => queryProperties( Type.Object({ - something: Type.Object({}) + something: Type.Ref(Type.Object({}, { $id: 'something' })) }) ), { - message: - "Can not create query syntax schema for property 'something'. Only types string, number, integer, boolean, null are allowed." - } - ) - - assert.throws( - () => - queryProperties( - Type.Object({ - otherThing: Type.Array(Type.String()) - }) - ), - { - message: - "Can not create query syntax schema for property 'otherThing'. Only types string, number, integer, boolean, null are allowed." + message: "Can not create query syntax schema for reference property 'something'" } ) }) @@ -72,6 +58,39 @@ describe('@feathersjs/schema/typebox', () => { new Ajv().compile(schema) }) + + it('query syntax can include additional extensions', async () => { + const schema = Type.Object({ + name: Type.String(), + age: Type.Number() + }) + const querySchema = querySyntax(schema, { + age: { + $notNull: Type.Boolean() + }, + name: { + $ilike: Type.String() + } + }) + const validator = new Ajv().compile(querySchema) + + type Query = Static + + const query: Query = { + age: { + $gt: 10, + $notNull: true + }, + name: { + $gt: 'David', + $ilike: 'Dave' + } + } + + const validated = (await validator(query)) as any as Query + + assert.ok(validated) + }) }) it('defaultAppConfiguration', async () => {