wbyoung/azul

View on GitHub
lib/model/model.js

Summary

Maintainability
B
5 hrs
Test Coverage
'use strict';

var _ = require('lodash');
var util = require('util');
var Promise = require('bluebird');
var Class = require('corazon/class');
var Actionable = require('maguey').Actionable;
var Manager = require('./manager');
var HasMany = require('../relations/has_many');
var HasOne = require('../relations/has_one');
var BelongsTo = require('../relations/belongs_to');
var RelationModelSave = require('../relations/model_save');
var property = require('corazon/property');
var attr = require('./attr').fn;
var inflection = require('../util/inflection');

/**
 * @class Model
 * @classdesc
 *
 * The base model class.
 *
 * ## Accessing Objects
 *
 *     var attr = db.attr;
 *     var Article = db.model('article', {
 *       title: attr(),
 *     });
 *
 *     var User = db.model('user', {
 *       username: attr()
 *     });
 *
 *     Article.objects.all().then('...'); // -> select * from "articles";
 *     Article.objects.where({ pk: 1 }).fetch().then('...');
 *     // -> select * from "articles" where "id" = ?;
 *     // !> [1]
 *
 *     Article.objects.where({ title$contains: 'Azul' })
 *       .fetchOne().then('...');
 *     // promise is rejected if not exactly one record
 *
 *     var user = User.create();
 *     user.save().then('...'); // insert
 *
 *     user.save().then('...'); // no save because no changes
 *     user.username = 'wbyoung';
 *     user.save().then('...'); // update
 *
 *     user.delete();
 *     user.save(); // delete
 *
 * ## Table Level Queries
 *
 *     var Article = db.model('article');
 *     Article.objects.delete(); // -> delete from "articles";
 *     Article.objects.update({ title: 'Article' });
 *     // -> update "articles" set "title" = ?;
 *     // !> ['Article']
 *
 * ## Options
 *
 *     var Article = db.model('article');
 *
 *     Article.reopenClass({
 *       tableName: 'articles',
 *     });
 *
 * ## Associations
 *
 *     var attr = db.attr;
 *     var hasMany = db.hasMany;
 *     var belongsTo = db.belongsTo;
 *
 *     var Blog = db.model('blog', {
 *       title: attr()
 *     });
 *
 *     var Article = db.model('article', {
 *       title: attr(),
 *       blog: belongsTo(),
 *       author: belongsTo('user')
 *     });
 *
 *     var User = db.model('user', {
 *       name: attr(),
 *       username: attr(),
 *       articles: hasMany('article', { inverse: 'author' })
 *     });
 *
 *     Article.objects.with('author').fetch().then('...');
 *     // -> select * from "articles";
 *     // -> select * from "users" where "id" in (?, ?, ?)
 *     // -> limit 3;
 *     // !> [3,5,8]
 *
 *     Article.objects.where({ 'blog.title$contains': 'Azul' })
 *       .fetch().then('...');
 *     // -> select "articles".* from "articles"
 *     // -> inner join "blogs" on "articles"."blog_id" = "blogs"."id"
 *     // -> where "blogs"."title" like ?;
 *     // !> ["%Azul%"];
 *
 * ### Accessing Relationships
 *
 *     User.objects.find(1).then(function(user) {
 *       user.articles; // throws because this was not `with` articles
 *     });
 *
 *     User.objects.with('articles').find(1).fetch().then(function(user) {
 *       user.articles; // okay
 *     });
 *
 *     User.objects.find(1)
 *     .then(function(user) { return user.articleObjects.fetch(); })
 *     .then(function(user) {
 *       return user.articles; // okay because of prior fetch
 *     });
 *
 *     Article.objects.find(1).then(function(article) {
 *       article.author; // throws because this was not `with` the author
 *     });
 *
 *     Article.objects.with('author').find(1).then(function(article) {
 *       article.author; // okay
 *     });
 *
 * ### Querying
 *
 *     Article.objects.where({ 'author.name$contains': 'Whit' })
 *       .fetch().then('...');
 *     // -> select "articles".* from "articles"
 *     // -> inner join "users" on "articles"."author_id" = "users"."id"
 *     // -> where "users"."name" like ?;
 *     // !> ["%Whit%"]
 *
 *     Article.objects.where({ 'author.name$contains': 'Whit' })
 *       .orderBy('title', '-author.name')
 *       .limit(10)
 *       .offset(20)
 *       .fetch()
 *       .then('...');
 *     // -> select "articles".* from "articles"
 *     // -> inner join users on "articles"."author_id" = "users"."id"
 *     // -> where "users"."name" like ?
 *     // -> order by "articles"."title" asc "users"."name" desc
 *     // -> limit 10 offset 20;
 *     // !> ["%Whit%"]
 *
 *     Article.objects.with('author', 'comments').fetch().then('...');
 *     // -> select * from "articles";
 *     // -> select * from "users" where "id" in (?, ?);
 *     // !> [10,32]
 *     // -> select * from "comments" "id" in (?, ?, ?);
 *     // !> [12,43,73]
 *
 *     User.objects.where({ 'articles.title$contains': 'news' }).unique()
 *     // -> select "users".* from "users"
 *     // -> inner join "articles" on "articles"."author_id" = "users"."id"
 *     // -> where "articles"."title" like ?
 *     // -> group by "users"."id";
 *     // !> ['%news%']
 *
 *
 * ### Manipulating Relationships
 *
 *
 *     var article = Article.create({ title: 'Example' });
 *     user.addArticle(article).then('...');
 *     // -> insert into "articles" ("title", "author_id") values ?, ?;
 *     // !> ["Example", 1]
 *
 *     user.createArticle({ title: 'Example' }).save().then(function(article) {
 *       // ...
 *     });
 *     // -> insert into "articles" ("title", "author_id") values ?, ?;
 *     // !> ["Example", 1]
 *
 *     user.addArticles(existingArticle1, existingArticle2).then('...');
 *     // -> update "articles" set "author_id" = ? where ."id" in (?, ?);
 *     // !> [1, 23, 84]
 *
 *     user.removeArticles(existingArticle1, existingArticle2).then('...');
 *     // -> update "articles" set "author_id" = null "id" in (?, ?);
 *     // !> [1, 2]
 *
 *     user.removeArticle(existingArticle1).then('...');
 *     // -> update "articles" set "author_id" = null "id" = ?;
 *     // !> [1]
 *
 *     user.clearArticles().then('...');
 *     // -> update "articles" set "author_id" = null "author_id" = ?;
 *     // !> [1]
 *
 *
 * ### Self Joins
 *
 *     Employee = db.model('employee').reopen({
 *       subordinates: hasMany('employee', { inverse: 'manager' }),
 *       manager: belongsTo('employee', { inverse: 'subordinates' })
 *     });
 *
 *     Employee.objects.where({ 'manager.id': 1 })
 *     // -> select "employees".* from "employees"
 *     // -> inner join "employees" "managers"
 *     // -> on "employees"."employee_id" = "managers"."id"
 *     // -> where "managers"."id" = ?
 *     // !> [1]
 *
 *     Employee.objects.join('manager').where({ id: 1 })
 *     // -> select "employees".* from "employees"
 *     // -> inner join "employees" "managers"
 *     // -> on "employees"."employee_id" = "managers"."id"
 *     // -> where "employees"."id" = ?
 *     // !> [1]
 *
 * #### When the relation name and the table name match
 *
 * This ambiguous naming should be avoided, but has been documented for
 * completeness.
 *
 * In the below example, the relation name, `nodes` is also the same as the
 * table name, `nodes`. The same basic rules apply as in the relational uses.
 * When using names within the `where` clauses, the prefix is assumed to be a
 * relation name if the relation exists. In this case, since the relation
 * exists, it will be used. This means that it is impossible to use the table
 * name to _actually_ represent the table name as part of the condition. The
 * table name _is_ automatically added to all fields that do not specify a
 * table name (see below), so in nearly all cases, this should not be a
 * problem. Again, it should be avoided.
 *
 *     Node = db.model('node').reopen({
 *       nodes: hasMany('node')
 *     });
 *
 *     Node.objects.join('nodes').where({ 'nodes.id': 1 })
 *     // -> select "nodes".* from "nodes"
 *     // -> inner join "nodes" "nodes_j1"
 *     // -> on "nodes"."node_id" = "nodes_j1"."id"
 *     // -> where "nodes_j1"."id" = ?
 *     // !> [1]
 *
 *     Node.objects.where({ 'nodes.id': 1 })
 *     // -> select "nodes".* from "nodes"
 *     // -> inner join "nodes" "nodes_j1"
 *     // -> on "nodes"."node_id" = "nodes_j1"."id"
 *     // -> where "nodes_j1"."id" = ?
 *     // !> [1]
 *
 * To specify a condition on the left hand side of the query (i.e.) to access
 * the original table that's being queried, simply don't provide the the table
 * name. This should come naturally, but a few examples should make this clear:
 *
 *     Node.objects.join('nodes').where({ id: 1 })
 *     // -> select "nodes".* from "nodes"
 *     // -> inner join "nodes" "nodes_j1"
 *     // -> on "nodes"."node_id" = "nodes_j1"."id"
 *     // -> where "nodes"."id" = ?
 *     // !> [1]
 *
 *     Node.objects.where({ id: 1 })
 *     // -> select * from "nodes" where "id" = ?
 *     // !> [1]
 */
var Model = Class.extend(/** @lends Model# */ {

  /**
   * Create a model.
   *
   * @public
   * @constructor Model
   */
  init: function(properties) {
    this._super();
    this._attrs = {};
    this._dirtyAttributes = {}; // required to init attributes
    this._initAttributes();
    this._dirtyAttributes = {}; // we still start off as not dirty
    this._deletePending = false;
    this._deleteExecuted = false;
    this._inFlight = false;

    // cannot _.extend because it knows not to attempt to write properties that
    // aren't writable. we want to write the non-writable properties, though,
    // so that the proper exception is thrown.
    _.forEach(properties, function(value, key) {
      this[key] = value;
    }, this);
  },

  /**
   * Initialize attributes. Any changes made during this method will not result
   * in a dirty model being created.
   *
   * This method exists as an override point for attributes to initialize
   * themselves.
   *
   * @method
   * @protected
   * @see {@link Attr#_init}
   */
  _initAttributes: function() { },

  /**
   * Each model comes by default with an `pk` attribute. By default, this
   * corresponds to the `id` column in the database. If you use a different
   * column name in your database, you can override this property:
   *
   *     MyModel.reopen({
   *       pk: attr('identifier')
   *     });
   *
   * If you prefer to access your custom column by another name, you can add an
   * additional attribute, making the column accessible by both attributes:
   *
   *     MyModel.reopen({
   *       identifier: attr('identifier')
   *     });
   *
   * @type {Integer|?}
   */
  pk: attr('id'),

  /**
   * An alias for the `pk` property.
   *
   * @type {Integer|?}
   * @see {@link Model#pk}
   */
  id: property({ property: 'pk', writable: true }),
  idAttr: property({ property: 'pkAttr' }), // docs implied from `id` docs

  /**
   * Get an attribute by the database column name. This is the preferred way of
   * reading individual attributes rather than using {@link Model#attrs}.
   *
   * @method
   * @protected
   */
  getAttribute: function(attr) {
    return this.attrs[attr];
  },

  /**
   * Set an attribute by the database column name. This is the only acceptable
   * way to set attributes in `attrs`. Do set them directly.
   *
   * @method
   * @protected
   */
  setAttribute: function(attr, val) {
    this._dirtyAttributes[attr] = true;
    this._attrs[attr] = val;
  },

  /**
   * Mark an attribute as clean.
   *
   * @method
   * @protected
   */
  cleanAttribute: function(attr) {
    delete this._dirtyAttributes[attr];
  },

  /**
   * The base where query for accessing this individual object in the database.
   * Build select/update queries from this.
   *
   * @method
   * @private
   * @param {Query} baseQuery The base object query to build from.
   * @return {BoundQuery} The query object.
   */
  _whereQuery: function(baseQuery) {
    return baseQuery.where(_.pick(this.attrs, this.pkAttr));
  },

  /**
   * An insert query for this object. This query also has an event listener
   * attached so that when the query is executed the primary key of the object
   * will be updated with the resulting value. The insert query will include
   * the primary key only when it's been set to some "truthy" value.
   *
   * @method
   * @private
   * @param {Query} baseQuery The base object query to build from.
   * @return {BoundQuery} The query object.
   */
  _insertQuery: function(baseQuery) {
    var pk = this.pkAttr;
    var attrs = this.pk ? this.attrs : _.omit(this.attrs, pk);

    if (!_.size(attrs)) {
      throw new Error(util.format(
        '%s has no attributes. Please make sure your model is properly ' +
        'defined.', this.__identity__.__name__));
    }

    var query = baseQuery.unbind().insert(attrs).returning(pk);
    return query.on('result', function(result) {
      this.pk = result[0][pk];
    }.bind(this));
  },

  /**
   * An update query for this object. This query will update the existing
   * object in the database with all of the attributes of the object.
   *
   * @method
   * @private
   * @param {Query} baseQuery The base object query to build from.
   * @return {BoundQuery} The query object.
   */
  _updateQuery: function(baseQuery) {
    var attrs = _.omit(this.attrs, this.pkAttr);
    return this._whereQuery(baseQuery).unbind().update(attrs);
  },

  /**
   * A delete query for this object. This query also has an event listener
   * attached so that when the query is executed the model will be updated and
   * marked as actually deleted (rather than being in a delete-pending state).
   *
   * @method
   * @private
   * @param {Query} baseQuery The base object query to build from.
   * @return {BoundQuery} The query object.
   */
  _deleteQuery: function(baseQuery) {
    return this._whereQuery(baseQuery).delete().on('result', function() {
      this._deleteExecuted = true;
      this._deletePending = false;
    }.bind(this));
  },

  /**
   * The insert/update/delete query for saving this object in the database.
   *
   * @method
   * @private
   * @param {Object} [options] The same as {@link Model#save}.
   * @return {BoundQuery} The query object.
   */
  _saveQuery: function(options) {
    if (this.inFlight) {
      throw new Error(util.format(
        'Cannot save %s while another save is in flight.',
        this.__identity__.__name__));
    }

    var query;
    var opts = _.defaults({}, options);
    var baseQuery = this.__identity__.objects;
    if (opts.transaction) {
      baseQuery = baseQuery.transaction(opts.transaction);
    }

    if (this.newRecord && !this.deleted) {
      query = this._insertQuery(baseQuery);
    }
    else if (this.dirty && this.persisted) {
      query = this._updateQuery(baseQuery);
    }
    else if (this.deleted && !this._deleteExecuted) {
      query = this._deleteQuery(baseQuery);
    }

    if (opts.method === 'insert') { query = this._insertQuery(baseQuery); }
    if (opts.method === 'update') { query = this._updateQuery(baseQuery); }

    if (query) {
      this._inFlight = true;
      query = query.on('result', this._afterSave.bind(this));
    }

    return query;
  },

  /**
   * Save a model instance. Objects that are not {@link Model#persisted} will
   * cause an `insert` to occur. Objects that have been persisted, but are
   * {@link Model#dirty} will cause an `update` to occur. Otherwise, no action
   * will be taken.
   *
   * After the save occurs, the model will be marked as clean & persisted.
   *
   * Multiple saves of the same object at the same time will result in an
   * error.
   *
   * @method
   * @public
   * @param {Object} [options]
   * @param {Boolean} [options.transaction] Associate save with a transaction.
   * @param {Boolean} [options.method] Force the method to use when saving.
   * This can be either `insert` or `update`.
   * @return {Promise} A promise that will resolve with the current model when
   * complete.
   */
  save: Promise.method(function(/*options*/) {
    return Promise
      .resolve(this._saveQuery.apply(this, arguments))
      .return(this);
  }),

  /**
   * This method is called after an object is successfully saved. It marks the
   * object as clean, no longer a new record, and no longer in flight.
   *
   * @method
   * @private
   */
  _afterSave: function() {
    this._dirtyAttributes = {};
    this._inFlight = false;
  },

  /**
   * Mark an object for deletion. It will be deleted once the model has been
   * saved.
   *
   * @method
   * @public
   * @return {Actionable} A thenable object, use of which will trigger the
   * instance to be saved.
   */
  delete: function() {
    this._deletePending = true;
    return Actionable.create(this.save.bind(this));
  },

  /**
   * The values of the database attributes, keyed by the actual column name.
   * This property is readonly, and altering the values the object is not
   * supported.
   *
   * @public
   * @readonly
   * @type {Object}
   */
  attrs: property(function() {
    return _.clone(this._attrs);
  }),

  /**
   * The values of the attributes, keyed by the attribute name.
   *
   * @public
   * @method
   * @return {Object}
   */
  toObject: function() {
    var prototype = this.__class__.prototype;
    return _.transform(this.__identity__.attrs, function(obj, attr) {
      var key = prototype[attr + 'Attr'];
      var value = this._attrs[key];
      obj[attr] = value;
    }.bind(this), {});
  },

  /**
   * The JSON representation of the object. This method is provided as a
   * customization point for individual model classes. Models can, for
   * instance, override this method to expand relationships so that
   * {@link Model#json} returns a representation with the relationship
   * values as JSON objects as well.
   *
   * @public
   * @method
   * @return {Object}
   */
  toJSON: function() {
    return this.toObject();
  },

  /**
   * The values of the attributes, keyed by the attribute name.
   *
   * @public
   * @readonly
   * @type {Object}
   */
  json: property(function() {
    return this.toJSON();
  }),

  /**
   * Whether this model is a new record, that is it does not have a
   * {@link Model#pk}. Objects that are new records can still be
   * {@link Model#dirty}.
   *
   * @public
   * @type {Boolean}
   */
  newRecord: property(function() {
    return !this.pk;
  }),

  /**
   * Whether this model is persisted, that is it neither a
   * {@link Model#newRecord} nor {@link Model#deleted}.
   *
   * @public
   * @type {Boolean}
   */
  persisted: property(function() {
    return !(this.newRecord || this.deleted);
  }),

  /**
   * Whether this model has been marked for deletion or has been deleted.
   *
   * @public
   * @type {Boolean}
   */
  deleted: property(function() {
    return this._deletePending || this._deleteExecuted;
  }),

  /**
   * Whether properties on this object have been changed since it was created
   * or loaded from the database.
   *
   * @public
   * @type {Boolean}
   */
  dirty: property(function() {
    return !!Object.keys(this._dirtyAttributes).length;
  }),

  /**
   * An array of all attributes that are dirty on this object.
   *
   * @public
   * @type {Array}
   */
  dirtyAttributes: property(function() {
    return Object.keys(this._dirtyAttributes);
  }),

  /**
   * Whether this object is in the process of being saved.
   *
   * @public
   * @type {Boolean}
   */
  inFlight: property(),

  /**
   * Show a few attributes of the model object for debugging.
   *
   * @public
   * @method
   * @return {String}
   */
  inspect: function(n) {
    var identity = this.__identity__;
    var attrs = identity.attrs;

    var show = _.map(attrs, function(attr) {
      var val = util.inspect(this[attr], { depth: n - 1 });
      if (val.length > 20) { val = '[' + typeof this[attr] + ']'; }
      return this[attr] && util.format('%s: %s', attr, val);
    }, this);

    // soft limit (look at attr[0] + first defined)
    if (n <= 2) { show = [show[0], _.find(show.slice(1))]; }
    var truncated = show.length < attrs.length;

    show = _.filter(show);

    // hard limit (one or two attrs)
    if (n <= 1) { show = show.slice(0, 1); }
    else if (n === 2) { show = show.slice(0, 2); }

    if (truncated) { show.push('...'); }

    return util.format('%s({ %s })', identity.__name__, show.join(', '));
  },

});

Model.reopenClass(/** @lends Model */ {

  /**
   * Access to the {@link Manager} for all objects corresponding to this model.
   * This is the entry point to allow manipulation of the underlying data.
   * Subclasses can override this property to define a custom manager that
   * changes the default set of data that this model works with. Subclasses can
   * also add additional managers, allowing quick access to different data
   * sets (also sometimes referred to as scopes).
   *
   * @public
   * @type {Manager}
   */
  objects: Manager.create(),

  /**
   * Create a database attribute. The preferred user facing function for this
   * is {@link Database#attr}.
   *
   * @private
   * @function
   * @see {@link Database#attr}
   */
  attr: attr,

  /**
   * Create a has-many relationship. The preferred user facing function for
   * this is {@link Database#hasMany}.
   *
   * @private
   * @function
   * @see {@link Database#hasMany}
   */
  hasMany: HasMany.attr(),

  /**
   * Create a has-one relationship. The preferred user facing function for
   * this is {@link Database#hasOne}.
   *
   * @private
   * @function
   * @see {@link Database#hasOne}
   */
  hasOne: HasOne.attr(),

  /**
   * Create a belongs-to relationship. The preferred user facing function for
   * this is {@link Database#belongsTo}.
   *
   * @private
   * @function
   * @see {@link Database#belongsTo}
   */
  belongsTo: BelongsTo.attr(),

  /**
   * Get attributes defined on this model.
   *
   * Internally this relies on a method being added to the class by the
   * attribute trigger.
   *
   * @public
   * @type {Array.<String>}
   * @readonly
   * @see {@link Attr~attrs}
   */
  attrs: property(function() {
    return this._attrs();
  }),

  /**
   * Get relations defined on this model.
   *
   * Internally this relies on a method being added to the class by the
   * relation attribute trigger.
   *
   * @public
   * @type {Object}
   * @readonly
   * @see {@link RelationAttr~relations}
   */
  relations: property(function() {
    return _.result(this, '_relations', {});
  }),

  /**
   * The name of the database table. Subclasses can override this property. It
   * defaults to the pluralized, underscored class name.
   *
   * @public
   * @type {String}
   */
  tableName: property(function() {
    return this._tableName ||
      inflection.pluralize(_.snakeCase(this.__name__));
  }, { writable: true }),

  /**
   * Whether the class customizes the table name.
   *
   * @public
   * @scope internal
   * @type {Boolean}
   */
  customizesTableName: property(function() {
    return !!this._tableName;
  }),

  /**
   * A {@link Manager} will use this method to set attributes of a model when
   * it is loaded from the database via a query.
   *
   * @method
   * @protected
   * @param {Object} attrs The attributes read from the database.
   */
  load: function(attrs) {
    return this.create({
      _attrs: attrs,
    });
  },

});

Model.reopen(RelationModelSave); // must be mixed in after `save` is defined

module.exports = Model.reopenClass({ __name__: 'Model' });