jii-model
Version:
916 lines (786 loc) • 26.7 kB
JavaScript
/**
* @author <a href="http://www.affka.ru">Vladimir Kozhin</a>
* @license MIT
*/
'use strict';
var Jii = require('jii');
var Validator = require('../validators/Validator');
var RequiredValidator = require('../validators/RequiredValidator');
var ChangeAttributeEvent = require('../model/ChangeAttributeEvent');
var ChangeEvent = require('../model/ChangeEvent');
var InvalidParamException = require('jii/exceptions/InvalidParamException');
var UnknownPropertyException = require('jii/exceptions/UnknownPropertyException');
var ApplicationException = require('jii/exceptions/ApplicationException');
var ValidateEvent = require('../model/ValidateEvent');
var _isObject = require('lodash/isObject');
var _isEmpty = require('lodash/isEmpty');
var _isEqual = require('lodash/isEqual');
var _isUndefined = require('lodash/isUndefined');
var _indexOf = require('lodash/indexOf');
var _isNumber = require('lodash/isNumber');
var _isArray = require('lodash/isArray');
var _isString = require('lodash/isString');
var _each = require('lodash/each');
var _has = require('lodash/has');
var _map = require('lodash/map');
var _keys = require('lodash/keys');
var _startCase = require('lodash/startCase');
var Component = require('jii/base/Component');
/**
* @class Jii.base.Model
* @extends Jii.base.Component
*/
var Model = Jii.defineClass('Jii.base.Model', /** @lends Jii.base.Model.prototype */{
__extends: Component,
_attributes: {},
_errors: {},
_validators: null,
_scenario: 'default',
_editedLevel: 0,
_editedSubModels: [],
_editedChanges: {},
__static: /** Jii.base.Model */{
/**
* @event Jii.base.Model#change
* @property {Jii.model.ChangeEvent} event
*/
EVENT_CHANGE: 'change',
/**
* @event Jii.base.Model#change:
* @property {Jii.model.ChangeAttributeEvent} event
*/
EVENT_CHANGE_NAME: 'change:',
/**
* @event Jii.base.Model#before_validate
* @property {Jii.model.ValidateEvent} event
*/
EVENT_BEFORE_VALIDATE: 'before_validate',
/**
* @event Jii.base.Model#change_errors
* @property {Jii.model.ValidateEvent} event
*/
EVENT_CHANGE_ERRORS: 'change_errors',
/**
* @event Jii.base.Model#after_validate
* @property {Jii.model.ValidateEvent} event
*/
EVENT_AFTER_VALIDATE: 'after_validate'
},
/**
* @constructor
*/
constructor(attributes, config) {
if (_isObject(attributes)) {
this.set(attributes);
}
this.__super(config);
},
/**
* Validation rules
* @returns {Array}
*/
rules() {
return [];
},
/**
* Begin change operation
*/
beginEdit() {
this._editedLevel++;
},
/**
* Cancel all changes after beginEdit() call
*/
cancelEdit() {
if (this._editedLevel > 0) {
this._editedLevel--;
}
// Cancel in sub-models
if (this._editedLevel === 0) {
_each(this._editedSubModels, subModel => {
subModel.cancelEdit();
});
// Revert attribute changes
_each(this._editedChanges, (values, name) => {
this._attributes[name] = values[0];
});
}
},
/**
* End change operation - trigger change events
*/
endEdit() {
if (this._editedLevel > 0) {
this._editedLevel--;
}
if (this._editedLevel === 0) {
// End in sub-models
_each(this._editedSubModels, subModel => {
subModel.endEdit();
});
// Trigger change attribute events
if (!_isEmpty(this._editedChanges)) {
_each(this._editedChanges, (values, name) => {
this.trigger(this.__static.EVENT_CHANGE_NAME + name, new ChangeAttributeEvent({
sender: this,
attribute: name,
oldValue: values[0],
newValue: values[1],
changedAttributes: this._editedChanges
}));
});
// Trigger change event
this.trigger(this.__static.EVENT_CHANGE, new ChangeEvent({
sender: this,
changedAttributes: this._editedChanges
}));
}
// Reset state
this._editedSubModels = [];
this._editedChanges = {};
}
},
/**
* Get attribute value
* @param {String} name
* @returns {*}
*/
get(name) {
if (this.hasAttribute(name)) {
return this.getAttribute(name);
}
// Sub models support: foo[0]
var collectionFormat = this._detectKeyFormatCollection(name, '', true);
if (collectionFormat) {
return collectionFormat.subName ?
collectionFormat.model.get(collectionFormat.subName) :
collectionFormat.model;
}
// Sub models support: foo.bar
var modelFormat = this._detectKeyFormatModel(name);
if (modelFormat) {
return modelFormat.model ?
modelFormat.model.get(modelFormat.subName) :
null;
}
try {
return this.__super(name);
} catch (e) {
if (!(e instanceof UnknownPropertyException)) {
throw e;
}
return null;
}
},
/**
* Set attribute value
* @param {object|string} name
* @param {*} [value]
*/
set(name, value) {
// Object format support
if (_isObject(name)) {
this.beginEdit();
var isChanged = false;
_each(name, (value, name) => {
if (this.set(name, value)) {
isChanged = true;
}
});
this.endEdit();
return isChanged;
}
// Sub models support: foo[0].bar.zen
var subMatches = /^(.+)\.([^\[\].]+)$/.exec(name);
if (subMatches !== null) {
var subModel = this.get(subMatches[1]);
// Check sub-model is Model
var Collection = require('./Collection');
if (subModel instanceof Collection) {
throw new InvalidParamException('Try set property of array models: `' + name + '`');
} else if (!(subModel instanceof module.exports)) {
throw new UnknownPropertyException('Setting property of null sub-model `' + name + '`');
}
subModel.beginEdit();
this._editedSubModels.push(subModel);
var isSubChanged = subModel.set(subMatches[2], value);
this.endEdit();
return isSubChanged;
}
if (this.hasAttribute(name)) {
this.beginEdit();
var oldValue = this._attributes[name];
var isAttributeChanged = !_isEqual(oldValue, value);
this._attributes[name] = value;
if (isAttributeChanged) {
this._editedChanges[name] = [oldValue, value];
}
this.endEdit();
return isAttributeChanged;
}
this.__super(name, value);
},
/**
*
* @param {string} name
* @param {string} [prefix]
* @param {boolean} [skipThrow]
* @returns {{model: Jii.base.BaseActiveRecord, name: string, subName: string}|null}
* @protected
*/
_detectKeyFormatCollection(name, prefix, skipThrow) {
prefix = prefix || '';
skipThrow = skipThrow || false;
// Sub models support: change:foo[0]
var arrRegExp = new RegExp('^' + prefix + '([^\\[\\].]+)\\[([-0-9]+)\\](\\.(.+))?$');
var arrMatches = arrRegExp.exec(name);
if (arrMatches === null) {
return null;
}
var collection = this.get(arrMatches[1]);
var Collection = require('./Collection');
if (collection instanceof Collection) {
var index = parseInt(arrMatches[2]);
var arrSubModel = collection.at(index);
if (arrSubModel) {
return {
model: arrSubModel,
name: arrMatches[1],
subName: arrMatches[4] ? prefix + arrMatches[4] : null,
index: index
};
} else if (!skipThrow) {
throw new InvalidParamException('Model with index `' + index + '` in collection `' + arrMatches[1] + '` is not found.');
}
} else if (!skipThrow) {
throw new InvalidParamException('Relation `' + arrMatches[1] + '` is not collection.');
}
return null;
},
/**
*
* @param {string} name
* @param {string} [prefix]
* @returns {{model: Jii.base.BaseActiveRecord|null, name: string, subName: string}|null}
* @protected
*/
_detectKeyFormatModel(name, prefix) {
prefix = prefix || '';
if (prefix && name.indexOf(prefix) !== 0) {
return null;
}
name = name.substr(prefix.length);
var dotIndex = name.indexOf('.');
if (dotIndex === -1) {
return null;
}
var relationName = name.substr(0, dotIndex);
return {
model: this.get(relationName),
name: relationName,
subName: prefix + name.substr(dotIndex + 1)
};
},
/**
* Returns the named attribute value.
* 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 attribute value. Null if the attribute is not set or does not exist.
* @see hasAttribute()
*/
getAttribute(name) {
return _has(this._attributes, name) ? this._attributes[name] : null;
},
/**
* Sets the named attribute value.
* @param {string} name the attribute name
* @param {*} value the attribute value.
* @throws {Jii.exceptions.InvalidParamException} if the named attribute does not exist.
* @see hasAttribute()
*/
setAttribute(name, value) {
if (this.hasAttribute(name)) {
this.set(name, value);
} else {
throw new InvalidParamException(this.className() + ' has no attribute named "' + name + '".');
}
},
/**
* Update model attributes. This method run change
* and change:* events, if attributes will be changes
* @param attributes
* @param {Boolean} [safeOnly]
* @returns {boolean}
*/
setAttributes(attributes, safeOnly) {
if (_isUndefined(safeOnly)) {
safeOnly = true;
}
var filteredAttributes = {};
var attributeNames = safeOnly ? this.safeAttributes() : this.attributes();
_each(attributes, (value, key) => {
if (_indexOf(attributeNames, key) !== -1) {
filteredAttributes[key] = value;
} else if (safeOnly) {
this.onUnsafeAttribute(key, value);
}
});
return this.set(filteredAttributes);
},
/**
*
* @param {object|Jii.base.ModelAdapterInterface} adapter
*/
createProxy(adapter) {
var cloned = adapter.instance(this);
var attributes = {};
_each(adapter.attributes || this.attributes(), (name, alias) => {
if (_isNumber(alias)) {
alias = name;
}
attributes[alias] = name;
});
// Fill model
var values = {};
_each(attributes, (name, alias) => {
values[alias] = this.get(name);
});
adapter.setValues(this, cloned, values);
// Subscribe for sync
_each(attributes, (name, alias) => {
this.on(
this.__static.EVENT_CHANGE_NAME + name,
/** @param {Jii.model.ChangeAttributeEvent} event */
event => {
var obj = {};
obj[alias] = event.newValue;
adapter.setValues(this, cloned, obj)
}
);
});
return cloned;
},
/**
* This method is invoked when an unsafe attribute is being massively assigned.
* The default implementation will log a warning message if YII_DEBUG is on.
* It does nothing otherwise.
* @param {string} name the unsafe attribute name
* @param {*} value the attribute value
*/
onUnsafeAttribute(name, value) {
if (Jii.debug) {
Jii.trace('Failed to set unsafe attribute `' + name + '` in ' + this.className() + '`');
}
},
/**
* Returns attribute values.
* @param {Array} [names]
* @param {Array} [except]
* @returns {{}} Attribute values (name => value).
*/
getAttributes(names, except) {
var values = {};
if (!_isArray(names)) {
names = this.attributes();
}
_each(names, name => {
if (!_isArray(except) || _indexOf(name, except) === -1) {
values[name] = this.get(name);
}
});
return values;
},
/**
* @param {string[]} names
* @returns {{}}
*/
getAttributesTree(names) {
// Convert string names to tree
var treeNames = {};
_each(names, name => {
var obj = treeNames;
var keys = name.split('.');
_each(keys, key => {
obj[key] = obj[key] || {};
obj = obj[key];
});
});
return this._buildTree(treeNames, this);
},
_buildTree(names, model) {
var obj = {};
_each(names, (child, name) => {
var value = model.get(name);
var Collection = require('./Collection');
if (value instanceof module.exports) {
obj[name] = this._buildTree(child, value);
} else if (value instanceof Collection) {
obj[name] = _map(value.getModels(), item => this._buildTree(child, item));
} else {
obj[name] = value;
}
});
return obj;
},
formName() {
return this.className().replace(/^.*\.([^.]+)$/, '$1');
},
/**
* Get attributes list for this model
* @return {Array}
*/
attributes() {
return _keys(this._attributes);
},
/**
* Check attribute exists in this model
* @param {String} name
* @returns {boolean}
*/
hasAttribute(name) {
//return true;
return _indexOf(this.attributes(), name) !== -1;
},
/**
* Format: attribute => label
* @return {object}
*/
attributeLabels() {
return {};
},
/**
* Get label by attribute name
* @param {string} name
* @returns {string}
*/
getAttributeLabel(name) {
var attributes = this.attributeLabels();
return _has(attributes, name) ? attributes[name] : this.generateAttributeLabel(name);
},
/**
* Format: attribute => hint
* @return {object}
*/
attributeHints() {
return {};
},
/**
* Get hint by attribute name
* @param {string} name
* @returns {string}
*/
getAttributeHint(name) {
var attributes = this.attributeHints();
return _has(attributes, name) ? attributes[name] : '';
},
/**
*
* @param scenario
*/
setScenario(scenario) {
this._scenario = scenario;
},
/**
*
* @returns {string}
*/
getScenario() {
return this._scenario;
},
safeAttributes() {
var scenario = this.getScenario();
var scenarios = this.scenarios();
if (!_has(scenarios, scenario)) {
return [];
}
var attributes = [];
_each(scenarios[scenario], (attribute) => {
if (attribute.substr(0, 1) !== '!') {
attributes.push(attribute);
}
});
return attributes;
},
/**
*
* @returns {*}
*/
activeAttributes() {
var scenario = this.getScenario();
var scenarios = this.scenarios();
if (!_has(scenarios, scenario)) {
return [];
}
var attributes = scenarios[scenario];
_each(attributes, (attribute, i) => {
if (attribute.substr(0, 1) === '!') {
attributes[i] = attribute.substr(1);
}
});
return attributes;
},
/**
*
* @returns {Object}
*/
scenarios() {
var scenarios = {};
scenarios['default'] = [];
_each(this.getValidators(), validator => {
_each(validator.on, scenario => {
scenarios[scenario] = [];
});
_each(validator.except, scenario => {
scenarios[scenario] = [];
});
});
var names = _keys(scenarios);
_each(this.getValidators(), validator => {
var validatorScenarios = validator.on && validator.on.length > 0 ? validator.on : names;
_each(validatorScenarios, name => {
if (!scenarios[name]) {
scenarios[name] = [];
}
if (_indexOf(validator.except, name) !== -1) {
return;
}
_each(validator.attributes, attribute => {
if (_indexOf(scenarios[name], attribute) !== -1) {
return;
}
scenarios[name].push(attribute);
});
});
});
return scenarios;
},
/**
*
* @returns {Array}
*/
createValidators() {
var validators = [];
_each(this.rules(), rule => {
if (rule instanceof Validator) {
validators.push(rule);
} else if (_isArray(rule) && rule.length >= 2) {
var attributes = _isString(rule[0]) ? [rule[0]] : rule[0];
var params = rule[2] || {};
if (params.on) {
params.on = _isString(params.on) ? [params.on] : params.on;
}
var validator = Validator.create(rule[1], this, attributes, params);
validators.push(validator);
} else {
throw new ApplicationException('Invalid validation rule: a rule must specify both attribute names and validator type.');
}
});
return validators;
},
/**
*
* @returns {*}
*/
getValidators() {
if (this._validators === null) {
this._validators = this.createValidators();
}
return this._validators;
},
/**
*
* @param [attribute]
* @returns {Array}
*/
getActiveValidators(attribute) {
var validators = [];
var scenario = this.getScenario();
_each(this.getValidators(), validator => {
if (!validator.isActive(scenario)) {
return;
}
if (attribute && _indexOf(validator.attributes, attribute) === -1) {
return;
}
validators.push(validator);
});
return validators;
},
/**
* Validate model by rules, see rules() method.
* @param {Array} [attributes]
* @param {Boolean} [isClearErrors]
*/
validate(attributes, isClearErrors) {
if (_isUndefined(isClearErrors)) {
isClearErrors = true;
}
if (!attributes) {
attributes = this.activeAttributes();
}
var scenarios = this.scenarios();
var scenario = this.getScenario();
if (!_has(scenarios, scenario)) {
throw new ApplicationException('Unknown scenario `' + scenario + '`.');
}
if (isClearErrors) {
this.clearErrors();
}
return Promise.resolve(this.beforeValidate())
.then(bool => {
if (!bool) {
return Promise.resolve(false);
}
var promises = _map(this.getActiveValidators(), validator => {
return validator.validate(this, attributes);
});
return Promise.all(promises);
})
.then(() => this.afterValidate())
.then(() => {
if (this.hasErrors()) {
return Promise.resolve(false);
}
// Return result
return Promise.resolve(true);
});
},
addError(attribute, error) {
if (!this._errors[attribute]) {
this._errors[attribute] = [];
}
this._errors[attribute].push(error);
this.trigger(this.__static.EVENT_CHANGE_ERRORS, new ValidateEvent({
errors: this._errors
}));
},
setErrors(errors) {
this._errors = errors;
this.trigger(this.__static.EVENT_CHANGE_ERRORS, new ValidateEvent({
errors: this._errors
}));
},
/**
*
* @param [attribute]
* @returns {*}
*/
getErrors(attribute) {
return !attribute ? this._errors : this._errors[attribute] || [];
},
/**
*
* @param [attribute]
* @returns {*}
*/
hasErrors(attribute) {
return attribute ? _has(this._errors, attribute) : !_isEmpty(this._errors);
},
/**
*
* @param [attribute]
* @returns {*}
*/
clearErrors(attribute) {
if (!attribute) {
this._errors = {};
} else if (this._errors) {
delete this._errors[attribute];
}
this.trigger(this.__static.EVENT_CHANGE_ERRORS, new ValidateEvent({
errors: this._errors
}));
},
beforeValidate() {
this.trigger(this.__static.EVENT_BEFORE_VALIDATE, new ValidateEvent());
return true;
},
afterValidate() {
this.trigger(this.__static.EVENT_AFTER_VALIDATE, new ValidateEvent({
errors: this._errors
}));
},
/**
* Returns a value indicating whether the attribute is required.
* This is determined by checking if the attribute is associated with a
* [[\jii\validators\RequiredValidator|required]] validation rule in the
* current [[scenario]].
*
* Note that when the validator has a conditional validation applied using
* [[\jii\validators\RequiredValidator.when|when]] this method will return
* `false` regardless of the `when` condition because it may be called be
* before the model is loaded with data.
*
* @param {string} attribute attribute name
* @returns {boolean} whether the attribute is required
*/
isAttributeRequired(attribute) {
var bool = false;
_each(this.getActiveValidators(attribute), validator => {
if (validator instanceof RequiredValidator && validator.when === null) {
bool = true;
}
});
return bool;
},
/**
* Returns a value indicating whether the attribute is safe for massive assignments.
* @param {string} attribute attribute name
* @returns {boolean} whether the attribute is safe for massive assignments
* @see safeAttributes()
*/
isAttributeSafe(attribute) {
return _indexOf(this.safeAttributes(), attribute) !== -1;
},
/**
* Returns a value indicating whether the attribute is active in the current scenario.
* @param {string} attribute attribute name
* @returns {boolean} whether the attribute is active in the current scenario
* @see activeAttributes()
*/
isAttributeActive(attribute) {
return _indexOf(this.activeAttributes(), attribute) !== -1;
},
/**
* Returns the first error of every attribute in the model.
* @returns {object} the first errors. The array keys are the attribute names, and the array
* values are the corresponding error messages. An empty array will be returned if there is no error.
* @see getErrors()
* @see getFirstError()
*/
getFirstErrors() {
if (_isEmpty(this._errors)) {
return {};
}
var errors = {};
_each(this._errors, (es, name) => {
if (es.length > 0) {
errors[name] = es[0];
}
});
return errors;
},
/**
* Returns the first error of the specified attribute.
* @param {string} attribute attribute name.
* @returns {string|null} the error message. Null is returned if no error.
* @see getErrors()
* @see getFirstErrors()
*/
getFirstError(attribute) {
return _has(this._errors, attribute) ? this._errors[attribute][0] : null;
},
/**
* Generates a user friendly attribute label based on the give attribute name.
* This is done by replacing underscores, dashes and dots with blanks and
* changing the first letter of each word to upper case.
* For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
* @param {string} name the column name
* @returns {string} the attribute label
*/
generateAttributeLabel(name) {
return _startCase(name);
}
});
module.exports = Model;