jii-model
Version:
1,432 lines (1,263 loc) • 65.4 kB
JavaScript
/**
* @author <a href="http://www.affka.ru">Vladimir Kozhin</a>
* @license MIT
*/
'use strict';
var Jii = require('jii');
var Event = require('jii/base/Event');
var ModelSchema = require('./ModelSchema');
var InvalidConfigException = require('jii/exceptions/InvalidConfigException');
var NotSupportedException = require('jii/exceptions/NotSupportedException');
var ChangeAttributeEvent = require('../model/ChangeAttributeEvent');
var InvalidParamException = require('jii/exceptions/InvalidParamException');
var ModelEvent = require('jii/base/ModelEvent');
var AfterSaveEvent = require('../model/AfterSaveEvent');
var InvalidCallException = require('jii/exceptions/InvalidCallException');
var Collection = require('../base/Collection');
var ActiveQuery = require('./ActiveQuery');
var _upperFirst = require('lodash/upperFirst');
var _isArray = require('lodash/isArray');
var _isObject = require('lodash/isObject');
var _isEmpty = require('lodash/isEmpty');
var _indexOf = require('lodash/indexOf');
var _isEqual = require('lodash/isEqual');
var _isNumber = require('lodash/isNumber');
var _isUndefined = require('lodash/isUndefined');
var _isFunction = require('lodash/isFunction');
var _each = require('lodash/each');
var _clone = require('lodash/clone');
var _size = require('lodash/size');
var _keys = require('lodash/keys');
var _has = require('lodash/has');
var _filter = require('lodash/filter');
var _first = require('lodash/first');
var _values = require('lodash/values');
var _map = require('lodash/map');
var Model = require('./Model');
/**
* @abstract
* @class Jii.base.BaseActiveRecord
* @extends Jii.base.Model
*/
var BaseActiveRecord = Jii.defineClass('Jii.base.BaseActiveRecord', /** @lends Jii.base.BaseActiveRecord.prototype */{
__extends: Model,
__static: /** @lends Jii.base.BaseActiveRecord */{
/**
* @event Jii.base.BaseActiveRecord#init
* @property {Jii.base.Event} event an event that is triggered when the record is initialized via [[init()]].
*/
EVENT_INIT: 'init',
/**
* @event Jii.base.BaseActiveRecord#afterFind
* @property {Jii.base.Event} event an event that is triggered after the record is created and populated with query result.
*/
EVENT_AFTER_FIND: 'afterFind',
/**
* You may set [[Jii.base.ModelEvent.isValid]] to be false to stop the insertion.
* @event Jii.base.BaseActiveRecord#beforeInsert
* @property {Jii.base.ModelEvent} event an event that is triggered before inserting a record.
*/
EVENT_BEFORE_INSERT: 'beforeInsert',
/**
* Event an event that is triggered after a record is inserted.
* @event Jii.base.BaseActiveRecord#afterInsert
* @property {Jii.model.AfterSaveEvent} event
*/
EVENT_AFTER_INSERT: 'afterInsert',
/**
* You may set [[ModelEvent.isValid]] to be false to stop the update.
* @event Jii.base.BaseActiveRecord#beforeUpdate
* @property {Jii.base.ModelEvent} event an event that is triggered before updating a record.
*/
EVENT_BEFORE_UPDATE: 'beforeUpdate',
/**
* @event Jii.base.BaseActiveRecord#afterUpdate
* @property {Jii.model.AfterSaveEvent} event an event that is triggered after a record is updated.
*/
EVENT_AFTER_UPDATE: 'afterUpdate',
/**
* You may set [[ModelEvent.isValid]] to be false to stop the deletion.
* @event Jii.base.BaseActiveRecord#beforeDelete
* @property {Jii.base.ModelEvent} event an event that is triggered before deleting a record.
*/
EVENT_BEFORE_DELETE: 'beforeDelete',
/**
* @event Jii.base.BaseActiveRecord#afterDelete
* @property {Jii.base.Event} event an event that is triggered after a record is deleted.
*/
EVENT_AFTER_DELETE: 'afterDelete',
_modelSchema: {},
/**
* @returns {{}}
*/
modelSchema() {
return {};
},
/**
* @returns {Jii.sql.TableSchema}
*/
getTableSchema() {
const className = this.className();
if (!this._modelSchema[className]) {
let schema = this.modelSchema();
if (!(schema instanceof ModelSchema)) {
schema = ModelSchema.createFromObject(schema);
}
this._modelSchema[className] = schema;
}
return this._modelSchema[className];
},
tableName() {
return null;
},
/**
* @inheritdoc
* @returns {Jii.base.BaseActiveRecord} BaseActiveRecord instance matching the condition, or `null` if nothing matches.
*/
findOne(condition) {
return this._findByCondition(condition, true);
},
/**
* @inheritdoc
* @returns {Jii.base.BaseActiveRecord[]} an array of BaseActiveRecord instances, or an empty array if nothing matches.
*/
findAll(condition) {
return this._findByCondition(condition, false);
},
/**
* @inheritdoc
*/
find() {
return new ActiveQuery(this);
},
/**
* Returns the database connection used by this AR class.
* By default, the "db" application component is used as the database connection.
* You may override this method if you want to use a different database connection.
* @returns {Jii.sql.BaseConnection} the database connection used by this AR class.
*/
getDb() {
return Jii.app ? Jii.app.getComponent('db') : null;
},
/**
* Returns the primary key name(s) for this AR class.
* The default implementation will return the primary key(s) as declared
* in the DB table that is associated with this AR class.
*
* If the DB table does not declare any primary key, you should override
* this method to return the attributes that you want to use as primary keys
* for this AR class.
*
* Note that an array should be returned even for a table with single primary key.
*
* @returns {string[]} the primary keys of the associated database table.
*/
primaryKey() {
return this.getTableSchema().primaryKey;
},
/**
* Finds BaseActiveRecord instance(s) by the given condition.
* This method is internally called by [[findOne()]] and [[findAll()]].
* @param {*} condition please refer to [[findOne()]] for the explanation of this parameter
* @param {boolean} one whether this method is called by [[findOne()]] or [[findAll()]]
* @returns {Jii.base.BaseActiveRecord|Jii.base.BaseActiveRecord[]}
* @throws {Jii.exceptions.InvalidConfigException} if there is no primary key defined
* @internal
*/
_findByCondition(condition, one) {
var query = this.find();
return Promise.resolve().then(() => {
if (_isArray(condition) || _isObject(condition)) {
return Promise.resolve(condition);
}
var primaryKey = this.primaryKey();
// query by primary key
if (primaryKey.length > 0) {
var pk = primaryKey[0];
if (!_isEmpty(query.getJoin()) || !_isEmpty(query.getJoinWith())) {
pk = this.tableName() + '.' + pk;
}
var conditionObject = {};
conditionObject[pk] = condition;
return conditionObject;
}
throw new InvalidConfigException(this.className() + ' must have a primary key.');
}).then(condition => {
query.andWhere(condition);
return one ? query.one() : query.all();
});
},
/**
* Updates the whole table using the provided attribute values and conditions.
* For example, to change the status to be 1 for all customers whose status is 2:
*
* ~~~
* Customer.updateAll({status: 1}, 'status = 2');
* ~~~
*
* @param {object} attributes attribute values (name-value pairs) to be saved into the table
* @param {string|[]} [condition] the conditions that will be put in the WHERE part of the UPDATE SQL.
* Please refer to [[Query.where()]] on how to specify this parameter.
* @returns {Promise.<number>} the number of rows updated
* @throws {Jii.exceptions.NotSupportedException} if not overrided
*/
updateAll(attributes, condition) {
condition = condition || '';
throw new NotSupportedException('updateAll() is not supported.');
},
/**
* Updates the whole table using the provided counter changes and conditions.
* For example, to increment all customers' age by 1,
*
* ~~~
* Customer.updateAllCounters({age: 1});
* ~~~
*
* @param {[]} counters the counters to be updated (attribute name => increment value).
* Use negative values if you want to decrement the counters.
* @param {string|[]} [condition] the conditions that will be put in the WHERE part of the UPDATE SQL.
* Please refer to [[Query.where()]] on how to specify this parameter.
* @returns {number} the number of rows updated
* @throws {Jii.exceptions.NotSupportedException} if not overrided
*/
updateAllCounters(counters, condition) {
condition = condition || '';
throw new NotSupportedException('updateAllCounters() is not supported.');
},
/**
* Deletes rows in the table using the provided conditions.
* WARNING: If you do not specify any condition, this method will delete ALL rows in the table.
*
* For example, to delete all customers whose status is 3:
*
* ~~~
* Customer.deleteAll('status = 3');
* ~~~
*
* @param {string|[]} [condition] the conditions that will be put in the WHERE part of the DELETE SQL.
* Please refer to [[Query.where()]] on how to specify this parameter.
* @param {[]} [params] the parameters (name => value) to be bound to the query.
* @returns {number} the number of rows deleted
* @throws {Jii.exceptions.NotSupportedException} if not overrided
*/
deleteAll(condition, params) {
condition = condition || '';
params = params || [];
throw new NotSupportedException('deleteAll() is not supported.');
},
/**
* Populates an active record object using a row of data from the database/storage.
*
* This is an internal method meant to be called to create active record objects after
* fetching data from the database. It is mainly used by [[ActiveQuery]] to populate
* the query results into active records.
*
* When calling this method manually you should call [[afterFind()]] on the created
* record to trigger the [[EVENT_AFTER_FIND|afterFind Event]].
*
* @param {Jii.base.BaseActiveRecord} record the record to be populated. In most cases this will be an instance
* created by [[instantiate()]] beforehand.
* @param {object} row attribute values (name => value)
*/
populateRecord(record, row) {
var columns = record.attributes();
_each(row, (value, name) => {
if (_indexOf(columns, name) !== -1) {
record._attributes[name] = value;
} else if (record.canSetProperty(name) || record.hasRelation(name)) {
record.set(name, value);
}
});
record.setOldAttributes(_clone(record._attributes));
},
/**
* Creates an active record instance.
*
* This method is called together with [[populateRecord()]] by [[ActiveQuery]].
* It is not meant to be used for creating new records() directly.
*
* You may override this method if the instance being created
* depends on the row data to be populated into the record.
* For example, by creating a record based on the value of a column,
* you may implement the so-called single-table inheritance mapping.
* @param {object} row row data to be populated into the record.
* @returns {Jii.base.BaseActiveRecord} the newly created active record
*/
instantiate(row) {
return new this(row);
},
/**
* Returns a value indicating whether the given set of attributes represents the primary key for this model
* @param {[]} keys the set of attributes to check
* @returns {boolean} whether the given set of attributes represents the primary key for this model
*/
isPrimaryKey(keys) {
var pks = this.primaryKey();
if (keys.length !== _size(pks)) {
return false;
}
return (!_isArray(pks) ? _keys(pks) : pks).sort().toString() === keys.sort().toString();
}
},
/**
* @type {object} related models indexed by the relation names
*/
_related: {},
/**
* @type {object}
*/
_relatedFetched: {},
/**
* @type {object}
*/
_relatedEvents: {},
/**
* @type {object|null} old attribute values indexed by attribute names.
* This is `null` if the record [[isNewRecord|is new]].
*/
_oldAttributes: null,
/**
* Initializes the object.
* This method is called at the end of the constructor.
* The default implementation will trigger an [[EVENT_INIT]] event.
* If you override this method, make sure you call the parent implementation at the end
* to ensure triggering of the event.
*/
init() {
this.trigger(this.__static.EVENT_INIT);
this.__super();
},
/**
* Declares a `has-one` relation.
* The declaration is returned in terms of a relational [[ActiveQuery]] instance
* through which the related record can be queried and retrieved back.
*
* A `has-one` relation means that there is at most one related record matching
* the criteria set by this relation, e.g., a customer has one country.
*
* For example, to declare the `country` relation for `Customer` class, we can write
* the following code in the `Customer` class:
*
* ~~~
* public function getCountry()
* {
* return this.hasOne(Country.className(), {id: 'country_id'});
* }
* ~~~
*
* Note that in the above, the 'id' key in the `link` parameter refers to an attribute name
* in the related class `Country`, while the 'country_id' value refers to an attribute name
* in the current AR class.
*
* Call methods declared in [[ActiveQuery]] to further customize the relation.
*
* @param {string} className the class name of the related record
* @param {object} link the primary-foreign key constraint. The keys of the array refer to
* the attributes of the record associated with the `class` model, while the values of the
* array refer to the corresponding attributes in **this** AR class.
* @returns {Jii.base.ActiveQuery} the relational query object.
*/
hasOne(className, link) {
/** @typedef {Jii.sql.ActiveRecord} classObject */
var classObject = Jii.namespace(className);
/** @typedef {Jii.data.ActiveQuery} query */
var query = classObject.find();
query.primaryModel = this;
query.link = link;
query.multiple = false;
return query;
},
/**
* Declares a `has-many` relation.
* The declaration is returned in terms of a relational [[ActiveQuery]] instance
* through which the related record can be queried and retrieved back.
*
* A `has-many` relation means that there are multiple related records matching
* the criteria set by this relation, e.g., a customer has many orders.
*
* For example, to declare the `orders` relation for `Customer` class, we can write
* the following code in the `Customer` class:
*
* ~~~
* public function getOrders()
* {
* return this.hasMany(Order.className(), {customer_id: 'id'});
* }
* ~~~
*
* Note that in the above, the 'customer_id' key in the `link` parameter refers to
* an attribute name in the related class `Order`, while the 'id' value refers to
* an attribute name in the current AR class.
*
* Call methods declared in [[ActiveQuery]] to further customize the relation.
*
* @param {string} className the class name of the related record
* @param {object} link the primary-foreign key constraint. The keys of the array refer to
* the attributes of the record associated with the `class` model, while the values of the
* array refer to the corresponding attributes in **this** AR class.
* @returns {Jii.base.ActiveQuery} the relational query object.
*/
hasMany(className, link) {
var classObject = Jii.namespace(className);
/** @type {Jii.base.ActiveQuery} */
var query = classObject.find();
query.primaryModel = this;
query.link = link;
query.multiple = true;
return query;
},
load(name) {
this._fetchRelationFromRoot(name);
if (this._related[name]) {
return Promise.resolve(this._related[name]);
}
var relation = this.getRelation(name);
if (_isFunction(relation.findFor)) {
return relation.findFor(name, this).then(models => {
this._setRelated(name, relation.multiple ? this._createRelatedCollection(name, models) : models);
return this._related[name];
});
}
return relation;
},
/**
* Populates the named relation with the related records.
* Note that this method does not check if the relation exists or not.
* @param {string} name the relation name (case-sensitive)
* @param {Jii.base.BaseActiveRecord|Jii.base.BaseActiveRecord[]|null} records the related records to be populated into the relation.
*/
populateRelation(name, records) {
this._setRelated(name, _isArray(records) ? this._createRelatedCollection(name, records) : records);
},
/**
* Check whether the named relation has been populated with records.
* @param {string} name the relation name (case-sensitive)
* @returns {boolean} whether relation has been populated with records.
*/
isRelationPopulated(name) {
return _has(this._related, name);
},
/**
* Returns all populated related records.
* @returns {object} an array of related records indexed by relation names.
*/
getRelatedRecords() {
return this._related;
},
/**
* Get attribute value
* @param {String} name
* @returns {*}
*/
get(name) {
if (this.hasRelation(name)) {
var relation = this.getRelation(name);
this._fetchRelationFromRoot(name);
if (!this._related[name] && relation.multiple) {
this._setRelated(name, this._createRelatedCollection(name));
}
return this._related[name] || null;
}
return this.__super(name);
},
/**
* Set attribute value
* @param {object|string} name
* @param {*} [value]
*/
set(name, value) {
if (this.hasRelation(name)) {
if (value === null) {
this._removeRelated(name);
return;
}
this._fetchRelationFromRoot(name);
if (this._related[name]) {
this._related[name].set(value);
} else {
var relation = this.getRelation(name);
if (relation.multiple) {
var models = !_isArray(value) ? [value] : value;
this._setRelated(name, this._createRelatedCollection(name, models));
} else {
var model = value;
if (!(model instanceof Model)) {
var _class = relation.modelClass;
/** @typedef {Jii.sql.ActiveRecord} model */
model = _class.instantiate(value);
_class.populateRecord(model, value);
}
this._setRelated(name, model);
}
}
return;
}
this.__super(name, value);
},
/**
* @param {string|string[]} name
* @param {function} handler
* @param {*} [data]
* @param {boolean} [isAppend]
*/
on(name, handler, data, isAppend) {
// Multiple names support
name = this._normalizeEventNames(name);
if (name.length > 1) {
_each(name, n => {
this.on(n, handler, data, isAppend)
});
return;
} else {
name = name[0];
}
// Sub models support: foo[0]
var collectionFormat = this._detectKeyFormatCollection(name, this.__static.EVENT_CHANGE_NAME);
if (collectionFormat) {
var collEventName = collectionFormat.subName || this.__static.EVENT_CHANGE;
collectionFormat.model.on(collEventName, handler, data, isAppend);
return;
}
// Sub models support: foo.bar
var modelFormat = this._detectKeyFormatModel(name, this.__static.EVENT_CHANGE_NAME);
if (modelFormat) {
this._relatedEvents[modelFormat.name] = this._relatedEvents[modelFormat.name] || [];
this._relatedEvents[modelFormat.name].push([modelFormat.subName, handler, data, isAppend]);
if (modelFormat.model) {
modelFormat.model.on(modelFormat.subName, handler, data, isAppend);
}
return;
}
// Relation support
var relationFormat = this._detectKeyFormatRelation(name, this.__static.EVENT_CHANGE_NAME);
if (relationFormat) {
var relationEvent = relationFormat.multiple ? Collection.EVENT_CHANGE : this.__static.EVENT_CHANGE;
this._relatedEvents[relationFormat.name] = this._relatedEvents[relationFormat.name] || [];
this._relatedEvents[relationFormat.name].push([relationEvent, handler, data, isAppend]);
if (relationFormat.model) {
relationFormat.model.on(relationEvent, handler, data, isAppend);
}
}
this.__super(name, handler, data, isAppend);
},
/**
* @param {string|string[]} name
* @param {function} [handler]
* @return boolean
*/
off(name, handler) {
// Multiple names support
name = this._normalizeEventNames(name);
if (name.length > 1) {
var bool = false;
_each(name, n => {
if (this.off(n, handler)) {
bool = true;
}
});
return bool;
} else {
name = name[0];
}
// Sub models support: foo[0]
var collectionFormat = this._detectKeyFormatCollection(name, this.__static.EVENT_CHANGE_NAME);
if (collectionFormat) {
var collEventName = collectionFormat.subName || this.__static.EVENT_CHANGE;
return collectionFormat.model.off(collEventName, handler);
}
// Sub models support: foo.bar
var modelFormat = this._detectKeyFormatModel(name, this.__static.EVENT_CHANGE_NAME);
if (modelFormat) {
if (this._relatedEvents[modelFormat.name]) {
this._relatedEvents[modelFormat.name] = _filter(this._relatedEvents[modelFormat.name], arr => {
return arr[0] !== modelFormat.subName || arr[1] !== handler;
});
}
if (modelFormat.model) {
return modelFormat.model.off(modelFormat.subName, handler);
}
}
// Relation support
var relationFormat = this._detectKeyFormatRelation(name, this.__static.EVENT_CHANGE_NAME);
if (relationFormat) {
var relationEvent = relationFormat.multiple ? Collection.EVENT_CHANGE : this.__static.EVENT_CHANGE;
if (this._relatedEvents[relationFormat.name]) {
this._relatedEvents[relationFormat.name] = _filter(this._relatedEvents[relationFormat.name], arr => {
return arr[0] !== relationEvent || arr[1] !== handler;
});
}
if (relationFormat.model) {
return relationFormat.model.off(relationEvent, handler);
}
}
return this.__super(name, handler);
},
/**
*
* @param {string} name
* @param {string} [prefix]
* @returns {{model: Jii.base.BaseActiveRecord|null, name: string}|null}
* @protected
*/
_detectKeyFormatRelation(name, prefix) {
prefix = prefix || '';
if (prefix && name.indexOf(prefix) !== 0) {
return null;
}
name = name.substr(prefix.length);
if (!this.hasRelation(name)) {
return null;
}
var multiple = null;
this._fetchRelationFromRoot(name);
if (this._related[name]) {
multiple = this._related[name] instanceof Collection;
}
if (multiple === null) {
multiple = this.getRelation(name).multiple;
}
return {
model: this.get(name),
name: name,
multiple: multiple
};
},
/**
*
* @param {string} name
* @param value
* @protected
*/
_setRelated(name, value) {
if (this._related[name] && this._related[name] === value) {
return;
}
this._related[name] = value;
// Attach events
_each(this._relatedEvents[name] || {}, args => {
this._related[name].on.apply(this._related[name], args);
});
this.trigger(this.__static.EVENT_CHANGE_NAME + name, new ChangeAttributeEvent({
attribute: name,
oldValue: null,
newValue: value,
isRelation: true
}));
},
/**
*
* @param {string} name
* @protected
*/
_removeRelated(name) {
var oldValue = this._related[name];
if (!oldValue) {
return;
}
delete this._related[name];
// Detach events
_each(this._relatedEvents[name] || {}, args => {
oldValue.off(args[0], args[1]);
});
this.trigger(this.__static.EVENT_CHANGE_NAME + name, new ChangeAttributeEvent({
attribute: name,
oldValue: oldValue,
newValue: null,
isRelation: true
}));
oldValue.off(this.__static.EVENT_CHANGE, {
callback: this._onChangeRelatedModel,
context: this
});
},
/**
*
* @param {string} name
* @returns {boolean}
*/
hasAttribute(name) {
return _has(this._attributes, name) || _indexOf(this.attributes(), name) !== -1;
},
/**
* Returns the old attribute values.
* @returns {object} the old attribute values (name-value pairs)
*/
getOldAttributes() {
return this._oldAttributes || {};
},
/**
* Sets the old attribute values.
* All existing old attribute values will be discarded.
* @param {{}|null} values old attribute values to be set.
* If set to `null` this record is considered to be [[isNewRecord|new]].
*/
setOldAttributes(values) {
this._oldAttributes = values;
},
/**
* Returns the old value of the named attribute.
* If this record is the result of a query and the attribute is not loaded,
* null will be returned.
* @param {string} name the attribute name
* @returns {*} the old attribute value. Null if the attribute is not loaded before
* or does not exist.
* @see hasAttribute()
*/
getOldAttribute(name) {
return _has(this._oldAttributes, name) ? this._oldAttributes[name] : null;
},
/**
* Sets the old value of the named attribute.
* @param {string} name the attribute name
* @param {*} value the old attribute value.
* @throws {Jii.exceptions.InvalidParamException} if the named attribute does not exist.
* @see hasAttribute()
*/
setOldAttribute(name, value) {
if (_has(this._oldAttributes, name) || this.hasAttribute(name)) {
if (this._oldAttributes === null) {
this._oldAttributes = {};
}
this._oldAttributes[name] = value;
}
throw new InvalidParamException(this.className() + ' has no attribute named "' + name + '".');
},
/**
* Marks an attribute dirty.
* This method may be called to force updating a record when calling [[update()]],
* even if there is no change being made to the record.
* @param {string} name the attribute name
*/
markAttributeDirty(name) {
delete this._oldAttributes[name];
},
/**
* Returns a value indicating whether the named attribute has been changed.
* @param {string} name the name of the attribute
* @returns {boolean} whether the attribute has been changed
*/
isAttributeChanged(name) {
if (_has(this._attributes, name) && this._oldAttributes && _has(this._oldAttributes, name)) {
return !_isEqual(this._attributes[name], this._oldAttributes[name]);
}
return _has(this._attributes, name) || (this._oldAttributes && _has(this._oldAttributes, name));
},
/**
* Returns the attribute values that have been modified since they are loaded or saved most recently.
* @param {string[]|null} names the names of the attributes whose values may be returned if they are
* changed recently. If null, [[attributes()]] will be used.
* @returns {object} the changed attribute values (name-value pairs)
*/
getDirtyAttributes(names) {
names = names || null;
if (names === null) {
names = this.attributes();
}
var attributes = {};
_each(this._attributes, (value, name) => {
if (_indexOf(names, name) === -1) {
return;
}
if (this._oldAttributes === null || !_has(this._oldAttributes, name) || !_isEqual(this._oldAttributes[name], value)) {
attributes[name] = value;
}
});
return attributes;
},
/**
* Returns the list of all attribute names of the model.
* The default implementation will return all column names of the table associated with this AR class.
* @return {string[]} list of attribute names.
*/
attributes() {
return _keys(this.__static.getTableSchema().columns);
},
/**
* Saves the current record.
*
* This method will call [[insert()]] when [[isNewRecord]] is true, or [[update()]]
* when [[isNewRecord]] is false.
*
* For example, to save a customer record:
*
* ~~~
* customer = new Customer(); // or customer = Customer.findOne(id);
* customer.name = name;
* customer.email = email;
* customer.save();
* ~~~
*
*
* @param {boolean} [runValidation] whether to perform validation before saving the record.
* If the validation fails, the record will not be saved to database.
* @param {string[]} [attributeNames] list of attribute names that need to be saved. Defaults to null,
* meaning all attributes that are loaded from DB will be saved.
* @returns {boolean} whether the saving succeeds
*/
save(runValidation, attributeNames) {
runValidation = runValidation !== false;
attributeNames = attributeNames || null;
if (this.isNewRecord()) {
return this.insert(runValidation, attributeNames);
} else {
return this.update(runValidation, attributeNames).then(result => {
return result !== false;
});
}
},
/**
* Inserts the record into the database using the attribute values of this record.
*
* Usage example:
*
* ```php
* $customer = new Customer;
* $customer->name = $name;
* $customer->email = $email;
* $customer->insert();
* ```
*
* @param {boolean} runValidation whether to perform validation before saving the record.
* If the validation fails, the record will not be inserted into the database.
* @param {object} attributeNames list of attributes that need to be saved. Defaults to null,
* meaning all attributes that are loaded from DB will be saved.
* @return boolean whether the attributes are valid and the record is inserted successfully.
*/
insert(runValidation, attributeNames) {
},
/**
* Saves the changes to this active record into the associated database table.
*
* This method performs the following steps in order:
*
* 1. call [[beforeValidate()]] when `runValidation` is true. If validation
* fails, it will skip the rest of the steps;
* 2. call [[afterValidate()]] when `runValidation` is true.
* 3. call [[beforeSave()]]. If the method returns false, it will skip the
* rest of the steps;
* 4. save the record into database. If this fails, it will skip the rest of the steps;
* 5. call [[afterSave()]];
*
* In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]],
* [[EVENT_BEFORE_UPDATE]], [[EVENT_AFTER_UPDATE]] and [[EVENT_AFTER_VALIDATE]]
* will be raised by the corresponding methods.
*
* Only the [[dirtyAttributes|changed attribute values]] will be saved into database.
*
* For example, to update a customer record:
*
* ~~~
* customer = Customer.findOne(id);
* customer.name = name;
* customer.email = email;
* customer.update();
* ~~~
*
* Note that it is possible the update does not affect any row in the table.
* In this case, this method will return 0. For this reason, you should use the following
* code to check if update() is successful or not:
*
* ~~~
* if (this.update() !== false) {
* // update successful
* } else {
* // update failed
* }
* ~~~
*
* @param {boolean} [runValidation] whether to perform validation before saving the record.
* If the validation fails, the record will not be inserted into the database.
* @param {string[]} [attributeNames] list of attribute names that need to be saved. Defaults to null,
* meaning all attributes that are loaded from DB will be saved.
* @returns {Promise.<number|boolean>} the number of rows affected, or false if validation fails
* or [[beforeSave()]] stops the updating process.
* @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
* being updated is outdated.
* @throws \Exception in case update failed.
*/
update(runValidation, attributeNames) {
runValidation = runValidation !== false;
attributeNames = attributeNames || null;
var validatePromise = runValidation ? this.validate(attributeNames) : Promise.resolve(true);
return validatePromise.then(isValid => {
if (!isValid) {
return false;
}
return this._updateInternal(attributeNames);
});
},
/**
* Updates the specified attributes.
*
* This method is a shortcut to [[update()]] when data validation is not needed
* and only a small set attributes need to be updated.
*
* You may specify the attributes to be updated as name list or name-value pairs.
* If the latter, the corresponding attribute values will be modified accordingly.
* The method will then save the specified attributes into database.
*
* Note that this method will **not** perform data validation and will **not** trigger events.
*
* @param {[]} attributes the attributes (names or name-value pairs) to be updated
* @returns {Promise.<number>} the number of rows affected.
*/
updateAttributes(attributes) {
var attrs = [];
_each(attributes, (value, name) => {
if (_isNumber(name)) {
attrs.push(value);
} else {
this.set(name, value);
attrs.push(name);
}
});
var values = this.getDirtyAttributes(attrs);
if (_isEmpty(values)) {
return Promise.resolve(0);
}
var oldPrimaryKey = this.getOldPrimaryKey(true);
return this.__static.updateAll(values, oldPrimaryKey)
.then(rows => {
_each(values, (value, name) => {
this._oldAttributes[name] = this._attributes[name];
});
return rows;
});
},
/**
* @see update()
* @param {[]} [attributes] attributes to update
* @returns {Promise.<number>} number of rows updated
* @throws StaleObjectException
*/
_updateInternal(attributes) {
attributes = attributes || null;
var values = null;
return this.beforeSave(false).then(bool => {
if (!bool) {
return Promise.resolve(false);
}
values = this.getDirtyAttributes(attributes);
if (_isEmpty(values)) {
return this.afterSave(false, values).then(() => {
return 0;
});
}
return this.__static.getDb().createCommand().updateModel(this, values);
}).then(rows => {
var changedAttributes = {};
_each(values, (value, name) => {
changedAttributes[name] = _has(this._oldAttributes, name) ? this._oldAttributes[name] : null;
this._oldAttributes[name] = value;
});
return this.afterSave(false, changedAttributes).then(() => {
return rows;
});
});
},
/**
* Updates one or several counter columns for the current AR object.
* Note that this method differs from [[updateAllCounters()]] in that it only
* saves counters for the current AR object.
*
* An example usage is as follows:
*
* ~~~
* post = Post.findOne(id);
* post.updateCounters({view_count: 1});
* ~~~
*
* @param {[]} counters the counters to be updated (attribute name => increment value)
* Use negative values if you want to decrement the counters.
* @returns {boolean} whether the saving is successful
* @see updateAllCounters()
*/
updateCounters(counters) {
var oldPrimaryKey = this.getOldPrimaryKey(true);
return this.__static.updateAllCounters(_clone(counters), oldPrimaryKey)
.then(affectedRows => {
if (affectedRows === 0) {
return Promise.resolve(false);
}
_each(counters, (value, name) => {
this._attributes[name] += value;
this._oldAttributes[name] = this._attributes[name];
});
return Promise.resolve(true);
});
},
/**
* Deletes the table row corresponding to this active record.
*
* This method performs the following steps in order:
*
* 1. call [[beforeDelete()]]. If the method returns false, it will skip the
* rest of the steps;
* 2. delete the record from the database;
* 3. call [[afterDelete()]].
*
* In the above step 1 and 3, events named [[EVENT_BEFORE_DELETE]] and [[EVENT_AFTER_DELETE]]
* will be raised by the corresponding methods.
*
* @returns {number|boolean} the number of rows deleted, or false if the deletion is unsuccessful for some reason.
* Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
* @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
* being deleted is outdated.
* @throws \Exception in case delete failed.
*/
delete() {
return this.beforeDelete().then(bool => {
if (!bool) {
return Promise.resolve(false);
}
var condition = this.getOldPrimaryKey(true);
// we do not check the return value of deleteAll() because it's possible
// the record is already deleted in the database and thus the method will return 0
return this.__static.deleteAll(condition);
}).then(result => {
this._oldAttributes = null;
return this.afterDelete().then(() => {
return result;
});
});
},
/**
* Returns a value indicating whether the current record is new.
* @returns {boolean} whether the record is new and() should be inserted when calling [[save()]].
*/
isNewRecord() {
return this._oldAttributes === null;
},
/**
* Sets the value indicating whether the record is new.
* @param {boolean} value whether the record is new and() should be inserted when calling [[save()]].
* @see isNewRecord()
*/
setIsNewRecord(value) {
this._oldAttributes = value ? null : _clone(this._attributes);
},
/**
* This method is called when the AR object is created and populated with the query result.
* The default implementation will trigger an [[EVENT_AFTER_FIND]] event.
* When overriding this method, make sure you call the parent implementation to ensure the
* event is triggered.
*/
afterFind() {
this.trigger(this.__static.EVENT_AFTER_FIND);
return Promise.resolve();
},
/**
* This method is called at the beginning of inserting or updating a record.
* The default implementation will trigger an [[EVENT_BEFORE_INSERT]] event when `insert` is true,
* or an [[EVENT_BEFORE_UPDATE]] event if `insert` is false.
* When overriding this method, make sure you call the parent implementation like the following:
*
* ~~~
* public function beforeSave(insert)
* {
* if (parent.beforeSave(insert)) {
* // ...custom code here...
* return true;
* } else {
* return false;
* }
* }
* ~~~
*
* @param {boolean} insert whether this method called while inserting a record.
* If false, it means the method is called while updating a record.
* @returns {Promise.<boolean>} whether the insertion or updating should continue.
* If false, the insertion or updating will be cancelled.
*/
beforeSave(insert) {
var event = new ModelEvent();
this.trigger(insert ? this.__static.EVENT_BEFORE_INSERT : this.__static.EVENT_BEFORE_UPDATE, event);
return Promise.resolve(event.isValid);
},
/**
* This method is called at the end of inserting or updating a record.
* The default implementation will trigger an [[EVENT_AFTER_INSERT]] event when `insert` is true,
* or an [[EVENT_AFTER_UPDATE]] event if `insert` is false. The event class used is [[AfterSaveEvent]].
* When overriding this method, make sure you call the parent implementation so that
* the event is triggered.
* @param {boolean} insert whether this method called while inserting a record.
* If false, it means the method is called while updating a record.
* @param {object} changedAttributes The old values of attributes that had changed and were saved.
* You can use this parameter to take action based on the changes made for example send an email
* when the password had changed or implement audit trail that tracks all the changes.
* `changedAttributes` gives you the old attribute values while the active record (`this`) has
* already the new, updated values.
*/
afterSave(insert, changedAttributes) {
var eventName = insert ? this.__static.EVENT_AFTER_INSERT : this.__static.EVENT_AFTER_UPDATE;
this.trigger(eventName, new AfterSaveEvent({
changedAttributes: changedAttributes
}));
return Promise.resolve();
},
/**
* This method is invoked before deleting a record.
* The default implementation raises the [[EVENT_BEFORE_DELETE]] event.
* When overriding this method, make sure you call the parent implementation like the following:
*
* ~~~
* public function beforeDelete()
* {
* if (parent.beforeDelete()) {
* // ...custom code here...
* return true;
* } else {
* return false;
* }
* }
* ~~~
*
* @returns {boolean} whether the record should be deleted. Defaults to true.
*/
beforeDelete() {
var event = new ModelEvent();
this.trigger(this.__static.EVENT_BEFORE_DELETE, event);
return Promise.resolve(event.isValid);
},
/**
* This method is invoked after deleting a record.
* The default implementation raises the [[EVENT_AFTER_DELETE]] event.
* You may override this method to do postprocessing after the record is deleted.
* Make sure you call the parent implementation so that the event is raised properly.
*/
afterDelete() {
this.trigger(this.__static.EVENT_AFTER_DELETE);
return Promise.resolve();
},
/**
* Repopulates this active record with the latest data.
* @returns {boolean} whether the row still exists in the database. If true, the latest data
* will be populated to this active record. Otherwise, this record will remain unchanged.
*/
refresh() {
var primaryKey = this.getPrimaryKey(true);
return this.__static.findOne(primaryKey).then(record => {
if (record === null) {
return Promise.resolve(false);
}
_each(this.attributes(), name => {
this._attributes[name] = _has(record._attributes, name) ? record._attributes[name] : null;
});
this._oldAttributes = _clone(this._attributes);
_each(this._related, (relation, name) => {
this._removeRelated(name);
});
return Promise.resolve(true);
});
},
/**
* Returns a value indicating whether the given active record is the same as the current one.
* The comparison is made by comparing the table names and the primary key values of the two active records.
* If one of the records [[isNewRecord|is new]] they are also considered not equal.
* @param {Jii.base.BaseActiveRecord} record record to compare to
* @returns {boolean} whether the two active records refer to the same row in the same database table.
*/
equals(record) {
if (this.isNewRecord() || record.isNewRecord()) {
return false;
}
if (this.className() !== record.className()) {
return false;
}
return this.getPrimaryKey().toString() === record.getPrimaryKey().toString();
},
/**
* Returns the primary key value(s).
* @param {boolean} [asArray] whether to return the primary key value as an array. If true,
* the return value will be an array with column names as keys and column values as values.
* Note that for composite primary keys, an array will always be returned regardless of this parameter value.
* @property mixed The primary key value. An array (column name => column value) is returned if
* the primary key is composite. A string is returned otherwise (null will be returned if
* the key value is null).
* @returns {*} the primary key value. An array (column name => column value) is returned if the primary key
* is composite or `asArray` is true. A string is returned otherwise (null will be returned if
* the key value is null).
*/
getPrimaryKey(asArray) {
asArray = asArray || false;
var keys = this.__static.primaryKey();
if (keys.length === 1 && !asArray) {
return _has(this._attributes, keys[0]) ? this._attributes[keys[0]] : null;
}
var values = {};
_each(keys, name => {
values[name] = _has(this._attributes, name) ? this._attributes[name] : null;
});
return values;
},
/**
* Returns the old primary key value(s).
* This refers to the primary key value that is populated into the record
* after executing a find method (e.g. find(), findOne()).
* The value remains unchanged even if the primary key attribute is manually assigned with a different value.
* @param {boolean} [asArray] whether to return the primary key value as an array. If true,
* the return value will be an array with column name as key and column value as value.
* If this is false (default), a scalar value will be returned for non-composite primary key.
* @property mixed The old primary key value. An array (column name => column value) is
* returned if the primary key is composite. A string is returned otherwise (null will be
* returned if the key value is null).
* @returns {*} the old primary key value. An array (column name => column value) is returned if the primary key
* is composite or `asArray` is true. A string is returned otherwise (null will be returned if
* the key value is null).
*/
getOldPrimaryKey(asArray) {
asArray = asArray || false;
var keys = this.__static.primaryKey();
if (keys.length === 1 && !asArray) {
return _has(this._oldAttributes, keys[0]) ? this._oldAttributes[keys[0]] : null;
}
var values = {};
_each(keys, name => {
values[name] = _has(this._oldAttributes, name) ? this._oldAttributes[name] : null;
});
return values;
},
/**
* Returns the relation object with the specified name.
* A relation is defined by a getter method which returns an [[ActiveQueryInterface]] object.
* It can be declared in either the Active Record class itself or one of its behaviors.
* @param {string} name the relation name
* @param {boolean} [throwException] whether to throw exception if the relation does not exist.
* @returns {Promise.<Jii.base.ActiveQuery>} the relational query object. If the relation does not exist
* and `throwException` is false, null will be returned.
* @throws {Jii.exceptions.InvalidParamException} if the named relation does not exist.
*/
getRelation(name, throwException) {
throwException = !_isUndefined(throwException) ? throwException : true;
var getter = 'get' + _upperFirst(name);
if (_isFunction(this[getter])) {
return this[getter]();
} else if (throwException) {
throw new InvalidParamException(this.className() + ' has no relation named `' + name + '`.');
}
return null;
},
/**
*
* @param {string} name
* @returns {boolean}
*/
hasRelation(name) {
var getter = 'get' + _upperFirst(name);
return _isFunction(this[getter]);
},
/**
* Establishes the relationship between two models.
*
* The relationship is established by setting the foreign key value(s) in one model
* to be the corresponding primary key value(s) in the other model.
* The model with the foreign key will be saved into database without performing validation.
*
* If the relationship involves a pivot table, a new row() will be inserted into the
* pivot table which contains the primary key values from both models.
*
* Note that this method requires that the primary key value is not null.
*
* @param {string} name the case sensitive name of the relationship
* @param {Jii.base.BaseActiveRecord} model the model to be linked with the current one.
*