UNPKG

alchemymvc

Version:
2,217 lines 47.4 kB
const CriteriaNS = Function.getNamespace('Alchemy.Criteria');

let class_cache = new Map(),
    fallback_datasource,
    TABLE = Symbol('table');

if (Blast.isBrowser) {
	Blast.once('hawkejs_init', function gotScene(hawkejs, variables, settings, view) {

		let config;

		for (config of hawkejs.scene.exposed.model_info) {
			// First get or create the client-side Model class,
			// then set some extra configuration coming from the server-side
			Model.getClass(config.model_name).setModelConfig(config);
		}
	});
}

/**
 * The Model class
 * (on the client side)
 *
 * @constructor
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.1.0
 *
 * @param    {Object}   options
 */
const Model = Function.inherits('Alchemy.Client.Base', 'Alchemy.Client.Model', function Model(options) {
	this.init(options);
});

/**
 * Set the modelName property after class creation
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.1.0
 * @version  1.4.0
 */
Model.postInherit(function setModelName() {

	let base_name = this.name;
	let model_name = base_name;
	let table_prefix = this.table_prefix;
	let namespace = this.namespace;

	if (namespace.startsWith('Alchemy.Model.') || namespace == 'Alchemy.Model') {
		namespace = namespace.slice(14);
	} else if (namespace.startsWith('Alchemy.Client.Model.') || namespace == 'Alchemy.Client.Model') {
		namespace = namespace.slice(21);
	}

	this.setStatic('model_namespace', namespace, false);

	let ns = namespace.replaceAll('.', '_');
	
	if (!table_prefix && ns) {
		table_prefix = ns.tableize();
	}

	if (ns) {
		model_name = ns + '_' + model_name;
	}

	// The simple name of the model
	this.model_name = model_name;
	this.setProperty('model_name', model_name);

	let table_name = base_name.tableize();

	if (table_prefix) {
		table_name = table_prefix + '_' + table_name;
	}

	this.table = table_name;
});

/**
 * Map these events to methods
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.2.5
 * @version  1.3.22
 */
Model.mapEventToMethod({
	saving         : 'beforeSave',
	saved          : 'afterSave',
	finding        : 'beforeFind',
	queried        : 'afterQuery',
	associated     : 'afterAssociated',

	// Was 'afterFind' in behaviours
	found          : 'afterData',
	foundDocuments : 'afterFind',
	removed        : 'afterRemove',
	to_datasource  : 'beforeToDatasource',
	removing       : 'beforeRemove',

	// New
	beforeFind         : 'beforeFind',
	afterData          : 'afterData',
	afterFind          : 'afterFind',
	afterQuery         : 'afterQuery',
	beforeSave         : 'beforeSave',
	beforeToDatasource : 'beforeToDatasource',
	afterAssociated    : 'afterAssociated',
	afterSave          : 'afterSave',
	beforeRemove       : 'beforeRemove',
	afterRemove        : 'afterRemove',
	beforeNormalize    : 'beforeNormalize',
	beforeValidate     : 'beforeValidate',
});

/**
 * Is the given action enabled on the server?
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.6
 * @version  1.0.6
 *
 * @param    {string}    action
 *
 * @return   {boolean}
 */
Model.setStatic(function hasServerAction(action) {

	var route_name = this.name + '#' + action,
	    config;

	if (typeof hawkejs == 'undefined') {
		throw new Error('Model#hasServerAction(action) tried to get Scene helper, but hawkejs is not yet available');
	}

	config = hawkejs.scene.helpers.Router.routeConfig(route_name);

	return !!config;
});

/**
 * Set the Model configuration
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.3.1
 * @version  1.3.1
 *
 * @param    {Object}    config
 */
Model.setStatic(function setModelConfig(config) {

	let model_name = this.model_name;

	config.schema.setModel(model_name);

	if (!this.prototype.hasOwnProperty('primary_key')) {
		this.setProperty('primary_key', config.primary_key);
	}

	if (!this.prototype.hasOwnProperty('display_field')) {
		this.setProperty('display_field', config.display_field);
	}
});

/**
 * Table name to use in the database.
 * False if no table should be used.
 *
 * @type     {string}
 */
Model.setProperty(function table() {

	if (this[TABLE]) {
		return this[TABLE];
	}

	return this.constructor.table;
}, function setTable(table) {
	return this[TABLE] = table;
});

/**
 * The datasource
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.1.7
 *
 * @type     {Datasource}
 */
Model.setProperty(function datasource() {

	if (Blast.isNode) {
		// We use the "remote" type datasource, which will make loopback
		// requests on the server side
		return Datasource.get({name: 'loopback', type: 'remote'});
	}

	if (!fallback_datasource) {
		fallback_datasource = new Blast.Classes.Alchemy.Datasource.Fallback('default', {
			upper: {
				type: 'indexed_db',
				name: 'local'
			},
			lower: {
				type: 'remote',
				name: 'remote'
			}
		});

		fallback_datasource.connect();
	}

	return fallback_datasource;
});

/**
 * Does this model have translatable fields?
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.3.0
 * @version  1.3.0
 *
 * @type     {boolean}
 */
Model.setProperty(function has_translatable_fields() {
	return this.schema.has_translatable_fields;
});

/**
 * The conduit
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.4
 * @version  1.0.4
 */
Model.setProperty(function conduit() {

	var result;

	if (this._conduit) {
		return this._conduit;
	}

	if (this.hawkejs_view) {
		result = this.hawkejs_view.server_var('conduit');

		if (result) {
			return result;
		}
	}

	return null;
}, function setConduit(conduit) {
	this._conduit = conduit;

	if (conduit) {
		this.hawkejs_view = conduit.renderer;
	}
});

/**
 * Get the document class constructor
 *
 * @type     {Hawkejs.Document}
 */
Model.prepareStaticProperty('Document', function getDocumentClass() {
	return Blast.Classes.Alchemy.Client.Document.Document.getDocumentClass(this);
});

/**
 * Create client model class for specific model name
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.4.0
 *
 * @param    {string}   model_name
 * @param    {boolean}  allow_create
 * @param    {string}   parent
 */
Model.setStatic(function getClass(model_name, allow_create, parent) {

	let cache_key = model_name;

	if (class_cache.has(cache_key)) {
		return class_cache.get(cache_key);
	}

	// Get the model path as an array
	let model_path = Blast.parseClassPath(model_name);

	// Ensure it is the correct model_name
	model_name = model_path.join('_');

	// Construct the name of the class
	let class_name = model_path.last();

	// The root path
	const root_path = 'Alchemy.Client.Model';

	// Construct the expected path to this class
	let class_path = root_path + '.' + model_path.join('.');

	// Get the class
	let ModelClass = Object.path(Blast.Classes, class_path);

	if (allow_create == null) {
		allow_create = true;
	}

	if (ModelClass == null && allow_create) {

		let namespace = root_path,
		    config;

		if (model_path.length > 1) {
			namespace += '.' + model_path.slice(0, -1).join('.');
		}

		let model_constructor = Function.create(class_name, function ModelConstructor(record, options) {
			ModelConstructor.super.call(this, record, options);
		});

		if (Blast.isBrowser) {

			if (parent) {
				let parent_model_path = Blast.parseClassPath(parent);
				parent = root_path + '.' + parent_model_path.join('.');
			} else {
				parent = root_path;
			}

		} else if (Blast.isNode) {
			let server_class = alchemy.getModel(model_name, false);

			parent = getClass(server_class.super.model_name);

			config = {
				primary_key   : server_class.prototype.primary_key,
				display_field : server_class.prototype.display_field,
			};
		}

		ModelClass = Function.inherits(parent, namespace, model_constructor);

		ModelClass.setProperty('$model_name', model_name);

		if (config) {
			if (config.primary_key) {
				ModelClass.setProperty('primary_key', config.primary_key);
			}

			if (config.display_field) {
				ModelClass.setProperty('display_field', config.display_field);
			}
		}

		if (Blast.isBrowser) {
			typeof ModelClass.Document;
		}
	}

	class_cache.set(cache_key, ModelClass);

	return ModelClass;
});

/**
 * Set a method on the hawkejs document class
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.4.0
 */
Model.setStatic(function setDocumentMethod(name, fnc, on_server) {

	var that = this;

	if (typeof name == 'function') {
		on_server = fnc;
		fnc = name;
		name = fnc.name;
	}

	if (Blast.isNode) {
		STAGES.afterStages('load_app.plugins', whenLoaded);
	} else {
		Blast.loaded(whenLoaded);
	}

	function whenLoaded() {
		that.Document.setMethod(name, fnc, on_server);
	}
});

/**
 * Set a property on the hawkejs document class
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.4.0
 */
Model.setStatic(function setDocumentProperty(key, getter, setter, on_server) {

	var that = this,
	    args = arguments;

	if (Blast.isNode) {
		STAGES.afterStages('load_app.plugins', whenLoaded);
	} else {
		Blast.loaded(whenLoaded);
	}

	function whenLoaded() {
		that.Document.setProperty.apply(that.Document, args);
	};
});

/**
 * The schema
 *
 * @type     {Schema}
 */
Model.setStaticProperty(function schema() {
	if (this.prototype.model_info) {
		return this.prototype.model_info.schema;
	}
});

/**
 * The model name
 *
 * @type     {string}
 */
Model.setProperty(function name() {
	return this._name || this.constructor.model_name;
}, function setName(name) {
	this._name = name;
});

/**
 * Initialize the model
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.1.0
 * @version  1.3.0
 *
 * @param    {Object}   options
 */
Model.setMethod(function init(options) {

	if (!options) {
		options = {};
	} else {
		if (options.table)    this.table    = options.table;
		if (options.dbConfig) this.dbConfig = options.dbConfig;
		if (options.name)     this.name     = options.name;
		if (options.alias)    this.alias    = options.alias;
	}

	this.options = options;

	if (!Blast.isNode) {
		return;
	}

	// Initialize behaviours
	if (this.schema && this.schema.has_behaviours && this.initBehaviours) {
		this.initBehaviours();
	}
});

/**
 * Get default find options
 *
 * @deprecated
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.0.0
 *
 * @param    {Object}   options
 *
 * @return   {Object}
 */
Model.setMethod(function getFindOptions(options) {

	var def_options = {
		conditions       : {},
		recursive        : 1,
		fields           : [],
		sort             : null,
		order            : 1,
		limit            : 0,
		page             : false,
		offset           : false,

		// Return a document by default
		document         : true,

		// Consider empty strings as valid translations
		allow_empty      : false,

		// Get the available count
		available        : true,

		// When a search result is found in a certain translated prefix,
		// prefer that prefix for the entire record
		use_found_prefix : false,

		// Other possible values are false, 'before', 'after
		callbacks        : true
	};

	return Object.assign({}, def_options, options);
});

/**
 * Get the title to display for this record
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.3.14
 *
 * @param    {Object}        item       The record item of this model
 * @param    {string|Array}  fallbacks  Extra fallbacks to use
 * 
 * @return   {string}        The display title to use
 */
Model.setMethod(function getDisplayTitleOrNull(item, fallbacks) {

	if (!item) {
		return null;
	}

	let main;

	if (item[this.modelName]) {
		main = item[this.modelName];
	} else {
		main = item;
	}

	if (!main) {
		return 'Undefined item';
	}

	let field,
	    val,
	    i;

	let fields = Array.cast(this.display_field);

	if (fallbacks) {
		fields = fields.concat(fallbacks);
	}

	for (i = 0; i < fields.length; i++) {
		val = main[fields[i]];

		if (Object.isObject(val)) {
			field = this.getField(fields[i]);

			if (field && field.isTranslatable) {
				val = alchemy.pickTranslation(this.conduit, val).result;
			}
		}

		if (val && typeof val == 'string') {
			return val;
		}
	}

	return null;
});

/**
 * Get the title to display for this record
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.3.16
 *
 * @param    {Object}        item       The record item of this model
 * @param    {string|Array}  fallbacks  Extra fallbacks to use
 * 
 * @return   {string}        The display title to use
 */
Model.setMethod(function getDisplayTitle(item, fallbacks) {

	if (!item) {
		return 'Undefined item';
	}

	let result = this.getDisplayTitleOrNull(item, fallbacks);

	if (result == null) {
		result = item[this.primary_key] || '';

		if (result && typeof result != 'string') {
			result = '' + result;
		}
	}

	return result;
});

/**
 * Return association configuration
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.1.0
 * @version  1.2.0
 *
 * @param    {string}   alias
 *
 * @return   {Object}
 */
Model.setMethod(function getAssociation(alias) {

	if (!this.schema || !this.schema.associations) {
		throw new Error('Unable to find ' + this.constructor.name + ' schema associations');
	}

	let config;

	// @TODO: Test nested association getting
	if (alias && alias.indexOf('.') > -1) {
		let pieces = alias.split('.'),
		    context = this,
		    piece,
		    temp;

		for (piece of pieces) {
			temp = context.getAssociation(piece);

			if (!temp) {
				break;
			}

			context = this.getModel(temp.modelName);
		}

		// The config s the last association gotten
		config = temp;
	} else {
		config = this.schema.associations[alias];
	}

	if (!config) {
		throw new Error('Unable to find ' + JSON.stringify(alias) + ' association in ' + this.constructor.name + ' model');
	}

	return config;
});

/**
 * Return a model instance for the given alias
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.0.0
 *
 * @param    {string}   alias
 *
 * @return   {Model}
 */
Model.setMethod(function getAliasModel(alias) {

	var config,
	    result;

	if (alias == this.name) {
		return this;
	}

	if (this.schema) {
		config = this.schema.associations[alias];
	}

	// @TODO: associated fields with an alias name inside a nested schema
	// somehow breaks! Should be looked in to?
	// Workaround: set `recursive: 0` on the root schema field
	// console.log('Alias:', alias, 'config:', config, this.schema);

	if (config) {
		result = this.getModel(config.modelName);
	} else {
		result = this.getModel(alias);
	}

	return result;
});

/**
 * Find one record
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.4.0
 * @version  1.4.0
 *
 * @param    {string|Object|Criteria}     conditions
 *
 * @return   {Pledge|Criteria}
 */
Model.setMethod(function findOne(conditions, options) {
	conditions = CriteriaNS.Model.cast(conditions, options, this);
	return this.find('first', conditions);
});

/**
 * Find all records
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.4.0
 * @version  1.4.0
 *
 * @param    {string|Object|Criteria}     conditions
 *
 * @return   {Pledge|Criteria}
 */
Model.setMethod(function findAll(conditions, options) {
	conditions = CriteriaNS.Model.cast(conditions, options, this);
	return this.find('all', conditions);
});

/**
 * Issue a data event
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.4.0
 * @version  1.4.0
 *
 * @param    {string}     event_name
 * @param    {Array}      args
 *
 * @return   {Pledge|null}
 */
Model.setMethod(function issueDataEvent(event_name, args, next) {

	let tasks = [];

	if (this.behaviours) {

		let method_name,
			behaviour,
			key;

		if (this.constructor.event_to_method_map) {
			method_name = this.constructor.event_to_method_map.get(event_name);
		}

		if (!method_name) {
			method_name = 'on' + event_name.camelize();
		}

		for (key in this.behaviours) {
			behaviour = this.behaviours[key];

			if (typeof behaviour[method_name] == 'function') {
				tasks.push(() => behaviour[method_name](...args));
			}
		}
	}

	if (!tasks.length) {
		let pledge = new Swift();
		this.issueEvent(event_name, args, pledge.getResolverFunction());

		if (next) {
			pledge.done(next);
		}

		return pledge;
	}

	tasks.push(() => {
		let pledge = new Swift();
		this.issueEvent(event_name, args, pledge.getResolverFunction());
		return pledge;
	});

	let result = Swift.waterfall(...tasks);

	if (next) {
		Swift.done(result, next);
	}

	return result;
});

/**
 * Query the database
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.4.0
 *
 * @param    {string}     type       The type of find (first, all)
 * @param    {Criteria}   criteria   The criteria object
 * @param    {Function}   callback
 *
 * @return   {Pledge|Criteria}
 */
Model.setMethod(function find(type, criteria, callback) {

	if (arguments.length == 0) {
		return new Classes.Alchemy.Criteria.Model(this);
	}

	let skip_type,
	    pledge,
	    error;

	if (!this.datasource) {
		pledge = new Pledge();
		pledge.done(callback);
		pledge.reject(new Classes.Alchemy.Error.Model('Unable to perform ' + this.constructor.name + '#find(), no datasource is available'));
		return pledge;
	}

	if (Classes.Alchemy.Criteria.Criteria.isCriteria(type)) {
		callback = criteria;
		criteria = type;
		type = null;
		skip_type = true;
	}

	if (callback == null) {
		if (typeof criteria == 'function') {
			callback = criteria;
			criteria = false;
		}
	}

	if (!skip_type && typeof type !== 'string') {
		error = new TypeError('Find type should be a string');
	}

	try {
		criteria = Classes.Alchemy.Criteria.Model.cast(criteria, this);
	} catch (err) {
		error = err;
	}

	if (error != null) {
		pledge = new Pledge();
		pledge.done(callback);
		pledge.reject(error);
		return pledge;
	}

	if (this.conduit) {
		criteria.conduit = this.conduit;
	}

	if (!criteria.options.locale && this.conduit) {
		criteria.setOption('locale', this.conduit.active_prefix);
	}

	let that = this,
	    available,
	    records,
	    Type;

	// The criteria instance has to know about the model
	criteria.model = this;

	if (!skip_type) {
		// Save the type
		criteria.setOption('find_type', type);

		// Get the camelized type
		Type = type.camelize();
	}

	// See if a sort has been given
	if (criteria.options.sort == null && this.sort) {
		criteria.setOption('sort', this.sort);
	}

	pledge = Function.series(false, function doBeforeType(next) {
		if (Type) {
			that.callOrNext('beforeFind' + Type, [criteria], next);
		} else {
			next();
		}
	}, function doBeforeEvent(next) {
		that.issueDataEvent('beforeFind', [criteria], next);
	}, function doQuery(next) {

		let context = new Classes.Alchemy.OperationalContext.ReadDocumentFromDatasource();
		context.setDatasource(that.datasource);
		context.setModel(that);
		context.setCriteria(criteria);

		Pledge.Swift.done(that.datasource.read(context), (err, result) => {

			if (err) {
				return next(err);
			}

			records = result.items;
			available = result.available;
			records.available = available;

			next();
		});
	}, function emitQueried(next) {
		that.issueDataEvent('afterQuery', [criteria, records], next);
	}, function doTranslations(next) {

		if (!that.translateItems) {
			return next();
		}

		that.translateItems(records, criteria, next);
	}, function doAssociated(next) {

		if (criteria.recursive_level <= 0 && !criteria.getAssociationsToSelect()) {
			return next();
		}

		let tasks = [],
		    i;

		for (i = 0; i < records.length; i++) {
			let record = records[i];

			tasks.push(function getAssociatedData(nextAssoc) {
				that.addAssociatedDataToRecord(criteria, record, nextAssoc);
			});
		}

		// Add the associated data, 8 records at a time
		Function.parallel(8, tasks, next);
	}, function emitAssociated(next) {
		that.issueDataEvent('afterAssociated', [criteria, records], next);
	}, function doAfterType(next) {

		if (!Type) {
			return next();
		}

		that.callOrNext('afterFind' + Type, [criteria, records], function doneAfterFind(err, replace) {

			if (!err && replace != null) {
				records = replace;
			}

			next(err);
		});

	}, function doAfterEvent(next) {

		that.issueDataEvent('afterData', [criteria, records], function afterFound(err) {

			if (err) {
				return next(err);
			}

			if (criteria.options.document !== false) {
				records = that.createDocumentList(records, criteria);
				records.available = available;
				that.issueDataEvent('afterFind', [criteria, records], next);
			} else {
				next();
			}
		});
	}, function recomputeAfterFind(next) {

		if (!that.schema.has_recompute_after_find || criteria.options.document === false) {
			return next();
		}

		let tasks = [];

		for (let document of records) {
			tasks.push((next) => {
				let promise = document.recomputeValues();

				if (!promise) {
					return next();
				}

				Pledge.done(promise, next);
			});
		}

		Function.parallel(8, tasks, next);
	}, function done(err) {

		if (err != null) {

			if (callback) {
				callback(err);
			}

			return;
		}

		if (Blast.isBrowser && criteria.options.document !== false) {
			let i;

			for (i = 0; i < records.length; i++) {
				records[i].checkAndInformDatasource();
			}
		}

		// It document is enabled, but the list is disabled,
		// return only the first entry
		if (criteria.options.document && criteria.options.document_list == false) {
			records = records[0] || null;
		}

		if (callback) {
			callback(null, records);
		}

		return records;
	});

	return pledge;
});

/**
 * Query the database by a single id
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.1.0
 *
 * @param    {string|ObjectId}   id        The object id as string or object
 * @param    {Object}            options   Optional options object
 * @param    {Function}          callback
 *
 * @return   {Pledge}
 */
Model.setMethod(function findById(id, options, callback) {
	return this.findByPk(id, options, callback);
});

/**
 * Query the database by a single id
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.4.0
 *
 * @param    {string|ObjectId}   pk        The primary key value
 * @param    {Object}            options   Optional options object
 * @param    {Function}          callback
 *
 * @return   {Pledge}
 */
Model.setMethod(function findByPk(pk, options, callback) {

	if (pk === '' || pk == null) {
		let pledge = Pledge.reject(new Error('Invalid pk given'));
		pledge.done(callback);
		return pledge;
	}

	let criteria = new Classes.Alchemy.Criteria.Model(this);

	criteria.where(this.primary_key).equals(pk);

	if (typeof options == 'function') {
		callback = options;
	} else {
		criteria.applyOldOptions(options);
	}

	return this.find('first', criteria, callback);
});

/**
 * Return the context for resolving a remote schema request.
 * The only thing we probably need to do is return a document.
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.4.0
 * @version  1.4.0
 *
 * @param    {Alchemy.OperationalContext.Schema}   context
 *
 * @return   {Alchemy.Document|Object|Schema}
 */
Model.setMethod(async function resolveRemoteSchemaRequest(context) {

	let our_field_value = context.getOurFieldValue(),
	    our_field_name = context.getOurFieldName();

	let doc = await this.findByValues({
		[our_field_name]: our_field_value,
	});

	if (!doc) {
		return null;
	}

	const external_field = context.getExternalField();

	context = context.createChild();
	context.setHolder(doc);
	context.setSchema(this.schema);

	let found_schema = external_field.resolveSchemaPath(context);

	return found_schema;
});

/**
 * Query the database by key-val attributes, return the first result
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.1.0
 * @version  1.4.0
 *
 * @param    {Object}    values       The values to look for
 * @param    {Object}    options      Optional options object
 *
 * @return   {Pledge}
 */
Model.setMethod(function findByValues(values, options) {

	var criteria = new Classes.Alchemy.Criteria.Model(this),
	    key;

	if (options) {
		criteria.applyOldOptions(options);
	}

	for (key in values) {
		criteria.where(key).equals(values[key]);
	}

	return this.find('first', criteria);
});

/**
 * Query the database by key-val attributes, return all results
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.1.0
 * @version  1.4.0
 *
 * @param    {Object}    values       The values to look for
 * @param    {Object}    options      Optional options object
 *
 * @return   {Pledge}
 */
Model.setMethod(function findAllByValues(values, options) {

	var criteria = new Classes.Alchemy.Criteria.Model(this),
	    key;

	if (options) {
		criteria.applyOldOptions(options);
	}

	for (key in values) {
		criteria.where(key).equals(values[key]);
	}

	return this.find('all', criteria);
});

/**
 * The 'first' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.1.0
 *
 * @param    {Criteria}   criteria
 * @param    {Function}   callback
 */
Model.setMethod(function beforeFindFirst(criteria, callback) {

	// Only return 1 item
	criteria.limit(1);

	// Disable returning a document list
	criteria.setOption('document_list', false);

	callback();
});

/**
 * The 'list' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.1.0
 *
 * @param    {Criteria}   criteria
 * @param    {Function}   callback
 */
Model.setMethod(function beforeFindList(criteria, callback) {

	var fields = criteria.getFieldsToSelect();

	if (!fields.length) {
		criteria.select('_id');
	}

	// Don't turn this into a document
	criteria.setOption('document', false);

	callback();
});

/**
 * The 'dict' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.1.0
 *
 * @param    {Criteria}   criteria
 * @param    {Function}   callback
 */
Model.setMethod(function beforeFindDict(criteria, callback) {
	this.beforeFindList(criteria, callback);
});

/**
 * The 'count' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.1.0
 *
 * @param    {Criteria}   criteria
 * @param    {Function}   callback
 */
Model.setMethod(function beforeFindCount(criteria, callback) {

	// Explicitly get the available count
	criteria.setOption('available', true);

	// Limit to 1 object being returned
	criteria.limit(1);

	this.beforeFindList(criteria, callback);
});

/**
 * The 'list' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.1.0
 *
 * @param    {Criteria}   criteria
 * @param    {Array}      records
 * @param    {Function}   callback
 */
Model.setMethod(function afterFindList(criteria, records, callback) {

	var results = [],
	    record,
	    fields,
	    temp,
	    key,
	    val,
	    i,
	    j;

	if (records == null || typeof records != 'object') {
		return callback(new Error('Records argument should be an array'));
	}

	fields = criteria.options.select.fields;

	for (i = 0; i < records.length; i++) {
		record = records[i];

		// The first field is always the key, mostly this is _id
		key = record[this.name][fields[0]];

		if (fields.length == 1) {
			results.push(key);
			continue;
		}

		temp = {};

		for (j = 0; j < fields.length; j++) {
			temp[fields[j]] = record[this.name][fields[j]];
		}

		results.push(temp);
	}

	results.available = records.available;

	callback(null, results);
});

/**
 * The 'dict' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  0.2.0
 */
Model.setMethod(function afterFindDict(options, records, callback) {

	var results = {},
	    record,
	    temp,
	    key,
	    val,
	    i,
	    j;

	for (i = 0; i < records.length; i++) {
		record = records[i];

		// The first field is always the key, mostly this is _id
		key = record[this.name][options.fields[0]];

		if (options.fields.length == 1) {
			results[key] = key;
			continue;
		} else if (options.fields.length == 2) {
			results[key] = record[this.name][options.fields[1]];
			continue;
		}

		temp = {};

		for (j = 1; j < options.fields.length; j++) {
			temp[options.fields[j]] = record[this.name][options.fields[j]];
		}

		results[key] = temp;
	}

	callback(null, results);
});

/**
 * The 'count' find method
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  0.4.0
 */
Model.setMethod(function afterFindCount(options, records, callback) {
	callback(null, records.available || 0);
});

/**
 * Add associated data to a single record
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.1.0
 * @version  1.1.5
 *
 * @param    {Criteria}  criteria
 * @param    {Object}    item
 * @param    {Function}  callback
 */
Model.setMethod(function addAssociatedDataToRecord(criteria, item, callback) {

	var associations,
	    aliases      = {},
	    options      = criteria.options,
	    that         = this,
	    data         = item[this.name],
	    pledge;

	if (!data) {
		pledge = Pledge.reject(new Error('Required data not found for model "' + this.name + '"'));
		pledge.done(callback);
		return pledge;
	}

	// If create references is not disabled, create the assoc cache
	if (options.create_references !== false && !options.assoc_cache) {
		options.assoc_cache = {};
	}

	associations = criteria.options.associations || this.schema.associations;

	Object.each(associations, function eachAssoc(association, alias) {

		if (criteria.model.name == alias || options.from_alias == alias) {
			return;
		}

		// If the item is already available, skip it
		// @TODO: what about deeper relations?
		if (item[alias]) {
			if (criteria.options.document !== false) {
				if (association.options.singular) {
					if (Object.isPlainObject(item[alias])) {
						let assoc_crit = criteria.getCriteriaForAssociation(alias, item);

						if (!(item[alias] instanceof assoc_crit.model.constructor.Document)) {
							item[alias] = assoc_crit.model.createDocument(item[alias]);
						}
					}
				} else if (Array.isArray(item[alias])) {
					let assoc_crit = criteria.getCriteriaForAssociation(alias, item);
					item[alias] = assoc_crit.model.createDocumentList(item[alias]);
				}
			}

			return;
		}

		if (!criteria.shouldQueryAssociation(alias)) {
			return;
		}

		// Some datasources need to resolve certain associations themselves
		if (association.options.defer_to_datasource) {
			aliases[alias] = async function deferedAliasRecordTask(nextAlias) {

				let pk = data[that.primary_key];

				if (!pk) {
					return nextAlias();
				}

				let result = await that.datasource.getAssociationForPk(that, pk, alias);

				if (!result) {
					return nextAlias(null);
				}

				let assoc_model = that.getModel(association.modelName);

				if (Array.isArray(result)) {
					result = assoc_model.createDocumentList(result);
				} else {
					result = assoc_model.createDocument(result);
				}

				return nextAlias(null, result);
			};

			return;
		}

		let assoc_crit = criteria.getCriteriaForAssociation(alias, item);

		if (!assoc_crit) {
			return;
		}

		// SchemaFields use a dummy Model name, so get the root_model in that case
		let root_model = (that.options && that.options.root_model) || that.name;

		// Make sure references to the same model don't cause a deadlock
		// (Can happen when a model refers to itself)
		if (association.modelName == root_model && criteria.options._root_data) {
			let assoc_key = assoc_crit.options.assoc_key,
			    assoc_value = assoc_crit.options.assoc_value,
			    root = criteria.options._root_data[root_model];

			if (root && root[assoc_key] && Object.alike(root[assoc_key], assoc_value)) {

				aliases[alias] = nextAlias => {
					nextAlias(null, root[assoc_key]);
				};

				return;
			}
		}

		aliases[alias] = function aliasRecordTask(nextAlias) {

			let pledge,
			    key;

			if (options.create_references !== false) {
				key = alchemy.checksum(assoc_crit);

				if (key != null) {
					if (options.assoc_cache[key]) {
						Pledge.done(options.assoc_cache[key], nextAlias);
						return;
					} else {
						pledge = new Pledge();
						options.assoc_cache[key] = pledge;
					}
				}
			}

			assoc_crit.model.find('all', assoc_crit, function foundAliasItems(err, assocItems) {

				var result,
				    a_item,
				    temp,
				    i;

				if (options.debug) {
					console.log(assoc_crit.model.name, err, JSON.clone(assocItems));
				}

				if (err != null) {

					if (pledge) {
						pledge.reject(err);
					}

					return nextAlias(err);
				}

				result = assocItems;

				if (association.options.singular) {
					result = result[0];
				}

				if (options.debug) {
					console.log('Finished assoc from', assoc_crit.model.name, 'for', that.name, result);
				}

				if (key) {
					options.assoc_cache[key] = result;

					if (pledge) {
						pledge.resolve(result);
					}
				}

				nextAlias(null, result);
			});
		};
	});

	pledge = Function.parallel(alchemy.settings.data_management.model_assoc_parallel_limit || 4, aliases, function gotAssociatedList(err, list) {

		if (err != null) {
			console.log('ERROR: ' + err, err);
			return;
		}

		// Add the associated data to the item
		Object.assign(item, list);

		return item;
	});

	pledge.handleCallback(callback);

	return pledge;
});

/**
 * Save (mixed) data to the database
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.0.7
 *
 * @return   {Pledge}
 */
Model.setMethod(function save(data, _options, _callback) {

	var that     = this,
	    callback = _callback,
	    options  = _options,
	    results,
	    pledge,
	    iter;

	// Normalize the arguments
	if (typeof options == 'function') {
		callback = options;
	}

	if (options == null || typeof options !== 'object') {
		options = {};
	}

	// Create the pledge
	pledge = new Pledge();

	// Turn the given data into a document list
	data = this.createDocumentList(data);

	// Tell the pledge how many parts there are
	pledge.addProgressPart(data.length + 1);

	// Make it handle the callback, if any
	pledge.done(callback);

	// Get an iterator
	iter = new Iterator(data.toArray());

	// Saved results go here
	results = [];

	// Save every given item
	Function.while(function test() {
		return iter.hasNext();
	}, function saveData(next) {

		var document = iter.next().value;

		// Skip invalid items
		if (!document) {
			pledge.reportProgressPart(1);
			return next();
		}

		// Let the user now if it's a new record
		if (options.create == null) {
			options.create = document.is_new_record;
		}

		// Save the data
		that.saveRecord(document, options, function saved(err, result) {

			pledge.reportProgressPart(1);

			if (err != null) {
				return next(err);
			}

			if (!result) {
				return next(new Error('Model#saveRecord response value is empty'));
			}

			results.push(result);
			next(null);
		});
	}, function savedAll(err) {

		if (err) {
			return pledge.reject(err);
		}

		if (options.document !== false) {
			results = that.createDocumentList(results);
		}

		pledge.resolve(results);
	});

	return pledge;
});

/**
 * Create a new document.
 * If data is given, the document is populated
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.3.0
 *
 * @param    {Object}    data     Optional data
 * @param    {Object}    options
 *
 * @return   {Document}
 */
Model.setMethod(function createDocument(data, options) {

	let original_record;

	if (!options) {
		options = {};
	}

	if (data && this.constructor.Document.isDocument(data)) {
		original_record = data.$attributes.original_record;

		data = {
			[data.$model_alias] : data.$main,
		};
	}

	options.model = this;

	let doc = new this.constructor.Document(data, options);

	if (original_record) {
		doc.$attributes.original_record = original_record;
	}

	return doc;
});

/**
 * Create a document list
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.0.5
 *
 * @param    {Array}     records
 * @param    {Object}    options
 *
 * @return   {DocumentList}
 */
Model.setMethod(function createDocumentList(records, options) {

	// Make sure we have an array of records
	records = Array.cast(records);

	// Create new target array
	let documents = Array(records.length);

	for (let i = 0; i < records.length; i++) {
		documents[i] = this.createDocument(records[i]);
	}

	documents.available = records.available;

	if (Classes.Alchemy.DocumentList) {
		documents = new Classes.Alchemy.DocumentList(documents, options);
	} else {
		documents = new Classes.Alchemy.Client.DocumentList(documents, options);
	}

	return documents;
});

/**
 * Insert record on the client side
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.0.0
 */
Model.setAfterMethod('ready', function _create(object, callback) {

	if (!object._id) {
		object._id = Blast.createObjectId();
	}

	// Store record in the client-side indexeddb
	this.datasource.put('records', object, callback);
});

/**
 * Is the given action enabled on the server?
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.6
 * @version  1.0.6
 *
 * @param    {string}    action
 *
 * @return   {boolean}
 */
Model.setMethod(function hasServerAction(action) {
	return this.constructor.hasServerAction(action);
});

/**
 * Client-side model info
 *
 * @type     {Object}
 */
Model.setProperty(function model_info() {

	let class_name = this.constructor.name,
	    model_name = this.constructor.model_name,
	    data;

	if (Blast.isBrowser) {

		// Scene is not ready yet...
		if (!hawkejs.scene) {
			return {};
		}

		let config;

		for (config of hawkejs.scene.exposed.model_info) {
			if (config.model_name === model_name) {
				data = config;
				break;
			}
		}
	} else {

		let MainClass = Blast.Classes.Alchemy.Model.Model.get(model_name, false);

		if (!MainClass) {
			throw new Error('Unable to find main class for "' + model_name + '", unable to get client config!');
		}

		data = MainClass.getClientConfig();
	}

	return data;
}, false);

/**
 * Schema
 *
 * @type     {Object}
 */
Model.setProperty(function schema() {
	return this.model_info.schema;
}, false);

/**
 * Associations
 *
 * @type     {Object}
 */
Model.setProperty(function associations() {
	return this.schema.associations;
}, false);

/**
 * Get a field instance from the schema
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.0
 * @version  1.2.0
 *
 * @param    {string}   path   The path to the field
 *
 * @return   {Object}
 */
Model.setMethod(function getField(path) {

	if (path.indexOf('.') > -1) {
		let config = new Classes.Alchemy.Criteria.FieldConfig(path);

		// If part of the path is an association, look for that now
		if (config.association) {
			let association = this.getAssociation(config.association);
			let model = this.getModel(association.modelName);
			return model.getField(config.local_path);
		}

		// If not, just use the local_path
		path = config.local_path;
	}

	return this.schema.getField(path);
});

// Make this class easily available
Hawkejs.Model = Model;

/**
 * Save one record
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.0.1
 * @version  1.3.20
 *
 * @param    {Document}   document
 * @param    {Object}     options
 * @param    {Function}   callback
 *
 * @return   {Pledge}
 */
Model.setMethod(function saveRecord(document, options, callback) {

	var that = this,
	    saved_record,
	    creating,
	    results,
	    pledge,
	    main,
	    iter;

	// Normalize the arguments
	if (typeof options == 'function') {
		callback = options;
	}

	if (typeof options !== 'object') {
		options = {};
	}

	pledge = Function.series(function doAudit(next) {

		if (Object.isPlainObject(document)) {
			return next(new Error('Model#saveRecord() expects a Document, not a plain object'));
		}

		creating = options.create || document.$pk == null;
		next();

		return;

		// Look through unique indexes if no _id is present
		that.auditRecord(document, options, function afterAudit(err, doc) {

			if (err) {
				return next(err);
			}

			// Is a new record being created?
			creating = options.create || doc.$pk == null;
			next();
		});
	}, function doBeforeNormalize(next) {
		// @TODO: make "beforeSave" only use promises
		that.issueDataEvent('beforeNormalize', [document, options], next);
	}, function emitSavingEvent(next) {
		// @TODO: Should this be able to stop the saving without throwing an error?
		that.issueDataEvent('beforeSave', [document, options, creating], next);
	}, function doDatabase(next) {

		if (options.debug) {
			console.log('Saving document', document, 'Creating?', creating);
		}

		function gotRecord(err, result) {
			if (err) {
				return next(err);
			}

			saved_record = result;
			next();
		}

		if (creating) {
			that.createRecord(document, options, gotRecord);
		} else {
			that.updateRecord(document, options, gotRecord);
		}
	}, function doAssociated(next) {

		var tasks = [],
		    assoc,
		    entry,
		    key;

		Object.each(document.$record, function eachEntry(entry, key) {

			// Skip our own record
			if (key == that.name) {
				return;
			}

			// Get the association configuration
			assoc = that.schema.associations[key];

			// If the association doesn't exist, do nothing
			if (!assoc) {
				return;
			}

			// Add the saved _id
			entry[assoc.options.foreignKey] = saved_record[assoc.options.localKey];

			// Add the task
			tasks.push(function doSave(next) {
				var a_model = that.getModel(assoc.modelName);
				a_model.save(entry, next);
			});
		});

		Function.parallel(tasks, next);
	}, function done(err) {

		if (err) {
			return;
		}

		return saved_record;
	});

	pledge.done(callback);

	return pledge;
});

/**
 * Prepare some data by passing it through the schema
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.1.0
 * @version  1.1.0
 *
 * @param    {Object}   data   Field values
 *
 * @return   {Object}
 */
Model.setMethod(function compose(data, options) {

	if (!this.schema || !this.schema.process) {
		return data;
	}

	return this.schema.process(data, options);
});

/**
 * Create a record in the database
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.4.0
 *
 * @param    {Document}   document
 * @param    {Object}     options
 * @param    {Function}   callback
 */
Model.setMethod(function createRecord(document, options, callback) {

	const that = this;

	// Normalize & clone the data, set default values, ...
	let data = this.compose(document, options);

	document = createDocumentForSaving(this, document, data);

	Function.series(function recompute(next) {
		let result = document.recomputeValues();

		if (result) {
			result.done(next);
		} else {
			next();
		}
	}, function doBeforeValidate(next) {
		that.issueDataEvent('beforeValidate', [document, options], next);
	}, function validate(next) {

		if (options.validate === false || !that.schema) {
			return next();
		}

		Pledge.done(that.schema.validate(document), next);
	}, function doBeforeSave(next) {
		that.issueDataEvent('beforeSave', [document, options], next);
	}, function done(err) {

		if (err) {
			return callback(err);
		}

		if (!that.datasource) {
			return callback(new Error('Model "' + that.model_name + '" has no datasource'));
		}

		let context = new Classes.Alchemy.OperationalContext.SaveDocumentToDatasource();
		context.setDatasource(that.datasource);
		context.setModel(that);
		context.setRootData(data);
		context.setSaveOptions(options);

		let create_promise;

		try {
			create_promise = that.datasource.create(context);
		} catch (err) {
			return callback(err);
		}

		Pledge.Swift.done(create_promise, function afterCreate(err, result) {

			if (err != null) {
				return callback(err);
			}

			that.issueDataEvent('afterSave', [result, options, true], function afterSavedEvent() {
				callback(null, result);
			});
		});
	})
});

/**
 * Create a document used for saving
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.3.21
 * @version  1.3.21
 *
 * @param    {Model}             model
 * @param    {Document|Object}   original_input
 * @param    {Object}            data
 */
function createDocumentForSaving(model, original_input, data) {

	// Turn it into a new document
	let document = model.createDocument(data);

	if (original_input?.$attributes.original_record) {
		document.$attributes.original_record = original_input.$attributes.original_record;
	}

	return document;
}

/**
 * Update a record in the database
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    0.2.0
 * @version  1.4.0
 *
 * @param    {Document}   document
 * @param    {Object}     options
 * @param    {Function}   callback
 */
Model.setMethod(function updateRecord(document, options, callback) {

	const that = this;

	// Normalize the data, but no default values should be set (skip non present items)
	let data = this.compose(document, Object.assign({update: true}, options));

	// Turn it into a new document
	document = createDocumentForSaving(this, document, data);

	Function.series(function recompute(next) {
		let result = document.recomputeValues();

		if (result) {
			result.done(next);
		} else {
			next();
		}
	}, function doBeforeValidate(next) {
		that.issueDataEvent('beforeValidate', [document, options], next);
	}, function validate(next) {

		if (options.validate === false || !that.schema) {
			return next();
		}

		Pledge.done(that.schema.validate(document), next);

	}, function doBeforeSave(next) {
		that.issueDataEvent('beforeSave', [document, options], next);
	}, function done(err) {

		if (err) {
			return callback(err);
		}

		if (!that.datasource) {
			return callback(new Error('Model "' + that.model_name + '" has no datasource'));
		}

		let context = new Classes.Alchemy.OperationalContext.SaveDocumentToDatasource();
		context.setDatasource(that.datasource);
		context.setModel(that);
		context.setRootData(data);
		context.setSaveOptions(options);

		let update_result;
		
		try {
			update_result = that.datasource.update(context);
		} catch (err) {
			return callback(err);
		}

		Swift.done(update_result, function afterUpdate(err, result) {

			if (err != null) {
				return callback(err);
			}

			that.issueDataEvent('afterSave', [result, options, false], function afterSavedEvent(err) {

				if (err) {
					return callback(err);
				}

				callback(null, result);
			});
		});
	});
});

/**
 * Get records that need to be saved to the server
 *
 * @author   Jelle De Loecker <jelle@elevenways.be>
 * @since    1.0.6
 * @version  1.1.0
 */
Model.setMethod(async function getRecordsToBeSavedRemotely() {

	var that = this,
	    pledge = new Pledge;

	if (!this.datasource.getRecordsToSync) {
		pledge.resolve([]);
		return pledge;
	}

	this.datasource.getRecordsToSync(this).done(function gotRecords(err, records) {

		if (err) {
			return pledge.reject(err);
		}

		let result = [],
		    data,
		    i;

		for (i = 0; i < records.length; i++) {
			result[i] = that.createDocument(records[i]);
		}

		result = new Blast.Classes.Alchemy.Client.DocumentList(result);

		pledge.resolve(result);
	});

	return pledge;
}, false);