diff --git a/_SPIKE_.md b/_SPIKE_.md new file mode 100644 index 000000000000..b3a856491182 --- /dev/null +++ b/_SPIKE_.md @@ -0,0 +1,348 @@ +# Spike documentation + +See the discussion in +[#2152](https://github.com/strongloop/loopback-next/issues/2152) for background +and information on different approaches I have researched and abandoned along +the way. + +## Overview + +In this feature branch, I am presenting a proof of concept implementation +demonstrating how to describe navigational properties for inclusion of related +models. + +The solution consists of three parts: + +1. A convention for describing navigational properties (links) via an interface, + including support in `CrudRepository` and `DefaultCrudRepository` classes. + +2. Improvements in repository-json-schema (`getJsonSchema` and + `modelToJsonSchema`): + + - 2.1: A new option `includeRelations` controlling whether navigational + properties are included in the generated schema. + + - 2.2: Support for cyclic references. + + For example: `CategoryWithRelations` has a property `products` containing + an array of `ProductWithRelations`. `ProductWithRelations` has a property + `category` containing `CategoryWithRelations`. + + Please note this example is NOT using the approach for model inclusion, + it's just an acceptance criteria. + + - 2.3: A new helper function `modelToJsonSchemaRef` that emits JSON Schema + reference and includes definitions of all references models. + +3. Improvements in code generating controller OpenAPI spec allowing app + developers to use `modelToJsonSchemaRef` to describe response schema. + +Additionally, the todo-list example has been updated to show the proposed +solution in practice. + +## Discussion + +We have the following requirements on a solution for describing navigational +properties: + +1. A (compile-time) type describing navigational properties of a model. For + example, when a `Category` model has many instances of a `Product` model, + then we want to define property `products` containing an array of `Product` + instances _with product navigational properties included_. + +2. Ability to define a compile-time type where all navigational properties are + defined as optional. This is needed by Repository implementation. + +3. Ability to generate two OpenAPI/JSON Schemas for each model: + + 1. Own properties only + 2. Both own properties and navigational properties + +4. SHOULD HAVE: To support JavaScript developers and declarative support, the + new types should be optional. At runtime, we should leverage the dynamic + nature of JavaScript objects and add navigational properties to an instance + of the original model. Specifically, we should not require another model + class to represent model with relations. + +My proposed solution meets all requirements above. Additionally, it consists of +several smaller building blocks that can be used beyond the scope of +navigational properties too. + +## Solution details + +### Interface describing model navigational properties + +To describe navigation properties for TypeScript compiler, application +developers will define a new interface for each model. + +BelongsTo relation: + +```ts +@model() +class Product extends Entity { + @belongsTo(() => Category) + categoryId: number; +} + +/** + * Navigation properties of the Product model. + */ +interface ProductRelations { + category?: CategoryWithRelations; +} + +/** + * Product's own properties and navigation properties. + */ +type ProductWithRelations = Product & ProductRelations; +``` + +HasMany relation: + +```ts +@model() +class Category extends Entity { + @hasMany(() => Product) + products?: Product[]; +} + +/** + * Navigation properties of the Category model. + */ +interface CategoryRelations { + products?: ProductWithRelations[]; +} + +/** + * Category's own properties and navigation properties. + */ +type CategoryWithRelations = Category & CategoryRelations; +``` + +This solution has few important properties I'd like to explicitly point out: + +- It is independent on how the relations are defined. `@belongsTo` decorator is + applied on the foreign key property, `@hasMany` decorator is applied to a kind + of a navigational property. If we decide to apply relational decorators at + class level in the future, this solution will support that too. + +- It does not trigger circular-reference bug in design:type metadata, see + https://github.com/Microsoft/TypeScript/issues/27519 + +- It makes it easy to define a type where all navigational properties are + optional. For example: `Product & Partial` + + UPDATE: As it turns out, it's not enough to mark all navigational properties + as optional. See the discussion in + https://github.com/strongloop/loopback-next/pull/2592#discussion_r267600322 + +### Integration with CrudRepository APIs + +The CRUD-related Repository interfaces and classes are accepting a new generic +argument `Relations` that's describing navigational properties. + +Example use in application-level repositories: + +```ts +export class CategoryRepository extends DefaultCrudRepository< + Category, + typeof Category.prototype.id, + CategoryRelations +> { + // (no changes here) +} +``` + +### OpenAPI Schema generation + +When building OpenAPI Schema from a model definition, we provide two modes: + +- Own properties only +- Both own properties and navigational properties + +The decision is controlled by a new option passed to `modelToJsonSchema` and +related methods. + +```ts +// own properties only +const spec = getJsonSchema(Product); +// include navigational properties too +const spec = getJsonSchema(Product, {includeRelations: true}); +``` + +An example of the produced schema: + +```js +{ + title: 'CategoryWithRelations', + properties: { + // own properties + id: {type: 'number'}, + // navigational properties + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithRelations'}, + }, + }, + definitions: { + ProductWithRelations: { + title: 'ProductWithRelations', + properties: { + // own properties + id: {type: 'number'}, + categoryId: {type: 'number'}, + // navigational properties + category: {$ref: '#/definitions/CategoryWithRelations'}, + }, + }, + }, +} +``` + +To support integration with OpenAPI spec of controllers, where we want to +reference a shared definition (component schema), we need a slightly different +schema. Here is an example as produced by `getJsonSchemaRef`: + +```js +{ + $ref: '#/definitions/CategoryWithRelations', + definitions: { + CategoryWithRelations: { + title: 'CategoryWithRelations', + properties: { + id: {type: 'number'}, + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithRelations'}, + }, + }, + } + ProductWithRelations: { + title: 'ProductWithRelations', + properties: { + id: {type: 'number'}, + categoryId: {type: 'number'}, + category: {$ref: '#/definitions/CategoryWithRelations'}, + }, + }, + }, +} +``` + +The first schema defines `CategoryWithRelations` as the top-level schema, +`definitions` contain only `ProductWithRelations` schema. + +The second schema contains only `$ref` entry at the top-level, the actual schema +for `CategoryWithRelations` is defined in `definitions`. + +### Controller spec + +The last missing piece is integration with controller spec builder. + +At the moment, we use the following implementation of the controller method +`find`: + +```ts +class CategoryController { + // constructor with @inject() decorators + + @get('/categories', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: {'x-ts-type': Category}, + }, + }, + }, + }, + }, + }) + async find( + @param.query.object('filter', getFilterSchemaFor(Category)) filter?: Filter, + ): Promise { + return await this.categoryRepository.find(filter); + } +} +``` + +In my proposal, we replace `x-ts-type` extension with a call to +`getModelSchemaRef`. + +```diff + schema: { + type: 'array', +- items: {'x-ts-type': Category}, ++ items: getModelSchemaRef(Category, {includeRelations: true}) + }, +``` + +I particularly like how this solution makes it easy to use a different mechanism +for generating model schema. Consider developers using TypeORM instead of our +Juggler-based Repository for an example. If we implement my proposal, then it +will be possible to implement a TypeORM extension for LoopBack that will provide +a different implementation of `getModelSchemaRef` function, one that will use +TypeORM metadata instead of juggler's LDL. + +Later on, we can explore different ways how to enable `includeRelations` flag +via OpenAPI spec extensions. For example: + +```diff + schema: { + type: 'array', +- items: {'x-ts-type': Category}, ++ items: {'x-ts-type': Category, 'x-include-relations': true}, + }, +``` + +## Follow-up stories + +1. Handle circular references when generating model JSON Schema + + --> https://github.com/strongloop/loopback-next/issues/2628 + +2. Support `schema: {$ref, definitions}` in resolveControllerSpec + + --> https://github.com/strongloop/loopback-next/issues/2629 + +3. Enhance `getJsonSchema` to describe navigational properties (introduce + `includeRelations` option). + + - Add a new `RelationDefinition` property: `targetsMany: boolean` + - Implement support for `includeRelations` in `getJsonSchema` & co. + + --> https://github.com/strongloop/loopback-next/issues/2630 + +4. Implement `getJsonSchemaRef` and `getModelSchemaRef` helpers + + --> https://github.com/strongloop/loopback-next/issues/2631 + +5) Modify Repository `find*` method signatures to include navigational + properties in the description of the return type + + - Add a new generic parameter `Relations` to CRUD-related Repository + interfaces and implementations. + - Modify the signature `find` and `findById` to return `T & Relations` + instead of `T`. If this requires too many explicit casts, then consider + using `T & Partial` instead, assuming it improves the situation. + + --> https://github.com/strongloop/loopback-next/issues/2632 + +6. Update `examples/todo-list` to leverage these new features: + + - Define `{Model}Relations` interfaces and `{Model}WithRelations` types + - Update `{Model}Repository` implementations to use these new interfaces + - Update repositories to include related models: overwrite `find` and + `findById` methods, add a hard-coded retrieval of related models. + - Update response schemas for controller methods `find` and `findById` + + --> https://github.com/strongloop/loopback-next/issues/2633 + +7. Replace our temporary poor-man's relation resolver with a real one, as + described in https://github.com/strongloop/loopback-next/pull/2124. Update + the example app as part of this work. + + --> https://github.com/strongloop/loopback-next/issues/2634 diff --git a/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts b/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts index 867f54b99198..38691e5f0323 100644 --- a/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts +++ b/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts @@ -12,9 +12,9 @@ import { toJSON, } from '@loopback/testlab'; import {TodoListApplication} from '../../application'; -import {TodoList} from '../../models/'; -import {TodoListRepository} from '../../repositories/'; -import {givenTodoList} from '../helpers'; +import {Todo, TodoList} from '../../models'; +import {TodoListRepository, TodoRepository} from '../../repositories'; +import {givenTodo, givenTodoList} from '../helpers'; describe('TodoListApplication', () => { let app: TodoListApplication; @@ -178,6 +178,20 @@ describe('TodoListApplication', () => { .expect(200, [toJSON(listInBlack)]); }); + it('includes Todos in query result', async () => { + const list = await givenTodoListInstance(); + const todo = await givenTodoInstance({todoListId: list.id}); + + const response = await client.get('/todo-lists').query({ + filter: JSON.stringify({include: [{relation: 'todos'}]}), + }); + expect(response.body).to.have.length(1); + expect(response.body[0]).to.deepEqual({ + ...toJSON(list), + todos: [toJSON(todo)], + }); + }); + /* ============================================================================ TEST HELPERS @@ -218,6 +232,11 @@ describe('TodoListApplication', () => { return await todoListRepo.create(givenTodoList(todoList)); } + async function givenTodoInstance(todo?: Partial) { + const repo = await app.getRepository(TodoRepository); + return await repo.create(givenTodo(todo)); + } + function givenMutlipleTodoListInstances() { return Promise.all([ givenTodoListInstance(), diff --git a/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts index 61d37703f81d..326071d88551 100644 --- a/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts @@ -149,6 +149,20 @@ describe('TodoListApplication', () => { .expect(200, [toJSON(todoInProgress)]); }); + it('includes TodoList in query result', async () => { + const list = await givenTodoListInstance(); + const todo = await givenTodoInstance({todoListId: list.id}); + + const response = await client.get('/todos').query({ + filter: JSON.stringify({include: [{relation: 'todoList'}]}), + }); + expect(response.body).to.have.length(1); + expect(response.body[0]).to.deepEqual({ + ...toJSON(todo), + todoList: toJSON(list), + }); + }); + /* ============================================================================ TEST HELPERS diff --git a/examples/todo-list/src/controllers/todo-list.controller.ts b/examples/todo-list/src/controllers/todo-list.controller.ts index e74260b273d3..f19528e95c68 100644 --- a/examples/todo-list/src/controllers/todo-list.controller.ts +++ b/examples/todo-list/src/controllers/todo-list.controller.ts @@ -15,6 +15,7 @@ import { get, getFilterSchemaFor, getWhereSchemaFor, + getModelSchemaRef, param, patch, post, @@ -59,7 +60,14 @@ export class TodoListController { responses: { '200': { description: 'Array of TodoList model instances', - content: {'application/json': {schema: {'x-ts-type': TodoList}}}, + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(TodoList, {includeRelations: true}), + }, + }, + }, }, }, }) diff --git a/examples/todo-list/src/controllers/todo.controller.ts b/examples/todo-list/src/controllers/todo.controller.ts index ec078880dbd5..ebcaf15eba97 100644 --- a/examples/todo-list/src/controllers/todo.controller.ts +++ b/examples/todo-list/src/controllers/todo.controller.ts @@ -13,6 +13,7 @@ import { post, put, requestBody, + getModelSchemaRef, } from '@loopback/rest'; import {Todo, TodoList} from '../models'; import {TodoRepository} from '../repositories'; @@ -53,7 +54,10 @@ export class TodoController { description: 'Array of Todo model instances', content: { 'application/json': { - schema: {type: 'array', items: {'x-ts-type': Todo}}, + schema: { + type: 'array', + items: getModelSchemaRef(Todo, {includeRelations: true}), + }, }, }, }, diff --git a/examples/todo-list/src/models/todo-list-image.model.ts b/examples/todo-list/src/models/todo-list-image.model.ts index 92c5077cec8a..2da331cd8125 100644 --- a/examples/todo-list/src/models/todo-list-image.model.ts +++ b/examples/todo-list/src/models/todo-list-image.model.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, model, property, belongsTo} from '@loopback/repository'; -import {TodoList} from './todo-list.model'; +import {belongsTo, Entity, model, property} from '@loopback/repository'; +import {TodoList, TodoListWithRelations} from './todo-list.model'; @model() export class TodoListImage extends Entity { @@ -29,3 +29,9 @@ export class TodoListImage extends Entity { super(data); } } + +export interface TodoListImageRelations { + todoList?: TodoListWithRelations; +} + +export type TodoListImageWithRelations = TodoListImage & TodoListImageRelations; diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index bdb1eb8d1382..94c24762c7d3 100644 --- a/examples/todo-list/src/models/todo-list.model.ts +++ b/examples/todo-list/src/models/todo-list.model.ts @@ -3,9 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, model, property, hasMany, hasOne} from '@loopback/repository'; -import {Todo} from './todo.model'; -import {TodoListImage} from './todo-list-image.model'; +import {Entity, hasMany, hasOne, model, property} from '@loopback/repository'; +import { + TodoListImage, + TodoListImageWithRelations, +} from './todo-list-image.model'; +import {Todo, TodoWithRelations} from './todo.model'; @model() export class TodoList extends Entity { @@ -36,3 +39,10 @@ export class TodoList extends Entity { super(data); } } + +export interface TodoListRelations { + todos?: TodoWithRelations[]; + image?: TodoListImageWithRelations; +} + +export type TodoListWithRelations = TodoList & TodoListRelations; diff --git a/examples/todo-list/src/models/todo.model.ts b/examples/todo-list/src/models/todo.model.ts index bc30ce80def0..4bbabb99d8cd 100644 --- a/examples/todo-list/src/models/todo.model.ts +++ b/examples/todo-list/src/models/todo.model.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, property, model, belongsTo} from '@loopback/repository'; -import {TodoList} from './todo-list.model'; +import {belongsTo, Entity, model, property} from '@loopback/repository'; +import {TodoList, TodoListRelations} from './todo-list.model'; @model() export class Todo extends Entity { @@ -41,3 +41,9 @@ export class Todo extends Entity { super(data); } } + +export interface TodoRelations { + todoList?: TodoList & TodoListRelations; +} + +export type TodoWithRelations = Todo & TodoRelations; diff --git a/examples/todo-list/src/repositories/todo-list.repository.ts b/examples/todo-list/src/repositories/todo-list.repository.ts index 4b1d63fe8f71..25a3cf3885d0 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -10,14 +10,23 @@ import { juggler, repository, HasOneRepositoryFactory, + Filter, + Options, } from '@loopback/repository'; -import {Todo, TodoList, TodoListImage} from '../models'; +import { + Todo, + TodoList, + TodoListImage, + TodoListRelations, + TodoListWithRelations, +} from '../models'; import {TodoRepository} from './todo.repository'; import {TodoListImageRepository} from './todo-list-image.repository'; export class TodoListRepository extends DefaultCrudRepository< TodoList, - typeof TodoList.prototype.id + typeof TodoList.prototype.id, + TodoListRelations > { public readonly todos: HasManyRepositoryFactory< Todo, @@ -49,4 +58,27 @@ export class TodoListRepository extends DefaultCrudRepository< public findByTitle(title: string) { return this.findOne({where: {title}}); } + + async find( + filter?: Filter, + options?: Options, + ): Promise { + // Prevent juggler for applying "include" filter + // Juggler is not aware of LB4 relations + const include = filter && filter.include; + filter = filter && Object.assign(filter, {include: undefined}); + + const result = await super.find(filter, options); + + // poor-mans inclusion resolver, this should be handled by DefaultCrudRepo + // and use `inq` operator to fetch related todos in fewer DB queries + if (include && include.length && include[0].relation === 'todos') { + await Promise.all( + result.map(async r => { + r.todos = await this.todos(r.id).find(); + }), + ); + } + return result; + } } diff --git a/examples/todo-list/src/repositories/todo.repository.ts b/examples/todo-list/src/repositories/todo.repository.ts index 4f26693b602c..f97fc2b51588 100644 --- a/examples/todo-list/src/repositories/todo.repository.ts +++ b/examples/todo-list/src/repositories/todo.repository.ts @@ -7,15 +7,18 @@ import {Getter, inject} from '@loopback/core'; import { BelongsToAccessor, DefaultCrudRepository, + Filter, juggler, + Options, repository, } from '@loopback/repository'; -import {Todo, TodoList} from '../models'; +import {Todo, TodoRelations, TodoList, TodoWithRelations} from '../models'; import {TodoListRepository} from './todo-list.repository'; export class TodoRepository extends DefaultCrudRepository< Todo, - typeof Todo.prototype.id + typeof Todo.prototype.id, + TodoRelations > { public readonly todoList: BelongsToAccessor< TodoList, @@ -34,4 +37,27 @@ export class TodoRepository extends DefaultCrudRepository< todoListRepositoryGetter, ); } + + async find( + filter?: Filter, + options?: Options, + ): Promise { + // Prevent juggler for applying "include" filter + // Juggler is not aware of LB4 relations + const include = filter && filter.include; + filter = filter && Object.assign(filter, {include: undefined}); + + const result = await super.find(filter, options); + + // poor-mans inclusion resolver, this should be handled by DefaultCrudRepo + // and use `inq` operator to fetch related todos in fewer DB queries + if (include && include.length && include[0].relation === 'todoList') { + await Promise.all( + result.map(async r => { + r.todoList = await this.todoList(r.id); + }), + ); + } + return result; + } } diff --git a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts index 99c70c707c92..068b19bfcc85 100644 --- a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts +++ b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts @@ -3,13 +3,24 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {ParameterObject, SchemaObject} from '@loopback/openapi-v3-types'; -import {model, property} from '@loopback/repository'; +import { + OperationObject, + ParameterObject, + SchemaObject, +} from '@loopback/openapi-v3-types'; +import { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; import {expect} from '@loopback/testlab'; import { ControllerSpec, get, getControllerSpec, + getModelSchemaRef, param, post, requestBody, @@ -421,4 +432,67 @@ describe('controller spec', () => { return MyController; } }); + + it('supports getModelSchemaRef with relations', () => { + @model() + class Product extends Entity { + @belongsTo(() => Category) + categoryId: number; + } + + @model() + class Category extends Entity { + @hasMany(() => Product) + products?: Product[]; + } + + class CategoryController { + @get('/categories', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: getModelSchemaRef(Category, {includeRelations: true}), + }, + }, + }, + }, + }) + async find(): Promise { + return []; // dummy implementation, it's never called + } + } + + const spec = getControllerSpec(CategoryController); + const opSpec: OperationObject = spec.paths['/categories'].get; + const responseSpec = opSpec.responses['200'].content['application/json']; + expect(responseSpec.schema).to.deepEqual({ + $ref: '#/components/schemas/CategoryWithRelations', + }); + + const globalSchemas = (spec.components || {}).schemas; + expect(globalSchemas).to.deepEqual({ + CategoryWithRelations: { + title: 'CategoryWithRelations', + properties: { + products: { + type: 'array', + items: {$ref: '#/components/schemas/ProductWithRelations'}, + }, + }, + }, + ProductWithRelations: { + title: 'ProductWithRelations', + properties: { + categoryId: { + type: 'number', + }, + category: { + $ref: '#/components/schemas/CategoryWithRelations', + }, + }, + }, + }); + }); }); diff --git a/packages/openapi-v3/src/__tests__/integration/spike.integration.ts b/packages/openapi-v3/src/__tests__/integration/spike.integration.ts new file mode 100644 index 000000000000..8a9d938f3f6e --- /dev/null +++ b/packages/openapi-v3/src/__tests__/integration/spike.integration.ts @@ -0,0 +1,59 @@ +// 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 {OperationObject} from '@loopback/openapi-v3-types'; +import {expect} from '@loopback/testlab'; +import {get, getControllerSpec} from '../..'; + +describe('controller spec', () => { + it('allows operations to provide definition of referenced models', () => { + class MyController { + @get('/todos', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Todo', + definitions: { + Todo: { + title: 'Todo', + properties: { + title: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + }) + async find(): Promise { + return []; // dummy implementation, it's never called + } + } + + const spec = getControllerSpec(MyController); + const opSpec: OperationObject = spec.paths['/todos'].get; + const responseSpec = opSpec.responses['200'].content['application/json']; + expect(responseSpec.schema).to.deepEqual({ + $ref: '#/components/schemas/Todo', + }); + + const globalSchemas = (spec.components || {}).schemas; + expect(globalSchemas).to.deepEqual({ + Todo: { + title: 'Todo', + properties: { + title: { + type: 'string', + }, + }, + }, + }); + }); +}); diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index 26992eb76ca7..9e6084a05a02 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -15,8 +15,13 @@ import { ReferenceObject, SchemaObject, isReferenceObject, + ISpecificationExtension, } from '@loopback/openapi-v3-types'; -import {getJsonSchema} from '@loopback/repository-json-schema'; +import { + getJsonSchema, + JsonSchemaOptions, + getJsonSchemaRef, +} from '@loopback/repository-json-schema'; import {OAI3Keys} from './keys'; import {jsonToSchemaObject} from './json-to-schema'; import * as _ from 'lodash'; @@ -56,6 +61,8 @@ export interface RestEndpoint { export const TS_TYPE_KEY = 'x-ts-type'; +type ComponentSchemaMap = {[key: string]: SchemaObject}; + /** * Build the api spec from class and method level decorations * @param constructor Controller class @@ -120,8 +127,8 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { if (isReferenceObject(responseObject)) continue; const content = responseObject.content || {}; for (const c in content) { - debug(' evaluating response code %s with content: %o', code, c); - resolveTSType(spec, content[c].schema); + debug(' processing response code %s with content-type %', code, c); + processSchemaExtensions(spec, content[c].schema); } } @@ -180,7 +187,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { const content = requestBody.content || {}; for (const mediaType in content) { - resolveTSType(spec, content[mediaType].schema); + processSchemaExtensions(spec, content[mediaType].schema); } } } @@ -235,12 +242,18 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { * @param spec Controller spec * @param schema Schema object */ -function resolveTSType( +function processSchemaExtensions( spec: ControllerSpec, - schema?: SchemaObject | ReferenceObject, + schema?: SchemaObject | (ReferenceObject & ISpecificationExtension), ) { - debug(' evaluating schema: %j', schema); - if (!schema || isReferenceObject(schema)) return; + debug(' processing extensions in schema: %j', schema); + if (!schema) return; + + assignRelatedSchemas(spec, schema.definitions); + delete schema.definitions; + + if (isReferenceObject(schema)) return; + const tsType = schema[TS_TYPE_KEY]; debug(' %s => %o', TS_TYPE_KEY, tsType); if (tsType) { @@ -252,11 +265,11 @@ function resolveTSType( return; } if (schema.type === 'array') { - resolveTSType(spec, schema.items); + processSchemaExtensions(spec, schema.items); } else if (schema.type === 'object') { if (schema.properties) { for (const p in schema.properties) { - resolveTSType(spec, schema.properties[p]); + processSchemaExtensions(spec, schema.properties[p]); } } } @@ -281,20 +294,38 @@ function generateOpenAPISchema(spec: ControllerSpec, tsType: Function) { } const jsonSchema = getJsonSchema(tsType); const openapiSchema = jsonToSchemaObject(jsonSchema); - const outputSchemas = spec.components.schemas; - if (openapiSchema.definitions) { - for (const key in openapiSchema.definitions) { - // Preserve user-provided definitions - if (key in outputSchemas) continue; - const relatedSchema = openapiSchema.definitions[key]; - debug(' defining referenced schema for %j: %j', key, relatedSchema); - outputSchemas[key] = relatedSchema; - } - delete openapiSchema.definitions; - } + + assignRelatedSchemas(spec, openapiSchema.definitions); + delete openapiSchema.definitions; debug(' defining schema for %j: %j', tsType.name, openapiSchema); - outputSchemas[tsType.name] = openapiSchema; + spec.components.schemas[tsType.name] = openapiSchema; +} + +function assignRelatedSchemas( + spec: ControllerSpec, + definitions?: ComponentSchemaMap, +) { + if (!definitions) return; + debug( + ' assigning related schemas: ', + definitions && Object.keys(definitions), + ); + if (!spec.components) { + spec.components = {}; + } + if (!spec.components.schemas) { + spec.components.schemas = {}; + } + const outputSchemas = spec.components.schemas; + + for (const key in definitions) { + // Preserve user-provided definitions + if (key in outputSchemas) continue; + const relatedSchema = definitions[key]; + debug(' defining referenced schema for %j: %j', key, relatedSchema); + outputSchemas[key] = relatedSchema; + } } /** @@ -317,3 +348,11 @@ export function getControllerSpec(constructor: Function): ControllerSpec { } return spec; } + +export function getModelSchemaRef( + modelCtor: Function, + options: JsonSchemaOptions, +) { + const jsonSchema = getJsonSchemaRef(modelCtor, options); + return jsonToSchemaObject(jsonSchema); +} diff --git a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts index ae24f4a0a0a9..c295f223bb18 100644 --- a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts @@ -582,7 +582,7 @@ describe('build-schema', () => { }; MetadataInspector.defineMetadata( JSON_SCHEMA_KEY, - cachedSchema, + {modelOnly: cachedSchema}, TestModel, ); const jsonSchema = getJsonSchema(TestModel); diff --git a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts new file mode 100644 index 000000000000..38000b5926e9 --- /dev/null +++ b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts @@ -0,0 +1,129 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository-json-schema +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import * as Ajv from 'ajv'; +import {JsonSchema, modelToJsonSchema} from '../..'; + +describe('build-schema', () => { + describe('modelToJsonSchema', () => { + it('converts basic model', () => { + @model() + class TestModel { + @property() + foo: string; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expectValidJsonSchema(jsonSchema); + expect(jsonSchema.properties).to.containDeep({ + foo: { + type: 'string', + }, + }); + }); + + it('handles circular references', () => { + @model() + class Category { + @property.array(() => Product) + products?: Product[]; + } + + @model() + class Product { + @property(() => Category) + category?: Category; + } + + const schema = modelToJsonSchema(Category); + expect(schema).to.deepEqual({ + title: 'Category', + properties: { + products: { + type: 'array', + items: {$ref: '#/definitions/Product'}, + }, + }, + definitions: { + Product: { + title: 'Product', + properties: { + category: { + $ref: '#/definitions/Category', + }, + }, + }, + }, + }); + }); + + it('converts HasMany and BelongsTo relation links', () => { + @model() + class Product extends Entity { + @property({id: true}) + id: number; + + @belongsTo(() => Category) + categoryId: number; + } + + @model() + class Category extends Entity { + @property({id: true}) + id: number; + + @hasMany(() => Product) + products?: Product[]; + } + + const jsonSchema = modelToJsonSchema(Category, {includeRelations: true}); + expectValidJsonSchema(jsonSchema); + expect(jsonSchema).to.deepEqual({ + title: 'CategoryWithRelations', + properties: { + // TODO(bajtos): inherit these properties from Category schema + // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + id: {type: 'number'}, + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithRelations'}, + }, + }, + definitions: { + ProductWithRelations: { + title: 'ProductWithRelations', + properties: { + // TODO(bajtos): inherit these properties from Product schema + // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + id: {type: 'number'}, + categoryId: {type: 'number'}, + category: {$ref: '#/definitions/CategoryWithRelations'}, + }, + }, + }, + }); + }); + }); +}); + +function expectValidJsonSchema(schema: JsonSchema) { + const ajv = new Ajv(); + const validate = ajv.compile( + require('ajv/lib/refs/json-schema-draft-06.json'), + ); + const isValid = validate(schema); + const result = isValid + ? 'JSON Schema is valid' + : ajv.errorsText(validate.errors!); + expect(result).to.equal('JSON Schema is valid'); +} diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 9824ea5b27a2..3609527ebcd1 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -14,23 +14,62 @@ import { import {JSONSchema6 as JSONSchema} from 'json-schema'; import {JSON_SCHEMA_KEY} from './keys'; +export interface JsonSchemaOptions { + includeRelations?: boolean; + visited?: {[key: string]: JSONSchema}; +} + /** * Gets the JSON Schema of a TypeScript model/class by seeing if one exists * in a cache. If not, one is generated and then cached. * @param ctor Contructor of class to get JSON Schema from */ -export function getJsonSchema(ctor: Function): JSONSchema { +export function getJsonSchema( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { // NOTE(shimks) currently impossible to dynamically update - const jsonSchema = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); - if (jsonSchema) { - return jsonSchema; + const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); + const key = options.includeRelations ? 'modelWithRelations' : 'modelOnly'; + + if (cached && cached[key]) { + return cached[key]; } else { - const newSchema = modelToJsonSchema(ctor); - MetadataInspector.defineMetadata(JSON_SCHEMA_KEY.key, newSchema, ctor); + const newSchema = modelToJsonSchema(ctor, options); + if (cached) { + cached[key] = newSchema; + } else { + MetadataInspector.defineMetadata( + JSON_SCHEMA_KEY.key, + {[key]: newSchema}, + ctor, + ); + } return newSchema; } } +export function getJsonSchemaRef( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + const schemaWithDefinitions = getJsonSchema(ctor, options); + const key = schemaWithDefinitions.title; + + // ctor is not a model + if (!key) return schemaWithDefinitions; + + const definitions = Object.assign({}, schemaWithDefinitions.definitions); + const schema = Object.assign({}, schemaWithDefinitions); + delete schema.definitions; + definitions[key] = schema; + + return { + $ref: `#/definitions/${key}`, + definitions, + }; +} + /** * Gets the wrapper function of primitives string, number, and boolean * @param type Name of type @@ -130,6 +169,27 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema { return result; } +export function modelToJsonSchemaRef( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + const schemaWithDefinitions = modelToJsonSchema(ctor, options); + const key = schemaWithDefinitions.title; + + // ctor is not a model + if (!key) return schemaWithDefinitions; + + const definitions = Object.assign({}, schemaWithDefinitions.definitions); + const schema = Object.assign({}, schemaWithDefinitions); + delete schema.definitions; + definitions[key] = schema; + + return { + $ref: `#/definitions/${key}`, + definitions, + }; +} + // NOTE(shimks) no metadata for: union, optional, nested array, any, enum, // string literal, anonymous types, and inherited properties @@ -138,16 +198,30 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema { * reflection API * @param ctor Constructor of class to convert from */ -export function modelToJsonSchema(ctor: Function): JSONSchema { +export function modelToJsonSchema( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + options.visited = options.visited || {}; + const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor); - const result: JSONSchema = {}; // returns an empty object if metadata is an empty object + // FIXME(bajtos) this pretty much breaks type validation. + // We should return `undefined` instead of an empty object. if (!(meta instanceof ModelDefinition)) { return {}; } - result.title = meta.title || ctor.name; + let title = meta.title || ctor.name; + if (options.includeRelations) { + title += 'WithRelations'; + } + + if (title in options.visited) return options.visited[title]; + + const result: JSONSchema = {title}; + options.visited[title] = result; if (meta.description) { result.description = meta.description; @@ -186,21 +260,54 @@ export function modelToJsonSchema(ctor: Function): JSONSchema { continue; } - const propSchema = getJsonSchema(referenceType); - - if (propSchema && Object.keys(propSchema).length > 0) { - result.definitions = result.definitions || {}; + const propSchema = getJsonSchema(referenceType, options); + includeReferencedSchema(referenceType.name, propSchema); + } - // delete nested definition - if (propSchema.definitions) { - for (const key in propSchema.definitions) { - result.definitions[key] = propSchema.definitions[key]; - } - delete propSchema.definitions; + if (options.includeRelations) { + for (const r in meta.relations) { + result.properties = result.properties || {}; + const relMeta = meta.relations[r]; + const targetType = resolveType(relMeta.target); + const targetSchema = getJsonSchema(targetType, options); + const targetTitle = targetSchema.title; + if (!targetTitle) { + throw new Error( + `The target of the relation ${title}.${relMeta.name} is not a model!`, + ); } + const targetRef = {$ref: `#/definitions/${targetTitle}`}; + + const propDef = relMeta.targetsMany + ? { + type: 'array', + items: targetRef, + } + : targetRef; - result.definitions[referenceType.name] = propSchema; + // IMPORTANT: r !== relMeta.name + // E.g. belongsTo sets r="categoryId" but name="category" + result.properties[relMeta.name] = + result.properties[relMeta.name] || propDef; + includeReferencedSchema(targetTitle, targetSchema); } } return result; + + function includeReferencedSchema(name: string, propSchema: JSONSchema) { + if (!propSchema || !Object.keys(propSchema).length) return; + + result.definitions = result.definitions || {}; + + // promote nested definition to the top level + if (propSchema.definitions) { + for (const key in propSchema.definitions) { + if (key === title) continue; + result.definitions[key] = propSchema.definitions[key]; + } + delete propSchema.definitions; + } + + result.definitions[name] = propSchema; + } } diff --git a/packages/repository-json-schema/src/keys.ts b/packages/repository-json-schema/src/keys.ts index 9975efa1749c..556e7eb144cf 100644 --- a/packages/repository-json-schema/src/keys.ts +++ b/packages/repository-json-schema/src/keys.ts @@ -10,6 +10,6 @@ import {JSONSchema6 as JSONSchema} from 'json-schema'; * Metadata key used to set or retrieve repository JSON Schema */ export const JSON_SCHEMA_KEY = MetadataAccessor.create< - JSONSchema, + {[options: string]: JSONSchema}, ClassDecorator >('loopback:json-schema'); diff --git a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts index 4080ee292ad7..11df6785ac25 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -217,6 +217,7 @@ class Order extends Entity { .addRelation({ name: 'customer', type: RelationType.belongsTo, + targetsMany: false, source: Order, target: () => Customer, keyFrom: 'customerId', @@ -253,6 +254,7 @@ class Customer extends Entity { .addRelation({ name: 'orders', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Order, keyTo: 'customerId', @@ -260,6 +262,7 @@ class Customer extends Entity { .addRelation({ name: 'reviewsAuthored', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Review, keyTo: 'authorId', @@ -267,6 +270,7 @@ class Customer extends Entity { .addRelation({ name: 'reviewsApproved', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Review, keyTo: 'approvedId', diff --git a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts index c95c7c98ff34..1212f693c7eb 100644 --- a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts @@ -49,6 +49,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -60,6 +61,7 @@ describe('relation decorator', () => { expect(AddressBook.definition.relations).to.eql({ addresses: { type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -93,6 +95,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -131,6 +134,7 @@ describe('relation decorator', () => { keyFrom: 'addressBookId', name: 'addressBook', type: 'belongsTo', + targetsMany: false, }, }); }); @@ -156,6 +160,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.belongsTo, + targetsMany: false, name: 'addressBook', source: Address, target: () => AddressBook, diff --git a/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts b/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts index 8a228d881e6c..3cdeb78f4898 100644 --- a/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts +++ b/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts @@ -48,6 +48,7 @@ function givenAnErrorInstance() { return new InvalidRelationError('a reason', { name: 'products', type: RelationType.hasMany, + targetsMany: true, source: Category, target: () => Product, }); diff --git a/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts index a4d19257a50d..a8b047faf0c3 100644 --- a/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts @@ -98,6 +98,7 @@ describe('createBelongsToAccessor', () => { const relationMeta: BelongsToDefinition = { type: RelationType.belongsTo, + targetsMany: false, name: 'category', source: Product, target: () => Category, @@ -165,6 +166,7 @@ describe('createBelongsToAccessor', () => { ): BelongsToDefinition { const defaults: BelongsToDefinition = { type: RelationType.belongsTo, + targetsMany: false, name: 'company', source: Company, target: () => Customer, diff --git a/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts index 5e5b9ed6cd7f..181a3b4da5cd 100644 --- a/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts @@ -114,6 +114,7 @@ describe('createHasManyRepositoryFactory', () => { const defaults: HasManyDefinition = { type: RelationType.hasMany, + targetsMany: true, name: 'customers', target: () => Customer, source: Company, diff --git a/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts index fc727dc2af9f..9dbc00e8d5ef 100644 --- a/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts @@ -125,6 +125,7 @@ describe('createHasOneRepositoryFactory', () => { ): HasOneDefinition { const defaults: HasOneDefinition = { type: RelationType.hasOne, + targetsMany: false, name: 'address', target: () => Address, source: Customer, diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index 4de95d6b133d..2eabf346a320 100644 --- a/packages/repository/src/decorators/metadata.ts +++ b/packages/repository/src/decorators/metadata.ts @@ -10,7 +10,8 @@ import { MODEL_WITH_PROPERTIES_KEY, PropertyMap, } from './model.decorator'; -import {ModelDefinition} from '../model'; +import {ModelDefinition, RelationDefinitionMap} from '../model'; +import {RELATIONS_KEY} from '../relations'; export class ModelMetadataHelper { /** @@ -60,6 +61,16 @@ export class ModelMetadataHelper { options, ), ); + + meta.relations = Object.assign( + {}, + MetadataInspector.getAllPropertyMetadata( + RELATIONS_KEY, + target.prototype, + options, + ), + ); + MetadataInspector.defineMetadata( MODEL_WITH_PROPERTIES_KEY.key, meta, diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 8586f9375644..300d3cb428d0 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -209,6 +209,12 @@ export abstract class Model { json[p] = asJSON((this as AnyObject)[p]); } } + // Include relational links in the JSON representation + for (const r in def.relations) { + if (r in this) { + json[r] = asJSON((this as AnyObject)[r]); + } + } return json; } diff --git a/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts b/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts index b999ef69d95a..296c0734b285 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts @@ -55,6 +55,7 @@ export function belongsTo( // properties enforced by the decorator { type: RelationType.belongsTo, + targetsMany: false, source: decoratedTarget.constructor, target: targetResolver, }, diff --git a/packages/repository/src/relations/has-many/has-many.decorator.ts b/packages/repository/src/relations/has-many/has-many.decorator.ts index f67c66ed82c4..b10bbc0de063 100644 --- a/packages/repository/src/relations/has-many/has-many.decorator.ts +++ b/packages/repository/src/relations/has-many/has-many.decorator.ts @@ -28,6 +28,7 @@ export function hasMany( // properties enforced by the decorator { type: RelationType.hasMany, + targetsMany: true, source: decoratedTarget.constructor, target: targetResolver, }, diff --git a/packages/repository/src/relations/has-one/has-one.decorator.ts b/packages/repository/src/relations/has-one/has-one.decorator.ts index 4d4779a03e07..f338b4917b02 100644 --- a/packages/repository/src/relations/has-one/has-one.decorator.ts +++ b/packages/repository/src/relations/has-one/has-one.decorator.ts @@ -29,6 +29,7 @@ export function hasOne( // properties enforced by the decorator { type: RelationType.hasOne, + targetsMany: false, name: key, source: decoratedTarget.constructor, target: targetResolver, diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index e7912e812e25..4179df4b0f16 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -22,6 +22,13 @@ export interface RelationDefinitionBase { */ type: RelationType; + /** + * True for relations targeting multiple instances (e.g. HasMany), + * false for relations with a single target (e.g. BelongsTo, HasOne). + * This property is need by OpenAPI/JSON Schema generator. + */ + targetsMany: boolean; + /** * The relation name, typically matching the name of the accessor property * defined on the source model. For example "orders" or "customer". @@ -45,6 +52,7 @@ export interface RelationDefinitionBase { export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; + targetsMany: true; /** * The foreign key used by the target model. @@ -58,6 +66,7 @@ export interface HasManyDefinition extends RelationDefinitionBase { export interface BelongsToDefinition extends RelationDefinitionBase { type: RelationType.belongsTo; + targetsMany: false; /* * The foreign key in the source model, e.g. Order#customerId. @@ -72,6 +81,7 @@ export interface BelongsToDefinition extends RelationDefinitionBase { export interface HasOneDefinition extends RelationDefinitionBase { type: RelationType.hasOne; + targetsMany: false; /** * The foreign key used by the target model. diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index bd522d8e5392..426a36464c5a 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -88,8 +88,11 @@ export function ensurePromise(p: legacy.PromiseOrVoid): Promise { * Default implementation of CRUD repository using legacy juggler model * and data source */ -export class DefaultCrudRepository - implements EntityCrudRepository { +export class DefaultCrudRepository< + T extends Entity, + ID, + Relations extends object = {} +> implements EntityCrudRepository { modelClass: juggler.PersistedModelClass; /** @@ -334,7 +337,10 @@ export class DefaultCrudRepository } } - async find(filter?: Filter, options?: Options): Promise { + async find( + filter?: Filter, + options?: Options, + ): Promise<(T & Relations)[]> { const models = await ensurePromise( this.modelClass.find(filter as legacy.Filter, options), ); @@ -349,14 +355,18 @@ export class DefaultCrudRepository return this.toEntity(model); } - async findById(id: ID, filter?: Filter, options?: Options): Promise { + async findById( + id: ID, + filter?: Filter, + options?: Options, + ): Promise { const model = await ensurePromise( this.modelClass.findById(id, filter as legacy.Filter, options), ); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } - return this.toEntity(model); + return this.toEntity(model); } update(entity: T, options?: Options): Promise { @@ -441,11 +451,11 @@ export class DefaultCrudRepository throw new Error('Not implemented'); } - protected toEntity(model: juggler.PersistedModel): T { - return new this.entityClass(model.toObject()) as T; + protected toEntity(model: juggler.PersistedModel): R { + return new this.entityClass(model.toObject()) as R; } - protected toEntities(models: juggler.PersistedModel[]): T[] { - return models.map(m => this.toEntity(m)); + protected toEntities(models: juggler.PersistedModel[]): R[] { + return models.map(m => this.toEntity(m)); } } diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 1f8a36d9ea49..be58cea9a146 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -3,20 +3,20 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, ValueObject, Model} from '../model'; import { - DataObject, - Options, AnyObject, Command, + Count, + DataObject, NamedParameters, + Options, PositionalParameters, - Count, } from '../common-types'; -import {DataSource} from '../datasource'; import {CrudConnector} from '../connectors'; -import {Filter, Where} from '../query'; +import {DataSource} from '../datasource'; import {EntityNotFoundError} from '../errors'; +import {Entity, Model, ValueObject} from '../model'; +import {Filter, Where} from '../query'; // tslint:disable:no-unused @@ -40,8 +40,10 @@ export interface ExecutableRepository extends Repository { /** * Basic CRUD operations for ValueObject and Entity. No ID is required. */ -export interface CrudRepository - extends Repository { +export interface CrudRepository< + T extends ValueObject | Entity, + Relations extends object = {} +> extends Repository { /** * Create a new record * @param dataObject The data to be created @@ -64,7 +66,7 @@ export interface CrudRepository * @param options Options for the operations * @returns A promise of an array of records found */ - find(filter?: Filter, options?: Options): Promise; + find(filter?: Filter, options?: Options): Promise<(T & Relations)[]>; /** * Updating matching records with attributes from the data object @@ -105,9 +107,11 @@ export interface EntityRepository /** * CRUD operations for a repository of entities */ -export interface EntityCrudRepository - extends EntityRepository, - CrudRepository { +export interface EntityCrudRepository< + T extends Entity, + ID, + Relations extends object = {} +> extends EntityRepository, CrudRepository { // entityClass should have type "typeof T", but that's not supported by TSC entityClass: typeof Entity & {prototype: T}; @@ -146,7 +150,11 @@ export interface EntityCrudRepository * @param options Options for the operations * @returns A promise of an entity found for the id */ - findById(id: ID, filter?: Filter, options?: Options): Promise; + findById( + id: ID, + filter?: Filter, + options?: Options, + ): Promise; /** * Update an entity by id with property/value pairs in the data object