UNPKG

@warlock.js/cascade

Version:

ORM for managing databases

912 lines (911 loc) 26.6 kB
import {get}from'@mongez/reinforcements';import {log}from'@warlock.js/logger';import {ObjectId}from'mongodb';import {ModelEvents}from'../model/model-events.js';import {query}from'../query/query.js';import {DeselectPipeline}from'./DeselectPipeline.js';import {GroupByPipeline}from'./GroupByPipeline.js';import {LimitPipeline}from'./LimitPipeline.js';import {LookupPipeline}from'./LookupPipeline.js';import {OrWherePipeline}from'./OrWherePipeline.js';import {SelectPipeline}from'./SelectPipeline.js';import {SkipPipeline}from'./SkipPipeline.js';import {SortByPipeline}from'./SortByPipeline.js';import {SortPipeline}from'./SortPipeline.js';import {SortRandomPipeline}from'./SortRandomPipeline.js';import {UnwindPipeline}from'./UnwindPipeline.js';import {WhereExpression,toOperator,parseValuesInObject}from'./WhereExpression.js';import {WherePipeline}from'./WherePipeline.js';import {year,$agg,month,dayOfMonth,week,last,count}from'./expressions.js';import {applyFilters}from'./filters/apply-filters.js';import {parsePipelines}from'./parsePipelines.js';class Aggregate { collection; /** * Collection pipelines */ pipelines = []; /** * Aggregate events */ static _events = new ModelEvents(); /** * Query manager */ query = query; /** * Constructor */ constructor(collection) { this.collection = collection; // get the events instance const events = Aggregate._events; Aggregate._events.trigger("fetching", this); events.collection = collection; } /** * Get the events instance */ static events() { return Aggregate._events; } /** * Sort by the given column */ sort(column, direction = "asc") { return this.pipeline(new SortPipeline(column, direction)); } /** * @alias sort */ orderBy(column, direction = "asc") { return this.sort(column, direction); } /** * Order by descending */ sortByDesc(column) { return this.sort(column, "desc"); } /** * Order by descending */ orderByDesc(column) { return this.sort(column, "desc"); } /** * Sort by multiple columns */ sortBy(columns) { return this.pipeline(new SortByPipeline(columns)); } /** * Sort randomly */ random(limit) { if (!limit) { // get limit pipeline const limitPipeline = this.pipelines.find(pipeline => pipeline.name === "limit"); if (limitPipeline) { limit = limitPipeline.getData(); } if (!limit) { throw new Error("You must provide a limit when using random() or use limit() pipeline"); } } // order by random in mongodb using $sample return this.pipeline(new SortRandomPipeline(limit)); } /** * Order by latest created records */ latest(column = "createdAt") { return this.sort(column, "desc"); } /** * Order by oldest created records */ oldest(column = "createdAt") { return this.sort(column, "asc"); } groupBy(...args) { const [groupBy_id, groupByData] = args; if (groupBy_id instanceof GroupByPipeline) { return this.pipeline(groupBy_id); } return this.pipeline(new GroupByPipeline(groupBy_id, groupByData)); } /** * Group by year */ groupByYear(column, groupByData) { return this.groupBy({ year: year($agg.columnName(column)), }, groupByData); } /** * Group by month and year */ groupByMonthAndYear(column, groupByData) { column = $agg.columnName(column); return this.groupBy({ year: year(column), month: month(column), }, groupByData); } /** * Group by month only */ groupByMonth(column, groupByData) { column = $agg.columnName(column); return this.groupBy({ month: month(column), }, groupByData); } /** * Group by day, month and year */ groupByDate(column, groupByData) { column = $agg.columnName(column); return this.groupBy({ year: year(column), month: month(column), day: dayOfMonth(column), }, groupByData); } /** * Group by week and year */ groupByWeek(column, groupByData) { column = $agg.columnName(column); return this.groupBy({ year: year(column), week: week(column), }, groupByData); } /** * Group by day only */ groupByDayOfMonth(column, groupByData) { column = $agg.columnName(column); return this.groupBy({ day: dayOfMonth(column), }, groupByData); } /** * Pluck only the given column */ async pluck(column) { return await this.select([column]).get(record => get(record, column)); } /** * Get average of the given column */ async avg(column) { const document = await this.groupBy(null, { avg: $agg.avg(column), }).first(document => document); return document?.avg || 0; } /** * {@alias} avg */ average(column) { return this.avg(column); } /** * Sum values of the given column */ async sum(column) { const document = await this.groupBy(null, { sum: $agg.sum(column), }).first(document => document); return document?.sum || 0; } /** * Get minimum value of the given column */ async min(column) { const document = await this.groupBy(null, { min: $agg.min(column), }).first(document => document); return document?.min || 0; } /** * Get maximum value of the given column */ async max(column) { const document = await this.groupBy(null, { max: $agg.max(column), }).first(document => document); return document?.max || 0; } /** * Get distinct value for the given column using aggregation */ async distinct(column) { return (await this.groupBy(null, { // use addToSet to get unique values [column]: $agg.addToSet(column), }) .select([column]) .get(data => data[column])); } /** * {@alias} distinct */ unique(column) { return this.distinct(column); } /** * Get distinct values that are not empty */ async distinctHeavy(column) { return await this.whereNotNull(column).distinct(column); } /** * {@alias} distinctHeavy */ async uniqueHeavy(column) { return await this.distinctHeavy(column); } /** * Get values list of the given column */ async values(column) { return (await this.groupBy(null, { values: $agg.push(column), }) .select(["values"]) .get(data => data.values)); } /** * Limit the number of results */ limit(limit) { return this.pipeline(new LimitPipeline(limit)); } /** * Skip the given number of results */ skip(skip) { return this.pipeline(new SkipPipeline(skip)); } select(...columns) { if (columns.length === 1 && Array.isArray(columns[0])) { columns = columns[0]; } return this.pipeline(new SelectPipeline(columns)); } /** * Deselect the given columns */ deselect(columns) { return this.pipeline(new DeselectPipeline(columns)); } /** * Unwind/Extract the given column */ unwind(column, options) { return this.pipeline(new UnwindPipeline(column, options)); } where(...args) { return this.pipeline(new WherePipeline(WhereExpression.parse.apply(null, args))); } /** * Add comparison between two or more columns */ whereColumns(column1, operator, ...otherColumns) { const mongoOperator = toOperator(operator) || operator; return this.where($agg.expr({ [mongoOperator]: [ $agg.columnName(column1), ...otherColumns.map(column => $agg.columnName(column)), ], })); } orWhere(column) { return this.pipeline(new OrWherePipeline(column)); } /** * Perform a text search * Please note that this method will add the `match` stage to the beginning of the pipeline * Also it will add `score` field to the result automatically * * @warning This method will not work if the collection is not indexed for text search */ textSearch(query, moreFilters) { this.pipelines.unshift({ $match: { $text: { $search: query }, ...moreFilters, }, }); this.addField("score", { $meta: "textScore" }); return this; } /** * Where null */ whereNull(column) { return this.where(column, null); } /** * Check if the given column array has the given value or it is empty * Empty means either the array column does not exists or exists but empty * * @usecase for when to use this method is when you have lessons collection and you want to get all lessons that either does not have column `allowedStudents` * or has an empty array of `allowedStudents` or the `allowedStudents` column has the given student id * * Passing third argument empty means we will check directly in the given array (not array of objects in this case) */ whereArrayHasOrEmpty(column, value, key = "id") { const keyName = key ? `.${key}` : ""; return this.orWhere([ { [`${column}${keyName}`]: value, }, { [column]: { $size: 0 }, }, { [column]: { $exists: false }, }, ]); } /** * Check if the given column array does not have the given value or it is empty. * Empty means either the array column does not exist or exists but is empty. * * @usecase This method is useful when you have a collection, such as `lessons`, and you want to retrieve all lessons that either column `excludedStudents` does not contain the specified student id, * have an empty array for `excludedStudents`, or the `excludedStudents` does not exist. */ whereArrayNotHaveOrEmpty(column, value, key = "id") { const keyName = key ? `.${key}` : ""; return this.orWhere([ { [`${column}${keyName}`]: { $ne: value }, }, { [column]: { $size: 0 }, }, { [column]: { $exists: false }, }, ]); } /** * Where not null */ whereNotNull(column) { return this.where(column, "!=", null); } /** * Where like operator */ whereLike(column, value) { return this.where(column, "like", value); } /** * Where not like operator */ whereNotLike(column, value) { return this.where(column, "notLike", value); } /** * Where column starts with the given value */ whereStartsWith(column, value) { return this.where(column, "startsWith", value); } /** * Where column not starts with the given value */ whereNotStartsWith(column, value) { return this.where(column, "notStartsWith", value); } /** * Where column ends with the given value */ whereEndsWith(column, value) { return this.where(column, "endsWith", value); } /** * Where column not ends with the given value */ whereNotEndsWith(column, value) { return this.where(column, "notEndsWith", value); } /** * Where between operator */ whereBetween(column, value) { return this.where(column, "between", value); } /** * Where date between operator */ whereDateBetween(column, value) { return this.where(column, "between", value); } /** * Where date not between operator */ whereDateNotBetween(column, value) { return this.where(column, "notBetween", value); } /** * Where not between operator */ whereNotBetween(column, value) { return this.where(column, "notBetween", value); } /** * Where exists operator */ whereExists(column) { return this.where(column, "exists", true); } /** * Where not exists operator */ whereNotExists(column) { return this.where(column, "exists", false); } whereSize(...args) { // first we need to project the column to get the size const [column, operator, columnSize] = args; this.project({ [column + "_size"]: { $size: $agg.columnName(column), }, }); // then we can use the size operator this.where(column + "_size", operator, columnSize); // now we need to deselect the column size // this.project({ // [column + "_size"]: 0, // }); return this; } /** * Add project pipeline * */ project(data) { return this.addPipeline({ $project: data, }); } /** * Where in operator * If value is a string, it will be treated as a column name */ whereIn(column, values) { return this.where(column, "in", values); } /** * Where not in operator * If value is a string, it will be treated as a column name */ whereNotIn(column, values) { return this.where(column, "notIn", values); } /** * // TODO: Make a proper implementation * Where location near */ whereNear(column, value, _distance) { return this.where(column, "near", value); } /** * // TODO: Make a proper implementation * Get nearby location between the given min and max distance */ async whereNearByIn(column, value, _minDistance, _maxDistance) { return this.where(column, value); } /** * Lookup the given collection */ lookup(options) { this.pipeline(new LookupPipeline(options)); if (options.single && options.as) { const as = options.as; this.addField(as, last(as)); } return this; } /** * Add field to the pipeline */ addField(field, value) { return this.addPipeline({ $addFields: { [field]: value, }, }); } /** * Add fields to the pipeline */ addFields(fields) { return this.addPipeline({ $addFields: fields, }); } /** * Get new pipeline instance */ pipeline(...pipelines) { this.pipelines.push(...pipelines); return this; } /** * Unshift pipeline to the beginning of the pipelines */ unshiftPipelines(pipelines) { this.pipelines.unshift(...pipelines); return this; } /** * Add mongodb plain stage */ addPipeline(pipeline) { this.pipelines.push(pipeline); return this; } /** * Add mongodb plain stages */ addPipelines(pipelines) { this.pipelines.push(...pipelines); return this; } /** * Get pipelines */ getPipelines() { return this.pipelines; } /** * Determine if record exists */ async exists() { return (await this.limit(1).count()) > 0; } /** * {@inheritdoc} */ toJSON() { return this.parse(); } /** * Get only first result */ async first(mapData) { const results = await this.limit(1).get(mapData); return results[0]; } /** * Get last result */ async last(filters) { if (filters) { this.where(filters); } const results = await this.orderByDesc("id").limit(1).get(); return results[0]; } /** * Delete records */ async delete() { const ids = await (await this.select(["_id"]).pluck("_id")).map(_id => new ObjectId(_id)); Aggregate._events.trigger("deleting", this); return await query.delete(this.collection, { _id: ids, }); } /** * Get the data */ async get(mapData) { const records = await this.execute(); return mapData ? records.map(mapData) : records; } /** * Chunk documents based on the given limit */ async chunk(limit, callback, mapData) { const totalDocuments = await this.clone().count(); const totalPages = Math.ceil(totalDocuments / limit); for (let page = 1; page <= totalPages; page++) { const results = await this.clone().paginate(page, limit, mapData); const { documents, paginationInfo } = results; const output = await callback(documents, paginationInfo); if (output === false) break; } } /** * Paginate records based on the given filter */ async paginate(page = 1, limit = 15, mapData) { const totalDocumentsQuery = this.parse(); this.skip((page - 1) * limit).limit(limit); const records = await this.get(mapData); this.pipelines = totalDocumentsQuery; const totalDocuments = await this.count(); const result = { documents: records, paginationInfo: { limit, page, result: records.length, total: totalDocuments, pages: Math.ceil(totalDocuments / limit), }, }; return result; } /** * Use cursor pagination-based for better performance */ async cursorPaginate(options, mapData) { if (options.cursorId) { this.where(options.column ?? "id", options.direction === "next" ? ">" : "<", options.cursorId); } // now set the limit // we need to increase the limit by 1 to check if we have more records this.limit(options.limit + 1); const records = await this.execute(); // now let's check if we have more records const hasMore = records.length > options.limit; let nextCursorId = null; if (hasMore) { // Remove the extra fetched record depending on the pagination direction const record = options.direction === "next" ? records.pop() // Forward: pop the last record : records.shift(); // Backward: shift the first record // Get the next cursor id from the popped or shifted record nextCursorId = get(record, options.column ?? "id"); } return { documents: mapData ? records.map(mapData) : records, hasMore, nextCursorId, }; } /** * Explain the query */ async explain() { return (await this.query.aggregate(this.collection, this.parse(), { explain: true, })).explain(); } /** * Update the given data */ async update(data) { try { const query = []; const filters = {}; this.parse().forEach(pipeline => { if (pipeline.$match) { Object.assign(filters, pipeline.$match); } else { query.push(pipeline); } }); Aggregate._events.trigger("updating", this); const results = await this.query.updateMany(this.collection, filters, [ ...query, { $set: parseValuesInObject(data), }, ]); return results.modifiedCount; } catch (error) { log.error("database", "aggregate.update", error); throw error; } } /** * Increment the given column */ async increment(column, value = 1) { try { const query = []; const filters = {}; this.parse().forEach(pipeline => { if (pipeline.$match) { Object.assign(filters, pipeline.$match); } else { query.push(pipeline); } }); Aggregate._events.trigger("updating", this); let incrementData; if (typeof column === "string") { incrementData = { [column]: value }; } else if (Array.isArray(column)) { incrementData = column.reduce((acc, col) => { acc[col] = value; return acc; }, {}); } else { incrementData = column; } const results = await this.query.updateMany(this.collection, filters, [ ...query, { $inc: incrementData, }, ]); return results.modifiedCount; } catch (error) { log.error("database", "aggregate.increment", error); throw error; } } /** * Decrement the given column(s) */ async decrement(column, value = 1) { return this.increment(column, -value); } /** * Multiply the given column(s) */ async multiply(column, value) { try { const query = []; const filters = {}; this.parse().forEach(pipeline => { if (pipeline.$match) { Object.assign(filters, pipeline.$match); } else { query.push(pipeline); } }); Aggregate._events.trigger("updating", this); let multiplyData; if (typeof column === "string") { multiplyData = { [column]: value }; } else if (Array.isArray(column)) { multiplyData = column.reduce((acc, col) => { acc[col] = value; return acc; }, {}); } else { multiplyData = column; } const results = await this.query.updateMany(this.collection, filters, [ ...query, { $mul: multiplyData, }, ]); return results.modifiedCount; } catch (error) { log.error("database", "aggregate.multiply", error); throw error; } } /** * Divide the given column(s) */ async divide(column, value) { if (value === 0) { throw new Error("Division by zero is not allowed."); } try { const query = []; const filters = {}; this.parse().forEach(pipeline => { if (pipeline.$match) { Object.assign(filters, pipeline.$match); } else { query.push(pipeline); } }); Aggregate._events.trigger("updating", this); let divideData; if (typeof column === "string") { divideData = { [column]: 1 / value }; } else if (Array.isArray(column)) { divideData = column.reduce((acc, col) => { acc[col] = 1 / value; return acc; }, {}); } else { divideData = Object.fromEntries(Object.entries(column).map(([key, val]) => [key, 1 / val])); } const results = await this.query.updateMany(this.collection, filters, [ ...query, { $mul: divideData, }, ]); return results.modifiedCount; } catch (error) { log.error("database", "aggregate.divide", error); throw error; } } /** * Unset the given columns */ async unset(...columns) { try { const query = []; const filters = {}; this.parse().forEach(pipeline => { if (pipeline.$match) { Object.assign(filters, pipeline.$match); } else { query.push(pipeline); } }); Aggregate._events.trigger("updating", this); const results = await this.query.updateMany(this.collection, filters, [ ...query, { $unset: columns, }, ]); return results.modifiedCount; } catch (error) { log.error("database", "aggregate.unset", error); console.log(error); throw error; } } /** * Execute the query */ async execute() { const results = (await this.query.aggregate(this.collection, this.parse())).toArray(); return results; } /** * Count the results */ async count() { this.groupBy(null, { total: count(), }); const results = await this.execute(); return get(results, "0.total", 0); } /** * Parse pipelines */ parse() { return parsePipelines(this.pipelines); } /** * Reset the pipeline */ reset() { this.pipelines = []; return this; } /** * Clone the aggregate class */ clone() { const aggregate = new this.constructor(this.collection); aggregate.pipelines = this.pipelines.slice(); return aggregate; } /** * Apply filters to the query */ applyFilters(filters, data = {}, options = {}) { applyFilters({ query: this, filters, data, options, }); return this; } }export{Aggregate};//# sourceMappingURL=aggregate.js.map