diff --git a/docs/site/HasMany-relation.md b/docs/site/HasMany-relation.md index 0b6670db65af..c1d7bb6e19c4 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) { @@ -66,13 +66,13 @@ export class Customer extends Entity { ``` The definition of the `hasMany` relation is inferred by using the `@hasMany` -decorator. The decorator takes in the target model class constructor and -optionally a custom foreign key to store the relation metadata. The decorator -logic also designates the relation type and tries to infer the foreign key on -the target model (`keyTo` in the relation metadata) to a default value (source -model name appended with `id` in camel case, same as LoopBack 3). It also calls -`property.array()` to ensure that the type of the property is inferred properly -as an array of the target model instances. +decorator. The decorator takes in a function resolving the target model class +constructor and optionally a custom foreign key to store the relation metadata. +The decorator logic also designates the relation type and tries to infer the +foreign key on the target model (`keyTo` in the relation metadata) to a default +value (source model name appended with `id` in camel case, same as LoopBack 3). +It also calls `property.array()` to ensure that the type of the property is +inferred properly as an array of the target model instances. The decorated property name is used as the relation name and stored as part of the source model definition's relation metadata. The property type metadata is @@ -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[]; } ``` @@ -97,8 +97,12 @@ repository level. Once `hasMany` relation is defined on the source model, then there are a couple of steps involved to configure it and use it. On the source repository, the following are required: -- Use [Dependency Injection](Dependency-injection.md) to inject an instance of - the target repository in the constructor of your source repository class. +- In the constructor of your source repository class, use + [Dependency Injection](Dependency-injection.md) to receive a getter function + for obtaining an instance of the target repository. _Note: We need a getter + function instead of a repository instance in order to break a cyclic + dependency between a repository with a hasMany relation and a repository with + the matching belongsTo relation._ - Declare a property with the factory function type `HasManyRepositoryFactory` on the source repository class. @@ -121,7 +125,7 @@ import { HasManyRepositoryFactory, repository, } from '@loopback/repository'; -import {inject} from '@loopback/core'; +import {inject, Getter} from '@loopback/core'; class CustomerRepository extends DefaultCrudRepository< Customer, @@ -130,12 +134,13 @@ class CustomerRepository extends DefaultCrudRepository< public orders: HasManyRepositoryFactory; constructor( @inject('datasources.db') protected db: juggler.DataSource, - @repository(OrderRepository) orderRepository: OrderRepository, + @repository.getter(OrderRepository) + getOrderRepository: Getter, ) { super(Customer, db); this.orders = this._createHasManyRepositoryFactoryFor( 'orders', - orderRepository, + getOrderRepository, ); } } diff --git a/docs/site/todo-list-tutorial-model.md b/docs/site/todo-list-tutorial-model.md index c8d5e60dfbff..806eb662baed 100644 --- a/docs/site/todo-list-tutorial-model.md +++ b/docs/site/todo-list-tutorial-model.md @@ -77,7 +77,7 @@ model. Add the following property to the `TodoList` model: export class TodoList extends Entity { // ...properties defined by the CLI... - @hasMany(Todo) + @hasMany(() => Todo) todos?: Todo[]; // ...constructor def... diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index 2fe4a29c2a1c..1db5263a4993 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) 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 916d2835c64d..a4d1b8d62453 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -3,14 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Getter, inject} from '@loopback/core'; import { DefaultCrudRepository, - juggler, HasManyRepositoryFactory, + juggler, repository, } from '@loopback/repository'; -import {TodoList, Todo} from '../models'; -import {inject} from '@loopback/core'; +import {Todo, TodoList} from '../models'; import {TodoRepository} from './todo.repository'; export class TodoListRepository extends DefaultCrudRepository< @@ -21,12 +21,13 @@ export class TodoListRepository extends DefaultCrudRepository< constructor( @inject('datasources.db') protected datasource: juggler.DataSource, - @repository(TodoRepository) protected todoRepository: TodoRepository, + @repository.getter(TodoRepository) + protected todoRepositoryGetter: Getter, ) { super(TodoList, datasource); this.todos = this._createHasManyRepositoryFactoryFor( 'todos', - todoRepository, + todoRepositoryGetter, ); } diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index ed7ecc02374d..b9f2173b137b 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -179,6 +179,16 @@ export function inject( */ export type Getter = () => Promise; +export namespace Getter { + /** + * Convert a value into a Getter returning that value. + * @param value + */ + export function fromValue(value: T): Getter { + return () => Promise.resolve(value); + } +} + /** * The function injected by `@inject.setter(key)`. */ diff --git a/packages/context/test/acceptance/class-level-bindings.acceptance.ts b/packages/context/test/acceptance/class-level-bindings.acceptance.ts index a471f8dbac2d..3a195ec3b77c 100644 --- a/packages/context/test/acceptance/class-level-bindings.acceptance.ts +++ b/packages/context/test/acceptance/class-level-bindings.acceptance.ts @@ -175,6 +175,12 @@ describe('Context bindings - Injecting dependencies of classes', () => { expect(ctx.getSync('key')).to.equal('a-value'); }); + it('creates getter from a value', () => { + const getter = Getter.fromValue('data'); + expect(getter).to.be.a.Function(); + return expect(getter()).to.be.fulfilledWith('data'); + }); + it('injects a nested property', async () => { class TestComponent { constructor(@inject('config#test') public config: string) {} 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 76dfbd63f67d..9f9f14cd343e 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,7 @@ // 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, Entity, hasMany} from '@loopback/repository'; import { modelToJsonSchema, JSON_SCHEMA_KEY, @@ -420,6 +420,49 @@ describe('build-schema', () => { expectValidJsonSchema(jsonSchema); }); + it('properly converts models with hasMany properties', () => { + @model() + class Order extends Entity { + @property({id: true}) + id: number; + + @property() + customerId: number; + } + + @model() + class Customer extends Entity { + @property({id: true}) + id: number; + + @hasMany(() => Order) + orders: Order[]; + } + + const customerSchema = modelToJsonSchema(Customer); + + expectValidJsonSchema(customerSchema); + + 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'}, + }, + }, + }); + }); + it('creates definitions only at the root level of the schema', () => { @model() class CustomTypeFoo { diff --git a/packages/repository-json-schema/test/unit/filter-json-schema.unit.ts b/packages/repository-json-schema/test/unit/filter-json-schema.unit.ts index e48df3c0a7f3..a23aff40c5f5 100644 --- a/packages/repository-json-schema/test/unit/filter-json-schema.unit.ts +++ b/packages/repository-json-schema/test/unit/filter-json-schema.unit.ts @@ -181,6 +181,6 @@ class Customer extends Entity { @property() name: string; - @hasMany(Order) + @hasMany(() => Order) orders?: Order[]; } diff --git a/packages/repository/package.json b/packages/repository/package.json index 1a6aa3ada601..6226ec4ae174 100644 --- a/packages/repository/package.json +++ b/packages/repository/package.json @@ -31,6 +31,8 @@ "@loopback/context": "^0.12.13", "@loopback/core": "^0.11.14", "@loopback/dist-util": "^0.3.7", + "@types/debug": "0.0.30", + "debug": "^4.0.1", "lodash": "^4.17.10", "loopback-datasource-juggler": "^3.23.0" }, diff --git a/packages/repository/src/decorators/model.decorator.ts b/packages/repository/src/decorators/model.decorator.ts index aef61cdc1b61..4c8f08bc66fb 100644 --- a/packages/repository/src/decorators/model.decorator.ts +++ b/packages/repository/src/decorators/model.decorator.ts @@ -4,16 +4,17 @@ // License text available at https://opensource.org/licenses/MIT import { - MetadataInspector, ClassDecoratorFactory, - PropertyDecoratorFactory, - MetadataMap, MetadataAccessor, + MetadataInspector, + MetadataMap, + PropertyDecoratorFactory, } from '@loopback/context'; import { ModelDefinition, ModelDefinitionSyntax, PropertyDefinition, + PropertyType, RelationDefinitionMap, } from '../model'; import {RELATIONS_KEY} from './relation.decorator'; @@ -104,12 +105,13 @@ export namespace property { /** * - * @param itemType The class of the array to decorate + * @param itemType The type of array items. + * Examples: `number`, `Product`, `() => Order`. * @param definition Optional PropertyDefinition object for additional * metadata */ export function array( - itemType: Function, + itemType: PropertyType, 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 0bdf74f1374b..03595c00f554 100644 --- a/packages/repository/src/decorators/relation.decorator.ts +++ b/packages/repository/src/decorators/relation.decorator.ts @@ -3,12 +3,10 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, Model, RelationDefinitionMap} from '../model'; import {PropertyDecoratorFactory} from '@loopback/context'; +import {Entity, EntityResolver, Model, RelationDefinitionMap} from '../model'; +import {TypeResolver} from '../type-resolver'; import {property} from './model.decorator'; -import {camelCase} from 'lodash'; - -// tslint:disable:no-any export enum RelationType { belongsTo = 'belongsTo', @@ -22,21 +20,21 @@ export enum RelationType { export const RELATIONS_KEY = 'loopback:relations'; -export class RelationMetadata { - type: RelationType; - target: string | typeof Entity; - as: string; -} - export interface RelationDefinitionBase { type: RelationType; + name: string; + source: typeof Entity; + target: TypeResolver; } export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; - keyTo: string; + keyTo?: string; } +// TODO(bajtos) add other relation types, e.g. BelongsToDefinition +export type RelationMetadata = HasManyDefinition | RelationDefinitionBase; + /** * Decorator for relations * @param definition @@ -72,41 +70,32 @@ export function hasOne(definition?: Object) { * Decorator for hasMany * Calls property.array decorator underneath the hood and infers foreign key * name from target model name unless explicitly specified - * @param targetModel Target model for hasMany relation + * @param targetResolver Target model for hasMany relation * @param definition Optional metadata for setting up hasMany relation * @returns {(target:any, key:string)} */ -export function hasMany( - targetModel: T, +export function hasMany( + targetResolver: EntityResolver, definition?: Partial, ) { // todo(shimks): extract out common logic (such as @property.array) to // @relation - 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}; - Object.assign(meta, definition, {type: RelationType.hasMany}); - - PropertyDecoratorFactory.createDecorator( - RELATIONS_KEY, - meta as HasManyDefinition, - )(target, key); + return function(decoratedTarget: Object, key: string) { + property.array(targetResolver)(decoratedTarget, key); + + const meta: HasManyDefinition = Object.assign( + {}, + // properties customizable by users + definition, + // properties enforced by the decorator + { + type: RelationType.hasMany, + name: key, + source: decoratedTarget.constructor, + target: targetResolver, + }, + ); + relation(meta)(decoratedTarget, key); }; } diff --git a/packages/repository/src/decorators/repository.decorator.ts b/packages/repository/src/decorators/repository.decorator.ts index e2496a66214f..1249e5e2e7ad 100644 --- a/packages/repository/src/decorators/repository.decorator.ts +++ b/packages/repository/src/decorators/repository.decorator.ts @@ -3,13 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Context, inject, Injection} from '@loopback/context'; import * as assert from 'assert'; -import {Model, Entity} from '../model'; -import {Repository, DefaultCrudRepository} from '../repositories'; +import {Class} from '../common-types'; import {DataSource} from '../datasource'; +import {Entity, Model} from '../model'; +import {DefaultCrudRepository, Repository} from '../repositories'; import {juggler} from '../repositories/legacy-juggler-bridge'; -import {inject, Context, Injection} from '@loopback/context'; -import {Class} from '../common-types'; /** * Type definition for decorators returned by `@repository` decorator factory @@ -172,6 +172,20 @@ 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 nameOrClass The repository class (ProductRepository) or a string name ('ProductRepository'). + */ + export function getter(nameOrClass: string | Class>) { + const name = + typeof nameOrClass === 'string' ? nameOrClass : nameOrClass.name; + return inject.getter(`repositories.${name}`); + } +} + /** * Resolve the @repository injection * @param ctx Context diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 6d6cc240d5fb..9267c471a583 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {AnyObject, DataObject, Options} from './common-types'; -import {RelationDefinitionBase} from './decorators/relation.decorator'; +import {RelationMetadata} from './decorators/relation.decorator'; import {TypeResolver} from './type-resolver'; import {Type} from './types'; @@ -49,7 +49,7 @@ export interface PropertyForm { * A relation name is used as the key, a relation definition is the value. */ export type RelationDefinitionMap = { - [relationName: string]: RelationDefinitionBase; + [relationName: string]: RelationMetadata; }; /** @@ -119,6 +119,15 @@ export class ModelDefinition { return this; } + /** + * Define a new relation. + * @param definition The definition of the new relation. + */ + addRelation(definition: RelationMetadata): this { + this.relations[definition.name] = definition; + return this; + } + /** * Get an array of names of ID properties, which are specified in * the model settings or properties with `id` attribute. For example, @@ -314,3 +323,5 @@ export class Event { } export type EntityData = DataObject; + +export type EntityResolver = TypeResolver; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 546554b6ec60..0389b510362f 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -3,17 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {isPromiseLike} from '@loopback/context'; +import {Getter, isPromiseLike} from '@loopback/context'; import * as assert from 'assert'; import * as legacy from 'loopback-datasource-juggler'; import { AnyObject, Command, + Count, DataObject, NamedParameters, Options, PositionalParameters, - Count, } from '../common-types'; import {HasManyDefinition} from '../decorators/relation.decorator'; import {EntityNotFoundError} from '../errors'; @@ -170,12 +170,12 @@ export class DefaultCrudRepository ForeignKeyType >( relationName: string, - targetRepo: EntityCrudRepository, + targetRepoGetter: Getter>, ): HasManyRepositoryFactory { const meta = this.entityClass.definition.relations[relationName]; return createHasManyRepositoryFactory( meta as HasManyDefinition, - targetRepo, + targetRepoGetter, ); } diff --git a/packages/repository/src/repositories/relation.factory.ts b/packages/repository/src/repositories/relation.factory.ts index 1b89d8d4b227..d270c2a2dec7 100644 --- a/packages/repository/src/repositories/relation.factory.ts +++ b/packages/repository/src/repositories/relation.factory.ts @@ -3,14 +3,19 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {EntityCrudRepository} from './repository'; +import {Getter} from '@loopback/context'; +import * as debugFactory from 'debug'; +import {camelCase} from 'lodash'; +import {DataObject, isTypeResolver} from '..'; import {HasManyDefinition} from '../decorators/relation.decorator'; import {Entity} from '../model'; import { - HasManyRepository, DefaultHasManyEntityCrudRepository, + HasManyRepository, } from './relation.repository'; -import {DataObject} from '..'; +import {EntityCrudRepository} from './repository'; + +const debug = debugFactory('loopback:repository:relation-factory'); export type HasManyRepositoryFactory = ( fkValue: ForeignKeyType, @@ -35,21 +40,73 @@ export function createHasManyRepositoryFactory< ForeignKeyType >( relationMetadata: HasManyDefinition, - targetRepository: EntityCrudRepository, + targetRepositoryGetter: Getter>, ): HasManyRepositoryFactory { + const meta = resolveHasManyMetadata(relationMetadata); + debug('Resolved relation metadata: %o', meta); 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}; + const constraint: any = {[meta.keyTo]: fkValue}; return new DefaultHasManyEntityCrudRepository< Target, TargetID, EntityCrudRepository - >(targetRepository, constraint as DataObject); + >(targetRepositoryGetter, constraint as DataObject); }; } + +type HasManyResolvedDefinition = HasManyDefinition & {keyTo: string}; + +/** + * 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 + */ +function resolveHasManyMetadata( + relationMeta: HasManyDefinition, +): HasManyResolvedDefinition { + if (!isTypeResolver(relationMeta.target)) { + const reason = 'target must be a type resolver'; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + if (relationMeta.keyTo) { + // The explict cast is needed because of a limitation of type inference + return relationMeta as HasManyResolvedDefinition; + } + + const sourceModel = relationMeta.source; + if (!sourceModel || !sourceModel.modelName) { + const reason = 'source model must be defined'; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + const targetModel = relationMeta.target(); + debug( + 'Resolved model %s from given metadata: %o', + targetModel.modelName, + targetModel, + ); + const defaultFkName = camelCase(sourceModel.modelName + '_id'); + const hasDefaultFkProperty = + targetModel.definition && + targetModel.definition.properties && + targetModel.definition.properties[defaultFkName]; + + if (!hasDefaultFkProperty) { + const reason = `target model ${ + targetModel.name + } is missing definition of foreign key ${defaultFkName}`; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + return Object.assign(relationMeta, {keyTo: defaultFkName}); +} + +function invalidDefinition(relationMeta: HasManyDefinition, reason: string) { + const source = relationMeta.source; + const model = (source && source.modelName) || ''; + const name = relationMeta.name; + return `Invalid hasMany definition for ${model}#${name}: ${reason}`; +} diff --git a/packages/repository/src/repositories/relation.repository.ts b/packages/repository/src/repositories/relation.repository.ts index 5ecb69f2aebd..08f88cca2cc0 100644 --- a/packages/repository/src/repositories/relation.repository.ts +++ b/packages/repository/src/repositories/relation.repository.ts @@ -3,15 +3,16 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {EntityCrudRepository} from './repository'; +import {Count, DataObject, Options} from '../common-types'; +import {Entity} from '../model'; +import {Filter, Where} from '../query'; import { constrainDataObject, constrainFilter, constrainWhere, } from './constraint-utils'; -import {DataObject, Options, Count} from '../common-types'; -import {Entity} from '../model'; -import {Filter, Where} from '../query'; +import {EntityCrudRepository} from './repository'; +import {Getter} from '@loopback/context'; /** * CRUD operations for a target repository of a HasMany relation @@ -62,12 +63,12 @@ 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, ) {} @@ -75,21 +76,24 @@ export class DefaultHasManyEntityCrudRepository< targetModelData: DataObject, options?: Options, ): Promise { - return await this.targetRepository.create( + const targetRepository = await this.getTargetRepository(); + return targetRepository.create( constrainDataObject(targetModelData, this.constraint), options, ); } async find(filter?: Filter, options?: Options): Promise { - return await this.targetRepository.find( + const targetRepository = await this.getTargetRepository(); + return targetRepository.find( constrainFilter(filter, this.constraint), options, ); } async delete(where?: Where, options?: Options): Promise { - return await this.targetRepository.deleteAll( + const targetRepository = await this.getTargetRepository(); + return targetRepository.deleteAll( constrainWhere(where, this.constraint), options, ); @@ -100,7 +104,8 @@ export class DefaultHasManyEntityCrudRepository< where?: Where, options?: Options, ): Promise { - return this.targetRepository.updateAll( + const targetRepository = await this.getTargetRepository(); + return targetRepository.updateAll( constrainDataObject(dataObject, this.constraint), constrainWhere(where, this.constraint), options, diff --git a/packages/repository/src/type-resolver.ts b/packages/repository/src/type-resolver.ts index 0d27c61b7f0e..3b8f9f9be952 100644 --- a/packages/repository/src/type-resolver.ts +++ b/packages/repository/src/type-resolver.ts @@ -6,11 +6,30 @@ import {Class} from './common-types'; /** - * A 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 (require() loops). + * A type resolver is a function that returns a class representing the type, + * typically a Model or Entity (e.g. Product). + * + * We use type resolvers to break require() loops when defining relations. + * The target model (class) is provided via a provider, thus deferring + * the actual reference to the class itself until later, when both sides + * of the relation are created as JavaScript classes. + * + * The template has two generic parameters: + * + * - `Type` (required) represents the type we are resolving, + * for example `Entity` or `Product`. + * + * - `StaticMembers` (optional) describe static properties available on the + * type class. For example, all models have static `modelName` property. + * When `StaticMembers` are not provided, we default to static properties of + * a `Function` - `name`, `length`, `apply`, `call`, etc. + * Please note the value returned by the resolver is described as having + * arbitrary additional static properties (see how Class is defined). */ -export type TypeResolver = () => Class; +export type TypeResolver< + Type extends Object, + StaticMembers = Function +> = () => Class & StaticMembers; /** * A function that checks whether a function is a TypeResolver or not. 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..6060cd578210 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 @@ -3,17 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Getter} from '@loopback/context'; +import {expect} from '@loopback/testlab'; import { - model, - property, - Entity, DefaultCrudRepository, - juggler, + Entity, + EntityCrudRepository, hasMany, HasManyRepositoryFactory, - EntityCrudRepository, + juggler, + model, + property, } from '../..'; -import {expect} from '@loopback/testlab'; describe('HasMany relation', () => { // Given a Customer and Order models - see definitions at the bottom @@ -116,7 +117,7 @@ describe('HasMany relation', () => { }) name: string; - @hasMany(Order) + @hasMany(() => Order) orders: Order[]; } @@ -140,12 +141,14 @@ describe('HasMany relation', () => { constructor( protected db: juggler.DataSource, - orderRepository: EntityCrudRepository, + orderRepositoryGetter: Getter< + EntityCrudRepository + >, ) { super(Customer, db); this.orders = this._createHasManyRepositoryFactoryFor( 'orders', - orderRepository, + orderRepositoryGetter, ); } } @@ -159,7 +162,7 @@ describe('HasMany relation', () => { } function givenCustomerRepository() { - customerRepo = new CustomerRepository(ds, orderRepo); + customerRepo = new CustomerRepository(ds, Getter.fromValue(orderRepo)); } async function givenPersistedCustomerInstance() { diff --git a/packages/repository/test/acceptance/has-many.relation.acceptance.ts b/packages/repository/test/acceptance/has-many.relation.acceptance.ts index abfd64b04981..672ab5cae1c8 100644 --- a/packages/repository/test/acceptance/has-many.relation.acceptance.ts +++ b/packages/repository/test/acceptance/has-many.relation.acceptance.ts @@ -17,7 +17,7 @@ import { } from '../..'; import {expect} from '@loopback/testlab'; import * as _ from 'lodash'; -import {inject} from '@loopback/context'; +import {inject, Getter} from '@loopback/context'; import {Application} from '@loopback/core'; describe('HasMany relation', () => { @@ -188,7 +188,7 @@ describe('HasMany relation', () => { }) name: string; - @hasMany(Order) + @hasMany(() => Order) orders: Order[]; } @@ -211,12 +211,13 @@ describe('HasMany relation', () => { >; constructor( @inject('datasources.db') protected db: juggler.DataSource, - @repository(OrderRepository) orderRepository: OrderRepository, + @repository.getter(OrderRepository) + orderRepositoryGetter: Getter, ) { super(Customer, db); this.orders = this._createHasManyRepositoryFactoryFor( 'orders', - orderRepository, + orderRepositoryGetter, ); } } diff --git a/packages/repository/test/integration/repositories/relation.factory.integration.ts b/packages/repository/test/integration/repositories/relation.factory.integration.ts index b144d88c9dab..c25857ad3577 100644 --- a/packages/repository/test/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/test/integration/repositories/relation.factory.integration.ts @@ -16,6 +16,7 @@ import { HasManyRepositoryFactory, } from '../../..'; import {expect} from '@loopback/testlab'; +import {Getter} from '@loopback/context'; describe('HasMany relation', () => { // Given a Customer and Order models - see definitions at the bottom @@ -116,18 +117,6 @@ describe('HasMany relation', () => { ); }); - it('errors when keyTo is not available hasMany metadata', () => { - const keytolessMeta = { - type: RelationType.hasMany, - }; - expect( - createHasManyRepositoryFactory( - keytolessMeta as HasManyDefinition, - reviewRepo, - ), - ).to.throw(/The foreign key property name \(keyTo\) must be specified/); - }); - //--- HELPERS ---// class Order extends Entity { @@ -169,7 +158,7 @@ describe('HasMany relation', () => { reviewsAuthored: Review[]; reviewsApproved: Review[]; - static definition = new ModelDefinition({ + static definition: ModelDefinition = new ModelDefinition({ name: 'Customer', properties: { id: {type: 'number', id: true}, @@ -178,21 +167,28 @@ describe('HasMany relation', () => { reviewsAuthored: {type: Review, array: true}, reviewsApproved: {type: Review, array: true}, }, - relations: { - orders: { - type: RelationType.hasMany, - keyTo: 'customerId', - }, - reviewsAuthored: { - type: RelationType.hasMany, - keyTo: 'authorId', - }, - reviewsApproved: { - type: RelationType.hasMany, - keyTo: 'approvedId', - }, - }, - }); + }) + .addRelation({ + name: 'orders', + type: RelationType.hasMany, + source: Customer, + target: () => Order, + keyTo: 'customerId', + }) + .addRelation({ + name: 'reviewsAuthored', + type: RelationType.hasMany, + source: Customer, + target: () => Review, + keyTo: 'authorId', + }) + .addRelation({ + name: 'reviewsApproved', + type: RelationType.hasMany, + source: Customer, + target: () => Review, + keyTo: 'approvedId', + }); } function givenCrudRepositories() { @@ -212,7 +208,10 @@ describe('HasMany relation', () => { Order, typeof Order.prototype.id, typeof Customer.prototype.id - >(Customer.definition.relations.orders as HasManyDefinition, orderRepo); + >( + Customer.definition.relations.orders as HasManyDefinition, + Getter.fromValue(orderRepo), + ); customerOrderRepo = orderFactoryFn(existingCustomerId); } @@ -220,11 +219,11 @@ describe('HasMany relation', () => { function givenRepositoryFactoryFunctions() { customerAuthoredReviewFactoryFn = createHasManyRepositoryFactory( Customer.definition.relations.reviewsAuthored as HasManyDefinition, - reviewRepo, + Getter.fromValue(reviewRepo), ); customerApprovedReviewFactoryFn = createHasManyRepositoryFactory( Customer.definition.relations.reviewsApproved as HasManyDefinition, - reviewRepo, + Getter.fromValue(reviewRepo), ); } }); 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 528bec0f0b63..57f720cfc453 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 @@ -23,6 +23,7 @@ import { ValueObject, } from '../../../'; import {MetadataInspector} from '@loopback/context'; +import {RelationDefinitionMap} from '../../../src'; describe('model decorator', () => { @model() @@ -124,7 +125,7 @@ describe('model decorator', () => { @referencesOne() profile: Profile; - @hasMany(Order) + @hasMany(() => Order) orders?: Order[]; @hasOne() @@ -258,14 +259,16 @@ describe('model decorator', () => { }); it('adds hasMany metadata', () => { - const meta = + const meta: RelationDefinitionMap = MetadataInspector.getAllPropertyMetadata( RELATIONS_KEY, Customer.prototype, ) || /* istanbul ignore next */ {}; expect(meta.orders).to.eql({ type: RelationType.hasMany, - keyTo: 'customerId', + name: 'orders', + source: Customer, + target: () => Order, }); }); @@ -310,7 +313,7 @@ describe('model decorator', () => { class House extends Entity { @property() name: string; - @hasMany(Person, {keyTo: 'fk'}) + @hasMany(() => Person, {keyTo: 'fk'}) person: Person[]; } diff --git a/packages/repository/test/unit/decorator/relation.decorator.unit.ts b/packages/repository/test/unit/decorator/relation.decorator.unit.ts index ea44def49f6f..10a6c91b46c6 100644 --- a/packages/repository/test/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/test/unit/decorator/relation.decorator.unit.ts @@ -10,25 +10,6 @@ import {MODEL_PROPERTIES_KEY, model, getModelRelations} 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() class Address extends Entity { @@ -42,7 +23,7 @@ describe('relation decorator', () => { class AddressBook extends Entity { id: number; - @hasMany(Address) + @hasMany(() => Address) addresses: Address[]; } @@ -58,11 +39,14 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, - keyTo: 'addressBookId', + name: 'addresses', + source: AddressBook, + target: () => Address, }); + expect(jugglerMeta).to.eql({ type: Array, - itemType: Address, + itemType: () => Address, }); }); @@ -76,7 +60,7 @@ describe('relation decorator', () => { class AddressBook extends Entity { id: number; - @hasMany(Address, {keyTo: 'someForeignKey'}) + @hasMany(() => Address, {keyTo: 'someForeignKey'}) addresses: Address[]; } @@ -92,16 +76,18 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, + name: 'addresses', + source: AddressBook, + target: () => Address, keyTo: 'someForeignKey', }); expect(jugglerMeta).to.eql({ type: Array, - itemType: Address, + 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 +100,7 @@ describe('relation decorator', () => { class AddressBook extends Entity { id: number; @property.array(Entity) - @hasMany(Address, { + @hasMany(() => Address, { keyTo: 'someForeignKey', }) addresses: Address[]; @@ -135,7 +121,7 @@ describe('getModelRelations', () => { @model() class User extends Entity { - @hasMany(AccessToken) + @hasMany(() => AccessToken) accessTokens: AccessToken[]; } @@ -147,19 +133,21 @@ describe('getModelRelations', () => { @model() class Customer extends User { - @hasMany(Order) + @hasMany(() => Order) orders: Order[]; } const relations = getModelRelations(Customer); - expect(relations).to.deepEqual({ + expect(relations).to.containDeep({ accessTokens: { - keyTo: 'userId', + name: 'accessTokens', type: 'hasMany', + target: () => AccessToken, }, orders: { - keyTo: 'customerId', + name: 'orders', type: 'hasMany', + target: () => Order, }, }); }); diff --git a/packages/repository/test/unit/decorator/repository.decorator.unit.ts b/packages/repository/test/unit/decorator/repository.decorator.unit.ts index 81d1a76d6e97..84d91949386e 100644 --- a/packages/repository/test/unit/decorator/repository.decorator.unit.ts +++ b/packages/repository/test/unit/decorator/repository.decorator.unit.ts @@ -3,17 +3,16 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Context, Getter} from '@loopback/context'; import {expect} from '@loopback/testlab'; -import {Context} from '@loopback/context'; - import { - juggler, - repository, - EntityCrudRepository, - Repository, DefaultCrudRepository, Entity, + EntityCrudRepository, + juggler, ModelDefinition, + repository, + Repository, } from '../../../'; class MyController { @@ -25,7 +24,8 @@ class MyController { describe('repository decorator', () => { let ctx: Context; - let repo: Repository; + let defaultRepo: Repository; + let noteRepo: NoteRepository; let ds: juggler.DataSource; class Note extends Entity { @@ -39,17 +39,25 @@ describe('repository decorator', () => { }); } + class NoteRepository extends DefaultCrudRepository { + constructor(dataSource: juggler.DataSource) { + super(Note, dataSource); + } + } + before(function() { ds = new juggler.DataSource({ name: 'db', connector: 'memory', }); - repo = new DefaultCrudRepository(Note, ds); + defaultRepo = new DefaultCrudRepository(Note, ds); + noteRepo = new NoteRepository(ds); ctx = new Context(); ctx.bind('models.Note').to(Note); ctx.bind('datasources.memory').to(ds); - ctx.bind('repositories.noteRepo').to(repo); + ctx.bind('repositories.noteRepo').to(defaultRepo); + ctx.bind(`repositories.${NoteRepository.name}`).to(noteRepo); ctx.bind('controllers.MyController').toClass(MyController); }); @@ -58,7 +66,7 @@ describe('repository decorator', () => { const myController = await ctx.get( 'controllers.MyController', ); - expect(myController.noteRepo).exactly(repo); + expect(myController.noteRepo).exactly(defaultRepo); }); // tslint:disable-next-line:max-line-length @@ -66,7 +74,7 @@ describe('repository decorator', () => { const myController = await ctx.get( 'controllers.MyController', ); - expect(myController.noteRepo2).exactly(repo); + expect(myController.noteRepo2).exactly(defaultRepo); }); it('throws not implemented for class-level @repository', () => { @@ -80,25 +88,24 @@ describe('repository decorator', () => { it('supports @repository(model, dataSource) by names', async () => { class Controller2 { constructor( - @repository('Note', 'memory') public noteRepo: Repository, + @repository('Note', 'memory') public repo: Repository, ) {} } ctx.bind('controllers.Controller2').toClass(Controller2); const myController = await ctx.get('controllers.Controller2'); - expect(myController.noteRepo).to.be.not.null(); + expect(myController.repo).to.be.not.null(); }); it('supports @repository(model, dataSource)', async () => { class Controller3 { constructor( - @repository(Note, ds) - public noteRepo: EntityCrudRepository, + @repository(Note, ds) public repo: EntityCrudRepository, ) {} } ctx.bind('controllers.Controller3').toClass(Controller3); const myController = await ctx.get('controllers.Controller3'); - const r = myController.noteRepo; + const r = myController.repo; expect(r).to.be.instanceof(DefaultCrudRepository); expect((r as DefaultCrudRepository).dataSource).to.be.exactly( ds, @@ -107,7 +114,7 @@ describe('repository decorator', () => { it('rejects @repository("")', async () => { class Controller4 { - constructor(@repository('') public noteRepo: Repository) {} + constructor(@repository('') public repo: Repository) {} } ctx.bind('controllers.Controller4').toClass(Controller4); @@ -118,4 +125,40 @@ describe('repository decorator', () => { expect(err).to.match(/invalid repository/i); } }); + + describe('@repository.getter() ', () => { + it('accepts repository name', async () => { + class TestController { + constructor( + @repository.getter('NoteRepository') + public getRepo: Getter, + ) {} + } + ctx.bind('TestController').toClass(TestController); + + const controller = await ctx.get('TestController'); + const repoGetter = controller.getRepo; + + expect(repoGetter).to.be.a.Function(); + const repo = await repoGetter(); + expect(repo).to.be.exactly(noteRepo); + }); + + it('accepts repository class', async () => { + class TestController { + constructor( + @repository.getter(NoteRepository) + public getRepo: Getter, + ) {} + } + ctx.bind('TestController').toClass(TestController); + + const controller = await ctx.get('TestController'); + const repoGetter = controller.getRepo; + + expect(repoGetter).to.be.a.Function(); + const repo = await repoGetter(); + expect(repo).to.be.exactly(noteRepo); + }); + }); }); diff --git a/packages/repository/test/unit/repositories/relation.factory.unit.ts b/packages/repository/test/unit/repositories/relation.factory.unit.ts new file mode 100644 index 000000000000..345ae1a2438c --- /dev/null +++ b/packages/repository/test/unit/repositories/relation.factory.unit.ts @@ -0,0 +1,124 @@ +// 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 {Getter} from '@loopback/context'; +import {expect, sinon} from '@loopback/testlab'; +import { + createHasManyRepositoryFactory, + DefaultCrudRepository, + Entity, + HasManyDefinition, + juggler, + ModelDefinition, + RelationType, +} from '../../..'; + +describe('createHasManyRepositoryFactory', () => { + let customerRepo: CustomerRepository; + + beforeEach(givenStubbedCustomerRepo); + + it('rejects relations with missing source', () => { + const relationMeta = givenHasManyDefinition({ + source: undefined, + }); + + expect(() => + createHasManyRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + ), + ).to.throw(/source model must be defined/); + }); + + it('rejects relations with missing target', () => { + const relationMeta = givenHasManyDefinition({ + target: undefined, + }); + + expect(() => + createHasManyRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with a target that is not a type resolver', () => { + const relationMeta = givenHasManyDefinition({ + // tslint:disable-next-line:no-any + target: Customer as any, + // the cast to any above is necessary to disable compile check + // we want to verify runtime assertion + }); + + expect(() => + createHasManyRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with keyTo pointing to an unknown property', () => { + const relationMeta = givenHasManyDefinition({ + target: () => Customer, + // Let the relation to use the default keyTo value "companyId" + // which does not exist on the Customer model! + keyTo: undefined, + }); + + expect(() => + createHasManyRepositoryFactory( + relationMeta, + Getter.fromValue(customerRepo), + ), + ).to.throw(/target model Customer is missing.*foreign key companyId/); + }); + + /*------------- HELPERS ---------------*/ + + class Customer extends Entity { + static definition = new ModelDefinition('Customer').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Customer, dataSource); + } + } + + function givenStubbedCustomerRepo() { + customerRepo = sinon.createStubInstance(CustomerRepository); + } + + function givenHasManyDefinition( + props?: Partial, + ): HasManyDefinition { + class Company extends Entity { + static definition = new ModelDefinition('Company').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + const defaults: HasManyDefinition = { + type: RelationType.hasMany, + name: 'customers', + target: () => Customer, + source: Company, + }; + + return Object.assign(defaults, props); + } +}); diff --git a/packages/repository/test/unit/repositories/relation.repository.unit.ts b/packages/repository/test/unit/repositories/relation.repository.unit.ts index 1f4dbdaeca04..1e8a443e8588 100644 --- a/packages/repository/test/unit/repositories/relation.repository.unit.ts +++ b/packages/repository/test/unit/repositories/relation.repository.unit.ts @@ -3,20 +3,21 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {sinon, expect} from '@loopback/testlab'; +import {Getter} from '@loopback/context'; +import {expect, sinon} from '@loopback/testlab'; import { - EntityCrudRepository, - HasManyRepository, + AnyObject, + Count, + DataObject, DefaultCrudRepository, - juggler, DefaultHasManyEntityCrudRepository, Entity, - AnyObject, + EntityCrudRepository, Filter, + HasManyRepository, + juggler, Options, - DataObject, Where, - Count, } from '../../..'; describe('relation repository', () => { @@ -135,6 +136,6 @@ describe('relation repository', () => { Customer, typeof Customer.prototype.id, CustomerRepository - >(repo, constraint); + >(Getter.fromValue(repo), constraint); } });