From 3bc76c644a30884003a25d55ff9a9fb0dfe68e47 Mon Sep 17 00:00:00 2001 From: daffl Date: Sun, 24 Apr 2022 10:25:13 -0700 Subject: [PATCH 1/2] fix(adapter-commons): Clean up semantics for adapter operators and filters BREAKING CHANGE: filterQuery will now more stricly separate between top level filters and query property level operators. --- packages/adapter-commons/src/declarations.ts | 47 +++-- packages/adapter-commons/src/filter-query.ts | 186 ++++++++++-------- packages/adapter-commons/src/service.ts | 4 +- .../adapter-commons/test/filter-query.test.ts | 39 +++- packages/adapter-commons/test/service.test.ts | 13 +- packages/memory/src/index.ts | 20 +- 6 files changed, 196 insertions(+), 113 deletions(-) diff --git a/packages/adapter-commons/src/declarations.ts b/packages/adapter-commons/src/declarations.ts index 0709046f3d..9a75996c4f 100644 --- a/packages/adapter-commons/src/declarations.ts +++ b/packages/adapter-commons/src/declarations.ts @@ -1,7 +1,15 @@ import { Query, Params, Paginated, Id, NullableId } from '@feathersjs/feathers'; -export type FilterSettings = string[]|{ - [key: string]: (value: any, options: any) => any +export type FilterQueryOptions = { + filters?: FilterSettings; + operators?: string[]; + paginate?: PaginationParams; +} + +export type QueryFilter = (value: any, options: FilterQueryOptions) => any + +export type FilterSettings = { + [key: string]: QueryFilter|true } export interface PaginationOptions { @@ -11,26 +19,39 @@ export interface PaginationOptions { export type PaginationParams = false|PaginationOptions; -export type FilterQueryOptions = { - filters?: FilterSettings; - operators?: string[]; - paginate?: PaginationParams; -} - export interface AdapterServiceOptions { - events?: string[]; + /** + * Whether to allow multiple updates for everything (`true`) or specific methods (e.g. `['create', 'remove']`) + */ multi?: boolean|string[]; + /** + * The name of the id property + */ id?: string; + /** + * Pagination settings for this service + */ paginate?: PaginationOptions /** - * @deprecated renamed to `allow`. + * A list of additional property query operators to allow in a query + */ + operators?: string[]; + /** + * An object of additional top level query filters, e.g. `{ $populate: true }` + * Can also be a converter function like `{ $ignoreCase: (value) => value === 'true' ? true : false }` + */ + filters?: FilterSettings; + /** + * @deprecated Use service `events` option when registering the service with `app.use`. */ + events?: string[]; + /** + * @deprecated renamed to `operators`. + */ whitelist?: string[]; - allow?: string[]; - filters?: string[]; } -export interface AdapterOptions extends Pick { +export interface AdapterOptions extends Pick { Model?: M; } diff --git a/packages/adapter-commons/src/filter-query.ts b/packages/adapter-commons/src/filter-query.ts index 4cc29e4472..fb80df281c 100644 --- a/packages/adapter-commons/src/filter-query.ts +++ b/packages/adapter-commons/src/filter-query.ts @@ -3,115 +3,135 @@ import { BadRequest } from '@feathersjs/errors'; import { Query } from '@feathersjs/feathers'; import { FilterQueryOptions, FilterSettings } from './declarations'; -function parse (number: any) { - if (typeof number !== 'undefined') { - return Math.abs(parseInt(number, 10)); - } - - return undefined; -} +const parse = (value: any) => typeof value !== 'undefined' ? parseInt(value, 10) : value; -// Returns the pagination limit and will take into account the -// default and max pagination settings -function getLimit (limit: any, paginate: any) { - if (paginate && (paginate.default || paginate.max)) { - const base = paginate.default || 0; - const lower = typeof limit === 'number' && !isNaN(limit) ? limit : base; - const upper = typeof paginate.max === 'number' ? paginate.max : Number.MAX_VALUE; +const isPlainObject = (value: any) => _.isObject(value) && value.constructor === {}.constructor; - return Math.min(lower, upper); +const validateQueryProperty = (query: any, operators: string[] = []): Query => { + if (!isPlainObject(query)) { + return query; } - return limit; -} + for (const key of Object.keys(query)) { + if (key.startsWith('$') && !operators.includes(key)) { + throw new BadRequest(`Invalid query parameter ${key}`, query); + } -// Makes sure that $sort order is always converted to an actual number -function convertSort (sort: any) { - if (typeof sort !== 'object' || Array.isArray(sort)) { - return sort; - } + const value = query[key]; - return Object.keys(sort).reduce((result, key) => { - result[key] = typeof sort[key] === 'object' - ? sort[key] : parseInt(sort[key], 10); + if (isPlainObject(value)) { + query[key] = validateQueryProperty(value, operators); + } + } - return result; - }, {} as { [key: string]: number }); + return { + ...query + } } -function cleanQuery (query: Query, operators: any, filters: any): any { - if (Array.isArray(query)) { - return query.map(value => cleanQuery(value, operators, filters)); - } else if (_.isObject(query) && query.constructor === {}.constructor) { - const result: { [key: string]: any } = {}; - - _.each(query, (value, key) => { - if (key[0] === '$') { - if (filters[key] !== undefined) { - return; - } - - if (!operators.includes(key)) { - throw new BadRequest(`Invalid query parameter ${key}`, query); - } - } +const getFilters = (query: Query, settings: FilterQueryOptions) => { + const filterNames = Object.keys(settings.filters); - result[key] = cleanQuery(value, operators, filters); - }); + return filterNames.reduce((current, key) => { + const queryValue = query[key]; + const filter = settings.filters[key]; - Object.getOwnPropertySymbols(query).forEach(symbol => { - // @ts-ignore - result[symbol] = query[symbol]; - }); + if (filter) { + const value = typeof filter === 'function' ? filter(queryValue, settings) : queryValue; - return result; - } + if (value !== undefined) { + current[key] = value; + } + } - return query; + return current; + }, {} as { [key: string]: any }); } -function assignFilters (object: any, query: Query, filters: FilterSettings, options: any): { [key: string]: any } { - if (Array.isArray(filters)) { - _.each(filters, (key) => { - if (query[key] !== undefined) { - object[key] = query[key]; - } - }); - } else { - _.each(filters, (converter, key) => { - const converted = converter(query[key], options); +const getQuery = (query: Query, settings: FilterQueryOptions) => { + const keys = Object.keys(query).concat(Object.getOwnPropertySymbols(query) as any as string[]); - if (converted !== undefined) { - object[key] = converted; + return keys.reduce((result, key) => { + if (typeof key === 'string' && key.startsWith('$')) { + if (settings.filters[key] === undefined) { + throw new BadRequest(`Invalid filter value ${key}`); } - }); - } + } else { + result[key] = validateQueryProperty(query[key], settings.operators); + } - return object; + return result; + }, {} as Query) } +export const OPERATORS = ['$in', '$nin', '$lt', '$lte', '$gt', '$gte', '$ne', '$or']; + export const FILTERS: FilterSettings = { - $sort: (value: any) => convertSort(value), - $limit: (value: any, options: any) => getLimit(parse(value), options.paginate), $skip: (value: any) => parse(value), - $select: (value: any) => value + $sort: (sort: any): { [key: string]: number } => { + if (typeof sort !== 'object' || Array.isArray(sort)) { + return sort; + } + + return Object.keys(sort).reduce((result, key) => { + result[key] = typeof sort[key] === 'object' + ? sort[key] : parse(sort[key]); + + return result; + }, {} as { [key: string]: number }); + }, + $limit: (_limit: any, { paginate }: FilterQueryOptions) => { + const limit = parse(_limit); + + if (paginate && (paginate.default || paginate.max)) { + const base = paginate.default || 0; + const lower = typeof limit === 'number' && !isNaN(limit) && limit >= 0 ? limit : base; + const upper = typeof paginate.max === 'number' ? paginate.max : Number.MAX_VALUE; + + return Math.min(lower, upper); + } + + return limit; + }, + $select: (select: any) => { + if (Array.isArray(select)) { + return select.map(current => `${current}`); + } + + return select; + }, + $or: (or: any, { operators }: FilterQueryOptions) => { + if (Array.isArray(or)) { + return or.map(current => validateQueryProperty(current, operators)); + } + + return or; + } }; -export const OPERATORS = ['$in', '$nin', '$lt', '$lte', '$gt', '$gte', '$ne', '$or']; - -// Converts Feathers special query parameters and pagination settings -// and returns them separately a `filters` and the rest of the query -// as `query` +/** + * Converts Feathers special query parameters and pagination settings + * and returns them separately as `filters` and the rest of the query + * as `query`. `options` also gets passed the pagination settings and + * a list of additional `operators` to allow when querying properties. + * + * @param query The initial query + * @param options Options for filtering the query + * @returns An object with `query` which contains the query without `filters` + * and `filters` which contains the converted values for each filter. + */ export function filterQuery (query: Query, options: FilterQueryOptions = {}) { - const { - filters: additionalFilters = [], - operators: additionalOperators = [] - } = options; - const baseFilters = assignFilters({}, query, FILTERS, options); - const filters = assignFilters(baseFilters, query, additionalFilters, options); + const settings = { + ...options, + filters: { + ...FILTERS, + ...options.filters + }, + operators: OPERATORS.concat(options.operators || []) + } return { - filters, - query: cleanQuery(query, OPERATORS.concat(additionalOperators), filters) as Query + filters: getFilters(query, settings), + query: getQuery(query, settings) } } diff --git a/packages/adapter-commons/src/service.ts b/packages/adapter-commons/src/service.ts index 7ec607fbb6..83b20374ec 100644 --- a/packages/adapter-commons/src/service.ts +++ b/packages/adapter-commons/src/service.ts @@ -29,7 +29,7 @@ export class AdapterBase = Partial = Partial { assert.ok(false, 'Should never get here'); } catch (error: any) { assert.strictEqual(error.name, 'BadRequest'); - assert.strictEqual(error.message, 'Invalid query parameter $foo'); + assert.strictEqual(error.message, 'Invalid filter value $foo'); } }); @@ -96,16 +96,16 @@ describe('@feathersjs/adapter-commons/filterQuery', () => { describe('pagination', () => { it('limits with default pagination', () => { const { filters } = filterQuery({}, { paginate: { default: 10 } }); + const { filters: filtersNeg } = filterQuery({ $limit: -20 }, { paginate: { default: 5, max: 10 } }); assert.strictEqual(filters.$limit, 10); + assert.strictEqual(filtersNeg.$limit, 5); }); it('limits with max pagination', () => { const { filters } = filterQuery({ $limit: 20 }, { paginate: { default: 5, max: 10 } }); - const { filters: filtersNeg } = filterQuery({ $limit: -20 }, { paginate: { default: 5, max: 10 } }); assert.strictEqual(filters.$limit, 10); - assert.strictEqual(filtersNeg.$limit, 10); }); it('limits with default pagination when not a number', () => { @@ -223,6 +223,16 @@ describe('@feathersjs/adapter-commons/filterQuery', () => { message: 'Invalid query parameter $exists' }); }); + + it('allows default operators in $or', () => { + const { filters } = filterQuery({ + $or: [{ value: { $gte: 10 } }] + }); + + assert.deepStrictEqual(filters, { + $or: [{ value: { $gte: 10 } }] + }); + }); }); describe('additional filters', () => { @@ -231,13 +241,18 @@ describe('@feathersjs/adapter-commons/filterQuery', () => { filterQuery({ $select: 1, $known: 1 }); assert.ok(false, 'Should never get here'); } catch (error: any) { - assert.strictEqual(error.message, 'Invalid query parameter $known'); + assert.strictEqual(error.message, 'Invalid filter value $known'); } }); it('returns default and known additional filters (array)', () => { const query = { $select: ['a', 'b'], $known: 1, $unknown: 1 }; - const { filters } = filterQuery(query, { filters: [ '$known', '$unknown' ] }); + const { filters } = filterQuery(query, { + filters: { + $known: true, + $unknown: true + } + }); assert.strictEqual(filters.$unknown, 1); assert.strictEqual(filters.$known, 1); @@ -259,12 +274,18 @@ describe('@feathersjs/adapter-commons/filterQuery', () => { describe('additional operators', () => { it('returns query with default and known additional operators', () => { const { query } = filterQuery({ - $ne: 1, $known: 1 + prop: { $ne: 1, $known: 1 } }, { operators: [ '$known' ] }); - assert.strictEqual(query.$ne, 1); - assert.strictEqual(query.$known, 1); - assert.strictEqual(query.$unknown, undefined); + assert.deepStrictEqual(query, { prop: { '$ne': 1, '$known': 1 } }); + }); + + it('throws an error with unknown query operator', () => { + assert.throws(() => filterQuery({ + prop: { $unknown: 'something' } + }), { + message: 'Invalid query parameter $unknown' + }); }); }); }); diff --git a/packages/adapter-commons/test/service.test.ts b/packages/adapter-commons/test/service.test.ts index 2f5635a970..4bfe339530 100644 --- a/packages/adapter-commons/test/service.test.ts +++ b/packages/adapter-commons/test/service.test.ts @@ -155,7 +155,9 @@ describe('@feathersjs/adapter-commons/service', () => { it('filterQuery', () => { const service = new CustomService({ - allow: [ '$something' ] + filters: { + $something: true + } }); const filtered = service.filterQuery({ query: { $limit: 10, test: 'me' } @@ -173,8 +175,11 @@ describe('@feathersjs/adapter-commons/service', () => { assert.deepStrictEqual(withAllowed, { paginate: false, - filters: { $limit: 10 }, - query: { $something: 'else' } + filters: { + $limit: 10, + $something: 'else' + }, + query: {} }); }); @@ -197,7 +202,7 @@ describe('@feathersjs/adapter-commons/service', () => { events: [], paginate: { default: 10, max: 100 }, multi: [ 'create' ], - filters: [], + filters: {}, allow: [] }); }); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index a05ea48b82..cbd7a5ec21 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,8 +1,11 @@ import { MethodNotAllowed, NotFound } from '@feathersjs/errors'; import { _ } from '@feathersjs/commons'; -import { sorter, select, AdapterBase, AdapterServiceOptions, InternalServiceMethods, PaginationOptions } from '@feathersjs/adapter-commons'; +import { + sorter, select, AdapterBase, AdapterServiceOptions, InternalServiceMethods, + PaginationOptions, AdapterParams, FilterQueryOptions +} from '@feathersjs/adapter-commons'; import sift from 'sift'; -import { NullableId, Id, Params, ServiceMethods, Paginated } from '@feathersjs/feathers'; +import { NullableId, Id, Params, ServiceMethods, Paginated, Query } from '@feathersjs/feathers'; export interface MemoryServiceStore { [key: string]: T; @@ -50,6 +53,19 @@ export class MemoryAdapter, P extends Params = Params> e }); } + filterQuery (params?: AdapterParams, opts?: FilterQueryOptions) { + const result = super.filterQuery(params, opts); + const { query, filters } = result; + + // $or is not treated as a filter but needs to be part of the query to work with SiftJS + result.query = { + ...query, + ...(filters.$or ? { $or: filters.$or } : {}) + } + + return result; + } + async _find (_params?: P & { paginate?: PaginationOptions }): Promise>; async _find (_params?: P & { paginate: false }): Promise; async _find (_params?: P): Promise|T[]>; From c7b3a864ceaa0931ba777810981b85eb0106374a Mon Sep 17 00:00:00 2001 From: daffl Date: Tue, 26 Apr 2022 16:58:42 -0700 Subject: [PATCH 2/2] Refactor adapter service handling --- packages/adapter-commons/src/declarations.ts | 62 ++-- packages/adapter-commons/src/index.ts | 2 +- .../src/{filter-query.ts => query.ts} | 5 +- packages/adapter-commons/src/service.ts | 281 +++++++++++++----- packages/adapter-commons/test/fixture.ts | 93 ++++++ .../{filter-query.test.ts => query.test.ts} | 0 packages/adapter-commons/test/service.test.ts | 215 +++++--------- packages/adapter-tests/src/basic.ts | 24 ++ packages/adapter-tests/src/declarations.ts | 8 +- packages/adapter-tests/src/index.ts | 1 - packages/adapter-tests/test/index.test.ts | 6 + packages/memory/src/index.ts | 130 ++++---- packages/memory/test/index.test.ts | 6 + 13 files changed, 499 insertions(+), 334 deletions(-) rename packages/adapter-commons/src/{filter-query.ts => query.ts} (97%) create mode 100644 packages/adapter-commons/test/fixture.ts rename packages/adapter-commons/test/{filter-query.test.ts => query.test.ts} (100%) diff --git a/packages/adapter-commons/src/declarations.ts b/packages/adapter-commons/src/declarations.ts index 9a75996c4f..6ac368e1a2 100644 --- a/packages/adapter-commons/src/declarations.ts +++ b/packages/adapter-commons/src/declarations.ts @@ -9,7 +9,7 @@ export type FilterQueryOptions = { export type QueryFilter = (value: any, options: FilterQueryOptions) => any export type FilterSettings = { - [key: string]: QueryFilter|true + [key: string]: QueryFilter | true } export interface PaginationOptions { @@ -17,13 +17,13 @@ export interface PaginationOptions { max?: number; } -export type PaginationParams = false|PaginationOptions; +export type PaginationParams = false | PaginationOptions; export interface AdapterServiceOptions { /** * Whether to allow multiple updates for everything (`true`) or specific methods (e.g. `['create', 'remove']`) */ - multi?: boolean|string[]; + multi?: boolean | string[]; /** * The name of the id property */ @@ -44,14 +44,14 @@ export interface AdapterServiceOptions { /** * @deprecated Use service `events` option when registering the service with `app.use`. */ - events?: string[]; - /** - * @deprecated renamed to `operators`. - */ + events?: string[]; + /** + * @deprecated renamed to `operators`. + */ whitelist?: string[]; } -export interface AdapterOptions extends Pick { +export interface AdapterOptions extends Pick { Model?: M; } @@ -62,7 +62,7 @@ export interface AdapterParams extends Params { /** * Hook-less (internal) service methods. Directly call database adapter service methods - * without running any service-level hooks. This can be useful if you need the raw data + * without running any service-level hooks or sanitization. This can be useful if you need the raw data * from the service and don't want to trigger any of its hooks. * * Important: These methods are only available internally on the server, not on the client @@ -72,42 +72,44 @@ export interface AdapterParams extends Params { * * @see {@link https://docs.feathersjs.com/guides/migrating.html#hook-less-service-methods} */ - export interface InternalServiceMethods, P extends AdapterParams = AdapterParams> { +export interface InternalServiceMethods, P extends AdapterParams = AdapterParams> { /** - * Retrieve all resources from this service, skipping any service-level hooks. + * Retrieve all resources from this service. + * Does not sanitize the query and should only be used on the server. * - * @param params - Service call parameters {@link Params} - * @see {@link HookLessServiceMethods} - * @see {@link https://docs.feathersjs.com/api/services.html#find-params|Feathers API Documentation: .find(params)} + * @param _params - Service call parameters {@link Params} */ - _find (_params?: P & { paginate?: PaginationOptions }): Promise>; - _find (_params?: P & { paginate: false }): Promise; - _find (params?: P): Promise>; + $find(_params?: P & { paginate?: PaginationOptions }): Promise>; + $find(_params?: P & { paginate: false }): Promise; + $find(params?: P): Promise>; /** * Retrieve a single resource matching the given ID, skipping any service-level hooks. + * Does not sanitize the query and should only be used on the server. * * @param id - ID of the resource to locate * @param params - Service call parameters {@link Params} * @see {@link HookLessServiceMethods} * @see {@link https://docs.feathersjs.com/api/services.html#get-id-params|Feathers API Documentation: .get(id, params)} */ - _get (id: Id, params?: P): Promise; + $get(id: Id, params?: P): Promise; /** * Create a new resource for this service, skipping any service-level hooks. + * Does not sanitize data or checks if multiple updates are allowed and should only be used on the server. * * @param data - Data to insert into this service. * @param params - Service call parameters {@link Params} * @see {@link HookLessServiceMethods} * @see {@link https://docs.feathersjs.com/api/services.html#create-data-params|Feathers API Documentation: .create(data, params)} */ - _create (data: Partial, params?: P): Promise; - _create (data: Partial[], params?: P): Promise; - _create (data: Partial|Partial[], params?: P): Promise; + $create(data: Partial, params?: P): Promise; + $create(data: Partial[], params?: P): Promise; + $create(data: Partial | Partial[], params?: P): Promise; /** - * Replace any resources matching the given ID with the given data, skipping any service-level hooks. + * Completely replace the resource identified by id, skipping any service-level hooks. + * Does not sanitize data or query and should only be used on the server. * * @param id - ID of the resource to be updated * @param data - Data to be put in place of the current resource. @@ -115,10 +117,11 @@ export interface AdapterParams extends Params { * @see {@link HookLessServiceMethods} * @see {@link https://docs.feathersjs.com/api/services.html#update-id-data-params|Feathers API Documentation: .update(id, data, params)} */ - _update (id: Id, data: D, params?: P): Promise; + $update(id: Id, data: D, params?: P): Promise; /** * Merge any resources matching the given ID with the given data, skipping any service-level hooks. + * Does not sanitize the data or query and should only be used on the server. * * @param id - ID of the resource to be patched * @param data - Data to merge with the current resource. @@ -126,19 +129,20 @@ export interface AdapterParams extends Params { * @see {@link HookLessServiceMethods} * @see {@link https://docs.feathersjs.com/api/services.html#patch-id-data-params|Feathers API Documentation: .patch(id, data, params)} */ - _patch (id: null, data: Partial, params?: P): Promise; - _patch (id: Id, data: Partial, params?: P): Promise; - _patch (id: NullableId, data: Partial, params?: P): Promise; + $patch(id: null, data: Partial, params?: P): Promise; + $patch(id: Id, data: Partial, params?: P): Promise; + $patch(id: NullableId, data: Partial, params?: P): Promise; /** * Remove resources matching the given ID from the this service, skipping any service-level hooks. + * Does not sanitize query and should only be used on the server. * * @param id - ID of the resource to be removed * @param params - Service call parameters {@link Params} * @see {@link HookLessServiceMethods} * @see {@link https://docs.feathersjs.com/api/services.html#remove-id-params|Feathers API Documentation: .remove(id, params)} */ - _remove (id: null, params?: P): Promise; - _remove (id: Id, params?: P): Promise; - _remove (id: NullableId, params?: P): Promise; + $remove(id: null, params?: P): Promise; + $remove(id: Id, params?: P): Promise; + $remove(id: NullableId, params?: P): Promise; } \ No newline at end of file diff --git a/packages/adapter-commons/src/index.ts b/packages/adapter-commons/src/index.ts index 0b3777c516..4ce7f7b07a 100644 --- a/packages/adapter-commons/src/index.ts +++ b/packages/adapter-commons/src/index.ts @@ -3,7 +3,7 @@ import { Params } from '@feathersjs/feathers'; export * from './declarations'; export * from './service'; -export { filterQuery, FILTERS, OPERATORS } from './filter-query'; +export { filterQuery, FILTERS, OPERATORS } from './query'; export * from './sort'; // Return a function that filters a result object or array diff --git a/packages/adapter-commons/src/filter-query.ts b/packages/adapter-commons/src/query.ts similarity index 97% rename from packages/adapter-commons/src/filter-query.ts rename to packages/adapter-commons/src/query.ts index fb80df281c..757f8112ae 100644 --- a/packages/adapter-commons/src/filter-query.ts +++ b/packages/adapter-commons/src/query.ts @@ -107,7 +107,7 @@ export const FILTERS: FilterSettings = { return or; } -}; +} /** * Converts Feathers special query parameters and pagination settings @@ -120,7 +120,8 @@ export const FILTERS: FilterSettings = { * @returns An object with `query` which contains the query without `filters` * and `filters` which contains the converted values for each filter. */ -export function filterQuery (query: Query, options: FilterQueryOptions = {}) { +export function filterQuery (_query: Query, options: FilterQueryOptions = {}) { + const query = _query || {}; const settings = { ...options, filters: { diff --git a/packages/adapter-commons/src/service.ts b/packages/adapter-commons/src/service.ts index 83b20374ec..68040cbdd6 100644 --- a/packages/adapter-commons/src/service.ts +++ b/packages/adapter-commons/src/service.ts @@ -1,15 +1,7 @@ -import { NotImplemented, BadRequest, MethodNotAllowed } from '@feathersjs/errors'; -import { ServiceMethods, Params, Id, NullableId, Paginated, Query } from '@feathersjs/feathers'; -import { AdapterParams, AdapterServiceOptions, FilterQueryOptions, PaginationOptions } from './declarations'; -import { filterQuery } from './filter-query'; - -const callMethod = (self: any, name: any, ...args: any[]) => { - if (typeof self[name] !== 'function') { - return Promise.reject(new NotImplemented(`Method ${name} not available`)); - } - - return self[name](...args); -}; +import { BadRequest, MethodNotAllowed } from '@feathersjs/errors'; +import { Id, NullableId, Paginated, Query } from '@feathersjs/feathers'; +import { AdapterParams, AdapterServiceOptions, InternalServiceMethods, PaginationOptions } from './declarations'; +import { filterQuery } from './query'; const alwaysMulti: { [key: string]: boolean } = { find: true, @@ -18,20 +10,27 @@ const alwaysMulti: { [key: string]: boolean } = { }; /** - * The base class that a database adapter can extend from. + * An abstract base class that a database adapter can extend from to implement the + * `__find`, `__get`, `__update`, `__patch` and `__remove` methods. */ -export class AdapterBase = Partial> { +export abstract class AdapterBase< + T = any, + D = Partial, + P extends AdapterParams = AdapterParams, + O extends Partial = Partial + > implements InternalServiceMethods { options: AdapterServiceOptions & O; constructor (options: O) { - this.options = Object.assign({ + this.options = { id: 'id', events: [], paginate: false, multi: false, filters: {}, - allow: [] - }, options); + operators: [], + ...options + }; } get id () { @@ -42,107 +41,231 @@ export class AdapterBase = Partial, - O extends Partial = Partial, - P extends Params = AdapterParams -> extends AdapterBase implements ServiceMethods, D> { - find (params?: P & { paginate?: PaginationOptions }): Promise>; - find (params?: P & { paginate: false }): Promise; - find (params?: P): Promise>; - find (params?: P): Promise> { - return callMethod(this, '_find', params); + /** + * Sanitize the incoming data, e.g. removing invalid keywords etc. + * + * @param data The data to sanitize + * @param _params Service call parameters + * @returns The sanitized data + */ + async sanitizeData> (data: X, _params: P) { + return data; + } + + /** + * Returns a sanitized version of `params.query`, converting filter values + * (like $limit and $skip) into the expected type. Will throw an error if + * a `$` prefixed filter or operator value that is not allowed in `filters` + * or `operators` is encountered. + * + * @param params The service call parameter. + * @returns A new object containing the sanitized query. + */ + async sanitizeQuery (params: P = {} as P): Promise { + const options = this.getOptions(params); + const { query, filters } = filterQuery(params.query, options) + + return { + ...filters, + ...query + }; + } + + abstract $find(_params?: P & { paginate?: PaginationOptions }): Promise>; + abstract $find(_params?: P & { paginate: false }): Promise; + abstract $find(params?: P): Promise>; + + /** + * Retrieve all resources from this service, skipping any service-level hooks but sanitize the query + * with allowed filters and properties by calling `sanitizeQuery`. + * + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#find-params|Feathers API Documentation: .find(params)} + */ + async _find(_params?: P & { paginate?: PaginationOptions }): Promise>; + async _find(_params?: P & { paginate: false }): Promise; + async _find(params?: P): Promise>; + async _find (params?: P): Promise> { + const query = await this.sanitizeQuery(params); + + return this.$find({ + ...params, + query + }); } - get (id: Id, params?: P): Promise { - return callMethod(this, '_get', id, params); + abstract $get(id: Id, params?: P): Promise; + + /** + * Retrieve a single resource matching the given ID, skipping any service-level hooks but sanitize the query + * with allowed filters and properties by calling `sanitizeQuery`. + * + * @param id - ID of the resource to locate + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#get-id-params|Feathers API Documentation: .get(id, params)} + */ + async _get (id: Id, params?: P): Promise { + const query = await this.sanitizeQuery(params); + + return this.$get(id, { + ...params, + query + }); } - create (data: Partial, params?: P): Promise; - create (data: Partial[], params?: P): Promise; - create (data: Partial | Partial[], params?: P): Promise { + abstract $create(data: Partial, params?: P): Promise; + abstract $create(data: Partial[], params?: P): Promise; + abstract $create(data: Partial | Partial[], params?: P): Promise; + + /** + * Create a new resource for this service, skipping any service-level hooks, sanitize the data + * and check if multiple updates are allowed. + * + * @param data - Data to insert into this service. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#create-data-params|Feathers API Documentation: .create(data, params)} + */ + async _create(data: Partial, params?: P): Promise; + async _create(data: Partial[], params?: P): Promise; + async _create(data: Partial | Partial[], params?: P): Promise; + async _create (data: Partial | Partial[], params?: P): Promise { if (Array.isArray(data) && !this.allowsMulti('create', params)) { - return Promise.reject(new MethodNotAllowed('Can not create multiple entries')); + throw new MethodNotAllowed('Can not create multiple entries'); } - return callMethod(this, '_create', data, params); + const payload = Array.isArray(data) + ? (await Promise.all(data.map(current => this.sanitizeData(current, params)))) + : (await this.sanitizeData(data, params)); + + return this.$create(payload, params); } - update (id: Id, data: D, params?: P): Promise { + abstract $update(id: Id, data: D, params?: P): Promise; + + /** + * Replace any resources matching the given ID with the given data, skipping any service-level hooks. + * + * @param id - ID of the resource to be updated + * @param data - Data to be put in place of the current resource. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#update-id-data-params|Feathers API Documentation: .update(id, data, params)} + */ + async _update (id: Id, data: D, params?: P): Promise { if (id === null || Array.isArray(data)) { - return Promise.reject(new BadRequest( + throw new BadRequest( 'You can not replace multiple instances. Did you mean \'patch\'?' - )); + ); } - return callMethod(this, '_update', id, data, params); + const payload = await this.sanitizeData(data, params); + const query = await this.sanitizeQuery(params); + + return this.$update(id, payload, { + ...params, + query + }); } - patch (id: Id, data: Partial, params?: P): Promise; - patch (id: null, data: Partial, params?: P): Promise; - patch (id: NullableId, data: Partial, params?: P): Promise { + abstract $patch(id: null, data: Partial, params?: P): Promise; + abstract $patch(id: Id, data: Partial, params?: P): Promise; + abstract $patch(id: NullableId, data: Partial, params?: P): Promise; + + /** + * Merge any resources matching the given ID with the given data, skipping any service-level hooks. + * Sanitizes the query and data and checks it multiple updates are allowed. + * + * @param id - ID of the resource to be patched + * @param data - Data to merge with the current resource. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#patch-id-data-params|Feathers API Documentation: .patch(id, data, params)} + */ + async _patch(id: null, data: Partial, params?: P): Promise; + async _patch(id: Id, data: Partial, params?: P): Promise; + async _patch(id: NullableId, data: Partial, params?: P): Promise; + async _patch (id: NullableId, data: Partial, params?: P): Promise { if (id === null && !this.allowsMulti('patch', params)) { - return Promise.reject(new MethodNotAllowed('Can not patch multiple entries')); + throw new MethodNotAllowed('Can not patch multiple entries'); } - return callMethod(this, '_patch', id, data, params); + const query = await this.sanitizeQuery(params); + const payload = await this.sanitizeData(data, params); + + return this.$patch(id, payload, { + ...params, + query + }); } - remove (id: Id, params?: P): Promise; - remove (id: null, params?: P): Promise; - remove (id: NullableId, params?: P): Promise { + abstract $remove(id: null, params?: P): Promise; + abstract $remove(id: Id, params?: P): Promise; + abstract $remove(id: NullableId, params?: P): Promise; + + /** + * Remove resources matching the given ID from the this service, skipping any service-level hooks. + * Sanitized the query and verifies that multiple updates are allowed. + * + * @param id - ID of the resource to be removed + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#remove-id-params|Feathers API Documentation: .remove(id, params)} + */ + async _remove(id: null, params?: P): Promise; + async _remove(id: Id, params?: P): Promise; + async _remove(id: NullableId, params?: P): Promise; + async _remove (id: NullableId, params?: P): Promise { if (id === null && !this.allowsMulti('remove', params)) { - return Promise.reject(new MethodNotAllowed('Can not remove multiple entries')); + throw new MethodNotAllowed('Can not remove multiple entries'); } - return callMethod(this, '_remove', id, params); - } + const query = await this.sanitizeQuery(params); - async setup () {} - - async teardown () {} + return this.$remove(id, { + ...params, + query + }); + } } diff --git a/packages/adapter-commons/test/fixture.ts b/packages/adapter-commons/test/fixture.ts new file mode 100644 index 0000000000..efa639f212 --- /dev/null +++ b/packages/adapter-commons/test/fixture.ts @@ -0,0 +1,93 @@ +import { AdapterBase, AdapterParams, InternalServiceMethods, PaginationOptions } from '../src'; +import { Id, NullableId, Paginated, Query } from '@feathersjs/feathers'; + +export type Data = { + id: Id +} + +export class MethodBase extends AdapterBase, AdapterParams> implements InternalServiceMethods { + async $find(_params?: AdapterParams & { paginate?: PaginationOptions }): Promise>; + async $find(_params?: AdapterParams & { paginate: false }): Promise; + async $find(params?: AdapterParams): Promise>; + async $find (params?: AdapterParams): Promise> { + if (params && params.paginate === false) { + return { + total: 0, + limit: 10, + skip: 0, + data: [] + } + } + + return []; + } + + async $get (id: Id, _params?: AdapterParams): Promise { + return { id }; + } + + async $create (data: Partial[], _params?: AdapterParams): Promise; + async $create (data: Partial, _params?: AdapterParams): Promise; + async $create (data: Partial|Partial[], _params?: AdapterParams): Promise { + if (Array.isArray(data)) { + return [{ + id: 'something' + }]; + } + + return { + id: 'something', + ...data + } + } + + async create (data: Partial|Partial[], params?: AdapterParams): Promise { + return this._create(data, params); + } + + async $update (id: NullableId, _data: Data, _params?: AdapterParams) { + return Promise.resolve({ id }); + } + + async $patch (id: null, _data: Partial, _params?: AdapterParams): Promise; + async $patch (id: Id, _data: Partial, _params?: AdapterParams): Promise; + async $patch (id: NullableId, _data: Partial, _params?: AdapterParams): Promise { + if (id === null) { + return [] + } + + return { id }; + } + + async $remove (id: null, _params?: AdapterParams): Promise; + async $remove (id: Id, _params?: AdapterParams): Promise; + async $remove (id: NullableId, _params?: AdapterParams) { + if (id === null) { + return [] as Data[]; + } + + return { id }; + } +} + +export class MethodService extends MethodBase { + find (params?: AdapterParams): Promise> { + return this._find(params); + } + + get (id: Id, params?: AdapterParams): Promise { + return this._get(id, params); + } + + async update (id: Id, data: Data, params?: AdapterParams) { + return this._update(id, data, params); + } + + async patch (id: NullableId, data: Partial, params?: AdapterParams) { + return this._patch(id, data, params); + } + + async remove (id: NullableId, params?: AdapterParams) { + return this._remove(id, params); + } +} diff --git a/packages/adapter-commons/test/filter-query.test.ts b/packages/adapter-commons/test/query.test.ts similarity index 100% rename from packages/adapter-commons/test/filter-query.test.ts rename to packages/adapter-commons/test/query.test.ts diff --git a/packages/adapter-commons/test/service.test.ts b/packages/adapter-commons/test/service.test.ts index 4bfe339530..59db52e530 100644 --- a/packages/adapter-commons/test/service.test.ts +++ b/packages/adapter-commons/test/service.test.ts @@ -1,97 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import assert from 'assert'; -import { NotImplemented } from '@feathersjs/errors'; -import { AdapterService, InternalServiceMethods, PaginationOptions } from '../src'; -import { Id, NullableId, Paginated } from '@feathersjs/feathers'; -import { AdapterParams } from '../lib'; +import { MethodService } from './fixture'; const METHODS: [ 'find', 'get', 'create', 'update', 'patch', 'remove' ] = [ 'find', 'get', 'create', 'update', 'patch', 'remove' ]; describe('@feathersjs/adapter-commons/service', () => { - class CustomService extends AdapterService { - } - - describe('errors when method does not exit', () => { - METHODS.forEach(method => { - it(`${method}`, () => { - const service = new CustomService({}); - - // @ts-ignore - return service[method]().then(() => { - throw new Error('Should never get here'); - }).catch((error: Error) => { - assert.ok(error instanceof NotImplemented); - assert.strictEqual(error.message, `Method _${method} not available`); - }); - }); - }); - }); - describe('works when methods exist', () => { - type Data = { - id: Id - } - - class MethodService extends AdapterService implements InternalServiceMethods { - _find (_params?: AdapterParams & { paginate?: PaginationOptions }): Promise>; - _find (_params?: AdapterParams & { paginate: false }): Promise; - async _find (params?: AdapterParams): Promise|Data[]> { - if (params && params.paginate === false) { - return { - total: 0, - limit: 10, - skip: 0, - data: [] - } - } - - return []; - } - - async _get (id: Id, _params?: AdapterParams) { - return { id }; - } - - async _create (data: Partial[], _params?: AdapterParams): Promise; - async _create (data: Partial, _params?: AdapterParams): Promise; - async _create (data: Partial|Partial[], _params?: AdapterParams): Promise { - if (Array.isArray(data)) { - return [{ - id: 'something' - }]; - } - - return { - id: 'something', - ...data - } - } - - async _update (id: NullableId, _data: any, _params?: AdapterParams) { - return Promise.resolve({ id }); - } - - async _patch (id: null, _data: any, _params?: AdapterParams): Promise; - async _patch (id: Id, _data: any, _params?: AdapterParams): Promise; - async _patch (id: NullableId, _data: any, _params?: AdapterParams): Promise { - if (id === null) { - return [] - } - - return { id }; - } - - async _remove (id: null, _params?: AdapterParams): Promise; - async _remove (id: Id, _params?: AdapterParams): Promise; - async _remove (id: NullableId, _params?: AdapterParams) { - if (id === null) { - return [] as Data[]; - } - - return { id }; - } - } - METHODS.forEach(method => { it(`${method}`, () => { const service = new MethodService({}); @@ -110,82 +24,82 @@ describe('@feathersjs/adapter-commons/service', () => { }); }); - it('does not allow multi patch', () => { + it('does not allow multi patch', async () => { const service = new MethodService({}); - return service.patch(null, {}) - .then(() => assert.ok(false)) - .catch(error => { - assert.strictEqual(error.name, 'MethodNotAllowed'); - assert.strictEqual(error.message, 'Can not patch multiple entries'); - }); + await assert.rejects(() => service.patch(null, {}), { + name: 'MethodNotAllowed', + message: 'Can not patch multiple entries' + }); }); - it('does not allow multi remove', () => { + it('does not allow multi remove', async () => { const service = new MethodService({}); - return service.remove(null, {}) - .then(() => assert.ok(false)) - .catch(error => { - assert.strictEqual(error.name, 'MethodNotAllowed'); - assert.strictEqual(error.message, 'Can not remove multiple entries'); - }); + await assert.rejects(() => service.remove(null, {}), { + name: 'MethodNotAllowed', + message: 'Can not remove multiple entries' + }); }); - it('does not allow multi create', () => { + it('does not allow multi create', async () => { const service = new MethodService({}); - return service.create([]) - .then(() => assert.ok(false)) - .catch(error => { - assert.strictEqual(error.name, 'MethodNotAllowed'); - assert.strictEqual(error.message, 'Can not create multiple entries'); - }); + await assert.rejects(() => service.create([], {}), { + name: 'MethodNotAllowed', + message: 'Can not create multiple entries' + }); }); - it('multi can be set to true', () => { + it('multi can be set to true', async () => { const service = new MethodService({}); service.options.multi = true; - return service.create([]) - .then(() => assert.ok(true)); + await service.create([]); }); }); - it('filterQuery', () => { - const service = new CustomService({ + it('sanitizeQuery', async () => { + const service = new MethodService({ filters: { $something: true - } - }); - const filtered = service.filterQuery({ - query: { $limit: 10, test: 'me' } + }, + operators: [ '$test' ] }); - assert.deepStrictEqual(filtered, { - paginate: false, - filters: { $limit: 10 }, - query: { test: 'me' } - }); + assert.deepStrictEqual(await service.sanitizeQuery({ + query: { $limit: '10', test: 'me' } + }), { $limit: 10, test: 'me' }); + + assert.deepStrictEqual(await service.sanitizeQuery({ + adapter: { + paginate: { max: 2 } + }, + query: { $limit: '10', test: 'me' } + }), { $limit: 2, test: 'me' }); - const withAllowed = service.filterQuery({ - query: { $limit: 10, $something: 'else' } + await assert.rejects(() => service.sanitizeQuery({ + query: { name: { $bla: 'me' } } + }), { + message: 'Invalid query parameter $bla' }); - assert.deepStrictEqual(withAllowed, { - paginate: false, - filters: { - $limit: 10, - $something: 'else' + assert.deepStrictEqual(await service.sanitizeQuery({ + adapter: { + operators: ['$bla'] }, - query: {} - }); + query: { name: { $bla: 'Dave' } } + }), { name: { $bla: 'Dave' } }); }); it('getOptions', () => { - const service = new AdapterService({ - multi: true + const service = new MethodService({ + multi: true, + paginate: { + default: 1, + max: 10 + } }); const opts = service.getOptions({ adapter: { @@ -203,19 +117,32 @@ describe('@feathersjs/adapter-commons/service', () => { paginate: { default: 10, max: 100 }, multi: [ 'create' ], filters: {}, - allow: [] + operators: [] + }); + + const notPaginated = service.getOptions({ + paginate: false + }); + + assert.deepStrictEqual(notPaginated, { + id: 'id', + events: [], + paginate: false, + multi: true, + filters: {}, + operators: [] }); }); it('allowsMulti', () => { context('with true', () => { - const service = new AdapterService({multi: true}); + const service = new MethodService({multi: true}); - it('does return true for multible methodes', () => { + it('does return true for multiple methodes', () => { assert.equal(service.allowsMulti('patch'), true); }); - it('does return false for always non-multible methodes', () => { + it('does return false for always non-multiple methodes', () => { assert.equal(service.allowsMulti('update'), false); }); @@ -225,13 +152,13 @@ describe('@feathersjs/adapter-commons/service', () => { }); context('with false', () => { - const service = new AdapterService({multi: false}); + const service = new MethodService({multi: false}); - it('does return false for multible methodes', () => { + it('does return false for multiple methodes', () => { assert.equal(service.allowsMulti('remove'), false); }); - it('does return true for always multible methodes', () => { + it('does return true for always multiple methodes', () => { assert.equal(service.allowsMulti('find'), true); }); @@ -241,17 +168,17 @@ describe('@feathersjs/adapter-commons/service', () => { }); context('with array', () => { - const service = new AdapterService({multi: ['create', 'get', 'other']}); + const service = new MethodService({multi: ['create', 'get', 'other']}); - it('does return true for specified multible methodes', () => { + it('does return true for specified multiple methodes', () => { assert.equal(service.allowsMulti('create'), true); }); - it('does return false for non-specified multible methodes', () => { + it('does return false for non-specified multiple methodes', () => { assert.equal(service.allowsMulti('patch'), false); }); - it('does return false for specified always multible methodes', () => { + it('does return false for specified always multiple methodes', () => { assert.equal(service.allowsMulti('get'), false); }); diff --git a/packages/adapter-tests/src/basic.ts b/packages/adapter-tests/src/basic.ts index a9599a3a72..26586e473c 100644 --- a/packages/adapter-tests/src/basic.ts +++ b/packages/adapter-tests/src/basic.ts @@ -49,6 +49,30 @@ export default (test: AdapterBasicTest, app: any, _errors: any, serviceName: str test('._remove', () => { assert.strictEqual(typeof service._remove, 'function'); }); + + test('.$get', () => { + assert.strictEqual(typeof service.$get, 'function'); + }); + + test('.$find', () => { + assert.strictEqual(typeof service.$find, 'function'); + }); + + test('.$create', () => { + assert.strictEqual(typeof service.$create, 'function'); + }); + + test('.$update', () => { + assert.strictEqual(typeof service.$update, 'function'); + }); + + test('.$patch', () => { + assert.strictEqual(typeof service.$patch, 'function'); + }); + + test('.$remove', () => { + assert.strictEqual(typeof service.$remove, 'function'); + }); }); }); }; diff --git a/packages/adapter-tests/src/declarations.ts b/packages/adapter-tests/src/declarations.ts index 80bb142385..fa76d68767 100644 --- a/packages/adapter-tests/src/declarations.ts +++ b/packages/adapter-tests/src/declarations.ts @@ -15,7 +15,13 @@ export type AdapterBasicTestName = '._create' | '._update' | '._patch' | - '._remove'; + '._remove'| + '.$get' | + '.$find' | + '.$create' | + '.$update' | + '.$patch' | + '.$remove'; export type AdapterMethodsTestName = '.get' | diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 51b3d31bd4..2757a9710e 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -28,7 +28,6 @@ const adapterTests = (testNames: AdapterTestName[]) => { describe(`Adapter tests for '${serviceName}' service with '${idProp}' id property`, () => { after(() => { - console.log('\n'); testNames.forEach(name => { if (!allTests.includes(name)) { console.error(`WARNING: '${name}' test is not part of the test suite`); diff --git a/packages/adapter-tests/test/index.test.ts b/packages/adapter-tests/test/index.test.ts index 445c3d9d6c..d825301296 100644 --- a/packages/adapter-tests/test/index.test.ts +++ b/packages/adapter-tests/test/index.test.ts @@ -9,6 +9,12 @@ const testSuite = adapterTests([ '._update', '._patch', '._remove', + '.$get', + '.$find', + '.$create', + '.$update', + '.$patch', + '.$remove', '.get', '.get + $select', '.get + id + query', diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index cbd7a5ec21..178f0d9963 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,11 +1,8 @@ -import { MethodNotAllowed, NotFound } from '@feathersjs/errors'; +import { NotFound } from '@feathersjs/errors'; import { _ } from '@feathersjs/commons'; -import { - sorter, select, AdapterBase, AdapterServiceOptions, InternalServiceMethods, - PaginationOptions, AdapterParams, FilterQueryOptions -} from '@feathersjs/adapter-commons'; +import { sorter, select, AdapterBase, AdapterServiceOptions, PaginationOptions } from '@feathersjs/adapter-commons'; import sift from 'sift'; -import { NullableId, Id, Params, ServiceMethods, Paginated, Query } from '@feathersjs/feathers'; +import { NullableId, Id, Params, ServiceMethods, Paginated } from '@feathersjs/feathers'; export interface MemoryServiceStore { [key: string]: T; @@ -24,8 +21,7 @@ const _select = (data: any, params: any, ...args: any[]) => { return base(JSON.parse(JSON.stringify(data))); }; -export class MemoryAdapter, P extends Params = Params> extends AdapterBase> - implements InternalServiceMethods { +export class MemoryAdapter, P extends Params = Params> extends AdapterBase> { store: MemoryServiceStore; _uId: number; @@ -44,34 +40,29 @@ export class MemoryAdapter, P extends Params = Params> e async getEntries (_params?: P) { const params = _params || {} as P; - const { query } = this.filterQuery(params); - return this._find({ + return this.$find({ ...params, - paginate: false, - query + paginate: false }); } - filterQuery (params?: AdapterParams, opts?: FilterQueryOptions) { - const result = super.filterQuery(params, opts); - const { query, filters } = result; + getQuery (params: P) { + const { $skip, $sort, $limit, $select, ...query } = params.query || {}; - // $or is not treated as a filter but needs to be part of the query to work with SiftJS - result.query = { - ...query, - ...(filters.$or ? { $or: filters.$or } : {}) + return { + query, + filters: { $skip, $sort, $limit, $select } } - - return result; } - async _find (_params?: P & { paginate?: PaginationOptions }): Promise>; - async _find (_params?: P & { paginate: false }): Promise; - async _find (_params?: P): Promise|T[]>; - async _find (_params?: P): Promise|T[]> { - const params = _params || {} as P; - const { query, filters, paginate } = this.filterQuery(params); + async $find (_params?: P & { paginate?: PaginationOptions }): Promise>; + async $find (_params?: P & { paginate: false }): Promise; + async $find (_params?: P): Promise|T[]>; + async $find (params: P = {} as P): Promise|T[]> { + const { paginate } = this.getOptions(params); + const { query, filters } = this.getQuery(params); + let values = _.values(this.store).filter(this.options.matcher(query)); const total = values.length; @@ -94,18 +85,17 @@ export class MemoryAdapter, P extends Params = Params> e data: values.map(value => _select(value, params)) }; - if (!(paginate && paginate.default)) { + if (!paginate) { return result.data; } return result; } - async _get (id: Id, _params?: P): Promise { - const params = _params || {} as P; + async $get (id: Id, params: P = {} as P): Promise { + const { query } = this.getQuery(params); if (id in this.store) { - const { query } = this.filterQuery(params); const value = this.store[id]; if (this.options.matcher(query)(value)) { @@ -116,19 +106,12 @@ export class MemoryAdapter, P extends Params = Params> e throw new NotFound(`No record found for id '${id}'`); } - // Create without hooks and mixins that can be used internally - async _create (data: Partial, params?: P): Promise; - async _create (data: Partial[], params?: P): Promise; - async _create (data: Partial|Partial[], _params?: P): Promise; - async _create (data: Partial|Partial[], _params?: P): Promise { - const params = _params || {} as P; - + async $create (data: Partial, params?: P): Promise; + async $create (data: Partial[], params?: P): Promise; + async $create (data: Partial|Partial[], _params?: P): Promise; + async $create (data: Partial|Partial[], params: P = {} as P): Promise { if (Array.isArray(data)) { - if (!this.allowsMulti('create', params)) { - throw new MethodNotAllowed('Can not create multiple entries'); - } - - return Promise.all(data.map(current => this._create(current, params))); + return Promise.all(data.map(current => this.$create(current, params))); } const id = (data as any)[this.id] || this._uId++; @@ -138,12 +121,8 @@ export class MemoryAdapter, P extends Params = Params> e return _select(result, params, this.id) as T; } - async _update (id: Id, data: D, params: P = {} as P): Promise { - if (id === null || Array.isArray(data)) { - throw new MethodNotAllowed('You can not replace multiple instances. Did you mean \'patch\'?'); - } - - const oldEntry = await this._get(id); + async $update (id: Id, data: D, params: P = {} as P): Promise { + const oldEntry = await this.$get(id); // We don't want our id to change type if it can be coerced const oldId = (oldEntry as any)[this.id]; @@ -152,14 +131,14 @@ export class MemoryAdapter, P extends Params = Params> e this.store[id] = _.extend({}, data, { [this.id]: id }); - return this._get(id, params); + return this.$get(id, params); } - async _patch (id: null, data: Partial, params?: P): Promise; - async _patch (id: Id, data: Partial, params?: P): Promise; - async _patch (id: NullableId, data: Partial, _params?: P): Promise; - async _patch (id: NullableId, data: Partial, _params?: P): Promise { - const params = _params || {} as P; + async $patch (id: null, data: Partial, params?: P): Promise; + async $patch (id: Id, data: Partial, params?: P): Promise; + async $patch (id: NullableId, data: Partial, _params?: P): Promise; + async $patch (id: NullableId, data: Partial, params: P = {} as P): Promise { + const { query } = this.getQuery(params); const patchEntry = (entry: T) => { const currentId = (entry as any)[this.id]; @@ -169,38 +148,35 @@ export class MemoryAdapter, P extends Params = Params> e }; if (id === null) { - if(!this.allowsMulti('patch', params)) { - throw new MethodNotAllowed('Can not patch multiple entries'); - } - - const entries = await this.getEntries(params); + const entries = await this.getEntries({ + ...params, + query + }); return entries.map(patchEntry); } - return patchEntry(await this._get(id, params)); // Will throw an error if not found + return patchEntry(await this.$get(id, params)); // Will throw an error if not found } - // Remove without hooks and mixins that can be used internally - async _remove (id: null, params?: P): Promise; - async _remove (id: Id, params?: P): Promise; - async _remove (id: NullableId, _params?: P): Promise; - async _remove (id: NullableId, _params?: P): Promise { - const params = _params || {} as P; + async $remove (id: null, params?: P): Promise; + async $remove (id: Id, params?: P): Promise; + async $remove (id: NullableId, _params?: P): Promise; + async $remove (id: NullableId, params: P = {} as P): Promise { + const { query } = this.getQuery(params); if (id === null) { - if(!this.allowsMulti('remove', params)) { - throw new MethodNotAllowed('Can not remove multiple entries'); - } + const entries = await this.getEntries({ + ...params, + query + }); - const entries = await this.getEntries(params); - - return Promise.all(entries.map(current => - this._remove((current as any)[this.id] as Id, params) + return Promise.all(entries.map((current: any) => + this.$remove(current[this.id] as Id, params) )); } - const entry = await this._get(id, params); + const entry = await this.$get(id, params); delete this.store[id]; @@ -214,7 +190,7 @@ export class MemoryService, P extends Params = Params> async find (params?: P & { paginate: false }): Promise; async find (params?: P): Promise|T[]>; async find (params?: P): Promise|T[]> { - return this._find(params) + return this._find(params) as any; } async get (id: Id, params?: P): Promise { @@ -248,4 +224,4 @@ export function memory, P extends Params = Params> ( options: Partial> = {} ) { return new MemoryService(options) -} \ No newline at end of file +} diff --git a/packages/memory/test/index.test.ts b/packages/memory/test/index.test.ts index 386bc883de..dbaccce017 100644 --- a/packages/memory/test/index.test.ts +++ b/packages/memory/test/index.test.ts @@ -14,6 +14,12 @@ const testSuite = adapterTests([ '._update', '._patch', '._remove', + '.$get', + '.$find', + '.$create', + '.$update', + '.$patch', + '.$remove', '.get', '.get + $select', '.get + id + query',