@warlock.js/cascade
Version:
ORM for managing databases
912 lines (911 loc) • 26.6 kB
JavaScript
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