diff --git a/packages/adapter-commons/src/declarations.ts b/packages/adapter-commons/src/declarations.ts index 0709046f3d..6ac368e1a2 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 { @@ -9,28 +17,41 @@ export interface PaginationOptions { max?: number; } -export type PaginationParams = false|PaginationOptions; - -export type FilterQueryOptions = { - filters?: FilterSettings; - operators?: string[]; - paginate?: PaginationParams; -} +export type PaginationParams = false | PaginationOptions; export interface AdapterServiceOptions { - events?: string[]; - multi?: boolean|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<M = any> extends Pick<AdapterServiceOptions, 'multi'|'allow'|'paginate'> { +export interface AdapterOptions<M = any> extends Pick<AdapterServiceOptions, 'multi' | 'filters' | 'operators' | 'paginate'> { Model?: M; } @@ -41,7 +62,7 @@ export interface AdapterParams<Q = Query, M = any> extends Params<Q> { /** * 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 @@ -51,42 +72,44 @@ export interface AdapterParams<Q = Query, M = any> extends Params<Q> { * * @see {@link https://docs.feathersjs.com/guides/migrating.html#hook-less-service-methods} */ - export interface InternalServiceMethods<T = any, D = Partial<T>, P extends AdapterParams = AdapterParams> { +export interface InternalServiceMethods<T = any, D = Partial<T>, 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<Paginated<T>>; - _find (_params?: P & { paginate: false }): Promise<T[]>; - _find (params?: P): Promise<T | T[] | Paginated<T>>; + $find(_params?: P & { paginate?: PaginationOptions }): Promise<Paginated<T>>; + $find(_params?: P & { paginate: false }): Promise<T[]>; + $find(params?: P): Promise<T[] | Paginated<T>>; /** * 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<T>; + $get(id: Id, params?: P): Promise<T>; /** * 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<D>, params?: P): Promise<T>; - _create (data: Partial<D>[], params?: P): Promise<T[]>; - _create (data: Partial<D>|Partial<D>[], params?: P): Promise<T|T[]>; + $create(data: Partial<D>, params?: P): Promise<T>; + $create(data: Partial<D>[], params?: P): Promise<T[]>; + $create(data: Partial<D> | Partial<D>[], params?: P): Promise<T | T[]>; /** - * 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. @@ -94,10 +117,11 @@ export interface AdapterParams<Q = Query, M = any> extends Params<Q> { * @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<T>; + $update(id: Id, data: D, params?: P): Promise<T>; /** * 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. @@ -105,19 +129,20 @@ export interface AdapterParams<Q = Query, M = any> extends Params<Q> { * @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<D>, params?: P): Promise<T[]>; - _patch (id: Id, data: Partial<D>, params?: P): Promise<T>; - _patch (id: NullableId, data: Partial<D>, params?: P): Promise<T|T[]>; + $patch(id: null, data: Partial<D>, params?: P): Promise<T[]>; + $patch(id: Id, data: Partial<D>, params?: P): Promise<T>; + $patch(id: NullableId, data: Partial<D>, params?: P): Promise<T | T[]>; /** * 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<T[]>; - _remove (id: Id, params?: P): Promise<T>; - _remove (id: NullableId, params?: P): Promise<T|T[]>; + $remove(id: null, params?: P): Promise<T[]>; + $remove(id: Id, params?: P): Promise<T>; + $remove(id: NullableId, params?: P): Promise<T | T[]>; } \ No newline at end of file diff --git a/packages/adapter-commons/src/filter-query.ts b/packages/adapter-commons/src/filter-query.ts deleted file mode 100644 index 4cc29e4472..0000000000 --- a/packages/adapter-commons/src/filter-query.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { _ } from '@feathersjs/commons'; -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; -} - -// 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; - - return Math.min(lower, upper); - } - - return limit; -} - -// 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; - } - - return Object.keys(sort).reduce((result, key) => { - result[key] = typeof sort[key] === 'object' - ? sort[key] : parseInt(sort[key], 10); - - return result; - }, {} as { [key: string]: number }); -} - -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); - } - } - - result[key] = cleanQuery(value, operators, filters); - }); - - Object.getOwnPropertySymbols(query).forEach(symbol => { - // @ts-ignore - result[symbol] = query[symbol]; - }); - - return result; - } - - return query; -} - -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); - - if (converted !== undefined) { - object[key] = converted; - } - }); - } - - return object; -} - -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 -}; - -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` -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); - - return { - filters, - query: cleanQuery(query, OPERATORS.concat(additionalOperators), filters) as Query - } -} 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/query.ts b/packages/adapter-commons/src/query.ts new file mode 100644 index 0000000000..757f8112ae --- /dev/null +++ b/packages/adapter-commons/src/query.ts @@ -0,0 +1,138 @@ +import { _ } from '@feathersjs/commons'; +import { BadRequest } from '@feathersjs/errors'; +import { Query } from '@feathersjs/feathers'; +import { FilterQueryOptions, FilterSettings } from './declarations'; + +const parse = (value: any) => typeof value !== 'undefined' ? parseInt(value, 10) : value; + +const isPlainObject = (value: any) => _.isObject(value) && value.constructor === {}.constructor; + +const validateQueryProperty = (query: any, operators: string[] = []): Query => { + if (!isPlainObject(query)) { + return query; + } + + for (const key of Object.keys(query)) { + if (key.startsWith('$') && !operators.includes(key)) { + throw new BadRequest(`Invalid query parameter ${key}`, query); + } + + const value = query[key]; + + if (isPlainObject(value)) { + query[key] = validateQueryProperty(value, operators); + } + } + + return { + ...query + } +} + +const getFilters = (query: Query, settings: FilterQueryOptions) => { + const filterNames = Object.keys(settings.filters); + + return filterNames.reduce((current, key) => { + const queryValue = query[key]; + const filter = settings.filters[key]; + + if (filter) { + const value = typeof filter === 'function' ? filter(queryValue, settings) : queryValue; + + if (value !== undefined) { + current[key] = value; + } + } + + return current; + }, {} as { [key: string]: any }); +} + +const getQuery = (query: Query, settings: FilterQueryOptions) => { + const keys = Object.keys(query).concat(Object.getOwnPropertySymbols(query) as any as string[]); + + 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 result; + }, {} as Query) +} + +export const OPERATORS = ['$in', '$nin', '$lt', '$lte', '$gt', '$gte', '$ne', '$or']; + +export const FILTERS: FilterSettings = { + $skip: (value: any) => parse(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; + } +} + +/** + * 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 query = _query || {}; + const settings = { + ...options, + filters: { + ...FILTERS, + ...options.filters + }, + operators: OPERATORS.concat(options.operators || []) + } + + return { + 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..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<O extends Partial<AdapterServiceOptions> = Partial<AdapterServiceOptions>> { +export abstract class AdapterBase< + T = any, + D = Partial<T>, + P extends AdapterParams = AdapterParams, + O extends Partial<AdapterServiceOptions> = Partial<AdapterServiceOptions> + > implements InternalServiceMethods<T, D, P> { options: AdapterServiceOptions & O; constructor (options: O) { - this.options = Object.assign({ + this.options = { id: 'id', events: [], paginate: false, multi: false, - filters: [], - allow: [] - }, options); + filters: {}, + operators: [], + ...options + }; } get id () { @@ -42,107 +41,231 @@ export class AdapterBase<O extends Partial<AdapterServiceOptions> = Partial<Adap return this.options.events; } - filterQuery (params: AdapterParams = {}, opts: FilterQueryOptions = {}) { - const paginate = typeof params.paginate !== 'undefined' - ? params.paginate - : this.getOptions(params).paginate; - const query: Query = { ...params.query }; - const options = { - operators: this.options.whitelist || this.options.allow || [], - filters: this.options.filters, - paginate, - ...opts - }; - const result = filterQuery(query, options); - - return { - paginate, - ...result - } - } - - allowsMulti (method: string, params: AdapterParams = {}) { + /** + * Check if this adapter allows multiple updates for a method. + * @param method The method name to check. + * @param params The service call params. + * @returns Wether or not multiple updates are allowed. + */ + allowsMulti (method: string, params: P = {} as P) { const always = alwaysMulti[method]; if (typeof always !== 'undefined') { return always; } - const { multi: option } = this.getOptions(params); + const { multi } = this.getOptions(params); - if (option === true || option === false) { - return option; + if (multi === true || multi === false) { + return multi; } - return option.includes(method); + return multi.includes(method); } - getOptions (params: AdapterParams): AdapterServiceOptions & { model?: any } { + /** + * Returns the combined options for a service call. Options will be merged + * with `this.options` and `params.adapter` for dynamic overrides. + * + * @param params The parameters for the service method call + * @returns The actual options for this call + */ + getOptions (params: P): AdapterServiceOptions { + const paginate = params.paginate !== undefined ? params.paginate : this.options.paginate; + return { ...this.options, + paginate, ...params.adapter } } -} -export class AdapterService< - T = any, - D = Partial<T>, - O extends Partial<AdapterServiceOptions> = Partial<AdapterServiceOptions>, - P extends Params = AdapterParams -> extends AdapterBase<O> implements ServiceMethods<T|Paginated<T>, D> { - find (params?: P & { paginate?: PaginationOptions }): Promise<Paginated<T>>; - find (params?: P & { paginate: false }): Promise<T[]>; - find (params?: P): Promise<T | T[] | Paginated<T>>; - find (params?: P): Promise<T[] | Paginated<T>> { - 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<X = Partial<D>> (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<Query> { + const options = this.getOptions(params); + const { query, filters } = filterQuery(params.query, options) + + return { + ...filters, + ...query + }; + } + + abstract $find(_params?: P & { paginate?: PaginationOptions }): Promise<Paginated<T>>; + abstract $find(_params?: P & { paginate: false }): Promise<T[]>; + abstract $find(params?: P): Promise<T[] | Paginated<T>>; + + /** + * 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<Paginated<T>>; + async _find(_params?: P & { paginate: false }): Promise<T[]>; + async _find(params?: P): Promise<T | T[] | Paginated<T>>; + async _find (params?: P): Promise<T | T[] | Paginated<T>> { + const query = await this.sanitizeQuery(params); + + return this.$find({ + ...params, + query + }); } - get (id: Id, params?: P): Promise<T> { - return callMethod(this, '_get', id, params); + abstract $get(id: Id, params?: P): Promise<T>; + + /** + * 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<T> { + const query = await this.sanitizeQuery(params); + + return this.$get(id, { + ...params, + query + }); } - create (data: Partial<D>, params?: P): Promise<T>; - create (data: Partial<D>[], params?: P): Promise<T[]>; - create (data: Partial<D> | Partial<D>[], params?: P): Promise<T | T[]> { + abstract $create(data: Partial<D>, params?: P): Promise<T>; + abstract $create(data: Partial<D>[], params?: P): Promise<T[]>; + abstract $create(data: Partial<D> | Partial<D>[], params?: P): Promise<T | T[]>; + + /** + * 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<D>, params?: P): Promise<T>; + async _create(data: Partial<D>[], params?: P): Promise<T[]>; + async _create(data: Partial<D> | Partial<D>[], params?: P): Promise<T | T[]>; + async _create (data: Partial<D> | Partial<D>[], params?: P): Promise<T | T[]> { 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<T> { + abstract $update(id: Id, data: D, params?: P): Promise<T>; + + /** + * 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<T> { 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<D>, params?: P): Promise<T>; - patch (id: null, data: Partial<D>, params?: P): Promise<T[]>; - patch (id: NullableId, data: Partial<D>, params?: P): Promise<T | T[]> { + abstract $patch(id: null, data: Partial<D>, params?: P): Promise<T[]>; + abstract $patch(id: Id, data: Partial<D>, params?: P): Promise<T>; + abstract $patch(id: NullableId, data: Partial<D>, params?: P): Promise<T | T[]>; + + /** + * 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<D>, params?: P): Promise<T[]>; + async _patch(id: Id, data: Partial<D>, params?: P): Promise<T>; + async _patch(id: NullableId, data: Partial<D>, params?: P): Promise<T | T[]>; + async _patch (id: NullableId, data: Partial<D>, params?: P): Promise<T | T[]> { 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<T>; - remove (id: null, params?: P): Promise<T[]>; - remove (id: NullableId, params?: P): Promise<T | T[]> { + abstract $remove(id: null, params?: P): Promise<T[]>; + abstract $remove(id: Id, params?: P): Promise<T>; + abstract $remove(id: NullableId, params?: P): Promise<T | T[]>; + + /** + * 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<T[]>; + async _remove(id: Id, params?: P): Promise<T>; + async _remove(id: NullableId, params?: P): Promise<T | T[]>; + async _remove (id: NullableId, params?: P): Promise<T | T[]> { 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<Data, Partial<Data>, AdapterParams> implements InternalServiceMethods<Data> { + async $find(_params?: AdapterParams & { paginate?: PaginationOptions }): Promise<Paginated<Data>>; + async $find(_params?: AdapterParams & { paginate: false }): Promise<Data[]>; + async $find(params?: AdapterParams): Promise<Data | Data[] | Paginated<Data>>; + async $find (params?: AdapterParams): Promise<Data | Data[] | Paginated<Data>> { + if (params && params.paginate === false) { + return { + total: 0, + limit: 10, + skip: 0, + data: [] + } + } + + return []; + } + + async $get (id: Id, _params?: AdapterParams): Promise<Data> { + return { id }; + } + + async $create (data: Partial<Data>[], _params?: AdapterParams): Promise<Data[]>; + async $create (data: Partial<Data>, _params?: AdapterParams): Promise<Data>; + async $create (data: Partial<Data>|Partial<Data>[], _params?: AdapterParams): Promise<Data|Data[]> { + if (Array.isArray(data)) { + return [{ + id: 'something' + }]; + } + + return { + id: 'something', + ...data + } + } + + async create (data: Partial<Data>|Partial<Data>[], params?: AdapterParams): Promise<Data|Data[]> { + return this._create(data, params); + } + + async $update (id: NullableId, _data: Data, _params?: AdapterParams) { + return Promise.resolve({ id }); + } + + async $patch (id: null, _data: Partial<Data>, _params?: AdapterParams): Promise<Data[]>; + async $patch (id: Id, _data: Partial<Data>, _params?: AdapterParams): Promise<Data>; + async $patch (id: NullableId, _data: Partial<Data>, _params?: AdapterParams): Promise<Data|Data[]> { + if (id === null) { + return [] + } + + return { id }; + } + + async $remove (id: null, _params?: AdapterParams): Promise<Data[]>; + async $remove (id: Id, _params?: AdapterParams): Promise<Data>; + async $remove (id: NullableId, _params?: AdapterParams) { + if (id === null) { + return [] as Data[]; + } + + return { id }; + } +} + +export class MethodService extends MethodBase { + find (params?: AdapterParams<Query, any>): Promise<Data|Data[]|Paginated<Data>> { + return this._find(params); + } + + get (id: Id, params?: AdapterParams): Promise<Data> { + 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<Data>, 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 89% rename from packages/adapter-commons/test/filter-query.test.ts rename to packages/adapter-commons/test/query.test.ts index b295c9c197..55ff60c226 100644 --- a/packages/adapter-commons/test/filter-query.test.ts +++ b/packages/adapter-commons/test/query.test.ts @@ -44,7 +44,7 @@ describe('@feathersjs/adapter-commons/filterQuery', () => { 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..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<Data> implements InternalServiceMethods<Data> { - _find (_params?: AdapterParams & { paginate?: PaginationOptions }): Promise<Paginated<Data>>; - _find (_params?: AdapterParams & { paginate: false }): Promise<Data[]>; - async _find (params?: AdapterParams): Promise<Paginated<Data>|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<Data>[], _params?: AdapterParams): Promise<Data[]>; - async _create (data: Partial<Data>, _params?: AdapterParams): Promise<Data>; - async _create (data: Partial<Data>|Partial<Data>[], _params?: AdapterParams): Promise<Data|Data[]> { - 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<Data[]>; - async _patch (id: Id, _data: any, _params?: AdapterParams): Promise<Data>; - async _patch (id: NullableId, _data: any, _params?: AdapterParams): Promise<Data|Data[]> { - if (id === null) { - return [] - } - - return { id }; - } - - async _remove (id: null, _params?: AdapterParams): Promise<Data[]>; - async _remove (id: Id, _params?: AdapterParams): Promise<Data>; - async _remove (id: NullableId, _params?: AdapterParams) { - if (id === null) { - return [] as Data[]; - } - - return { id }; - } - } - METHODS.forEach(method => { it(`${method}`, () => { const service = new MethodService({}); @@ -110,77 +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({ - allow: [ '$something' ] - }); - const filtered = service.filterQuery({ - query: { $limit: 10, test: 'me' } + it('sanitizeQuery', async () => { + const service = new MethodService({ + filters: { + $something: true + }, + 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' }); - const withAllowed = service.filterQuery({ - query: { $limit: 10, $something: 'else' } - }); + assert.deepStrictEqual(await service.sanitizeQuery({ + adapter: { + paginate: { max: 2 } + }, + query: { $limit: '10', test: 'me' } + }), { $limit: 2, test: 'me' }); - assert.deepStrictEqual(withAllowed, { - paginate: false, - filters: { $limit: 10 }, - query: { $something: 'else' } + await assert.rejects(() => service.sanitizeQuery({ + query: { name: { $bla: 'me' } } + }), { + message: 'Invalid query parameter $bla' }); + + assert.deepStrictEqual(await service.sanitizeQuery({ + adapter: { + operators: ['$bla'] + }, + 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: { @@ -197,20 +116,33 @@ describe('@feathersjs/adapter-commons/service', () => { events: [], paginate: { default: 10, max: 100 }, multi: [ 'create' ], - filters: [], - allow: [] + filters: {}, + 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); }); @@ -220,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); }); @@ -236,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 a05ea48b82..178f0d9963 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,6 +1,6 @@ -import { MethodNotAllowed, NotFound } from '@feathersjs/errors'; +import { NotFound } from '@feathersjs/errors'; import { _ } from '@feathersjs/commons'; -import { sorter, select, AdapterBase, AdapterServiceOptions, InternalServiceMethods, PaginationOptions } from '@feathersjs/adapter-commons'; +import { sorter, select, AdapterBase, AdapterServiceOptions, PaginationOptions } from '@feathersjs/adapter-commons'; import sift from 'sift'; import { NullableId, Id, Params, ServiceMethods, Paginated } from '@feathersjs/feathers'; @@ -21,8 +21,7 @@ const _select = (data: any, params: any, ...args: any[]) => { return base(JSON.parse(JSON.stringify(data))); }; -export class MemoryAdapter<T = any, D = Partial<T>, P extends Params = Params> extends AdapterBase<MemoryServiceOptions<T>> - implements InternalServiceMethods<T, D, P> { +export class MemoryAdapter<T = any, D = Partial<T>, P extends Params = Params> extends AdapterBase<T, D, P, MemoryServiceOptions<T>> { store: MemoryServiceStore<T>; _uId: number; @@ -41,21 +40,29 @@ export class MemoryAdapter<T = any, D = Partial<T>, 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 }); } - async _find (_params?: P & { paginate?: PaginationOptions }): Promise<Paginated<T>>; - async _find (_params?: P & { paginate: false }): Promise<T[]>; - async _find (_params?: P): Promise<Paginated<T>|T[]>; - async _find (_params?: P): Promise<Paginated<T>|T[]> { - const params = _params || {} as P; - const { query, filters, paginate } = this.filterQuery(params); + getQuery (params: P) { + const { $skip, $sort, $limit, $select, ...query } = params.query || {}; + + return { + query, + filters: { $skip, $sort, $limit, $select } + } + } + + async $find (_params?: P & { paginate?: PaginationOptions }): Promise<Paginated<T>>; + async $find (_params?: P & { paginate: false }): Promise<T[]>; + async $find (_params?: P): Promise<Paginated<T>|T[]>; + async $find (params: P = {} as P): Promise<Paginated<T>|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; @@ -78,18 +85,17 @@ export class MemoryAdapter<T = any, D = Partial<T>, 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<T> { - const params = _params || {} as P; + async $get (id: Id, params: P = {} as P): Promise<T> { + 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)) { @@ -100,19 +106,12 @@ export class MemoryAdapter<T = any, D = Partial<T>, 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<D>, params?: P): Promise<T>; - async _create (data: Partial<D>[], params?: P): Promise<T[]>; - async _create (data: Partial<D>|Partial<D>[], _params?: P): Promise<T|T[]>; - async _create (data: Partial<D>|Partial<D>[], _params?: P): Promise<T|T[]> { - const params = _params || {} as P; - + async $create (data: Partial<D>, params?: P): Promise<T>; + async $create (data: Partial<D>[], params?: P): Promise<T[]>; + async $create (data: Partial<D>|Partial<D>[], _params?: P): Promise<T|T[]>; + async $create (data: Partial<D>|Partial<D>[], params: P = {} as P): Promise<T|T[]> { 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++; @@ -122,12 +121,8 @@ export class MemoryAdapter<T = any, D = Partial<T>, P extends Params = Params> e return _select(result, params, this.id) as T; } - async _update (id: Id, data: D, params: P = {} as P): Promise<T> { - 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<T> { + 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]; @@ -136,14 +131,14 @@ export class MemoryAdapter<T = any, D = Partial<T>, 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<D>, params?: P): Promise<T[]>; - async _patch (id: Id, data: Partial<D>, params?: P): Promise<T>; - async _patch (id: NullableId, data: Partial<D>, _params?: P): Promise<T|T[]>; - async _patch (id: NullableId, data: Partial<D>, _params?: P): Promise<T|T[]> { - const params = _params || {} as P; + async $patch (id: null, data: Partial<D>, params?: P): Promise<T[]>; + async $patch (id: Id, data: Partial<D>, params?: P): Promise<T>; + async $patch (id: NullableId, data: Partial<D>, _params?: P): Promise<T|T[]>; + async $patch (id: NullableId, data: Partial<D>, params: P = {} as P): Promise<T|T[]> { + const { query } = this.getQuery(params); const patchEntry = (entry: T) => { const currentId = (entry as any)[this.id]; @@ -153,38 +148,35 @@ export class MemoryAdapter<T = any, D = Partial<T>, 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<T[]>; - async _remove (id: Id, params?: P): Promise<T>; - async _remove (id: NullableId, _params?: P): Promise<T|T[]>; - async _remove (id: NullableId, _params?: P): Promise<T|T[]> { - const params = _params || {} as P; + async $remove (id: null, params?: P): Promise<T[]>; + async $remove (id: Id, params?: P): Promise<T>; + async $remove (id: NullableId, _params?: P): Promise<T|T[]>; + async $remove (id: NullableId, params: P = {} as P): Promise<T|T[]> { + 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]; @@ -198,7 +190,7 @@ export class MemoryService<T = any, D = Partial<T>, P extends Params = Params> async find (params?: P & { paginate: false }): Promise<T[]>; async find (params?: P): Promise<Paginated<T>|T[]>; async find (params?: P): Promise<Paginated<T>|T[]> { - return this._find(params) + return this._find(params) as any; } async get (id: Id, params?: P): Promise<T> { @@ -232,4 +224,4 @@ export function memory<T = any, D = Partial<T>, P extends Params = Params> ( options: Partial<MemoryServiceOptions<T>> = {} ) { return new MemoryService<T, D, P>(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',