diff --git a/docs/site/HasManyThrough-relation.md b/docs/site/HasManyThrough-relation.md new file mode 100644 index 000000000000..6aa48a62a69d --- /dev/null +++ b/docs/site/HasManyThrough-relation.md @@ -0,0 +1,205 @@ +--- +lang: en +title: 'hasManyThrough Relation' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/HasMany-relation.html +--- + +{% include important.html content="The underlying implementation may change in the near future. +If some of the changes break backward-compatibility a semver-major may not +be released. +" %} + +## Overview + +A `hasManyThrough` relation sets up a many-to-many connection with another model. This relation indicates that the declaring model can be matched with zero or more instances of another model by proceeding through a third model. For example, in an application for a medical practice where patients make appointments to see physicians, the relevant relation declarations might be: + +![hasManyThrough relation illustration](./imgs/hasManyThrough-relation-example.png) + +The `through` model, Appointment, has two foreign key properties, physicianId and patientId, that reference the primary keys in the declaring model, Physician, and the target model, Patient. + +## Defining a hasManyThrough Relation + +A `hasManyThrough` relation is defined in a model using the `@hasMany` decorator. + +The following example shows how to define a `hasManyThrough` between a `Customer` and `Seller` +model through an `Order` model. + +_models/customer.model.ts_ +```ts +import {Entity, property, hasMany} from '@loopback/repository'; +import {Order} from './order.model'; +import {Seller} from './seller.model'; + +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @hasMany(() => Seller, {through: () => Order}) + sellers?: Seller[]; + + constructor(data: Partial) { + super(data); + } +} +``` + +_models/seller.model.ts_ +```ts +import {Entity, property, hasMany} from '@loopback/repository'; +import {Order} from './order.model'; +import {Customer} from './customer.model'; + +export class Seller extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @hasMany(() => Customer, {through: () => Order}) + customers?: Customer[]; + + constructor(data: Partial) { + super(data); + } +} +``` + +_models/order.model.ts_ +```ts +import {Entity, property, belongsTo} from '@loopback/repository'; +import {Customer} from './customer.model'; +import {Seller} from './seller.model'; + +export class Order extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @belongsTo(() => Customer) + customerId?: number; + + @belongsTo(() => Seller) + sellerId?: number; + + constructor(data: Partial) { + super(data); + } +} +``` + +The definition of the `hasManyThrough` relation is inferred by using the `@hasMany` +decorator with a `through` property. The decorator takes in a function resolving +the target model class constructor. The `through` property takes in a function +resolving the through model class constructor. + +The decorated property name is used as the relation name and stored as part of +the source model definition's relation metadata. The property type metadata is +also preserved as an array of type `Seller` as part of the decoration. + +## Configuring a hasManyThrough relation + +The configuration and resolution of a `hasManyThrough` relation takes place at the +repository level. Once the `hasManyThrough` relation is defined on the source model, +then there are a couple of steps involved to configure it and use it. On the source +repository, the following are required: + +- In the constructor of your source repository class, use + [Dependency Injection](Dependency-injection.md) to receive a getter function + for obtaining an instance of the target repository and an instance of the + through repository. _Note: We need a getter function, accepting a string + repository name instead of a repository constructor, or a repository instance, + in order to break a cyclic dependency between two repositories referencing + eachother with a hasManyThrough relation._ + +- Declare a property with the factory function type + `HasManyThroughRepositoryFactory` + on the source repository class. +- call the `createHasManyThroughRepositoryFactoryFor` function in the constructor of + the source repository class with the relation name (decorated relation + property on the source model), target repository instance and through repository instance + and assign it the property mentioned above. + +_repositories/customer.repository.ts_ +```ts +import {Order, Customer, Seller} from '../models'; +import {OrderRepository, SellerRepository} from './order.repository'; +import { + DefaultCrudRepository, + juggler, + HasManyThroughRepositoryFactory, + repository, +} from '@loopback/repository'; +import {inject, Getter} from '@loopback/core'; + +export class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id +> { + public readonly sellers: HasManyThroughRepositoryFactory< + Seller, + Order, + typeof Customer.prototype.id + >; + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('SellerRepository') + getSellerRepository: Getter, + @repository.getter('OrderRepository') + getOrderRepository: Getter, + ) { + super(Customer, db); + this.sellers = this.createHasManyThroughRepositoryFactoryFor( + 'sellers', + getSellerRepository, + getOrderRepository, + ); + } +} +``` + +The following CRUD APIs are now available in the constrained target repository +factory `orders` for instances of `customerRepository`: + +- `create` for creating a target model instance belonging to customer model + instance + ([API Docs](https://apidocs.strongloop.com/@loopback%2fdocs/repository.html#HasManyThroughRepository.prototype.create)) +- `find` finding target model instance(s) belonging to customer model instance + ([API Docs](https://apidocs.strongloop.com/@loopback%2fdocs/repository.html#HasManyThroughRepository.prototype.find)) +- `delete` for deleting target model instance(s) belonging to customer model + instance + ([API Docs](https://apidocs.strongloop.com/@loopback%2fdocs/repository.html#HasManyThroughRepository.prototype.delete)) +- `patch` for patching target model instance(s) belonging to customer model + instance + ([API Docs](https://apidocs.strongloop.com/@loopback%2fdocs/repository.html#HasManyThroughRepository.prototype.patch)) + +## Using hasMany constrained repository in a controller + +```ts +import {post, param, requestBody} from '@loopback/rest'; +import {CustomerRepository} from '../repositories/'; +import {Customer, Seller} from '../models/'; +import {repository} from '@loopback/repository'; + +export class CustomerOrdersController { + constructor( + @repository(CustomerRepository) + protected customerRepository: CustomerRepository, + ) {} + + @post('/customers/{id}/order') + async createOrder( + @param.path.number('id') customerId: typeof Customer.prototype.id, + @requestBody() sellerData: Seller, + ): Promise { + return await this.customerRepository.sellers(customerId).create(sellerData); + } +} +``` diff --git a/docs/site/Relations.md b/docs/site/Relations.md index d81baf32a381..f7591d010420 100644 --- a/docs/site/Relations.md +++ b/docs/site/Relations.md @@ -39,6 +39,7 @@ each relation type. Here are the currently supported relations: - [HasMany](HasMany-relation.md) +- [HasManyThrough](HasManyThrough-relation.md) - [BelongsTo](BelongsTo-relation.md) - [HasOne](hasOne-relation.md) diff --git a/docs/site/imgs/hasManyThrough-relation-example.png b/docs/site/imgs/hasManyThrough-relation-example.png new file mode 100644 index 000000000000..ac3ec67c2b4e Binary files /dev/null and b/docs/site/imgs/hasManyThrough-relation-example.png differ diff --git a/packages/repository-tests/src/__tests__/acceptance/has-many-through.relation.acceptance.ts b/packages/repository-tests/src/__tests__/acceptance/has-many-through.relation.acceptance.ts new file mode 100644 index 000000000000..a7ec8a5b4091 --- /dev/null +++ b/packages/repository-tests/src/__tests__/acceptance/has-many-through.relation.acceptance.ts @@ -0,0 +1,250 @@ +// Copyright IBM Corp. 2019. 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 * as _ from 'lodash'; +import {Application} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import { + ApplicationWithRepositories, + juggler, + repository, + RepositoryMixin, +} from '@loopback/repository'; +import { + Order, + Seller, +} from '@loopback/repository/src/__tests__/fixtures/models'; +import { + CustomerRepository, + OrderRepository, + SellerRepository, +} from '@loopback/repository/src/__tests__/fixtures/repositories'; + +describe('HasManyThrough relation', () => { + // Given a Customer and Seller models - see definitions at the bottom + + let app: ApplicationWithRepositories; + let controller: CustomerController; + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + let sellerRepo: SellerRepository; + let existingCustomerId: number; + + before(givenApplicationWithMemoryDB); + before(givenBoundCrudRepositoriesForCustomerAndSeller); + before(givenCustomerController); + + beforeEach(async () => { + await sellerRepo.deleteAll(); + }); + + beforeEach(async () => { + existingCustomerId = (await givenPersistedCustomerInstance()).id; + }); + + it('can create an instance of the related model', async () => { + const seller = await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + expect(seller.toObject()).to.containDeep({ + name: 'Jam Risser', + }); + + const persisted = await sellerRepo.findById(seller.id); + expect(persisted.toObject()).to.deepEqual(seller.toObject()); + }); + + it('can find instances of the related model', async () => { + const seller = await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + const notMySeller = await controller.createCustomerSellers( + existingCustomerId + 1, + { + name: 'Mark Twain', + }, + {description: 'some order'}, + ); + const foundSellers = await controller.findCustomerSellers( + existingCustomerId, + ); + expect(foundSellers).to.containEql(seller); + expect(foundSellers).to.not.containEql(notMySeller); + + const persistedOrders = await orderRepo.find({ + where: { + customerId: existingCustomerId, + }, + }); + const persisted = await sellerRepo.find({ + where: { + or: persistedOrders.map((order: Order) => ({ + id: order.sellerId, + })), + }, + }); + expect(persisted).to.deepEqual(foundSellers); + }); + + it('can patch many instances', async () => { + await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + const patchObject = {name: 'Mark Twain'}; + const arePatched = await controller.patchCustomerSellers( + existingCustomerId, + patchObject, + ); + expect(arePatched.count).to.equal(2); + const patchedData = _.map( + await controller.findCustomerSellers(existingCustomerId), + d => _.pick(d, ['name']), + ); + expect(patchedData).to.eql([ + { + name: 'Mark Twain', + }, + { + name: 'Mark Twain', + }, + ]); + }); + + it('can delete many instances', async () => { + await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + const deletedSellers = await controller.deleteCustomerSellers( + existingCustomerId, + ); + expect(deletedSellers.count).to.equal(2); + const relatedSellers = await controller.findCustomerSellers( + existingCustomerId, + ); + expect(relatedSellers).to.be.empty(); + }); + + it("does not delete instances that don't belong to the constrained instance", async () => { + await controller.createCustomerSellers( + existingCustomerId, + { + name: 'Jam Risser', + }, + {description: 'some order'}, + ); + const newSeller = { + name: 'Mark Twain', + }; + await sellerRepo.create(newSeller); + await controller.deleteCustomerSellers(existingCustomerId); + const sellers = await sellerRepo.find(); + expect(sellers).to.have.length(1); + expect(_.pick(sellers[0], ['name'])).to.eql({ + name: 'Mark Twain', + }); + }); + + it('does not create an array of the related model', async () => { + await expect( + customerRepo.create({ + name: 'a customer', + sellers: [ + { + name: 'Mark Twain', + }, + ], + }), + ).to.be.rejectedWith(/`sellers` is not defined/); + }); + + // This should be enforced by the database to avoid race conditions + it.skip('reject create request when the customer does not exist'); + + class CustomerController { + constructor( + @repository(CustomerRepository) + protected customerRepository: CustomerRepository, + ) {} + + async createCustomerSellers( + customerId: number, + sellerData: Partial, + orderData?: Partial, + ): Promise { + return this.customerRepository + .sellers(customerId) + .create(sellerData, orderData); + } + + async findCustomerSellers(customerId: number) { + return this.customerRepository.sellers(customerId).find(); + } + + async patchCustomerSellers(customerId: number, seller: Partial) { + return this.customerRepository.sellers(customerId).patch(seller); + } + + async deleteCustomerSellers(customerId: number) { + return this.customerRepository.sellers(customerId).delete(); + } + } + + function givenApplicationWithMemoryDB() { + class TestApp extends RepositoryMixin(Application) {} + + app = new TestApp(); + app.dataSource(new juggler.DataSource({name: 'db', connector: 'memory'})); + } + + async function givenBoundCrudRepositoriesForCustomerAndSeller() { + app.repository(CustomerRepository); + app.repository(OrderRepository); + app.repository(SellerRepository); + customerRepo = await app.getRepository(CustomerRepository); + orderRepo = await app.getRepository(OrderRepository); + sellerRepo = await app.getRepository(SellerRepository); + } + + async function givenCustomerController() { + app.controller(CustomerController); + controller = await app.get( + 'controllers.CustomerController', + ); + } + + async function givenPersistedCustomerInstance() { + return customerRepo.create({name: 'a customer'}); + } +}); diff --git a/packages/repository/package.json b/packages/repository/package.json index c5c8819a492f..5bbdc1be55b3 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@loopback/build": "^2.0.13", "@loopback/eslint-config": "^4.1.1", + "@loopback/repository-tests": "^0.5.1", "@loopback/testlab": "^1.9.1", "@types/bson": "^4.0.0", "@types/lodash": "^4.14.141", diff --git a/packages/repository/src/__tests__/fixtures/models/customer.model.ts b/packages/repository/src/__tests__/fixtures/models/customer.model.ts new file mode 100644 index 000000000000..90a7cf82bee6 --- /dev/null +++ b/packages/repository/src/__tests__/fixtures/models/customer.model.ts @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2019. 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 {belongsTo, Entity, hasMany, hasOne, model, property} from '../../..'; +import { + Address, + AddressWithRelations, +} from '@loopback/repository-tests/dist/crud/relations/fixtures/models/address.model'; +import {Order, OrderWithRelations} from './order.model'; +import {Seller} from './seller.model'; + +@model() +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @property({ + type: 'string', + }) + name: string; + + @hasMany(() => Order) + orders: Order[]; + + @hasMany(() => Seller, {through: () => Order}) + sellers: Seller[]; + + @hasOne(() => Address) + address: Address; + + @hasMany(() => Customer, {keyTo: 'parentId'}) + customers?: Customer[]; + + @belongsTo(() => Customer) + parentId?: number; +} + +export interface CustomerRelations { + address?: AddressWithRelations; + orders?: OrderWithRelations[]; + customers?: CustomerWithRelations[]; + parentCustomer?: CustomerWithRelations; +} + +export type CustomerWithRelations = Customer & CustomerRelations; diff --git a/packages/repository/src/__tests__/fixtures/models/order.model.ts b/packages/repository/src/__tests__/fixtures/models/order.model.ts new file mode 100644 index 000000000000..58648c8092ff --- /dev/null +++ b/packages/repository/src/__tests__/fixtures/models/order.model.ts @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2019. 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 {belongsTo, Entity, model, property} from '../../..'; +import {Customer, CustomerWithRelations} from './customer.model'; +import { + Shipment, + ShipmentWithRelations, +} from '@loopback/repository-tests/dist/crud/relations/fixtures/models/shipment.model'; +import {Seller} from './seller.model'; + +@model() +export class Order extends Entity { + @property({ + type: 'string', + id: true, + }) + id: string; + + @property({ + type: 'string', + required: true, + }) + description: string; + + @property({ + type: 'boolean', + required: false, + }) + isShipped: boolean; + + @belongsTo(() => Customer) + customerId: number; + + @belongsTo(() => Seller) + sellerId: number; + + @belongsTo(() => Shipment, {name: 'shipment'}) + shipment_id: number; +} + +export interface OrderRelations { + customer?: CustomerWithRelations; + shipment?: ShipmentWithRelations; +} + +export type OrderWithRelations = Order & OrderRelations; diff --git a/packages/repository/src/__tests__/fixtures/models/seller.model.ts b/packages/repository/src/__tests__/fixtures/models/seller.model.ts new file mode 100644 index 000000000000..42cc585fbdee --- /dev/null +++ b/packages/repository/src/__tests__/fixtures/models/seller.model.ts @@ -0,0 +1,26 @@ +// Copyright IBM Corp. 2018,2019. 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 {hasMany, Entity, model, property} from '../../..'; +import {Customer} from './customer.model'; +import {Order} from './order.model'; + +@model() +export class Seller extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @property({ + type: 'string', + required: true, + }) + name: string; + + @hasMany(() => Customer, {through: () => Order}) + customers: Customer[]; +} 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 e347ae765f08..5c7ce8ceb3de 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -9,6 +9,7 @@ import { BelongsToDefinition, createBelongsToAccessor, createHasManyRepositoryFactory, + createHasManyThroughRepositoryFactory, DefaultCrudRepository, Entity, EntityCrudRepository, @@ -17,21 +18,27 @@ import { HasManyDefinition, HasManyRepository, HasManyRepositoryFactory, + HasManyThroughRepository, + HasManyThroughDefinition, + HasManyThroughRepositoryFactory, juggler, ModelDefinition, RelationType, } from '../../..'; +import {Seller} from '../../fixtures/models/seller.model'; // Given a Customer and Order models - see definitions at the bottom let db: juggler.DataSource; let customerRepo: EntityCrudRepository; let orderRepo: EntityCrudRepository; +let sellerRepo: EntityCrudRepository; let reviewRepo: EntityCrudRepository; describe('HasMany relation', () => { let existingCustomerId: number; let customerOrderRepo: HasManyRepository; + let customerSellerRepo: HasManyThroughRepository; let customerAuthoredReviewFactoryFn: HasManyRepositoryFactory< Review, typeof Customer.prototype.id @@ -81,6 +88,57 @@ describe('HasMany relation', () => { expect(orders).to.deepEqual(persistedOrders); }); + it('can create an instance of a related model through a junction table', async () => { + const seller = await customerSellerRepo.create( + { + name: 'Jam Risser', + }, + { + description: 'some order description', + }, + ); + const persisted = await sellerRepo.findById(seller.id); + + expect(seller).to.deepEqual(persisted); + }); + + it('can find an instance of a related model through a junction table', async () => { + const seller = await customerSellerRepo.create( + { + name: 'Jam Risser', + }, + { + description: 'some order description', + }, + ); + const notTheSeller = await sellerRepo.create( + { + name: 'Mark Twain', + }, + { + description: 'some order description', + }, + ); + + const persistedOrders = await orderRepo.find({ + where: { + customerId: existingCustomerId, + }, + }); + const persistedSellers = await sellerRepo.find({ + where: { + or: persistedOrders.map((order: Order) => ({ + id: order.sellerId, + })), + }, + }); + const sellers = await customerSellerRepo.find(); + + expect(sellers).to.containEql(seller); + expect(sellers).to.not.containEql(notTheSeller); + expect(sellers).to.deepEqual(persistedSellers); + }); + it('finds appropriate related model instances for multiple relations', async () => { // note(shimks): roundabout way of creating reviews with 'approves' // ideally, the review repository should have a approve function @@ -138,7 +196,24 @@ describe('HasMany relation', () => { Getter.fromValue(orderRepo), ); + const sellerFactoryFn: HasManyThroughRepositoryFactory< + Seller, + Order, + typeof Customer.prototype.id + > = createHasManyThroughRepositoryFactory< + Seller, + typeof Seller.prototype.id, + Order, + typeof Order.prototype.id, + typeof Customer.prototype.id + >( + Customer.definition.relations.sellers as HasManyThroughDefinition, + Getter.fromValue(sellerRepo), + Getter.fromValue(orderRepo), + ); + customerOrderRepo = orderFactoryFn(existingCustomerId); + customerSellerRepo = sellerFactoryFn(existingCustomerId); } function givenRepositoryFactoryFunctions() { @@ -208,11 +283,14 @@ describe('BelongsTo relation', () => { class Order extends Entity { id: number; description: string; - customerId: number; + customerId?: number; + sellerId?: number; static definition = new ModelDefinition('Order') .addProperty('id', {type: 'number', id: true}) .addProperty('description', {type: 'string', required: true}) + .addProperty('customerId', {type: 'number'}) + .addProperty('sellerId', {type: 'number'}) .addProperty('customerId', {type: 'number', required: true}) .addRelation({ name: 'customer', @@ -243,6 +321,7 @@ class Customer extends Entity { orders: Order[]; reviewsAuthored: Review[]; reviewsApproved: Review[]; + sellers: Seller[]; static definition: ModelDefinition = new ModelDefinition('Customer') .addProperty('id', {type: 'number', id: true}) @@ -273,6 +352,13 @@ class Customer extends Entity { source: Customer, target: () => Review, keyTo: 'approvedId', + }) + .addRelation({ + name: 'sellers', + type: RelationType.hasMany, + source: Customer, + target: () => Seller, + through: () => Order, }); } @@ -282,4 +368,5 @@ function givenCrudRepositories() { customerRepo = new DefaultCrudRepository(Customer, db); orderRepo = new DefaultCrudRepository(Order, db); reviewRepo = new DefaultCrudRepository(Review, db); + sellerRepo = new DefaultCrudRepository(Seller, db); } diff --git a/packages/repository/src/__tests__/unit/repositories/has-many-through-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-many-through-repository-factory.unit.ts new file mode 100644 index 000000000000..d371d6a1ff02 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/has-many-through-repository-factory.unit.ts @@ -0,0 +1,185 @@ +// Copyright IBM Corp. 2017,2018,2019. 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 {Getter} from '@loopback/context'; +import {createStubInstance, expect} from '@loopback/testlab'; +import { + DefaultCrudRepository, + Entity, + HasManyThroughDefinition, + ModelDefinition, + RelationType, + createHasManyThroughRepositoryFactory, + juggler, +} from '../../..'; +import {TypeResolver} from '../../../type-resolver'; + +describe('createHasManyThroughRepositoryFactory', () => { + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + + beforeEach(givenStubbedCustomerRepo); + + it('rejects relations with missing source', () => { + const relationMeta = givenHasManyThroughDefinition({ + source: undefined, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(customerRepo), + ), + ).to.throw(/source model must be defined/); + }); + + it('rejects relations with missing target', () => { + const relationMeta = givenHasManyThroughDefinition({ + target: undefined, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(customerRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with a target that is not a type resolver', () => { + const relationMeta = givenHasManyThroughDefinition({ + // tslint:disable-next-line:no-any + target: (Customer as unknown) as TypeResolver, + // the cast to any above is necessary to disable compile check + // we want to verify runtime assertion + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with keyTo pointing to an unknown property', () => { + const relationMeta = givenHasManyThroughDefinition({ + target: () => Customer, + // Let the relation to use the default keyTo value "companyId" + // which does not exist on the Customer model! + keyTo: undefined, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through model Customer is missing.*foreign key companyId/); + }); + + it('rejects relations with missing "through"', () => { + const relationMeta = givenHasManyThroughDefinition({ + target: () => Customer, + through: undefined, + }); + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through must be a type resolver/); + }); + + it('rejects relations with "through" that is not a type resolver', () => { + const relationMeta = givenHasManyThroughDefinition({ + target: () => Customer, + }); + relationMeta.through = (true as unknown) as TypeResolver< + Entity, + typeof Entity + >; + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through must be a type resolver/); + }); + + /*------------- HELPERS ---------------*/ + + class Customer extends Entity { + static definition = new ModelDefinition('Customer').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + class Order extends Entity { + static definition = new ModelDefinition('Order') + .addProperty('id', { + type: Number, + id: true, + }) + .addProperty('customerId', { + type: Number, + }); + id: number; + customerId: number; + } + + class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Customer, dataSource); + } + } + + class OrderRepository extends DefaultCrudRepository< + Order, + typeof Order.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Order, dataSource); + } + } + + function givenStubbedCustomerRepo() { + customerRepo = createStubInstance(CustomerRepository); + } + + function givenHasManyThroughDefinition( + props?: Partial, + ): HasManyThroughDefinition { + class Company extends Entity { + static definition = new ModelDefinition('Company').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + const defaults: HasManyThroughDefinition = { + type: RelationType.hasMany, + targetsMany: true, + name: 'customers', + target: () => Customer, + through: () => Order, + source: Company, + }; + + return Object.assign(defaults, props); + } +}); diff --git a/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts b/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts new file mode 100644 index 000000000000..9e42f9971f17 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts @@ -0,0 +1,87 @@ +// Copyright IBM Corp. 2018,2019. 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 * as debugFactory from 'debug'; +import {DataObject} from '../../common-types'; +import {Entity} from '../../model'; +import {EntityCrudRepository} from '../../repositories/repository'; +import {Getter, HasManyThroughDefinition} from '../relation.types'; +import { + createTargetConstraint, + createThroughConstraint, + resolveHasManyThroughMetadata, +} from './has-many-through.helpers'; +import { + DefaultHasManyThroughRepository, + HasManyThroughRepository, +} from './has-many-through.repository'; + +const debug = debugFactory( + 'loopback:repository:has-many-through-repository-factory', +); + +export type HasManyThroughRepositoryFactory< + Target extends Entity, + Through extends Entity, + ForeignKeyType +> = (fkValue: ForeignKeyType) => HasManyThroughRepository; + +/** + * Enforces a constraint on a repository based on a relationship contract + * between models. For example, if a Customer model is related to an Order model + * via a HasMany relation, then, the relational repository returned by the + * factory function would be constrained by a Customer model instance's id(s). + * + * @param relationMetadata - The relation metadata used to describe the + * relationship and determine how to apply the constraint. + * @param targetRepositoryGetter - The repository which represents the target model of a + * relation attached to a datasource. + * @returns The factory function which accepts a foreign key value to constrain + * the given target repository + */ +export function createHasManyThroughRepositoryFactory< + Target extends Entity, + TargetID, + Through extends Entity, + ThroughID, + ForeignKeyType +>( + relationMetadata: HasManyThroughDefinition, + targetRepositoryGetter: Getter>, + throughRepositoryGetter: Getter>, +): HasManyThroughRepositoryFactory { + const meta = resolveHasManyThroughMetadata(relationMetadata); + debug('Resolved HasManyThrough relation metadata: %o', meta); + return function(fkValue?: ForeignKeyType) { + function getTargetContraint( + throughInstances: Through[], + ): DataObject { + return createTargetConstraint(meta, throughInstances); + } + function getThroughConstraint( + targetInstance?: Target, + ): DataObject { + const constriant: DataObject = createThroughConstraint< + Target, + Through, + ForeignKeyType + >(meta, fkValue, targetInstance); + return constriant; + } + return new DefaultHasManyThroughRepository< + Target, + TargetID, + EntityCrudRepository, + Through, + ThroughID, + EntityCrudRepository + >( + targetRepositoryGetter, + throughRepositoryGetter, + getTargetContraint, + getThroughConstraint, + ); + }; +} diff --git a/packages/repository/src/relations/has-many/has-many-through.helpers.ts b/packages/repository/src/relations/has-many/has-many-through.helpers.ts new file mode 100644 index 000000000000..7b3d7c846999 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through.helpers.ts @@ -0,0 +1,164 @@ +// Copyright IBM Corp. 2018,2019. 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 * as debugFactory from 'debug'; +import {DataObject} from '../../common-types'; +import {camelCase} from 'lodash'; +import {Entity} from '../../model'; +import {InvalidRelationError} from '../../errors'; +import {isTypeResolver} from '../../type-resolver'; +import {HasManyThroughDefinition} from '../relation.types'; + +const debug = debugFactory('loopback:repository:has-many-through-helpers'); + +/** + * Relation definition with optional metadata (e.g. `keyTo`) filled in. + * @internal + */ +export type HasManyThroughResolvedDefinition = HasManyThroughDefinition & { + keyTo: string; + keyThrough: string; + targetPrimaryKey: string; +}; + +/** + * Creates constraint used to query target + * @param relationMeta - hasManyThrough metadata to resolve + * @param throughInstances - Instances of through entities used to constrain the target + * @internal + */ +export function createTargetConstraint< + Target extends Entity, + Through extends Entity +>( + relationMeta: HasManyThroughResolvedDefinition, + throughInstances: Through[], +): DataObject { + const {targetPrimaryKey} = relationMeta; + const targetFkName = relationMeta.keyThrough; + const fkValues = throughInstances.map( + (throughInstance: Through) => + throughInstance[targetFkName as keyof Through], + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const constraint: any = { + [targetPrimaryKey]: fkValues.length === 1 ? fkValues[0] : {inq: fkValues}, + }; + return constraint; +} + +/** + * Creates constraint used to query through + * @param relationMeta - hasManyThrough metadata to resolve + * @param fkValue - Value of the foreign key used to constrain through + * @param targetInstance - Instance of target entity used to constrain through + * @internal + */ +export function createThroughConstraint< + Target extends Entity, + Through extends Entity, + ForeignKeyType +>( + relationMeta: HasManyThroughResolvedDefinition, + fkValue?: ForeignKeyType, + targetInstance?: Target, +): DataObject { + const {targetPrimaryKey} = relationMeta; + const targetFkName = relationMeta.keyThrough; + const sourceFkName = relationMeta.keyTo; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const constraint: any = {[sourceFkName]: fkValue}; + if (targetInstance) { + constraint[targetFkName] = targetInstance[targetPrimaryKey as keyof Target]; + } + return constraint; +} + +/** + * Resolves given hasMany metadata if target is specified to be a resolver. + * Mainly used to infer what the `keyTo` property should be from the target's + * belongsTo metadata + * @param relationMeta - hasManyThrough metadata to resolve + * @internal + */ +export function resolveHasManyThroughMetadata( + relationMeta: HasManyThroughDefinition, +): HasManyThroughResolvedDefinition { + if (!isTypeResolver(relationMeta.target)) { + const reason = 'target must be a type resolver'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.through)) { + const reason = 'through must be a type resolver'; + throw new InvalidRelationError(reason, relationMeta); + } + + const throughModel = relationMeta.through(); + const throughModelProperties = + throughModel.definition && throughModel.definition.properties; + const targetModel = relationMeta.target(); + const targetModelProperties = + targetModel.definition && targetModel.definition.properties; + + // Make sure that if it already keys to the foreign key property, + // the key exists in the target model + if ( + relationMeta.keyTo && + throughModelProperties[relationMeta.keyTo] && + relationMeta.keyThrough && + throughModelProperties[relationMeta.keyThrough] && + relationMeta.targetPrimaryKey && + targetModelProperties[relationMeta.targetPrimaryKey] + ) { + // The explict cast is needed because of a limitation of type inference + return relationMeta as HasManyThroughResolvedDefinition; + } + + const sourceModel = relationMeta.source; + if (!sourceModel || !sourceModel.modelName) { + const reason = 'source model must be defined'; + throw new InvalidRelationError(reason, relationMeta); + } + + debug( + 'Resolved model %s from given metadata: %o', + targetModel.modelName, + targetModel, + ); + + debug( + 'Resolved model %s from given metadata: %o', + throughModel.modelName, + throughModel, + ); + + const sourceFkName = + relationMeta.keyTo || camelCase(sourceModel.modelName + '_id'); + const hasSourceFkProperty = throughModelProperties[sourceFkName]; + if (!hasSourceFkProperty) { + const reason = `through model ${targetModel.name} is missing definition of default foreign key ${sourceFkName}`; + throw new InvalidRelationError(reason, relationMeta); + } + + const targetFkName = + relationMeta.keyThrough || camelCase(targetModel.modelName + '_id'); + const hasTargetFkName = throughModelProperties[targetFkName]; + if (!hasTargetFkName) { + const reason = `through model ${throughModel.name} is missing definition of target foreign key ${targetFkName}`; + throw new InvalidRelationError(reason, relationMeta); + } + + const targetPrimaryKey = targetModel.definition.idProperties()[0]; + if (!targetPrimaryKey) { + const reason = `target model ${targetModel.modelName} does not have any primary key (id property)`; + throw new InvalidRelationError(reason, relationMeta); + } + + return Object.assign(relationMeta, { + keyTo: sourceFkName, + keyThrough: targetFkName, + targetPrimaryKey, + }); +} diff --git a/packages/repository/src/relations/has-many/has-many-through.repository.ts b/packages/repository/src/relations/has-many/has-many-through.repository.ts new file mode 100644 index 000000000000..5fd128112e08 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through.repository.ts @@ -0,0 +1,182 @@ +// Copyright IBM Corp. 2018,2019. 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 {Getter} from '@loopback/context'; +import {Count, DataObject, Options} from '../../common-types'; +import {Entity} from '../../model'; +import {Filter, Where} from '../../query'; +import { + constrainDataObject, + constrainFilter, + constrainWhere, +} from '../../repositories/constraint-utils'; +import {EntityCrudRepository} from '../../repositories/repository'; + +/** + * CRUD operations for a target repository of a HasManyThrough relation + */ +export interface HasManyThroughRepository< + Target extends Entity, + Through extends Entity +> { + /** + * Create a target model instance + * @param targetModelData - The target model data + * @param throughModelData - The through model data + * @param options - Options for the operation + * @param throughOptions - Options passed to create through + * @returns A promise which resolves to the newly created target model instance + */ + create( + targetModelData: DataObject, + throughModelData?: DataObject, + options?: Options, + throughOptions?: 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, + options?: Options, + throughOptions?: Options, + ): Promise; + /** + * Delete multiple target model instances + * @param where - Instances within the where scope are deleted + * @param options + * @returns A promise which resolves the deleted target model instances + */ + delete( + where?: Where, + options?: Options, + throughOptions?: Options, + ): Promise; + /** + * Patch multiple target model instances + * @param dataObject - The fields and their new values to patch + * @param where - Instances within the where scope are patched + * @param options + * @returns A promise which resolves the patched target model instances + */ + patch( + dataObject: DataObject, + where?: Where, + options?: Options, + throughOptions?: Options, + ): Promise; +} + +export class DefaultHasManyThroughRepository< + TargetEntity extends Entity, + TargetID, + TargetRepository extends EntityCrudRepository, + ThroughEntity extends Entity, + ThroughID, + ThroughRepository extends EntityCrudRepository +> implements HasManyThroughRepository { + /** + * Constructor of DefaultHasManyThroughEntityCrudRepository + * @param getTargetRepository - the getter of the related target model repository instance + * @param getThroughRepository - the getter of the related through model repository instance + * @param getTargetConstraint - the getter of the constraint used to query target + * @param getThroughConstraint - the getter of the constraint used to query through + * the hasManyThrough instance + */ + constructor( + public getTargetRepository: Getter, + public getThroughRepository: Getter, + public getTargetConstraint: ( + throughInstances: ThroughEntity[], + ) => DataObject, + public getThroughConstraint: ( + targetInstance?: TargetEntity, + ) => DataObject, + ) {} + + async create( + targetModelData: DataObject, + throughModelData: DataObject = {}, + options?: Options, + throughOptions?: Options, + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const targetInstance = await targetRepository.create( + targetModelData, + options, + ); + const throughConstraint = this.getThroughConstraint(targetInstance); + await throughRepository.create( + constrainDataObject(throughModelData, throughConstraint as DataObject< + ThroughEntity + >), + throughOptions, + ); + return targetInstance; + } + + async find( + filter?: Filter, + options?: Options, + throughOptions?: Options, + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.find( + constrainFilter(filter, targetConstraint), + options, + ); + } + + async delete( + where?: Where, + options?: Options, + throughOptions?: Options, + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.deleteAll( + constrainWhere(where, targetConstraint as Where), + options, + ); + } + + async patch( + dataObject: DataObject, + where?: Where, + options?: Options, + throughOptions?: Options, + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.updateAll( + constrainDataObject(dataObject, targetConstraint), + constrainWhere(where, targetConstraint as Where), + options, + ); + } +} 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 70f57f0bfd1c..51899626c4f3 100644 --- a/packages/repository/src/relations/has-many/has-many.decorator.ts +++ b/packages/repository/src/relations/has-many/has-many.decorator.ts @@ -1,11 +1,15 @@ -// Copyright IBM Corp. 2018,2019. All Rights Reserved. +// Copyright IBM Corp. 2017,2018,2019. 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, EntityResolver} from '../../model'; import {relation} from '../relation.decorator'; -import {HasManyDefinition, RelationType} from '../relation.types'; +import { + HasManyDefinition, + HasManyThroughDefinition, + RelationType, +} from '../relation.types'; /** * Decorator for hasMany @@ -17,7 +21,7 @@ import {HasManyDefinition, RelationType} from '../relation.types'; */ export function hasMany( targetResolver: EntityResolver, - definition?: Partial, + definition?: Partial, ) { return function(decoratedTarget: object, key: string) { const meta: HasManyDefinition = Object.assign( diff --git a/packages/repository/src/relations/has-many/index.ts b/packages/repository/src/relations/has-many/index.ts index 7780e8b4c018..44e3d81547f8 100644 --- a/packages/repository/src/relations/has-many/index.ts +++ b/packages/repository/src/relations/has-many/index.ts @@ -7,3 +7,5 @@ export * from './has-many.decorator'; export * from './has-many.repository'; export * from './has-many-repository.factory'; export * from './has-many.inclusion-resolver'; +export * from './has-many-through-repository.factory'; +export * from './has-many-through.repository'; diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index 1bed6582653b..b8341f866593 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -68,6 +68,42 @@ export interface HasManyDefinition extends RelationDefinitionBase { keyTo?: string; } +export interface HasManyThroughDefinition extends RelationDefinitionBase { + type: RelationType.hasMany; + targetsMany: true; + + /** + * The through model of this relation. + * + * E.g. when a Customer has many Order instances and a Seller has many Order instances, + * then Order is through. + */ + through: TypeResolver; + + /** + * The foreign key used by the through model to reference the source model. + * + * E.g. when a Customer has many Order instances and a Seller has many Order instances, + * then keyTo is "customerId". + * Note that "customerId" is the default FK assumed by the framework, users + * can provide a custom FK name by setting "keyTo". + */ + keyTo?: string; + + /** + * The foreign key used by the through model to reference the target model. + * + * E.g. when a Customer has many Order instances and a Seller has many Order instances, + * then keyThrough is "sellerId". + */ + keyThrough?: string; + + /* + * The primary key in the target model when using through, e.g. Seller#id. + */ + targetPrimaryKey?: string; +} + export interface BelongsToDefinition extends RelationDefinitionBase { type: RelationType.belongsTo; targetsMany: false; @@ -102,6 +138,7 @@ export interface HasOneDefinition extends RelationDefinitionBase { */ export type RelationMetadata = | HasManyDefinition + | HasManyThroughDefinition | BelongsToDefinition | HasOneDefinition // TODO(bajtos) add other relation types and remove RelationDefinitionBase once diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index c2f6de4b666a..a8a19c9e5aac 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -23,9 +23,12 @@ import { BelongsToDefinition, createBelongsToAccessor, createHasManyRepositoryFactory, + createHasManyThroughRepositoryFactory, createHasOneRepositoryFactory, HasManyDefinition, HasManyRepositoryFactory, + HasManyThroughDefinition, + HasManyThroughRepositoryFactory, HasOneDefinition, HasOneRepositoryFactory, includeRelatedModels, @@ -262,6 +265,62 @@ export class DefaultCrudRepository< ); } + /** + * EXPIRMENTAL: The underlying implementation may change in the near future. + * If some of the changes break backward-compatibility a semver-major may not + * be released. + * + * Function to create a constrained relation repository factory + * + * ```ts + * class CustomerRepository extends DefaultCrudRepository< + * Customer, + * typeof Customer.prototype.id + * > { + * public readonly orders: HasManyRepositoryFactory; + * + * constructor( + * protected db: juggler.DataSource, + * orderRepository: EntityCrudRepository, + * ) { + * super(Customer, db); + * this.orders = this._createHasManyRepositoryFactoryFor( + * 'orders', + * orderRepository, + * ); + * } + * } + * ``` + * + * @param relationName Name of the relation defined on the source model + * @param targetRepo Target repository instance + * @param throughRepo Through repository instance + */ + protected createHasManyThroughRepositoryFactoryFor< + Target extends Entity, + TargetID, + Through extends Entity, + ThroughID, + ForeignKeyType + >( + relationName: string, + targetRepoGetter: Getter>, + throughRepositoryGetter: Getter>, + ): HasManyThroughRepositoryFactory { + const meta = this.entityClass.definition.relations[relationName]; + return createHasManyThroughRepositoryFactory< + Target, + TargetID, + Through, + ThroughID, + ForeignKeyType + >( + meta as HasManyThroughDefinition, + targetRepoGetter, + throughRepositoryGetter, + ); + } + /** * @deprecated * Function to create a belongs to accessor