diff --git a/packages/repository/src/relations/has-many/has-many-through.helper.ts b/packages/repository/src/relations/has-many/has-many-through.helper.ts new file mode 100644 index 000000000000..fda94a403739 --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through.helper.ts @@ -0,0 +1,176 @@ +import debugFactory from 'debug'; +import {camelCase} from 'lodash'; +import { + DataObject, + Entity, + HasManyDefinition, + InvalidRelationError, + isTypeResolver, +} from '../..'; + +const debug = debugFactory('loopback:repository:has-many-through-helpers'); + +export type HasManyThroughResolvedDefinition = HasManyDefinition & { + 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: HasManyDefinition, +): 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/relation.types.ts b/packages/repository/src/relations/relation.types.ts index bbb06fd36b39..974551f0d474 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -52,6 +52,10 @@ export interface RelationDefinitionBase { target: TypeResolver; } +/** + * HasManyDefinition defines one-to-many relations and also possible defines + * many-to-many relations with through models. + */ export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; targetsMany: true; @@ -69,47 +73,37 @@ export interface HasManyDefinition extends RelationDefinitionBase { */ keyTo?: string; keyFrom?: string; -} - -/** - * A `hasManyThrough` relation defines 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. - * - * Warning: The hasManyThrough interface is experimental and is subject to change. - * If backwards-incompatible changes are made, a new major version may not be - * released. - */ -export interface HasManyThroughDefinition extends RelationDefinitionBase { - type: RelationType.hasMany; - targetsMany: true; /** - * The foreign key in the source model, e.g. Customer#id. - */ - keyFrom: string; - - /** - * The primary key of the target model, e.g Seller#id. + * Description of the through model of the hasManyThrough relation. + * + * A `hasManyThrough` relation defines 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. + * + * E.g a Category has many Items, and an Item can have many Categories. CategoryItemLink can be the through model. + * Such a through model has information of the source model(Category) and the target model(Item). + * + * Warning: The hasManyThrough interface is experimental and is subject to change. + * If backwards-incompatible changes are made, a new major version may not be + * released. */ - keyTo: string; - - through: { + through?: { /** * 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. + * E.g. when a Category has many CategoryItemLink instances and a Item has many CategoryItemLink instances, + * then CategoryItemLink is through. */ model: TypeResolver; /** - * The foreign key of the source model defined in the through model, e.g. Order#customerId + * The foreign key of the source model defined in the through model, e.g. CategoryItemLink#categoryId */ keyFrom: string; /** - * The foreign key of the target model defined in the through model, e.g. Order#sellerId + * The foreign key of the target model defined in the through model, e.g. CategoryItemLink#ItemId */ keyTo: string; }; @@ -153,7 +147,6 @@ export interface HasOneDefinition extends RelationDefinitionBase { */ export type RelationMetadata = | HasManyDefinition - | HasManyThroughDefinition | BelongsToDefinition | HasOneDefinition // TODO(bajtos) add other relation types and remove RelationDefinitionBase once