nodal
Version:
An API Server and Framework for node.js
1,545 lines (1,177 loc) • 37.9 kB
JavaScript
;
const DataTypes = require('./db/data_types.js');
const Database = require('./db/database.js');
const Transaction = require('./db/transaction.js');
const Composer = require('./composer.js');
const ModelArray = require('./model_array.js');
const utilities = require('./utilities.js');
const async = require('async');
const inflect = require('i')();
const deepEqual = require('deep-equal');
const RelationshipGraph = require('./relationship_graph.js');
const Relationships = new RelationshipGraph();
/**
* Basic Model implementation. Optionally interfaces with database.
* @class
*/
class Model {
/**
* @param {Object} modelData Data to load into the object
* @param {optional boolean} fromStorage Is this model being loaded from storage? Defaults to false.
* @param {option boolean} fromSeed Is this model being seeded?
*/
constructor(modelData, fromStorage, fromSeed) {
modelData = modelData || {};
this.__initialize__();
this.__load__(modelData, fromStorage, fromSeed);
}
/**
* Finds a model with a provided id, otherwise returns a notFound error.
* @param {number} id The id of the model you're looking for
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
*/
static find(id, callback) {
let db = this.prototype.db;
// legacy support
if (arguments.length === 3) {
db = arguments[0];
id = arguments[1];
callback = arguments[2];
}
return new Composer(this)
.where({id: id})
.end((err, models) => {
if (!err && !models.length) {
let err = new Error(`Could not find ${this.name} with id "${id}".`);
err.notFound = true;
return callback(err);
}
callback(err, models[0]);
});
}
/**
* Finds a model with a provided field, value pair. Returns the first found.
* @param {string} field Name of the field
* @param {any} value Value of the named field to compare against
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
*/
static findBy(field, value, callback) {
let db = this.prototype.db;
let query = {};
query[field] = value;
return new Composer(this)
.where(query)
.end((err, models) => {
if (!err && !models.length) {
let err = new Error(`Could not find ${this.name} with ${field} "${value}".`);
err.notFound = true;
return callback(err);
}
callback(err, models[0]);
});
}
/**
* Creates a new model instance using the provided data.
* @param {object} data The data to load into the object.
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
* @param {Transaction} txn OPTIONAL: The SQL transaction to use for this method
*/
static create(data, callback, txn) {
let model = new this(data);
return model.save(callback, txn);
}
/**
* Finds a model with a provided field, value pair. Returns the first found.
* @param {string} field Name of the field
* @param {object} data Key-value pairs of Model creation data. Will use appropriate value to query for based on "field" parametere.
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
*/
static findOrCreateBy(field, data, callback) {
this.findBy(field, data[field], (err, model) => {
if (err) {
if (err.notFound) {
return this.create(data, callback);
} else {
return callback(err);
}
} else {
return callback(null, model);
}
});
};
/**
* Finds and updates a model with a specified id. Return a notFound error if model does not exist.
* @param {number} id The id of the model you're looking for
* @param {object} data The data to load into the object.
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
*/
static update(id, data, callback) {
this.find(id, (err, model) => {
if (err) {
return callback(err);
}
model.read(data);
model.save(callback);
});
}
/**
* Finds and destroys a model with a specified id. Return a notFound error if model does not exist.
* @param {number} id The id of the model you're looking for
* @param {function({Error} err, {Nodal.Model} model)} callback The callback to execute upon completion
*/
static destroy(id, callback) {
this.find(id, (err, model) => {
if (err) {
return callback(err);
}
model.destroy(callback);
});
}
/**
* Creates a new Composer (ORM) instance to begin a new query.
* @param {optional Nodal.Database} readonlyDb Provide a readonly database to query from
* @return {Nodal.Composer}
*/
static query(readonlyDb) {
return new Composer(this, null, readonlyDb);
}
/**
* Creates a transaction object to be passed to database methods
* @param {Function} callback Callback to execute upon completion
*/
static transaction(callback) {
return this.prototype.db.adapter.createTransaction(callback);
}
/**
* Creates a serializable transaction object to be passed to database methods
* @param {Function} callback Callback to execute upon completion
*/
static serializableTransaction(callback) {
return this.prototype.db.adapter.createSerializableTransaction(callback);
}
/**
* Get the model's table name
* @return {string}
*/
static table() {
return this.prototype.schema.table;
}
/**
* Get the model's column data
* @return {Array}
*/
static columns() {
return this.prototype.schema.columns;
};
/**
* Get the model's column names (fields)
* @return {Array}
*/
static columnNames() {
return this.columns().map(v => v.name);
}
/**
* Get the model's column names with additional data for querying
* @return {Array}
*/
static columnQueryInfo (columnList) {
let columns = columnList
? this.prototype.schema.columns.filter(c => columnList.indexOf(c.name) > -1)
: this.prototype.schema.columns.slice();
return columns.map(c => {
let nc = Object.keys(c).reduce((nc, key) => {
nc[key] = c[key];
return nc;
}, {});
nc.columnNames = [nc.name];
nc.alias = nc.name;
nc.transformation = v => v;
nc.joined = false;
return nc;
});
}
/**
* Get the model's column lookup data
* @return {Object}
*/
static columnLookup() {
return this.columns().reduce((p, c) => {
p[c.name] = c;
return p;
}, {});
}
/**
* Check if the model has a column name in its schema
* @param {string} columnName
*/
static hasColumn(columnName) {
return !!this.column(columnName);
}
/**
* Return the column schema data for a given name
* @param {string} columnName
*/
static column(columnName) {
return this.prototype._columnLookup[columnName];
}
/**
* Get resource data for a model, for API responses and debug information
* @param {Array} arrInterface Array of strings representing output columns, or singularly-keyed objects representing relationships and their interface.
* @return {Object} Resource object for the model
* @deprecated
*/
static toResource(arrInterface) {
if (!arrInterface || !arrInterface.length) {
arrInterface = this.columnNames().concat(
Object.keys(this.prototype._joins)
.map(r => {
let obj = {};
obj[r] = this.joinInformation(r).Model.columnNames();
return obj;
})
);
}
let columnLookup = this.columnLookup();
let resourceColumns = arrInterface.map(r => {
if (typeof r === 'string') {
let field = columnLookup[r];
if (!field) {
return null;
}
let fieldData = {
name: r,
type: field ? field.type : 'string'
};
field.array && (fieldData.array = true);
return fieldData;
} else if (typeof r === 'object' && r !== null) {
return null; // FIXME: Deprecated for relationships.
let key = Object.keys(r)[0];
let relationship = this.joinInformation(key);
if (!relationship) {
return null;
}
return relationship.Model.toResource(r[key]);
}
}).filter(r => r);
return {
name: this.name,
type: 'resource',
fields: resourceColumns
};
}
/**
* Set the database to be used for this model
* @param {Nodal.Database} db
*/
static setDatabase(db) {
this.prototype.db = db;
}
/**
* Set the schema to be used for this model
* @param {Object} schema
*/
static setSchema(schema) {
if (!schema) {
throw new Error([
`Could not set Schema for ${this.name}.`,
`Please make sure to run any outstanding migrations.`
].join('\n'));
}
this.prototype.schema = schema;
this.prototype._table = this.table();
this.prototype._columns = this.columns();
this.prototype._columnNames = this.columnNames();
this.prototype._columnLookup = this.columnLookup();
this.prototype._data = this.columnNames()
.reduce((p, c) => {
p[c] = null;
return p;
}, {});
this.prototype._changed = this.columnNames()
.reduce((p, c) => {
p[c] = false;
return p;
}, {});
}
/**
* FIXME
*/
static relationships() {
return Relationships.of(this);
}
/**
* FIXME
*/
static relationship(name) {
this._relationshipCache = this._relationshipCache || {};
this._relationshipCache[name] = (this._relationshipCache[name] || this.relationships().findExplicit(name));
return this._relationshipCache[name];
}
/**
* Sets a joins relationship for the Model. Sets joinedBy relationship for parent.
* @param {class Nodal.Model} Model The Model class which your current model belongs to
* @param {Object} [options={}]
* "name": The string name of the parent in the relationship (default to camelCase of Model name)
* "via": Which field in current model represents this relationship, defaults to `${name}_id`
* "as": What to display the name of the child as when joined to the parent (default to camelCase of child name)
* "multiple": Whether the child exists in multiples for the parent (defaults to false)
*/
static joinsTo(Model, options) {
return this.relationships().joinsTo(Model, options);
}
/**
* Create a validator. These run synchronously and check every time a field is set / cleared.
* @param {string} field The field you'd like to validate
* @param {string} message The error message shown if a validation fails.
* @param {function({any} value)} fnAction the validation to run - first parameter is the value you're testing.
*/
static validates(field, message, fnAction) {
if (!this.prototype.hasOwnProperty('_validations')) {
this.prototype._validations = {};
this.prototype._validationsList = [];
};
if (!this.prototype._validations[field]) {
this.prototype._validationsList.push(field);
}
this.prototype._validations[field] = this.prototype._validations[field] || [];
this.prototype._validations[field].push({message: message, action: fnAction});
}
/**
* Checks a validator synchronously.
* @param {string} field The field you'd like to validate
* @param {any} value The value of the field to validate
*/
static validationCheck(field, value) {
return (this.prototype._validations[field] || []).map(validation => {
return validation.action(value) ? null : validation.message;
}).filter(v => !!v);
}
/**
* Creates a verifier. These run asynchronously, support multiple fields, and check every time you try to save a Model.
* @param {string} field The field applied to the verification.
* @param {string} message The error message shown if a verification fails.
* @param {function} fnAction The asynchronous verification method. The last argument passed is always a callback, and field names are determined by the argument names.
*/
static verifies(field, message, fnAction) {
// Legacy support
if (arguments.length === 2) {
fnAction = message;
message = field;
field = null;
}
if (!this.prototype.hasOwnProperty('_verifications')) {
this.prototype._verifications = {};
this.prototype._verificationsList = [];
};
this.prototype._verificationsList.push({
field: field,
message: message,
action: fnAction,
fields: utilities.getFunctionParameters(fnAction).slice(0, -1)
});
this.prototype._verifications[field] = this.prototype._verifications[field] || [];
this.prototype._verifications[field].push({
message: message,
action: fnAction,
fields: utilities.getFunctionParameters(fnAction).slice(0, -1)
});
}
/**
* Checks a validator synchronously.
* @param {string} field The field you'd like to verify
* @param {any} value The value of the field you'd like to verify
* @param {object} data Any additional field data, in key-value pairs
* @param {function} callback Callback to execute upon completion
*/
static verificationCheck(field, value, data, callback) {
data = data || {};
data[field] = value;
return async.series(
(this.prototype._verifications[field] || []).map(verification => {
return cb => {
verification.action.apply(
this,
verification.fields
.map(field => data[field])
.concat(result => cb(null, result))
)
};
}),
(err, results) => {
if (err) {
return callback(err);
}
return callback(
null,
results.map((result, i) => {
return result ?
null :
this.prototype._verifications[field][i].message;
}).filter(v => !!v)
);
}
)
}
/**
* Create a calculated field (in JavaScript). Must be synchronous.
* @param {string} calcField The name of the calculated field
* @param {function} fnCalculate The synchronous method to perform a calculation for.
* Pass the names of the (non-computed) fields you'd like to use as parameters.
*/
static calculates(calcField, fnCompute) {
if (!this.prototype.hasOwnProperty('_calculations')) {
this.prototype._calculations = {};
this.prototype._calculationsList = [];
}
if (this.prototype._calculations[calcField]) {
throw new Error(`Calculated field "${calcField}" for "${this.name}" already exists!`);
}
let columnLookup = this.columnLookup();
if (columnLookup[calcField]) {
throw new Error(`Cannot create calculated field "${calcField}" for "${this.name}", field already exists.`);
}
let fields = utilities.getFunctionParameters(fnCompute);
fields.forEach(f => {
if (!columnLookup[f]) {
throw new Error(`Calculation function error: "${calcField} for "${this.name}" using field "${f}", "${f}" does not exist.`)
}
});
this.prototype._calculations[calcField] = {
calculate: fnCompute,
fields: fields
};
this.prototype._calculationsList.push(calcField);
}
/**
* Hides fields from being output in .toObject() (i.e. API responses), even if asked for
* @param {String} field
*/
static hides(field) {
if (!this.prototype.hasOwnProperty('_hides')) {
this.prototype._hides = {};
}
this.prototype._hides[field] = true;
return true;
}
/**
* Tells us if a field is hidden (i.e. from API queries)
* @param {String} field
*/
static isHidden(field) {
return this.prototype._hides[field] || false;
}
/**
* Prepare model for use
* @private
*/
__initialize__() {
this._relationshipCache = {};
this._joinsCache = {};
this._joinsList = [];
this._data = Object.create(this._data); // Inherit from prototype
this._changed = Object.create(this._changed); // Inherit from prototype
this._errors = {};
this._errorDetails = {};
return true;
}
/**
* Loads data into the model
* @private
* @param {Object} data Data to load into the model
* @param {optional boolean} fromStorage Specify if the model was loaded from storage. Defaults to false.
* @param {optional boolean} fromSeed Specify if the model was generated from a seed. Defaults to false.
*/
__load__(data, fromStorage, fromSeed) {
data = data || {};
this._inStorage = !!fromStorage;
this._isSeeding = !!fromSeed;
if (!fromStorage) {
data.created_at = new Date();
data.updated_at = new Date();
}
let keys = Object.keys(data);
keys.forEach(key => {
this.__safeSet__(key, data[key]);
this._changed[key] = !fromStorage
});
this.__validate__();
return this;
}
/**
* Validates provided fieldList (or all fields if not provided)
* @private
* @param {optional Array} fieldList fields to validate
*/
__validate__(field) {
let data = this._data;
if (!field) {
let valid = true;
this._validationsList.forEach(field => valid = (this.__validate__(field) && valid));
return valid;
} else if (!this._validations[field]) {
return true;
}
this.clearError(field);
let value = this._data[field];
return this._validations[field].filter(validation => {
let valid = validation.action.call(null, value);
!valid && this.setError(field, validation.message);
return valid;
}).length === 0;
}
/**
* Sets specified field data for the model, assuming data is safe and does not log changes
* @param {string} field Field to set
* @param {any} value Value for the field
*/
__safeSet__(field, value) {
if (this.relationship(field)) {
return this.setJoined(field, value);
}
if (!this.hasField(field)) {
return;
}
this._data[field] = this.convert(field, value);
}
/**
* Indicates whethere or not the model is currently represented in hard storage (db).
* @return {boolean}
*/
inStorage() {
return this._inStorage;
}
/**
* Indicates whethere or not the model is currently being created, handled by the save() method.
* @return {boolean}
*/
isCreating() {
return !!this._isCreating;
}
/**
* Indicates whethere or not the model is being generated from a seed.
* @return {boolean}
*/
isSeeding() {
return this._isSeeding;
}
/**
* Tells us whether a model field has changed since we created it or loaded it from storage.
* @param {string} field The model field
* @return {boolean}
*/
hasChanged(field) {
return field === undefined ? this.changedFields().length > 0 : !!this._changed[field];
}
/**
* Provides an array of all changed fields since model was created / loaded from storage
* @return {Array}
*/
changedFields() {
let changed = this._changed;
return Object.keys(changed).filter(v => changed[v]);
}
/**
* Creates an error object for the model if any validations have failed, returns null otherwise
* @return {Error}
*/
errorObject() {
let error = null;
if (this.hasErrors()) {
let errorObject = this.getErrors();
let message = errorObject._query || 'Validation error';
error = new Error(message);
error.details = errorObject;
error.values = Object.keys(error.details).reduce((values, key) => {
values[key] = this._data[key];
return values;
}, {});
if (errorObject._query) {
error.identifier = this._errorDetails._query;
}
}
return error;
}
/**
* Tells us whether or not the model has errors (failed validations)
* @return {boolean}
*/
hasErrors() {
return Object.keys(this._errors).length > 0;
}
/**
* Gives us an error object with each errored field as a key, and each value
* being an array of failure messages from the validators
* @return {Object}
*/
getErrors() {
let obj = {};
let errors = this._errors;
Object.keys(errors).forEach(function(key) {
obj[key] = errors[key];
obj
});
return obj;
}
/**
* Reads new data into the model.
* @param {Object} data Data to inject into the model
* @return {this}
*/
read(data) {
this.fieldList()
.concat(this._joinsList)
.filter((key) => data.hasOwnProperty(key))
.forEach((key) => this.set(key, data[key]));
return this;
}
/**
* Converts a value to its intended format based on its field. Returns null if field not found.
* @param {string} field The field to use for conversion data
* @param {any} value The value to convert
*/
convert(field, value) {
if (!this.hasField(field) || value === null || value === undefined) {
return null;
}
let dataType = this.getDataTypeOf(field);
if (this.isFieldArray(field)) {
return (value instanceof Array ? value : [value]).map(v => dataType.convert(v));
}
return dataType.convert(value);
}
/**
* Grabs the path of the given relationship from the RelationshipGraph
* @param {string} name the name of the relationship
*/
relationship(name) {
return this.constructor.relationship(name);
}
/**
* Sets specified field data for the model. Logs and validates the change.
* @param {string} field Field to set
* @param {any} value Value for the field
*/
set(field, value) {
if (!this.hasField(field)) {
throw new Error('Field ' + field + ' does not belong to model ' + this.constructor.name);
}
let curValue = this._data[field];
let changed = false;
value = this.convert(field, value);
if (value !== curValue) {
changed = true;
if (
value instanceof Array &&
curValue instanceof Array &&
value.length === curValue.length
) {
changed = false;
// If we have two equal length arrays, we must compare every value
for (let i = 0; i < value.length; i++) {
if (value[i] !== curValue[i]) {
changed = true;
break;
}
}
}
// If we have an object value (json), do a deterministic diff using
// node-deep-equals
// NOTE: Lets do an extra deep object test
if ( utilities.isObject(value) ) {
changed = !deepEqual( curValue, value, { strict: true});
}
}
this._data[field] = value;
this._changed[field] = changed;
changed && this.__validate__(field);
return value;
}
/**
* Set a joined object (Model or ModelArray)
* @param {string} field The field (name of the join relationship)
* @param {Model|ModelArray} value The joined model or array of models
*/
setJoined(field, value) {
let relationship = this.relationship(field);
if (Array.isArray(value) && !value.length) {
value = new ModelArray(relationship.getModel());
}
if (!relationship.multiple()) {
if (!(value instanceof relationship.getModel())) {
throw new Error(`${value} is not an instance of ${relationship.getModel().name}`);
}
} else {
if (!(value instanceof ModelArray) && ModelArray.Model !== relationship.getModel()) {
throw new Error(`${value} is not an instanceof ModelArray[${relationship.getModel().name}]`);
}
}
if (!this._joinsCache[field]) {
this._joinsList.push(field);
}
this._joinsCache[field] = value;
return value;
}
/**
* Clear a joined object (Model or ModelArray)
* @param {string} field The field (name of the join relationship)
*/
clearJoined(field) {
let relationship = this.relationship(field);
if (!relationship) {
throw new Error(`No relationship named "${field}" exists`);
}
this._joinsList = this._joinsList.filter((joinName) => {
return joinName !== field;
});
let value = this._joinsCache[field];
delete this._joinsCache[field];
return value;
}
/**
* Calculate field from calculations (assumes it exists)
* @param {string} field Name of the calculated field
*/
calculate(field) {
let calc = this._calculations[field];
return calc.calculate.apply(
this,
calc.fields.map(f => this.get(f))
);
}
/**
* Retrieve field data for the model.
* @param {string} field Field for which you'd like to retrieve data.
*/
get(field, defaultValue) {
if (this._calculations[field]) {
return this.calculate(field);
}
return this._data.hasOwnProperty(field) ? this._data[field] : defaultValue;
}
/**
* Retrieves joined Model or ModelArray
* @param {String} joinName the name of the join (list of connectors separated by __)
*/
joined(joinName) {
return this._joinsCache[joinName];
}
/**
* Retrieve associated models joined this model from the database.
* @param {function({Error} err, {Nodal.Model|Nodal.ModelArray} model_1, ... {Nodal.Model|Nodal.ModelArray} model_n)}
* Pass in a function with named parameters corresponding the relationships you'd like to retrieve.
* The first parameter is always an error callback.
*/
include(callback) {
let db = this.db;
// legacy support
if (arguments.length === 2) {
db = arguments[0];
callback = arguments[1];
}
let joinNames = utilities.getFunctionParameters(callback);
joinNames = joinNames.slice(1);
if (!joinNames.length) {
throw new Error('No valid relationships (1st parameter is error)');
}
let invalidJoinNames = joinNames.filter(r => !this.relationship(r));
if (invalidJoinNames.length) {
throw new Error(`Joins "${invalidJoinNames.join('", "')}" for model "${this.constructor.name}" do not exist.`);
}
let query = this.constructor.query().where({id: this.get('id')});
joinNames.forEach(joinName => query = query.join(joinName));
query.end((err, models) => {
if (err) {
return callback(err);
}
if (!models || !models.length) {
return callback(new Error('Could not fetch parent'));
}
let model = models[0];
let joins = joinNames.map(joinName => {
let join = model.joined(joinName);
join && this.setJoined(joinName, join);
return join;
});
return callback.apply(this, [null].concat(joins));
});
};
/**
* Creates a plain object from the Model, with properties matching an optional interface
* @param {Array} arrInterface Interface to use for object creation
*/
toObject(arrInterface) {
let obj = {};
arrInterface = arrInterface ||
this.fieldList()
.concat(this._calculationsList)
.filter(key => !this._hides[key]);
arrInterface.forEach(key => {
if (this._hides[key]) {
return;
}
let joinObject;
if (typeof key === 'object' && key !== null) {
let subInterface = key;
key = Object.keys(key)[0];
joinObject = this._joinsCache[key];
joinObject && (obj[key] = joinObject.toObject(subInterface[key]));
} else if (this._data[key] !== undefined) {
obj[key] = this._data[key];
} else if (this._calculations[key] !== undefined) {
obj[key] = this.calculate(key);
} else if (joinObject = this._joinsCache[key]) {
obj[key] = joinObject.toObject();
}
});
return obj;
}
/**
* Get the table name for the model.
* @return {string}
*/
tableName() {
return this._table;
}
/**
* Determine if the model has a specified field.
* @param {string} field
* @return {boolean}
*/
hasField(field) {
return !!this._columnLookup[field];
}
/**
* Retrieve the schema field data for the specified field
* @param {string} field
* @return {Object}
*/
getFieldData(field) {
return this._columnLookup[field];
}
/**
* Retrieve the schema data type for the specified field
* @param {string} field
* @return {string}
*/
getDataTypeOf(field) {
return DataTypes[this._columnLookup[field].type];
}
/**
* Determine whether or not this field is an Array (PostgreSQL supports this)
* @param {string} field
* @return {boolean}
*/
isFieldArray(field) {
let fieldData = this._columnLookup[field];
return !!(fieldData && fieldData.properties && fieldData.properties.array);
}
/**
* Determine whether or not this field is a primary key in our schema
* @param {string} field
* @return {boolean}
*/
isFieldPrimaryKey(field) {
let fieldData = this._columnLookup[field];
return !!(fieldData && fieldData.properties && fieldData.properties.primary_key);
}
/**
* Retrieve the defaultValue for this field from our schema
* @param {string} field
* @return {any}
*/
fieldDefaultValue(field) {
let fieldData = this._columnLookup[field];
return fieldData && fieldData.properties ? fieldData.properties.defaultValue : null;
}
/**
* Retrieve an array of fields for our model
* @return {Array}
*/
fieldList() {
return this._columnNames.slice();
}
/**
* Retrieve our field schema definitions
* @return {Array}
*/
fieldDefinitions() {
return this._columns.slice();
}
/**
* Set an error for a specified field (supports multiple errors)
* @param {string} key The specified field for which to create the error (or '*' for generic)
* @param {string} message The error message
* @return {boolean}
*/
setError(key, message, code) {
this._errors[key] = this._errors[key] || [];
this._errors[key].push(message);
if (code) {
this._errorDetails[key] = this.db.adapter.readErrorCode(code);
}
return true;
}
/**
* Clears all errors for a specified field
* @param {string} key The specified field for which to create the error (or '*' for generic)
* @return {boolean}
*/
clearError (key) {
delete this._errors[key];
delete this._errorDetails[key];
return true;
}
__generateSaveQuery__() {
let query, columns;
let db = this.db;
if (!this.inStorage()) {
columns = this.fieldList().filter(v => !this.isFieldPrimaryKey(v) && this.get(v) !== undefined);
query = db.adapter.generateInsertQuery(this.schema.table, columns);
} else {
columns = ['id'].concat(this.changedFields().filter(v => !this.isFieldPrimaryKey(v)));
query = db.adapter.generateUpdateQuery(this.schema.table, columns);
}
return {
sql: query,
params: columns.map(v => db.adapter.sanitize(this.getFieldData(v).type, this.get(v)))
};
}
/**
* Logic to execute before a model saves. Intended to be overwritten when inherited.
* @param {Function} callback Invoke with first argument as an error if failure.
*/
beforeSave(callback) {
callback(null, this);
}
/**
* Logic to execute after a model saves. Intended to be overwritten when inherited.
* @param {Function} callback Invoke with first argument as an error if failure.
*/
afterSave(callback) {
callback(null, this);
}
/**
* Save a model (execute beforeSave and afterSave)
* @param {Function} callback Callback to execute upon completion
* @param {Transaction} txn The SQL transaction used to execute this save method
*/
save(callback, txn) {
callback = callback || (() => {});
let series = [];
let newTransaction = !txn;
if (newTransaction) {
series = series.concat(
cb => {
this.constructor.transaction((err, newTxn) => {
if (err) {
cb(err);
}
txn = newTxn;
return cb();
});
}
);
}
if (!this.inStorage()) {
this._isCreating = true;
}
series = series.concat([
cb => this.__verify__(cb, txn),
cb => this.beforeSave(cb, txn),
cb => this.__save__(cb, txn),
cb => this.afterSave(cb, txn)
]);
async.series(series, (err) => {
this._isCreating = false;
if (newTransaction) {
if (err) {
console.error(err);
return txn.rollback(txnErr => callback(err, this));
}
return txn.commit(txnErr => callback(txnErr, this));
}
return callback(err || null, this);
});
}
/**
* Runs an update query for this specific model instance
* @param {Object} fields Key-value pairs of fields to update
* @param {Function} callback Callback to execute upon completion
*/
update(fields, callback) {
callback = callback || (() => {});
this.constructor.query()
.where({id: this.get('id')})
.update(fields, (err, models) => callback(err, models && models[0]));
}
/**
* Runs all verifications before saving
* @param {function} callback Method to execute upon completion. Returns true if OK, false if failed
* @private
*/
__verify__(callback) {
// Run through verifications in order they were added
async.series(
this._verificationsList.map(verification => {
return callback => {
verification.action.apply(
this,
verification.fields
.map(field => this.get(field))
.concat(bool => {
if (bool) {
callback(null);
} else {
if (verification.field) {
this.setError(verification.field, verification.message);
callback(null);
} else {
callback(new Error(verification.message))
}
}
})
)
};
}),
(err) => {
if (this.hasErrors()) {
return callback.call(this, this.errorObject());
} else if (err) {
return callback.call(this, err);
}
callback(null);
}
);
}
/**
* Saves model to database
* @param {function} callback Method to execute upon completion, returns error if failed (including validations didn't pass)
* @param {Transaction} txn OPTIONAL: SQL transaction to use for save
* @private
*/
__save__(callback, txn) {
let db = this.db;
if(typeof callback !== 'function') {
callback = function() {};
}
if (this.fieldList().indexOf('updated_at') !== -1) {
this.set('updated_at', new Date());
}
let query = this.__generateSaveQuery__();
let source = txn ? txn : this.db;
source.query(
query.sql,
query.params,
(err, result) => {
if (err) {
this.setError('_query', err.message, err.code);
} else {
result.rows.length && this.__load__(result.rows[0], true);
}
callback.call(this, this.errorObject());
}
);
}
/**
* Destroys model and cascades all deletes.
* @param {function} callback method to run upon completion
*/
destroyCascade(callback) {
ModelArray.from([this]).destroyCascade(callback);
}
/**
* Logic to execute before a model gets destroyed. Intended to be overwritten when inherited.
* @param {Function} callback Invoke with first argument as an error if failure.
*/
beforeDestroy(callback) {
callback(null, this);
}
/**
* Logic to execute after a model is destroyed. Intended to be overwritten when inherited.
* @param {Function} callback Invoke with first argument as an error if failure.
*/
afterDestroy(callback) {
callback(null, this);
}
/**
* Destroys model reference in database.
* @param {function({Error} err, {Nodal.Model} model)} callback
* Method to execute upon completion, returns error if failed
*/
destroy(callback) {
callback = callback || (() => {});
async.series([
this.beforeDestroy,
this.__destroy__,
this.afterDestroy
].map(f => f.bind(this)), (err) => {
callback(err || null, this);
});
}
/**
* Destroys model reference in database
* @param {function} callback Method to execute upon completion, returns error if failed
* @private
*/
__destroy__(callback) {
let db = this.db;
// Legacy
if (arguments.length === 2) {
db = arguments[0];
callback = arguments[1];
}
let model = this;
if (!(db instanceof Database)) {
throw new Error('Must provide a valid Database to save to');
}
if (typeof callback !== 'function') {
callback = function() {};
}
if (!model.inStorage()) {
setTimeout(callback.bind(model, {'_query': 'Model has not been saved'}, model), 1);
return;
}
let columns = model.fieldList().filter(function(v) {
return model.isFieldPrimaryKey(v);
});
let query = db.adapter.generateDeleteQuery(model.schema.table, columns);
db.query(
query,
columns.map(function(v) {
return db.adapter.sanitize(model.getFieldData(v).type, model.get(v, true));
}),
function(err, result) {
if (err) {
model.setError('_query', err.message);
} else {
model._inStorage = false;
}
callback.call(model, err, model);
}
);
}
}
Model.prototype.schema = {
table: '',
columns: []
};
Model.prototype._validations = {};
Model.prototype._validationsList = [];
Model.prototype._calculations = {};
Model.prototype._calculationsList = [];
Model.prototype._verificationsList = [];
Model.prototype._hides = {};
Model.prototype.data = null;
Model.prototype.db = null;
Model.prototype.externalInterface = [
'id',
'created_at',
'updated_at'
];
Model.prototype.aggregateBy = {
'id': 'count',
'created_at': 'min',
'updated_at': 'min'
};
module.exports = Model;