Mongoose: Document#$locals

Created on 28 Dec 2018  路  10Comments  路  Source: Automattic/mongoose

Do you want to request a feature or report a bug?
Neither, but potentially a feature

What is the current behavior?
I would like to pass state around that belongs to a document on the document object. I could track those changes in a local variable, but then have to pass the variable to every function that requires that information. or I could create a global variable, but that can be dangerous and I don't want to pollute the global namespace. I also don't want to accidentally override mongoose properties.

What is the suggested method of passing state? Can I _safely_ just create any random property on the document object? I am currently able to get away with creating custom properties on the object, but I cringe a little 馃槃

FYI: I know express.js has a locals property on all response objects where you can store whatever information without polluting the response object. Do mongoose documents provide something or the sort?

Please mention your node.js, mongoose and MongoDB version.
Latest versions

enhancement

Most helpful comment

All 10 comments

@govindrai we do not, we'll add a $locals property in the next minor release. Until then, you're safe creating properties on a document unless it conflicts with any existing properties on Document or Model.

An alternative approach would be to use an ES6 symbol.

const mySecretProperty = Symbol.for('mySecretProperty');

// later
app.getAsync(async (req, res) => {
  const doc = await MyModel.findOne({ _id: req.params.id });
  doc[mySecretProperty] = req.params.other;
  // Can now access `this[mySecretProperty]` in save middleware: https://mongoosejs.com/docs/middleware.html
  await doc.save();
});

Symbols are the way to go if you want to create a property name that is not going to conflict with any other properties.

$locals property would be a great addition. Looking forward to it.


Haven't used Symbols before, but your comment made me do some initial testing with Symbols.

If I did end up using Symbols (for now) I would have to pass the reference holding symbol anywhere I'd need to access the symbol property's value. This mimics the current process of passing around a local variable everywhere and wouldn't provide any advantage (sure the property would exist on the document object itself instead of being a standalone variable (and will be guaranteed to be unique)) but I would still have to have access to the variable referencing the symbol).

Also came across some quirks of symbols (i.e. you can create two symbols with the same id and assign them as two separate properties in the same object resulting in have two identical looking keys (visual nightmare!), also symbol keys are not enumerable (you have to separately call Object.getOwnPropertySymbols and even with that, you get back and array of symbol strings so you couldn't actually access any properties using those results anyways). $locals approach is definitely preferred. 馃槂

image

The fact that you can have two separate symbol properties with the same description is more a feature than a bug. Each symbol is unique, so you're guaranteed to not get any collisions, but I agree it can be a headache for debugging :) FWIW in mongoose's code we have symbol files so instead of passing a symbol around we just require() it in.

The locals property is not working if you set it to object in Model.create(obj).... The Document is overwritten with empty locals (which is what you get in middleware)... I had a look at the mongoose source code, the $locals is not reading the $locals of passed object when using create...

The actual code is

function Document(obj, fields, skipId, options) {
  if (typeof skipId === 'object' && skipId != null) {
    options = skipId;
    skipId = options.skipId;
  }
  options = options || {};

  // Support `browserDocument.js` syntax
  if (this.schema == null) {
    const _schema = utils.isObject(fields) && !fields.instanceOfSchema ?
      new Schema(fields) :
      fields;
    this.$__setSchema(_schema);
    fields = skipId;
    skipId = options;
    options = arguments[4] || {};
  }

  this.$__ = new InternalCache;
  this.$__.emitter = new EventEmitter();
  this.isNew = 'isNew' in options ? options.isNew : true;
  this.errors = undefined;
  this.$__.$options = options || {};

  if (obj != null && typeof obj !== 'object') {
    throw new ObjectParameterError(obj, 'obj', 'Document');
  }

  const schema = this.schema;

  if (typeof fields === 'boolean') {
    this.$__.strictMode = fields;
    fields = undefined;
  } else {
    this.$__.strictMode = schema.options.strict;
    this.$__.selected = fields;
  }

  const required = schema.requiredPaths(true);
  for (let i = 0; i < required.length; ++i) {
    this.$__.activePaths.require(required[i]);
  }

  this.$__.emitter.setMaxListeners(0);

  let exclude = null;

  // determine if this doc is a result of a query with
  // excluded fields
  if (utils.isPOJO(fields)) {
    exclude = isExclusive(fields);
  }

  const hasIncludedChildren = exclude === false && fields ?
    $__hasIncludedChildren(fields) :
    {};

  if (this._doc == null) {
    this.$__buildDoc(obj, fields, skipId, exclude, hasIncludedChildren, false);

    // By default, defaults get applied **before** setting initial values
    // Re: gh-6155
    $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, true, {
      isNew: this.isNew
    });
  }

  if (obj) {
    if (obj instanceof Document) {
      this.isNew = obj.isNew;
    }
    // Skip set hooks
    if (this.$__original_set) {
      this.$__original_set(obj, undefined, true);
    } else {
      this.$set(obj, undefined, true);
    }
  }

  // Function defaults get applied **after** setting initial values so they
  // see the full doc rather than an empty one, unless they opt out.
  // Re: gh-3781, gh-6155
  if (options.willInit) {
    EventEmitter.prototype.once.call(this, 'init', () => {
      $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, {
        isNew: this.isNew
      });
    });
  } else {
    $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, {
      isNew: this.isNew
    });
  }

  this.$__._id = this._id;
  this.$locals = {};

  if (!schema.options.strict && obj) {
    const _this = this;
    const keys = Object.keys(this._doc);

    keys.forEach(function(key) {
      if (!(key in schema.tree)) {
        defineKey(key, null, _this);
      }
    });
  }

  applyQueue(this);
}

Which I believe should be:

function Document(obj, fields, skipId, options) {
  if (typeof skipId === 'object' && skipId != null) {
    options = skipId;
    skipId = options.skipId;
  }
  options = options || {};

  // Support `browserDocument.js` syntax
  if (this.schema == null) {
    const _schema = utils.isObject(fields) && !fields.instanceOfSchema ?
      new Schema(fields) :
      fields;
    this.$__setSchema(_schema);
    fields = skipId;
    skipId = options;
    options = arguments[4] || {};
  }

  this.$__ = new InternalCache;
  this.$__.emitter = new EventEmitter();
  this.isNew = 'isNew' in options ? options.isNew : true;
  this.errors = undefined;
  this.$__.$options = options || {};

  if (obj != null && typeof obj !== 'object') {
    throw new ObjectParameterError(obj, 'obj', 'Document');
  }

  const schema = this.schema;

  if (typeof fields === 'boolean') {
    this.$__.strictMode = fields;
    fields = undefined;
  } else {
    this.$__.strictMode = schema.options.strict;
    this.$__.selected = fields;
  }

  const required = schema.requiredPaths(true);
  for (let i = 0; i < required.length; ++i) {
    this.$__.activePaths.require(required[i]);
  }

  this.$__.emitter.setMaxListeners(0);

  let exclude = null;

  // determine if this doc is a result of a query with
  // excluded fields
  if (utils.isPOJO(fields)) {
    exclude = isExclusive(fields);
  }

  const hasIncludedChildren = exclude === false && fields ?
    $__hasIncludedChildren(fields) :
    {};

  if (this._doc == null) {
    this.$__buildDoc(obj, fields, skipId, exclude, hasIncludedChildren, false);

    // By default, defaults get applied **before** setting initial values
    // Re: gh-6155
    $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, true, {
      isNew: this.isNew
    });
  }

  if (obj) {


    /**************
    // this line here would solve the problem
    this.$locals = obj.$locals || {};


   ********************/

    if (obj instanceof Document) {
      this.isNew = obj.isNew;
    }
    // Skip set hooks
    if (this.$__original_set) {
      this.$__original_set(obj, undefined, true);
    } else {
      this.$set(obj, undefined, true);
    }
  }

  // Function defaults get applied **after** setting initial values so they
  // see the full doc rather than an empty one, unless they opt out.
  // Re: gh-3781, gh-6155
  if (options.willInit) {
    EventEmitter.prototype.once.call(this, 'init', () => {
      $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, {
        isNew: this.isNew
      });
    });
  } else {
    $__applyDefaults(this, fields, skipId, exclude, hasIncludedChildren, false, options.skipDefaults, {
      isNew: this.isNew
    });
  }

  this.$__._id = this._id;

  /**************

  Removing this line from here
  //this.$locals = {};

 ****************/

  if (!schema.options.strict && obj) {
    const _this = this;
    const keys = Object.keys(this._doc);

    keys.forEach(function(key) {
      if (!(key in schema.tree)) {
        defineKey(key, null, _this);
      }
    });
  }

  applyQueue(this);
}

@struckap can you please clarify what you mean by "The locals property is not working if you set it to object in Model.create(obj)"? Some code samples would be helpful.

@vkarpov15 here you go, it's just an example

let objectToSave = {
  name: 'Some string',
  $locals: {
    localProperty: 'anything'
  }
}

Model.create(objectToSave)
  .then(savedObject => {

    console.log(savedObject.$locals) // {}

  })
  .catch(err => {
    console.log(err);
  })

schema.pre('save', function (next) {
  console.log(this.$locals) // {}
  next();
});

@struckap ah I'd argue that's by design. $locals is something that you set on the document directly, like savedObject.$locals = 'whatever'. The reason why we have $locals as a separate property as opposed to just using a virtual is specifically so that new Model({ $locals: 'foo' }) doesn't touch $locals, so there's a clean separation between user data and program data.

If you want to be able to set locals using new Model(), just define a virtual.

const schema = new Schema({ name: String });
schema.virtual('_locals').get(function() { return this.__locals; }).set(function(v) { this.__locals = v; });

@struckap ah I'd argue that's by design. $locals is something that you set on the document directly, like savedObject.$locals = 'whatever'. The reason why we have $locals as a separate property as opposed to just using a virtual is specifically so that new Model({ $locals: 'foo' }) doesn't touch $locals, so there's a clean separation between user data and program data.

If you want to be able to set locals using new Model(), just define a virtual.

const schema = new Schema({ name: String });
schema.virtual('_locals').get(function() { return this.__locals; }).set(function(v) { this.__locals = v; });

In my experience, virtuals doesn't work on pre('save') hooks. (At least when using the create function)

@paullaffitte can you please clarify what you mean by "doesn't work"?

Was this page helpful?
0 / 5 - 0 ratings