diff --git a/lib/deserializer.js b/lib/deserializer.js index 0b22eb2..bc1cceb 100644 --- a/lib/deserializer.js +++ b/lib/deserializer.js @@ -24,14 +24,14 @@ function defaultAfterDeserialize (options, cb) { module.exports = function deserializer (options, cb) { var model = options.model - var beforeDeserialize = (typeof model.beforeJsonApiDeserialize === 'function') ? - model.beforeJsonApiDeserialize : defaultBeforeDeserialize + var beforeDeserialize = (typeof model.beforeJsonApiDeserialize === 'function') + ? model.beforeJsonApiDeserialize : defaultBeforeDeserialize - var deserialize = (typeof model.jsonApiDeserialize === 'function') ? - model.jsonApiDeserialize : defaultDeserialize + var deserialize = (typeof model.jsonApiDeserialize === 'function') + ? model.jsonApiDeserialize : defaultDeserialize - var afterDeserialize = (typeof model.afterJsonApiDeserialize === 'function') ? - model.afterJsonApiDeserialize : defaultAfterDeserialize + var afterDeserialize = (typeof model.afterJsonApiDeserialize === 'function') + ? model.afterJsonApiDeserialize : defaultAfterDeserialize var deserializeOptions = _.cloneDeep(options) diff --git a/lib/relationships.js b/lib/relationships.js index 0d3375c..fb3999f 100644 --- a/lib/relationships.js +++ b/lib/relationships.js @@ -2,6 +2,7 @@ var _ = require('lodash') var utils = require('./utils') +var debug = require('debug')('loopback-component-jsonapi') module.exports = function (app, options) { // get remote methods. @@ -100,6 +101,40 @@ function relationships (id, data, model) { updateRelation(modelTo, idToFind, where) }) + + if (serverRelation.modelThrough) { + var modelThrough = serverRelation.modelThrough + var key = keyByModel(modelThrough, modelTo) + var data = {} + data[fkName] = id + var stringIds = false + + var payloadIds = _.map(relationship.data, function (item) { + if (typeof item.id === 'string') { + stringIds = true + } + return item.id + }) + + modelThrough.find({where: data, fields: key}, function(err, instances) { + if (err) return + var serverIds = _.map(instances, function(instance) { + return stringIds ? instance[key].toString() : instance[key] + }) + // to delete + var toDelete = _.difference(serverIds, payloadIds) + _.each(toDelete, function (id) { + data[key] = id + modelThrough.destroyAll(data) + }) + // new + var newAssocs = _.difference(payloadIds, serverIds) + _.each(newAssocs, function (id) { + data[key] = id + modelThrough.create(data) + }) + }) + } } else { if (relationship.data === null) { where[fkName] = null @@ -132,3 +167,17 @@ function updateRelation (model, id, data) { } }) } + +function keyByModel (assocModel, model) { + var key = null + _.each(assocModel.relations, function (relation) { + if (relation.modelTo.modelName === model.modelName) { + key = relation.keyFrom + } + }) + + if (key === null) { + debug('Can not find relation for ' + model.modelName) + } + return key +} diff --git a/package.json b/package.json index d20e69b..1a1e2d4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "test": "npm run lint && istanbul cover _mocha --report lcovonly --reporter=spec ./test/**/*.test.js && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", "tester": "mocha --reporter=spec ./test/**/*.test.js", "coverage": "istanbul cover _mocha ./test/**/*.test.js", - "lint": "standard .", + "lint": "standard ./test/**/*.js ./lib/**/*.js --verbose | snazzy", "semantic-release": "semantic-release pre && npm publish && semantic-release post" }, "repository": { @@ -32,6 +32,7 @@ "http-status-codes": "^1.0.5", "inflection": "^1.7.2", "lodash": "^4.17.1", + "snazzy": "^7.0.0", "type-is": "^1.6.14" }, "devDependencies": { diff --git a/test/hasManyThroughUpsert.test.js b/test/hasManyThroughUpsert.test.js new file mode 100644 index 0000000..a6e7e88 --- /dev/null +++ b/test/hasManyThroughUpsert.test.js @@ -0,0 +1,205 @@ +/* global describe, beforeEach, it */ + +var request = require('supertest') +var loopback = require('loopback') +var expect = require('chai').expect +var JSONAPIComponent = require('../') +var RSVP = require('rsvp') + +var app +var Movie, Category, MovieCategoryAssoc + +describe('hasManyThrough upsert', function () { + beforeEach(function (done) { + app = loopback() + app.set('legacyExplorer', false) + var ds = loopback.createDataSource('memory') + // create models + Movie = ds.createModel('movie', { + id: { type: Number, id: true }, + name: String + }) + + Category = ds.createModel('category', { + id: { type: Number, id: true }, + name: String + }) + + MovieCategoryAssoc = ds.createModel('movieCategoryAssoc', { + id: { type: Number, id: true } + }) + + // add models + app.model(Movie) + app.model(Category) + app.model(MovieCategoryAssoc) + + // set up relationships + Movie.hasMany(Category, { through: MovieCategoryAssoc }) + Category.hasMany(Movie, { through: MovieCategoryAssoc }) + + MovieCategoryAssoc.belongsTo(Movie) + MovieCategoryAssoc.belongsTo(Category) + makeData() + .then(function () { + done() + }) + .catch(function (err) { + done(err) + }) + app.use(loopback.rest()) + JSONAPIComponent(app, { restApiRoot: '' }) + }) + + it('should make initial data', function (done) { + request(app).get('/movies/1/categories').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.length).to.equal(2) + expect(res.body.data[0].attributes.name).to.equal('Crime') + done(err) + }) + }) + + it('should handle POST', function (done) { + var agent = request(app) + agent + .post('/movies') + .send({ + data: { + type: 'movies', + attributes: { + name: 'Ace Ventura: Pet Detective' + }, + relationships: { + categories: { + data: [{ type: 'categories', id: 4 }] + } + } + } + }) + .end(function () { + agent.get('/movieCategoryAssocs').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.length).to.equal(3) + done() + }) + }) + }) + + it('should handle PATCH', function (done) { + var agent = request(app) + agent + .patch('/movies/1') + .send({ + data: { + id: 1, + type: 'movies', + attributes: { + name: 'The Shawshank Redemption' + }, + relationships: { + categories: { + data: [ + { type: 'categories', id: 1 }, + { type: 'categories', id: 2 }, + { type: 'categories', id: 3 } + ] + } + } + } + }) + .end(function () { + agent.get('/movieCategoryAssocs').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.length).to.equal(3) + done() + }) + }) + }) + + it('should handle string IDs', function (done) { + var agent = request(app) + agent + .patch('/movies/1') + .send({ + data: { + id: '1', + type: 'movies', + attributes: { + name: 'The Shawshank Redemption' + }, + relationships: { + categories: { + data: [{ type: 'categories', id: '1' }, { type: 'categories', id: '4' }] + } + } + } + }) + .end(function () { + agent.get('/movieCategoryAssocs/1').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.id).to.equal('1') + done() + }) + }) + }) + + it('should handle PATCH with less assocs', function (done) { + var agent = request(app) + agent + .patch('/movies/1') + .send({ + data: { + id: 1, + type: 'movies', + attributes: { + name: 'The Shawshank Redemption' + }, + relationships: { + categories: { + data: [{ type: 'categories', id: 1 }, { type: 'categories', id: 4 }] + } + } + } + }) + .end(function (err, res) { + expect(err).to.equal(null) + agent.get('/movieCategoryAssocs').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.length).to.equal(2) + + agent.get('/movies/1/categories').end(function (err, res) { + expect(err).to.equal(null) + expect(res.body.data.length).to.equal(2) + expect(res.body.data[1].attributes.name).to.equal('Comedy') + done() + }) + }) + }) + }) +}) + +function makeData () { + var createMovie = denodeifyCreate(Movie) + var createCategory = denodeifyCreate(Category) + var createAssoc = denodeifyCreate(MovieCategoryAssoc) + + return RSVP.hash({ + movie: createMovie({ name: 'The Shawshank Redemption' }), + categories: RSVP.all([ + createCategory({ name: 'Crime' }), + createCategory({ name: 'Drama' }), + createCategory({ name: 'History' }), + createCategory({ name: 'Comedy' }) + ]) + }).then(function (models) { + return RSVP.all([ + createAssoc({ movieId: models.movie.id, categoryId: models.categories[0].id }), + createAssoc({ movieId: models.movie.id, categoryId: models.categories[2].id }) + ]) + }) + + function denodeifyCreate (Model) { + return RSVP.denodeify(Model.create.bind(Model)) + } +}