Skip to content

Commit

Permalink
feat(repository): adding hasManyThrough to hasMany and its helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
Agnes Lin committed May 7, 2020
1 parent 0663c04 commit 2e62bc6
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 28 deletions.
176 changes: 176 additions & 0 deletions packages/repository/src/relations/has-many/has-many-through.helper.ts
Original file line number Diff line number Diff line change
@@ -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<Target> {
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<Through> {
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,
},
});
}
49 changes: 21 additions & 28 deletions packages/repository/src/relations/relation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export interface RelationDefinitionBase {
target: TypeResolver<Entity, typeof Entity>;
}

/**
* 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;
Expand All @@ -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<Entity, typeof Entity>;

/**
* 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;
};
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 2e62bc6

Please sign in to comment.