From efaa4441f35e142dfb055d4206ba92df6797b2d3 Mon Sep 17 00:00:00 2001 From: Michel Betancourt Date: Thu, 8 Oct 2020 02:39:53 -0400 Subject: [PATCH] feat(filter): allow use an array in filter.fields Signed-off-by: Michel Betancourt --- docs/site/Fields-filter.md | 17 ++-- .../__tests__/acceptance/todo.acceptance.ts | 11 +++ .../acceptance/query-builder.acceptance.ts | 12 ++- packages/filter/src/query.ts | 15 +++- .../unit/decorators/query.decorator.unit.ts | 69 +++++++++++++-- .../src/__tests__/unit/filter-schema.unit.ts | 87 +++++++++++++++---- .../__tests__/unit/filter-json-schema.unit.ts | 55 ++++++++++++ .../src/filter-json-schema.ts | 34 +++++--- 8 files changed, 252 insertions(+), 48 deletions(-) diff --git a/docs/site/Fields-filter.md b/docs/site/Fields-filter.md index 9e74d236cbe1..5824c2586fee 100644 --- a/docs/site/Fields-filter.md +++ b/docs/site/Fields-filter.md @@ -21,14 +21,21 @@ Where: - `` signifies either `true` or `false` Boolean literal. Use `true` to include the property or `false` to exclude it from results. +also can be used an array of strings with the properties + +
+{ fields: [propertyName, propertyName, ... ] }
+
+ By default, queries return all model properties in results. However, if you -specify at least one fields filter with a value of `true`, then by default the -query will include **only** those you specifically include with filters. +specify at least one fields filter with a value of `true` or put it in the +array, then by default the query will include **only** those you specifically +include with filters. ### REST API
-filter[fields][propertyName]=true|false&filter[fields][propertyName]=true|false...
+filter[fields]=propertyName&filter[fields]=propertyName...
 
Note that to include more than one field in REST, use multiple filters. @@ -47,14 +54,14 @@ fields, you can specify all required properties as: {% include code-caption.html content="Node.js API" %} ```ts -await customerRepository.find({fields: {name: true, address: true}}); +await customerRepository.find({fields: ['name', 'address']}); ``` {% include code-caption.html content="REST" %} Its equivalent stringified JSON format: -`/customers?filter={"fields":{"name":true,"address":true}}` +`/customers?filter={"fields":["name","address"]}` Returns: diff --git a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts index d807196342ae..00909dd6e9c0 100644 --- a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts @@ -293,6 +293,17 @@ describe('TodoApplication', () => { ]); }); + it('queries todos with exploded array-based fields filter', async () => { + await givenTodoInstance({ + title: 'go to sleep', + isComplete: false, + }); + await client + .get('/todos') + .query('filter[fields][0]=title') + .expect(200, toJSON([{title: 'go to sleep'}])); + }); + it('queries todos with exploded array-based order filter', async () => { const todoInProgress = await givenTodoInstance({ title: 'go to sleep', diff --git a/packages/filter/src/__tests__/acceptance/query-builder.acceptance.ts b/packages/filter/src/__tests__/acceptance/query-builder.acceptance.ts index f74a8aa93c41..724f5b4486c6 100644 --- a/packages/filter/src/__tests__/acceptance/query-builder.acceptance.ts +++ b/packages/filter/src/__tests__/acceptance/query-builder.acceptance.ts @@ -301,7 +301,17 @@ describe('FilterBuilder', () => { }, }); }); - + it('builds a filter object with array', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.fields(['a', 'b']); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + fields: { + a: true, + b: true, + }, + }); + }); it('builds a filter object with limit/offset', () => { const filterBuilder = new FilterBuilder(); filterBuilder.limit(10).offset(5); diff --git a/packages/filter/src/query.ts b/packages/filter/src/query.ts index 209876ef4eca..386ed5faa20f 100644 --- a/packages/filter/src/query.ts +++ b/packages/filter/src/query.ts @@ -159,7 +159,9 @@ export type Order = {[P in keyof MT]: Direction}; * Example: * `{afieldname: true}` */ -export type Fields = {[P in keyof MT]?: boolean}; +export type Fields = + | {[P in keyof MT]?: boolean} + | Extract[]; /** * Inclusion of related items @@ -560,16 +562,21 @@ export class FilterBuilder { * @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 | (keyof MT)[] | keyof MT)[]): this { + fields(...f: (Fields | Extract)[]): 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 as (keyof MT)[]).forEach(i => (fields[i] = true)); + field.forEach(i => (fields[i] = true)); } else if (typeof field === 'string') { - fields[field as keyof MT] = true; + fields[field] = true; } else { Object.assign(fields, field); } diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/query.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/query.decorator.unit.ts index 4c143b7d5bdd..bb3797db7286 100644 --- a/packages/openapi-v3/src/__tests__/unit/decorators/query.decorator.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/decorators/query.decorator.unit.ts @@ -35,10 +35,27 @@ describe('sugar decorators for filter and where', () => { 'x-typescript-type': '@loopback/repository#Filter', properties: { fields: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['name'], + type: 'string', + example: 'name', + }, + }, + ], title: 'MyModel.Fields', - type: 'object', - properties: {name: {type: 'boolean'}}, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, @@ -76,10 +93,27 @@ describe('sugar decorators for filter and where', () => { 'x-typescript-type': '@loopback/repository#Filter', properties: { fields: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['name'], + type: 'string', + example: 'name', + }, + }, + ], title: 'MyModel.Fields', - type: 'object', - properties: {name: {type: 'boolean'}}, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, @@ -124,10 +158,27 @@ describe('sugar decorators for filter and where', () => { 'x-typescript-type': '@loopback/repository#Filter', properties: { fields: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['name'], + type: 'string', + example: 'name', + }, + }, + ], title: 'MyModel.Fields', - type: 'object', - properties: {name: {type: 'boolean'}}, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, diff --git a/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts b/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts index 9c398c42d274..e92aad2104aa 100644 --- a/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts @@ -30,13 +30,30 @@ describe('filterSchema', () => { additionalProperties: true, }, fields: { - type: 'object', + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'boolean', + }, + age: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['id', 'age'], + type: 'string', + example: 'id', + }, + }, + ], title: 'my-user-model.Fields', - properties: { - id: {type: 'boolean'}, - age: {type: 'boolean'}, - }, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, @@ -57,13 +74,30 @@ describe('filterSchema', () => { 'x-typescript-type': '@loopback/repository#Filter', properties: { fields: { - type: 'object', + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'boolean', + }, + age: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['id', 'age'], + type: 'string', + example: 'id', + }, + }, + ], title: 'my-user-model.Fields', - properties: { - id: {type: 'boolean'}, - age: {type: 'boolean'}, - }, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, @@ -98,13 +132,30 @@ describe('filterSchema', () => { additionalProperties: true, }, fields: { - type: 'object', + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'boolean', + }, + age: { + type: 'boolean', + }, + }, + }, + { + type: 'array', + uniqueItems: true, + items: { + enum: ['id', 'age'], + type: 'string', + example: 'id', + }, + }, + ], title: 'CustomUserModel.Fields', - properties: { - id: {type: 'boolean'}, - age: {type: 'boolean'}, - }, - additionalProperties: false, }, offset: {type: 'integer', minimum: 0}, limit: {type: 'integer', minimum: 1, example: 100}, diff --git a/packages/repository-json-schema/src/__tests__/unit/filter-json-schema.unit.ts b/packages/repository-json-schema/src/__tests__/unit/filter-json-schema.unit.ts index 19612fcf9bd3..9e510a7aa36f 100644 --- a/packages/repository-json-schema/src/__tests__/unit/filter-json-schema.unit.ts +++ b/packages/repository-json-schema/src/__tests__/unit/filter-json-schema.unit.ts @@ -25,6 +25,7 @@ import { describe('getFilterJsonSchemaFor', () => { let ajv: Ajv.Ajv; let customerFilterSchema: JsonSchema; + let dynamicCustomerFilterSchema: JsonSchema; let customerFilterExcludingWhereSchema: JsonSchema; let customerFilterExcludingIncludeSchema: JsonSchema; let orderFilterSchema: JsonSchema; @@ -32,6 +33,7 @@ describe('getFilterJsonSchemaFor', () => { beforeEach(() => { ajv = new Ajv(); customerFilterSchema = getFilterJsonSchemaFor(Customer); + dynamicCustomerFilterSchema = getFilterJsonSchemaFor(DynamicCustomer); customerFilterExcludingWhereSchema = getFilterJsonSchemaFor(Customer, { exclude: ['where'], }); @@ -127,6 +129,51 @@ describe('getFilterJsonSchemaFor', () => { ]); }); + it('allows free-form properties in "fields" for non-strict models"', () => { + const filter = {fields: ['test', 'id']}; + ajv.validate(dynamicCustomerFilterSchema, filter); + expect(ajv.errors ?? []).to.be.empty(); + }); + + it('allows only defined properties in "fields" for strict models"', () => { + const filter = {fields: ['test']}; + ajv.validate(customerFilterSchema, filter); + expect(ajv.errors ?? []).to.containDeep([ + { + keyword: 'enum', + dataPath: '.fields[0]', + params: {allowedValues: ['id', 'name']}, + message: 'should be equal to one of the allowed values', + }, + ]); + }); + + it('rejects "fields" with duplicated items for strict models', () => { + const filter = {fields: ['id', 'id']}; + ajv.validate(customerFilterSchema, filter); + expect(ajv.errors ?? []).to.containDeep([ + { + keyword: 'uniqueItems', + dataPath: '.fields', + message: + 'should NOT have duplicate items (items ## 1 and 0 are identical)', + }, + ]); + }); + + it('rejects "fields" with duplicated items for non-strict models', () => { + const filter = {fields: ['test', 'test']}; + ajv.validate(dynamicCustomerFilterSchema, filter); + expect(ajv.errors ?? []).to.containDeep([ + { + keyword: 'uniqueItems', + dataPath: '.fields', + message: + 'should NOT have duplicate items (items ## 1 and 0 are identical)', + }, + ]); + }); + it('describes "include" as an array for models with relations', () => { const filter = {include: 'invalid-include'}; ajv.validate(customerFilterSchema, filter); @@ -499,3 +546,11 @@ class Customer extends Entity { @hasMany(() => Order) orders?: Order[]; } + +@model({ + settings: {strict: false}, +}) +class DynamicCustomer extends Entity { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} diff --git a/packages/repository-json-schema/src/filter-json-schema.ts b/packages/repository-json-schema/src/filter-json-schema.ts index 2075fbabe685..7b023de20c1f 100644 --- a/packages/repository-json-schema/src/filter-json-schema.ts +++ b/packages/repository-json-schema/src/filter-json-schema.ts @@ -197,20 +197,32 @@ export function getFieldsJsonSchemaFor( modelCtor: typeof Model, options: FilterSchemaOptions = {}, ): JsonSchema { - const schema: JsonSchema = { - ...(options.setTitle !== false && { - title: `${modelCtor.modelName}.Fields`, - }), - type: 'object', + const schema: JsonSchema = {oneOf: []}; + if (options.setTitle !== false) { + schema.title = `${modelCtor.modelName}.Fields`; + } - properties: Object.assign( + const properties = Object.keys(modelCtor.definition.properties); + const additionalProperties = modelCtor.definition.settings.strict === false; + + schema.oneOf?.push({ + type: 'object', + properties: properties.reduce( + (prev, crr) => ({...prev, [crr]: {type: 'boolean'}}), {}, - ...Object.keys(modelCtor.definition.properties).map(k => ({ - [k]: {type: 'boolean'}, - })), ), - additionalProperties: modelCtor.definition.settings.strict === false, - }; + additionalProperties, + }); + + schema.oneOf?.push({ + type: 'array', + items: { + type: 'string', + enum: properties.length && !additionalProperties ? properties : undefined, + examples: properties, + }, + uniqueItems: true, + }); return schema; }