diff --git a/.gitignore b/.gitignore index d2c58a3..fec5389 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ typings/ # Optional npm cache directory .npm +# yarn lock (npm used) +*yarn.lock + # Optional eslint cache .eslintcache diff --git a/prisma-filter-common/src/filter.builder.ts b/prisma-filter-common/src/filter.builder.ts index 3416e72..dcbf50d 100644 --- a/prisma-filter-common/src/filter.builder.ts +++ b/prisma-filter-common/src/filter.builder.ts @@ -1,5 +1,9 @@ import { FilterOperationType, FilterOrder } from './filter.enum'; -import { IFilter, ISingleFilter, ISingleOrder } from './filter.interface'; +import { + IFilter, + ISingleFilter, + ISingleOrder, +} from './filter.interface'; export class FilterBuilder { /** @@ -12,31 +16,65 @@ export class FilterBuilder { public static buildFilterQueryString(filter: IFilter): string { const parts: string[] = []; - if(filter.offset != null) { + if (filter.offset != null) { parts.push(`offset=${filter.offset}`); } - if(filter.limit != null) { + if (filter.limit != null) { parts.push(`limit=${filter.limit}`); } - const filterQuery = filter.filter ? FilterBuilder.buildQueryString('filter', filter.filter) : null; - if(filterQuery != null) { + if (filter.page != null) { + parts.push(`page=${filter.page}`); + } + if (filter.skip != null) { + parts.push(`skip=${filter.skip}`); + } + const selectQuery = filter.select + ? 'select=' + filter.select.toString() + : null; + if (selectQuery != null) { + parts.push(selectQuery); + } + const filterQuery = filter.filter + ? FilterBuilder.buildQueryString('filter', filter.filter) + : null; + if (filterQuery != null) { parts.push(filterQuery); } - const orderQuery = filter.order ? FilterBuilder.buildQueryString('order', filter.order) : null; - if(orderQuery != null) { + const orderQuery = filter.order + ? FilterBuilder.buildQueryString('order', filter.order) + : null; + if (orderQuery != null) { parts.push(orderQuery); } + const sortQuery = filter.sort + ? FilterBuilder.buildQueryString('sort', filter.sort) + : null; + if (sortQuery != null) { + parts.push(sortQuery); + } + const cursorQuery = filter.cursor + ? 'cursor[field]=' + + filter.cursor.field.toString() + + '&cursor[value]=' + + filter.cursor.value.toString() + : null; + if (cursorQuery != null) { + parts.push(cursorQuery); + } - if(parts.length === 0) return ''; + if (parts.length === 0) return ''; return `?${parts.join('&')}`; } - private static buildQueryString(paramName: string, array: Array): string | null { + private static buildQueryString( + paramName: string, + array: Array + ): string | null { const parts: Array = []; - for(let i = 0; i < array.length; i++) { - for(const [key, value] of Object.entries(array[i])) { - if(!['field', 'dir', 'type', 'value'].includes(key)) { + for (let i = 0; i < array.length; i++) { + for (const [key, value] of Object.entries(array[i])) { + if (!['field', 'dir', 'type', 'value'].includes(key)) { continue; } /** @@ -45,21 +83,33 @@ export class FilterBuilder { * filter[x][field]= * & filter[x][type]=in */ - if(Array.isArray(value)) { - if(value.length === 0) { - parts.push(`${encodeURIComponent(paramName)}[${i}][${encodeURIComponent(key)}]=`); + if (Array.isArray(value)) { + if (value.length === 0) { + parts.push( + `${encodeURIComponent(paramName)}[${i}][${encodeURIComponent( + key + )}]=` + ); } - for(let y = 0; y < value.length; y++) { + for (let y = 0; y < value.length; y++) { /** * & filter[x][value][y]= */ const valueY = value[y]; parts.push( - `${encodeURIComponent(paramName)}[${i}][${encodeURIComponent(key)}][${y}]=${encodeURIComponent(valueY != null ? valueY.toString() : '')}`, + `${encodeURIComponent(paramName)}[${i}][${encodeURIComponent( + key + )}][${y}]=${encodeURIComponent( + valueY != null ? valueY.toString() : '' + )}` ); } } else { - parts.push(`${encodeURIComponent(paramName)}[${i}][${encodeURIComponent(key)}]=${encodeURIComponent(value != null ? value.toString() : '')}`); + parts.push( + `${encodeURIComponent(paramName)}[${i}][${encodeURIComponent( + key + )}]=${encodeURIComponent(value != null ? value.toString() : '')}` + ); } } } @@ -68,6 +118,7 @@ export class FilterBuilder { private readonly filter: IFilter = Object.create(null); + // eslint-disable-next-line @typescript-eslint/no-empty-function constructor() {} /** @@ -79,8 +130,12 @@ export class FilterBuilder { * * @returns FilterBuilder for chaining */ - public addFilter(field: keyof T & string, type: FilterOperationType, value: any): this { - if(this.filter.filter == null) { + public addFilter( + field: keyof T & string, + type: FilterOperationType, + value: any + ): this { + if (this.filter.filter == null) { this.filter.filter = []; } this.filter.filter.push({ field, type, value }); @@ -88,6 +143,24 @@ export class FilterBuilder { return this; } + /** + * Adds a single filter for one field. + * + * @param field - The name of the field to filter by + * @param type - The type of filter to apply + * @param value - The value to filter for + * + * @returns FilterBuilder for chaining + */ + public addCursor(field: keyof T & string, value: any): this { + if (this.filter.filter == null) { + this.filter.filter = []; + } + this.filter.cursor = { field, value }; + + return this; + } + /** * Adds an offset to the result. * @@ -126,6 +199,32 @@ export class FilterBuilder { return this.limitTo(pagesize); } + /** + * Sets the page + * + * @param page - The page that should be returned + * + * @returns FilterBuilder for chaining + */ + public setPage(page: number): this { + this.filter.page = page; + + return this; + } + + /** + * Sets the number of records to skip + * + * @param skip - The number of records to skip. Used for cursor based pagination + * + * @returns FilterBuilder for chaining + */ + public skip(skip: number): this { + this.filter.skip = skip; + + return this; + } + /** * Requests a specific page of the result set. Requires {@link setPageSize} to have been called before. * Automatically calculates the required offset for the given page. @@ -136,10 +235,12 @@ export class FilterBuilder { * @returns FilterBuilder for chaining */ public requestPage(page: number): this { - if(this.filter.limit == null) { - throw new Error('requestPage can only be called after calling setPageSize'); + if (this.filter.limit == null) { + throw new Error( + 'requestPage can only be called after calling setPageSize' + ); } - if(page <= 0) { + if (page <= 0) { throw new Error('Invalid argument: page must be at least 1'); } this.filter.offset = this.filter.limit * (page - 1); @@ -147,6 +248,23 @@ export class FilterBuilder { return this; } + /** + * Adds field selection to the query. Only selected fields will be requested. nested fields in related records can be added + * using the format 'permissions.id' on a user resource with a many-many relationship with permission + * + * @param field - The name of the field to order by + * + * @returns FilterBuilder for chaining + */ + public addSelectFields(field: string[]): this { + if (this.filter.select == null) { + this.filter.select = []; + } + this.filter.select = this.filter.select.concat(field); + + return this; + } + /** * Adds an ordering to the result. * If there are multiple entries with the same value in the given field, then later `orderBy`s are used. @@ -159,7 +277,7 @@ export class FilterBuilder { * @returns FilterBuilder for chaining */ public addOrderBy(field: keyof T & string, dir: FilterOrder): this { - if(this.filter.order == null) { + if (this.filter.order == null) { this.filter.order = []; } this.filter.order.push({ field, dir }); @@ -167,6 +285,26 @@ export class FilterBuilder { return this; } + /** + * Adds an sorting to the result. + * If there are multiple entries with the same value in the given field, then later `sortBy`s are used. + * If no additional `sortBy`s are added, then the resulting sort between them is unspecified (and may break pagination). + * Therefore, it is recommended to add a unique sort field in the end (e.g. sort by id if everything else is the same). + * + * @param field - The name of the field to sort by + * @param dir - FilterOrder direction + * + * @returns FilterBuilder for chaining + */ + public addSortBy(field: keyof T & string, dir: FilterOrder): this { + if (this.filter.sort == null) { + this.filter.sort = []; + } + this.filter.sort.push({ field, dir }); + + return this; + } + /** * Returns the built filter object. * diff --git a/prisma-filter-common/src/filter.interface.ts b/prisma-filter-common/src/filter.interface.ts index 878c1ca..6bca8a1 100644 --- a/prisma-filter-common/src/filter.interface.ts +++ b/prisma-filter-common/src/filter.interface.ts @@ -2,9 +2,14 @@ import { FilterOperationType, FilterOrder } from './filter.enum'; export interface IFilter { filter?: Array>; + sort?: Array>; order?: Array>; offset?: number; limit?: number; + page?: number; + cursor?: ISingleCursor; + skip?: number; + select?: string[]; } export interface ISingleFilter { @@ -17,3 +22,8 @@ export interface ISingleOrder { field: keyof T & string; dir: FilterOrder; } + +export interface ISingleCursor { + field: keyof T & string; + value: any; +} diff --git a/prisma-filter/README.md b/prisma-filter/README.md index fdb13a4..5208230 100644 --- a/prisma-filter/README.md +++ b/prisma-filter/README.md @@ -179,6 +179,7 @@ export class SomeController { @Query(new DirectFilterPipe( ['id', 'status', 'createdAt', 'refundStatus', 'refundedPrice', 'paymentDate', 'totalPrice', 'paymentMethod'], ['event.title', 'user.email', 'user.firstname', 'user.lastname', 'contactAddress.firstName', 'contactAddress.lastName', '!paymentInAdvance'], + ['user'] // a list of relations can now be passed to a new FilterPipe to define a set of default included records in the prisma query )) filterDto: FilterDto, ) { return this.someService.getOrders(filterDto.findOptions); diff --git a/prisma-filter/package-lock.json b/prisma-filter/package-lock.json index 14b1cb5..3d1cafb 100644 --- a/prisma-filter/package-lock.json +++ b/prisma-filter/package-lock.json @@ -9,12 +9,14 @@ "version": "2.3.0", "license": "MIT", "dependencies": { - "@chax-at/prisma-filter-common": "^2.3.0" + "@chax-at/prisma-filter-common": "^2.3.0", + "lodash": "^4.17.21" }, "devDependencies": { "@swc/core": "^1.2.165", "@swc/jest": "^0.2.20", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.190", "@types/node": "^16.11.26", "jest": "^27.5.1", "typescript": "^4.8.2" @@ -1304,6 +1306,12 @@ "pretty-format": "^27.0.0" } }, + "node_modules/@types/lodash": { + "version": "4.14.190", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.190.tgz", + "integrity": "sha512-5iJ3FBJBvQHQ8sFhEhJfjUP+G+LalhavTkYyrAYqz5MEJG+erSv0k9KJLb6q7++17Lafk1scaTIFXcMJlwK8Mw==", + "dev": true + }, "node_modules/@types/node": { "version": "16.11.45", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.45.tgz", @@ -3288,8 +3296,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lru-cache": { "version": "6.0.0", @@ -5332,6 +5339,12 @@ "pretty-format": "^27.0.0" } }, + "@types/lodash": { + "version": "4.14.190", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.190.tgz", + "integrity": "sha512-5iJ3FBJBvQHQ8sFhEhJfjUP+G+LalhavTkYyrAYqz5MEJG+erSv0k9KJLb6q7++17Lafk1scaTIFXcMJlwK8Mw==", + "dev": true + }, "@types/node": { "version": "16.11.45", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.45.tgz", @@ -6845,8 +6858,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lru-cache": { "version": "6.0.0", diff --git a/prisma-filter/package.json b/prisma-filter/package.json index a62a5c6..41be499 100644 --- a/prisma-filter/package.json +++ b/prisma-filter/package.json @@ -14,12 +14,14 @@ "author": "chax.at - Challenge Accepted", "license": "MIT", "dependencies": { - "@chax-at/prisma-filter-common": "^2.3.0" + "@chax-at/prisma-filter-common": "^2.3.0", + "lodash": "^4.17.21" }, "devDependencies": { "@swc/core": "^1.2.165", "@swc/jest": "^0.2.20", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.190", "@types/node": "^16.11.26", "jest": "^27.5.1", "typescript": "^4.8.2" diff --git a/prisma-filter/src/all.filter.pipe.unsafe.ts b/prisma-filter/src/all.filter.pipe.unsafe.ts index 1c6fa30..05d2b28 100644 --- a/prisma-filter/src/all.filter.pipe.unsafe.ts +++ b/prisma-filter/src/all.filter.pipe.unsafe.ts @@ -15,8 +15,17 @@ import { FilterParser } from './filter.parser'; * See filter.parser.ts for FilterParser implementation details. */ @Injectable() -export class AllFilterPipeUnsafe implements PipeTransform, IGeneratedFilter> { - private readonly filterParser: FilterParser; +export class AllFilterPipeUnsafe< + TDto, + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } +> implements PipeTransform, IGeneratedFilter> +{ + private readonly filterParser: FilterParser; /** * Create a new filter pipe that transforms Tabulator-like filters (usually in query parameters) to a generated filter with prisma WhereInput for a specified @@ -27,16 +36,23 @@ export class AllFilterPipeUnsafe implements PipeTransform(['user.name', 'articles.some.name']) * * @param compoundKeys - Keys in the form of 'user.firstname' (-to-one relation) or 'articles.some.name' (-to-many relation) which will be mapped to relations. Keys starting with ! are ignored. + * @param defaultIncludes - Keys in the form of 'roles' (-to-many relation) or 'roles.permissions' (-to-many relation) which will include related records in prisma query by default. */ - constructor(compoundKeys: string[] = []) { - const mapping: { [p in keyof TDto]?: keyof TWhereInput & string } = Object.create(null); - for(const untypedKey of compoundKeys) { + constructor(compoundKeys: string[] = [], defaultIncludes: string[] = []) { + const mapping: { + [p in keyof TDto]?: keyof TFindManyArgs['where'] & string; + } = Object.create(null); + for (const untypedKey of compoundKeys) { (mapping as any)[untypedKey] = untypedKey; } - this.filterParser = new FilterParser(mapping, true); + this.filterParser = new FilterParser( + mapping, + true, + defaultIncludes + ); } - public transform(value: IFilter): IGeneratedFilter { + public transform(value: IFilter): IGeneratedFilter { return { ...value, findOptions: this.filterParser.generateQueryFindOptions(value), diff --git a/prisma-filter/src/direct.filter.pipe.ts b/prisma-filter/src/direct.filter.pipe.ts index 0bc0f74..6084172 100644 --- a/prisma-filter/src/direct.filter.pipe.ts +++ b/prisma-filter/src/direct.filter.pipe.ts @@ -14,34 +14,55 @@ import { FilterParser } from './filter.parser'; * See comment in filter.pipe.ts for further explanation how this pipe works (except that the constructor takes an array of strings instead of a mapping) * See filter.parser.ts for FilterParser implementation details. */ -@Injectable() -export class DirectFilterPipe implements PipeTransform, IGeneratedFilter> { - private readonly filterParser: FilterParser; - - /** - * Create a new filter pipe that transforms Tabulator-like filters (usually in query parameters) to a generated filter with prisma WhereInput for a specified - * model assuming you have a direct 1:1 mapping (i.e. the filter field names are the same as the database field names). - * - * @example new DirectFilterPipe(['id', 'createdAt'], ['user.name', 'articles.some.name']) - * - * @param keys - Keys that are mapped 1:1 - * @param compoundKeys - Keys in the form of 'user.firstname' (-to-one relation) or 'articles.some.name' (-to-many relation) which will be mapped to relations. Keys starting with ! are ignored. - */ - constructor(keys: Array, compoundKeys: string[] = []) { - const mapping: { [p in keyof TDto]?: keyof TWhereInput & string } = Object.create(null); - for(const key of keys) { - mapping[key] = key; - } - for(const untypedKey of compoundKeys) { - (mapping as any)[untypedKey] = untypedKey; - } - this.filterParser = new FilterParser(mapping); - } - - public transform(value: IFilter): IGeneratedFilter { - return { - ...value, - findOptions: this.filterParser.generateQueryFindOptions(value), - }; - } -} + @Injectable() + export class DirectFilterPipe< + TDto, + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } + > implements PipeTransform, IGeneratedFilter> + { + private readonly filterParser: FilterParser; + + /** + * Create a new filter pipe that transforms Tabulator-like filters (usually in query parameters) to a generated filter with prisma WhereInput for a specified + * model assuming you have a direct 1:1 mapping (i.e. the filter field names are the same as the database field names). + * + * @example new DirectFilterPipe(['id', 'createdAt'], ['user.name', 'articles.some.name']) + * + * @param keys - Keys that are mapped 1:1 + * @param compoundKeys - Keys in the form of 'user.firstname' (-to-one relation) or 'articles.some.name' (-to-many relation) which will be mapped to relations. Keys starting with ! are ignored. + * @param defaultIncludes - Keys in the form of 'roles' (-to-many relation) or 'roles.permissions' (-to-many relation) which will include related records in prisma query by default. + */ + constructor( + keys: Array, + compoundKeys: string[] = [], + defaultIncludes: string[] = [] + ) { + const mapping: { + [p in keyof TDto]?: keyof TFindManyArgs['where'] & string; + } = Object.create(null); + for (const key of keys) { + mapping[key] = key; + } + for (const untypedKey of compoundKeys) { + (mapping as any)[untypedKey] = untypedKey; + } + this.filterParser = new FilterParser( + mapping, + false, + defaultIncludes + ); + } + + public transform(value: IFilter): IGeneratedFilter { + return { + ...value, + findOptions: this.filterParser.generateQueryFindOptions(value), + }; + } + } + \ No newline at end of file diff --git a/prisma-filter/src/filter-classes.ts b/prisma-filter/src/filter-classes.ts new file mode 100644 index 0000000..c056756 --- /dev/null +++ b/prisma-filter/src/filter-classes.ts @@ -0,0 +1,117 @@ +import { Expose, Transform, Type } from 'class-transformer'; +import { + IsArray, + IsDefined, + IsEnum, + IsIn, + IsInt, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { + FilterOperationType, + FilterOrder, + IFilter, + ISingleCursor, + ISingleFilter, + ISingleOrder, +} from '.'; + +// The fields are also validated in filter.parser.ts to make sure that only correct fields are used to filter +export class SingleFilter implements ISingleFilter { + constructor(partial: Partial>) { + Object.assign(this, partial); + } + @Expose() + @IsString() + field!: keyof T & string; + + @Expose() + @IsEnum(FilterOperationType) + type!: FilterOperationType; + + @Expose() + @IsDefined() + value: any; +} + +export class SingleFilterOrder implements ISingleOrder { + constructor(partial: Partial>) { + Object.assign(this, partial); + } + @Expose() + @IsString() + field!: keyof T & string; + + @Expose() + @IsIn(['asc', 'desc']) + dir!: FilterOrder; +} + +export class SingleCursor implements ISingleCursor { + constructor(partial: Partial>) { + Object.assign(this, partial); + } + @Expose() + @IsString() + field!: keyof T & string; + + @Expose() + @IsDefined() + value: any; +} + +export class Filter implements IFilter { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SingleFilter) + @IsOptional() + filter?: Array>; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SingleFilterOrder) + @IsOptional() + sort?: Array>; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SingleFilterOrder) + @IsOptional() + order?: Array>; + + @Type(() => Number) + @IsInt() + @IsOptional() + offset?: number; + + @Type(() => Number) + @IsInt() + @Min(1) + @IsOptional() + limit?: number; + + @Type(() => Number) + @IsInt() + @IsOptional() + page?: number; + + @ValidateNested({ each: true }) + @Type(() => SingleCursor) + @IsOptional() + cursor?: SingleCursor; + + @Type(() => Number) + @IsInt() + @IsOptional() + skip?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Type(() => String) + @Transform(({ value }) => String(value).split(',')) + select?: string[]; +} diff --git a/prisma-filter/src/filter.interface.ts b/prisma-filter/src/filter.interface.ts index c714ffe..de965c7 100644 --- a/prisma-filter/src/filter.interface.ts +++ b/prisma-filter/src/filter.interface.ts @@ -1,14 +1,42 @@ -import { FilterOrder, IFilter } from '@chax-at/prisma-filter-common'; +import { + FilterOrder, + IFilter, +} from '@chax-at/prisma-filter-common'; -export type GeneratedFindOptions = { - where: TWhereInput; +export type GeneratedFindOptions< + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } +> = { + where: TFindManyArgs['where']; skip: number | undefined; take: number | undefined; - // This can be "any" because we might order by relations, therefore this will be an object - orderBy: Array<{ [p in keyof TWhereInput]?: FilterOrder | any }>; + cursor: TFindManyArgs['cursor']; + // This can be "any" because we might sort by relations, therefore this will be an object + orderBy: TFindManyArgs['orderBy']; + select?: TRecursiveField; + include?: TRecursiveField; }; +export interface IGeneratedFilter< + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } +> extends IFilter { + findOptions: GeneratedFindOptions; +} + +export type TRecursiveField = { + [key: string]: boolean | TRecursiveField; +}; -export interface IGeneratedFilter extends IFilter { - findOptions: GeneratedFindOptions; +export interface IParsedQueryParams { + select?: TRecursiveField; + include?: TRecursiveField; } diff --git a/prisma-filter/src/filter.parser.ts b/prisma-filter/src/filter.parser.ts index 2ac0d02..ecf23dc 100644 --- a/prisma-filter/src/filter.parser.ts +++ b/prisma-filter/src/filter.parser.ts @@ -1,69 +1,146 @@ -import { FilterOperationType, FilterOrder, IFilter, ISingleFilter, ISingleOrder } from '@chax-at/prisma-filter-common'; -import { GeneratedFindOptions } from './filter.interface'; +import { + FilterOperationType, + FilterOrder, + IFilter, + ISingleCursor, + ISingleFilter, + ISingleOrder, +} from '@chax-at/prisma-filter-common'; +import { + GeneratedFindOptions, + IParsedQueryParams, + TRecursiveField, +} from './filter.interface'; import { IntFilter, StringFilter } from './prisma.type'; +import { set } from 'lodash'; -export class FilterParser { +export class FilterParser< + TDto, + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } +> { /** * * @param mapping - An object mapping from Dto key to database key * @param allowAllFields - Allow filtering on *all* top-level keys. Warning! Only use this if the user should have access to ALL data of the column + * @param defaultIncludes - Keys in the form of 'roles' (-to-many relation) or 'roles.permissions' (-to-many relation) which will include related records in prisma query by default. */ constructor( - private readonly mapping: { [p in keyof TDto]?: keyof TWhereInput & string }, + private readonly mapping: { + [p in keyof TDto]?: keyof TFindManyArgs['where'] & string; + }, private readonly allowAllFields = false, - ) { } + private readonly defaultIncludes?: string[] + ) {} - public generateQueryFindOptions(filterDto: IFilter): GeneratedFindOptions { - if(filterDto.filter == null) { + public generateQueryFindOptions( + filterDto: IFilter + ): GeneratedFindOptions { + if (filterDto.filter == null) { filterDto.filter = []; } - if(filterDto.order == null) { + if (filterDto.sort == null) { + filterDto.sort = []; + } + if (filterDto.order == null) { filterDto.order = []; } - + const order = filterDto.order.concat(filterDto.sort); const where = this.generateWhere(filterDto.filter); - const generatedOrder = this.generateOrder(filterDto.order); + const generatedOrder = this.generateOrder(order); + const skip = this.calculateSkip( + filterDto.limit, + filterDto.page, + filterDto.offset, + filterDto.skip + ); + + // if select defined, add select + // if include defined, add include + // if neither select or include defined, add default include + const selectIncludeOptions: IParsedQueryParams = this.generateSelectInclude( + filterDto.select, + this.defaultIncludes + ); + const cursor = this.generateCursor(filterDto.cursor); return { - where: where as TWhereInput, - skip: filterDto.offset, + where: where as TFindManyArgs['where'], + skip: skip, take: filterDto.limit, orderBy: generatedOrder, + select: selectIncludeOptions['select'], + include: selectIncludeOptions['include'], + cursor: cursor, }; } - private generateWhere(filter: Array>): { [p in keyof TWhereInput]?: any } { - const where: { [p in keyof TWhereInput]?: any } = Object.create(null); - for(const filterEntry of filter) { + private calculateSkip( + limit?: number, + page?: number, + offset?: number, + skip?: number + ): number { + if (limit && page) { + return (page - 1) * limit; + } + + if (offset) { + return offset; + } + + if (skip) { + return skip; + } + return 0; + } + + private generateWhere(filter: Array>): { + [p in keyof TFindManyArgs['where']]?: any; + } { + const where: { [p in keyof TFindManyArgs['where']]?: any } = + Object.create(null); + for (const filterEntry of filter) { const fieldName = filterEntry.field; let dbFieldName = this.mapping[filterEntry.field]; - if(dbFieldName == null) { - if(this.allowAllFields && !fieldName.includes('.')) { - dbFieldName = fieldName as unknown as (keyof TWhereInput & string); + if (dbFieldName == null) { + if (this.allowAllFields && !fieldName.includes('.')) { + dbFieldName = fieldName as unknown as keyof TFindManyArgs['where'] & + string; } else { throw new Error(`${fieldName} is not filterable`); } } - if(dbFieldName.length > 0 && dbFieldName[0] === '!') { + if (dbFieldName.length > 0 && dbFieldName[0] === '!') { continue; } const dbFieldNameParts = dbFieldName.split('.'); let currentWhere: any = where; - for(const dbFieldPart of dbFieldNameParts) { - if(currentWhere[dbFieldPart] == null) { + for (const dbFieldPart of dbFieldNameParts) { + if (currentWhere[dbFieldPart] == null) { currentWhere[dbFieldPart] = Object.create(null); } currentWhere = currentWhere[dbFieldPart]; } - Object.assign(currentWhere, this.generateWhereValue(filterEntry.type, filterEntry.value)); + Object.assign( + currentWhere, + this.generateWhereValue(filterEntry.type, filterEntry.value) + ); } return where; } - private generateWhereValue(type: FilterOperationType, value: any): { [p in keyof IntFilter | keyof StringFilter]?: any } { + private generateWhereValue( + type: FilterOperationType, + value: any + ): { [p in keyof IntFilter | keyof StringFilter]?: any } { const queryValue = this.getFormattedQueryValueForType(value, type); - if(type === FilterOperationType.Ilike) { + if (type === FilterOperationType.Ilike) { return { [this.getOpByType(type)]: queryValue, mode: 'insensitive', @@ -74,34 +151,57 @@ export class FilterParser { }; } - private getFormattedQueryValueForType(rawValue: any, type: FilterOperationType): string | number | string[] | number[] | boolean | null { - if(Array.isArray(rawValue)) { - if(rawValue.some(value => typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')) { - throw new Error(`Array filter value must be an Array`); + private getFormattedQueryValueForType( + rawValue: any, + type: FilterOperationType + ): string | number | string[] | number[] | boolean | null { + if (Array.isArray(rawValue)) { + if ( + rawValue.some( + (value) => + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) + ) { + throw new Error( + `Array filter value must be an Array` + ); } - if(type === FilterOperationType.InStrings) return rawValue; - if(type !== FilterOperationType.In) { + if (type === FilterOperationType.InStrings) return rawValue; + if (type !== FilterOperationType.In) { throw new Error(`Filter type ${type} does not support array values`); } - return rawValue.map(v => !isNaN(+v) ? +v : v); + return rawValue.map((v) => (!isNaN(+v) ? +v : v)); } - if(typeof rawValue !== 'string' && typeof rawValue !== 'number' && typeof rawValue !== 'boolean') { + if ( + typeof rawValue !== 'string' && + typeof rawValue !== 'number' && + typeof rawValue !== 'boolean' + ) { throw new Error(`Filter value must be a string, a number or a boolean`); } - if(type === FilterOperationType.EqNull || type === FilterOperationType.NeNull) { + if ( + type === FilterOperationType.EqNull || + type === FilterOperationType.NeNull + ) { // When the operator is of type equal/not equal null: ignore the filter value and set it to null. Otherwise, the value will be taken as a string ('null') return null; } - if(type === FilterOperationType.Eq || type === FilterOperationType.Ne) { + if (type === FilterOperationType.Eq || type === FilterOperationType.Ne) { // If we filter for equality and the value looks like a boolean, then cast it into a boolean - if(rawValue === 'true') return true; + if (rawValue === 'true') return true; else if (rawValue === 'false') return false; } - if(type === FilterOperationType.Like || type === FilterOperationType.EqString || type === FilterOperationType.NeString) { + if ( + type === FilterOperationType.Like || + type === FilterOperationType.EqString || + type === FilterOperationType.NeString + ) { // Never cast this value for a like filter because this only applies to strings return rawValue; } @@ -109,8 +209,10 @@ export class FilterParser { return !isNaN(+rawValue) ? +rawValue : rawValue; } - private getOpByType(type: FilterOperationType): keyof IntFilter | keyof StringFilter { - switch(type) { + private getOpByType( + type: FilterOperationType + ): keyof IntFilter | keyof StringFilter { + switch (type) { case FilterOperationType.Eq: case FilterOperationType.EqNull: case FilterOperationType.EqString: @@ -138,42 +240,118 @@ export class FilterParser { } } - private generateOrder(order: Array>): Array<{ [p in keyof TWhereInput]?: FilterOrder }> { + private generateOrder( + order: Array> + ): Array { const generatedOrder = []; - for(const orderEntry of order) { + for (const orderEntry of order) { const fieldName = orderEntry.field; let dbFieldName = this.mapping[fieldName]; - if(dbFieldName == null) { - if(this.allowAllFields && !fieldName.includes('.')) { - dbFieldName = fieldName as unknown as (keyof TWhereInput & string); + if (dbFieldName == null) { + if (this.allowAllFields && !fieldName.includes('.')) { + dbFieldName = fieldName as unknown as keyof TFindManyArgs['where'] & + string; } else { - throw new Error(`${fieldName} is not sortable`); + throw new Error(`${fieldName} is not orderable`); } } - if(dbFieldName.length > 0 && dbFieldName[0] === '!') { + if (dbFieldName.length > 0 && dbFieldName[0] === '!') { continue; } const dbFieldNameParts = dbFieldName.split('.'); - const sortObjToAdd = Object.create(null); - let currentObj: any = sortObjToAdd; + const orderObjToAdd = Object.create(null); + let currentObj: any = orderObjToAdd; - for(let i = 0; i < dbFieldNameParts.length; i++) { + for (let i = 0; i < dbFieldNameParts.length; i++) { const dbFieldPart = dbFieldNameParts[i]; - if(currentObj[dbFieldPart] == null) { + if (currentObj[dbFieldPart] == null) { currentObj[dbFieldPart] = Object.create(null); } - if(i < dbFieldNameParts.length - 1) { + if (i < dbFieldNameParts.length - 1) { currentObj = currentObj[dbFieldPart]; } else { currentObj[dbFieldPart] = orderEntry.dir; } } - generatedOrder.push(sortObjToAdd); + generatedOrder.push(orderObjToAdd); + } + return generatedOrder as Array<{ + [p in keyof TFindManyArgs['where']]?: FilterOrder; + }>; + } + + private generateCursor( + cursor?: ISingleCursor + ): TFindManyArgs['cursor'] { + if (!cursor) { + return undefined; + } + + const fieldName = cursor.field; + let dbFieldName = this.mapping[fieldName]; + + if (dbFieldName == null) { + if (this.allowAllFields && !fieldName.includes('.')) { + dbFieldName = fieldName as unknown as keyof TFindManyArgs['cursor'] & + string; + } else { + throw new Error(`${fieldName} is not orderable`); + } + } + + if (dbFieldName.length > 0 && dbFieldName[0] === '!') { + return undefined; } - return generatedOrder as Array<{ [p in keyof TWhereInput]?: FilterOrder }>; + + const generatedcursor = {} as unknown as { + [p in keyof TFindManyArgs['cursor']]?: any; + }; + set(generatedcursor, cursor.field, cursor.value); + + return generatedcursor as { + [p in keyof TFindManyArgs['cursor']]?: any; + }; } + + // select parsing + private generateSelectInclude = ( + selectFields?: string[], + defaultIncludes?: string[] + ): TRecursiveField => { + if (!selectFields && (!defaultIncludes || defaultIncludes.length == 0)) { + return { + select: false, + include: false, + }; + } + + if (!selectFields && defaultIncludes) { + const includeQuery: TRecursiveField = {}; + defaultIncludes.forEach((field) => { + field = field.replace('.', '.include.'); + field ? set(includeQuery, field, true) : null; + }); + return { + select: false, + include: includeQuery, + }; + } + + if (selectFields) { + const selectFieldsQuery: TRecursiveField = {}; + selectFields.forEach((field) => { + field = field.replace('.', '.select.'); + field ? set(selectFieldsQuery, field, true) : null; + }); + return { + select: selectFieldsQuery, + include: false, + }; + } + return {}; + }; } diff --git a/prisma-filter/src/filter.pipe.ts b/prisma-filter/src/filter.pipe.ts index d6fb380..b0be9ce 100644 --- a/prisma-filter/src/filter.pipe.ts +++ b/prisma-filter/src/filter.pipe.ts @@ -23,14 +23,31 @@ import { FilterParser } from './filter.parser'; * See filter/filter.parser.ts for FilterParser implementation details. */ @Injectable() -export class FilterPipe implements PipeTransform, IGeneratedFilter> { - private readonly filterParser: FilterParser; +export class FilterPipe< + TDto, + TFindManyArgs extends { + where?: unknown; + select?: unknown; + orderBy?: unknown; + cursor?: unknown; + } +> implements PipeTransform, IGeneratedFilter> +{ + private readonly filterParser: FilterParser; - constructor(mapping: { [p in keyof TDto]?: keyof TWhereInput & string }) { - this.filterParser = new FilterParser(mapping); + constructor( + mapping: { [p in keyof TDto]?: keyof TFindManyArgs['where'] & string }, + allowAllFields = false, + defaultIncludes: string[] = [] + ) { + this.filterParser = new FilterParser( + mapping, + allowAllFields, + defaultIncludes + ); } - public transform(value: IFilter): IGeneratedFilter { + public transform(value: IFilter): IGeneratedFilter { return { ...value, findOptions: this.filterParser.generateQueryFindOptions(value), diff --git a/prisma-filter/tests/allow.all.fields.test.ts b/prisma-filter/tests/allow.all.fields.test.ts index 494e3b3..eeec191 100644 --- a/prisma-filter/tests/allow.all.fields.test.ts +++ b/prisma-filter/tests/allow.all.fields.test.ts @@ -1,12 +1,19 @@ import { FilterOperationType } from '@chax-at/prisma-filter-common'; import { FilterParser } from '../src/filter.parser'; -const filterParser = new FilterParser({ 'user.some.email': 'user.some.email' }, true); +const filterParser = new FilterParser( + { 'user.some.email': 'user.some.email' }, + true +); test('Allow all fields allows all fields + custom fields', () => { const findOptions = filterParser.generateQueryFindOptions({ filter: [ - { field: 'user.some.email', type: FilterOperationType.Eq, value: 'value' }, + { + field: 'user.some.email', + type: FilterOperationType.Eq, + value: 'value', + }, { field: 'someRandomKey', type: FilterOperationType.Eq, value: 'value2' }, ], order: [ @@ -30,13 +37,23 @@ test('Allow all fields allows all fields + custom fields', () => { }); test('Allow all fields does not allow filtering field with dot in the name', () => { - expect(() => filterParser.generateQueryFindOptions({ - filter: [{ field: 'user.some.password', type: FilterOperationType.Eq, value: 'value' }], - })).toThrow(`user.some.password is not filterable`); + expect(() => + filterParser.generateQueryFindOptions({ + filter: [ + { + field: 'user.some.password', + type: FilterOperationType.Eq, + value: 'value', + }, + ], + }) + ).toThrow(`user.some.password is not filterable`); }); -test('Allow all fields does not allow sorting field with dot in the name', () => { - expect(() => filterParser.generateQueryFindOptions({ - order: [{ field: 'user.some.password', dir: 'asc' }], - })).toThrow(`user.some.password is not sortable`); +test('Allow all fields does not allow ordering field with dot in the name', () => { + expect(() => + filterParser.generateQueryFindOptions({ + order: [{ field: 'user.some.password', dir: 'asc' }], + }) + ).toThrow(`user.some.password is not orderable`); }); diff --git a/prisma-filter/tests/complex.filter.test.ts b/prisma-filter/tests/complex.filter.test.ts index 66150d4..7ee226f 100644 --- a/prisma-filter/tests/complex.filter.test.ts +++ b/prisma-filter/tests/complex.filter.test.ts @@ -1,13 +1,24 @@ import { FilterOperationType } from '@chax-at/prisma-filter-common'; import { FilterParser } from '../src/filter.parser'; -const filterParser = new FilterParser({ 'user.some.email': 'user.some.email', 'order.someValue': 'order.someValue' }); +const filterParser = new FilterParser({ + 'user.some.email': 'user.some.email', + 'order.someValue': 'order.someValue', +}); test('Complex eq', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'user.some.email', type: FilterOperationType.Eq, value: 'value' }], + filter: [ + { + field: 'user.some.email', + type: FilterOperationType.Eq, + value: 'value', + }, + ], + }); + expect(findOptions.where.user).toEqual({ + some: { email: { equals: 'value' } }, }); - expect(findOptions.where.user).toEqual({ some: { email: { equals: 'value' } } }); }); test('Complex order', () => { @@ -17,7 +28,6 @@ test('Complex order', () => { expect(findOptions.orderBy[0]).toEqual({ order: { someValue: 'desc' } }); }); - test('Complex order', () => { const findOptions = filterParser.generateQueryFindOptions({ order: [{ field: 'order.someValue', dir: 'desc' }], diff --git a/prisma-filter/tests/error.test.ts b/prisma-filter/tests/error.test.ts index 4d0ddf4..fd4aa48 100644 --- a/prisma-filter/tests/error.test.ts +++ b/prisma-filter/tests/error.test.ts @@ -3,31 +3,57 @@ import { FilterParser } from '../src/filter.parser'; const filterParser = new FilterParser({ test: 'test' }); test('Eq invalid field', () => { - expect(() => filterParser.generateQueryFindOptions({ - filter: [{ field: 'nonExistant', type: FilterOperationType.Eq, value: '13.5' }], - })).toThrow(`nonExistant is not filterable`); + expect(() => + filterParser.generateQueryFindOptions({ + filter: [ + { field: 'nonExistant', type: FilterOperationType.Eq, value: '13.5' }, + ], + }) + ).toThrow(`nonExistant is not filterable`); }); -test('Sort invalid field', () => { - expect(() => filterParser.generateQueryFindOptions({ - order: [{ field: 'nonExistant', dir: 'asc' }], - })).toThrow(`nonExistant is not sortable`); +test('Order invalid field', () => { + expect(() => + filterParser.generateQueryFindOptions({ + order: [{ field: 'nonExistant', dir: 'asc' }], + }) + ).toThrow(`nonExistant is not orderable`); }); test('Eq array', () => { - expect(() => filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.Eq, value: ['13.5', '12'] }], - })).toThrow(`Filter type = does not support array values`); + expect(() => + filterParser.generateQueryFindOptions({ + filter: [ + { field: 'test', type: FilterOperationType.Eq, value: ['13.5', '12'] }, + ], + }) + ).toThrow(`Filter type = does not support array values`); }); test('Should not allow an object as filter', () => { - expect(() => filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.Eq, value: { injections: { are: 'bad' } } }], - })).toThrow(); + expect(() => + filterParser.generateQueryFindOptions({ + filter: [ + { + field: 'test', + type: FilterOperationType.Eq, + value: { injections: { are: 'bad' } }, + }, + ], + }) + ).toThrow(); }); test('Should not allow an object array as filter', () => { - expect(() => filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.InStrings, value: [{ injections: { are: 'bad' } }] }], - })).toThrow(); + expect(() => + filterParser.generateQueryFindOptions({ + filter: [ + { + field: 'test', + type: FilterOperationType.InStrings, + value: [{ injections: { are: 'bad' } }], + }, + ], + }) + ).toThrow(); }); diff --git a/prisma-filter/tests/filter.builder.test.ts b/prisma-filter/tests/filter.builder.test.ts index 6abd0a1..bff483f 100644 --- a/prisma-filter/tests/filter.builder.test.ts +++ b/prisma-filter/tests/filter.builder.test.ts @@ -1,4 +1,7 @@ -import { FilterBuilder, FilterOperationType } from '@chax-at/prisma-filter-common'; +import { + FilterBuilder, + FilterOperationType, +} from '@chax-at/prisma-filter-common'; test('Builds a simple filter query string', () => { const queryString = FilterBuilder.buildFilterQueryString({ @@ -6,17 +9,22 @@ test('Builds a simple filter query string', () => { offset: 30, filter: [ { field: 'field1', type: FilterOperationType.NeNull, value: 'val1' }, - { field: 'field2', type: FilterOperationType.InStrings, value: ['str1', 'str2'] }, + { + field: 'field2', + type: FilterOperationType.InStrings, + value: ['str1', 'str2'], + }, ], order: [ { field: 'field1', dir: 'asc' }, { field: 'field2', dir: 'desc' }, ], }); - expect(queryString).toEqual('?offset=30&limit=20&filter[0][field]=field1&filter[0][type]=nenull&filter[0][value]=val1&filter[1][field]=field2&filter[1][type]=instrings&filter[1][value][0]=str1&filter[1][value][1]=str2&order[0][field]=field1&order[0][dir]=asc&order[1][field]=field2&order[1][dir]=desc'); + expect(queryString).toEqual( + '?offset=30&limit=20&filter[0][field]=field1&filter[0][type]=nenull&filter[0][value]=val1&filter[1][field]=field2&filter[1][type]=instrings&filter[1][value][0]=str1&filter[1][value][1]=str2&order[0][field]=field1&order[0][dir]=asc&order[1][field]=field2&order[1][dir]=desc' + ); }); - test('Builds a simple filter', () => { const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. .addFilter('name', FilterOperationType.Ilike, '%Max%') // ...filter by name ilike '%Max%' @@ -25,9 +33,89 @@ test('Builds a simple filter', () => { .requestPage(3); // ...return the third page const filter = filterBuilder.toFilter(); // get the resulting IFilter expect(filter).toEqual({ - filter: [{ field: 'name', type: FilterOperationType.Ilike, value: '%Max%'}], + filter: [ + { field: 'name', type: FilterOperationType.Ilike, value: '%Max%' }, + ], order: [{ field: 'name', dir: 'asc' }], limit: 40, offset: 80, }); }); + +test('Builds a simple sort filter', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .addSortBy('name', 'asc'); // ...order by name, asc + const filter = filterBuilder.toFilter(); // get the resulting IFilter + expect(filter).toEqual({ + sort: [{ field: 'name', dir: 'asc' }], + }); + expect(filterBuilder.toQueryString()).toEqual( + '?sort[0][field]=name&sort[0][dir]=asc' + ); +}); + +test('Builds a simple cursor filter', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .addCursor('id', 3) + .addSortBy('id', 'asc') // ...order by name, asc + .limitTo(2) + .skip(0); + const filter = filterBuilder.toFilter(); // get the resulting IFilter + console.log(filter); + expect(filter).toEqual({ + filter: [], + cursor: { field: 'id', value: 3 }, + sort: [{ field: 'id', dir: 'asc' }], + limit: 2, + skip: 0, + }); + console.log(filterBuilder.toQueryString()); + expect(filterBuilder.toQueryString()).toEqual( + '?limit=2&skip=0&sort[0][field]=id&sort[0][dir]=asc&cursor[field]=id&cursor[value]=3' + ); +}); + +test('Builds a simple query with selected fields', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .addSelectFields(['firstName', 'lastName']); + const filter = filterBuilder.toFilter(); // get the resulting IFilter + expect(filter).toEqual({ + select: ['firstName', 'lastName'], + }); + expect(filterBuilder.toQueryString()).toEqual('?select=firstName,lastName'); +}); + +test('Builds a simple query with multiple selected field additions', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .addSelectFields(['firstName', 'lastName']) + .addSelectFields(['email']); + const filter = filterBuilder.toFilter(); // get the resulting IFilter + expect(filter).toEqual({ + select: ['firstName', 'lastName', 'email'], + }); + expect(filterBuilder.toQueryString()).toEqual( + '?select=firstName,lastName,email' + ); +}); + +test('Builds a simple query with page param', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .limitTo(50) + .setPage(3); + const filter = filterBuilder.toFilter(); // get the resulting IFilter + expect(filter).toEqual({ + limit: 50, + page: 3, + }); + expect(filterBuilder.toQueryString()).toEqual('?limit=50&page=3'); +}); + +test('Builds a simple query with skip param', () => { + const filterBuilder = new FilterBuilder() // create a new filter builder for User entities.. + .skip(5); + const filter = filterBuilder.toFilter(); // get the resulting IFilter + expect(filter).toEqual({ + skip: 5, + }); + expect(filterBuilder.toQueryString()).toEqual('?skip=5'); +}); diff --git a/prisma-filter/tests/filter.order.test.ts b/prisma-filter/tests/filter.order.test.ts index 1635d13..f0b4866 100644 --- a/prisma-filter/tests/filter.order.test.ts +++ b/prisma-filter/tests/filter.order.test.ts @@ -1,6 +1,9 @@ import { FilterParser } from '../src/filter.parser'; -const filterParser = new FilterParser({ test: 'test', test2: 'test2' }); +const filterParser = new FilterParser({ + test: 'test', + test2: 'test2', +}); test('Order asc', () => { const findOptions = filterParser.generateQueryFindOptions({ @@ -11,7 +14,28 @@ test('Order asc', () => { test('Order multiple fields', () => { const findOptions = filterParser.generateQueryFindOptions({ - order: [{ field: 'test', dir: 'asc' }, { field: 'test2', dir: 'desc' }], + order: [ + { field: 'test', dir: 'asc' }, + { field: 'test2', dir: 'desc' }, + ], + }); + expect(findOptions.orderBy[0]).toEqual({ test: 'asc' }); + expect(findOptions.orderBy[1]).toEqual({ test2: 'desc' }); +}); + +test('Sort asc', () => { + const findOptions = filterParser.generateQueryFindOptions({ + sort: [{ field: 'test', dir: 'asc' }], + }); + expect(findOptions.orderBy[0]).toEqual({ test: 'asc' }); +}); + +test('Sort multiple fields', () => { + const findOptions = filterParser.generateQueryFindOptions({ + sort: [ + { field: 'test', dir: 'asc' }, + { field: 'test2', dir: 'desc' }, + ], }); expect(findOptions.orderBy[0]).toEqual({ test: 'asc' }); expect(findOptions.orderBy[1]).toEqual({ test2: 'desc' }); diff --git a/prisma-filter/tests/filter.type.conversion.test.ts b/prisma-filter/tests/filter.type.conversion.test.ts index 8a0dcce..57b78ba 100644 --- a/prisma-filter/tests/filter.type.conversion.test.ts +++ b/prisma-filter/tests/filter.type.conversion.test.ts @@ -33,7 +33,9 @@ test('Ne false', () => { test('In number array', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.In, value: ['1', '2', '3.5'] }], + filter: [ + { field: 'test', type: FilterOperationType.In, value: ['1', '2', '3.5'] }, + ], }); expect(findOptions.where.test).toEqual({ in: [1, 2, 3.5] }); }); @@ -42,35 +44,49 @@ test('In number array', () => { test('Eq number string', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.EqString, value: '13.5' }], + filter: [ + { field: 'test', type: FilterOperationType.EqString, value: '13.5' }, + ], }); expect(findOptions.where.test).toEqual({ equals: '13.5' }); }); test('Ne number string', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.NeString, value: '13.5' }], + filter: [ + { field: 'test', type: FilterOperationType.NeString, value: '13.5' }, + ], }); expect(findOptions.where.test).toEqual({ not: '13.5' }); }); test('Eq true string', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.EqString, value: 'true' }], + filter: [ + { field: 'test', type: FilterOperationType.EqString, value: 'true' }, + ], }); expect(findOptions.where.test).toEqual({ equals: 'true' }); }); test('Ne false string', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.NeString, value: 'false' }], + filter: [ + { field: 'test', type: FilterOperationType.NeString, value: 'false' }, + ], }); expect(findOptions.where.test).toEqual({ not: 'false' }); }); test('In number array string', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.InStrings, value: ['1', '2', '3.5'] }], + filter: [ + { + field: 'test', + type: FilterOperationType.InStrings, + value: ['1', '2', '3.5'], + }, + ], }); expect(findOptions.where.test).toEqual({ in: ['1', '2', '3.5'] }); }); diff --git a/prisma-filter/tests/filter.type.test.ts b/prisma-filter/tests/filter.type.test.ts index 2f3f9ed..e879840 100644 --- a/prisma-filter/tests/filter.type.test.ts +++ b/prisma-filter/tests/filter.type.test.ts @@ -54,49 +54,70 @@ test('Like', () => { test('Ilike', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.Ilike, value: '%val%' }], + filter: [ + { field: 'test', type: FilterOperationType.Ilike, value: '%val%' }, + ], + }); + expect(findOptions.where.test).toEqual({ + contains: '%val%', + mode: 'insensitive', }); - expect(findOptions.where.test).toEqual({ contains: '%val%', mode: 'insensitive' }); }); test('In', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.In, value: ['val1', 'val2'] }], + filter: [ + { field: 'test', type: FilterOperationType.In, value: ['val1', 'val2'] }, + ], }); expect(findOptions.where.test).toEqual({ in: ['val1', 'val2'] }); }); test('InStrings', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.InStrings, value: ['val1', 'val2'] }], + filter: [ + { + field: 'test', + type: FilterOperationType.InStrings, + value: ['val1', 'val2'], + }, + ], }); expect(findOptions.where.test).toEqual({ in: ['val1', 'val2'] }); }); test('EqNull', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.EqNull, value: 'irrelevant' }], + filter: [ + { field: 'test', type: FilterOperationType.EqNull, value: 'irrelevant' }, + ], }); expect(findOptions.where.test).toEqual({ equals: null }); }); test('NeNull', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.NeNull, value: 'irrelevant' }], + filter: [ + { field: 'test', type: FilterOperationType.NeNull, value: 'irrelevant' }, + ], }); expect(findOptions.where.test).toEqual({ not: null }); }); test('EqString', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.EqString, value: 'value' }], + filter: [ + { field: 'test', type: FilterOperationType.EqString, value: 'value' }, + ], }); expect(findOptions.where.test).toEqual({ equals: 'value' }); }); test('NeString', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: 'test', type: FilterOperationType.NeString, value: 'value' }], + filter: [ + { field: 'test', type: FilterOperationType.NeString, value: 'value' }, + ], }); expect(findOptions.where.test).toEqual({ not: 'value' }); }); diff --git a/prisma-filter/tests/virtual.field.test.ts b/prisma-filter/tests/virtual.field.test.ts index 83cadbd..c822033 100644 --- a/prisma-filter/tests/virtual.field.test.ts +++ b/prisma-filter/tests/virtual.field.test.ts @@ -1,11 +1,15 @@ import { FilterOperationType } from '@chax-at/prisma-filter-common'; import { FilterParser } from '../src/filter.parser'; -const filterParser = new FilterParser({ '!virtualField': '!virtualField' }); +const filterParser = new FilterParser({ + '!virtualField': '!virtualField', +}); test('Virtual Field is ignored', () => { const findOptions = filterParser.generateQueryFindOptions({ - filter: [{ field: '!virtualField', type: FilterOperationType.Eq, value: 'value' }], + filter: [ + { field: '!virtualField', type: FilterOperationType.Eq, value: 'value' }, + ], order: [{ field: '!virtualField', dir: 'asc' }], }); expect(findOptions.orderBy.length).toBe(0);