diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-many-through.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many-through.relation.acceptance.ts new file mode 100644 index 000000000000..0827b62be549 --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many-through.relation.acceptance.ts @@ -0,0 +1,99 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, toJSON} from '@loopback/testlab'; +import { + CrudFeatures, + CrudRepositoryCtor, + CrudTestContext, + DataSourceOptions, +} from '../../..'; +import { + deleteAllModelsInDefaultDataSource, + MixedIdType, + withCrudCtx, +} from '../../../helpers.repository-tests'; +import { + Customer, + CustomerRepository, + Order, + OrderRepository, + Seller, + SellerRepository, +} from '../fixtures/models'; +import {givenBoundCrudRepositories} from '../helpers'; + +export function hasManyThroughRelationAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + describe('HasManyThrough relation (acceptance)', () => { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + let sellerRepo: SellerRepository; + let existingCustomerId: MixedIdType; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + ({customerRepo, orderRepo, sellerRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + features, + )); + await ctx.dataSource.automigrate([ + Customer.name, + Order.name, + Seller.name, + ]); + }), + ); + + beforeEach(async () => { + await customerRepo.deleteAll(); + await orderRepo.deleteAll(); + await sellerRepo.deleteAll(); + }); + + beforeEach(async () => { + existingCustomerId = (await givenPersistedCustomerInstance()).id; + }); + + it('can create related models', async () => { + // TODO(derdeka): creating seller though order is not the best example usecase - find alternative + const sellerData: Partial = { + name: 'Domino’s Pizza', + }; + const orderData: Partial = { + description: 'pizza', + }; + const seller = await customerRepo + .sellers(existingCustomerId) + .create(sellerData, { + throughData: orderData, + }); + expect(toJSON(seller)).containDeep(toJSON(sellerData)); + + // check target object + const persistedSeller = await sellerRepo.findById(seller.id); + expect(toJSON(persistedSeller)).containDeep(toJSON(sellerData)); + + // check through object + const persistedOrders: Order[] = await orderRepo.find({ + where: { + sellerId: seller.id, + customerId: existingCustomerId, + }, + }); + expect(persistedOrders.length).to.eql(1); + expect(persistedOrders[0]).containDeep(toJSON(orderData)); + }); + + async function givenPersistedCustomerInstance() { + return customerRepo.create({name: 'a customer'}); + } + }); +} diff --git a/packages/repository-tests/src/crud/relations/fixtures/models/customer.model.ts b/packages/repository-tests/src/crud/relations/fixtures/models/customer.model.ts index 07b4104f770a..5f9d37e3b01f 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/models/customer.model.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/models/customer.model.ts @@ -5,19 +5,21 @@ import { belongsTo, + BelongsToAccessor, Entity, EntityCrudRepository, hasMany, HasManyRepositoryFactory, + HasManyThroughRepositoryFactory, hasOne, HasOneRepositoryFactory, model, property, } from '@loopback/repository'; -import {BelongsToAccessor} from '@loopback/repository/src'; import {MixedIdType} from '../../../../helpers.repository-tests'; import {Address, AddressWithRelations} from './address.model'; import {Order, OrderWithRelations} from './order.model'; +import {Seller, SellerWithRelations} from './seller.model'; @model() export class Customer extends Entity { @@ -36,6 +38,13 @@ export class Customer extends Entity { @hasMany(() => Order) orders: Order[]; + @hasMany(() => Seller, { + through: { + model: () => Order, + }, + }) + sellers: Seller[]; + @hasOne(() => Address) address: Address; @@ -49,6 +58,7 @@ export class Customer extends Entity { export interface CustomerRelations { address?: AddressWithRelations; orders?: OrderWithRelations[]; + sellers?: SellerWithRelations[]; customers?: CustomerWithRelations[]; parentCustomer?: CustomerWithRelations; } @@ -60,6 +70,12 @@ export interface CustomerRepository // define additional members like relation methods here address: HasOneRepositoryFactory; orders: HasManyRepositoryFactory; + sellers: HasManyThroughRepositoryFactory< + Seller, + typeof Seller.prototype.id, + Order, + typeof Customer.prototype.id + >; customers: HasManyRepositoryFactory; parent: BelongsToAccessor; } diff --git a/packages/repository-tests/src/crud/relations/fixtures/models/index.ts b/packages/repository-tests/src/crud/relations/fixtures/models/index.ts index 67fe32851ea0..d7d052f91236 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/models/index.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/models/index.ts @@ -6,4 +6,5 @@ export * from './address.model'; export * from './customer.model'; export * from './order.model'; +export * from './seller.model'; export * from './shipment.model'; diff --git a/packages/repository-tests/src/crud/relations/fixtures/models/order.model.ts b/packages/repository-tests/src/crud/relations/fixtures/models/order.model.ts index b1045586a200..911a510bf113 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/models/order.model.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/models/order.model.ts @@ -13,6 +13,7 @@ import { } from '@loopback/repository'; import {MixedIdType} from '../../../../helpers.repository-tests'; import {Customer, CustomerWithRelations} from './customer.model'; +import {Seller} from './seller.model'; import {Shipment, ShipmentWithRelations} from './shipment.model'; @model() @@ -39,6 +40,9 @@ export class Order extends Entity { @belongsTo(() => Customer) customerId: MixedIdType; + @belongsTo(() => Seller) + sellerId: MixedIdType; + @belongsTo(() => Shipment, {keyTo: 'shipment_id', name: 'shipment'}) shipmentInfo: number; } diff --git a/packages/repository-tests/src/crud/relations/fixtures/models/seller.model.ts b/packages/repository-tests/src/crud/relations/fixtures/models/seller.model.ts new file mode 100644 index 000000000000..5135691d32ff --- /dev/null +++ b/packages/repository-tests/src/crud/relations/fixtures/models/seller.model.ts @@ -0,0 +1,54 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Entity, + EntityCrudRepository, + hasMany, + HasManyThroughRepositoryFactory, + model, + property, +} from '@loopback/repository'; +import {Customer, CustomerWithRelations} 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: { + model: () => Order, + }, + }) + customers?: Customer[]; +} + +export interface SellerRelations { + customers?: CustomerWithRelations; +} + +export type SellerWithRelations = Seller & SellerRelations; + +export interface SellerRepository + extends EntityCrudRepository { + // define additional members like relation methods here + customers: HasManyThroughRepositoryFactory< + Customer, + typeof Customer.prototype.id, + Order, + typeof Seller.prototype.id + >; +} diff --git a/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts b/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts index 9de711d82756..2f89ea8943cd 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts @@ -6,18 +6,21 @@ import {Getter} from '@loopback/context'; import { BelongsToAccessor, - HasManyRepositoryFactory, - HasOneRepositoryFactory, - juggler, + BelongsToDefinition, + createBelongsToAccessor, createHasManyRepositoryFactory, + createHasManyThroughRepositoryFactory, + createHasOneRepositoryFactory, HasManyDefinition, + HasManyRepositoryFactory, + HasManyThroughDefinition, + HasManyThroughRepositoryFactory, HasOneDefinition, - createBelongsToAccessor, - BelongsToDefinition, - createHasOneRepositoryFactory, + HasOneRepositoryFactory, + juggler, } from '@loopback/repository'; -import {Address, Customer, CustomerRelations, Order} from '../models'; import {CrudRepositoryCtor} from '../../../../types.repository-tests'; +import {Address, Customer, CustomerRelations, Order, Seller} from '../models'; // create the CustomerRepo by calling this func so that it can be extended from CrudRepositoryCtor export function createCustomerRepo(repoClass: CrudRepositoryCtor) { @@ -30,6 +33,12 @@ export function createCustomerRepo(repoClass: CrudRepositoryCtor) { Order, typeof Customer.prototype.id >; + public readonly sellers: HasManyThroughRepositoryFactory< + Seller, + typeof Seller.prototype.id, + Order, + typeof Customer.prototype.id + >; public readonly address: HasOneRepositoryFactory< Address, typeof Customer.prototype.id @@ -47,6 +56,7 @@ export function createCustomerRepo(repoClass: CrudRepositoryCtor) { db: juggler.DataSource, orderRepositoryGetter: Getter, addressRepositoryGetter: Getter, + sellersRepositoryGetter: Getter, ) { super(Customer, db); const ordersMeta = this.entityClass.definition.relations['orders']; @@ -56,6 +66,13 @@ export function createCustomerRepo(repoClass: CrudRepositoryCtor) { orderRepositoryGetter, ); + const sellersMeta = this.entityClass.definition.relations['sellers']; + this.sellers = createHasManyThroughRepositoryFactory( + sellersMeta as HasManyThroughDefinition, + sellersRepositoryGetter, + orderRepositoryGetter, + ); + const addressMeta = this.entityClass.definition.relations['address']; this.address = createHasOneRepositoryFactory( addressMeta as HasOneDefinition, diff --git a/packages/repository-tests/src/crud/relations/fixtures/repositories/index.ts b/packages/repository-tests/src/crud/relations/fixtures/repositories/index.ts index 50970152cee5..cc5832162759 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/repositories/index.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/repositories/index.ts @@ -6,4 +6,5 @@ export * from './address.repository'; export * from './customer.repository'; export * from './order.repository'; +export * from './seller.repository'; export * from './shipment.repository'; diff --git a/packages/repository-tests/src/crud/relations/fixtures/repositories/seller.repository.ts b/packages/repository-tests/src/crud/relations/fixtures/repositories/seller.repository.ts new file mode 100644 index 000000000000..92f28882b1c7 --- /dev/null +++ b/packages/repository-tests/src/crud/relations/fixtures/repositories/seller.repository.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Getter} from '@loopback/context'; +import { + createHasManyThroughRepositoryFactory, + HasManyThroughDefinition, + HasManyThroughRepositoryFactory, + juggler, +} from '@loopback/repository'; +import {CrudRepositoryCtor} from '../../../../types.repository-tests'; +import {Customer, Order, Seller, SellerRelations} from '../models'; + +// create the SellerRepo by calling this func so that it can be extended from CrudRepositoryCtor +export function createSellerRepo(repoClass: CrudRepositoryCtor) { + return class SellerRepository extends repoClass< + Seller, + typeof Seller.prototype.id, + SellerRelations + > { + public readonly customers: HasManyThroughRepositoryFactory< + Customer, + typeof Customer.prototype.id, + Order, + typeof Seller.prototype.id + >; + + constructor( + db: juggler.DataSource, + customerRepositoryGetter: Getter, + orderRepositoryGetter: Getter, + ) { + super(Seller, db); + const customersMeta = this.entityClass.definition.relations['customers']; + this.customers = createHasManyThroughRepositoryFactory( + customersMeta as HasManyThroughDefinition, + customerRepositoryGetter, + orderRepositoryGetter, + ); + } + }; +} diff --git a/packages/repository-tests/src/crud/relations/helpers.ts b/packages/repository-tests/src/crud/relations/helpers.ts index 80dd97d5ec09..dc7dea5abfa9 100644 --- a/packages/repository-tests/src/crud/relations/helpers.ts +++ b/packages/repository-tests/src/crud/relations/helpers.ts @@ -12,6 +12,8 @@ import { CustomerRepository, Order, OrderRepository, + Seller, + SellerRepository, Shipment, ShipmentRepository, } from './fixtures/models'; @@ -19,6 +21,7 @@ import { createAddressRepo, createCustomerRepo, createOrderRepo, + createSellerRepo, createShipmentRepo, } from './fixtures/repositories'; @@ -28,6 +31,7 @@ export function givenBoundCrudRepositories( features: CrudFeatures, ) { Order.definition.properties.id.type = features.idType; + Seller.definition.properties.id.type = features.idType; Address.definition.properties.id.type = features.idType; Customer.definition.properties.id.type = features.idType; Shipment.definition.properties.id.type = features.idType; @@ -49,6 +53,7 @@ export function givenBoundCrudRepositories( db, async () => orderRepo, async () => addressRepo, + async () => sellerRepo, ); // register the inclusionResolvers here for customerRepo @@ -64,6 +69,10 @@ export function givenBoundCrudRepositories( 'address', customerRepo.address.inclusionResolver, ); + // customerRepo.inclusionResolvers.set( + // 'sellers', + // customerRepo.sellers.inclusionResolver, + // ); const orderRepoClass = createOrderRepo(repositoryClass); const orderRepo: OrderRepository = new orderRepoClass( @@ -94,10 +103,18 @@ export function givenBoundCrudRepositories( async () => customerRepo, ); + const sellerRepoClass = createSellerRepo(repositoryClass); + const sellerRepo: SellerRepository = new sellerRepoClass( + db, + async () => customerRepo, + async () => orderRepo, + ); + return { customerRepo, orderRepo, shipmentRepo, addressRepo, + sellerRepo, }; } 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..2cf6f78897b3 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/has-many-through-repository-factory.unit.ts @@ -0,0 +1,221 @@ +// Copyright IBM Corp. 2020. 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 { + createHasManyThroughRepositoryFactory, + DefaultCrudRepository, + Entity, + HasManyThroughDefinition, + juggler, + ModelDefinition, + RelationType, +} 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(orderRepo), + ), + ).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(orderRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with a target that is not a type resolver', () => { + const relationMeta = givenHasManyThroughDefinition({ + // cast to any to disable compile check - we want to verify runtime assertion + target: (Customer as unknown) as TypeResolver, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with through.keyFrom pointing to an unknown property', () => { + const relationMeta = givenHasManyThroughDefinition({ + // Let the relation to use the default keyTo value "companyId" + // which does not exist on the Order model! + through: { + model: () => Order, + keyFrom: undefined, + keyTo: 'customerId', + }, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through model Order is missing.*foreign key companyId/); + }); + + it('rejects relations with through.keyTo pointing to an unknown property', () => { + const relationMeta = givenHasManyThroughDefinition({ + through: { + model: () => Order, + keyFrom: 'sellerId', + keyTo: 'not-existing', + }, + }); + + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through model Order is missing.*foreign key not-existing/); + }); + + 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 specified/); + }); + + it('rejects relations with "through model" that is not a type resolver', () => { + const relationMeta = givenHasManyThroughDefinition(); + relationMeta.through.model = (true as unknown) as TypeResolver< + Entity, + typeof Entity + >; + expect(() => + createHasManyThroughRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + Getter.fromValue(orderRepo), + ), + ).to.throw(/through\.model must be a type resolver/); + }); + + /*------------- HELPERS ---------------*/ + + class Customer extends Entity { + static definition = new ModelDefinition('Customer').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + class Company extends Entity { + static definition = new ModelDefinition('Company').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, + }) + .addProperty('sellerId', { + type: Number, + }); + id: number; + customerId: number; + companyId: number; + } + + class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Customer, dataSource); + } + } + + /* + class CompanyRepository extends DefaultCrudRepository< + Company, + typeof Company.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Company, 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 { + const defaults: HasManyThroughDefinition = { + type: RelationType.hasMany, + targetsMany: true, + name: 'customers', + source: Company, + target: () => Customer, + through: { + model: () => Order, + keyFrom: 'sellerId', + keyTo: 'customerId', + }, + }; + + 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..1eb1e9f41502 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts @@ -0,0 +1,80 @@ +import { + DataObject, + Entity, + EntityCrudRepository, + Getter, + HasManyThroughDefinition, +} from '../..'; +import { + createTargetConstraint, + createThroughConstraint, + resolveHasManyThroughMetadata, +} from './has-many-through.helpers'; +import { + DefaultHasManyThroughRepository, + HasManyThroughRepository, +} from './has-many-through.repository'; + +export type HasManyThroughRepositoryFactory< + TargetEntity extends Entity, + TargetID, + ThroughEntity extends Entity, + ForeignKeyType +> = ( + fkValue: ForeignKeyType, +) => HasManyThroughRepository; + +export function createHasManyThroughRepositoryFactory< + TargetEntity extends Entity, + TargetID, + ThroughEntity extends Entity, + ThroughID, + ForeignKeyType +>( + relationMetadata: HasManyThroughDefinition, + targetRepositoryGetter: Getter>, + throughRepositoryGetter: Getter< + EntityCrudRepository + >, +): HasManyThroughRepositoryFactory< + TargetEntity, + TargetID, + ThroughEntity, + ForeignKeyType +> { + const meta = resolveHasManyThroughMetadata(relationMetadata); + return (fkValue?: ForeignKeyType) => { + const getTargetContraint = ( + throughInstances: ThroughEntity[], + ): DataObject => { + return createTargetConstraint( + meta, + throughInstances, + ); + }; + const getThroughConstraint = ( + targetInstance?: TargetEntity, + ): DataObject => { + const constriant: DataObject = createThroughConstraint< + TargetEntity, + ThroughEntity, + ForeignKeyType + >(meta, fkValue, targetInstance); + return constriant; + }; + + return new DefaultHasManyThroughRepository< + TargetEntity, + TargetID, + EntityCrudRepository, + ThroughEntity, + 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..ff394c96fe92 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through.helpers.ts @@ -0,0 +1,176 @@ +import debugFactory from 'debug'; +import {camelCase} from 'lodash'; +import { + DataObject, + Entity, + HasManyThroughDefinition, + InvalidRelationError, + isTypeResolver, +} from '../..'; + +const debug = debugFactory('loopback:repository:has-many-through-helpers'); + +export type HasManyThroughResolvedDefinition = HasManyThroughDefinition & { + keyTo: string; + keyFrom: string; + through: { + keyTo: string; + keyFrom: 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.keyTo; + const targetFkName = relationMeta.through.keyTo; + 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.keyTo; + const targetFkName = relationMeta.through.keyTo; + const sourceFkName = relationMeta.through.keyFrom; + // 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 (!relationMeta.source) { + const reason = 'source model must be defined'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.target)) { + const reason = 'target must be a type resolver'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!relationMeta.through) { + const reason = 'through must be specified'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.through?.model)) { + const reason = 'through.model must be a type resolver'; + throw new InvalidRelationError(reason, relationMeta); + } + + const throughModel = relationMeta.through.model(); + const throughModelProperties = throughModel.definition?.properties; + + const targetModel = relationMeta.target(); + const targetModelProperties = targetModel.definition?.properties; + + // check if metadata is already complete + if ( + relationMeta.through?.keyTo && + throughModelProperties[relationMeta.through.keyTo] && + relationMeta.through?.keyFrom && + throughModelProperties[relationMeta.through.keyFrom] && + relationMeta.keyTo && + targetModelProperties[relationMeta.keyTo] + ) { + // 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.through?.keyFrom ?? camelCase(sourceModel.modelName + '_id'); + if (!throughModelProperties[sourceFkName]) { + const reason = `through model ${throughModel.name} is missing definition of source foreign key ${sourceFkName}`; + throw new InvalidRelationError(reason, relationMeta); + } + + const targetFkName = + relationMeta.through?.keyTo ?? camelCase(targetModel.modelName + '_id'); + if (!throughModelProperties[targetFkName]) { + const reason = `through model ${throughModel.name} is missing definition of target foreign key ${targetFkName}`; + throw new InvalidRelationError(reason, relationMeta); + } + + const targetPrimaryKey = + relationMeta.keyTo ?? targetModel.definition.idProperties()[0]; + if (!targetPrimaryKey || !targetModelProperties[targetPrimaryKey]) { + const reason = `target model ${targetModel.modelName} does not have any primary key (id property)`; + throw new InvalidRelationError(reason, relationMeta); + } + + const sourcePrimaryKey = + relationMeta.keyFrom ?? sourceModel.definition.idProperties()[0]; + if (!sourcePrimaryKey || !targetModelProperties[sourcePrimaryKey]) { + const reason = `source model ${sourceModel.modelName} does not have any primary key (id property)`; + throw new InvalidRelationError(reason, relationMeta); + } + + return Object.assign(relationMeta, { + keyTo: targetPrimaryKey, + keyFrom: sourcePrimaryKey, + through: { + ...relationMeta.through, + keyTo: targetFkName, + keyFrom: sourceFkName, + }, + }); +} 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 index 33e96e3e85f7..7c581ecbc764 100644 --- a/packages/repository/src/relations/has-many/has-many-through.repository.ts +++ b/packages/repository/src/relations/has-many/has-many-through.repository.ts @@ -3,7 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Getter} from '@loopback/core'; import {Count, DataObject, Entity, Filter, Options, Where} from '../..'; +import { + constrainDataObject, + constrainFilter, + constrainWhere, + EntityCrudRepository, +} from '../../repositories'; /** * CRUD operations for a target repository of a HasManyThrough relation @@ -98,3 +105,129 @@ export interface HasManyThroughRepository< }, ): Promise; } + +export class DefaultHasManyThroughRepository< + TargetEntity extends Entity, + TargetID, + TargetRepository extends EntityCrudRepository, + ThroughEntity extends Entity, + ThroughID, + ThroughRepository extends EntityCrudRepository +> implements HasManyThroughRepository { + constructor( + public getTargetRepository: Getter, + public getThroughRepository: Getter, + public getTargetConstraint: ( + throughInstances: ThroughEntity[], + ) => DataObject, + public getThroughConstraint: ( + targetInstance?: TargetEntity, + ) => DataObject, + ) {} + + async create( + targetModelData: DataObject, + options?: Options & { + throughData?: DataObject; + 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( + options?.throughData ?? {}, + throughConstraint as DataObject, + ), + options?.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), + options?.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(); + // TODO(derdeka): How to delete throughInstances? + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + options?.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), + options?.throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.updateAll( + constrainDataObject(dataObject, targetConstraint), + constrainWhere(where, targetConstraint as Where), + options, + ); + } + + async link( + targetModelId: TargetID, + options?: Options & { + throughData?: DataObject; + throughOptions?: Options; + }, + ): Promise { + throw new Error('Method not implemented.'); + } + + async unlink( + targetModelId: TargetID, + options?: Options & { + throughOptions?: Options; + }, + ): Promise { + throw new Error('Method not implemented.'); + } +} 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 29371b33cbab..8fb780c1f8e7 100644 --- a/packages/repository/src/relations/has-many/has-many.decorator.ts +++ b/packages/repository/src/relations/has-many/has-many.decorator.ts @@ -5,7 +5,11 @@ import {Entity, EntityResolver} from '../../model'; import {relation} from '../relation.decorator'; -import {HasManyDefinition, RelationType} from '../relation.types'; +import { + HasManyDefinition, + RelationType, + HasManyThroughDefinition, +} 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 a1fd1dee453a..61678cc48013 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 f40b35975efe..ca4b8ce3dec3 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -87,12 +87,12 @@ export interface HasManyThroughDefinition extends RelationDefinitionBase { /** * The foreign key in the source model, e.g. Customer#id. */ - keyFrom: string; + keyFrom?: string; /** * The primary key of the target model, e.g Seller#id. */ - keyTo: string; + keyTo?: string; through: { /** @@ -106,12 +106,12 @@ export interface HasManyThroughDefinition extends RelationDefinitionBase { /** * The foreign key of the source model defined in the through model, e.g. Order#customerId */ - keyFrom: string; + keyFrom?: string; /** * The foreign key of the target model defined in the through model, e.g. Order#sellerId */ - keyTo: string; + keyTo?: string; }; } diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index cfa6f75d5631..3124b94b1927 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, @@ -277,6 +280,38 @@ export class DefaultCrudRepository< ); } + protected createHasManyThroughRepositoryFactoryFor< + TargetEntiy extends Entity, + TargetID, + ThroughEntity extends Entity, + ThroughID, + ForeignKeyType + >( + relationName: string, + targetRepositoryGetter: Getter>, + throughRepositoryGetter: Getter< + EntityCrudRepository + >, + ): HasManyThroughRepositoryFactory< + TargetEntiy, + TargetID, + ThroughEntity, + ForeignKeyType + > { + const meta = this.entityClass.definition.relations[relationName]; + return createHasManyThroughRepositoryFactory< + TargetEntiy, + TargetID, + ThroughEntity, + ThroughID, + ForeignKeyType + >( + meta as HasManyThroughDefinition, + targetRepositoryGetter, + throughRepositoryGetter, + ); + } + /** * @deprecated * Function to create a belongs to accessor