UNPKG

alchemymvc

Version:
1,182 lines (960 loc) 23.4 kB
const CriteriaNS = Function.getNamespace('Alchemy.Criteria'), Expressions = Function.getNamespace('Alchemy.Criteria.Expression'); /** * The Criteria class * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.4.0 * * @param {Model} model */ const Criteria = Function.inherits('Alchemy.Criteria.Criteria', function Model(model) { // The model this.model = model; Model.super.call(this, { select : new Select(this), document : true, document_list : true, }); }); /** * Make sure to get a criteria * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.5 * @version 1.4.0 * * @param {Object} conditions The thing that should be a criteria * @param {Object} options The options to apply * @param {Model} model The model that it probably belongs to * * @return {Criteria} */ Criteria.setStatic(function cast(conditions, options, model) { if (Criteria.isCriteria(conditions)) { return conditions; } if (arguments.length == 2) { model = options; options = conditions; conditions = null; } let instance = new Criteria(model); if (options) { instance.applyOldOptions(options); } if (conditions) { instance.applyConditions(conditions); } return instance; }); /** * Undry the given object * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.3 * * @param {Object} data * * @return {Criteria} */ Criteria.setStatic(function unDry(data) { var criteria = new Criteria(); if (data.model) { try { criteria.model = alchemy.getModel(data.model); } catch (err) { // Ignore console.warn('Failed to find "' + data.model + '" model'); } } // Revive the group instance criteria.group = Expressions.Group.revive(data.group, criteria); if (!data.options) { data.options = {}; } // Revive the select data.options.select = Select.revive(data.options.select, criteria); criteria.options = data.options || {}; return criteria; }); /** * Parse a path to an object * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} path * @param {Criteria} criteria * * @return {Object} */ Criteria.setStatic(function parsePath(path, criteria) { var target_path, result = {}, pieces, piece, alias, i; if (path.indexOf('.') > -1) { pieces = path.split('.'); } else { pieces = [path]; } for (i = 0; i < pieces.length; i++) { piece = pieces[i]; alias = null; if (criteria && criteria.model) { if (criteria.model.associations[piece]) { alias = piece; } } if (!alias && piece[0].isUpperCase()) { alias = piece; } if (alias) { if (!result.association) { result.association = []; } result.association.push(alias); continue; } target_path = pieces.slice(i).join('.'); break; } if (target_path) { result.target_path = target_path; } return result; }); /** * Create a reference to the datasource * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @type {Datasource} */ Criteria.setProperty(function datasource() { if (this._datasource) { return this._datasource; } if (this.model) { return this.model.datasource; } }, function setDatasource(ds) { this._datasource = ds; return this._datasource; }); /** * The recursiveness of this criteria * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @type {number} */ Criteria.setProperty(function recursive_level() { if (this.options.recursive) { return this.options.recursive; } return 0; }); /** * Allow the criteria to be used in a for wait loop * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 */ Criteria.setMethod(Symbol.asyncIterator, function asyncIterator() { const that = this, model = this.model; if (!model) { throw new Error('Unable to iterate over a criteria without a model'); } // Clone it let criteria = this.clone(); // Set the limit to 1 criteria.limit(1); // Create the iterator context let context = { index : 0, next : async function next() { criteria.skip(this.index++); let record = await model.find('first', criteria); if (!record) { return {done: true}; } return {value: record, done: false}; } }; return context; }); /** * Return object for jsonifying * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.3 * * @return {Object} */ Criteria.setMethod(function toJSON() { let result = {}, options; if (this.model && this.model.name) { result.model = this.model.name; } if (this.options) { let key; options = {}; for (key in this.options) { if (key == 'assoc_cache' || key == 'init_record') { continue; } options[key] = this.options[key]; } } result.group = this.group; result.options = options; return result; }); /** * Clone this instance * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @return {Criteria} */ Criteria.setMethod(function clone() { var data = JSON.toDryObject(this), result; data.model = null; result = JSON.undry(data); result.model = this.model; return result; }); /** * Get the main fields to select * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.3 * * @return {string[]} */ Criteria.setMethod(function getFieldsToSelect() { let result; if (this.options?.select?.fields?.length) { result = this.options.select.fields.slice(0); } // Fields can sometimes be required for a query (like in a join) but they // won't be selected if other fields are explicitly set. // So in that case: add these special fields to the projection if (result && this.options?.select?.query_fields) { result.push(...this.options.select.query_fields); } return result || []; }); /** * Get the association selects, if any * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @return {Object} */ Criteria.setMethod(function getAssociationsToSelect() { let result = this.options.select.associations; return result; }); /** * Should the given association be queried? * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} name * * @return {boolean} */ Criteria.setMethod(function shouldQueryAssociation(name) { var result = false; // If there are explicit associations selected, // then this one has to be in it! if (this.getAssociationsToSelect()) { result = this.options.select.shouldQueryAssociation(name); } else { // There are no explicit associations, look at the recursive level result = this.recursive_level > 0; } return result; }); /** * Get association configuration in the current active model * or in the options * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} alias * * @return {Object} */ Criteria.setMethod(function getAssociationConfiguration(alias) { if (this.options.associations && this.options.associations[alias]) { return this.options.associations[alias]; } return this.model.getAssociation(alias); }); /** * Get a new criteria for adding associated data * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.3 * * @param {string} name * @param {Object} item * * @return {Criteria} */ Criteria.setMethod(function getCriteriaForAssociation(name, item) { if (!this.model) { throw new Error('Unable to create criteria for association "' + name + '" without originating model instance'); } let assoc_model = this.model.getAliasModel(name), data = item[this.model.name]; // @TODO: For deadlock reasons we don't query self-referencing links! // (Implemented in Schema fields, BelongsTo and such could still pose problems!) if (assoc_model.name == this.options.init_model) { return; } let association = this.getAssociationConfiguration(name); let value = data[association.options.localKey]; // If no valid value is found for the associated key, do nothing if (value == null) { return; } let assoc_crit = assoc_model.find(), assoc_key = association.options.foreignKey, options = this.options, select; if (Array.isArray(value)) { assoc_crit.where(assoc_key).in(value); } else { assoc_crit.where(assoc_key).equals(value); } assoc_crit.setOption('assoc_key', assoc_key); assoc_crit.setOption('assoc_value', value); // Make the assoc_cache if it doesn't exist yet if (options.create_references !== false && !options.assoc_cache) { options.assoc_cache = {}; } // Add the assoc_cache if (options.assoc_cache) { assoc_crit.setOption('assoc_cache', options.assoc_cache); } // Take over the locale option if (options.locale) { assoc_crit.setOption('locale', options.locale); } // The debug object, if there is one if (options._debugObject) { assoc_crit.setOption('_debugObject', options._debugObject); } // Don't get the available count assoc_crit.setOption('available', false); if (options.select.associations && options.select.associations[name]) { select = options.select.associations[name]; assoc_crit.options.select = select.cloneForCriteria(assoc_crit); } // Sort the results // @TODO: add sorts // if (query.sort && query.sort[alias]) { // assocOpts.sort = query.sort[alias]; // } if (Number.isSafeInteger(options.recursive) && options.recursive > 0) { assoc_crit.recursive(options.recursive - 1); } else { // Disable recursiveness for the next level assoc_crit.recursive(0); } // Add the model name from where we're adding associated data assoc_crit.setOption('init_model', options.init_model || this.model.name); assoc_crit.setOption('init_record', options.init_record || item); assoc_crit.setOption('from_alias', options.for_alias); assoc_crit.setOption('from_model', options.for_model); assoc_crit.setOption('for_alias', name); assoc_crit.setOption('for_model', assoc_model.name); // Honor the original document option assoc_crit.setOption('document', options.document); if (options.debug) { assoc_crit.setOption('debug', true); console.log('Associated criteria:', assoc_model.name, assoc_crit); } return assoc_crit; }); /** * Limit the amount of records to get * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.7 * * @param {number} amount * * @return {Criteria} */ Criteria.setMethod(function limit(amount) { if (typeof amount != 'number') { amount = parseInt(amount); } this.options.limit = amount; return this; }); /** * Skip an amount of records * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.7 * * @param {number} amount * * @return {Criteria} */ Criteria.setMethod(function skip(amount) { if (typeof amount != 'number') { amount = parseInt(amount); } this.options.skip = amount; return this; }); /** * Get a specific page * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.3 * @version 1.2.7 * * @param {number} page A 1-indexed page number * @param {number} page_size * * @return {Criteria} */ Criteria.setMethod(function page(page, page_size) { if (typeof page != 'number') { page = parseInt(page); } if (page_size && typeof page_size != 'number') { page_size = parseInt(page_size); } if (!page) { throw new Error('A page number is required'); } if (!page_size || !isFinite(page_size)) { page_size = 10; } let skip = (page - 1) * page_size; this.options.page = page; this.options.page_size = page_size; this.skip(skip); return this.limit(page_size); }); /** * Select a specific field or association * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.0 * * @param {string|Array} field * * @return {Criteria} */ Criteria.setMethod(function select(field) { var context; if (Object.isIterable(field)) { let entry; for (entry of field) { context = this.select(entry); } } else { if (this._select) { context = this._select.parse(field); } else { context = this.options.select.parse(field); } } // Selects don't always change the context if (context) { return context; } return this; }); /** * Add a specific association * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} alias * * @return {Criteria} */ Criteria.setMethod(['contain', 'populate'], function populate(alias) { if (Array.isArray(alias)) { let i; for (i = 0; i < alias.length; i++) { this.populate(alias[i]); } } else { let select = this._select || this.options.select; select.addAssociation(alias); } return this; }); /** * How deep can we go? * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {number} amount * * @return {Criteria} */ Criteria.setMethod(function recursive(amount) { this.options.recursive = amount; return this; }); /** * Set the sort * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.4 * * @param {Array} value * * @return {Criteria} */ Criteria.setMethod(function sort(value) { var result; if (value) { result = []; // Parse strings if (typeof value == 'string') { // When it contains a space, we expect something // like "_id asc" if (value.indexOf(' ') > -1) { result.push(value.split(' ')); } else { // Sort ascending by default result.push([value, 1]); } } else if (Array.isArray(value)) { if (Array.isArray(value[0])) { result = value; } else { result.push(value); } } else { let keys = Object.keys(value), key; if (keys.length == 2 && ~keys.indexOf('dir') && ~keys.indexOf('field')) { if (value.field && value.dir) { result.push([value.field, value.dir]); } } else { for (key in value) { result.push([key, value[key]]); } } } let entry, i; for (i = 0; i < result.length; i++) { entry = result[i]; if (typeof entry[1] == 'string') { entry[1] = entry[1].toLowerCase(); if (entry[1] == 'asc') { entry[1] = 1; } else if (entry[1] == 'desc') { entry[1] = -1; } else { throw new Error('Unable to parse sort specification "' + entry[1] + '"'); } } } // @TODO: implement better handling of ModelName.field sort stuff // (Because at this moment, it's just ignored!) for (entry of result) { if (entry[0].indexOf('.') > -1) { let pieces = entry[0].split('.'), char = pieces[0][0]; if (char == char.toUpperCase()) { pieces = pieces.slice(1); } entry[0] = pieces.join('.'); } } } else { result = null; } this.options.sort = result; return this; }); /** * Normalize the criteria by filling in some values on datasources without joins * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @return {Pledge} */ Criteria.setMethod(function normalize() { if (!this.model) { return Pledge.reject(new Error('Unable to normalize criteria without model instance')); } let that = this, tasks = [], i; for (i = 0; i < this.all_expressions.length; i++) { let expression = this.all_expressions[i]; if (!expression) { continue; } // Do we need to normalize association values? if (expression.requires_association_normalization) { tasks.push(function doNormalize(next) { expression.normalizeAssociationValues().done(next); }); continue; } let pledge = expression.normalize(); if (pledge) { tasks.push(pledge); } } return Function.parallel(4, tasks); }); /** * Compile to MongoDB-like query * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @return {Object} */ Criteria.setMethod(function compile() { if (!this.datasource) { throw new Error('Unable to compile criteria without a datasource target'); } return this.datasource.compileCriteria(this); }); /** * Parse an old, mongodb specific options object * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {Object} options * * @return {Criteria} */ Criteria.setMethod(function applyOldOptions(options) { if (!options || Object.isEmpty(options)) { return this; } let entry, key; for (key in options) { entry = options[key]; switch (key) { case 'sort' : this.sort(entry); break; case 'limit' : this.limit(entry); break; case 'fields' : this.select(entry); break; case 'select' : this.select(entry); break; case 'recursive' : this.recursive(entry); break; case 'offset' : this.skip(entry); break; case 'populate' : this.populate(entry); break; case 'conditions': this.applyConditions(entry); break; default: this.setOption(key, entry); } } return this; }); /** * The Criteria Select class * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 */ var Select = Function.inherits('Alchemy.Base', 'Alchemy.Criteria', function Select(criteria) { // The parent criteria instance this.criteria = criteria; }); /** * Revive the given object * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.3 * * @return {Select} */ Select.setStatic(function revive(data, criteria) { if (!data) { return; } let result = new Select(criteria), key; result.fields = data.fields; if (data.associations) { result.associations = {}; for (key in data.associations) { result.associations[key] = Select.revive(data.associations[key], criteria); } } if (data.association_name) { result.association_name = data.association_name; } return result; }); /** * Return object to jsonify * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @return {Object} */ Select.setMethod(function toJSON() { return { association_name : this.association_name, associations : this.associations, fields : this.fields }; }); /** * Return the elements to checksum in place of this object * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 */ Select.setMethod(Blast.checksumSymbol, function toChecksum() { var result = []; if (this.associations) { result.push(this.associations); } if (this.fields) { result.push(this.fields); } if (this.association_name) { result.push(this.association_name); } if (!result.length) { return null; } return result; }); /** * Add an association * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.3.4 * * @param {string} name * * @return {Select} This creates a new Select instance */ Select.setMethod(function addAssociation(name) { if (!this.criteria?.model) { throw new Error('Unable to select an association: this Criteria has no model info'); } var pieces; if (!this.associations) { this.associations = {}; } if (Array.isArray(name)) { pieces = name; } else if (name.indexOf('.') > -1) { pieces = name.split('.'); } if (pieces && pieces.length) { let context = this; while (pieces.length) { name = pieces.shift(); context = context.addAssociation(name); } return context; } if (!this.associations[name]) { this.associations[name] = new Select(this.criteria); this.associations[name].association_name = name; } // Get the association data try { let info = this.criteria.model.getAssociation(name); if (info) { // Make sure the localkey is added to the resultset this.requireFieldForQuery(info.options.localKey); } } catch (err) { console.warn('Failed to find "' + name + '" association for ' + this.criteria.model.model_name); } return this.associations[name]; }); /** * Require a field for query purposes * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.0 * @version 1.2.0 * * @param {string} path */ Select.setMethod(function requireFieldForQuery(path) { if (!this.query_fields) { this.query_fields = []; } this.query_fields.push(path); }); /** * Add a field * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} path */ Select.setMethod(function addField(path) { if (!this.fields) { this.fields = []; } this.fields.push(path); }); /** * Parse a path meant to add as a selection * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.0 * * @param {string|Object} path * * @return {Criteria|Null} A criteria object if the context has changed */ Select.setMethod(function parse(path) { let context, select = this, parsed; if (typeof path == 'object' && path && path.name) { if (path.path) { path = path.path; } else { let obj = path; path = obj.name; if (obj.association) { path = obj.association + '.' + path; } } } parsed = Criteria.parsePath(path, this.criteria); // Associations were found, // like "Comment._id" or "Comment.User" if (parsed.association) { let name, i; for (i = 0; i < parsed.association.length; i++) { name = parsed.association[i]; if (this.model && this.model.name == name) { continue; } select = select.addAssociation(name); } } if (parsed.target_path) { select.addField(parsed.target_path); } else if (parsed.association) { // When only an association was given, then the context changes context = this.criteria.augment('select'); context._select = select; return context; } }); /** * Clone this select for the given criteria * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.2.0 * * @param {Criteria} criteria * * @return {Select} */ Select.setMethod(function cloneForCriteria(criteria) { var clone = new Select(criteria); if (this.association_name) { clone.association_name = this.association_name; } if (this.fields && this.fields.length) { clone.fields = this.fields.slice(0); } if (this.query_fields && this.query_fields.length) { clone.query_fields = this.query_fields.slice(0); } if (this.associations) { let key; clone.associations = {}; for (key in this.associations) { clone.associations[key] = this.associations[key].cloneForCriteria(criteria); } } return clone; }); /** * Should the given association be queried according to this select? * (The Criteria instance can also have a recursive level set) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {string} name * * @return {boolean} */ Select.setMethod(function shouldQueryAssociation(name) { if (this.associations) { return !!this.associations[name]; } return false; });