Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inclusion of related models [MVP] #1352

Closed
16 of 20 tasks
dhmlau opened this issue May 24, 2018 · 50 comments
Closed
16 of 20 tasks

Inclusion of related models [MVP] #1352

dhmlau opened this issue May 24, 2018 · 50 comments
Assignees

Comments

@dhmlau
Copy link
Member

dhmlau commented May 24, 2018

Description / Steps to reproduce / Feature proposal

Follow up task for PR #1342

Inclusion of related models (same as LB3)
For example, Customer model has {include: ['order']}. When query on Customer, the result should includes the Order array.

Duplicates:

Follow-up stories:

Acceptance Criteria

MVP scope

2019Q3

2019Q4

Out of MVP scope

See #3585 for the full list.

@bajtos
Copy link
Member

bajtos commented Oct 26, 2018

I added the following acceptance criteria:

  • Modify the implementation of the following DefaultCrudRepository methods to correctly support inclusion of related models as configured via filter.include property.
    • find
    • findOne
    • findById
  • Include integration-level tests using in-memory database to verify the implementation.
  • Add new acceptance-level tests for "find" method to verify how to send a REST request to find models including related models. Because the REST API is generated by our CLI, I think these tests should be implemented in the todo-list example repository.

I think we need a spike to determine how to actually implement relation traversal, because right now, a repository for source model does not necessarily know how to obtain an instance of a repository for accessing the target model.

/cc @dhmlau

@dhmlau
Copy link
Member Author

dhmlau commented Oct 31, 2018

@bajtos , I've created the spike #1952. I've copied and pasted part of the acceptance criteria over there. Could you please review? Thanks.

@dhmlau dhmlau added Relations Model relations (has many, etc.) and removed needs grooming labels Oct 31, 2018
@raymondfeng
Copy link
Contributor

raymondfeng commented Oct 31, 2018

Cross-posting from #1939.

I realized this PR is just a starting point to fully support include. There are a few issues:

@mrbatista
Copy link
Contributor

@dhmlau The inclusion of related models should work with belongsTo relation like LB3.

@bajtos
Copy link
Member

bajtos commented Nov 6, 2018

I was thinking about this problem and perhaps we can implement this at LB4 Repository level?

Let's say "Customer" has many "Order" instances, the relation is called "orders" and we want to fetch a customer with their orders.

The way how inclusion works in juggler and LB 3.x, such request creates multiple queries:

  1. First, Customer instances are fetched the usual way.
  2. Secondly, we process "filter.include" entries and load related models - see lib/include.js. The relation name (e.g. "orders") is provided by the caller in filter.include and juggler uses the relation name to look up the relation definition, the target model, etc.

For LoopBack 4, I am thinking about making the relation lookup more explicit and letting the Repository to specify a lookup table for inclusion. Similarly to how we explicitly build HasManyRepositoryFactory and BelongsToAccessor now.

A mock-up usage to illustrate what I mean:

export class TodoListRepository extends DefaultCrudRepository<
  TodoList,
  typeof TodoList.prototype.id
> {
  public readonly todos: HasManyRepositoryFactory<
    Todo,
    typeof TodoList.prototype.id
  >;

  constructor(
    @inject('datasources.db') dataSource: juggler.DataSource,
    @repository.getter(TodoRepository)
    protected todoRepositoryGetter: Getter<TodoRepository>,
  ) {
    super(TodoList, dataSource);
    this.todos = this._createHasManyRepositoryFactoryFor(
      'todos',
      todoRepositoryGetter,
    );
   ////// THE FOLLOWING LINE IS NEWLY ADDED
   this._registerHasManyInclusion('todos', todoRepositoryGetter);
  }
}

export class TodoRepository extends DefaultCrudRepository<
  Todo,
  typeof Todo.prototype.id
> {
  public readonly todoList: BelongsToAccessor<
    TodoList,
    typeof Todo.prototype.id
  >;

  constructor(
    @inject('datasources.db') dataSource: juggler.DataSource,
    @repository.getter('TodoListRepository')
    protected todoListRepositoryGetter: Getter<TodoListRepository>,
  ) {
    super(Todo, dataSource);

    this.todoList = this._createBelongsToAccessorFor(
      'todoList',
      todoListRepositoryGetter,
    );
   ////// THE FOLLOWING LINE IS NEWLY ADDED
    this._registerBelongsToInclusion('todoList', todoListRepositoryGetter);
  }
}

Implementation-wise, the base Repository implementation should process include inside the find method. A mock-up:

class DefaultCrudRepository {
  // ...
  _inclusions: {[key: string]: InclusionHandler};

  async find(filter?: Filter<T>, options?: Options): Promise<T[]> {
    const include = filter.include;
    filter = Object.assign({}, filter, {include: undefined});

    const models = await ensurePromise(
      this.modelClass.find(filter as legacy.Filter, options),
    );
    const entities = this.toEntities(models);

    for (relationName of include) {
      const handler = this._inclusions[relationName];
      if (!(handler)) throw new HttpErrors.BadRequest('Invalid inclusion.');
      await handler.fetchIncludedModels(entities, key);
    }
  }

  _registerHasManyInclusion(relationName, targetRepoGetter) {
    const meta = this.entityClass.definition.relations[relationName];
    this._inclusions[relationName] = new HasManyInclusionHandler(meta, targetRepoGetter);
  }
}

class HasManyInclusionHandler {
  constructor(public relation, public repositoryGetter) {}

  fetchIncludedModels(entities, relationName) {
    // see https://github.com/strongloop/loopback-datasource-juggler/blob/f0a6bd146b7ef2f987fd974ffdb5906cf6a584db/lib/include.js#L609-L634
   const objIdMap2 = includeUtils.buildOneToOneIdentityMapWithOrigKeys(entities, this.relation.keyFrom);

    const filter = {};
    filter.where[relation.keyTo] = {
      inq: uniq(objIdMap2.getKeys()),
    };
  
    const targets = findWithForeignKeysByPage(
      await this.repositoryGetter(), filter, this.relation.keyTo, 0);
   const targetsIdMap = includeUtils.buildOneToManyIdentityMapWithOrigKeys(
      targets, this.relation.keyTo);
    // store targets to entities, e.g. 
    // set entities[0].orders to the target with "customerId=entities[0].id"
  }

@samarpanB
Copy link
Contributor

Hello @bajtos , what's the update on this ? When can we expect this feature in LB4. We are using LB4 since its GA, but this missing feature is making us to not to move to production. Majorly due to performance issues. In order to fetch related models, we have to query other repositories manually to fetch data which in case of find method is too many DB hits.
We really need this.

@cbullokles
Copy link

Is this issue still there? I'm trying to execute a code similar to the todo-list tutorial and it is not working... using MySQL...

@sestienne
Copy link

Hi,

Do you know how to do a where filter on data retrieved by the inclusion resolver ?
For exemple : Author(id, name) Book(id, title, extract, category, authorId)
/authors?filter[include][0][relation]=books&filter[where][books][category]=SF
/authors?filter[include][0][relation]=books&filter[where][books][][category]=SF
/authors?filter[include][0][relation]=books&filter[where][books.authorId][category]=SF
/authors?filter[include][0][relation]=books&filter[where][books.authorId][][category]=SF
No one of them works
Thanks

@agnes512
Copy link
Contributor

agnes512 commented Oct 8, 2019

@sestienne Hi!

Do you know how to do a where filter on data retrieved by the inclusion resolver ?

Here is an example for including a todoList instance of a todo that has id = 1 via belongsTo relation.

http://127.0.0.1:3000/todos/1?filter[include][][relation]=todoList

does inclusion resolver working in acceptance tests?

Yes! We've tested it over SQL and NoSQL databases. You can check the Querying related models section in each relation for the usages on our site. You can also check out the acceptance test if you're interested.

@sestienne
Copy link

@agnes512 Thanks a lot for your response.
I finally success to test inclusion resolver in acceptance tests.
By the way, I success with something like /authors?filter[include][][relation]=books or /authors/1?filter[include][][relation]=books, but whith a where filter it doesn't work.
I will try to test with todolist example with this request :
http://127.0.0.1:3000/todos?filter[include][][relation]=todoList&filter[where][todos][][isComplete]=false

@sestienne
Copy link

@agnes512 So, I launch the todolist example and try request like :
http://127.0.0.1:3000/todo-lists?filter[include][][relation]=todos&filter[where][todos][][desc]=MWAHAHAHAHAHAHAHAHAHAHAHAHAMWAHAHAHAHAHAHAHAHAHAHAHAHA
But it doesn't work. What's wrong ?

@agnes512
Copy link
Contributor

agnes512 commented Oct 8, 2019

@sestienne sorry I didn't realize that you were trying to filter out todos.

In url GET /todo-lists?filter[where][id]=1&filter[include].., the where filter only filters out todo-list. It's the where filter inside of scope of include filters out todos.

{
  "where": {// filters todo-list},
  "include": [
    {
      "relation": "string",
      "scope": {
        "where": { // filters todo },
       ...
      }
    }
  ]
}

Unfortunately we don't support inclusion with custom scope at the moment.

@sestienne
Copy link

sestienne commented Oct 8, 2019

OK thanks, do you know any planned date for this feature ?

@agnes512
Copy link
Contributor

agnes512 commented Oct 8, 2019

The task #3453 is out of the scope of this MVP. So it probably won't be up that soon.

@pratikjaiswal15
Copy link

https://stackoverflow.com/q/58742689/7628381
Can anyone please tell how to add inclusion resolvers

@agnes512
Copy link
Contributor

agnes512 commented Nov 7, 2019

@pratikjaiswal15 Hi, I believe that the code you showed in the SO has already setup the inclusion resolver for the relation retailers ( I assume the rest of code are correct).
You should be able to use it at the repository level . For example, if you want to find a Retailer that has id=1, you can do

let result = await someClassRepository.findById(1, {include: [{relation: 'retailers'}]});

If you'd like to do CRUD operations at the repository level, here are some examples of usage of inclusion resolver in our test case.

If you'd like to use it with REST endpoints, set up the controller as usual, and the url

 GET http://localhost:3000/someClasses/1?filter[include][][relation]=retailers

should give you the same result as what it gets at the repository level. Reference: inclusion resolver blog.

I've update the corresponding docs in #4007. But it seems like the site hasn't updated yet.

Let me know if you need more help, thanks.

@pratikjaiswal15
Copy link

pratikjaiswal15 commented Nov 7, 2019

Thank you for the response. I have just updated loopback-cli version from 1.21.4 to 1.24.0. So for new project inclusion resolver is working fine but for the old project which built with 1.21.4 version is giving error following line

this.registerInclusionResolver('retailers', this.retailers.inclusionResolver);
and also for every command with such as lb4 model etc it is givivng error as folllows


`The project was originally generated by @loopback/cli@1.21.4.
The following dependencies are incompatible with @loopback/cli@1.24.0:
- typescript: ~3.5.3 (cli ~3.6.4)
? Continue to run the command? (y/N)`

There are some issues with typescript. How to resolve it . thank you

@agnes512
Copy link
Contributor

agnes512 commented Nov 8, 2019

@pratikjaiswal15 the error is because he older project is generated by 1.21.4 and so is the app dependencies.

If you want to use the latest cli, you need to upgrade dependencies to match the newer CLI ( you can force it or upgrade deps to versions matching cli 1.24.0. Command lb4 -v shows a list of compatible versions).
Ref: Upgrading LoopBack Dependencies

After deps upgrade, you might have to fix the project manually for any errors.

@pratikjaiswal15
Copy link

pratikjaiswal15 commented Nov 12, 2019

Thank you for your reply. Inclusion resolver with has many relations is working pretty well. But with belongs to the relation there are some conservations. In my example, the retailer belongs to users.

http://[::1]:3000/retailers?filter[include][][relation]=users&filter[where][retailer_id]=1
This extension is working and including both tables users and retailers

http://[::1]:3000/retailers/1?filter[include][][relation]=users&filter[where][retailer_id]=1

`But this extension is not working. It is simply giving retailer data with id = 1 and not any user data.

@pratikjaiswal15
Copy link

pratikjaiswal15 commented Nov 12, 2019

And another question is how to include three or more models because
http://[::1]:3000/users?filter[include][][relation]=retailers&filter[include][][relation]=hotels

This extension is giving internal server error.
Thank you in advance

@pratikjaiswal15
Copy link

Sir, does loopback's current version supports inclusion of three models where a model has has many relation with other two? If not then what is alternative.

@agnes512
Copy link
Contributor

agnes512 commented Nov 12, 2019

@pratikjaiswal15
For question 1, you need to replace the endpoint @get('/todo-lists/{id}', in your Retailer controller to:

  @get('/retailers/{id}', {
    ...
    ...
  async findById(
    @param.path.number('id') id: number,
    @param.query.object('filter', getFilterSchemaFor(Retailer))
    filter?: Filter<Retailer>,
  ): Promise<Retailer> {
    return this.todoListRepository.findById(id, filter);
  }

As for question 2, yes it is possible to include multiple relations.
with controllers, for your example, use the url: ( the include here is an array)

http://[::1]:3000/users?filter[include][0][relation]=retailers&filter[include][1][relation]= hotels

with repositories, you can do:

const result = await userRepo.find({
        include: [{relation: 'retails'}, {relation: 'hotels'}],
      });

Notice that you need to make sure all relation names are unique.

And thanks for your comments. They are good suggestions of improving our docs!

@pratikjaiswal15
Copy link

Thank you very much. Both solutions working.

@muhrifai7
Copy link

muhrifai7 commented Nov 15, 2019

@raymondfeng agnes512

hello sir,can you help me, i new in loopback 4 and i stiil get stuck, how to include three or more models :
Page,Sections,Content
and this is my code for pageController

`@get('/pages/{id}', { 
  async findById(
    @param.path.number('id') id: number,
    @param.query.object('filter', getFilterSchemaFor(Page))filter?: Filter<Page>,
  ): Promise<any> {
      return this.pageRepository.findById(
         id, 
        { include: [{relation: 'sections'}, {relation: 'content'}]}
    );
  }
}

i just have relation for sections
i want to include section and content too

thanks `

@agnes512
Copy link
Contributor

@muhrifai7 have you tried the solution that I post above? i.e, modify your GET /pages/{id} endpoint to

  @get('/pages/{id}', {
    responses: {
      '200': {
        description: 'Page model instance',
        content: {
          'application/json': {
            schema: getModelSchemaRef(Page, { includeRelations: true }),
          },
        },
      },
    },
  })
  async findById(
    @param.path.number('id') id: number,
    @param.query.object('filter', getFilterSchemaFor(Page)) filter?: Filter<Page>
  ): Promise<Page> {
    return this.pageRepository.findById(id, filter);
  }

Usually we don't set the inclusion in the endpoint. We hand it to the filter to take care to make the endpoint flexible.

For example, with the code above, you can include sections relation only with http request:

http://[::1]:3000/pages?filter[include][][relation]=sections

You can also include multiple relations with request:

http://[::1]:3000/pages?filter[include][0][relation]=sections&filter[include][1][relation]= content

@muhrifai7
Copy link

muhrifai7 commented Nov 16, 2019

@muhrifai7 have you tried the solution that I post above? i.e, modify your GET /pages/{id} endpoint to

  @get('/pages/{id}', {
    responses: {
      '200': {
        description: 'Page model instance',
        content: {
          'application/json': {
            schema: getModelSchemaRef(Page, { includeRelations: true }),
          },
        },
      },
    },
  })
  async findById(
    @param.path.number('id') id: number,
    @param.query.object('filter', getFilterSchemaFor(Page)) filter?: Filter<Page>
  ): Promise<Page> {
    return this.pageRepository.findById(id, filter);
  }

Usually we don't set the inclusion in the endpoint. We hand it to the filter to take care to make the endpoint flexible.

For example, with the code above, you can include sections relation only with http request:

http://[::1]:3000/pages?filter[include][][relation]=sections

You can also include multiple relations with request:

http://[::1]:3000/pages?filter[include][0][relation]=sections&filter[include][1][relation]= content

thanks for your response sir,

it still doesnt works, because the relation is Page hasMany Section hasMany Content, so i want to retrive the data like this :

[
   {
      "id": 1,
     "name": "LandingPage",
     "sections": [
          {
              "id": 2,
             "pageId": 1,
             "name": "string",
             "header": "string",
             "sub_header": "string",
              “contents” : [
                 {
                    “id”:1,
                  “sectionId”: 2
                }
        ]
       }
    ]
  }
]

not like this

{
       "id": 1,
       "name": "LandingPage",
       "sections": [
          {
             "id": 2,
             "pageId": 1,
             "name": "string",
             "header": "string",
             "sub_header": "string",
           }
      ],
      “contents” : [
   {
           “id”:1,
         “sectionId”: 2
     }
  ]
}

@agnes512
Copy link
Contributor

@muhrifai7 I see. So you would like to traverse nested relations not multiple relations. Currently we don't support this feature yet. See #3453. Feel free to join the discussion there :D

@KyDenZ
Copy link

KyDenZ commented Nov 22, 2019

Hi,

Is it possible to replace the optional attribute with a required attribute for relationships included in openApi? And the Id, he returned by the Api

@hasMany(() => Role)
roles: Role[];
@get('/users', {
    responses: {
      '200': {
        description: 'Array of Users model instances',
        content: {
          'application/json': {
            schema: {
              type: 'array',
              items: getModelSchemaRef(User, {includeRelations: true}),
            },
          },
        },
      },
    },
  })

OpenApi.json

"UserWithRelations": {
  "title": "UserWithRelations",
  "description": "(Schema options: { includeRelations: true })",
  "properties": {
    "id": {
      "type": "number"
     },
     "name": {
       "type": "string"
      },
      "roles": {
         "type": "array",
         "items": {
            "$ref": "#/components/schemas/RoleWithRelations"
          }
       }
   },
  "required": [
     "name",
   ],
  "additionalProperties": false
},

To avoid in my client to make conditions everywhere to know if "roles" is not undefined, while in any case an empty array will be returned if there is no relation.

OpenApi :

/**
  * 
  * @type {Array<RoleWithRelations>}
  * @memberof UserWithRelations
*/
roles?: Array<RoleWithRelations>;

thanks

@jannyHou
Copy link
Contributor

jannyHou commented Nov 22, 2019

@KyDenZ If I understand correctly, your expected schema with related items is

"UserWithRelations": {
  "title": "UserWithRelations",
  "description": "(Schema options: { includeRelations: true })",
  "properties": {
    "id": {
      "type": "number"
     },
     "name": {
       "type": "string"
      },
      "roles": {
         "type": "array",
         "items": {
            "$ref": "#/components/schemas/RoleWithRelations"
          }
       }
   },
  // DIFFERENCE
  // `roles` should be added to `required`
  "required": [
     "name", "roles"
   ],
  "additionalProperties": false
},

?

@KyDenZ
Copy link

KyDenZ commented Nov 23, 2019

That's exactly, with the Id also in required, but that it is generated automatically

@bajtos
Copy link
Member

bajtos commented Nov 25, 2019

Is it possible to replace the optional attribute with a required attribute for relationships included in openApi?

At the moment, the schema describes the responses that can be returned by the server. If we modify the schema and make a navigational property like roles required, then we also have to ensure that we always fetch that relation when querying models. Otherwise there will be a mismatch between the schema and the actual response body.

Please note that by default, LoopBack does not include any related models. Assuming User has-many Role instance: if you call GET /users, we return only user data, no roles. If UserWithRelations sets roles as a required property, then some part of the app must ensure that GET /users is eventually converted to database query (LoopBack filter) {include:[{relation: 'roles'}]}.

How do you envision to implement this part?


Using the current features provided by LoopBack, my recommendation is to use function composition to apply any tweaks to the model schema produced by LoopBack.

For example, you can write a function like this:

function withRequiredProps(propNames: string[], schemaRef: SchemaRef) {
  const result = _.deepClone(schemaRef);
  const title = schemaRef.$ref.match(/[^/]+$/)[0];
  const modelSchema = result.components.schemas[title];
  if (!modelSchema.required)
    modelSchema.required = [];
  modelSchema.required.push(...propNames);
  return result;
}

Then you can use it as follows:

@get('/users', {
    responses: {
      '200': {
        description: 'Array of Users model instances',
        content: {
          'application/json': {
            schema: {
              type: 'array',
              // LOOK HERE
              items: withRequiredProps(
                ['roles'],
                getModelSchemaRef(User, {includeRelations: true}),
              ),
            },
          },
        },
      },
    },
  })

@KyDenZ
Copy link

KyDenZ commented Nov 26, 2019

@bajtos
Great, thank you !

If it interests someone I just had to change some elements for it to work:

import { SchemaRef } from '@loopback/rest';
import * as _ from 'lodash';

export function withRequiredProps(propNames: string[], schemaRef: SchemaRef) {
  const result = _.cloneDeep(schemaRef); // Replace deepClone by cloneDeep
  const title = schemaRef.$ref.match(/[^/]+$/)![0]; // Add !
  const modelSchema = result.definitions[title]; // Replace components.schemas by definitions
  if (!modelSchema.required) modelSchema.required = [];
  modelSchema.required.push(...propNames);
  return result;
}

It would be interesting to propose as in loopback 3 a "scope" attribute which allows to automatically include relations. This makes it possible to generate a schema with the required relationships for all requests.

For a specific route, I like your solution. This function should be added in @ loopback/rest and later supported nested includes, when loopback will support this feature.

@dhmlau
Copy link
Member Author

dhmlau commented Dec 4, 2019

This epic is done! Thanks @agnes512 @nabdelgadir @bajtos!
Closing this issue as done. If there's any outstanding discussion, please feel free to open a new issue. Thanks.

@dhmlau dhmlau closed this as completed Dec 4, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests