// Base Model
// ---------------
import _, { assign, identity, mapKeys, mapValues, clone } from 'lodash';
import {deprecate} from '../helpers';
import inherits from 'inherits';
import Events from './events';
import {PIVOT_PREFIX, DEFAULT_TIMESTAMP_KEYS} from '../constants';
// List of attributes attached directly from the `options` passed to the constructor.
const modelProps = ['tableName', 'hasTimestamps'];
/**
* @class
* @classdesc
* @extends Events
* @inheritdoc
* @description
*
* The "ModelBase" is similar to the 'Active Model' in Rails, it defines a
* standard interface from which other objects may inherit.
*/
function ModelBase(attributes, options) {
let attrs = attributes || {};
options = options || {};
this.attributes = Object.create(null);
this._reset();
this.relations = {};
this.cid = _.uniqueId('c');
if (options) {
_.extend(this, _.pick(options, modelProps));
if (options.parse) attrs = this.parse(attrs, options) || {};
}
this.set(attrs, options);
this.initialize.apply(this, arguments);
}
/**
* @method ModelBase#on
* @example
*
* customer.on('fetching', function(model, columns) {
* // Do something before the data is fetched from the database
* });
*
* @see Events#on
*/
/**
* @method ModelBase#off
* @example
*
* customer.off('fetched fetching');
* ship.off(); // This will remove all event listeners
*
* @see Events#off
*/
/**
* @method ModelBase#trigger
* @example
*
* ship.trigger('fetched');
*
* @see Events#trigger
*/
inherits(ModelBase, Events);
/**
* @method ModelBase#initialize
* @description
*
* Called by the {@link Model Model constructor} when creating a new instance.
* Override this function to add custom initialization, such as event listeners.
* Because plugins may override this method in subclasses, make sure to call
* your super (extended) class. e.g.
*
* initialize: function() {
* this.constructor.__super__.initialize.apply(this, arguments);
* // Your initialization code ...
* }
*
* @see Model
*
* @param {Object} attributes
* Initial values for this model's attributes.
* @param {Object=} options
* The hash of options passed to {@link Model constructor}.
*/
ModelBase.prototype.initialize = function() {};
/**
* @name ModelBase#tableName
* @member {string}
* @description
*
* A required property for any database usage, The
* {@linkcode Model#tableName tableName} property refers to the database
* table name the model will query against.
*
* @example
*
* var Television = bookshelf.Model.extend({
* tableName: 'televisions'
* });
*/
/**
* @member {string}
* @default "id"
* @description
*
* This tells the model which attribute to expect as the unique identifier
* for each database row (typically an auto-incrementing primary key named
* `'id'`). Note that if you are using {@link Model#parse parse} and {@link
* Model#format format} (to have your model's attributes in `camelCase`,
* but your database's columns in `snake_case`, for example) this refers to
* the name returned by parse (`myId`), not the actual database column
* (`my_id`).
*
* You can also get the parsed id attribute value by using the model's
* {@link Model#parsedIdAttribute parsedIdAttribute} method.
*
* If the table you're working with does not have a Primary-Key in the form
* of a single column you'll have to override it with a getter that returns
* `null`. Overriding with `undefined` does not cascade the default behavior of
* the value `'id'`. Such a getter in ES6 would look like
* `get idAttribute() { return null }`.
*/
ModelBase.prototype.idAttribute = 'id';
/**
* @member {Object|Null}
* @default null
* @description
*
* This can be used to define any default values for attributes that are not
* present when creating or updating a model in a {@link Model#save save} call.
* The default behavior is to *not* use these default values on updates unless
* the `defaults: true` option is passed to the {@link Model#save save} call.
* For inserts the default values will always be used if present.
*
* @example
*
* var MyModel = bookshelf.Model.extend({
* defaults: {property1: 'foo', property2: 'bar'},
* tableName: 'my_table'
* })
*
* MyModel.forge({property1: 'blah'}).save().then(function(model) {
* // {property1: 'blah', property2: 'bar'}
* })
*/
ModelBase.prototype.defaults = null;
/**
* @member {Boolean|Array}
* @default false
* @description
*
* Automatically sets the current date and time on the timestamp attributes
* `created_at` and `updated_at` based on the type of save method. The *update*
* method will only update `updated_at`, while the *insert* method will set
* both values.
*
* To override the default attribute names, assign an array to this property.
* The first element will be the *created* column name and the second will be
* the *updated* one. If any of these elements is set to `null` that particular
* timestamp attribute will not be used in the model. For example, to
* automatically update only the `created_at` attribute set this property to
* `['created_at', null]`.
*
* You can override the timestamp attribute values of a model and those values
* will be used instead of the automatic ones when saving.
*
* @example
*
* var MyModel = bookshelf.Model.extend({
* hasTimestamps: true,
* tableName: 'my_table'
* })
*
* var myModel = MyModel.forge({name: 'blah'}).save().then(function(savedModel) {
* // {
* // name: 'blah',
* // created_at: 'Sun Mar 25 2018 15:07:11 GMT+0100 (WEST)',
* // updated_at: 'Sun Mar 25 2018 15:07:11 GMT+0100 (WEST)'
* // }
* })
*
* myModel.save({created_at: new Date(2015, 5, 2)}).then(function(updatedModel) {
* // {
* // name: 'blah',
* // created_at: 'Tue Jun 02 2015 00:00:00 GMT+0100 (WEST)',
* // updated_at: 'Sun Mar 25 2018 15:07:11 GMT+0100 (WEST)'
* // }
* })
*/
ModelBase.prototype.hasTimestamps = false;
/**
* @method
* @private
* @description
*
* Converts the timestamp keys to actual Date objects. This will not run if the
* model doesn't have {@link Model#hasTimestamps hasTimestamps} set to either
* `true` or an array of key names.
* This method is run internally when reading data from the database to ensure
* data consistency between the several database implementations.
* It returns the model instance that called it, so it allows chaining of other
* model methods.
*
* @returns {Model} The model that called this.
*/
ModelBase.prototype.formatTimestamps = function formatTimestamps() {
if (!this.hasTimestamps) return this;
this.getTimestampKeys().forEach((key) => {
if (this.get(key)) this.set(key, new Date(this.get(key)));
});
return this;
};
/**
* @method
* @description Get the current value of an attribute from the model.
* @example note.get("title");
*
* @param {string} attribute - The name of the attribute to retrieve.
* @returns {mixed} Attribute value.
*/
ModelBase.prototype.get = function(attr) {
return this.attributes[attr];
};
/**
* @method
* @private
* @description
*
* Returns the model's {@link Model#idAttribute idAttribute} after applying the
* model's {@link Model#parse parse} method to it. Doesn't mutate the original
* value of {@link Model#idAttribute idAttribute} in any way.
*
* @example
*
* var Customer = bookshelf.Model.extend({
* idAttribute: 'id',
* parse: function(attrs) {
* return _.mapKeys(attrs, function(value, key) {
* return 'parsed_' + key;
* });
* }
* });
*
* customer.parsedIdAttribute() // 'parsed_id'
*
* @returns {mixed} Whatever value the parse method returns.
*/
ModelBase.prototype.parsedIdAttribute = function() {
var parsedAttributes = this.parse({[this.idAttribute]: null})
return parsedAttributes && Object.keys(parsedAttributes)[0]
}
/**
* @method
* @description Set a hash of attributes (one or many) on the model.
* @example
*
* customer.set({first_name: "Joe", last_name: "Customer"});
* customer.set("telephone", "555-555-1212");
*
* @param {string|Object} attribute Attribute name, or hash of attribute names and values.
* @param {mixed=} value If a string was provided for `attribute`, the value to be set.
* @param {Object=} options
* @param {Object} [options.unset=false] Remove attributes from the model instead of setting them.
* @returns {Model} This model.
*/
ModelBase.prototype.set = function(key, val, options) {
if (key == null) return this;
let attrs;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (typeof key === 'object') {
attrs = key;
options = val;
} else {
(attrs = {})[key] = val;
}
options = clone(options) || {};
// Extract attributes and options.
const unset = options.unset;
const current = this.attributes;
const prev = this._previousAttributes;
// Check for changes of `id`.
if (this.idAttribute in attrs)
this.id = attrs[this.idAttribute];
else if (this.parsedIdAttribute() in attrs)
this.id = attrs[this.parsedIdAttribute()];
// For each `set` attribute, update or delete the current value.
for (const attr in attrs) {
val = attrs[attr];
if (!_.isEqual(prev[attr], val)) {
this.changed[attr] = val;
} else {
delete this.changed[attr];
}
if (unset) {
delete current[attr]
} else {
current[attr] = val
}
}
return this;
};
/**
* @method
* @description
*
* Checks for the existence of an id to determine whether the model is
* considered "new".
*
* @example
*
* var modelA = new bookshelf.Model();
* modelA.isNew(); // true
*
* var modelB = new bookshelf.Model({id: 1});
* modelB.isNew(); // false
*/
ModelBase.prototype.isNew = function() {
return this.id == null;
};
/**
* @method
* @description
*
* Return a copy of the model's {@link Model#attributes attributes} for JSON
* stringification. If the {@link Model model} has any relations defined, this
* will also call {@link Model#toJSON toJSON} on each of the related
* objects, and include them on the object unless `{shallow: true}` is
* passed as an option.
*
* `serialize` is called internally by {@link Model#toJSON toJSON}. Override
* this function if you want to customize its output.
*
* @example
* var artist = new bookshelf.Model({
* firstName: "Wassily",
* lastName: "Kandinsky"
* });
*
* artist.set({birthday: "December 16, 1866"});
*
* console.log(JSON.stringify(artist));
* // {firstName: "Wassily", lastName: "Kandinsky", birthday: "December 16, 1866"}
*
* @param {Object=} options
* @param {Boolean} [options.shallow=false] Exclude relations.
* @param {Boolean} [options.omitPivot=false] Exclude pivot values.
* @returns {Object} Serialized model as a plain object.
*/
ModelBase.prototype.serialize = function(options = {}) {
const { shallow = false, omitPivot = false, omitNew = false } = options;
const { attributes } = this;
if (omitNew && this.isNew()) {
return null;
}
if (!shallow) {
let relations = mapValues(this.relations, (relation, key) =>
relation.toJSON != null
? relation.toJSON(options)
: relation
);
// Omit null relations from the omitNew option
relations = _.omitBy(relations, _.isNull);
const pivot = this.pivot && !omitPivot && this.pivot.attributes;
const pivotAttributes = mapKeys(pivot, (value, key) =>
`${PIVOT_PREFIX}${key}`
);
return { ...attributes, ...relations, ...pivotAttributes };
}
return { ...attributes };
}
/**
* @method
* @description
*
* Called automatically by {@link
* https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
* `JSON.stringify`}. To customize serialization, override {@link
* Model#serialize serialize}.
*
* @param {Object=} options Options passed to {@link Model#serialize}.
*/
ModelBase.prototype.toJSON = function(options) {
return this.serialize(options);
};
/**
* @method
* @private
* @returns String representation of the object.
*/
ModelBase.prototype.toString = function() {
return '[Object Model]';
};
/**
* @method
* @description Get the HTML-escaped value of an attribute.
* @param {string} attribute The attribute to escape.
* @returns {string} HTML-escaped value of an attribute.
*/
ModelBase.prototype.escape = function(key) {
return _.escape(this.get(key));
};
/**
* @method
* @description
* Returns `true` if the attribute contains a value that is not null or undefined.
* @param {string} attribute The attribute to check.
* @returns {Boolean} True if `attribute` is set, otherwise `false`.
*/
ModelBase.prototype.has = function(attr) {
return this.get(attr) != null;
};
/**
* @method
* @description
*
* The `parse` method is called whenever a {@link Model model}'s data is
* returned in a {@link Model#fetch fetch} call. The function is passed the raw
* database response object, and should return the {@link Model#attributes
* attributes} hash to be {@link Model#set set} on the model. The default
* implementation is a no-op, simply passing through the JSON response.
* Override this if you need to format the database responses - for example
* calling {@link
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
* JSON.parse} on a text field containing JSON, or explicitly typecasting a
* boolean in a sqlite3 database response.
*
* If you need to format your data before it is saved to the database, override
* the {@link Model#format format} method in your models. That method does the
* opposite operation of `parse`.
*
* @example
* // Example of a parser to convert snake_case to camelCase, using lodash
* // This is just an example. You can use the built-in case-converter plugin
* // to achieve the same functionality.
* model.parse = function(attrs) {
* return _.mapKeys(attrs, function(value, key) {
* return _.camelCase(key);
* });
* };
*
* @param {Object} attributes Hash of attributes to parse.
* @returns {Object} Parsed attributes.
*/
ModelBase.prototype.parse = identity;
/**
* @method
* @description
*
* Remove an attribute from the model. `unset` is a noop if the attribute
* doesn't exist.
*
* Note that unsetting an attribute from the model will not affect the related
* record's column value when saving the model. In order to clear the value of a
* column in the database record, set the attribute value to `null` instead:
* `model.set("column_name", null)`.
*
* @param attribute Attribute to unset.
* @returns {Model} This model.
*/
ModelBase.prototype.unset = function(attr, options) {
return this.set(attr, void 0, _.extend({}, options, {unset: true}));
};
/**
* @method
* @description Clear all attributes on the model.
* @returns {Model} This model.
*/
ModelBase.prototype.clear = function(options) {
const undefinedKeys = mapValues(this.attributes, () => undefined);
return this.set(undefinedKeys, { ...options, unset: true });
};
/**
* @method
* @description
*
* The `format` method is used to modify the current state of the model before
* it is persisted to the database. The `attributes` passed are a shallow clone
* of the {@link Model model}, and are only used for inserting/updating - the
* current values of the model are left intact.
*
* Do note that `format` is used to modify the state of the model when
* accessing the database, so if you remove an attribute in your `format`
* method, that attribute will never be persisted to the database, but it will
* also never be used when doing a `fetch()`, which may cause unexpected
* results. You should be very cautious with implementations of this method
* that may remove the primary key from the list of attributes.
*
* If you need to modify the database data before it is given to the model,
* override the {@link Model#parse parse} method instead. That method does the
* opposite operation of `format`.
*
* @param {Object} attributes The attributes to be converted.
* @returns {Object} Formatted attributes.
*/
ModelBase.prototype.format = identity;
/**
* @method
* @description
*
* The `related` method returns a specified relation loaded on the relations
* hash on the model, or calls the associated relation method and adds it to
* the relations hash if one exists and has not yet been loaded.
*
* @example
*
* new Photo({id: 1}).fetch({
* withRelated: ['account']
* }).then(function(photo) {
* if (photo) {
* var account = photo.related('account');
* if (account.id) {
* return account.related('trips').fetch();
* }
* }
* });
*
* @param name {string} The name of the relation to retrieve.
* @returns {Model|Collection|undefined} The specified relation as defined by a
* method on the model, or undefined if it does not exist.
*/
ModelBase.prototype.related = function(name) {
return this.relations[name] || (this[name] ? this.relations[name] = this[name]() : void 0);
};
/**
* @method
* @description
* Returns a new instance of the model with identical {@link
* Model#attributes attributes}, including any relations from the cloned
* model.
*
* @returns {Model} Cloned instance of this model.
*/
ModelBase.prototype.clone = function() {
const model = new this.constructor(this.attributes);
assign(model.relations, mapValues(this.relations, r => r.clone()));
model._previousAttributes = clone(this._previousAttributes);
model.changed = clone(this.changed);
return model;
};
/**
* @method
* @private
* @description
*
* Returns the method that will be used on save, either 'update' or 'insert'.
* This is an internal helper that uses `isNew` and `options.method` to
* determine the correct method. If `option.method` is provided, it will be
* returned, but lowercased for later comparison.
*
* @returns {string} Either `'insert'` or `'update'`.
*/
ModelBase.prototype.saveMethod = function({ method = null, patch = false } = {}) {
if (patch) {
if (method == 'insert') throw new TypeError(
`Cannot accept incompatible options: methods=${method}, patch=${patch}`
);
method = 'update';
}
return (patch && 'update' || method) == null
? this.isNew() ? 'insert' : 'update'
: method.toLowerCase();
};
/**
* @method
* @private
* @description
*
* Returns the automatic timestamp key names set on this model. Note that this
* will always return a value even if the model has {@link Model#hasTimestamps
* hasTimestamps} set to `false`. In this case and when set to `true` the
* return value will be the default names of `created_at` and `updated_at`.
*
* @returns {Array<string>} The two timestamp key names.
*/
ModelBase.prototype.getTimestampKeys = function() {
return Array.isArray(this.hasTimestamps) ? this.hasTimestamps : DEFAULT_TIMESTAMP_KEYS;
}
/**
* @method
* @description
* Automatically sets the timestamp attributes on the model, if
* {@link Model#hasTimestamps hasTimestamps} is set to `true` or an array. It
* checks if the model is new and sets the `created_at` and `updated_at`
* attributes (or any other custom attribute names you have set) to the current
* date. If the model is not new and is just being updated then only the
* `updated_at` attribute gets automatically updated.
*
* If the model contains any user defined `created_at` or `updated_at` values,
* there won't be any automatic updated of these attributes and the user
* supplied values will be used instead.
*
* @param {Object=} options
* @param {string} [options.method]
* Either `'insert'` or `'update'` to specify what kind of save the attribute
* update is for.
* @returns {Object} A hash of timestamp attributes that were set.
*/
ModelBase.prototype.timestamp = function(options) {
if (!this.hasTimestamps) return {};
const now = (options || {}).date ? new Date(options.date) : new Date();
const attributes = {};
const method = this.saveMethod(options);
const [createdAtKey, updatedAtKey] = this.getTimestampKeys();
const isNewModel = method === 'insert';
const setUpdatedAt = updatedAtKey && this.hasChanged(updatedAtKey)
if (options && options.date) deprecate('options.date', 'the model\'s timestamp attributes to set these values')
if (isNewModel && !setUpdatedAt || this.hasChanged() && !setUpdatedAt) {
attributes[updatedAtKey] = now;
}
if (createdAtKey && isNewModel && !this.hasChanged(createdAtKey)) {
attributes[createdAtKey] = now;
}
this.set(attributes, _.extend(options, {silent: true}));
return attributes;
};
/**
* @method
* @description
*
* Returns true if any {@link Model#attributes attribute} attribute has changed
* since the last {@link Model#fetch fetch}, {@link Model#save save}, or {@link
* Model#destroy destroy}. If an attribute is passed, returns true only if that
* specific attribute has changed.
*
* @param {string=} attribute
* @returns {Boolean}
* `true` if any attribute has changed. Or, if `attribute` was specified, true
* if it has changed.
*/
ModelBase.prototype.hasChanged = function(attr) {
if (attr == null) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
};
/**
* @method
* @description
*
* Returns the this previous value of a changed {@link Model#attributes
* attribute}, or `undefined` if one had not been specified previously.
*
* @param {string} attribute The attribute to check
* @returns {mixed} The previous value
*/
ModelBase.prototype.previous = function(attribute) {
return this._previousAttributes[attribute];
};
/**
* @method
* @description
*
* Return a copy of the {@link Model model}'s previous attributes from the
* model's last {@link Model#fetch fetch}, {@link Model#save save}, or {@link
* Model#destroy destroy}. Useful for getting a diff between versions of a
* model, or getting back to a valid state after an error occurs.
*
* @returns {Object} The attributes as they were before the last change.
*/
ModelBase.prototype.previousAttributes = function() {
return clone(this._previousAttributes);
};
/**
* @method
* @private
* @description
*
* Resets the `_previousAttributes` and `changed` hash for the model.
* Typically called after a `sync` action (save, fetch, delete) -
*
* @returns {Model} This model.
*/
ModelBase.prototype._reset = function() {
this._previousAttributes = clone(this.attributes);
this.changed = Object.create(null);
return this;
};
/**
* @method ModelBase#keys
* @see http://lodash.com/docs/#keys
*/
/**
* @method ModelBase#values
* @see http://lodash.com/docs/#values
*/
/**
* @method ModelBase#toPairs
* @see http://lodash.com/docs/#toPairs
*/
/**
* @method ModelBase#invert
* @see http://lodash.com/docs/#invert
*/
/**
* @method ModelBase#pick
* @see http://lodash.com/docs/#pick
*/
/**
* @method ModelBase#omit
* @see http://lodash.com/docs/#omit
*/
// "_" methods that we want to implement on the Model.
const modelMethods = ['keys', 'values', 'toPairs', 'invert', 'pick', 'omit'];
// Mix in each "_" method as a proxy to `Model#attributes`.
_.each(modelMethods, function(method) {
ModelBase.prototype[method] = function() {
return _[method](this.attributes, ...arguments);
};
});
/**
* @method Model.extend
* @description
*
* To create a Model class of your own, you extend {@link Model bookshelf.Model}.
*
* `extend` correctly sets up the prototype chain, so subclasses created with
* `extend` can be further extended and subclassed as far as you like.
*
* var checkit = require('checkit');
* var Promise = require('bluebird');
* var bcrypt = Promise.promisifyAll(require('bcrypt'));
*
* var Customer = bookshelf.Model.extend({
* initialize: function() {
* this.constructor.__super__.initialize.apply(this, arguments);
* this.on('saving', this.validateSave);
* },
*
* validateSave: function() {
* return checkit(rules).run(this.attributes);
* },
*
* account: function() {
* return this.belongsTo(Account);
* },
* }, {
* login: Promise.method(function(email, password) {
* if (!email || !password) throw new Error('Email and password are both required');
* return new this({email: email.toLowerCase().trim()}).fetch({require: true}).tap(function(customer) {
* return bcrypt.compareAsync(password, customer.get('password'))
* .then(function(res) {
* if (!res) throw new Error('Invalid password');
* });
* });
* })
* });
*
* Customer.login(email, password)
* .then(function(customer) {
* res.json(customer.omit('password'));
* }).catch(Customer.NotFoundError, function() {
* res.json(400, {error: email + ' not found'});
* }).catch(function(err) {
* console.error(err);
* });
*
* _Brief aside on `super`: JavaScript does not provide a simple way to call
* `super` — the function of the same name defined higher on the prototype
* chain. If you override a core function like {@link Model#set set}, or {@link
* Model#save save}, and you want to invoke the parent object's implementation,
* you'll have to explicitly call it, along these lines:_
*
* var Customer = bookshelf.Model.extend({
* set: function() {
* // ...
* bookshelf.Model.prototype.set.apply(this, arguments);
* // ...
* }
* });
*
* @param {Object=} prototypeProperties
* Instance methods and properties to be attached to instances of the new
* class.
* @param {Object=} classProperties
* Class (ie. static) functions and properties to be attached to the
* constructor of the new class.
* @returns {Function} Constructor for new `Model` subclass.
*/
ModelBase.extend = require('../extend');
module.exports = ModelBase;