diff --git a/packages/repository/package.json b/packages/repository/package.json index d7fabdae791b..e214d858c737 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -25,7 +25,9 @@ "devDependencies": { "@loopback/build": "^0.6.5", "@loopback/testlab": "^0.10.4", - "@types/node": "^10.1.1" + "@types/lodash": "^4.14.108", + "@types/node": "^10.1.1", + "lodash": "^4.17.10" }, "dependencies": { "@loopback/context": "^0.11.2", diff --git a/packages/repository/src/query.ts b/packages/repository/src/query.ts index dbeca55e72b7..0f5a0dd7219e 100644 --- a/packages/repository/src/query.ts +++ b/packages/repository/src/query.ts @@ -279,6 +279,19 @@ export class WhereBuilder { exists(key: string, val?: boolean): this { return this.add({[key]: {exists: !!val || val == null}}); } + /** + * Add a where object. For conflicting keys with the existing where object, + * create an `and` clause. + * @param where Where filter + */ + impose(where: Where): this { + if (!this.where) { + this.where = where || {}; + } else { + this.add(where); + } + return this; + } /** * Get the where object @@ -429,6 +442,36 @@ export class FilterBuilder { return this; } + /** + * Add a filter object. For conflicting keys with its where object, + * create an `and` clause. For any other properties, throw an error. + * @param filter filter object + */ + impose(filter: Filter): this { + if (!this.filter) { + this.filter = filter || {}; + } else if (this.filter) { + if ( + filter.fields || + filter.include || + filter.limit || + filter.offset || + filter.order || + filter.skip + ) { + throw new Error( + 'merging strategy for selection, pagination, and sorting not implemented', + ); + } + if (filter.where) { + this.filter.where = new WhereBuilder(this.filter.where) + .impose(filter.where) + .build(); + } + } + return this; + } + /** * Return the filter object */ diff --git a/packages/repository/src/repositories/constraint-utils.ts b/packages/repository/src/repositories/constraint-utils.ts new file mode 100644 index 000000000000..6c342a1bfbe5 --- /dev/null +++ b/packages/repository/src/repositories/constraint-utils.ts @@ -0,0 +1,87 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Filter, WhereBuilder, Where, FilterBuilder} from '../query'; +import {AnyObject, DataObject} from '../common-types'; +import {cloneDeep, isArray} from 'lodash'; +import {Entity} from '../model'; + +/** + * 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 { + const builder = new FilterBuilder(originalFilter); + return builder.impose(constraint).build(); +} + +/** + * A utility function which takes a where filter and enforces constraint(s) + * on it + * @param originalWhere the where 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 { + const builder = new WhereBuilder(originalWhere); + return builder.impose(constraint).build(); +} + +export function constrainDataObject( + originalData: DataObject, + constraint: AnyObject, +): DataObject; + +export 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 +export function constrainDataObject(originalData: any, constraint: any): any { + const constrainedData = cloneDeep(originalData); + if (typeof originalData === 'object') { + addConstraintToDataObject(constrainedData, constraint); + } else if (isArray(originalData)) { + for (const data in originalData) { + addConstraintToDataObject(constrainedData[data], constraint[data]); + } + } + return constrainedData; + + function addConstraintToDataObject( + modelData: AnyObject, + constrainObject: AnyObject, + ) { + for (const c in constrainObject) { + if (c in modelData) { + console.warn( + 'Overwriting %s with %s', + modelData[c], + constrainObject[c], + ); + } + modelData[c] = constrainObject[c]; + } + } +} diff --git a/packages/repository/src/repositories/index.ts b/packages/repository/src/repositories/index.ts index b252cee98be8..6550183558c6 100644 --- a/packages/repository/src/repositories/index.ts +++ b/packages/repository/src/repositories/index.ts @@ -6,3 +6,6 @@ export * from './kv.repository'; export * from './legacy-juggler-bridge'; export * from './repository'; +export * from './relation.factory'; +export * from './relation.repository'; +export * from './constraint-utils'; diff --git a/packages/repository/src/repositories/relation.factory.ts b/packages/repository/src/repositories/relation.factory.ts new file mode 100644 index 000000000000..24c110319a77 --- /dev/null +++ b/packages/repository/src/repositories/relation.factory.ts @@ -0,0 +1,55 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {EntityCrudRepository} from './repository'; +import {Class} from '../common-types'; +import {RelationType} from '../decorators/relation.decorator'; +import {Entity} from '../model'; +import { + HasManyEntityCrudRepository, + DefaultHasManyEntityCrudRepository, +} from './relation.repository'; + +export interface RelationDefinitionBase { + type: RelationType; + modelFrom: Class | string; + keyTo: string; + keyFrom: string; +} + +export interface HasManyDefinition extends RelationDefinitionBase { + type: RelationType.hasMany; +} +/** + * Enforces a constraint on a repository based on a relationship contract + * between models. Returns a relational repository that exposes applicable CRUD + * method APIs for the related target repository. For example, if a Customer model is + * related to an Order model via a HasMany relation, then, the relational + * repository returned by this method would be constrained by a Customer model + * instance's id(s). + * + * @param constraint The constraint to apply to the target repository. For + * example, {id: '5'}. + * @param relationMetadata The relation metadata used to used to describe the + * relationship and determine how to apply the constraint. + * @param targetRepository The repository which represents the target model of a + * relation attached to a datasource. + * + */ +export function hasManyRepositoryFactory( + sourceModelId: SourceID, + relationMetadata: HasManyDefinition, + targetRepository: EntityCrudRepository, +): HasManyEntityCrudRepository { + switch (relationMetadata.type) { + case RelationType.hasMany: + const fkConstraint = {[relationMetadata.keyTo]: sourceModelId}; + + return new DefaultHasManyEntityCrudRepository( + targetRepository, + fkConstraint, + ); + } +} diff --git a/packages/repository/src/repositories/relation.repository.ts b/packages/repository/src/repositories/relation.repository.ts new file mode 100644 index 000000000000..ab075210b0e5 --- /dev/null +++ b/packages/repository/src/repositories/relation.repository.ts @@ -0,0 +1,62 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {EntityCrudRepository} from './repository'; +import {constrainDataObject, constrainFilter} from './constraint-utils'; +import {AnyObject, Options} from '../common-types'; +import {Entity} from '../model'; +import {Filter} from '../query'; + +/** + * CRUD operations for a target repository of a HasMany relation + */ +export interface HasManyEntityCrudRepository { + /** + * 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 + */ + create(targetModelData: Partial, options?: Options): Promise; + /** + * Find target model instance(s) + * @param Filter A filter object for where, order, limit, etc. + * @param options Options for the operation + * @returns A promise which resolves with the found target instance(s) + */ + find(filter?: Filter | undefined, options?: Options): Promise; +} + +export class DefaultHasManyEntityCrudRepository< + T extends Entity, + TargetRepository extends EntityCrudRepository, + ID +> implements HasManyEntityCrudRepository { + /** + * 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 targetRepository: TargetRepository, + public constraint: AnyObject, + ) {} + + async create(targetModelData: Partial, options?: Options): Promise { + return await this.targetRepository.create( + constrainDataObject(targetModelData, this.constraint) as Partial, + options, + ); + } + + async find(filter?: Filter | undefined, options?: Options): Promise { + return await this.targetRepository.find( + constrainFilter(filter, this.constraint), + options, + ); + } +} diff --git a/packages/repository/test/acceptance/has-many.relation.acceptance.ts b/packages/repository/test/acceptance/has-many.relation.acceptance.ts new file mode 100644 index 000000000000..34edf3f06b7f --- /dev/null +++ b/packages/repository/test/acceptance/has-many.relation.acceptance.ts @@ -0,0 +1,126 @@ +// 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 { + model, + property, + Entity, + DefaultCrudRepository, + juggler, + EntityCrudRepository, + hasManyRepositoryFactory, + HasManyDefinition, + RelationType, +} from '../..'; +import {expect} from '@loopback/testlab'; + +describe('HasMany relation', () => { + // Given a Customer and Order models - see definitions at the bottom + + beforeEach(givenCrudRepositoriesForCustomerAndOrder); + + let existingCustomerId: number; + //FIXME: this should be inferred from relational decorators + let customerHasManyOrdersRelationMeta: HasManyDefinition; + + beforeEach(async () => { + existingCustomerId = (await givenPersistedCustomerInstance()).id; + customerHasManyOrdersRelationMeta = givenHasManyRelationMetadata(); + }); + + it('can create an instance of the related model', async () => { + // A controller method - CustomerOrdersController.create() + // customerRepo and orderRepo would be injected via constructor arguments + async function create(customerId: number, orderData: Partial) { + // Ideally, we would like to write + // customerRepo.orders.create(customerId, orderData); + // or customerRepo.orders({id: customerId}).* + // The initial "involved" implementation is below + + //FIXME: should be automagically instantiated via DI or other means + const customerOrders = hasManyRepositoryFactory( + customerId, + customerHasManyOrdersRelationMeta, + orderRepo, + ); + return await customerOrders.create(orderData); + } + + const description = 'an order desc'; + const order = await create(existingCustomerId, {description}); + + expect(order.toObject()).to.containDeep({ + customerId: existingCustomerId, + description, + }); + const persisted = await orderRepo.findById(order.id); + expect(persisted.toObject()).to.deepEqual(order.toObject()); + }); + + // This should be enforced by the database to avoid race conditions + it('reject create request when the customer does not exist'); + + //--- HELPERS ---// + + @model() + class Customer extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @property({ + type: 'string', + }) + name: string; + } + + @model() + class Order extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @property({ + type: 'string', + required: true, + }) + description: string; + + @property({ + type: 'number', + required: true, + }) + customerId: number; + } + + let customerRepo: EntityCrudRepository< + Customer, + typeof Customer.prototype.id + >; + let orderRepo: EntityCrudRepository; + function givenCrudRepositoriesForCustomerAndOrder() { + const db = new juggler.DataSource({connector: 'memory'}); + + customerRepo = new DefaultCrudRepository(Customer, db); + orderRepo = new DefaultCrudRepository(Order, db); + } + + async function givenPersistedCustomerInstance() { + return customerRepo.create({name: 'a customer'}); + } + + function givenHasManyRelationMetadata(): HasManyDefinition { + return { + modelFrom: Customer, + keyFrom: 'id', + keyTo: 'customerId', + type: RelationType.hasMany, + }; + } +}); diff --git a/packages/repository/test/unit/query/query-builder.unit.ts b/packages/repository/test/unit/query/query-builder.unit.ts index 748ce61ae003..48ef0b58304a 100644 --- a/packages/repository/test/unit/query/query-builder.unit.ts +++ b/packages/repository/test/unit/query/query-builder.unit.ts @@ -143,6 +143,12 @@ describe('WhereBuilder', () => { .build(); expect(where).to.eql({y: 'y', a: 1, b: {gt: 2}, c: {lt: 2}, x: 'x'}); }); + + it('constrains an existing where object with another where filter', () => { + const builder = new WhereBuilder({x: 'x'}); + const where = builder.impose({x: 'y', z: 'z'}).build(); + expect(where).to.be.deepEqual({and: [{x: 'x'}, {x: 'y', z: 'z'}]}); + }); }); describe('FilterBuilder', () => { @@ -324,6 +330,51 @@ describe('FilterBuilder', () => { include: [{relation: 'orders'}, {relation: 'friends'}], }); }); + + it('imposes a constraint with only a where object on an existing filter', () => { + const filterBuilder = new FilterBuilder() + .fields({a: true}, 'b') + .include('orders') + .limit(5) + .offset(2) + .order('a ASC') + .where({x: 'x'}); + filterBuilder.impose({where: {x: 'y', z: 'z'}}); + expect(filterBuilder.build()).to.have.properties([ + 'fields', + 'include', + 'limit', + 'offset', + 'order', + ]); + expect(filterBuilder.build()).to.have.property('where', { + and: [{x: 'x'}, {x: 'y', z: 'z'}], + }); + }); + + it('throws an error when imposing a constraint filter with unsupported properties', () => { + const filterBuilder = new FilterBuilder() + .fields({a: true}, 'b') + .include('orders') + .limit(5) + .offset(2) + .order('a ASC') + .where({x: 'x'}); + const constraint = new FilterBuilder() + .fields({a: false}, {c: false}) + .include({relation: 'orders', scope: {limit: 5}}) + .limit(10) + .offset(3) + .order('b DESC', 'a DESC', 'c ASC') + .where({x: 'y', y: 'z'}) + .build(); + + expect(() => { + filterBuilder.impose(constraint); + }).to.throw( + /merging strategy for selection, pagination, and sorting not implemented/, + ); + }); }); describe('FilterTemplate', () => {