@loopback/filter
Version:
Utility typings and filters for LoopBack filters.
753 lines (694 loc) • 17.5 kB
text/typescript
// 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);
}
};
}