Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions examples/todo-list/src/controllers/todo-list.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,17 @@ export class TodoListController {
},
},
})
async findById(@param.path.number('id') id: number): Promise<TodoList> {
return await this.todoListRepository.findById(id);
async findById(
@param.path.number('id') id: number,
@param.query.object('filter') filter?: Filter<TodoList>,
): Promise<TodoList> {
// somehow the filter sent in the request query is undefined
// will dig more.
// hardcoded the inclusion filter in the PoC PR
const hardcodedFilterForPoC = {
include: [{relation: 'todos'}],
};
return await this.todoListRepository.findById(id, hardcodedFilterForPoC);
}

@patch('/todo-lists/{id}', {
Expand Down
6 changes: 5 additions & 1 deletion examples/todo-list/src/controllers/todo.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ export class TodoController {
async findTodoById(
@param.path.number('id') id: number,
@param.query.boolean('items') items?: boolean,
@param.query.object('filter') filter?: Filter<Todo>,
): Promise<Todo> {
return await this.todoRepo.findById(id);
const hardcodedFilterForPoC = {
include: [{relation: 'todoList'}],
};
return await this.todoRepo.findById(id, hardcodedFilterForPoC);
}

@get('/todos', {
Expand Down
4 changes: 4 additions & 0 deletions examples/todo-list/src/repositories/todo-list.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export class TodoListRepository extends DefaultCrudRepository<
'image',
todoListImageRepositoryGetter,
);
this._inclusionHandler.registerHandler<Todo, typeof Todo.prototype.id>(
Copy link
Contributor Author

@jannyHou jannyHou Dec 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From @bajtos

This block of code will be repeated a lot, I'd like to see a simpler solution, e.g. a sugar API building on top of InclusionHandlerFactory.

Ideally, the entire block should collapse to a single statement, e.g.

this._registerInclusion('todos', todoRepositoryGetter);

Under the hood, the helper should look up the definition of todos relation to learn about the target model, keyTo, and any other metadata needed. See how _createHasManyRepositoryFactoryFor is implemented.

Also based on the code in juggler, every relation needs a slightly different implementation of the inclusion handler. Please include an example/acceptance test showing how to include models via belongsTo relation to ensure requirements of other relation types were considered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos

I added the belongsTo handler and it does fetch the parent instance for the child. BUT there is a problem when construct the returned data:

The Todo model only has property todoListId as the foreign key, but not todoList as its parent property. Therefore given the object

const todo = {
    id: 1,
    title: 'a todo item',
    todoListId: 1,
    todoList: {
      id: 1,
      color: red
    }
}

method toEntity(todo) won't convert property todoList and removes it in the constructed todo entity.

'todos',
todoRepositoryGetter,
);
}

public findByTitle(title: string) {
Expand Down
4 changes: 4 additions & 0 deletions examples/todo-list/src/repositories/todo.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,9 @@ export class TodoRepository extends DefaultCrudRepository<
'todoList',
todoListRepositoryGetter,
);
this._inclusionHandler.registerHandler<
TodoList,
typeof TodoList.prototype.id
>('todoList', todoListRepositoryGetter);
}
}
2 changes: 2 additions & 0 deletions packages/repository/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export type Order<MT = AnyObject> = {[P in keyof MT]: Direction};
*/
export type Fields<MT = AnyObject> = {[P in keyof MT]?: boolean};

// The entity type provided for the scope filter is the source model
// while it should be the target(related) model
/**
* Inclusion of related items
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ export function createBelongsToAccessor<
};
}

type BelongsToResolvedDefinition = BelongsToDefinition & {keyTo: string};
export 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) {
export function resolveBelongsToMetadata(relationMeta: BelongsToDefinition) {
if (!isTypeResolver(relationMeta.target)) {
const reason = 'target must be a type resolver';
throw new InvalidRelationError(reason, relationMeta);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ export function createHasManyRepositoryFactory<
};
}

type HasManyResolvedDefinition = HasManyDefinition & {keyTo: string};
export 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(
export function resolveHasManyMetadata(
relationMeta: HasManyDefinition,
): HasManyResolvedDefinition {
if (!isTypeResolver(relationMeta.target)) {
Expand All @@ -73,7 +73,7 @@ function resolveHasManyMetadata(
}

if (relationMeta.keyTo) {
// The explict cast is needed because of a limitation of type inference
// The explicit cast is needed because of a limitation of type inference
return relationMeta as HasManyResolvedDefinition;
}

Expand Down
96 changes: 96 additions & 0 deletions packages/repository/src/repositories/inclusion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {Entity} from '../model';
import {Getter} from '@loopback/core';
import {
Filter,
Where,
resolveHasManyMetadata,
resolveBelongsToMetadata,
RelationMetadata,
RelationType,
constrainWhere,
} from '../';
import {AnyObject} from '..';
import {DefaultCrudRepository} from './legacy-juggler-bridge';
import {inspect} from 'util';
import {
HasManyDefinition,
BelongsToDefinition,
HasManyResolvedDefinition,
BelongsToResolvedDefinition,
} from '../relations';

type ResolvedRelationMetadata =
| HasManyResolvedDefinition
| BelongsToResolvedDefinition;

// SE: the source entity
// TE: the target entity
// SID: the ID of source entity
// TID: the ID of target entity

export class InclusionHandler<SE extends Entity, SID> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name is a bit confusing as InclusionHandler contains a list of _handlers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it should be plural

_handlers: {[relation: string]: Function} = {};
constructor(public sourceRepository: DefaultCrudRepository<SE, SID>) {}

registerHandler<TE extends Entity, TID>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea to delegate resolution of inclusion to a list of functions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can borrow some ideas from https://graphql.org/learn/execution/. It will allow us even define custom relations.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relationName: string,
targetRepoGetter: Getter<DefaultCrudRepository<TE, TID>>,
) {
this._handlers[relationName] = fetchIncludedItems;
const self = this;

async function fetchIncludedItems(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be simplified as:

this._handlers[relationName] = async (
      fks: SID[],
      filter?: Filter<TE>,
    ): Promise<TE[]> => {...}

Copy link
Contributor

@raymondfeng raymondfeng Dec 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should define a type such as:

export type IncludedItemsFetcher<SID, TE> = (
      fks: SID[],
      filter?: Filter<TE>,
    ) => Promise<TE[]>

fks: SID[],
filter?: Filter<TE>,
): Promise<TE[]> {
const targetRepo = await targetRepoGetter();
const relationDef: ResolvedRelationMetadata = self.getResolvedRelationDefinition(
relationName,
);
filter = filter || {};
filter.where = self.buildConstrainedWhere<TE>(
fks,
filter.where || {},
relationDef,
);
console.log(`inclusion filter: ${inspect(filter)}`);

return await targetRepo.find(filter);
}
}

findHandler(relationName: string) {
const errMsg =
`The inclusion handler for relation ${relationName} is not found!` +
`Make sure you defined ${relationName} properly.`;

return this._handlers[relationName] || new Error(errMsg);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's suspicious to return an Error but later on it checks:

const handler = this._inclusionHandler.findHandler(relation);
    if (!handler) {
      throw new Error('Fetch included items is not supported');
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the code here should definitely be refined ^

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's suspicious to return an Error but later on it checks:

const handler = this._inclusionHandler.findHandler(relation);
    if (!handler) {
      throw new Error('Fetch included items is not supported');
    }

}

buildConstrainedWhere<TE extends Entity>(
ids: SID[],
whereFilter: Where<TE>,
relationDef: ResolvedRelationMetadata,
): Where<TE> {
const keyPropName: string = relationDef.keyTo;
const where: AnyObject = {};
where[keyPropName] = {inq: ids};
return constrainWhere(whereFilter, where as Where<TE>);
}

getResolvedRelationDefinition(name: string): ResolvedRelationMetadata {
const relationMetadata: RelationMetadata = this.sourceRepository.entityClass
.definition.relations[name];

switch (relationMetadata.type) {
case RelationType.hasMany:
return resolveHasManyMetadata(relationMetadata as HasManyDefinition);
case RelationType.belongsTo:
return resolveBelongsToMetadata(
relationMetadata as BelongsToDefinition,
);
default:
throw new Error(`Unsupported relation type ${relationMetadata.type}`);
}
}
}
1 change: 1 addition & 0 deletions packages/repository/src/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './legacy-juggler-bridge';
export * from './kv.repository.bridge';
export * from './repository';
export * from './constraint-utils';
export * from './inclusion';
32 changes: 31 additions & 1 deletion packages/repository/src/repositories/legacy-juggler-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
} from '../relations';
import {resolveType} from '../type-resolver';
import {EntityCrudRepository} from './repository';
import * as utils from 'util';
import {InclusionHandler} from './inclusion';

export namespace juggler {
export import DataSource = legacy.DataSource;
Expand Down Expand Up @@ -80,6 +82,7 @@ export function ensurePromise<T>(p: legacy.PromiseOrVoid<T>): Promise<T> {
export class DefaultCrudRepository<T extends Entity, ID>
implements EntityCrudRepository<T, ID> {
modelClass: juggler.PersistedModelClass;
_inclusionHandler: InclusionHandler<T, ID>;

/**
* Constructor of DefaultCrudRepository
Expand All @@ -103,6 +106,7 @@ export class DefaultCrudRepository<T extends Entity, ID>
);

this.setupPersistedModel(definition);
this._inclusionHandler = new InclusionHandler(this);
}

// Create an internal legacy Model attached to the datasource
Expand Down Expand Up @@ -252,13 +256,39 @@ export class DefaultCrudRepository<T extends Entity, ID>
}

async findById(id: ID, filter?: Filter<T>, options?: Options): Promise<T> {
// advanced discussion: cache the related items
const relatedItems = {} as AnyObject;
if (filter && filter.include) {
for (let i of filter.include) {
const results = await this._fetchIncludedItems(
i.relation,
[id],
i.scope,
);
if (results) relatedItems[i.relation] = results;
}
delete filter.include;
}
const model = await ensurePromise(
this.modelClass.findById(id, filter as legacy.Filter, options),
);
if (!model) {
throw new EntityNotFoundError(this.entityClass, id);
}
return this.toEntity(model);
return Object.assign(this.toEntity(model), relatedItems);
}

async _fetchIncludedItems(
relation: string,
ids: ID[],
filter?: Filter<AnyObject>,
) {
const handler = this._inclusionHandler.findHandler(relation);
if (!handler) {
throw new Error('Fetch included items is not supported');
}
const includedItems = await handler(ids, filter);
return includedItems;
}

update(entity: T, options?: Options): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ describe('BelongsTo relation', () => {
expect(result).to.deepEqual(customer);
});

it('can find order includes customer', 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.findOrderIncludesCustomer(order.id);
// The code won't work since Order model doesn't have a property called customer
// expect(result.customer.length).to.equal(1);
// expect(result.customer[0]).to.deepEqual(customer);
});

//--- HELPERS ---//

class OrderController {
Expand All @@ -49,6 +61,10 @@ describe('BelongsTo relation', () => {
async findOwnerOfOrder(orderId: string) {
return await this.orderRepository.customer(orderId);
}
async findOrderIncludesCustomer(orderId: string) {
const inclusionFilter = {include: [{relation: 'customer'}]};
return await this.orderRepository.findById(orderId, inclusionFilter);
}
}

function givenApplicationWithMemoryDB() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ describe('HasMany relation', () => {
expect(persisted).to.deepEqual(foundOrders);
});

it('can find related instances with include filter', async () => {
const order = await controller.createCustomerOrders(existingCustomerId, {
description: 'order 1',
});
const notMyOrder = await controller.createCustomerOrders(
existingCustomerId + 1,
{
description: 'order 2',
},
);
const foundCustomer = await controller.findCustomerIncludesOrders(
existingCustomerId,
);
expect(foundCustomer.orders.length).to.equal(1);
expect(foundCustomer.orders[0]).to.containEql(order);
});

it('can patch many instances', async () => {
await controller.createCustomerOrders(existingCustomerId, {
description: 'order 1',
Expand Down Expand Up @@ -166,6 +183,14 @@ describe('HasMany relation', () => {
async deleteCustomerOrders(customerId: number) {
return await this.customerRepository.orders(customerId).delete();
}

async findCustomerIncludesOrders(customerId: number) {
const inclusionFilter = {include: [{relation: 'orders'}]};
return await this.customerRepository.findById(
customerId,
inclusionFilter,
);
}
}

function givenApplicationWithMemoryDB() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,9 @@ export class CustomerRepository extends DefaultCrudRepository<
'address',
addressRepositoryGetter,
);
this._inclusionHandler.registerHandler<Order, typeof Order.prototype.id>(
'orders',
orderRepositoryGetter,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,9 @@ export class OrderRepository extends DefaultCrudRepository<
'customer',
customerRepositoryGetter,
);
this._inclusionHandler.registerHandler<
Customer,
typeof Customer.prototype.id
>('customer', customerRepositoryGetter);
}
}