diff --git a/docs/site/Controller-generator.md b/docs/site/Controller-generator.md index 33d41976519e..5290927e301a 100644 --- a/docs/site/Controller-generator.md +++ b/docs/site/Controller-generator.md @@ -100,6 +100,7 @@ import { Count, CountSchema, Filter, + FilterExcludingWhere, repository, Where } from '@loopback/repository'; @@ -152,7 +153,7 @@ export class TodoController { }, }) async count( - @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, + @param.where(Todo) where?: Where, ): Promise { return this.todoRepository.count(where); } @@ -170,7 +171,7 @@ export class TodoController { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(Todo)) + @param.filter(Todo) filter?: Filter, ): Promise { return this.todoRepository.find(filter); @@ -193,7 +194,7 @@ export class TodoController { }, }) todo: Partial - @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, + @param.where(Todo) where?: Where, ): Promise { return this.todoRepository.updateAll(todo, where); } @@ -212,7 +213,7 @@ export class TodoController { }) async findById( @param.path.number('id') id: number, - @param.query.object('filter', getFilterSchemaFor(Todo)) filter?: Filter + @param.filter(Todo, {exclude: 'where'}) filter?: FilterExcludingWhere ): Promise { return this.todoRepository.findById(id, filter); } diff --git a/docs/site/Sequence.md b/docs/site/Sequence.md index 7854485de537..f5378e4131fc 100644 --- a/docs/site/Sequence.md +++ b/docs/site/Sequence.md @@ -241,7 +241,7 @@ from the path object. }) async findById( @param.path.string('id') id: string, - @param.query.object('filter', getFilterSchemaFor(Note)) filter?: Filter + @param.filter(Note, {exclude: 'where'}) filter?: FilterExcludingWhere ): Promise { return this.noteRepository.findById(id, filter); } diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index f3a63b7f7175..33fc59ff5b91 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -794,3 +794,63 @@ This decorator does not affect the top-level `tags` section defined in the [OpenAPI Tag Object specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#tag-object). This decorator only affects the spec partial generated at the class level. You may find that your final tags also include a tag for the controller name. + +## Shortcuts for Filter and Where params + +CRUD APIs often expose REST endpoints that take `filter` and `where` query +parameters. For example: + +```ts +class TodoController { + async find( + @param.query.object('filter', getFilterSchemaFor(Todo)) + filter?: Filter, + ): Promise { + return this.todoRepository.find(filter); + } + + async findById( + @param.path.number('id') id: number, + @param.query.object('filter', getFilterSchemaFor(Todo)) + filter?: Filter, + ): Promise { + return this.todoRepository.findById(id, filter); + } + + async count( + @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, + ): Promise { + return this.todoRepository.count(where); + } +} +``` + +To simplify the parameter decoration for `filter` and `where`, we introduce two +sugar decorators: + +- `@param.filter`: For a `filter` query parameter +- `@param.where`: For a `where` query parameter + +Now the code from above can be refined as follows: + +```ts +class TodoController { + async find( + @param.filter(Todo) + filter?: Filter, + ): Promise { + return this.todoRepository.find(filter); + } + + async findById( + @param.path.number('id') id: number, + @param.filter(Todo, {exclude: 'where'}) filter?: FilterExcludingWhere, + ): Promise { + return this.todoRepository.findById(id, filter); + } + + async count(@param.where(Todo) where?: Where): Promise { + return this.todoRepository.count(where); + } +} +``` diff --git a/examples/express-composition/src/controllers/note.controller.ts b/examples/express-composition/src/controllers/note.controller.ts index ff9c604cc03a..0a312c6b4d31 100644 --- a/examples/express-composition/src/controllers/note.controller.ts +++ b/examples/express-composition/src/controllers/note.controller.ts @@ -13,9 +13,7 @@ import { import { del, get, - getFilterSchemaFor, getModelSchemaRef, - getWhereSchemaFor, param, patch, post, @@ -60,9 +58,7 @@ export class NoteController { }, }, }) - async count( - @param.query.object('where', getWhereSchemaFor(Note)) where?: Where, - ): Promise { + async count(@param.where(Note) where?: Where): Promise { return this.noteRepository.count(where); } @@ -79,7 +75,7 @@ export class NoteController { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(Note)) + @param.filter(Note) filter?: Filter, ): Promise { return this.noteRepository.find(filter); @@ -102,7 +98,7 @@ export class NoteController { }, }) note: Partial, - @param.query.object('where', getWhereSchemaFor(Note)) where?: Where, + @param.where(Note) where?: Where, ): Promise { return this.noteRepository.updateAll(note, where); } diff --git a/examples/todo-list/src/controllers/todo-list-todo.controller.ts b/examples/todo-list/src/controllers/todo-list-todo.controller.ts index 7607c85104e2..dfe3e68e95ee 100644 --- a/examples/todo-list/src/controllers/todo-list-todo.controller.ts +++ b/examples/todo-list/src/controllers/todo-list-todo.controller.ts @@ -14,7 +14,6 @@ import { del, get, getModelSchemaRef, - getWhereSchemaFor, param, patch, post, @@ -91,7 +90,7 @@ export class TodoListTodoController { }, }) todo: Partial, - @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, + @param.where(Todo) where?: Where, ): Promise { return this.todoListRepo.todos(id).patch(todo, where); } @@ -106,7 +105,7 @@ export class TodoListTodoController { }) async delete( @param.path.number('id') id: number, - @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, + @param.where(Todo) where?: Where, ): Promise { return this.todoListRepo.todos(id).delete(where); } diff --git a/examples/todo-list/src/controllers/todo-list.controller.ts b/examples/todo-list/src/controllers/todo-list.controller.ts index 4108360ae6f7..50e4041aef2a 100644 --- a/examples/todo-list/src/controllers/todo-list.controller.ts +++ b/examples/todo-list/src/controllers/todo-list.controller.ts @@ -7,6 +7,7 @@ import { Count, CountSchema, Filter, + FilterExcludingWhere, repository, Where, } from '@loopback/repository'; @@ -15,7 +16,6 @@ import { get, getFilterSchemaFor, getModelSchemaRef, - getWhereSchemaFor, param, patch, post, @@ -63,7 +63,7 @@ export class TodoListController { }, }) async count( - @param.query.object('where', getWhereSchemaFor(TodoList)) + @param.where(TodoList) where?: Where, ): Promise { return this.todoListRepository.count(where); @@ -85,7 +85,7 @@ export class TodoListController { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(TodoList)) + @param.filter(TodoList) filter?: Filter, ): Promise { return this.todoListRepository.find(filter); @@ -108,7 +108,7 @@ export class TodoListController { }, }) todoList: Partial, - @param.query.object('where', getWhereSchemaFor(TodoList)) + @param.where(TodoList) where?: Where, ): Promise { return this.todoListRepository.updateAll(todoList, where); @@ -128,8 +128,11 @@ export class TodoListController { }) async findById( @param.path.number('id') id: number, - @param.query.object('filter', getFilterSchemaFor(TodoList)) - filter?: Filter, + @param.query.object( + 'filter', + getFilterSchemaFor(TodoList, {exclude: 'where'}), + ) + filter?: FilterExcludingWhere, ): Promise { return this.todoListRepository.findById(id, filter); } diff --git a/examples/todo-list/src/controllers/todo.controller.ts b/examples/todo-list/src/controllers/todo.controller.ts index 3d8c76a24614..9ef281c9966a 100644 --- a/examples/todo-list/src/controllers/todo.controller.ts +++ b/examples/todo-list/src/controllers/todo.controller.ts @@ -7,7 +7,6 @@ import {Filter, repository} from '@loopback/repository'; import { del, get, - getFilterSchemaFor, getModelSchemaRef, param, patch, @@ -58,7 +57,7 @@ export class TodoController { }) async findTodoById( @param.path.number('id') id: number, - @param.query.object('filter', getFilterSchemaFor(Todo)) + @param.filter(Todo) filter?: Filter, ): Promise { return this.todoRepository.findById(id, filter); @@ -80,7 +79,7 @@ export class TodoController { }, }) async findTodos( - @param.query.object('filter', getFilterSchemaFor(Todo)) + @param.filter(Todo) filter?: Filter, ): Promise { return this.todoRepository.find(filter); diff --git a/examples/todo/src/controllers/todo.controller.ts b/examples/todo/src/controllers/todo.controller.ts index 69d8f9d9937f..29ead67a017b 100644 --- a/examples/todo/src/controllers/todo.controller.ts +++ b/examples/todo/src/controllers/todo.controller.ts @@ -8,7 +8,6 @@ import {Filter, repository} from '@loopback/repository'; import { del, get, - getFilterSchemaFor, getModelSchemaRef, HttpErrors, param, @@ -90,7 +89,7 @@ export class TodoController { }, }) async findTodos( - @param.query.object('filter', getFilterSchemaFor(Todo)) + @param.filter(Todo) filter?: Filter, ): Promise { return this.todoRepository.find(filter); diff --git a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs index 92fe6ef3fd6b..3d2aaaa159b6 100644 --- a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs +++ b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs @@ -2,6 +2,7 @@ import { Count, CountSchema, Filter, + FilterExcludingWhere, repository, Where, } from '@loopback/repository'; @@ -59,7 +60,7 @@ export class <%= className %>Controller { }, }) async count( - @param.query.object('where', getWhereSchemaFor(<%= modelName %>)) where?: Where<<%= modelName %>>, + @param.where(<%= modelName %>) where?: Where<<%= modelName %>>, ): Promise { return this.<%= repositoryNameCamel %>.count(where); } @@ -80,7 +81,7 @@ export class <%= className %>Controller { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(<%= modelName %>)) filter?: Filter<<%= modelName %>>, + @param.filter(<%= modelName %>) filter?: Filter<<%= modelName %>>, ): Promise<<%= modelName %>[]> { return this.<%= repositoryNameCamel %>.find(filter); } @@ -102,7 +103,7 @@ export class <%= className %>Controller { }, }) <%= modelVariableName %>: <%= modelName %>, - @param.query.object('where', getWhereSchemaFor(<%= modelName %>)) where?: Where<<%= modelName %>>, + @param.where(<%= modelName %>) where?: Where<<%= modelName %>>, ): Promise { return this.<%= repositoryNameCamel %>.updateAll(<%= modelVariableName %>, where); } @@ -121,7 +122,7 @@ export class <%= className %>Controller { }) async findById( @param.path.<%= idType %>('id') id: <%= idType %>, - @param.query.object('filter', getFilterSchemaFor(<%= modelName %>)) filter?: Filter<<%= modelName %>> + @param.filter(<%= modelName %>, {exclude: 'where'}) filter?: FilterExcludingWhere<<%= modelName %>> ): Promise<<%= modelName %>> { return this.<%= repositoryNameCamel %>.findById(id, filter); } diff --git a/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js b/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js index bd9b1a7fa5bc..e8c1cc57eb27 100644 --- a/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js +++ b/packages/cli/snapshots/integration/generators/controller.integration.snapshots.js @@ -12,6 +12,7 @@ import { Count, CountSchema, Filter, + FilterExcludingWhere, repository, Where, } from '@loopback/repository'; @@ -69,7 +70,7 @@ export class ProductReviewController { }, }) async count( - @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + @param.where(ProductReview) where?: Where, ): Promise { return this.barRepository.count(where); } @@ -90,7 +91,7 @@ export class ProductReviewController { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter, + @param.filter(ProductReview) filter?: Filter, ): Promise { return this.barRepository.find(filter); } @@ -112,7 +113,7 @@ export class ProductReviewController { }, }) productReview: ProductReview, - @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + @param.where(ProductReview) where?: Where, ): Promise { return this.barRepository.updateAll(productReview, where); } @@ -131,7 +132,7 @@ export class ProductReviewController { }) async findById( @param.path.number('id') id: number, - @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter + @param.filter(ProductReview, {exclude: 'where'}) filter?: FilterExcludingWhere ): Promise { return this.barRepository.findById(id, filter); } @@ -191,6 +192,7 @@ import { Count, CountSchema, Filter, + FilterExcludingWhere, repository, Where, } from '@loopback/repository'; @@ -248,7 +250,7 @@ export class ProductReviewController { }, }) async count( - @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + @param.where(ProductReview) where?: Where, ): Promise { return this.barRepository.count(where); } @@ -269,7 +271,7 @@ export class ProductReviewController { }, }) async find( - @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter, + @param.filter(ProductReview) filter?: Filter, ): Promise { return this.barRepository.find(filter); } @@ -291,7 +293,7 @@ export class ProductReviewController { }, }) productReview: ProductReview, - @param.query.object('where', getWhereSchemaFor(ProductReview)) where?: Where, + @param.where(ProductReview) where?: Where, ): Promise { return this.barRepository.updateAll(productReview, where); } @@ -310,7 +312,7 @@ export class ProductReviewController { }) async findById( @param.path.number('id') id: number, - @param.query.object('filter', getFilterSchemaFor(ProductReview)) filter?: Filter + @param.filter(ProductReview, {exclude: 'where'}) filter?: FilterExcludingWhere ): Promise { return this.barRepository.findById(id, filter); } 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 new file mode 100644 index 000000000000..209db29ba615 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/query.decorator.unit.ts @@ -0,0 +1,201 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Count, + Filter, + FilterExcludingWhere, + model, + Model, + property, + Where, +} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {ControllerSpec, get, getControllerSpec, param} from '../../..'; + +describe('sugar decorators for filter and where', () => { + let controllerSpec: ControllerSpec; + + before(() => { + controllerSpec = getControllerSpec(MyController); + }); + + it('allows @param.filter', () => { + expect(controllerSpec.paths['/'].get.parameters).to.eql([ + { + name: 'filter', + in: 'query', + content: { + 'application/json': { + schema: { + type: 'object', + title: 'MyModel.Filter', + properties: { + fields: { + title: 'MyModel.Fields', + type: 'object', + properties: {name: {type: 'boolean'}}, + additionalProperties: false, + }, + offset: {type: 'integer', minimum: 0}, + limit: {type: 'integer', minimum: 1, example: 100}, + skip: {type: 'integer', minimum: 0}, + order: {type: 'array', items: {type: 'string'}}, + where: { + title: 'MyModel.WhereFilter', + type: 'object', + additionalProperties: true, + }, + }, + additionalProperties: false, + }, + }, + }, + }, + ]); + }); + + it('allows @param.filter with a custom name', () => { + expect(controllerSpec.paths['/find'].get.parameters).to.eql([ + { + name: 'query', + in: 'query', + content: { + 'application/json': { + schema: { + type: 'object', + title: 'MyModel.Filter', + properties: { + fields: { + title: 'MyModel.Fields', + type: 'object', + properties: {name: {type: 'boolean'}}, + additionalProperties: false, + }, + offset: {type: 'integer', minimum: 0}, + limit: {type: 'integer', minimum: 1, example: 100}, + skip: {type: 'integer', minimum: 0}, + order: {type: 'array', items: {type: 'string'}}, + where: { + title: 'MyModel.WhereFilter', + type: 'object', + additionalProperties: true, + }, + }, + additionalProperties: false, + }, + }, + }, + }, + ]); + }); + + it('allows @param.filter with a custom name via options', () => { + expect(controllerSpec.paths['/find'].get.parameters[0].name).to.eql( + 'query', + ); + }); + + it('allows @param.filter() excluding where', () => { + expect(controllerSpec.paths['/{id}'].get.parameters).to.eql([ + {name: 'id', in: 'path', schema: {type: 'string'}, required: true}, + { + name: 'filter', + in: 'query', + content: { + 'application/json': { + schema: { + type: 'object', + title: 'MyModel.Filter', + properties: { + fields: { + title: 'MyModel.Fields', + type: 'object', + properties: {name: {type: 'boolean'}}, + additionalProperties: false, + }, + offset: {type: 'integer', minimum: 0}, + limit: {type: 'integer', minimum: 1, example: 100}, + skip: {type: 'integer', minimum: 0}, + order: {type: 'array', items: {type: 'string'}}, + }, + additionalProperties: false, + }, + }, + }, + }, + ]); + }); + + it('allows @param.where', () => { + expect(controllerSpec.paths['/count'].get.parameters).to.eql([ + { + name: 'where', + in: 'query', + content: { + 'application/json': { + schema: { + type: 'object', + title: 'MyModel.WhereFilter', + additionalProperties: true, + }, + }, + }, + }, + ]); + }); + + @model() + class MyModel extends Model { + constructor(data: Partial) { + super(data); + } + @property() + name: string; + } + + class MyController { + @get('/') + async find( + @param.filter(MyModel) + filter?: Filter, + ): Promise { + throw new Error('Not implemented'); + } + + @get('/find') + async findByQuery( + @param.filter(MyModel, 'query') + query?: Filter, + ): Promise { + throw new Error('Not implemented'); + } + + @get('/search') + async search( + @param.filter(MyModel, {name: 'query'}) + query?: Filter, + ): Promise { + throw new Error('Not implemented'); + } + + @get('/{id}') + async findById( + @param.path.string('id') id: string, + @param.filter(MyModel, {exclude: 'where'}) + filter?: FilterExcludingWhere, + ): Promise { + throw new Error('Not implemented'); + } + + @get('/count') + async count( + @param.where(MyModel) + where?: Where, + ): Promise { + throw new Error('Not implemented'); + } + } +}); 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 3c031328143c..ab04e8c5f938 100644 --- a/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/filter-schema.unit.ts @@ -46,6 +46,30 @@ describe('filterSchema', () => { }); }); + it('generate filter schema excluding where', () => { + const schema = getFilterSchemaFor(MyUserModel, {exclude: ['where']}); + expect(MyUserModel.definition.name).to.eql('my-user-model'); + expect(schema).to.eql({ + title: 'my-user-model.Filter', + properties: { + fields: { + type: 'object', + 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}, + skip: {type: 'integer', minimum: 0}, + order: {type: 'array', items: {type: 'string'}}, + }, + additionalProperties: false, + }); + }); + @model({ name: 'CustomUserModel', }) diff --git a/packages/openapi-v3/src/decorators/parameter.decorator.ts b/packages/openapi-v3/src/decorators/parameter.decorator.ts index 40bb3ecb52e2..73a7fc9abb0c 100644 --- a/packages/openapi-v3/src/decorators/parameter.decorator.ts +++ b/packages/openapi-v3/src/decorators/parameter.decorator.ts @@ -4,6 +4,8 @@ // License text available at https://opensource.org/licenses/MIT import {MetadataInspector, ParameterDecoratorFactory} from '@loopback/core'; +import {FilterSchemaOptions, Model} from '@loopback/repository-json-schema'; +import {getFilterSchemaFor, getWhereSchemaFor} from '../filter-schema'; import {resolveSchema} from '../generate-schema'; import {OAI3Keys} from '../keys'; import { @@ -437,6 +439,53 @@ export namespace param { schema: {type: 'array', items: itemSpec}, }); }; + + /** + * Sugar decorator for `filter` query parameter + * + * @example + * ```ts + * async find( + * @param.filter(modelCtor)) filter?: Filter, + * ): Promise<(T & Relations)[]> { + * // ... + * } + * ``` + * @param modelCtor - Model class + * @param options - Options to customize the parameter name or filter schema + * + */ + export function filter( + modelCtor: typeof Model, + options?: string | (FilterSchemaOptions & {name?: string}), + ) { + let name = 'filter'; + if (typeof options === 'string') { + name = options; + options = {}; + } + name = options?.name ?? name; + return param.query.object(name, getFilterSchemaFor(modelCtor, options)); + } + + /** + * Sugar decorator for `where` query parameter + * + * @example + * ```ts + * async count( + * @param.where(modelCtor)) where?: Where, + * ): Promise { + * // ... + * } + * ``` + * @param modelCtor - Model class + * @param name - Custom name for the parameter, default to `where` + * + */ + export function where(modelCtor: typeof Model, name = 'where') { + return param.query.object(name, getWhereSchemaFor(modelCtor)); + } } interface ParamShortcutOptions { diff --git a/packages/openapi-v3/src/filter-schema.ts b/packages/openapi-v3/src/filter-schema.ts index e31b0040f9a5..e8d06cd90e76 100644 --- a/packages/openapi-v3/src/filter-schema.ts +++ b/packages/openapi-v3/src/filter-schema.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import { + FilterSchemaOptions, getFilterJsonSchemaFor, getWhereJsonSchemaFor, Model, @@ -19,9 +20,13 @@ import {SchemaObject} from './types'; * a generic json schema allowing any "where" condition. * * @param modelCtor - The model constructor to build the filter schema for. + * @param options - Options to build the filter schema. */ -export function getFilterSchemaFor(modelCtor: typeof Model): SchemaObject { - const jsonSchema = getFilterJsonSchemaFor(modelCtor); +export function getFilterSchemaFor( + modelCtor: typeof Model, + options?: FilterSchemaOptions, +): SchemaObject { + const jsonSchema = getFilterJsonSchemaFor(modelCtor, options); const schema = jsonToSchemaObject(jsonSchema); return schema; } 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 3d5aadeb907d..21dc2339c556 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 @@ -16,11 +16,15 @@ import { describe('getFilterJsonSchemaFor', () => { let ajv: Ajv.Ajv; let customerFilterSchema: JsonSchema; + let customerFilterExcludingWhereSchema: JsonSchema; let orderFilterSchema: JsonSchema; beforeEach(() => { ajv = new Ajv(); customerFilterSchema = getFilterJsonSchemaFor(Customer); + customerFilterExcludingWhereSchema = getFilterJsonSchemaFor(Customer, { + exclude: ['where'], + }); orderFilterSchema = getFilterJsonSchemaFor(Order); }); @@ -50,6 +54,21 @@ describe('getFilterJsonSchemaFor', () => { expectSchemaToAllowFilter(customerFilterSchema, filter); }); + it('disallows "where"', () => { + const filter = {where: {name: 'John'}}; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ajv.validate(customerFilterExcludingWhereSchema, filter); + expect(ajv.errors ?? []).to.containDeep([ + { + keyword: 'additionalProperties', + dataPath: '', + schemaPath: '#/additionalProperties', + params: {additionalProperty: 'where'}, + message: 'should NOT have additional properties', + }, + ]); + }); + it('describes "where" as an object', () => { const filter = {where: 'invalid-where'}; // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -182,6 +201,24 @@ describe('getFilterJsonSchemaFor', () => { } }); +describe('getFilterJsonSchemaFor - excluding where', () => { + let customerFilterSchema: JsonSchema; + + it('excludes "where" using string[]', () => { + customerFilterSchema = getFilterJsonSchemaFor(Customer, { + exclude: ['where'], + }); + expect(customerFilterSchema.properties).to.not.have.property('where'); + }); + + it('excludes "where" using string', () => { + customerFilterSchema = getFilterJsonSchemaFor(Customer, { + exclude: 'where', + }); + expect(customerFilterSchema.properties).to.not.have.property('where'); + }); +}); + describe('getFilterJsonSchemaForOptionsSetTitle', () => { let customerFilterSchema: JsonSchema; diff --git a/packages/repository-json-schema/src/filter-json-schema.ts b/packages/repository-json-schema/src/filter-json-schema.ts index 4ece6964ac8c..46cf9bedd9b5 100644 --- a/packages/repository-json-schema/src/filter-json-schema.ts +++ b/packages/repository-json-schema/src/filter-json-schema.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {getModelRelations, Model, model} from '@loopback/repository'; -import {JSONSchema6 as JsonSchema} from 'json-schema'; +import {JSONSchema6 as JsonSchema, JSONSchema6Definition} from 'json-schema'; export interface FilterSchemaOptions { /** @@ -14,6 +14,11 @@ export interface FilterSchemaOptions { * */ setTitle?: boolean; + + /** + * To exclude one or more property from `filter` + */ + exclude?: string[] | string; } /** @@ -50,43 +55,60 @@ export function getScopeFilterJsonSchemaFor( * a generic json schema allowing any "where" condition. * * @param modelCtor - The model constructor to build the filter schema for. + * @param options - Options to build the filter schema. */ export function getFilterJsonSchemaFor( modelCtor: typeof Model, options: FilterSchemaOptions = {}, ): JsonSchema { - const schema: JsonSchema = { - ...(options.setTitle !== false && { - title: `${modelCtor.modelName}.Filter`, - }), - properties: { - where: getWhereJsonSchemaFor(modelCtor, options), + let excluded: string[]; + if (typeof options.exclude === 'string') { + excluded = [options.exclude]; + } else { + excluded = options.exclude ?? []; + } + const properties: Record = { + offset: { + type: 'integer', + minimum: 0, + }, - fields: getFieldsJsonSchemaFor(modelCtor, options), + limit: { + type: 'integer', + minimum: 1, + examples: [100], + }, - offset: { - type: 'integer', - minimum: 0, - }, + skip: { + type: 'integer', + minimum: 0, + }, - limit: { - type: 'integer', - minimum: 1, - examples: [100], + order: { + type: 'array', + items: { + type: 'string', }, + }, + }; - skip: { - type: 'integer', - minimum: 0, - }, + if (!excluded.includes('where')) { + properties.where = getWhereJsonSchemaFor(modelCtor, options); + } + if (!excluded.includes('fields')) { + properties.fields = getFieldsJsonSchemaFor(modelCtor, options); + } - order: { - type: 'array', - items: { - type: 'string', - }, - }, - }, + // Remove excluded properties + for (const p of excluded) { + delete properties[p]; + } + + const schema: JsonSchema = { + ...(options.setTitle !== false && { + title: `${modelCtor.modelName}.Filter`, + }), + properties, additionalProperties: false, }; diff --git a/packages/repository/src/__tests__/unit/model/model.unit.ts b/packages/repository/src/__tests__/unit/model/model.unit.ts index 5f4974ac0a8a..7ce5a41b1d34 100644 --- a/packages/repository/src/__tests__/unit/model/model.unit.ts +++ b/packages/repository/src/__tests__/unit/model/model.unit.ts @@ -193,6 +193,12 @@ describe('model', () => { // notice that "extra" property was discarded from the output }); + it('skips properties with undefined values', () => { + const customer = createCustomer(); + delete customer.email; + expect(customer.toJSON()).to.eql({id: '123'}); + }); + it('converts to json recursively', () => { const customer = createCustomerWithContact(); expect(customer.toJSON()).to.eql({ diff --git a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts index 64029831e15b..bbf4cae030b7 100644 --- a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts @@ -309,6 +309,13 @@ describe('DefaultCrudRepository', () => { expect(result?.toJSON()).to.eql(note.toJSON()); }); + it('returns the correct instance with fields', async () => { + const repo = new DefaultCrudRepository(Note, ds); + const note = await repo.create({title: 'a-title', content: 'a-content'}); + const result = await repo.findById(note.id, {fields: {title: true}}); + expect(result?.toJSON()).to.eql({title: 'a-title'}); + }); + it('throws when the instance does not exist', async () => { const repo = new DefaultCrudRepository(Note, ds); await expect(repo.findById(999999)).to.be.rejectedWith({ @@ -656,7 +663,6 @@ describe('DefaultCrudRepository', () => { expect(result.toJSON()).to.eql({ id: note.id, title: 't4', - content: undefined, }); }); diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 6663a8bbb6a3..6d14a33e1908 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -217,7 +217,10 @@ export abstract class Model { } const copyPropertyAsJson = (key: string) => { - json[key] = asJSON((this as AnyObject)[key]); + const val = asJSON((this as AnyObject)[key]); + if (val !== undefined) { + json[key] = val; + } }; const json: AnyObject = {}; diff --git a/packages/repository/src/query.ts b/packages/repository/src/query.ts index 7ee845a9973d..41ed029e2702 100644 --- a/packages/repository/src/query.ts +++ b/packages/repository/src/query.ts @@ -221,6 +221,14 @@ export interface Filter { include?: Inclusion[]; } +/** + * Filter without `where` property + */ +export type FilterExcludingWhere = Omit< + Filter, + 'where' +>; + /** * TypeGuard for Filter * @param candidate diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 041fbbba587c..bdd17c4ea1b1 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -17,7 +17,7 @@ import { } from '../common-types'; import {EntityNotFoundError} from '../errors'; import {Entity, Model, PropertyType} from '../model'; -import {Filter, Inclusion, Where} from '../query'; +import {Filter, Inclusion, Where, FilterExcludingWhere} from '../query'; import { BelongsToAccessor, BelongsToDefinition, @@ -396,7 +396,7 @@ export class DefaultCrudRepository< async findById( id: ID, - filter?: Filter, + filter?: FilterExcludingWhere, options?: Options, ): Promise { const include = filter?.include; diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 7d96ecad9898..d89c3d81248e 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -16,7 +16,7 @@ import {CrudConnector} from '../connectors'; import {DataSource} from '../datasource'; import {EntityNotFoundError} from '../errors'; import {Entity, Model, ValueObject} from '../model'; -import {Filter, Where} from '../query'; +import {Filter, FilterExcludingWhere, Where} from '../query'; import {InclusionResolver} from '../relations/relation.types'; import {IsolationLevel, Transaction} from '../transaction'; @@ -172,6 +172,13 @@ export interface EntityCrudRepository< /** * Find an entity by id, return a rejected promise if not found. + * + * @remarks + * + * The rationale behind findById is to find an instance by its primary key + * (id). No other search criteria than id should be used. If a client wants + * to use a `where` clause beyond id, use `find` or `findOne` instead. + * * @param id - Value for the entity id * @param filter - Additional query options. E.g. `filter.include` configures * which related models to fetch as part of the database query (or queries). @@ -180,7 +187,7 @@ export interface EntityCrudRepository< */ findById( id: ID, - filter?: Filter, + filter?: FilterExcludingWhere, options?: Options, ): Promise; @@ -303,7 +310,11 @@ export class CrudRepositoryImpl ); } - async findById(id: ID, filter?: Filter, options?: Options): Promise { + async findById( + id: ID, + filter?: FilterExcludingWhere, + options?: Options, + ): Promise { if (typeof this.connector.findById === 'function') { return this.toModel( this.connector.findById(this.entityClass, id, options), diff --git a/packages/rest-crud/src/crud-rest.controller.ts b/packages/rest-crud/src/crud-rest.controller.ts index fbe0861849c7..590c9c5e2c5e 100644 --- a/packages/rest-crud/src/crud-rest.controller.ts +++ b/packages/rest-crud/src/crud-rest.controller.ts @@ -10,6 +10,7 @@ import { Entity, EntityCrudRepository, Filter, + FilterExcludingWhere, Where, } from '@loopback/repository'; import { @@ -19,7 +20,6 @@ import { getFilterSchemaFor, getJsonSchema, getModelSchemaRef, - getWhereSchemaFor, JsonSchemaOptions, jsonToSchemaObject, MediaTypeObject, @@ -172,7 +172,7 @@ export function defineCrudRestController< }), }) async find( - @param.query.object('filter', getFilterSchemaFor(modelCtor)) + @param.filter(modelCtor) filter?: Filter, ): Promise<(T & Relations)[]> { return this.repository.find(filter); @@ -185,8 +185,11 @@ export function defineCrudRestController< }) async findById( @param(idPathParam) id: IdType, - @param.query.object('filter', getFilterSchemaFor(modelCtor)) - filter?: Filter, + @param.query.object( + 'filter', + getFilterSchemaFor(modelCtor, {exclude: 'where'}), + ) + filter?: FilterExcludingWhere, ): Promise { return this.repository.findById(id, filter); } @@ -195,7 +198,7 @@ export function defineCrudRestController< ...response(200, `${modelName} count`, {schema: CountSchema}), }) async count( - @param.query.object('where', getWhereSchemaFor(modelCtor)) + @param.where(modelCtor) where?: Where, ): Promise { return this.repository.count(where); @@ -208,7 +211,7 @@ export function defineCrudRestController< }) async updateAll( @body(modelCtor, {partial: true}) data: Partial, - @param.query.object('where', getWhereSchemaFor(modelCtor)) + @param.where(modelCtor) where?: Where, ): Promise { return this.repository.updateAll(