Mongoose schema inheritance and model populate

6.9k Views Asked by At

I have been trying this with the built in inheritance features of mongoose (rather than the extend plugin) but haven't been having much luck so far. This is a simplified example of code I am trying to use which exhibits the same problem. This is based on an expanded version of the mongoose documentation for schema inheritance using discriminators - http://mongoosejs.com/docs/api.html#model_Model.discriminator

var util = require('util');
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/problem');

var Schema = mongoose.Schema;
var ObjectId = Schema.ObjectId;

function BaseSchema() {
  Schema.apply(this, arguments);

  this.add({
    name: String,
    createdAt: Date
  });
}
util.inherits(BaseSchema, Schema);


var BossStatusSchema = new Schema({
  status: String
});
var BossStatus = mongoose.model('BossStatus', BossStatusSchema);

var PersonSchema = new BaseSchema();
var Person = mongoose.model('Person', PersonSchema);

var BossSchema = new BaseSchema({
  department: String,
  bossStatus: {
    type: ObjectId,
    ref: 'BossStatus'
  }
});
var Boss = Person.discriminator('Boss', BossSchema);

Example code to add the documents:

var superBoss = new BossStatus({
  status: 'super'
});
var normalBoss = new BossStatus({
  status: 'normal'
});
var andy = new Person({
  name: 'Andy'
});
var billy = new Boss({
  name: 'Billy',
  bossStatus: superBoss._id
});

var callback = function(err, result) {
  console.dir(err);
  console.dir(result);
};

superBoss.save(callback);
normalBoss.save(callback);
andy.save(callback);
billy.save(callback);

So when finding a record without populate:

Person
.findOne({
  name: 'Billy'
})
.exec(callback);

The result is as expected, the bossStatus refers to an _id from the bossstatuses collection:

null
{ name: 'Billy',
  bossStatus: 52a20ab0185a7f4530000001,
  _id: 52a20ab0185a7f4530000004,
  __v: 0,
  __t: 'Boss' }

When adding the populate call:

Person
.findOne({
  name: 'Billy'
})
.populate('bossStatus')
.exec(callback);

The resulting bossStatus property of the Person result is null:

null
{ name: 'Billy',
  bossStatus: null,
  _id: 52a20ab0185a7f4530000004,
  __v: 0,
  __t: 'Boss' }

EDIT:

Ok I've just put together what is probably a better example of what I'm trying to achieve, the schema structure lends itself more to a relational DB but hopefully makes the problem clearer.

var util = require('util');
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/problem');

var Schema = mongoose.Schema;
var ObjectId = Schema.ObjectId;

function BaseSchema() {
  Schema.apply(this, arguments);

  this.add({
    name: {
      type: String,
      unique: true,
      required: true
    }
  });
}
util.inherits(BaseSchema, Schema);

var DeviceSchema = new BaseSchema();
var LocalDeviceSchema = new BaseSchema({
  driver: {
    type: ObjectId,
    ref: 'Driver'
  }
});
var RemoteDeviceSchema = new BaseSchema({
  networkAddress: {
    type: ObjectId,
    ref: 'NetworkAddress'
  }
});

var DriverSchema = new Schema({
  name: {
    type: String,
    unique: true,
    required: true
  }
});

var NetworkHostSchema = new Schema({
  host: {
    type: String,
    unique: true,
    required: true
  }
});

var NetworkAddressSchema = new Schema({
  networkHost: {
    type: ObjectId,
    ref: 'NetworkHost'
  },
  port: {
    type: Number,
    min: 1,
    max: 65535
  }
});

var Driver = mongoose.model('Driver', DriverSchema);
var NetworkHost = mongoose.model('NetworkHost', NetworkHostSchema);
var NetworkAddress = mongoose.model('NetworkAddress', NetworkAddressSchema);

var Device = mongoose.model('Device', DeviceSchema);
var LocalDevice = Device.discriminator('LocalDevice', LocalDeviceSchema);
var RemoteDevice = Device.discriminator('RemoteDevice', RemoteDeviceSchema);

var networkHost = new NetworkHost({
  host: '192.168.2.1'
});

var networkAddress = new NetworkAddress({
  networkHost: networkHost._id,
  port: 3000
});

var remoteDevice = new RemoteDevice({
  name: 'myRemoteDevice',
  networkAddress: networkAddress._id
});


var driver = new Driver({
  name: 'ftdi'
});

var localDevice = new LocalDevice({
  name: 'myLocalDevice',
  driver: driver._id
});


var callback = function(err, result) {
  if(err) {
    console.log(err);
  }
  console.dir(result);
};

/*
// Uncomment to save documents

networkHost.save(function() {
  networkAddress.save(function() {
    remoteDevice.save(callback);
  });
});

driver.save(function() {
  localDevice.save(callback);
});
*/

var deviceCallback = function(err, device) {
  if(err) {
    console.log(err);
  }
  switch(device.__t) {
    case 'LocalDevice':
      console.log('Would create a local device instance passing populated result');
      break;
    case 'RemoteDevice':
      console.log('Would create a remote device instance passing populated result');
      break;
  }
};

Device
.findOne({name: 'myLocalDevice'})
.populate('driver')
.exec(deviceCallback);

The LocalDevice and RemoteDevice schemas could (and probably would) include other differences.. The switch would for example use a DeviceFactory or something to create the instances. My thinking was it should be possible to search the devices table for a device by 'name' and populate the collection references (if this is the correct terminology?) without having to specify the collection to search in - this was my understanding of the point of schema inheritance - or have I completely misunderstood?

Thanks for replies so far!

2

There are 2 best solutions below

5
On

Looks like a bug. With debugging active, this is what's being shown for the population query:

Mongoose: people.findOne({ name: 'Billy' }) { fields: undefined }
Mongoose: people.find({ _id: { '$in': [ ObjectId("52a221ee639cc03d71000001") ] } }) { fields: undefined }

(the ObjectId shown is the one stored in bossStatus)

So Mongoose is querying the wrong collection (people instead of bossstatuses).

As @regretoverflow pointed out, if you're looking for a boss, use the Boss model and not the Person model.

If you do want to populate bossStatus through the Person model, you can explicitly state a model that needs to be searched for population:

.populate({
  path  : 'bossStatus',
  model : 'BossStatus'
})
// or shorter but less clear:
// .populate('bossStatus', {}, 'BossStatus')

EDIT: (with your Device examples)

driver is part of LocalDeviceSchema, but you're querying the Device model, which has no notion of what driver is and populating driver within the context of a Device instance doesn't make sense to Mongoose.

Another possibility for populating each instance is to do it after you retrieved the document. You already have the deviceCallback function, and this will probably work:

var deviceCallback = function(err, device) {
  if(err) {
    console.log(err);
  }
  switch(device.__t) { // or `device.constructor.modelName`
    case 'LocalDevice':
      device.populate('driver', ...);
      break;
    case 'RemoteDevice':
      device.populate('networkAddress', ...);
      break;
  }
};

The reason is that the document is already cast into the correct model there, something that apparently doesn't happen when you chain populate with the find.

1
On

You are looking for a Boss, not a Person:

Boss
.findOne({
  name: 'Billy'
})
.populate('bossStatus')
.exec(callback);