diff --git a/examples/todo-list/src/models/todo.model.ts b/examples/todo-list/src/models/todo.model.ts index c360e0e60063..1b8a89d7dccd 100644 --- a/examples/todo-list/src/models/todo.model.ts +++ b/examples/todo-list/src/models/todo.model.ts @@ -3,7 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, property, model} from '@loopback/repository'; +import {Entity, property, model, belongsTo} from '@loopback/repository'; +import {TodoList} from './todo-list.model'; @model() export class Todo extends Entity { @@ -29,7 +30,7 @@ export class Todo extends Entity { }) isComplete: boolean; - @property() + @belongsTo(() => TodoList) todoListId: number; getId() { diff --git a/examples/todo-list/src/repositories/todo.repository.ts b/examples/todo-list/src/repositories/todo.repository.ts index c17a78653566..5901e3bee09b 100644 --- a/examples/todo-list/src/repositories/todo.repository.ts +++ b/examples/todo-list/src/repositories/todo.repository.ts @@ -3,15 +3,32 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {DefaultCrudRepository, juggler} from '@loopback/repository'; -import {Todo} from '../models'; -import {inject} from '@loopback/core'; +import {Getter, inject} from '@loopback/core'; +import { + BelongsToAccessor, + DefaultCrudRepository, + juggler, + repository, +} from '@loopback/repository'; +import {Todo, TodoList} from '../models'; +import {TodoListRepository} from './todo-list.repository'; export class TodoRepository extends DefaultCrudRepository< Todo, typeof Todo.prototype.id > { - constructor(@inject('datasources.db') dataSource: juggler.DataSource) { + public todoList: BelongsToAccessor; + + constructor( + @inject('datasources.db') dataSource: juggler.DataSource, + @repository.getter('TodoListRepository') + protected todoListRepositoryGetter: Getter, + ) { super(Todo, dataSource); + + this.todoList = this._createBelongsToAccessorFor( + 'todoListId', + todoListRepositoryGetter, + ); } } diff --git a/examples/todo-list/test/acceptance/todo-list-todo.acceptance.ts b/examples/todo-list/test/acceptance/todo-list-todo.acceptance.ts index 62f5c8e613db..ad3368626074 100644 --- a/examples/todo-list/test/acceptance/todo-list-todo.acceptance.ts +++ b/examples/todo-list/test/acceptance/todo-list-todo.acceptance.ts @@ -8,6 +8,7 @@ import { createRestAppClient, expect, givenHttpServerConfig, + toJSON, } from '@loopback/testlab'; import {TodoListApplication} from '../../src/application'; import {Todo, TodoList} from '../../src/models/'; @@ -41,13 +42,17 @@ describe('TodoListApplication', () => { }); it('creates todo for a todoList', async () => { - const todo = givenTodo(); + const todo = givenTodo({todoListId: undefined}); const response = await client .post(`/todo-lists/${persistedTodoList.id}/todos`) .send(todo) .expect(200); - expect(response.body).to.containDeep(todo); + const expected = {...todo, todoListId: persistedTodoList.id}; + expect(response.body).to.containEql(expected); + + const created = await todoRepo.findById(response.body.id); + expect(toJSON(created)).to.deepEqual({id: response.body.id, ...expected}); }); context('when dealing with multiple persisted Todos', () => { @@ -213,7 +218,9 @@ describe('TodoListApplication', () => { id: typeof Todo.prototype.id, todo?: Partial, ) { - return await todoListRepo.todos(id).create(givenTodo(todo)); + const data = givenTodo(todo); + delete data.todoListId; + return await todoListRepo.todos(id).create(data); } async function givenTodoListInstance(todoList?: Partial) { diff --git a/examples/todo-list/test/acceptance/todo-list.acceptance.ts b/examples/todo-list/test/acceptance/todo-list.acceptance.ts index 02009f431a65..03d6d39ca21b 100644 --- a/examples/todo-list/test/acceptance/todo-list.acceptance.ts +++ b/examples/todo-list/test/acceptance/todo-list.acceptance.ts @@ -148,7 +148,7 @@ describe('TodoListApplication', () => { it('returns 404 when updating a todo-list that does not exist', () => { return client - .patch('/todos/99999') + .patch('/todo-lists/99999') .send(givenTodoList()) .expect(404); }); diff --git a/examples/todo-list/test/helpers.ts b/examples/todo-list/test/helpers.ts index 3f81c8e69be0..1c0cf08e1815 100644 --- a/examples/todo-list/test/helpers.ts +++ b/examples/todo-list/test/helpers.ts @@ -35,6 +35,7 @@ export function givenTodo(todo?: Partial) { title: 'do a thing', desc: 'There are some things that need doing', isComplete: false, + todoListId: 999, }, todo, ); 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 9f9f14cd343e..866a5086bb34 100644 --- a/packages/repository-json-schema/test/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/test/integration/build-schema.integration.ts @@ -3,16 +3,22 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {model, property, Entity, hasMany} from '@loopback/repository'; +import {MetadataInspector} from '@loopback/context'; +import { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import * as Ajv from 'ajv'; import { - modelToJsonSchema, - JSON_SCHEMA_KEY, getJsonSchema, JsonSchema, + JSON_SCHEMA_KEY, + modelToJsonSchema, } from '../..'; -import {expect} from '@loopback/testlab'; -import {MetadataInspector} from '@loopback/context'; -import * as Ajv from 'ajv'; describe('build-schema', () => { describe('modelToJsonSchema', () => { @@ -420,13 +426,13 @@ describe('build-schema', () => { expectValidJsonSchema(jsonSchema); }); - it('properly converts models with hasMany properties', () => { + it('properly converts models with hasMany/belongsTo relation', () => { @model() class Order extends Entity { @property({id: true}) id: number; - @property() + @belongsTo(() => Customer) customerId: number; } @@ -439,10 +445,16 @@ describe('build-schema', () => { orders: Order[]; } + const orderSchema = modelToJsonSchema(Order); const customerSchema = modelToJsonSchema(Customer); expectValidJsonSchema(customerSchema); + expectValidJsonSchema(orderSchema); + expect(orderSchema.properties).to.deepEqual({ + id: {type: 'number'}, + customerId: {type: 'number'}, + }); expect(customerSchema.properties).to.deepEqual({ id: {type: 'number'}, orders: { diff --git a/packages/repository/examples/models/order.model.ts b/packages/repository/examples/models/order.model.ts index 5dea92626ded..35b5180bab15 100644 --- a/packages/repository/examples/models/order.model.ts +++ b/packages/repository/examples/models/order.model.ts @@ -24,8 +24,7 @@ class Order extends Entity { // as simple types string, number, boolean can be inferred @property({type: 'string', id: true, generated: true}) id: string; - customerId: string; - @belongsTo() - customer: Customer; + @belongsTo(() => Customer) + customerId: string; } diff --git a/packages/repository/src/decorators/relation.decorator.ts b/packages/repository/src/decorators/relation.decorator.ts index 03595c00f554..7e93abb8eaaf 100644 --- a/packages/repository/src/decorators/relation.decorator.ts +++ b/packages/repository/src/decorators/relation.decorator.ts @@ -3,8 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {PropertyDecoratorFactory} from '@loopback/context'; -import {Entity, EntityResolver, Model, RelationDefinitionMap} from '../model'; +import {MetadataInspector, PropertyDecoratorFactory} from '@loopback/context'; +import { + Entity, + EntityResolver, + Model, + PropertyDefinition, + RelationDefinitionMap, +} from '../model'; import {TypeResolver} from '../type-resolver'; import {property} from './model.decorator'; @@ -21,19 +27,65 @@ export enum RelationType { export const RELATIONS_KEY = 'loopback:relations'; export interface RelationDefinitionBase { + /** + * The type of the relation, must be one of RelationType values. + */ type: RelationType; + + /** + * The relation name, typically matching the name of the accessor property + * defined on the source model. For example "orders" or "customer". + */ name: string; + + /** + * The source model of this relation. + * + * E.g. when a Customer has many Order instances, then Customer is the source. + */ source: typeof Entity; + + /** + * The target model of this relation. + * + * E.g. when a Customer has many Order instances, then Order is the target. + */ target: TypeResolver; } export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; + + /** + * The foreign key used by the target model. + * + * E.g. when a Customer has many Order instances, then keyTo is "customerId". + * Note that "customerId" is the default FK assumed by the framework, users + * can provide a custom FK name by setting "keyTo". + */ + keyTo?: string; +} + +export interface BelongsToDefinition extends RelationDefinitionBase { + type: RelationType.belongsTo; + + /* + * The foreign key in the source model, e.g. Order#customerId. + */ + keyFrom: string; + + /* + * The primary key of the target model, e.g Customer#id. + */ keyTo?: string; } -// TODO(bajtos) add other relation types, e.g. BelongsToDefinition -export type RelationMetadata = HasManyDefinition | RelationDefinitionBase; +export type RelationMetadata = + | HasManyDefinition + | BelongsToDefinition + // TODO(bajtos) add other relation types and remove RelationDefinitionBase once + // all relation types are covered. + | RelationDefinitionBase; /** * Decorator for relations @@ -48,12 +100,45 @@ export function relation(definition?: Object) { /** * Decorator for belongsTo * @param definition - * @returns {(target:any, key:string)} + * @returns {(target: Object, 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( + targetResolver: EntityResolver, + definition?: Partial, +) { + return function(decoratedTarget: Entity, decoratedKey: string) { + const propMeta: PropertyDefinition = { + type: MetadataInspector.getDesignTypeForProperty( + decoratedTarget, + decoratedKey, + ), + // TODO(bajtos) Make the foreign key required once our REST API layer + // allows controller methods to exclude required properties + // required: true, + }; + property(propMeta)(decoratedTarget, decoratedKey); + + // @belongsTo() is typically decorating the foreign key property, + // e.g. customerId. We need to strip the trailing "Id" suffix from the name. + const relationName = decoratedKey.replace(/Id$/, ''); + + const meta: BelongsToDefinition = Object.assign( + // default values, can be customized by the caller + { + keyFrom: decoratedKey, + }, + // properties provided by the caller + definition, + // properties enforced by the decorator + { + type: RelationType.belongsTo, + name: relationName, + source: decoratedTarget.constructor, + target: targetResolver, + }, + ); + relation(meta)(decoratedTarget, decoratedKey); + }; } /** @@ -78,14 +163,13 @@ export function hasMany( targetResolver: EntityResolver, definition?: Partial, ) { - // todo(shimks): extract out common logic (such as @property.array) to - // @relation return function(decoratedTarget: Object, key: string) { property.array(targetResolver)(decoratedTarget, key); const meta: HasManyDefinition = Object.assign( + // default values, can be customized by the caller {}, - // properties customizable by users + // properties provided by the caller definition, // properties enforced by the decorator { diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 0389b510362f..518a69b1f520 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -15,11 +15,16 @@ import { Options, PositionalParameters, } from '../common-types'; -import {HasManyDefinition} from '../decorators/relation.decorator'; +import { + BelongsToDefinition, + HasManyDefinition, +} from '../decorators/relation.decorator'; import {EntityNotFoundError} from '../errors'; import {Entity, ModelDefinition} from '../model'; import {Filter, Where} from '../query'; import { + BelongsToAccessor, + createBelongsToAccessor, createHasManyRepositoryFactory, HasManyRepositoryFactory, } from './relation.factory'; @@ -179,6 +184,18 @@ export class DefaultCrudRepository ); } + protected _createBelongsToAccessorFor( + relationName: string, + targetRepoGetter: Getter>, + ): BelongsToAccessor { + const meta = this.entityClass.definition.relations[relationName]; + return createBelongsToAccessor( + meta as BelongsToDefinition, + targetRepoGetter, + this, + ); + } + async create(entity: DataObject, options?: Options): Promise { const model = await ensurePromise(this.modelClass.create(entity, options)); return this.toEntity(model); diff --git a/packages/repository/src/repositories/relation.factory.ts b/packages/repository/src/repositories/relation.factory.ts index d270c2a2dec7..dd40cac44118 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 {Getter} from '@loopback/context'; import * as debugFactory from 'debug'; import {camelCase} from 'lodash'; import {DataObject, isTypeResolver} from '..'; -import {HasManyDefinition} from '../decorators/relation.decorator'; +import { + BelongsToDefinition, + HasManyDefinition, + RelationMetadata, +} from '../decorators/relation.decorator'; import {Entity} from '../model'; import { + DefaultBelongsToRepository, DefaultHasManyEntityCrudRepository, + Getter, HasManyRepository, } from './relation.repository'; import {EntityCrudRepository} from './repository'; @@ -21,6 +26,10 @@ export type HasManyRepositoryFactory = ( 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 @@ -43,7 +52,7 @@ export function createHasManyRepositoryFactory< targetRepositoryGetter: Getter>, ): HasManyRepositoryFactory { const meta = resolveHasManyMetadata(relationMetadata); - debug('Resolved relation metadata: %o', meta); + debug('Resolved HasMany relation metadata: %o', meta); return function(fkValue: ForeignKeyType) { // tslint:disable-next-line:no-any const constraint: any = {[meta.keyTo]: fkValue}; @@ -104,9 +113,84 @@ function resolveHasManyMetadata( return Object.assign(relationMeta, {keyTo: defaultFkName}); } -function invalidDefinition(relationMeta: HasManyDefinition, reason: string) { - const source = relationMeta.source; +/** + * Enforces a BelongsTo constraint on a repository + */ +export function createBelongsToAccessor< + Target extends Entity, + TargetId, + Source extends Entity, + SourceId +>( + belongsToMetadata: BelongsToDefinition, + targetRepoGetter: Getter>, + sourceRepository: EntityCrudRepository, +): BelongsToAccessor { + const meta = resolveBelongsToMetadata(belongsToMetadata); + debug('Resolved BelongsTo relation metadata: %o', meta); + return async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { + const foreignKey = meta.keyFrom; + const primaryKey = meta.keyTo; + 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 DefaultBelongsToRepository( + targetRepoGetter, + constraint as DataObject, + ); + return constrainedRepo.get(); + }; +} + +type BelongsToResolvedDefinition = BelongsToDefinition & {keyTo: string}; + +/** + * 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 + */ +function resolveBelongsToMetadata(relationMeta: BelongsToDefinition) { + if (!isTypeResolver(relationMeta.target)) { + const reason = 'target must be a type resolver'; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + if (!relationMeta.keyFrom) { + const reason = 'keyFrom is required'; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + 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(); + const targetName = targetModel.modelName; + debug('Resolved model %s from given metadata: %o', targetName, targetModel); + + const targetProperties = targetModel.definition.properties; + debug('relation metadata from %o: %o', targetName, targetProperties); + + if (relationMeta.keyTo) { + // The explict cast is needed because of a limitation of type inference + return relationMeta as BelongsToResolvedDefinition; + } + + const targetPrimaryKey = targetModel.definition.idProperties()[0]; + if (!targetPrimaryKey) { + const reason = `${targetName} does not have any primary key (id property)`; + throw new Error(invalidDefinition(relationMeta, reason)); + } + + return Object.assign(relationMeta, {keyTo: targetPrimaryKey}); +} + +function invalidDefinition(relationMeta: RelationMetadata, reason: string) { + const {name, type, source} = relationMeta; const model = (source && source.modelName) || ''; - const name = relationMeta.name; - return `Invalid hasMany definition for ${model}#${name}: ${reason}`; + return `Invalid ${type} definition for ${model}#${name}: ${reason}`; } diff --git a/packages/repository/src/repositories/relation.repository.ts b/packages/repository/src/repositories/relation.repository.ts index 08f88cca2cc0..8f9d6fb9c05a 100644 --- a/packages/repository/src/repositories/relation.repository.ts +++ b/packages/repository/src/repositories/relation.repository.ts @@ -13,6 +13,10 @@ import { } from './constraint-utils'; import {EntityCrudRepository} from './repository'; import {Getter} from '@loopback/context'; +import {EntityNotFoundError} from '../errors'; + +// Re-export Getter so that users don't have to import from @loopback/context +export {Getter}; /** * CRUD operations for a target repository of a HasMany relation @@ -56,6 +60,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, @@ -112,3 +127,34 @@ export class DefaultHasManyEntityCrudRepository< ); } } + +export class DefaultBelongsToRepository< + 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, + ); + if (!result.length) { + // We don't have a direct access to the foreign key value here :( + const id = 'constraint ' + JSON.stringify(this.constraint); + throw new EntityNotFoundError(targetRepo.entityClass, id); + } + return result[0]; + } +} diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 6e1bcead89ac..89945b6db151 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -108,6 +108,9 @@ export interface EntityRepository export interface EntityCrudRepository extends EntityRepository, CrudRepository { + // entityClass should have type "typeof T", but that's not supported by TSC + entityClass: typeof Entity & {prototype: T}; + /** * Save an entity. If no id is present, create a new entity * @param entity Entity to be saved @@ -213,34 +216,38 @@ export class CrudRepositoryImpl constructor( public dataSource: DataSource, // model should have type "typeof T", but that's not supported by TSC - public model: typeof Entity & {prototype: T}, + public entityClass: typeof Entity & {prototype: T}, ) { this.connector = dataSource.connector as CrudConnector; } private toModels(data: Promise[]>): Promise { - return data.then(items => items.map(i => new this.model(i) as T)); + return data.then(items => items.map(i => new this.entityClass(i) as T)); } private toModel(data: Promise>): Promise { - return data.then(d => new this.model(d) as T); + return data.then(d => new this.entityClass(d) as T); } create(entity: DataObject, options?: Options): Promise { - return this.toModel(this.connector.create(this.model, entity, options)); + return this.toModel( + this.connector.create(this.entityClass, entity, options), + ); } createAll(entities: DataObject[], options?: Options): Promise { return this.toModels( - this.connector.createAll!(this.model, entities, options), + this.connector.createAll!(this.entityClass, entities, options), ); } async save(entity: DataObject, options?: Options): Promise { if (typeof this.connector.save === 'function') { - return this.toModel(this.connector.save(this.model, entity, options)); + return this.toModel( + this.connector.save(this.entityClass, entity, options), + ); } else { - const id = this.model.getIdOf(entity); + const id = this.entityClass.getIdOf(entity); if (id != null) { await this.replaceById(id, entity, options); return this.toModel(Promise.resolve(entity)); @@ -251,29 +258,33 @@ export class CrudRepositoryImpl } find(filter?: Filter, options?: Options): Promise { - return this.toModels(this.connector.find(this.model, filter, options)); + return this.toModels( + this.connector.find(this.entityClass, filter, options), + ); } async findById(id: ID, filter?: Filter, options?: Options): Promise { if (typeof this.connector.findById === 'function') { - return this.toModel(this.connector.findById(this.model, id, options)); + return this.toModel( + this.connector.findById(this.entityClass, id, options), + ); } - const where = this.model.buildWhereForId(id); + const where = this.entityClass.buildWhereForId(id); const entities = await this.toModels( - this.connector.find(this.model, {where: where}, options), + this.connector.find(this.entityClass, {where: where}, options), ); if (!entities.length) { - throw new EntityNotFoundError(this.model, id); + throw new EntityNotFoundError(this.entityClass, id); } return entities[0]; } update(entity: DataObject, options?: Options): Promise { - return this.updateById(this.model.getIdOf(entity), entity, options); + return this.updateById(this.entityClass.getIdOf(entity), entity, options); } delete(entity: DataObject, options?: Options): Promise { - return this.deleteById(this.model.getIdOf(entity), options); + return this.deleteById(this.entityClass.getIdOf(entity), options); } updateAll( @@ -281,7 +292,7 @@ export class CrudRepositoryImpl where?: Where, options?: Options, ): Promise { - return this.connector.updateAll(this.model, data, where, options); + return this.connector.updateAll(this.entityClass, data, where, options); } async updateById( @@ -291,14 +302,19 @@ export class CrudRepositoryImpl ): Promise { let success: boolean; if (typeof this.connector.updateById === 'function') { - success = await this.connector.updateById(this.model, id, data, options); + success = await this.connector.updateById( + this.entityClass, + id, + data, + options, + ); } else { - const where = this.model.buildWhereForId(id); + const where = this.entityClass.buildWhereForId(id); const result = await this.updateAll(data, where, options); success = result.count > 0; } if (!success) { - throw new EntityNotFoundError(this.model, id); + throw new EntityNotFoundError(this.entityClass, id); } } @@ -309,48 +325,53 @@ export class CrudRepositoryImpl ): Promise { let success: boolean; if (typeof this.connector.replaceById === 'function') { - success = await this.connector.replaceById(this.model, id, data, options); + success = await this.connector.replaceById( + this.entityClass, + id, + data, + options, + ); } else { // FIXME: populate inst with all properties // tslint:disable-next-line:no-unused-variable const inst = data; - const where = this.model.buildWhereForId(id); + const where = this.entityClass.buildWhereForId(id); const result = await this.updateAll(data, where, options); success = result.count > 0; } if (!success) { - throw new EntityNotFoundError(this.model, id); + throw new EntityNotFoundError(this.entityClass, id); } } deleteAll(where?: Where, options?: Options): Promise { - return this.connector.deleteAll(this.model, where, options); + return this.connector.deleteAll(this.entityClass, where, options); } async deleteById(id: ID, options?: Options): Promise { let success: boolean; if (typeof this.connector.deleteById === 'function') { - success = await this.connector.deleteById(this.model, id, options); + success = await this.connector.deleteById(this.entityClass, id, options); } else { - const where = this.model.buildWhereForId(id); + const where = this.entityClass.buildWhereForId(id); const result = await this.deleteAll(where, options); success = result.count > 0; } if (!success) { - throw new EntityNotFoundError(this.model, id); + throw new EntityNotFoundError(this.entityClass, id); } } count(where?: Where, options?: Options): Promise { - return this.connector.count(this.model, where, options); + return this.connector.count(this.entityClass, where, options); } exists(id: ID, options?: Options): Promise { if (typeof this.connector.exists === 'function') { - return this.connector.exists(this.model, id, options); + return this.connector.exists(this.entityClass, id, options); } else { - const where = this.model.buildWhereForId(id); + const where = this.entityClass.buildWhereForId(id); return this.count(where, options).then(result => result.count > 0); } } diff --git a/packages/repository/src/type-resolver.ts b/packages/repository/src/type-resolver.ts index 3b8f9f9be952..2d0060e022c2 100644 --- a/packages/repository/src/type-resolver.ts +++ b/packages/repository/src/type-resolver.ts @@ -14,17 +14,15 @@ import {Class} from './common-types'; * 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: + * @template Type The type we are resolving, for example `Entity` or `Product`. + * This parameter is required. * - * - `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). + * @template StaticMembers The 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< Type extends Object, 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..894414f75530 --- /dev/null +++ b/packages/repository/test/acceptance/belongs-to.relation.acceptance.ts @@ -0,0 +1,71 @@ +// 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, + ApplicationWithRepositories, +} from '../..'; +import {CustomerRepository, OrderRepository} from '../fixtures/repositories'; +import {expect} from '@loopback/testlab'; +import {Application} from '@loopback/core'; + +describe('BelongsTo relation', () => { + // Given a Customer and Order models - see definitions at the bottom + + let app: ApplicationWithRepositories; + 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 6060cd578210..79cda35c13c4 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,7 +3,6 @@ // 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 { DefaultCrudRepository, @@ -14,6 +13,7 @@ import { juggler, model, property, + Getter, } from '../..'; describe('HasMany relation', () => { @@ -28,10 +28,8 @@ describe('HasMany relation', () => { before(givenOrderRepository); before(givenCustomerRepository); beforeEach(async () => { - existingCustomerId = (await givenPersistedCustomerInstance()).id; - }); - afterEach(async () => { await orderRepo.deleteAll(); + existingCustomerId = (await givenPersistedCustomerInstance()).id; }); it('can create an instance of the related model', async () => { diff --git a/packages/repository/test/acceptance/has-many.relation.acceptance.ts b/packages/repository/test/acceptance/has-many.relation.acceptance.ts index 672ab5cae1c8..00214ac9dbc2 100644 --- a/packages/repository/test/acceptance/has-many.relation.acceptance.ts +++ b/packages/repository/test/acceptance/has-many.relation.acceptance.ts @@ -3,22 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Application} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import * as _ from 'lodash'; import { - model, - property, - Entity, - DefaultCrudRepository, + ApplicationWithRepositories, juggler, - hasMany, repository, RepositoryMixin, - ApplicationWithRepositories, - HasManyRepositoryFactory, } from '../..'; -import {expect} from '@loopback/testlab'; -import * as _ from 'lodash'; -import {inject, Getter} from '@loopback/context'; -import {Application} from '@loopback/core'; +import {Order} from '../fixtures/models'; +import {CustomerRepository, OrderRepository} from '../fixtures/repositories'; describe('HasMany relation', () => { // Given a Customer and Order models - see definitions at the bottom @@ -34,12 +29,13 @@ 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, { description: 'order 1', @@ -76,13 +72,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 +86,18 @@ describe('HasMany relation', () => { expect(arePatched.count).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, }, ]); }); @@ -146,82 +142,6 @@ describe('HasMany relation', () => { // This should be enforced by the database to avoid race conditions it.skip('reject create request when the customer does not exist'); - //--- 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.getter(OrderRepository) - orderRepositoryGetter: Getter, - ) { - super(Customer, db); - this.orders = this._createHasManyRepositoryFactoryFor( - 'orders', - orderRepositoryGetter, - ); - } - } - 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..3631ce9ec7a4 --- /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 {Entity, hasMany, model, property} 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..3f10d310a1b1 --- /dev/null +++ b/packages/repository/test/fixtures/models/index.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 * from './customer.model'; +export * from './order.model'; +export * from './product.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..695dc8875625 --- /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 {belongsTo, Entity, model, property} 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..374bc7e85f56 --- /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 {Getter, inject} from '@loopback/context'; +import { + DefaultCrudRepository, + HasManyRepositoryFactory, + juggler, + repository, +} from '../../..'; +import {Customer, Order} from '../models'; +import {OrderRepository} from './order.repository'; + +export class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id +> { + public orders: HasManyRepositoryFactory; + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('OrderRepository') + orderRepositoryGetter: Getter, + ) { + super(Customer, db); + this.orders = this._createHasManyRepositoryFactoryFor( + '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..7da82c261ecf --- /dev/null +++ b/packages/repository/test/fixtures/repositories/index.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 * from './customer.repository'; +export * from './order.repository'; +export * from './product.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..5b78adee144a --- /dev/null +++ b/packages/repository/test/fixtures/repositories/order.repository.ts @@ -0,0 +1,33 @@ +// 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 {Getter, inject} from '@loopback/context'; +import { + BelongsToAccessor, + DefaultCrudRepository, + juggler, + repository, +} from '../../..'; +import {Customer, Order} from '../models'; +import {CustomerRepository} from '../repositories'; + +export class OrderRepository extends DefaultCrudRepository< + Order, + typeof Order.prototype.id +> { + public customer: BelongsToAccessor; + + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('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 c25857ad3577..3e4716499a9c 100644 --- a/packages/repository/test/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/test/integration/repositories/relation.factory.integration.ts @@ -3,30 +3,34 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {expect} from '@loopback/testlab'; import { - Entity, + BelongsToAccessor, + BelongsToDefinition, + createBelongsToAccessor, + createHasManyRepositoryFactory, DefaultCrudRepository, - juggler, + Entity, EntityCrudRepository, - RelationType, - HasManyRepository, - ModelDefinition, - createHasManyRepositoryFactory, + EntityNotFoundError, + Getter, HasManyDefinition, + HasManyRepository, HasManyRepositoryFactory, + juggler, + ModelDefinition, + RelationType, } from '../../..'; -import {expect} from '@loopback/testlab'; -import {Getter} from '@loopback/context'; + +// Given a Customer and Order models - see definitions at the bottom +let db: juggler.DataSource; +let customerRepo: EntityCrudRepository; +let orderRepo: EntityCrudRepository; +let reviewRepo: EntityCrudRepository; 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 existingCustomerId: number; + let customerOrderRepo: HasManyRepository; let customerAuthoredReviewFactoryFn: HasManyRepositoryFactory< Review, @@ -36,13 +40,13 @@ describe('HasMany relation', () => { Review, typeof Customer.prototype.id >; - let existingCustomerId: number; before(givenCrudRepositories); before(givenPersistedCustomerInstance); before(givenConstrainedRepositories); before(givenRepositoryFactoryFunctions); - afterEach(async function resetOrderRepository() { + + beforeEach(async function resetDatabase() { await orderRepo.deleteAll(); await reviewRepo.deleteAll(); }); @@ -119,88 +123,9 @@ describe('HasMany relation', () => { //--- HELPERS ---// - class Order extends Entity { - id: number; - description: string; - customerId: number; - - static definition = new ModelDefinition({ - name: 'Order', - properties: { - id: {type: 'number', id: true}, - description: {type: 'string', required: true}, - customerId: {type: 'number', required: true}, - }, - }); - } - - 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}, - }, - }) - .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() { - 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; + const customer = await customerRepo.create({name: 'a customer'}); + existingCustomerId = customer.id; } function givenConstrainedRepositories() { @@ -227,3 +152,131 @@ describe('HasMany relation', () => { ); } }); + +describe('BelongsTo relation', () => { + let findCustomerOfOrder: BelongsToAccessor< + Customer, + typeof Order.prototype.id + >; + + before(givenCrudRepositories); + before(givenAccessor); + beforeEach(async function resetDatabase() { + await Promise.all([ + customerRepo.deleteAll(), + orderRepo.deleteAll(), + reviewRepo.deleteAll(), + ]); + }); + + it('finds an instance of the related model', 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 findCustomerOfOrder(order.id); + + expect(result).to.deepEqual(customer); + }); + + it('throws EntityNotFound error when the related model does not exist', async () => { + const order = await orderRepo.create({ + customerId: 999, // does not exist + description: 'Order of a fictional customer', + }); + + await expect(findCustomerOfOrder(order.id)).to.be.rejectedWith( + EntityNotFoundError, + ); + }); + + //--- HELPERS ---// + + function givenAccessor() { + findCustomerOfOrder = createBelongsToAccessor( + Order.definition.relations.customer as BelongsToDefinition, + Getter.fromValue(customerRepo), + orderRepo, + ); + } +}); + +//--- HELPERS ---// + +class Order extends Entity { + id: number; + description: string; + customerId: number; + + static definition = new ModelDefinition('Order') + .addProperty('id', {type: 'number', id: true}) + .addProperty('description', {type: 'string', required: true}) + .addProperty('customerId', {type: 'number', required: true}) + .addRelation({ + name: 'customer', + type: RelationType.belongsTo, + source: Order, + target: () => Customer, + keyFrom: 'customerId', + keyTo: 'id', + }); +} + +class Review extends Entity { + id: number; + description: string; + authorId: number; + approvedId: number; + + static definition = new ModelDefinition('Review') + .addProperty('id', {type: 'number', id: true}) + .addProperty('description', {type: 'string', required: true}) + .addProperty('authorId', {type: 'number', required: false}) + .addProperty('approvedId', {type: 'number', required: false}); +} + +class Customer extends Entity { + id: number; + name: string; + orders: Order[]; + reviewsAuthored: Review[]; + reviewsApproved: Review[]; + + static definition: ModelDefinition = new ModelDefinition('Customer') + .addProperty('id', {type: 'number', id: true}) + .addProperty('name', {type: 'string', required: true}) + .addProperty('orders', {type: Order, array: true}) + .addProperty('reviewsAuthored', {type: Review, array: true}) + .addProperty('reviewsApproved', {type: Review, array: true}) + .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() { + db = new juggler.DataSource({connector: 'memory'}); + + customerRepo = new DefaultCrudRepository(Customer, db); + orderRepo = new DefaultCrudRepository(Order, db); + reviewRepo = new DefaultCrudRepository(Review, db); +} 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 57f720cfc453..bf096d15557a 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 @@ -3,27 +3,27 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {MetadataInspector} from '@loopback/context'; import {expect} from '@loopback/testlab'; +import {RelationMetadata} from '../../..'; import { - model, - property, - MODEL_KEY, - MODEL_PROPERTIES_KEY, - relation, - hasOne, belongsTo, - embedsOne, embedsMany, + embedsOne, + Entity, hasMany, + hasOne, + model, + MODEL_KEY, + MODEL_PROPERTIES_KEY, + property, referencesMany, referencesOne, + relation, RELATIONS_KEY, RelationType, - Entity, ValueObject, } from '../../../'; -import {MetadataInspector} from '@loopback/context'; -import {RelationDefinitionMap} from '../../../src'; describe('model decorator', () => { @model() @@ -69,8 +69,6 @@ describe('model decorator', () => { description: string; } - interface ICustomer {} - @model() class Product extends Entity { @property() @@ -94,12 +92,9 @@ describe('model decorator', () => { @property({type: 'string', id: true, generated: true}) id: string; - @property() - customerId: string; - @belongsTo({target: 'Customer'}) - // TypeScript does not allow me to reference Customer here - customer: ICustomer; + @belongsTo(() => Customer) + customerId: string; // Validates that property no longer requires a parameter @property() @@ -107,8 +102,10 @@ describe('model decorator', () => { } @model() - class Customer extends Entity implements ICustomer { + class Customer extends Entity { + @property({type: 'string', id: true, generated: true}) id: string; + email: string; firstName: string; lastName: string; @@ -259,29 +256,34 @@ describe('model decorator', () => { }); it('adds hasMany metadata', () => { - const meta: RelationDefinitionMap = - MetadataInspector.getAllPropertyMetadata( + const meta = + MetadataInspector.getAllPropertyMetadata( RELATIONS_KEY, Customer.prototype, ) || /* istanbul ignore next */ {}; - expect(meta.orders).to.eql({ + expect(meta.orders).to.containEql({ type: RelationType.hasMany, name: 'orders', - source: Customer, - target: () => Order, }); + expect(meta.orders.source).to.be.exactly(Customer); + expect(meta.orders.target()).to.be.exactly(Order); }); it('adds belongsTo metadata', () => { const meta = - MetadataInspector.getAllPropertyMetadata( + MetadataInspector.getAllPropertyMetadata( RELATIONS_KEY, Order.prototype, ) || /* istanbul ignore next */ {}; - expect(meta.customer).to.eql({ + const relationDef = meta.customerId; + expect(relationDef).to.containEql({ type: RelationType.belongsTo, - target: 'Customer', + name: 'customer', + target: () => Customer, + keyFrom: 'customerId', }); + expect(relationDef.source).to.be.exactly(Order); + expect(relationDef.target()).to.be.exactly(Customer); }); it('adds hasOne metadata', () => { diff --git a/packages/repository/test/unit/decorator/relation.decorator.unit.ts b/packages/repository/test/unit/decorator/relation.decorator.unit.ts index 10a6c91b46c6..cee01aba0dc4 100644 --- a/packages/repository/test/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/test/unit/decorator/relation.decorator.unit.ts @@ -3,13 +3,22 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {expect} from '@loopback/testlab'; -import {Entity, hasMany, RELATIONS_KEY, RelationType, property} from '../../..'; import {MetadataInspector} from '@loopback/context'; -import {MODEL_PROPERTIES_KEY, model, getModelRelations} from '../../../src'; +import {expect} from '@loopback/testlab'; +import { + belongsTo, + Entity, + getModelRelations, + hasMany, + model, + MODEL_PROPERTIES_KEY, + property, + RELATIONS_KEY, + RelationType, +} from '../../..'; describe('relation decorator', () => { - context('hasMany', () => { + describe('hasMany', () => { it('takes in complex property type and infers foreign key via source model name', () => { @model() class Address extends Entity { @@ -109,6 +118,84 @@ describe('relation decorator', () => { }); }); }); + + describe('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('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, + name: 'addressBook', + source: Address, + target: () => AddressBook, + keyFrom: 'addressBookId', + }); + }); + + it('accepts explicit keyFrom and keyTo', () => { + class Address extends Entity { + addressId: number; + street: string; + province: string; + @belongsTo(() => AddressBook, { + keyFrom: 'aForeignKey', + keyTo: 'aPrimaryKey', + }) + addressBookId: number; + } + + class AddressBook extends Entity { + id: number; + addresses: Address[]; + } + const meta = MetadataInspector.getPropertyMetadata( + RELATIONS_KEY, + Address.prototype, + 'addressBookId', + ); + expect(meta).to.containEql({ + keyFrom: 'aForeignKey', + keyTo: 'aPrimaryKey', + }); + }); + }); }); describe('getModelRelations', () => { diff --git a/packages/repository/test/unit/repositories/belongs-to-repository-factory.unit.ts b/packages/repository/test/unit/repositories/belongs-to-repository-factory.unit.ts new file mode 100644 index 000000000000..fd07a7a0739f --- /dev/null +++ b/packages/repository/test/unit/repositories/belongs-to-repository-factory.unit.ts @@ -0,0 +1,176 @@ +// 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 {createStubInstance, expect} from '@loopback/testlab'; +import { + BelongsToDefinition, + createBelongsToAccessor, + DefaultCrudRepository, + Entity, + Getter, + juggler, + ModelDefinition, + RelationType, +} from '../../..'; + +describe('createBelongsToAccessor', () => { + let customerRepo: CustomerRepository; + let companyRepo: CompanyRepository; + + beforeEach(givenStubbedCustomerRepo); + beforeEach(givenStubbedCompanyRepo); + + it('rejects relations with missing source', () => { + const relationMeta = givenBelongsToDefinition({ + source: undefined, + }); + + expect(() => + createBelongsToAccessor( + relationMeta, + Getter.fromValue(companyRepo), + customerRepo, + ), + ).to.throw(/source model must be defined/); + }); + + it('rejects relations with missing target', () => { + const relationMeta = givenBelongsToDefinition({ + target: undefined, + }); + + expect(() => + createBelongsToAccessor( + relationMeta, + Getter.fromValue(companyRepo), + customerRepo, + ), + ).to.throw(/target must be a type resolver/); + }); + + it('rejects relations with missing keyFrom', () => { + const relationMeta = givenBelongsToDefinition({ + keyFrom: undefined, + }); + + expect(() => + createBelongsToAccessor( + relationMeta, + Getter.fromValue(companyRepo), + customerRepo, + ), + ).to.throw(/keyFrom is required/); + }); + + it('rejects relations with a target that is not a type resolver', () => { + const relationMeta = givenBelongsToDefinition({ + // 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(() => + createBelongsToAccessor( + relationMeta, + Getter.fromValue(companyRepo), + customerRepo, + ), + ).to.throw(/target must be a type resolver/); + }); + + it('throws an error when the target does not have any primary key', () => { + class Product extends Entity { + static definition = new ModelDefinition('Product').addProperty( + 'categoryId', + {type: Number}, + ); + } + + class Category extends Entity { + static definition = new ModelDefinition('Category'); + } + + const productRepo = createStubInstance(DefaultCrudRepository); + const categoryRepo = createStubInstance(DefaultCrudRepository); + + const relationMeta: BelongsToDefinition = { + type: RelationType.belongsTo, + name: 'category', + source: Product, + target: () => Category, + keyFrom: 'categoryId', + // Let the relation to look up keyTo as the primary key of Category + // (which is not defined!) + keyTo: undefined, + }; + + expect(() => + createBelongsToAccessor( + relationMeta, + Getter.fromValue(categoryRepo), + productRepo, + ), + ).to.throw(/Category does not have any primary key/); + }); + + /*------------- HELPERS ---------------*/ + + class Customer extends Entity { + static definition = new ModelDefinition('Customer').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + class Company extends Entity { + static definition = new ModelDefinition('Company').addProperty('id', { + type: Number, + id: true, + }); + id: number; + } + + class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Customer, dataSource); + } + } + + class CompanyRepository extends DefaultCrudRepository< + Company, + typeof Company.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Company, dataSource); + } + } + + function givenStubbedCustomerRepo() { + customerRepo = createStubInstance(CustomerRepository); + } + + function givenStubbedCompanyRepo() { + customerRepo = createStubInstance(CompanyRepository); + } + + function givenBelongsToDefinition( + props?: Partial, + ): BelongsToDefinition { + const defaults: BelongsToDefinition = { + type: RelationType.belongsTo, + name: 'company', + source: Company, + target: () => Customer, + keyFrom: 'customerId', + }; + + return Object.assign(defaults, props); + } +}); diff --git a/packages/repository/test/unit/repositories/relation.factory.unit.ts b/packages/repository/test/unit/repositories/has-many-repository-factory.unit.ts similarity index 100% rename from packages/repository/test/unit/repositories/relation.factory.unit.ts rename to packages/repository/test/unit/repositories/has-many-repository-factory.unit.ts