Loopback: Automatically include relations to every operation.

Created on 13 Feb 2015  路  11Comments  路  Source: strongloop/loopback

It's possible to automatically include relations to response in every operation (create, update, delete, ...) without an explicit "include" filter in url?

e.g.

When I send request:

path: '/api/users/12'
method: DELETE

It should respond with:

{
  "id": "12",
  "groupId": "10",
  "group": {
    "id": "10"
  }
}

I end with this code, but it only include group relation for findAll and findOne methods:

module.exports = function(User) {
  User.afterRemote('*', function(ctx, user, next) {
    User.include(user, 'group', next);
  });
};

Even when I update code to:

module.exports = function(User) {
  User.afterRemote('*', function(ctx, user, next) {
    User.app.models.groups.findOne({id: user.groupId}, function(err, group) {
      user.group = group;
      user.foo = 'bar';
      next();
    });
  });
};

The response is:

{
  "id": "12",
  "foo": "bar", // "foo" was send, but "group" disappear
  "groupId": "10"
}

Thank you.

Most helpful comment

Hey @danielesalvatore. So I changed a few things since this post. First, it seems as though changing the key names of relationships is not possible. If someone can point me in the right direction here I would appreciate it. (@raymondfeng perhaps). However I just added them with the relationship key name preceded by an _. Also I decided (for now to not support PUT/DELETE as there may be times where the related entity is not deleted or updated)

Here is an example:
Mixin: nested-create.js

'use strict';

var Promise = require('bluebird');

module.exports = function (Model, options) {

  Model.observe('before save', function event(ctx, next) {

    if(!!ctx.instance.isNewInstance) {
      return next();
    }

    var records = [];

    Object.keys(Model.relations).forEach(function (related) {
      var key = Model.relations[related].keyTo,
          type = Model.relations[related].type,
          record = ctx.instance[related]();

      if (typeof record !== 'undefined') {
        if(!record[key]) {
          records.push(ctx.instance[related].create(record).then(function (record) {
            ctx.instance.setAttribute('_' + related, record);
          }));
        } else {
          ctx.instance.setAttribute('_' + related, record);
        }
      }
    });

    Promise.all(records).then(function () {
      return next();
    });

  });

};

Model: company.json

{
  "name": "Company",
  "description": "Generic company model",
  "base": "PersistedModel",
  "strict": true,
  "options": {
    "validateUpsert": true,
    "allowEternalTokens": false
  },
  "dataSource": "sql",
  "forceId": true,
  "scope": {
    "include": "address"
  },
  "mixins": {
    "NestedCreate": true
  },
  "properties": {
    "companyId": {
      "type": "string",
      "id": true,
      "defaultFn": "uuidv4",
      "length": 36
    },
    "name": {
      "type": "string",
      "required": true
    },
    "addressId": {
      "type": "string",
      "required": false,
      "length": 36
    }
  },
  "validations": [],
  "relations": {
    "address": {
      "type": "belongsTo",
      "model": "Address",
      "foreignKey": "addressId"
    },
    "phonenumbers": {
      "type": "hasAndBelongsToMany",
      "model": "PhoneNumber",
      "foreignKey": ""
    }
  },
  "acls": [],
  "methods": {}
}

Post: http://0.0.0.0:3000/companies/
Post Body:

{
  "name": "Tim's Company",
  "address": {
    "address1": "127 Main St",
    "address2": "Suite Now Promise 2",
    "city": "Nasville",
    "state": "TN",
    "county": "Davidson",
    "zip": "37215"
  },
  "phonenumbers": [
    {"number": "888-555-1212"},
    {"number": "888-555-1213"},
    {"number": "888-555-1214"},
    {"number": "888-555-1215"}
  ]
}

Response:

{
  "companyId": "ec572afc-4885-4e64-bc61-cea13bdb070a",
  "name": "Tim's Company",
  "addressId": "88b1abc4-f92f-4efe-806f-a24aef280d51",
  "_address": {
    "addressId": "88b1abc4-f92f-4efe-806f-a24aef280d51",
    "address1": "127 Main St",
    "address2": "Suite Now Promise 2",
    "city": "Nasville",
    "state": "TN",
    "county": "Davidson",
    "zip": "37215"
  },
  "_phonenumbers": [
    {
      "phoneNumberId": "a30278b3-b9e6-4d2a-81a8-19ae0605a315",
      "number": "888-555-1212"
    },
    {
      "phoneNumberId": "f8f8a19d-9a1c-48b2-a795-20c14a7834d4",
      "number": "888-555-1213"
    },
    {
      "phoneNumberId": "859f78b0-f4db-419d-bcc2-802d196e032e",
      "number": "888-555-1214"
    },
    {
      "phoneNumberId": "756fc638-ee28-4c25-bb32-16a16e7cf583",
      "number": "888-555-1215"
    }
  ]
}

All 11 comments

Unfortunately, this does not work.

I'm not sure if I get it, but so far I tried:

// user.json
{
  ...
  "scope": {
    "include": "group"
  }
}

but when I for example make POST request:

path: '/'
method: POST
data: {"groupId": "2"}

the "group" field in response is missing.

// actual
{
  "id": "1",
  "groupId": "2"
}

// expected
{
  "id": "1",
  "groupId": "2",
  "group": {
    "id": "2"
  }
}

That's expected. The default scope won't come into the picture for the response of create. But subsequent queries will include the group.

to make it work, we use a remote after create hook where we put the
group relation in user.__data
Same pattern followed by User.login where the user can be returned within
an AccessToken.

@seriousben Thanks, I'll try to attach relation data in remote hook

For anyone else who stumbles here this is the solution I am working on. Currently tested with POST's and belongsTo relationships:

mixin: allownested.js

'use strict';

var Promise = require('bluebird');

module.exports = function (Model, options) {

  Model.observe('before save', function event(ctx, next) { 

    var records = [];

    Object.keys(Model.relations).forEach(function (related) {
      var record = ctx.instance[related]();
      if (typeof record !== 'undefined') {
        records.push(ctx.instance[related].create(record).then(function (record) {
          ctx.instance.set(related, record);          
        }));
      }
    });

    Promise.all(records).then(function () {
      return next();
    });

  });

};

Relevant parts of the model: company.js

"scope": {
    "include": "address"
  },
  "mixins": {
    "Allownested": true
  },
"relations": {
    "address": {
      "type": "belongsTo",
      "model": "Address",
      "foreignKey": "addressId"
    }
  }

I will be extending this mixin to handle belongsTo, hasMany, hasOne. I will also ensure it works across Create, Update, Delete (Get already works like a champ)

Hi @twickstrom thank you for your message. Did you manage to make your code work across Create, Update, Delete? I tried it and it returns the nested models only while fetching entities, in particular the response of a Create returns only the ids of the nested properties. All my relations are "belongsTo"

Hey @danielesalvatore. So I changed a few things since this post. First, it seems as though changing the key names of relationships is not possible. If someone can point me in the right direction here I would appreciate it. (@raymondfeng perhaps). However I just added them with the relationship key name preceded by an _. Also I decided (for now to not support PUT/DELETE as there may be times where the related entity is not deleted or updated)

Here is an example:
Mixin: nested-create.js

'use strict';

var Promise = require('bluebird');

module.exports = function (Model, options) {

  Model.observe('before save', function event(ctx, next) {

    if(!!ctx.instance.isNewInstance) {
      return next();
    }

    var records = [];

    Object.keys(Model.relations).forEach(function (related) {
      var key = Model.relations[related].keyTo,
          type = Model.relations[related].type,
          record = ctx.instance[related]();

      if (typeof record !== 'undefined') {
        if(!record[key]) {
          records.push(ctx.instance[related].create(record).then(function (record) {
            ctx.instance.setAttribute('_' + related, record);
          }));
        } else {
          ctx.instance.setAttribute('_' + related, record);
        }
      }
    });

    Promise.all(records).then(function () {
      return next();
    });

  });

};

Model: company.json

{
  "name": "Company",
  "description": "Generic company model",
  "base": "PersistedModel",
  "strict": true,
  "options": {
    "validateUpsert": true,
    "allowEternalTokens": false
  },
  "dataSource": "sql",
  "forceId": true,
  "scope": {
    "include": "address"
  },
  "mixins": {
    "NestedCreate": true
  },
  "properties": {
    "companyId": {
      "type": "string",
      "id": true,
      "defaultFn": "uuidv4",
      "length": 36
    },
    "name": {
      "type": "string",
      "required": true
    },
    "addressId": {
      "type": "string",
      "required": false,
      "length": 36
    }
  },
  "validations": [],
  "relations": {
    "address": {
      "type": "belongsTo",
      "model": "Address",
      "foreignKey": "addressId"
    },
    "phonenumbers": {
      "type": "hasAndBelongsToMany",
      "model": "PhoneNumber",
      "foreignKey": ""
    }
  },
  "acls": [],
  "methods": {}
}

Post: http://0.0.0.0:3000/companies/
Post Body:

{
  "name": "Tim's Company",
  "address": {
    "address1": "127 Main St",
    "address2": "Suite Now Promise 2",
    "city": "Nasville",
    "state": "TN",
    "county": "Davidson",
    "zip": "37215"
  },
  "phonenumbers": [
    {"number": "888-555-1212"},
    {"number": "888-555-1213"},
    {"number": "888-555-1214"},
    {"number": "888-555-1215"}
  ]
}

Response:

{
  "companyId": "ec572afc-4885-4e64-bc61-cea13bdb070a",
  "name": "Tim's Company",
  "addressId": "88b1abc4-f92f-4efe-806f-a24aef280d51",
  "_address": {
    "addressId": "88b1abc4-f92f-4efe-806f-a24aef280d51",
    "address1": "127 Main St",
    "address2": "Suite Now Promise 2",
    "city": "Nasville",
    "state": "TN",
    "county": "Davidson",
    "zip": "37215"
  },
  "_phonenumbers": [
    {
      "phoneNumberId": "a30278b3-b9e6-4d2a-81a8-19ae0605a315",
      "number": "888-555-1212"
    },
    {
      "phoneNumberId": "f8f8a19d-9a1c-48b2-a795-20c14a7834d4",
      "number": "888-555-1213"
    },
    {
      "phoneNumberId": "859f78b0-f4db-419d-bcc2-802d196e032e",
      "number": "888-555-1214"
    },
    {
      "phoneNumberId": "756fc638-ee28-4c25-bb32-16a16e7cf583",
      "number": "888-555-1215"
    }
  ]
}

@raymondfeng

Can I ask if you know of a way of setting an attribute manually?

Example:

module.exports = function (Model, options) {
  Model.observe('before save', function event(ctx, next) {
    ctx.instance.setAttribute(related, record);
  });
};

This works fine as long as _related_ equals a string that is not a name of a attribute or relationship on a model. If _related_ does equal a string that is an attribute or relationship on a model it is ignored.

I would like to force the inclusion if I can. Alternatively my work around prepends an _ to the related name, I wouldn't mind removing the _ prior to the response but so far have been unsuccessful in my attempts. Any pointers would be appreciated.

@twickstrom - Thanks for your reference code. As suggested by seriousben above you can set the attribute like this -
ctx.instance.__data[related] = record;

Below is the code which we enhanced for create and update of the hasMany and hasOne scenario for our case -

Model.observe('before save', (ctx, next) => {
ctx.hookState.relations = {};
if (ctx.isNewInstance && ctx.instance) {
Object.keys(Model.relations).forEach((related) => {
ctx.hookState.relations[related] = ctx.instance [related] ();
});
} else if (ctx.data) {
Object.keys(Model.relations).forEach((related) => {
ctx.hookState.relations[related] = ctx.data[related];
});
}
next();
});

Model.observe('after save', (ctx, next) => {
const promises = [];

if (ctx.instance) {
  Object.keys(Model.relations).forEach((related) => {
    const data = ctx.hookState.relations[related];
    if (typeof data !== 'undefined') {
      const relatedId = Model.relations[related].modelTo.getIdName();
      let promise = Promise.resolve('ready');
      if (data[relatedId]) {
        promise = ctx.instance[related].update(data);
      } else {
        promise = ctx.instance[related].create(data);
      }
      promise = promise.then((record) => {
        ctx.instance.__data[related] = record;
        return ctx;
      });
      promises.push(promise);
    }
  });
}
Promise.all(promises).then(() => next()).catch(err => next(err));

});

Was this page helpful?
0 / 5 - 0 ratings