diff --git a/docs/site/Model-relations.md b/docs/site/Model-relations.md new file mode 100644 index 000000000000..e9ed05f00a77 --- /dev/null +++ b/docs/site/Model-relations.md @@ -0,0 +1,27 @@ +- Relations are defined between models via relational properties on the source + {model, repository}. The relation metadata from the relational property should be used to construct the constrained repository instance (we need the source instance, target repository and constraint to do so). +- The relational access needs to be configured/resolved between repositories. + For example, a CustomerRepository and an OrderRepository are needed to perform + CRUD operations for a given customer and his/her orders. +- Repository interfaces for relations define the available CRUD methods based on + the relation type. Default relation repository classes implement those + interfaces and enforce constraints on them using utility functions. +- We should support LB3 inclusion for related models which returns an array of + related model instances as plain data objects. The array is populated only + when the caller specifies the `includes` inside the filter. This would be a + plural of the related model, in the case of customer has many orders, it would + be `orders`. +- The metadata `as` from the relation decorators should be inferred from the + relational property. +- Infer target repository when related models are backed by the same + datasource, otherwise let user explicitly provide the target repository +- Remove `execute` function out of `Repository` interface and into its own + interface for arbitrary SQL commands. +- Split `Repository` interface into different interfaces based on the + persistence function type i.e. `LookupRepository` interface to have all the + Retrieval methods, `WriteRepository` (I'm sure there is a better name), would + have the create methods, `MutationRepository` might have the update and + related methods (this might fall under the previous one), and + DestroyRepository for deletes. + - Explore the use of a mixin for a smart way of sharing the implementation + bits from the different repositories. diff --git a/packages/repository/package.json b/packages/repository/package.json index 9e4812f90ba0..0e92854862a9 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -24,8 +24,10 @@ "license": "MIT", "devDependencies": { "@loopback/build": "^0.6.4", + "@types/lodash": "^4.14.108", + "@types/node": "^8.10.4", "@loopback/testlab": "^0.10.3", - "@types/node": "^8.10.4" + "lodash": "^4.17.10" }, "dependencies": { "@loopback/context": "^0.11.1", diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index 4de95d6b133d..f6a1493acd9a 100644 --- a/packages/repository/src/decorators/metadata.ts +++ b/packages/repository/src/decorators/metadata.ts @@ -11,6 +11,7 @@ import { PropertyMap, } from './model.decorator'; import {ModelDefinition} from '../model'; +import {RELATIONS_KEY, RelationMap} from '.'; export class ModelMetadataHelper { /** @@ -61,10 +62,18 @@ export class ModelMetadataHelper { ), ); MetadataInspector.defineMetadata( - MODEL_WITH_PROPERTIES_KEY.key, + MODEL_WITH_PROPERTIES_KEY, meta, target, ); + meta.relations = Object.assign( + {}, + MetadataInspector.getAllPropertyMetadata( + RELATIONS_KEY, + target.prototype, + options, + ), + ); return meta; } } diff --git a/packages/repository/src/decorators/relation.decorator.ts b/packages/repository/src/decorators/relation.decorator.ts index ea1638e1f6ab..bb8e1352b718 100644 --- a/packages/repository/src/decorators/relation.decorator.ts +++ b/packages/repository/src/decorators/relation.decorator.ts @@ -6,7 +6,7 @@ import {Class} from '../common-types'; import {Entity} from '../model'; -import {PropertyDecoratorFactory} from '@loopback/context'; +import {PropertyDecoratorFactory, MetadataMap} from '@loopback/context'; // tslint:disable:no-any @@ -28,6 +28,8 @@ export class RelationMetadata { as: string; } +export type RelationMap = MetadataMap; + /** * Decorator for relations * @param definition diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 67e2c50401de..03ecbdb87584 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -5,6 +5,7 @@ import {Options, AnyObject, DataObject} from './common-types'; import {Type} from './types'; +import {RelationMetadata} from '.'; /** * This module defines the key classes representing building blocks for Domain @@ -55,6 +56,7 @@ export class ModelDefinition { readonly name: string; properties: {[name: string]: PropertyDefinition}; settings: {[name: string]: any}; + relations: {[name: string]: RelationMetadata}; // indexes: Map; [attribute: string]: any; // Other attributes @@ -62,7 +64,7 @@ export class ModelDefinition { if (typeof nameOrDef === 'string') { nameOrDef = {name: nameOrDef}; } - const {name, properties, settings} = nameOrDef; + const {name, properties, settings, relations} = nameOrDef; this.name = name; @@ -74,6 +76,7 @@ export class ModelDefinition { } this.settings = settings || new Map(); + this.relations = relations || {}; } /** diff --git a/packages/repository/src/repositories/relation.repository.ts b/packages/repository/src/repositories/relation.repository.ts new file mode 100644 index 000000000000..99db4fa57284 --- /dev/null +++ b/packages/repository/src/repositories/relation.repository.ts @@ -0,0 +1,307 @@ +import {DefaultCrudRepository, EntityCrudRepository} from '.'; +import { + Entity, + Filter, + AnyObject, + Where, + DataObject, + Options, + WhereBuilder, +} from '..'; +import {cloneDeep, isArray} from 'lodash'; + +/** + * CRUD operations for a target repository of a HasMany relation + */ +export interface HasManyEntityCrudRepository + extends EntityCrudRepository { + /** + * Build a target model instance + * @param targetModelData The target model data + * @returns A promise of the created model instance + */ + build(targetModelData: DataObject): Promise; +} + +export class DefaultHasManyEntityCrudRepository< + S extends Entity, + T extends Entity, + TargetRepository extends DefaultCrudRepository, + ID +> implements HasManyEntityCrudRepository { + public constraint: AnyObject = {}; + /** + * Constructor of DefaultHasManyEntityCrudRepository + * @param sourceInstance the source model instance + * @param targetRepository the related target model repository instance + * @param foreignKeyName the foreign key name to constrain the target repository + * instance + */ + constructor( + public sourceInstance: S, + public targetRepository: TargetRepository, + public foreignKeyName: string, + ) { + let targetProp = this.targetRepository.entityClass.definition.properties[ + this.foreignKeyName + ].type; + this.constraint[ + this.foreignKeyName + ] = sourceInstance.getId() as typeof targetProp; + } + execute( + command: string | AnyObject, + // tslint:disable-next-line:no-any + parameters: any[] | AnyObject, + options?: Options, + ): Promise { + throw new Error('Method not implemented.'); + } + /** + * Create a target model instance + * @param targetModelData The target model data + * @param options Options for the operation + * @returns A promise which resolves to the newly created target model instance + */ + async create(targetModelData: Partial, options?: Options): Promise { + return await this.targetRepository.create( + constrainDataObject(targetModelData, this.constraint) as Partial, + options, + ); + } + /** + * Build a target model instance + * @param targetModelData The target model data + * @returns A promise of the created model instance + */ + build(targetModelData: DataObject): Promise { + throw new Error('Method not implemented.'); + } + /** + * Find a related entity by id + * @param id The foreign key + * @param options Options for the operation + * @returns A promise of an entity found for the id + */ + async findById( + id: ID, + filter?: Filter | undefined, + options?: Options, + ): Promise { + throw new Error( + `Method is not supported via HasMany relations. Use ${ + this.targetRepository.entityClass.name + }'s findById CRUD method directly.`, + ); + } + /** + * Update a related entity by foreign key + * @param data Data attributes to be updated + * @param id Value for the foreign key + * @param options Options for the operation + * @returns Promise if the entity is updated, otherwise + * Promise + */ + updateById(id: ID, data: Partial, options?: Options): Promise { + throw new Error( + `Method is not supported via HasMany relations. Use ${ + this.targetRepository.entityClass.name + }'s updateById CRUD method directly.`, + ); + } + /** + * Delete a related entity by id + * @param id Value for the entity foreign key + * @param options Options for the operation + * @returns Promise if an entity is deleted for the id, otherwise + * Promise + */ + deleteById(id: ID, options?: Options): Promise { + throw new Error( + `Method is not supported via HasMany relations. Use ${ + this.targetRepository.entityClass.name + }'s deleteById CRUD method directly.`, + ); + } + /** + * Check if the related entity exists for the given foreign key + * @param id Value for the entity foreign key + * @param options Options for the operation + * @returns Promise if an entity exists for the id, otherwise + * Promise + */ + exists(id: ID, options?: Options): Promise { + throw new Error( + `Method is not supported via HasMany relations. Use ${ + this.targetRepository.entityClass.name + }'s exists CRUD method directly.`, + ); + } + + async save(entity: DataObject, options?: Options): Promise { + return await this.targetRepository.save( + constrainDataObject(entity, this.constraint) as T, + options, + ); + } + async update(entity: DataObject, options?: Options): Promise { + return await this.targetRepository.update( + constrainDataObject(entity, this.constraint) as T, + options, + ); + } + async delete(entity: DataObject, options?: Options): Promise { + return await this.targetRepository.delete( + constrainDataObject(entity, this.constraint) as T, + options, + ); + } + replaceById( + id: ID, + data: DataObject, + options?: Options, + ): Promise { + throw new Error( + `Method is not supported via HasMany relations. Use ${ + this.targetRepository.entityClass.name + }'s replaceById CRUD method directly`, + ); + } + async createAll( + dataObjects: DataObject[], + options?: Options, + ): Promise { + return await this.targetRepository.createAll( + constrainDataObject(dataObjects, this.constraint) as Partial[], + options, + ); + } + async find(filter?: Filter | undefined, options?: Options): Promise { + return await this.targetRepository.find( + constrainFilter(filter, this.constraint), + options, + ); + } + async updateAll( + dataObject: DataObject, + where?: Where | undefined, + options?: Options, + ): Promise { + return await this.targetRepository.updateAll( + constrainDataObject(dataObject, this.constraint) as Partial, + where, + options, + ); + } + async deleteAll( + where?: Where | undefined, + options?: Options, + ): Promise { + return await this.targetRepository.deleteAll( + constrainWhere(where, this.constraint), + options, + ); + } + async count(where?: Where | undefined, options?: Options): Promise { + return await this.targetRepository.count( + constrainWhere(where, this.constraint), + options, + ); + } +} + +/** + * A utility function which takes a filter and enforces constraint(s) + * on it + * @param originalFilter the filter to apply the constrain(s) to + * @param constraint the constraint which is to be applied on the filter + * @returns Filter the modified filter with the constraint, otherwise + * the original filter + */ +export function constrainFilter( + originalFilter: Filter | undefined, + constraint: AnyObject, +): Filter { + let constrainedFilter: Filter = {}; + let constrainedWhere = new WhereBuilder(); + for (const c in constraint) { + constrainedWhere.eq(c, constraint[c]); + } + if (originalFilter) { + constrainedFilter = cloneDeep(originalFilter); + if (originalFilter.where) { + constrainedFilter.where = constrainedWhere.and( + originalFilter.where, + ).where; + } + } else if (originalFilter === undefined) { + constrainedFilter.where = constrainedWhere.where; + } + return constrainedFilter; +} + +/** + * A utility function which takes a filter and enforces constraint(s) + * on it + * @param originalFilter the filter to apply the constrain(s) to + * @param constraint the constraint which is to be applied on the filter + * @returns Filter the modified filter with the constraint, otherwise + * the original filter + */ +export function constrainWhere( + originalWhere: Where | undefined, + constraint: AnyObject, +): Where { + let constrainedWhere = new WhereBuilder(); + for (const c in constraint) { + constrainedWhere.eq(c, constraint[c]); + } + if (originalWhere) { + constrainedWhere.where = constrainedWhere.and(originalWhere).where; + } + return constrainedWhere.where; +} + +function constrainDataObject( + originalData: DataObject, + constraint: AnyObject, +): DataObject; + +function constrainDataObject( + originalData: DataObject[], + constraint: AnyObject, +): DataObject[]; +/** + * A utility function which takes a model instance data and enforces constraint(s) + * on it + * @param originalData the model data to apply the constrain(s) to + * @param constraint the constraint which is to be applied on the filter + * @returns the modified data with the constraint, otherwise + * the original instance data + */ +// tslint:disable-next-line:no-any +function constrainDataObject(originalData: any, constraint: any): any { + let constrainedData = cloneDeep(originalData); + if (typeof originalData === 'object') { + addConstraintToDataObject(constraint, constrainedData); + } else if (isArray(originalData)) { + for (const data in originalData) { + addConstraintToDataObject(constraint, constrainedData); + } + } + return constrainedData; + + // tslint:disable-next-line:no-any + function addConstraintToDataObject(constrainObject: any, modelData: any) { + for (const c in constraint) { + if (constrainedData[c]) { + console.warn( + 'Overwriting %s with %s', + constrainedData[c], + constraint[c], + ); + } + constrainedData[c] = constraint[c]; + } + } +} diff --git a/packages/repository/test/acceptance/relations/hasMany/fixtures/datasources/memory.datasource.ts b/packages/repository/test/acceptance/relations/hasMany/fixtures/datasources/memory.datasource.ts new file mode 100644 index 000000000000..10d5897824de --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/fixtures/datasources/memory.datasource.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/* tslint:disable:no-unused-variable */ +// juggler import is required to infer DataSourceConstructor type +import {DataSourceConstructor, juggler} from '../../../../../../src'; + +export const memoryDs = new DataSourceConstructor({ + connector: 'memory', +}); diff --git a/packages/repository/test/acceptance/relations/hasMany/hasMany-relation.acceptance.ts b/packages/repository/test/acceptance/relations/hasMany/hasMany-relation.acceptance.ts new file mode 100644 index 000000000000..4f6c779309fe --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/hasMany-relation.acceptance.ts @@ -0,0 +1,80 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {OrderRepository} from './repositories/order.repository'; +import {CustomerRepository} from './repositories/customer.repository'; +import {memoryDs} from './fixtures/datasources/memory.datasource'; +import {Customer} from './models/customer.model'; +import {DefaultHasManyEntityCrudRepository} from '../../../../src/repositories/relation.repository'; + +describe('hasMany relationship', () => { + let orderRepo: OrderRepository; + let customerRepo: CustomerRepository; + beforeEach(givenCustomerAndOrderRepositories); + + it('creates a customer with an order and retrieves it', async () => { + const c1: Customer = await customerRepo.create({ + name: 'John Smith', + email: 'johnsmith@yahoo.com', + }); + + // TODO: make this happen automatically + c1.customerOrders = new DefaultHasManyEntityCrudRepository( + c1, + orderRepo, + 'customerId', + ); + const order = await c1.customerOrders.create({ + desc: 'order1 description', + date: new Date().toISOString(), + }); + + //make sure that the order created has + // customerId constraint enforced + expect(order).to.have.properties({ + // FIXME: use something more elegant than toString + customerId: c1.getId().toString(), + desc: 'order1 description', + }); + + // if we were to find the order from the order repository + // we should get the same result. + let foundOrder = await orderRepo.findById(order.id); + expect(foundOrder).to.have.properties({ + desc: 'order1 description', + customerId: c1.getId().toString(), + }); + + /* // we should also be able to support inclusion of orders from + // a customer repository get request + const includeFilter = new FilterBuilder().include('orders').filter; + let foundCustomer = await customerRepo.findById(c1.id, includeFilter); + expect(foundCustomer).to.have.properties({ + name: c1.name, + email: c1.email, + orders: [ + { + desc: 'order1 description', + customerId: c1.getId(), + id: order.id, + }, + ], + }); + let orderViaCustomer = await foundCustomer.orders.find({ + where: {customerId: foundCustomer.id}, + }); + + expect(orderViaCustomer).to.have.properties({ + desc: 'order1 description', + customerId: foundCustomer.getId(), + }); */ + }); + + function givenCustomerAndOrderRepositories() { + orderRepo = new OrderRepository(memoryDs); + customerRepo = new CustomerRepository(memoryDs); + } +}); diff --git a/packages/repository/test/acceptance/relations/hasMany/models/customer.model.ts b/packages/repository/test/acceptance/relations/hasMany/models/customer.model.ts new file mode 100644 index 000000000000..1e9fa1046cd2 --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/models/customer.model.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Entity} from '../../../../../'; +import {Order} from './order.model'; +import {model, property, hasMany} from '../../../../../src/decorators'; +import {HasManyEntityCrudRepository} from '../../../../../src/repositories/relation.repository'; + +@model() +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + }) + id?: number; + + @property({ + type: 'string', + }) + name: string; + + @property({ + type: 'string', + regexp: '^w+([.-]?w+)*@w+([.-]?w+)*(.w{2,3})+$', + }) + email: string; + + @hasMany({ + target: Order, + }) + customerOrders: HasManyEntityCrudRepository; +} diff --git a/packages/repository/test/acceptance/relations/hasMany/models/order.model.ts b/packages/repository/test/acceptance/relations/hasMany/models/order.model.ts new file mode 100644 index 000000000000..6c2ebd33e737 --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/models/order.model.ts @@ -0,0 +1,41 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Entity} from '../../../../../'; +import {model, property, belongsTo} from '../../../../../src/decorators'; +import {CustomerRepository} from '../repositories/customer.repository'; + +@model() +export class Order extends Entity { + @property({ + type: 'number', + id: true, + }) + id?: number; + + @property({ + type: 'string', + required: true, + }) + desc?: string; + + @property({ + type: 'date', + }) + date?: string; + + @property({ + type: 'string', + required: true, + }) + customerId: string; + + @belongsTo({ + target: 'Customer', + foreignKey: 'customerId', + }) + //placeholder type + customer: CustomerRepository; +} diff --git a/packages/repository/test/acceptance/relations/hasMany/repositories/customer.repository.ts b/packages/repository/test/acceptance/relations/hasMany/repositories/customer.repository.ts new file mode 100644 index 000000000000..22050c12c6e7 --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/repositories/customer.repository.ts @@ -0,0 +1,20 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Customer} from '../models/customer.model'; +import {inject, Context} from '@loopback/core'; +import { + DefaultCrudRepository, + DataSourceType, +} from '../../../../../src/repositories'; + +export class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id +> { + constructor(protected datasource: DataSourceType) { + super(Customer, datasource); + } +} diff --git a/packages/repository/test/acceptance/relations/hasMany/repositories/order.repository.ts b/packages/repository/test/acceptance/relations/hasMany/repositories/order.repository.ts new file mode 100644 index 000000000000..65d6b231a4ce --- /dev/null +++ b/packages/repository/test/acceptance/relations/hasMany/repositories/order.repository.ts @@ -0,0 +1,19 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + DefaultCrudRepository, + DataSourceType, +} from '../../../../../src/repositories'; +import {Order} from '../models/order.model'; + +export class OrderRepository extends DefaultCrudRepository< + Order, + typeof Order.prototype.id +> { + constructor(protected datasource: DataSourceType) { + super(Order, datasource); + } +} diff --git a/packages/repository/test/unit/decorator/metadata.unit.ts b/packages/repository/test/unit/decorator/metadata.unit.ts index abd66970785b..b709fc8cb9a1 100644 --- a/packages/repository/test/unit/decorator/metadata.unit.ts +++ b/packages/repository/test/unit/decorator/metadata.unit.ts @@ -57,6 +57,7 @@ describe('Repository', () => { name: 'Samoflange', properties: {}, settings: new Map(), + relations: {}, }), ); });