UNPKG

@loopback/filter

Version:

Utility typings and filters for LoopBack filters.

753 lines (694 loc) 17.5 kB
// Copyright IBM Corp. and LoopBack contributors 2020. All Rights Reserved. // Node module: @loopback/filter // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import assert from 'assert'; import {AnyObject} from './types'; /* eslint-disable @typescript-eslint/no-explicit-any */ const nonWhereFields = [ 'fields', 'order', 'limit', 'skip', 'offset', 'include', ]; const filterFields = ['where', ...nonWhereFields]; /** * Operators for where clauses */ export type Operators = | 'eq' // Equal | 'neq' // Not Equal | 'gt' // > | 'gte' // >= | 'lt' // < | 'lte' // <= | 'inq' // IN | 'nin' // NOT IN | 'between' // BETWEEN [val1, val2] | 'exists' | 'and' // AND | 'or' // OR | 'like' // LIKE | 'nlike' // NOT LIKE | 'ilike' // ILIKE' | 'nilike' // NOT ILIKE | 'regexp' // REGEXP' | 'match' // match | 'contains'; // for array /** * Matching predicate comparison */ export type PredicateComparison<PT> = { eq?: PT; neq?: PT; gt?: PT; gte?: PT; lt?: PT; lte?: PT; inq?: PT[]; nin?: PT[]; between?: [PT, PT]; exists?: boolean; like?: PT; nlike?: PT; ilike?: PT; nilike?: PT; regexp?: string | RegExp; // [extendedOperation: string]: any; }; /** * Value types for `{propertyName: value}` */ export type ShortHandEqualType = string | number | boolean | Date; /** * Key types of a given model, excluding operators */ export type KeyOf<MT extends object> = Exclude< Extract<keyof MT, string>, Operators >; /** * Condition clause * * @example * ```ts * { * name: {inq: ['John', 'Mary']}, * status: 'ACTIVE', * age: {gte: 40} * } * ``` */ export type Condition<MT extends object> = { [P in KeyOf<MT>]?: | PredicateComparison<MT[P]> // {x: {lt: 1}} | (MT[P] & ShortHandEqualType); // {x: 1}, }; /** * Where clause * * @example * ```ts * { * name: {inq: ['John', 'Mary']}, * status: 'ACTIVE' * and: [...], * or: [...], * } * ``` */ export type Where<MT extends object = AnyObject> = | Condition<MT> | AndClause<MT> | OrClause<MT>; /** * And clause * * @example * ```ts * { * and: [...], * } * ``` */ export interface AndClause<MT extends object> { and: Where<MT>[]; } /** * Or clause * * @example * ```ts * { * or: [...], * } * ``` */ export interface OrClause<MT extends object> { or: Where<MT>[]; } /** * Order by direction */ export type Direction = 'ASC' | 'DESC'; /** * Order by * * Example: * `{afieldname: 'ASC'}` */ export type Order<MT = AnyObject> = {[P in keyof MT]: Direction}; /** * Selection of fields * * Example: * `{afieldname: true}` */ export type Fields<MT = AnyObject> = | {[P in keyof MT]?: boolean} | Extract<keyof MT, string>[]; /** * Inclusion of related items * * Note: scope means filter on related items * * Example: * `{relation: 'aRelationName', scope: {<AFilterObject>}}` */ export interface Inclusion { relation: string; targetType?: string; // Technically, we should restrict the filter to target model. // That is unfortunately rather difficult to achieve, because // an Entity does not provide type information about related models. // We could use {ModelName}WithRelations interface for first-level inclusion, // but that won't handle second-level (and deeper) inclusions. // To keep things simple, we allow any filter here, effectively turning off // compiler checks. scope?: Filter<AnyObject> & { /** * Global maximum number of inclusions. This is just to remain backward * compatibility. This totalLimit props takes precedence over limit * https://github.com/loopbackio/loopback-next/issues/6832 */ totalLimit?: number; }; } /** * Query filter object */ export interface Filter<MT extends object = AnyObject> { /** * The matching criteria */ where?: Where<MT>; /** * To include/exclude fields */ fields?: Fields<MT>; /** * Sorting order for matched entities. Each item should be formatted as * `fieldName ASC` or `fieldName DESC`. * For example: `['f1 ASC', 'f2 DESC', 'f3 ASC']`. * * We might want to use `Order` in the future. Keep it as `string[]` for now * for compatibility with LoopBack 3.x. */ order?: string[]; /** * Maximum number of entities */ limit?: number; /** * Skip N number of entities */ skip?: number; /** * Offset N number of entities. An alias for `skip` */ offset?: number; /** * To include related objects */ include?: InclusionFilter[]; } /** * Inclusion filter type e.g. 'property', {relation: 'property'} */ export type InclusionFilter = string | Inclusion; /** * Filter without `where` property */ export type FilterExcludingWhere<MT extends object = AnyObject> = Omit< Filter<MT>, 'where' >; /** * TypeGuard for Filter * @param candidate */ export function isFilter<MT extends object>( candidate: any, ): candidate is Filter<MT> { if (typeof candidate !== 'object') return false; for (const key in candidate) { if (!filterFields.includes(key)) { return false; } } return true; } /** * A builder for Where object. It provides fluent APIs to add clauses such as * `and`, `or`, and other operators. * * @example * ```ts * const whereBuilder = new WhereBuilder(); * const where = whereBuilder * .eq('a', 1) * .and({x: 'x'}, {y: {gt: 1}}) * .and({b: 'b'}, {c: {lt: 1}}) * .or({d: 'd'}, {e: {neq: 1}}) * .build(); * ``` */ export class WhereBuilder<MT extends object = AnyObject> { where: Where<MT>; constructor(w?: Where<MT>) { this.where = w ?? {}; } private add(w: Where<MT>): this { for (const k of Object.keys(w)) { if (k in this.where) { // Found conflicting keys, create an `and` operator to join the existing // conditions with the new one const where = {and: [this.where, w]}; this.where = where; return this; } } // Merge the where items this.where = Object.assign(this.where, w); return this; } /** * @deprecated * Starting from TypeScript 3.2, we don't have to cast any more. This method * should be considered as `deprecated`. * * Cast an `and`, `or`, or condition clause to Where * @param clause - And/Or/Condition clause */ cast(clause: AndClause<MT> | OrClause<MT> | Condition<MT>): Where<MT> { return clause; } /** * Add an `and` clause. * @param w - One or more where objects */ and(...w: (Where<MT> | Where<MT>[])[]): this { let clauses: Where<MT>[] = []; w.forEach(where => { clauses = clauses.concat(Array.isArray(where) ? where : [where]); }); return this.add({and: clauses}); } /** * Add an `or` clause. * @param w - One or more where objects */ or(...w: (Where<MT> | Where<MT>[])[]): this { let clauses: Where<MT>[] = []; w.forEach(where => { clauses = clauses.concat(Array.isArray(where) ? where : [where]); }); return this.add({or: clauses}); } /** * Add an `=` condition * @param key - Property name * @param val - Property value */ eq<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = val as ShortHandEqualType & MT[K]; return this.add(w); } /** * Add a `!=` condition * @param key - Property name * @param val - Property value */ neq<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {neq: val}; return this.add(w); } /** * Add a `>` condition * @param key - Property name * @param val - Property value */ gt<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {gt: val}; return this.add(w); } /** * Add a `>=` condition * @param key - Property name * @param val - Property value */ gte<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {gte: val}; return this.add(w); } /** * Add a `<` condition * @param key - Property name * @param val - Property value */ lt<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {lt: val}; return this.add(w); } /** * Add a `<=` condition * @param key - Property name * @param val - Property value */ lte<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {lte: val}; return this.add(w); } /** * Add a `inq` condition * @param key - Property name * @param val - An array of property values */ inq<K extends KeyOf<MT>>(key: K, val: MT[K][]): this { const w: Where<MT> = {}; w[key] = {inq: val}; return this.add(w); } /** * Add a `nin` condition * @param key - Property name * @param val - An array of property values */ nin<K extends KeyOf<MT>>(key: K, val: MT[K][]): this { const w: Where<MT> = {}; w[key] = {nin: val}; return this.add(w); } /** * Add a `between` condition * @param key - Property name * @param val1 - Property value lower bound * @param val2 - Property value upper bound */ between<K extends KeyOf<MT>>(key: K, val1: MT[K], val2: MT[K]): this { const w: Where<MT> = {}; w[key] = {between: [val1, val2]}; return this.add(w); } /** * Add a `exists` condition * @param key - Property name * @param val - Exists or not */ exists<K extends KeyOf<MT>>(key: K, val?: boolean): this { const w: Where<MT> = {}; w[key] = {exists: !!val || val == null}; return this.add(w); } /** * Add a where object. For conflicting keys with the existing where object, * create an `and` clause. * @param where - Where filter */ impose(where: Where<MT>): this { if (!this.where) { this.where = where || {}; } else { this.add(where); } return this; } /** * Add a `like` condition * @param key - Property name * @param val - Regexp condition */ like<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {like: val}; return this.add(w); } /** * Add a `nlike` condition * @param key - Property name * @param val - Regexp condition */ nlike<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {nlike: val}; return this.add(w); } /** * Add a `ilike` condition * @param key - Property name * @param val - Regexp condition */ ilike<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {ilike: val}; return this.add(w); } /** * Add a `nilike` condition * @param key - Property name * @param val - Regexp condition */ nilike<K extends KeyOf<MT>>(key: K, val: MT[K]): this { const w: Where<MT> = {}; w[key] = {nilike: val}; return this.add(w); } /** * Add a `regexp` condition * @param key - Property name * @param val - Regexp condition */ regexp<K extends KeyOf<MT>>(key: K, val: string | RegExp): this { const w: Where<MT> = {}; w[key] = {regexp: val}; return this.add(w); } /** * Get the where object */ build() { return this.where; } } /** * A builder for Filter. It provides fleunt APIs to add clauses such as * `fields`, `order`, `where`, `limit`, `offset`, and `include`. * * @example * ```ts * const filterBuilder = new FilterBuilder(); * const filter = filterBuilder * .fields('id', 'a', 'b') * .limit(10) * .offset(0) * .order(['a ASC', 'b DESC']) * .where({id: 1}) * .build(); * ``` */ export class FilterBuilder<MT extends object = AnyObject> { filter: Filter<MT>; constructor(f?: Filter<MT>) { this.filter = f ?? {}; } /** * Set `limit` * @param limit - Maximum number of records to be returned */ limit(limit: number): this { assert(limit >= 1, `Limit ${limit} must a positive number`); this.filter.limit = limit; return this; } /** * Set `offset` * @param offset - Offset of the number of records to be returned */ offset(offset: number): this { this.filter.offset = offset; return this; } /** * Alias to `offset` * @param skip */ skip(skip: number): this { return this.offset(skip); } /** * Describe what fields to be included/excluded * @param f - A field name to be included, an array of field names to be * included, or an Fields object for the inclusion/exclusion */ fields(...f: (Fields<MT> | Extract<keyof MT, string>)[]): this { if (!this.filter.fields) { this.filter.fields = {}; } else if (Array.isArray(this.filter.fields)) { this.filter.fields = this.filter.fields.reduce( (prev, current) => ({...prev, [current]: true}), {}, ); } const fields = this.filter.fields; for (const field of f) { if (Array.isArray(field)) { field.forEach(i => (fields[i] = true)); } else if (typeof field === 'string') { fields[field] = true; } else { Object.assign(fields, field); } } return this; } private validateOrder(order: string) { assert(order.match(/^[^\s]+( (ASC|DESC))?$/), 'Invalid order: ' + order); } /** * Describe the sorting order * @param o - A field name with optional direction, an array of field names, * or an Order object for the field/direction pairs */ order(...o: (string | string[] | Order<MT>)[]): this { if (!this.filter.order) { this.filter.order = []; } o.forEach(order => { if (typeof order === 'string') { this.validateOrder(order); if (!order.endsWith(' ASC') && !order.endsWith(' DESC')) { order = order + ' ASC'; } this.filter.order!.push(order); return this; } if (Array.isArray(order)) { order.forEach(this.validateOrder); order = order.map(i => { if (!i.endsWith(' ASC') && !i.endsWith(' DESC')) { i = i + ' ASC'; } return i; }); this.filter.order = this.filter.order!.concat(order); return this; } for (const i in order) { this.filter.order!.push(`${i} ${order[i]}`); } }); return this; } /** * Declare `include` * @param i - A relation name, an array of relation names, or an `Inclusion` * object for the relation/scope definitions */ include(...i: (string | string[] | Inclusion)[]): this { if (this.filter.include == null) { this.filter.include = []; } for (const include of i) { if (typeof include === 'string') { this.filter.include.push({relation: include}); } else if (Array.isArray(include)) { for (const inc of include) this.filter.include.push({relation: inc}); } else { this.filter.include!.push(include); } } return this; } /** * Declare a where clause * @param w - Where object */ where(w: Where<MT>): this { this.filter.where = w; return this; } /** * Add a Filter or Where constraint object. If it is a filter object, create * an `and` clause for conflicting keys with its where object. For any other * properties, throw an error. If it's not a Filter, coerce it to a filter, * and carry out the same logic. * * @param constraint - a constraint object to merge with own filter object */ impose(constraint: Filter<MT> | Where<MT>): this { if (!this.filter) { // if constraint is a Where, turn into a Filter if (!isFilter(constraint)) { constraint = {where: constraint}; } this.filter = (constraint as Filter<MT>) || {}; } else { if (isFilter(constraint)) { // throw error if imposed Filter has non-where fields for (const key of Object.keys(constraint)) { if (nonWhereFields.includes(key)) { throw new Error( 'merging strategy for selection, pagination, and sorting not implemented', ); } } } this.filter.where = isFilter(constraint) ? new WhereBuilder(this.filter.where).impose(constraint.where!).build() : new WhereBuilder(this.filter.where).impose(constraint).build(); } return this; } /** * Return the filter object */ build() { return this.filter; } } /** * Get nested properties by path * @param value - Value of an object * @param path - Path to the property */ function getDeepProperty(value: AnyObject, path: string): any { const props = path.split('.'); for (const p of props) { value = value[p]; if (value == null) { return null; } } return value; } export function filterTemplate(strings: TemplateStringsArray, ...keys: any[]) { return function filter(ctx: AnyObject) { const tokens = [strings[0]]; keys.forEach((key, i) => { if ( typeof key === 'object' || typeof key === 'boolean' || typeof key === 'number' ) { tokens.push(JSON.stringify(key), strings[i + 1]); return; } const value = getDeepProperty(ctx, key); tokens.push(JSON.stringify(value), strings[i + 1]); }); const result = tokens.join(''); try { return JSON.parse(result); } catch (e) { throw new Error('Invalid JSON: ' + result); } }; }