diff --git a/docs/site/HasMany-relation.md b/docs/site/HasMany-relation.md index 8fecec727def..4d0790562b33 100644 --- a/docs/site/HasMany-relation.md +++ b/docs/site/HasMany-relation.md @@ -56,7 +56,7 @@ export class Customer extends Entity { }) name: string; - @hasMany(Order) + @hasMany(() => Order) orders?: Order[]; constructor(data: Partial) { @@ -85,7 +85,7 @@ as follows: // import statements class Customer extends Entity { // constructor, properties, etc. - @hasMany(Order, {keyTo: 'custId'}) + @hasMany(() => Order, {keyTo: 'custId'}) orders?: Order[]; } ``` @@ -99,18 +99,19 @@ repository, the following are required: - Use [Dependency Injection](Dependency-injection.md) to inject an instance of the target repository in the constructor of your source repository class. + - Declare a property with the factory function type - `HasManyRepositoryFactory` on - the source repository class. -- call the `_createHasManyRepositoryFactoryFor` function in the constructor of - the source repository class with the relation name (decorated relation - property on the source model) and target repository instance and assign it the - property mentioned above. + `HasManyAccessor` on the source + repository class. +- call the `_createHasManyAccessorFor` function in the constructor of the source + repository class with the relation name (decorated relation property on the + source model) and target repository instance and assign it the property + mentioned above. The following code snippet shows how it would look like: {% include code-caption.html -content="/src/repositories/customer.repository.ts.ts" %} +content="/src/repositories/customer.repository.ts" %} ```ts import {Order, Customer} from '../models'; @@ -118,25 +119,23 @@ import {OrderRepository} from './order.repository.ts'; import { DefaultCrudRepository, juggler, - HasManyRepositoryFactory, + HasManyAccessor, repository, } from '@loopback/repository'; -import {inject} from '@loopback/core'; +import {inject, Getter} from '@loopback/core'; class CustomerRepository extends DefaultCrudRepository< Customer, typeof Customer.prototype.id > { - public orders: HasManyRepositoryFactory; + public orders: HasManyAccessor; constructor( @inject('datasources.db') protected db: juggler.DataSource, - @repository(OrderRepository) orderRepository: OrderRepository, + @repository.getter('repositories.OrderRepository') + getOrderRepository: Getter, ) { super(Customer, db); - this.orders = this._createHasManyRepositoryFactoryFor( - 'orders', - orderRepository, - ); + this.orders = this._createHasManyAccessorFor_('orders', getOrderRepository); } } ``` diff --git a/docs/site/todo-list-tutorial-model.md b/docs/site/todo-list-tutorial-model.md index 18066eab237a..2f3b1f6131ed 100644 --- a/docs/site/todo-list-tutorial-model.md +++ b/docs/site/todo-list-tutorial-model.md @@ -78,7 +78,7 @@ model. To `TodoList` model, add in the following property: export class TodoList extends Entity { // ...properties defined by the CLI... - @hasMany(Todo) + @hasMany(() => Todo, {keyTo: 'todoListId'}) todos?: Todo[]; // ...constructor def... diff --git a/docs/site/todo-list-tutorial-repository.md b/docs/site/todo-list-tutorial-repository.md index c9e6769bdafe..758d5524f2dc 100644 --- a/docs/site/todo-list-tutorial-repository.md +++ b/docs/site/todo-list-tutorial-repository.md @@ -35,9 +35,9 @@ we'll need to make two more additions: - inject `TodoRepository` instance Once the property type for `todos` have been defined, use -`this._createHasManyRepositoryFactoryFor` to assign it a repository contraining -factory function. Pass in the name of the relationship (`todos`) and the Todo -repository instance to constrain as the arguments for the function. +`this._createHasManyAccessorFor` to assign it a repository constraining factory +function. Pass in the name of the relationship (`todos`) and the Todo repository +instance to constrain as the arguments for the function. #### src/repositories/todo-list.repository.ts @@ -45,28 +45,26 @@ repository instance to constrain as the arguments for the function. import { DefaultCrudRepository, juggler, - HasManyRepositoryFactory, + HasManyAccessor, repository, } from '@loopback/repository'; import {TodoList, Todo} from '../models'; -import {inject} from '@loopback/core'; +import {inject, Getter} from '@loopback/core'; import {TodoRepository} from './todo.repository'; export class TodoListRepository extends DefaultCrudRepository< TodoList, typeof TodoList.prototype.id > { - public todos: HasManyRepositoryFactory; + public todos: HasManyAccessor; constructor( @inject('datasources.db') protected datasource: juggler.DataSource, - @repository(TodoRepository) protected todoRepository: TodoRepository, + @repository.getter('repositories.TodoRepository') + protected getTodoRepository: Getter, ) { super(TodoList, datasource); - this.todos = this._createHasManyRepositoryFactoryFor( - 'todos', - todoRepository, - ); + this.todos = this._createHasManyAccessorFor('todos', getTodoRepository); } } ``` diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index 2fe4a29c2a1c..229d2dca7136 100644 --- a/examples/todo-list/src/models/todo-list.model.ts +++ b/examples/todo-list/src/models/todo-list.model.ts @@ -25,7 +25,7 @@ export class TodoList extends Entity { }) color?: string; - @hasMany(Todo) + @hasMany(() => Todo, {keyTo: 'todoListId'}) todos: Todo[]; constructor(data?: Partial) { diff --git a/examples/todo-list/src/repositories/todo-list.repository.ts b/examples/todo-list/src/repositories/todo-list.repository.ts index 7fb84eda2623..f1eb213f5112 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -6,27 +6,25 @@ import { DefaultCrudRepository, juggler, - HasManyRepositoryFactory, + HasManyAccessor, repository, } from '@loopback/repository'; import {TodoList, Todo} from '../models'; -import {inject} from '@loopback/core'; +import {inject, Getter} from '@loopback/core'; import {TodoRepository} from './todo.repository'; export class TodoListRepository extends DefaultCrudRepository< TodoList, typeof TodoList.prototype.id > { - public todos: HasManyRepositoryFactory; + public todos: HasManyAccessor; constructor( @inject('datasources.db') protected datasource: juggler.DataSource, - @repository(TodoRepository) protected todoRepository: TodoRepository, + @repository.getter('repositories.TodoRepository') + protected getTodoRepository: Getter, ) { super(TodoList, datasource); - this.todos = this._createHasManyRepositoryFactoryFor( - 'todos', - todoRepository, - ); + this.todos = this._createHasManyAccessorFor('todos', getTodoRepository); } } diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 2ce6fa7b8006..db55a7dd1e34 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -7,6 +7,7 @@ import { ModelMetadataHelper, PropertyDefinition, ModelDefinition, + resolveType, } from '@loopback/repository'; import {MetadataInspector} from '@loopback/context'; import { @@ -124,6 +125,7 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema { propertyType = stringTypeToWrapper(propertyType); if (isComplexType(propertyType)) { + propertyType = resolveType(propertyType); Object.assign(propDef, {$ref: `#/definitions/${propertyType.name}`}); } else { Object.assign(propDef, { @@ -171,12 +173,13 @@ export function modelToJsonSchema(ctor: Function): JSONSchema { result.properties[p] = metaToJsonProperty(metaProperty); // populating JSON Schema 'definitions' - const referenceType = isArrayType(metaProperty.type as string | Function) + let referenceType = isArrayType(metaProperty.type as string | Function) ? // shimks: ugly type casting; this should be replaced by logic to throw // error if itemType/type is not a string or a function (metaProperty.itemType as string | Function) : (metaProperty.type as string | Function); if (typeof referenceType === 'function' && isComplexType(referenceType)) { + referenceType = resolveType(referenceType); const propSchema = getJsonSchema(referenceType); if (propSchema && Object.keys(propSchema).length > 0) { diff --git a/packages/repository-json-schema/test/integration/build-schema.integration.ts b/packages/repository-json-schema/test/integration/build-schema.integration.ts index f4d35bf45721..a8fd9137d022 100644 --- a/packages/repository-json-schema/test/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/test/integration/build-schema.integration.ts @@ -3,7 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {model, property} from '@loopback/repository'; +import { + model, + property, + belongsTo, + hasMany, + Entity, +} from '@loopback/repository'; import { modelToJsonSchema, JSON_SCHEMA_KEY, @@ -370,6 +376,84 @@ describe('build-schema', () => { expectValidJsonSchema(jsonSchema); }); + it('properly converts decorated custom array type with a resolver', () => { + @model() + class CustomType { + @property() + prop: string; + } + + @model() + class TestModel { + @property.array(() => CustomType) + cusType: CustomType[]; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + cusType: { + type: 'array', + items: {$ref: '#/definitions/CustomType'}, + }, + }); + expect(jsonSchema.definitions).to.deepEqual({ + CustomType: { + title: 'CustomType', + properties: { + prop: { + type: 'string', + }, + }, + }, + }); + expectValidJsonSchema(jsonSchema); + }); + + it('properly converts decorated models with hasMany and belongsTo', () => { + @model() + class Order extends Entity { + @property({id: true}) + id: number; + @belongsTo(() => Customer) + customerId: number; + } + + @model() + class Customer extends Entity { + @property({id: true}) + id: number; + @hasMany(() => Order) + orders: Order[]; + } + + const orderSchema = modelToJsonSchema(Order); + const customerSchema = modelToJsonSchema(Customer); + expect(orderSchema.properties).to.deepEqual({ + id: {type: 'number'}, + customerId: {type: 'number'}, + }); + expect(customerSchema.properties).to.deepEqual({ + id: {type: 'number'}, + orders: { + type: 'array', + items: {$ref: '#/definitions/Order'}, + }, + }); + expect(customerSchema.definitions).to.deepEqual({ + Order: { + title: 'Order', + properties: { + id: { + type: 'number', + }, + customerId: {type: 'number'}, + }, + }, + }); + + expectValidJsonSchema(orderSchema); + }); + it('creates definitions only at the root level of the schema', () => { @model() class CustomTypeFoo { diff --git a/packages/repository-json-schema/test/unit/build-schema.unit.ts b/packages/repository-json-schema/test/unit/build-schema.unit.ts index 5d3ecf898264..08e4df2c7737 100644 --- a/packages/repository-json-schema/test/unit/build-schema.unit.ts +++ b/packages/repository-json-schema/test/unit/build-schema.unit.ts @@ -7,6 +7,8 @@ import {expect} from '@loopback/testlab'; import {isComplexType, stringTypeToWrapper, metaToJsonProperty} from '../..'; describe('build-schema', () => { + class CustomType {} + describe('stringTypeToWrapper', () => { context('when given primitive types in string', () => { it('returns String for "string"', () => { @@ -76,7 +78,6 @@ describe('build-schema', () => { }); it('returns true if any other wrappers are passed in', () => { - class CustomType {} expect(isComplexType(CustomType)).to.eql(true); }); }); @@ -107,12 +108,17 @@ describe('build-schema', () => { }); it('converts complex types', () => { - class CustomType {} expect(metaToJsonProperty({type: CustomType})).to.eql({ $ref: '#/definitions/CustomType', }); }); + it('converts complex types with resolver', () => { + expect(metaToJsonProperty({type: () => CustomType})).to.eql({ + $ref: '#/definitions/CustomType', + }); + }); + it('converts primitive arrays', () => { expect(metaToJsonProperty({type: Array, itemType: Number})).to.eql({ type: 'array', @@ -121,11 +127,19 @@ describe('build-schema', () => { }); it('converts arrays of custom types', () => { - class CustomType {} expect(metaToJsonProperty({type: Array, itemType: CustomType})).to.eql({ type: 'array', items: {$ref: '#/definitions/CustomType'}, }); }); + + it('converts array types with resolver', () => { + expect( + metaToJsonProperty({type: Array, itemType: () => CustomType}), + ).to.eql({ + type: 'array', + items: {$ref: '#/definitions/CustomType'}, + }); + }); }); }); diff --git a/packages/repository/examples/models/order.model.ts b/packages/repository/examples/models/order.model.ts index 5dea92626ded..2a2fbf03a2fa 100644 --- a/packages/repository/examples/models/order.model.ts +++ b/packages/repository/examples/models/order.model.ts @@ -26,6 +26,6 @@ class Order extends Entity { id: string; customerId: string; - @belongsTo() + @belongsTo(() => Customer) customer: Customer; } diff --git a/packages/repository/src/decorators/model.decorator.ts b/packages/repository/src/decorators/model.decorator.ts index 21be80f0c937..79c74fc18fc4 100644 --- a/packages/repository/src/decorators/model.decorator.ts +++ b/packages/repository/src/decorators/model.decorator.ts @@ -14,6 +14,8 @@ import { ModelDefinition, ModelDefinitionSyntax, PropertyDefinition, + TypeResolver, + ERR_TARGET_UNDEFINED, } from '../model'; import {RELATIONS_KEY, RelationDefinitionBase} from './relation.decorator'; @@ -91,6 +93,21 @@ export function model(definition?: Partial) { * @returns {(target:any, key:string)} */ export function property(definition?: Partial) { + const isCyclic = + definition && + (definition as Object).hasOwnProperty('type') && + !definition.type; + const isCyclicArray = + definition && + (definition.type === Array || definition.type === 'array') && + (definition as object).hasOwnProperty('itemType') && + !definition.itemType; + + if (isCyclic || isCyclicArray) { + // this path is taken when cyclic dependency is detected + // in that case, a TypeResolver should be used instead + throw new Error(ERR_TARGET_UNDEFINED); + } return PropertyDecoratorFactory.createDecorator( MODEL_PROPERTIES_KEY, Object.assign({}, definition), @@ -100,7 +117,6 @@ export function property(definition?: Partial) { export namespace property { export const ERR_PROP_NOT_ARRAY = '@property.array can only decorate array properties!'; - export const ERR_NO_ARGS = 'decorator received less than two parameters'; /** * @@ -108,8 +124,8 @@ export namespace property { * @param definition Optional PropertyDefinition object for additional * metadata */ - export function array( - itemType: Function, + export function array( + itemType: T | TypeResolver, definition?: Partial, ) { return function(target: Object, propertyName: string) { diff --git a/packages/repository/src/decorators/relation.decorator.ts b/packages/repository/src/decorators/relation.decorator.ts index e683765fd77b..2cb01e13e972 100644 --- a/packages/repository/src/decorators/relation.decorator.ts +++ b/packages/repository/src/decorators/relation.decorator.ts @@ -4,8 +4,13 @@ // License text available at https://opensource.org/licenses/MIT import {Class} from '../common-types'; -import {Entity} from '../model'; -import {PropertyDecoratorFactory} from '@loopback/context'; +import { + Entity, + TypeResolver, + isTypeResolver, + ERR_TARGET_UNDEFINED, +} from '../model'; +import {PropertyDecoratorFactory, MetadataInspector} from '@loopback/context'; import {property} from './model.decorator'; import {camelCase} from 'lodash'; @@ -31,11 +36,18 @@ export class RelationMetadata { export interface RelationDefinitionBase { type: RelationType; + target: TypeResolver; } export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; - keyTo: string; + keyTo?: string; +} + +export interface BelongsToDefinition extends RelationDefinitionBase { + type: RelationType.belongsTo; + keyTo?: string; + keyFrom?: string; } /** @@ -53,10 +65,33 @@ export function relation(definition?: Object) { * @param definition * @returns {(target:any, key:string)} */ -export function belongsTo(definition?: Object) { - // Apply model definition to the model class - const rel = Object.assign({type: RelationType.belongsTo}, definition); - return PropertyDecoratorFactory.createDecorator(RELATIONS_KEY, rel); +export function belongsTo( + targetModel: TypeResolver, + definition?: Partial, +) { + const defIsCyclic = + definition && + (definition as Object).hasOwnProperty('target') && + !definition.target; + if (!targetModel || defIsCyclic) { + throw new Error(ERR_TARGET_UNDEFINED); + } + return function(target: Object, key: string) { + const propMeta = { + type: MetadataInspector.getDesignTypeForProperty(target, key), + }; + property(propMeta)(target, key); + + const rel: BelongsToDefinition = { + type: RelationType.belongsTo, + target: targetModel, + keyFrom: key, + }; + + // Apply model definition to the model class + Object.assign(rel, definition); + relation(rel)(target, key); + }; } /** @@ -78,7 +113,7 @@ export function hasOne(definition?: Object) { * @returns {(target:any, key:string)} */ export function hasMany( - targetModel: T, + targetModel: TypeResolver, definition?: Partial, ) { // todo(shimks): extract out common logic (such as @property.array) to @@ -86,28 +121,11 @@ export function hasMany( return function(target: Object, key: string) { property.array(targetModel)(target, key); - const defaultFkName = camelCase(target.constructor.name + '_id'); - const hasKeyTo = definition && definition.keyTo; - const hasDefaultFkProperty = - targetModel.definition && - targetModel.definition.properties && - targetModel.definition.properties[defaultFkName]; - if (!(hasKeyTo || hasDefaultFkProperty)) { - // note(shimks): should we also check for the existence of explicitly - // given foreign key name on the juggler definition? - throw new Error( - `foreign key ${defaultFkName} not found on ${ - targetModel.name - } model's juggler definition`, - ); - } - const meta = {keyTo: defaultFkName}; + const meta: Partial = {target: targetModel}; + Object.assign(meta, definition, {type: RelationType.hasMany}); - PropertyDecoratorFactory.createDecorator( - RELATIONS_KEY, - meta as HasManyDefinition, - )(target, key); + relation(meta)(target, key); }; } diff --git a/packages/repository/src/decorators/repository.decorator.ts b/packages/repository/src/decorators/repository.decorator.ts index e2496a66214f..8d5daaff88ea 100644 --- a/packages/repository/src/decorators/repository.decorator.ts +++ b/packages/repository/src/decorators/repository.decorator.ts @@ -8,7 +8,7 @@ import {Model, Entity} from '../model'; import {Repository, DefaultCrudRepository} from '../repositories'; import {DataSource} from '../datasource'; import {juggler} from '../repositories/legacy-juggler-bridge'; -import {inject, Context, Injection} from '@loopback/context'; +import {inject, Context, Injection, BindingAddress} from '@loopback/context'; import {Class} from '../common-types'; /** @@ -172,6 +172,19 @@ export function repository( }; } +export namespace repository { + /** + * Decorator used to inject a Getter for a repository + * Mainly intended for usage with repository injections on relation repository + * factory + * @param bindingKey + */ + // tslint:disable-next-line:no-any + export function getter(bindingKey: BindingAddress) { + return inject.getter(bindingKey); + } +} + /** * Resolve the @repository injection * @param ctx Context diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 10acd085baee..9c3740797e42 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -21,11 +21,11 @@ export type PropertyType = string | Function | Object | Type; * Property definition for a model */ export interface PropertyDefinition { - type: PropertyType; // For example, 'string', String, or {} + type: PropertyType | TypeResolver; // For example, 'string', String, or {} id?: boolean; json?: PropertyForm; store?: PropertyForm; - itemType?: PropertyType; // type of array + itemType?: PropertyType | TypeResolver; // type of array [attribute: string]: any; // Other attributes } @@ -263,6 +263,34 @@ export abstract class Entity extends Model implements Persistable { } } +/** + * An anonymous function that resolves to a class/entity + * Intended to be used for cases when the JS engine is unable to fully define + * a given type + */ +export type TypeResolver = () => T; + +/** + * A function that checks whether given element is a TypeResolver or not + * @param fn + */ +export function isTypeResolver( + fn: TypeResolver | T, +): fn is TypeResolver { + return !/^class/.test(fn.toString()); +} + +/** + * If given class/function is a TypeResolver, the resolved class is returned + * @param fn + */ +export function resolveType(fn: TypeResolver | T) { + return isTypeResolver(fn) ? fn() : fn; +} + +export const ERR_TARGET_UNDEFINED = + 'Target model is undefined. Please consider using TypeResolver (() => TargetModel)'; + /** * Domain events */ diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index c862cbcb4e48..773867cac9bd 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -6,7 +6,7 @@ import * as legacy from 'loopback-datasource-juggler'; import * as assert from 'assert'; -import {isPromiseLike} from '@loopback/context'; +import {isPromiseLike, Getter} from '@loopback/context'; import { Options, AnyObject, @@ -20,9 +20,14 @@ import {Filter, Where} from '../query'; import {EntityCrudRepository} from './repository'; import { createHasManyRepositoryFactory, - HasManyRepositoryFactory, + HasManyAccessor, + BelongsToAccessor, + createBelongsToFactory, } from './relation.factory'; -import {HasManyDefinition} from '../decorators/relation.decorator'; +import { + HasManyDefinition, + BelongsToDefinition, +} from '../decorators/relation.decorator'; export namespace juggler { export import DataSource = legacy.DataSource; @@ -145,7 +150,7 @@ export class DefaultCrudRepository * orderRepository: EntityCrudRepository, * ) { * super(Customer, db); - * this.orders = this._createHasManyRepositoryFactoryFor( + * this.orders = this._createHasManyAccessorFor( * 'orders', * orderRepository, * ); @@ -154,20 +159,28 @@ export class DefaultCrudRepository * ``` * * @param relationName Name of the relation defined on the source model - * @param targetRepo Target repository instance + * @param targetRepoGetter Getter for the target repository instance */ - protected _createHasManyRepositoryFactoryFor< - Target extends Entity, - TargetID, - ForeignKeyType - >( + protected _createHasManyAccessorFor( relationName: string, - targetRepo: EntityCrudRepository, - ): HasManyRepositoryFactory { + targetRepoGetter: Getter>, + ): HasManyAccessor { const meta = this.entityClass.definition.relations[relationName]; - return createHasManyRepositoryFactory( + return createHasManyRepositoryFactory( meta as HasManyDefinition, - targetRepo, + targetRepoGetter, + ); + } + + protected _createBelongsToAccessorFor( + relationName: string, + targetRepoGetter: Getter>, + ): BelongsToAccessor { + const meta = this.entityClass.definition.relations[relationName]; + return createBelongsToFactory( + meta as BelongsToDefinition, + targetRepoGetter, + this, ); } diff --git a/packages/repository/src/repositories/relation.factory.ts b/packages/repository/src/repositories/relation.factory.ts index 1b89d8d4b227..893b17cf1124 100644 --- a/packages/repository/src/repositories/relation.factory.ts +++ b/packages/repository/src/repositories/relation.factory.ts @@ -4,18 +4,33 @@ // License text available at https://opensource.org/licenses/MIT import {EntityCrudRepository} from './repository'; -import {HasManyDefinition} from '../decorators/relation.decorator'; -import {Entity} from '../model'; +import { + HasManyDefinition, + RelationType, + BelongsToDefinition, +} from '../decorators/relation.decorator'; +import {Entity, isTypeResolver} from '../model'; import { HasManyRepository, DefaultHasManyEntityCrudRepository, + DefaultBelongsToEntityCrudRepository, } from './relation.repository'; -import {DataObject} from '..'; +import {DataObject} from '../common-types'; +import {Getter} from '@loopback/context'; + +const debug = require('debug')('loopback:repository:relation:factory'); + +const ERR_NO_BELONGSTO_META = 'no belongsTo metadata found'; +const ERR_NO_ID_META = 'no id metadata found'; -export type HasManyRepositoryFactory = ( +export type HasManyAccessor = ( fkValue: ForeignKeyType, ) => HasManyRepository; +export type BelongsToAccessor = ( + sourceId: SourceId, +) => Promise; + /** * 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 @@ -35,21 +50,140 @@ export function createHasManyRepositoryFactory< ForeignKeyType >( relationMetadata: HasManyDefinition, - targetRepository: EntityCrudRepository, -): HasManyRepositoryFactory { + targetRepoGetter: Getter>, +): HasManyAccessor { + resolveHasManyMetadata(relationMetadata); + debug('resolved relation metadata: %o', relationMetadata); + const fkName = relationMetadata.keyTo; + if (!fkName) { + throw new Error('The foreign key property name (keyTo) must be specified'); + } return function(fkValue: ForeignKeyType) { - const fkName = relationMetadata.keyTo; - if (!fkName) { - throw new Error( - 'The foreign key property name (keyTo) must be specified', - ); - } // tslint:disable-next-line:no-any const constraint: any = {[fkName]: fkValue}; return new DefaultHasManyEntityCrudRepository< Target, TargetID, EntityCrudRepository - >(targetRepository, constraint as DataObject); + >(targetRepoGetter, constraint as DataObject); }; } + +/** + * Enforces a BelongsTo constraint on a repository + */ +export function createBelongsToFactory< + Target extends Entity, + TargetId, + Source extends Entity, + SourceId +>( + belongsToMetadata: BelongsToDefinition, + targetRepoGetter: Getter>, + sourceRepository: EntityCrudRepository, +): BelongsToAccessor { + resolveBelongsToMetadata(belongsToMetadata); + const foreignKey = belongsToMetadata.keyFrom; + const primaryKey = belongsToMetadata.keyTo; + if (!foreignKey) { + throw new Error( + 'The foreign key property name (keyFrom) must be specified', + ); + } + if (!primaryKey) { + throw new Error('The primary key property name (keyTo) must be specified'); + } + return async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { + const sourceModel = await sourceRepository.findById(sourceId); + const foreignKeyValue = sourceModel[foreignKey as keyof Source]; + // tslint:disable-next-line:no-any + const constraint: any = {[primaryKey]: foreignKeyValue}; + const constrainedRepo = new DefaultBelongsToEntityCrudRepository( + targetRepoGetter, + constraint as DataObject, + ); + return constrainedRepo.get(); + }; +} + +/** + * 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 hasMany metadata to resolve + */ +export function resolveHasManyMetadata(relationMeta: HasManyDefinition) { + if ( + relationMeta.target && + isTypeResolver(relationMeta.target) && + !relationMeta.keyTo + ) { + const resolvedModel = relationMeta.target(); + + debug('resolved model from given metadata: %o', resolvedModel); + + const targetRelationMeta = resolvedModel.definition.relations; + + debug('relation metadata from %o: %o', resolvedModel, targetRelationMeta); + + if (!targetRelationMeta) { + throw new Error(ERR_NO_BELONGSTO_META); + } + + let belongsToMetaExists = false; + + for (const key in targetRelationMeta) { + if (targetRelationMeta[key].type === RelationType.belongsTo) { + relationMeta.keyTo = key; + belongsToMetaExists = true; + break; + } + } + + if (!belongsToMetaExists) { + throw new Error(ERR_NO_BELONGSTO_META); + } + } + return relationMeta; +} + +/** + * Resolves given belongsTo metadata if target is specified to be a resolver. + * Mainly used to infer what the `keyTo` property should be from the target's + * property id metadata + * @param relationMeta belongsTo metadata to resolve + */ +export function resolveBelongsToMetadata(relationMeta: BelongsToDefinition) { + if ( + relationMeta.target && + isTypeResolver(relationMeta.target) && + !relationMeta.keyTo + ) { + const resolvedModel = relationMeta.target(); + + debug('resolved model from given metadata: %o', resolvedModel); + + const targetPropertiesMeta = resolvedModel.definition.properties; + + debug('relation metadata from %o: %o', resolvedModel, targetPropertiesMeta); + + if (!targetPropertiesMeta) { + throw new Error(ERR_NO_ID_META); + } + + let idMetaExists = false; + + for (const key in targetPropertiesMeta) { + if (targetPropertiesMeta[key].id === true) { + relationMeta.keyTo = key; + idMetaExists = true; + break; + } + } + + if (!idMetaExists) { + throw new Error(ERR_NO_ID_META); + } + } + return relationMeta; +} diff --git a/packages/repository/src/repositories/relation.repository.ts b/packages/repository/src/repositories/relation.repository.ts index b9e255dc5552..a889224cd856 100644 --- a/packages/repository/src/repositories/relation.repository.ts +++ b/packages/repository/src/repositories/relation.repository.ts @@ -12,6 +12,7 @@ import { import {DataObject, Options} from '../common-types'; import {Entity} from '../model'; import {Filter, Where} from '../query'; +import {Getter} from '@loopback/context'; /** * CRUD operations for a target repository of a HasMany relation @@ -55,6 +56,17 @@ export interface HasManyRepository { ): Promise; } +/** + * CRUD operations for a target repository of a BelongsTo relation + */ +export interface BelongsToRepository { + /** + * Gets the target model instance + * @param options + */ + get(options?: Options): Promise; +} + export class DefaultHasManyEntityCrudRepository< TargetEntity extends Entity, TargetID, @@ -62,34 +74,33 @@ export class DefaultHasManyEntityCrudRepository< > implements HasManyRepository { /** * Constructor of DefaultHasManyEntityCrudRepository - * @param targetRepository the related target model repository instance + * @param getTargetRepository the getter of the related target model repository instance * @param constraint the key value pair representing foreign key name to constrain * the target repository instance */ constructor( - public targetRepository: TargetRepository, + public getTargetRepository: Getter, public constraint: DataObject, ) {} - async create( targetModelData: DataObject, options?: Options, ): Promise { - return await this.targetRepository.create( + const targetRepo = await this.getTargetRepository(); + return targetRepo.create( constrainDataObject(targetModelData, this.constraint), options, ); } async find(filter?: Filter, options?: Options): Promise { - return await this.targetRepository.find( - constrainFilter(filter, this.constraint), - options, - ); + const targetRepo = await this.getTargetRepository(); + return targetRepo.find(constrainFilter(filter, this.constraint), options); } async delete(where?: Where, options?: Options): Promise { - return await this.targetRepository.deleteAll( + const targetRepo = await this.getTargetRepository(); + return targetRepo.deleteAll( constrainWhere(where, this.constraint), options, ); @@ -100,10 +111,36 @@ export class DefaultHasManyEntityCrudRepository< where?: Where, options?: Options, ): Promise { - return await this.targetRepository.updateAll( + const targetRepo = await this.getTargetRepository(); + return targetRepo.updateAll( constrainDataObject(dataObject, this.constraint), constrainWhere(where, this.constraint), options, ); } } + +export class DefaultBelongsToEntityCrudRepository< + TargetEntity extends Entity, + TargetId, + TargetRepository extends EntityCrudRepository +> implements BelongsToRepository { + /** + * Constructor of DefaultBelongsToEntityCrudRepository + * @param getTargetRepository the getter of the related target model repository instance + * @param constraint the key value pair representing foreign key name to constrain + * the target repository instance + */ + constructor( + public getTargetRepository: Getter, + public constraint: DataObject, + ) {} + async get(options?: Options): Promise { + const targetRepo = await this.getTargetRepository(); + const result = await targetRepo.find( + constrainFilter(undefined, this.constraint), + options, + ); + return result[0]; + } +} diff --git a/packages/repository/test/acceptance/belongs-to.relation.acceptance.ts b/packages/repository/test/acceptance/belongs-to.relation.acceptance.ts new file mode 100644 index 000000000000..760779e070c2 --- /dev/null +++ b/packages/repository/test/acceptance/belongs-to.relation.acceptance.ts @@ -0,0 +1,66 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler, repository, RepositoryMixin, AppWithRepository} from '../..'; +import {CustomerRepository, OrderRepository} from '../fixtures/repositories'; +import {expect} from '@loopback/testlab'; +import {Application} from '@loopback/core'; + +describe('HasMany relation', () => { + // Given a Customer and Order models - see definitions at the bottom + + let app: AppWithRepository; + let controller: OrderController; + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + + before(givenApplicationWithMemoryDB); + before(givenBoundCrudRepositoriesForCustomerAndOrder); + before(givenOrderController); + + beforeEach(async () => { + await orderRepo.deleteAll(); + }); + + it('can find customer of order', async () => { + const customer = await customerRepo.create({name: 'Order McForder'}); + const order = await orderRepo.create({ + customerId: customer.id, + description: 'Order from Order McForder, the hoarder of Mordor', + }); + const result = await controller.findOwnerOfOrder(order.id); + expect(result).to.deepEqual(customer); + }); + + //--- HELPERS ---// + + class OrderController { + constructor( + @repository(OrderRepository) protected orderRepository: OrderRepository, + ) {} + + async findOwnerOfOrder(orderId: string) { + return await this.orderRepository.customer(orderId); + } + } + + function givenApplicationWithMemoryDB() { + class TestApp extends RepositoryMixin(Application) {} + app = new TestApp(); + app.dataSource(new juggler.DataSource({name: 'db', connector: 'memory'})); + } + + async function givenBoundCrudRepositoriesForCustomerAndOrder() { + app.repository(CustomerRepository); + app.repository(OrderRepository); + customerRepo = await app.getRepository(CustomerRepository); + orderRepo = await app.getRepository(OrderRepository); + } + + async function givenOrderController() { + app.controller(OrderController); + controller = await app.get('controllers.OrderController'); + } +}); diff --git a/packages/repository/test/acceptance/has-many-without-di.relation.acceptance.ts b/packages/repository/test/acceptance/has-many-without-di.relation.acceptance.ts index a5e05aa77657..d9ef9685ec87 100644 --- a/packages/repository/test/acceptance/has-many-without-di.relation.acceptance.ts +++ b/packages/repository/test/acceptance/has-many-without-di.relation.acceptance.ts @@ -10,10 +10,11 @@ import { DefaultCrudRepository, juggler, hasMany, - HasManyRepositoryFactory, + HasManyAccessor, EntityCrudRepository, } from '../..'; import {expect} from '@loopback/testlab'; +import {createGetter} from '../test-utils'; describe('HasMany relation', () => { // Given a Customer and Order models - see definitions at the bottom @@ -27,11 +28,11 @@ describe('HasMany relation', () => { before(givenOrderRepository); before(givenCustomerRepository); beforeEach(async () => { - existingCustomerId = (await givenPersistedCustomerInstance()).id; - }); - afterEach(async () => { await orderRepo.deleteAll(); }); + beforeEach(async () => { + existingCustomerId = (await givenPersistedCustomerInstance()).id; + }); it('can create an instance of the related model', async () => { async function createCustomerOrders( @@ -116,7 +117,7 @@ describe('HasMany relation', () => { }) name: string; - @hasMany(Order) + @hasMany(() => Order, {keyTo: 'customerId'}) orders: Order[]; } @@ -133,19 +134,16 @@ describe('HasMany relation', () => { Customer, typeof Customer.prototype.id > { - public orders: HasManyRepositoryFactory< - Order, - typeof Customer.prototype.id - >; + public orders: HasManyAccessor; constructor( protected db: juggler.DataSource, orderRepository: EntityCrudRepository, ) { super(Customer, db); - this.orders = this._createHasManyRepositoryFactoryFor( + this.orders = this._createHasManyAccessorFor( 'orders', - orderRepository, + createGetter(orderRepository), ); } } diff --git a/packages/repository/test/acceptance/has-many.relation.acceptance.ts b/packages/repository/test/acceptance/has-many.relation.acceptance.ts index 5da5cd482767..d9faf9f721a5 100644 --- a/packages/repository/test/acceptance/has-many.relation.acceptance.ts +++ b/packages/repository/test/acceptance/has-many.relation.acceptance.ts @@ -3,21 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - model, - property, - Entity, - DefaultCrudRepository, - juggler, - hasMany, - repository, - RepositoryMixin, - AppWithRepository, - HasManyRepositoryFactory, -} from '../..'; +import {juggler, repository, RepositoryMixin, AppWithRepository} from '../..'; +import {Order} from '../fixtures/models'; +import {CustomerRepository, OrderRepository} from '../fixtures/repositories'; import {expect} from '@loopback/testlab'; import * as _ from 'lodash'; -import {inject} from '@loopback/context'; import {Application} from '@loopback/core'; describe('HasMany relation', () => { @@ -34,11 +24,11 @@ describe('HasMany relation', () => { before(givenCustomerController); beforeEach(async () => { - existingCustomerId = (await givenPersistedCustomerInstance()).id; - }); - afterEach(async () => { await orderRepo.deleteAll(); }); + beforeEach(async () => { + existingCustomerId = (await givenPersistedCustomerInstance()).id; + }); it('can create an instance of the related model', async () => { const order = await controller.createCustomerOrders(existingCustomerId, { @@ -76,13 +66,13 @@ describe('HasMany relation', () => { it('can patch many instances', async () => { await controller.createCustomerOrders(existingCustomerId, { description: 'order 1', - isDelivered: false, + isShipped: false, }); await controller.createCustomerOrders(existingCustomerId, { description: 'order 2', - isDelivered: false, + isShipped: false, }); - const patchObject = {isDelivered: true}; + const patchObject = {isShipped: true}; const arePatched = await controller.patchCustomerOrders( existingCustomerId, patchObject, @@ -90,18 +80,18 @@ describe('HasMany relation', () => { expect(arePatched).to.equal(2); const patchedData = _.map( await controller.findCustomerOrders(existingCustomerId), - d => _.pick(d, ['customerId', 'description', 'isDelivered']), + d => _.pick(d, ['customerId', 'description', 'isShipped']), ); expect(patchedData).to.eql([ { customerId: existingCustomerId, description: 'order 1', - isDelivered: true, + isShipped: true, }, { customerId: existingCustomerId, description: 'order 2', - isDelivered: true, + isShipped: true, }, ]); }); @@ -148,79 +138,6 @@ describe('HasMany relation', () => { //--- HELPERS ---// - @model() - class Order extends Entity { - @property({ - type: 'number', - id: true, - }) - id: number; - - @property({ - type: 'string', - required: true, - }) - description: string; - - @property({ - type: 'boolean', - required: false, - }) - isDelivered: boolean; - - @property({ - type: 'number', - required: true, - }) - customerId: number; - } - - @model() - class Customer extends Entity { - @property({ - type: 'number', - id: true, - }) - id: number; - - @property({ - type: 'string', - }) - name: string; - - @hasMany(Order) - orders: Order[]; - } - - class OrderRepository extends DefaultCrudRepository< - Order, - typeof Order.prototype.id - > { - constructor(@inject('datasources.db') protected db: juggler.DataSource) { - super(Order, db); - } - } - - class CustomerRepository extends DefaultCrudRepository< - Customer, - typeof Customer.prototype.id - > { - public orders: HasManyRepositoryFactory< - Order, - typeof Customer.prototype.id - >; - constructor( - @inject('datasources.db') protected db: juggler.DataSource, - @repository(OrderRepository) orderRepository: OrderRepository, - ) { - super(Customer, db); - this.orders = this._createHasManyRepositoryFactoryFor( - 'orders', - orderRepository, - ); - } - } - class CustomerController { constructor( @repository(CustomerRepository) diff --git a/packages/repository/test/fixtures/models/customer.model.ts b/packages/repository/test/fixtures/models/customer.model.ts new file mode 100644 index 000000000000..2523e210219f --- /dev/null +++ b/packages/repository/test/fixtures/models/customer.model.ts @@ -0,0 +1,24 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {model, property, hasMany, Entity} from '../../..'; +import {Order} from './order.model'; + +@model() +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + }) + id: number; + + @property({ + type: 'string', + }) + name: string; + + @hasMany(() => Order) + orders: Order[]; +} diff --git a/packages/repository/test/fixtures/models/index.ts b/packages/repository/test/fixtures/models/index.ts new file mode 100644 index 000000000000..b08915009cef --- /dev/null +++ b/packages/repository/test/fixtures/models/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './customer.model'; +export * from './order.model'; diff --git a/packages/repository/test/fixtures/models/order.model.ts b/packages/repository/test/fixtures/models/order.model.ts new file mode 100644 index 000000000000..5c8f5ff876e4 --- /dev/null +++ b/packages/repository/test/fixtures/models/order.model.ts @@ -0,0 +1,31 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {model, property, belongsTo, Entity} from '../../..'; +import {Customer} from './customer.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; +} diff --git a/packages/repository/test/fixtures/repositories/customer.repository.ts b/packages/repository/test/fixtures/repositories/customer.repository.ts new file mode 100644 index 000000000000..5cf83842cf15 --- /dev/null +++ b/packages/repository/test/fixtures/repositories/customer.repository.ts @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Customer, Order} from '../models'; +import {OrderRepository} from './order.repository'; +import { + DefaultCrudRepository, + HasManyAccessor, + juggler, + repository, +} from '../../..'; +import {inject, Getter} from '@loopback/context'; + +export class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id +> { + public orders: HasManyAccessor; + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('repositories.OrderRepository') + orderRepositoryGetter: Getter, + ) { + super(Customer, db); + this.orders = this._createHasManyAccessorFor( + 'orders', + orderRepositoryGetter, + ); + } +} diff --git a/packages/repository/test/fixtures/repositories/index.ts b/packages/repository/test/fixtures/repositories/index.ts new file mode 100644 index 000000000000..e77360ff004c --- /dev/null +++ b/packages/repository/test/fixtures/repositories/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './customer.repository'; +export * from './order.repository'; diff --git a/packages/repository/test/fixtures/repositories/order.repository.ts b/packages/repository/test/fixtures/repositories/order.repository.ts new file mode 100644 index 000000000000..b2e04c23986e --- /dev/null +++ b/packages/repository/test/fixtures/repositories/order.repository.ts @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Order, Customer} from '../models'; +import {CustomerRepository} from '../repositories'; +import { + DefaultCrudRepository, + juggler, + BelongsToAccessor, + repository, +} from '../../..'; +import {inject, Getter} from '@loopback/context'; + +export class OrderRepository extends DefaultCrudRepository< + Order, + typeof Order.prototype.id +> { + public customer: BelongsToAccessor; + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('repositories.CustomerRepository') + customerRepositoryGetter: Getter, + ) { + super(Order, db); + this.customer = this._createBelongsToAccessorFor( + 'customerId', + customerRepositoryGetter, + ); + } +} diff --git a/packages/repository/test/integration/repositories/relation.factory.integration.ts b/packages/repository/test/integration/repositories/relation.factory.integration.ts index b144d88c9dab..0896beb408b5 100644 --- a/packages/repository/test/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/test/integration/repositories/relation.factory.integration.ts @@ -13,30 +13,34 @@ import { ModelDefinition, createHasManyRepositoryFactory, HasManyDefinition, - HasManyRepositoryFactory, + HasManyAccessor, + hasMany, + belongsTo, + model, + property, + createBelongsToFactory, + BelongsToDefinition, } from '../../..'; import {expect} from '@loopback/testlab'; +import {createGetter} from '../../test-utils'; -describe('HasMany relation', () => { - // Given a Customer and Order models - see definitions at the bottom - let db: juggler.DataSource; - let customerRepo: EntityCrudRepository< - Customer, - typeof Customer.prototype.id - >; - let orderRepo: EntityCrudRepository; - let reviewRepo: EntityCrudRepository; - let customerOrderRepo: HasManyRepository; - let customerAuthoredReviewFactoryFn: HasManyRepositoryFactory< - Review, - typeof Customer.prototype.id - >; - let customerApprovedReviewFactoryFn: HasManyRepositoryFactory< - Review, - typeof Customer.prototype.id - >; - let existingCustomerId: number; +// Given a Customer and Order models - see definitions at the bottom +let db: juggler.DataSource; +let customerRepo: EntityCrudRepository; +let orderRepo: EntityCrudRepository; +let reviewRepo: EntityCrudRepository; +let customerOrderRepo: HasManyRepository; +let customerAuthoredReviewFactoryFn: HasManyAccessor< + Review, + typeof Customer.prototype.id +>; +let customerApprovedReviewFactoryFn: HasManyAccessor< + Review, + typeof Customer.prototype.id +>; +let existingCustomerId: number; +describe('HasMany relation', () => { before(givenCrudRepositories); before(givenPersistedCustomerInstance); before(givenConstrainedRepositories); @@ -116,115 +120,237 @@ describe('HasMany relation', () => { ); }); - it('errors when keyTo is not available hasMany metadata', () => { - const keytolessMeta = { - type: RelationType.hasMany, - }; - expect( + context('createHasManyRepositoryFactory', () => { + it('resolves belongsTo metadata', () => { + @model() + class Card extends Entity { + @property({id: true}) + id: number; + @belongsTo(() => Suite) + suiteId: string; + } + + @model() + class Suite extends Entity { + @property({id: true}) + id: string; + @hasMany(() => Card) + cards: Card[]; + } + + const hasManyMeta = Suite.definition.relations.cards as HasManyDefinition; + expect(hasManyMeta).to.eql({ + type: RelationType.hasMany, + target: () => Card, + }); createHasManyRepositoryFactory( - keytolessMeta as HasManyDefinition, - reviewRepo, - ), - ).to.throw(/The foreign key property name \(keyTo\) must be specified/); + hasManyMeta, + createGetter( + new DefaultCrudRepository( + Suite, + new juggler.DataSource({connector: 'memory'}), + ), + ), + ); + expect(hasManyMeta).to.eql({ + type: RelationType.hasMany, + target: () => Card, + keyTo: 'suiteId', + }); + }); }); +}); - //--- HELPERS ---// +describe('belongsTo relation', () => { + it('can find an instance of the related model', async () => { + const findCustomerOfOrder = createBelongsToFactory( + Order.definition.relations.customerId as BelongsToDefinition, + createGetter(customerRepo), + orderRepo, + ); - class Order extends Entity { - id: number; - description: string; - customerId: number; + const customer = await customerRepo.create({name: 'Order McForder'}); + const order = await orderRepo.create({ + customerId: customer.id, + description: 'Order from Order McForder, the hoarder of Mordor', + }); + const result = await findCustomerOfOrder(order.id); + expect(result).to.deepEqual(customer); + }); - static definition = new ModelDefinition({ - name: 'Order', - properties: { - id: {type: 'number', id: true}, - description: {type: 'string', required: true}, - customerId: {type: 'number', required: true}, - }, + context('createBelongsToFactory', () => { + it('errors when keyFrom is not available from belongsTo metadata', () => { + class SomeClass extends Entity {} + const keyFromLessMeta: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => SomeClass, + keyTo: 'someKey', + }; + expect(() => + createBelongsToFactory( + keyFromLessMeta, + createGetter(reviewRepo), + orderRepo, + ), + ).to.throw(/The foreign key property name \(keyFrom\) must be specified/); }); - } - - class Review extends Entity { - id: number; - description: string; - authorId: number; - approvedId: number; - - static definition = new ModelDefinition({ - name: 'Review', - properties: { - id: {type: 'number', id: true}, - description: {type: 'string', required: true}, - authorId: {type: 'number', required: false}, - approvedId: {type: 'number', required: false}, - }, + + it('resolves property id metadata', () => { + @model() + class Card extends Entity { + @property({id: true}) + id: number; + @belongsTo(() => Suite) + suiteId: string; + } + + @model() + class Suite extends Entity { + @property({id: true}) + id: string; + cards: Card[]; + } + + const belongsToMeta = Card.definition.relations + .suiteId as BelongsToDefinition; + expect(belongsToMeta).to.eql({ + type: RelationType.belongsTo, + target: () => Suite, + keyFrom: 'suiteId', + }); + createBelongsToFactory( + belongsToMeta, + createGetter( + new DefaultCrudRepository( + Suite, + new juggler.DataSource({connector: 'memory'}), + ), + ), + new DefaultCrudRepository( + Card, + new juggler.DataSource({connector: 'memory'}), + ), + ); + expect(belongsToMeta).to.eql({ + type: RelationType.belongsTo, + target: () => Suite, + keyFrom: 'suiteId', + keyTo: 'id', + }); }); - } - - class Customer extends Entity { - id: number; - name: string; - orders: Order[]; - reviewsAuthored: Review[]; - reviewsApproved: Review[]; - - static definition = new ModelDefinition({ - name: 'Customer', - properties: { - id: {type: 'number', id: true}, - name: {type: 'string', required: true}, - orders: {type: Order, array: true}, - reviewsAuthored: {type: Review, array: true}, - reviewsApproved: {type: Review, array: true}, + }); +}); + +//--- HELPERS ---// + +class Order extends Entity { + id: number; + description: string; + customerId: number; + + static definition: ModelDefinition = new ModelDefinition({ + name: 'Order', + properties: { + id: {type: 'number', id: true}, + description: {type: 'string', required: true}, + customerId: {type: 'number', required: true}, + }, + relations: { + customerId: { + type: RelationType.belongsTo, + target: () => Customer, + keyFrom: 'customerId', + keyTo: 'id', }, - relations: { - orders: { - type: RelationType.hasMany, - keyTo: 'customerId', - }, - reviewsAuthored: { - type: RelationType.hasMany, - keyTo: 'authorId', - }, - reviewsApproved: { - type: RelationType.hasMany, - keyTo: 'approvedId', - }, + }, + }); +} + +class Review extends Entity { + id: number; + description: string; + authorId: number; + approvedId: number; + + static definition = new ModelDefinition({ + name: 'Review', + properties: { + id: {type: 'number', id: true}, + description: {type: 'string', required: true}, + authorId: {type: 'number', required: false}, + approvedId: {type: 'number', required: false}, + }, + }); +} + +class Customer extends Entity { + id: number; + name: string; + orders: Order[]; + reviewsAuthored: Review[]; + reviewsApproved: Review[]; + + static definition: ModelDefinition = new ModelDefinition({ + name: 'Customer', + properties: { + id: {type: 'number', id: true}, + name: {type: 'string', required: true}, + orders: {type: Order, array: true}, + reviewsAuthored: {type: Review, array: true}, + reviewsApproved: {type: Review, array: true}, + }, + relations: { + orders: { + type: RelationType.hasMany, + target: () => Order, + keyTo: 'customerId', }, - }); - } - - function givenCrudRepositories() { - db = new juggler.DataSource({connector: 'memory'}); - - customerRepo = new DefaultCrudRepository(Customer, db); - orderRepo = new DefaultCrudRepository(Order, db); - reviewRepo = new DefaultCrudRepository(Review, db); - } - - async function givenPersistedCustomerInstance() { - existingCustomerId = (await customerRepo.create({name: 'a customer'})).id; - } - - function givenConstrainedRepositories() { - const orderFactoryFn = createHasManyRepositoryFactory< - Order, - typeof Order.prototype.id, - typeof Customer.prototype.id - >(Customer.definition.relations.orders as HasManyDefinition, orderRepo); - - customerOrderRepo = orderFactoryFn(existingCustomerId); - } - - function givenRepositoryFactoryFunctions() { - customerAuthoredReviewFactoryFn = createHasManyRepositoryFactory( - Customer.definition.relations.reviewsAuthored as HasManyDefinition, - reviewRepo, - ); - customerApprovedReviewFactoryFn = createHasManyRepositoryFactory( - Customer.definition.relations.reviewsApproved as HasManyDefinition, - reviewRepo, - ); - } -}); + reviewsAuthored: { + type: RelationType.hasMany, + target: () => Review, + keyTo: 'authorId', + }, + reviewsApproved: { + type: RelationType.hasMany, + target: () => Review, + keyTo: 'approvedId', + }, + }, + }); +} + +function givenCrudRepositories() { + db = new juggler.DataSource({connector: 'memory'}); + + customerRepo = new DefaultCrudRepository(Customer, db); + orderRepo = new DefaultCrudRepository(Order, db); + reviewRepo = new DefaultCrudRepository(Review, db); +} + +async function givenPersistedCustomerInstance() { + existingCustomerId = (await customerRepo.create({name: 'a customer'})).id; +} + +function givenConstrainedRepositories() { + const orderFactoryFn = createHasManyRepositoryFactory< + Order, + typeof Order.prototype.id, + typeof Customer.prototype.id + >( + Customer.definition.relations.orders as HasManyDefinition, + createGetter(orderRepo), + ); + + customerOrderRepo = orderFactoryFn(existingCustomerId); +} + +function givenRepositoryFactoryFunctions() { + customerAuthoredReviewFactoryFn = createHasManyRepositoryFactory( + Customer.definition.relations.reviewsAuthored as HasManyDefinition, + createGetter(reviewRepo), + ); + customerApprovedReviewFactoryFn = createHasManyRepositoryFactory( + Customer.definition.relations.reviewsApproved as HasManyDefinition, + createGetter(reviewRepo), + ); +} diff --git a/packages/repository/test/test-utils.ts b/packages/repository/test/test-utils.ts new file mode 100644 index 000000000000..496f1bed4bef --- /dev/null +++ b/packages/repository/test/test-utils.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export function createGetter(value: T) { + return () => Promise.resolve(value); +} diff --git a/packages/repository/test/unit/decorator/model-and-relation.decorator.unit.ts b/packages/repository/test/unit/decorator/model-and-relation.decorator.unit.ts index 17f8c10c127d..192c13553bb1 100644 --- a/packages/repository/test/unit/decorator/model-and-relation.decorator.unit.ts +++ b/packages/repository/test/unit/decorator/model-and-relation.decorator.unit.ts @@ -21,6 +21,7 @@ import { RelationType, Entity, ValueObject, + ERR_TARGET_UNDEFINED, } from '../../../'; import {MetadataInspector} from '@loopback/context'; @@ -96,8 +97,8 @@ describe('model decorator', () => { @property() customerId: string; - @belongsTo({target: 'Customer'}) // TypeScript does not allow me to reference Customer here + @belongsTo(() => Customer) customer: ICustomer; // Validates that property no longer requires a parameter @@ -124,7 +125,7 @@ describe('model decorator', () => { @referencesOne() profile: Profile; - @hasMany(Order) + @hasMany(() => Order) orders?: Order[]; @hasOne() @@ -259,7 +260,7 @@ describe('model decorator', () => { ) || /* istanbul ignore next */ {}; expect(meta.orders).to.eql({ type: RelationType.hasMany, - keyTo: 'customerId', + target: () => Order, }); }); @@ -271,7 +272,8 @@ describe('model decorator', () => { ) || /* istanbul ignore next */ {}; expect(meta.customer).to.eql({ type: RelationType.belongsTo, - target: 'Customer', + target: () => Customer, + keyFrom: 'customer', }); }); @@ -304,7 +306,7 @@ describe('model decorator', () => { class House extends Entity { @property() name: string; - @hasMany(Person, {keyTo: 'fk'}) + @hasMany(() => Person, {keyTo: 'fk'}) person: Person[]; } @@ -319,7 +321,7 @@ describe('model decorator', () => { describe('property namespace', () => { describe('array', () => { - it('"@property.array" adds array metadata', () => { + it('adds array metadata', () => { @model() class TestModel { @property.array(Product) @@ -334,18 +336,40 @@ describe('model decorator', () => { expect(meta.items).to.eql({type: Array, itemType: Product}); }); - it('throws when @property.array is used on a non-array property', () => { - expect.throws( - () => { - // tslint:disable-next-line:no-unused-variable - class Oops { - @property.array(Product) - product: Product; - } - }, - Error, - property.ERR_PROP_NOT_ARRAY, + it('adds model resolver metadata', () => { + class CyclicX { + @property.array(() => CyclicY) + cyclicProp: CyclicY[]; + } + class CyclicY { + @property.array(() => CyclicX) + cyclicProp: CyclicX[]; + } + const cyclicXMeta = MetadataInspector.getAllPropertyMetadata( + MODEL_PROPERTIES_KEY, + CyclicX.prototype, + ); + const cyclicYMeta = MetadataInspector.getAllPropertyMetadata( + MODEL_PROPERTIES_KEY, + CyclicY.prototype, ); + + expect(cyclicXMeta) + .to.have.property('cyclicProp') + .which.eql({type: Array, itemType: () => CyclicY}); + expect(cyclicYMeta) + .to.have.property('cyclicProp') + .which.eql({type: Array, itemType: () => CyclicX}); + }); + + it('throws when used on a non-array property', () => { + expect(() => { + // tslint:disable-next-line:no-unused-variable + class Oops { + @property.array(Product) + product: Product; + } + }).to.throw(property.ERR_PROP_NOT_ARRAY); }); }); }); diff --git a/packages/repository/test/unit/decorator/relation.decorator.unit.ts b/packages/repository/test/unit/decorator/relation.decorator.unit.ts index d186edf0d314..fc695af3476a 100644 --- a/packages/repository/test/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/test/unit/decorator/relation.decorator.unit.ts @@ -4,69 +4,45 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {Entity, hasMany, RELATIONS_KEY, RelationType, property} from '../../..'; +import { + Entity, + hasMany, + RELATIONS_KEY, + RelationType, + property, + MODEL_PROPERTIES_KEY, + model, + belongsTo, +} from '../../..'; import {MetadataInspector} from '@loopback/context'; -import {MODEL_PROPERTIES_KEY, model} from '../../../src'; describe('relation decorator', () => { context('hasMany', () => { - it('throws when foreign key is not defined in the target TypeScript model', () => { - expect(() => { - @model() - class Address extends Entity { - addressId: number; - street: string; - province: string; - } - - // tslint:disable-next-line:no-unused-variable - class AddressBook extends Entity { - id: number; - - @hasMany(Address) - addresses: Address[]; - } - }).throw(/addressBookId not found on Address/); - }); - - it('takes in complex property type and infers foreign key via source model name', () => { - @model() + it('assigns it to target key', () => { class Address extends Entity { addressId: number; street: string; province: string; - @property() - addressBookId: number; } class AddressBook extends Entity { id: number; - @hasMany(Address) + @hasMany(() => Address) addresses: Address[]; } - const meta = MetadataInspector.getPropertyMetadata( RELATIONS_KEY, AddressBook.prototype, 'addresses', ); - const jugglerMeta = MetadataInspector.getPropertyMetadata( - MODEL_PROPERTIES_KEY, - AddressBook.prototype, - 'addresses', - ); expect(meta).to.eql({ type: RelationType.hasMany, - keyTo: 'addressBookId', - }); - expect(jugglerMeta).to.eql({ - type: Array, - itemType: Address, + target: () => Address, }); }); - it('takes in both complex property type and hasMany metadata', () => { + it('accepts explicit keyTo property', () => { class Address extends Entity { addressId: number; street: string; @@ -76,32 +52,22 @@ describe('relation decorator', () => { class AddressBook extends Entity { id: number; - @hasMany(Address, {keyTo: 'someForeignKey'}) + @hasMany(() => Address, {keyTo: 'someForeignKey'}) addresses: Address[]; } - const meta = MetadataInspector.getPropertyMetadata( RELATIONS_KEY, AddressBook.prototype, 'addresses', ); - const jugglerMeta = MetadataInspector.getPropertyMetadata( - MODEL_PROPERTIES_KEY, - AddressBook.prototype, - 'addresses', - ); expect(meta).to.eql({ type: RelationType.hasMany, + target: () => Address, keyTo: 'someForeignKey', }); - expect(jugglerMeta).to.eql({ - type: Array, - itemType: Address, - }); }); context('when interacting with @property.array', () => { - // Do you think this test case is necessary? it('does not get its property metadata overwritten by @property.array', () => { expect(() => { class Address extends Entity { @@ -114,7 +80,7 @@ describe('relation decorator', () => { class AddressBook extends Entity { id: number; @property.array(Entity) - @hasMany(Address, { + @hasMany(() => Address, { keyTo: 'someForeignKey', }) addresses: Address[]; @@ -123,4 +89,98 @@ describe('relation decorator', () => { }); }); }); + + context('belongsTo', () => { + it('creates juggler property metadata', () => { + @model() + class AddressBook extends Entity { + @property({id: true}) + id: number; + } + class Address extends Entity { + @belongsTo(() => AddressBook) + addressBookId: number; + } + const jugglerMeta = MetadataInspector.getAllPropertyMetadata( + MODEL_PROPERTIES_KEY, + Address.prototype, + ); + expect(jugglerMeta).to.eql({ + addressBookId: {type: Number}, + }); + }); + + it('infers foreign key', () => { + @model() + class AddressBook extends Entity { + @property({id: true}) + id: number; + } + class Address extends Entity { + @belongsTo(() => AddressBook) + addressBookId: number; + @property() + someOtherKey: string; + } + const meta = MetadataInspector.getAllPropertyMetadata( + RELATIONS_KEY, + Address.prototype, + ); + expect(meta) + .to.have.property('addressBookId') + .which.containEql({keyFrom: 'addressBookId'}); + }); + + it('assigns it to target key', () => { + class Address extends Entity { + addressId: number; + street: string; + province: string; + @belongsTo(() => AddressBook) + addressBookId: number; + } + + class AddressBook extends Entity { + id: number; + addresses: Address[]; + } + + const meta = MetadataInspector.getPropertyMetadata( + RELATIONS_KEY, + Address.prototype, + 'addressBookId', + ); + expect(meta).to.eql({ + type: RelationType.belongsTo, + target: () => AddressBook, + keyFrom: 'addressBookId', + }); + }); + + it('accepts explicit keyTo property', () => { + class Address extends Entity { + addressId: number; + street: string; + province: string; + @belongsTo(() => AddressBook, {keyTo: 'somePrimaryKey'}) + addressBookId: number; + } + + class AddressBook extends Entity { + id: number; + addresses: Address[]; + } + const meta = MetadataInspector.getPropertyMetadata( + RELATIONS_KEY, + Address.prototype, + 'addressBookId', + ); + expect(meta).to.eql({ + type: RelationType.belongsTo, + target: () => AddressBook, + keyFrom: 'addressBookId', + keyTo: 'somePrimaryKey', + }); + }); + }); }); diff --git a/packages/repository/test/unit/model/model.unit.ts b/packages/repository/test/unit/model/model.unit.ts index fd952dc47ddc..9c3d1d8b436b 100644 --- a/packages/repository/test/unit/model/model.unit.ts +++ b/packages/repository/test/unit/model/model.unit.ts @@ -4,8 +4,13 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {STRING} from '../../../'; -import {Entity, ModelDefinition} from '../../../'; +import { + STRING, + Entity, + ModelDefinition, + isTypeResolver, + resolveType, +} from '../../../'; describe('model', () => { const customerDef = new ModelDefinition('Customer'); @@ -186,4 +191,27 @@ describe('model', () => { const instance = new NoId(); expect(() => instance.getId()).to.throw(/missing.*id/); }); + + context('TypeResolver', () => { + class SomeModel {} + + context('isTypeResolver', () => { + it('returns true if given arg is a resolver', () => { + expect(isTypeResolver(() => SomeModel)).to.be.true(); + }); + + it('returns false if given arg is not a resolver', () => { + expect(isTypeResolver(SomeModel)).to.be.false(); + }); + }); + + context('resolveType', () => { + it('resolves given TypeResolver', () => { + expect(resolveType(() => SomeModel)).to.eql(SomeModel); + }); + it('returns given arg if not a TypeResolver', () => { + expect(resolveType(SomeModel)).to.eql(SomeModel); + }); + }); + }); }); diff --git a/packages/repository/test/unit/repositories/constraint-utils.unit.ts b/packages/repository/test/unit/repositories/constraint-utils.unit.ts index 1a970c2aa50c..51b6e9629625 100644 --- a/packages/repository/test/unit/repositories/constraint-utils.unit.ts +++ b/packages/repository/test/unit/repositories/constraint-utils.unit.ts @@ -31,6 +31,15 @@ describe('constraint utility functions', () => { where: Object.assign({}, inputFilter.where, constraint), }); }); + + it('applies a where constraint without a filter to start from', () => { + const constraint = {id: 'a wonderful id'}; + const result = constrainFilter(undefined, constraint); + expect(result).to.containEql({ + where: constraint, + }); + }); + it('applies a filter constraint with where object', () => { const constraint: Filter = {where: {id: '10'}}; const result = constrainFilter(inputFilter, constraint); diff --git a/packages/repository/test/unit/repositories/relation.repository.unit.ts b/packages/repository/test/unit/repositories/relation.repository.unit.ts index 5dec448cd672..d57504f6a189 100644 --- a/packages/repository/test/unit/repositories/relation.repository.unit.ts +++ b/packages/repository/test/unit/repositories/relation.repository.unit.ts @@ -16,7 +16,14 @@ import { Options, DataObject, Where, + resolveHasManyMetadata, + HasManyDefinition, + RelationType, + ModelDefinition, + BelongsToDefinition, + resolveBelongsToMetadata, } from '../../..'; +import {createGetter} from '../../test-utils'; describe('relation repository', () => { context('HasManyRepository interface', () => { @@ -108,6 +115,155 @@ describe('relation repository', () => { }); }); + context('resolveHasManyMetadata', () => { + it('throws if no target relation metadata is found', () => { + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + }); + } + const meta: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + }; + expect(() => resolveHasManyMetadata(meta)).to.throw( + /no belongsTo metadata found/, + ); + }); + + it('throws if no belongsTo metadata is found', () => { + class SourceModel extends Entity {} + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + relations: { + foreignId: {type: RelationType.hasMany, target: () => SourceModel}, + }, + }); + } + const meta: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + }; + expect(() => resolveHasManyMetadata(meta)).to.throw( + /no belongsTo metadata found/, + ); + }); + + it('retains predefined keyTo property', () => { + class TargetModel extends Entity {} + const meta: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + keyTo: 'someOtherForeignId', + }; + const result = resolveHasManyMetadata(meta); + const expected: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + keyTo: 'someOtherForeignId', + }; + expect(result).to.eql(expected); + }); + + it('infers keyTo from property decorated with @belongsTo on target model', () => { + class SourceModel extends Entity {} + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + relations: { + foreignId: { + type: RelationType.belongsTo, + target: () => SourceModel, + }, + }, + }); + } + const meta: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + }; + const result = resolveHasManyMetadata(meta); + const expected: HasManyDefinition = { + type: RelationType.hasMany, + target: () => TargetModel, + keyTo: 'foreignId', + }; + expect(result).to.eql(expected); + }); + }); + + context('resolveBelongsToMetadata', () => { + it('throws if no target definition metadata is found', () => { + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + }); + } + const meta: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + }; + expect(() => resolveBelongsToMetadata(meta)).to.throw( + /no id metadata found/, + ); + }); + + it('throws if no belongsTo metadata is found', () => { + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + properties: { + propertyThatIsNotId: {type: 'number'}, + }, + }); + } + const meta: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + }; + expect(() => resolveBelongsToMetadata(meta)).to.throw( + /no id metadata found/, + ); + }); + + it('retains predefined keyTo property', () => { + class TargetModel extends Entity {} + const meta: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + keyTo: 'someOtherForeignId', + }; + const result = resolveBelongsToMetadata(meta); + const expected: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + keyTo: 'someOtherForeignId', + }; + expect(result).to.eql(expected); + }); + + it('infers keyTo from property decorated with @property({id: true}) on target model', () => { + class TargetModel extends Entity { + static definition = new ModelDefinition({ + name: 'TargetModel', + properties: {anId: {type: 'number', id: true}}, + }); + } + const meta: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + }; + const result = resolveBelongsToMetadata(meta); + const expected: BelongsToDefinition = { + type: RelationType.belongsTo, + target: () => TargetModel, + keyTo: 'anId', + }; + expect(result).to.eql(expected); + }); + }); + /*------------- HELPERS ---------------*/ class Customer extends Entity { @@ -130,10 +286,11 @@ describe('relation repository', () => { function givenDefaultHasManyCrudInstance(constraint: DataObject) { repo = sinon.createStubInstance(CustomerRepository); + const repoGetter = createGetter(repo); return new DefaultHasManyEntityCrudRepository< Customer, typeof Customer.prototype.id, CustomerRepository - >(repo, constraint); + >(repoGetter, constraint); } });