node.jspostgresqlsequelize.jsfeathersjsvorpal.js

Sequelize and Feathers: When Relationships Fall Apart


After two days of trying to figure out why my Sequelize models aren't committed to their relationships, I've decided it was time to ask y'all for advice.

Here's the story.

I'm writing a Feathers JS app using a Postgres (9.4) database with Sequelize as the driver. I ran through the setup in the Feathers Docs, and with some coaxing, I got my migrations to run.

From what I understand, special consideration must be taken to get two-way relations working with Sequelize because if ModelA references ModelB, ModelB must be defined already, but if ModelB references ModelA...well, we run into a dependency loop.

It's because of that dependency loop that the docs say to "define your models using the method described here." (Okay, technically it just "assumes" such a structure is used. Also, I can only post 2 links, otherwise I'd link that sucker. Sorry about that.) I've found the same structure in a Feathers demo.

Naturally, I mirrored all of that (unless I'm missing a small-but-important detail, of course), but...still no dice.

Here's what I'm looking at:

Migrations

migrations/create-accounts.js

'use strict';

module.exports = {
  up: function (queryInterface, Sequelize) {
    // Make the accounts table if it doesn't already exist.
    // "If it doesn't already exist" because we have the previous migrations
    //  from Laravel.
    return queryInterface.showAllTables().then(function(tableNames) {
      if (tableNames.accounts === undefined) {
        queryInterface.createTable('accounts', {
          // Field definitions here
          id: {
            type: Sequelize.INTEGER,
            primaryKey: true,
            autoIncrement: true
          },
          name: Sequelize.STRING,
          url_name: Sequelize.STRING,
          createdAt: {
            type: Sequelize.DATE,
            allowNull: false
          },
          updatedAt: {
            type: Sequelize.DATE,
            allowNull: false
          },
          deletedAt: Sequelize.DATE
        });
      }
    });

    // See the create-user migration for an explanation of why I
    //  commented out the above code.
  },

  down: function (queryInterface, Sequelize) {
    return queryInterface.dropTable('accounts');
  }
};

migrations/create-users.js

'use strict';

module.exports = {
  up: function (queryInterface, Sequelize) {
    return queryInterface.showAllTables().then(function(tableNames) {
      if (tableNames.users === undefined) {
        queryInterface.createTable('users', {
          id: {
            type: Sequelize.INTEGER,
            primaryKey: true,
            autoIncrement: true
          },
          accountId: {
            type: Sequelize.INTEGER,
            references: {
              model: 'accounts',
              key: 'id'
            },
            allowNull: false
          },
          email: {
            type: Sequelize.STRING,
            allowNull: false
          },
          [...]
        });
      }
    });
  },

  down: function (queryInterface, Sequelize) {
    return queryInterface.dropTable('users');
  }
};

psql

I then fired up psql to see if the references were right:

databaseName=# \d accounts:

Referenced by:
    TABLE "users" CONSTRAINT "users_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES accounts(id)

databaseName=# \d users:

Foreign-key constraints:
    "users_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES accounts(id)

So far so good, right?

Let's look at the models segment of this program!

Models

src/models/account.js

'use strict';

// account-model.js - A sequelize model
//
// See http://docs.sequelizejs.com/en/latest/docs/models-definition/
// for more of what you can do here.

const Sequelize = require('sequelize');

module.exports = function(app) {
  // We assume we're being called from app.configure();
  // If we're not, though, we need to be passed the app instance.
  // Fair warning: I added this bit myself, so it's suspect.
  if (app === undefined)
    app = this;
  const sequelize = app.get('sequelize');

  // The rest of this is taken pretty much verbatim from the examples
  const account = sequelize.define('account', {
    id: {
      type: Sequelize.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },
    name: Sequelize.STRING,
    url_name: Sequelize.STRING,
  }, {
    paranoid: true,
    timestamps: true,

    classMethods: {
      associate() {
        const models = app.get('models');
        this.hasMany(models['user'], {});
      }
    }
  });

  return account;
};

src/models/user.js

'use strict';

// user-model.js - A sequelize model
//
// See http://docs.sequelizejs.com/en/latest/docs/models-definition/
// for more of what you can do here.

const Sequelize = require('sequelize');

module.exports = function(app) {
  // We assume we're being called from app.configure();
  // If we're not, though, we need to be passed the app instance
  if (app === undefined)
    app = this;
  const sequelize = app.get('sequelize');

  const user = sequelize.define('user', {
    id: {
      type: Sequelize.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },
    accountId: {
      type: Sequelize.INTEGER,
      references: {
        model: 'accounts', // Table name...is that right? Made the migration work...
        key: 'id'
      }
    },
    email: Sequelize.STRING,
    [... curtailed for brevity ...]
  }, {
    // Are these necessary here, or just when defining the model to make a
    //  psuedo-migration?
    paranoid: true, // soft deletes
    timestamps: true,

    classMethods: {
      associate() {
        const models = app.get('models');
        // This outputs like I'd expect:
        // Just to be sure...From the user model, models["account"]: account
        console.log('Just to be sure...From the user model, models["account"]:', models['account']);
        this.belongsTo(models['account'], {});
      }
    }
  });

  return user;
};

src/models/index.js

// I blatantly ripped this from both the following:
// https://github.com/feathersjs/generator-feathers/issues/94#issuecomment-204165134
// https://github.com/feathersjs/feathers-demos/blob/master/examples/migrations/sequelize/src/models/index.js

const Sequelize = require('sequelize');
const _ = require('lodash');

// Import the models
const account = require('./account');
const user = require('./user');

module.exports = function () {
  const app = this;

  // Note: 'postgres' is found in config/default.json as the db url
  const sequelize = new Sequelize(app.get('postgres'), {
    dialect: app.get('db_dialect'),
    logging: console.log
  });
  app.set('sequelize', sequelize);

  // Configure the models
  app.configure(account);
  app.configure(user);

  app.set('models', sequelize.models);

  // Set associations
  Object.keys(sequelize.models).forEach(modelName => {
    if ('associate' in sequelize.models[modelName]) {
      sequelize.models[modelName].associate();
    }
  });

  sequelize.sync();

  // Extra credit: Check to make sure the two instances of sequelize.models are the same...
  // Outputs: sequelize.models after sync === app.get("models")
  // I've also run this comparison on sequelize and app.get('sequelize'); _.eq() said they also were identical
  if (_.eq(sequelize.models, app.get('models')))
    console.log('sequelize.models after sync === app.get("models")');
  else
    console.log('sequelize.models after sync !== app.get("models")');
};

Pulling it Together

src/app.js

Cutting a lot out of it for brevity, I load the models in app like so:

const models = require('./models')
app.use(compress())
  // Lots of other statements
  .configure(models);

Testing

I've been trying to make a command line utility for changing passwords, modifying user permissions and other utility tasks, so I've taken up Vorpal (again, only 2 links, so you'll have to look it up yourself if you're not familiar--sorry). The following is the relevant snippet of my Vorpal program:

cli.js

const vorpal = require('vorpal')();
const _ = require('lodash');

// Initialize app
// This seems a bit overkill since we don't need the server bit for this, but...
const app = require('./src/app');
const models = app.get('models');

// Get the models for easy access...
const User = models['user'];
const Account = models['account'];

// Run by issuing the command: node cli test
// Outputs to terminal
vorpal.command('test', 'A playground for testing the Vorpal environment.')
  .action(function(args, callback) {
    // User.belongsTo(Account); // <-- uncomment this and it works
    User.findOne({ include: [{ model: Account }]}).then((user) => {
      console.log("user.account.name:", user.account.name);
    });
  });

vorpal.show().parse(process.argv);

The Problem

Sorry it's taken so long to get here, but I don't know which part of this is the relevant part, so I had to vomit it all up.

Running node cli test gives me an error

Just to be sure...From the user model, models["account"]: account
sequelize.models after sync === app.get("models")
connect: 
Unhandled rejection Error: account is not associated to user!
    at validateIncludedElement (/vagrant/node_modules/sequelize/lib/model.js:550:11)
    at /vagrant/node_modules/sequelize/lib/model.js:432:29
    at Array.map (native)
    at validateIncludedElements (/vagrant/node_modules/sequelize/lib/model.js:428:37)
    at .<anonymous> (/vagrant/node_modules/sequelize/lib/model.js:1364:32)
    at tryCatcher (/vagrant/node_modules/bluebird/js/release/util.js:16:23)
    at Promise._settlePromiseFromHandler (/vagrant/node_modules/bluebird/js/release/promise.js:504:31)
    at Promise._settlePromise (/vagrant/node_modules/bluebird/js/release/promise.js:561:18)
    at Promise._settlePromise0 (/vagrant/node_modules/bluebird/js/release/promise.js:606:10)
    at Promise._settlePromises (/vagrant/node_modules/bluebird/js/release/promise.js:685:18)
    at Async._drainQueue (/vagrant/node_modules/bluebird/js/release/async.js:138:16)
    at Async._drainQueues (/vagrant/node_modules/bluebird/js/release/async.js:148:10)
    at Immediate.Async.drainQueues (/vagrant/node_modules/bluebird/js/release/async.js:17:14)
    at runCallback (timers.js:574:20)
    at tryOnImmediate (timers.js:554:5)
    at processImmediate [as _immediateCallback] (timers.js:533:5)

Agh!

If, however, I uncomment the line right above the User.findOne(), it works like a charm.

Why do I have to explicitly set the relation immediately before querying for the relation? Why is the relation (presumably) established in the user model's associate() method not sticking? It's being called--and on the proper model, as far as I can tell. Is it somehow being overridden? Is app, for some bizarre reason, not the same in the user model when it's making the association as it is in cli.js?

I'm really quite baffled. Any help y'all can give is much, much appreciated.


Solution

  • I don't know why this works, but I did get it to work by making the following changes.

    src/models/index.js

    I commented out the following chunk near the end of the exported function:

    Object.keys(sequelize.models).forEach(modelName => {
      if ('associate' in sequelize.models[modelName]) {
        sequelize.models[modelName].associate();
      }
    });
    

    I then moved it into src/relate-models.js:

    src/relate-models.js

    /**
     * This is workaround for relating models.
     * I don't know why it works, but it does.
     *
     * @param app  The initialized app
     */
    module.exports = function(app) {
      const sequelize = app.get('sequelize');
    
      // Copied this from src/models/index.js
      Object.keys(sequelize.models).forEach(modelName => {
        if ('associate' in sequelize.models[modelName]) {
          sequelize.models[modelName].associate();
        }
      });
    }
    

    In src/app.js I called that function and... presto change-o, it worked.

    src/app.js`

    const models = require('./models')
    app.use(compress())
      // Lots of other statements
      .configure(models);
    
    require('./relate-models')(app);
    

    The end. If anybody has an explanation for why doing the exact same thing later would work, please let me know, but for now...It works.