@atscript/db-mongo 0.1.146 → 0.1.148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agg.cjs +126 -18
- package/dist/agg.d.cts +40 -5
- package/dist/agg.d.mts +40 -5
- package/dist/agg.mjs +123 -19
- package/dist/index.cjs +599 -194
- package/dist/index.d.cts +118 -23
- package/dist/index.d.mts +118 -23
- package/dist/index.mjs +597 -197
- package/dist/mongo-accumulator-BKMdRf3s.cjs +611 -0
- package/dist/mongo-accumulator-BvJNCVoc.mjs +534 -0
- package/dist/mongo-filter-CGd9ryOD.d.cts +123 -0
- package/dist/mongo-filter-CGd9ryOD.d.mts +123 -0
- package/package.json +9 -9
- package/dist/mongo-accumulator-BalE9LkU.cjs +0 -265
- package/dist/mongo-accumulator-Dc0w4t0l.mjs +0 -236
package/dist/agg.cjs
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_mongo_accumulator = require("./mongo-accumulator-
|
|
2
|
+
const require_mongo_accumulator = require("./mongo-accumulator-BKMdRf3s.cjs");
|
|
3
|
+
let _atscript_db = require("@atscript/db");
|
|
3
4
|
let _atscript_db_agg = require("@atscript/db/agg");
|
|
4
5
|
let _uniqu_core = require("@uniqu/core");
|
|
5
6
|
//#region src/agg.ts
|
|
7
|
+
/**
|
|
8
|
+
* MongoDB aggregation pipeline builder.
|
|
9
|
+
* Dynamically imported by MongoAdapter.aggregate() on first call.
|
|
10
|
+
*
|
|
11
|
+
* Constructs MongoDB aggregation pipelines from translated DbQuery objects
|
|
12
|
+
* containing $groupBy, $select (with AggregateExpr), $having, $sort, etc.
|
|
13
|
+
*/
|
|
6
14
|
/** Maps an AggregateExpr to its MongoDB `$group` accumulator (see `buildAccumulator`). */
|
|
7
15
|
function toAccumulator(expr) {
|
|
8
16
|
return require_mongo_accumulator.buildAccumulator(expr.$fn, expr.$field === "*" ? "*" : `$${expr.$field}`);
|
|
9
17
|
}
|
|
18
|
+
/** An operand of query-time arithmetic: the field as a double (IEEE, like the SQL adapters). */
|
|
19
|
+
const asDouble = (path) => ({ $toDouble: `$${path}` });
|
|
20
|
+
/** Prefix of the hidden `$group` field counting a sum's non-null values. */
|
|
21
|
+
const NON_NULL_PREFIX = "__as_n_";
|
|
10
22
|
const DAY_MS = 864e5;
|
|
11
23
|
const ISO_DATE = "%Y-%m-%d";
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
const LOCAL_DATE_FORMATS = {
|
|
24
|
+
/** Units `$dateToString` labels straight from the instant in the zone (literal `01` for day / month). */
|
|
25
|
+
const LOCAL_LABEL_FORMATS = {
|
|
26
|
+
hour: "%Y-%m-%dT%H:00",
|
|
17
27
|
day: ISO_DATE,
|
|
18
28
|
month: "%Y-%m-01",
|
|
19
29
|
year: "%Y-01-01"
|
|
@@ -41,7 +51,8 @@ function naiveFirstDay(b) {
|
|
|
41
51
|
}
|
|
42
52
|
/**
|
|
43
53
|
* The `$group._id` expression of a calendar bucket: the ISO local date
|
|
44
|
-
* `YYYY-MM-DD` of the bucket's first day in `b.tz`,
|
|
54
|
+
* `YYYY-MM-DD` of the bucket's first day in `b.tz` (for `hour`, the local
|
|
55
|
+
* `YYYY-MM-DDTHH:00`), or `null`.
|
|
45
56
|
*
|
|
46
57
|
* The only zone-aware step is instant → local date (`timezone` on
|
|
47
58
|
* `$dateToString` / `$dateToParts`, never ambiguous). `$dateTrunc` is
|
|
@@ -58,7 +69,7 @@ function naiveFirstDay(b) {
|
|
|
58
69
|
function bucketExpression(b) {
|
|
59
70
|
const source = `$${b.field}`;
|
|
60
71
|
const date = { $toDate: { $toLong: source } };
|
|
61
|
-
const format =
|
|
72
|
+
const format = LOCAL_LABEL_FORMATS[b.unit];
|
|
62
73
|
const label = format ? { $dateToString: {
|
|
63
74
|
date,
|
|
64
75
|
format,
|
|
@@ -101,10 +112,11 @@ function bucketExpression(b) {
|
|
|
101
112
|
* position in front of the filter `$match`. Resolving it needs adapter state,
|
|
102
113
|
* so the caller passes it in and this module stays a pure translation.
|
|
103
114
|
*/
|
|
104
|
-
function buildPrefix(query, searchStage) {
|
|
115
|
+
function buildPrefix(query, searchStage, filterOptions) {
|
|
105
116
|
const controls = query.controls || {};
|
|
106
117
|
const groupBy = controls.$groupBy ?? [];
|
|
107
|
-
const
|
|
118
|
+
const filterStages = require_mongo_accumulator.mongoFilterStages(query.filter, filterOptions);
|
|
119
|
+
const pipeline = searchStage ? [searchStage, ...filterStages] : filterStages;
|
|
108
120
|
const groupId = {};
|
|
109
121
|
const groupKeys = [];
|
|
110
122
|
for (const [index, key] of groupBy.entries()) {
|
|
@@ -132,8 +144,8 @@ function buildPrefix(query, searchStage) {
|
|
|
132
144
|
* (no accumulators, no `$project`, no `$having`) — the cheapest shape for a
|
|
133
145
|
* plain group count, where no alias has to be resolvable.
|
|
134
146
|
*/
|
|
135
|
-
function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
136
|
-
const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage);
|
|
147
|
+
function buildGroupedStages(query, { accumulators, searchStage, filterOptions }) {
|
|
148
|
+
const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage, filterOptions);
|
|
137
149
|
const groupStage = { _id: groupId };
|
|
138
150
|
if (!accumulators) {
|
|
139
151
|
pipeline.push({ $group: groupStage });
|
|
@@ -142,15 +154,41 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
|
142
154
|
controls
|
|
143
155
|
};
|
|
144
156
|
}
|
|
157
|
+
const firstLast = controls.$select?.firstLast ?? [];
|
|
158
|
+
const rowOrder = controls.$select?.rowOrder ?? [];
|
|
159
|
+
if (firstLast.length > 0 && rowOrder.length > 0) pipeline.push({ $sort: Object.fromEntries(rowOrder.map((k) => [k.column, k.desc ? -1 : 1])) });
|
|
145
160
|
const project = { _id: 0 };
|
|
146
161
|
for (const [field, idKey] of groupKeys) project[field] = `$_id.${idKey}`;
|
|
162
|
+
const nullWhenEmpty = (alias, nonNull) => {
|
|
163
|
+
const count = `${NON_NULL_PREFIX}${alias}`;
|
|
164
|
+
groupStage[count] = { $sum: { $cond: [
|
|
165
|
+
nonNull,
|
|
166
|
+
1,
|
|
167
|
+
0
|
|
168
|
+
] } };
|
|
169
|
+
return { $cond: [
|
|
170
|
+
{ $eq: [`$${count}`, 0] },
|
|
171
|
+
null,
|
|
172
|
+
`$${alias}`
|
|
173
|
+
] };
|
|
174
|
+
};
|
|
147
175
|
for (const expr of controls.$select?.aggregates ?? []) {
|
|
148
176
|
const alias = (0, _atscript_db_agg.resolveAlias)(expr);
|
|
149
177
|
groupStage[alias] = toAccumulator(expr);
|
|
150
|
-
project[alias] = expr.$fn === "countDistinct" ? require_mongo_accumulator.distinctCountExpr(`$${alias}`) : 1;
|
|
178
|
+
project[alias] = expr.$fn === "countDistinct" ? require_mongo_accumulator.distinctCountExpr(`$${alias}`) : expr.$fn === "sum" ? nullWhenEmpty(alias, require_mongo_accumulator.notNullExpr(`$${expr.$field}`)) : 1;
|
|
179
|
+
}
|
|
180
|
+
for (const e of controls.$select?.exprAggregates ?? []) {
|
|
181
|
+
const value = require_mongo_accumulator.exprToMongo(e.expr, asDouble);
|
|
182
|
+
groupStage[e.alias] = { [`$${e.fn}`]: value };
|
|
183
|
+
project[e.alias] = e.fn === "sum" ? nullWhenEmpty(e.alias, require_mongo_accumulator.notNullExpr(value)) : 1;
|
|
184
|
+
}
|
|
185
|
+
for (const fl of firstLast) {
|
|
186
|
+
groupStage[fl.alias] = { [`$${fl.fn}`]: require_mongo_accumulator.orNull(`$${fl.column}`) };
|
|
187
|
+
project[fl.alias] = 1;
|
|
151
188
|
}
|
|
152
189
|
pipeline.push({ $group: groupStage });
|
|
153
190
|
pipeline.push({ $project: project });
|
|
191
|
+
for (const e of controls.$select?.exprs ?? []) pipeline.push({ $addFields: { [e.alias]: require_mongo_accumulator.exprToMongo(e.expr, asDouble) } });
|
|
154
192
|
if (controls.$having) pipeline.push({ $match: require_mongo_accumulator.buildMongoFilter(controls.$having) });
|
|
155
193
|
return {
|
|
156
194
|
pipeline,
|
|
@@ -165,12 +203,14 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
|
165
203
|
*
|
|
166
204
|
* `searchStage` is the resolved `$search` / `$text` stage (see
|
|
167
205
|
* `buildAggregateSearchStage`); unlike the leaf runner this path adds no
|
|
168
|
-
* relevance `$sort` and no default `$limit`.
|
|
206
|
+
* relevance `$sort` and no default `$limit`. `filterOptions` renders a
|
|
207
|
+
* filter with relational predicates (`mongoFilterStages`).
|
|
169
208
|
*/
|
|
170
|
-
function buildAggregatePipeline(query, searchStage) {
|
|
209
|
+
function buildAggregatePipeline(query, searchStage, filterOptions) {
|
|
171
210
|
const { pipeline, controls } = buildGroupedStages(query, {
|
|
172
211
|
accumulators: true,
|
|
173
|
-
searchStage
|
|
212
|
+
searchStage,
|
|
213
|
+
filterOptions
|
|
174
214
|
});
|
|
175
215
|
if (controls.$sort) pipeline.push({ $sort: controls.$sort });
|
|
176
216
|
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
@@ -178,6 +218,69 @@ function buildAggregatePipeline(query, searchStage) {
|
|
|
178
218
|
return pipeline;
|
|
179
219
|
}
|
|
180
220
|
/**
|
|
221
|
+
* The row an UNGROUPED aggregate yields over no input rows: a pipeline's
|
|
222
|
+
* `$group` emits nothing there, while SQL (and the memory adapter) always
|
|
223
|
+
* yield the one group — counts 0, every other aggregate, `first` / `last` and
|
|
224
|
+
* the expressions over them `null`. `undefined` when the query is grouped or
|
|
225
|
+
* computes nothing. The row is BEFORE `$having` / `$skip` — see
|
|
226
|
+
* {@link emptyGroupStages}.
|
|
227
|
+
*/
|
|
228
|
+
function emptyGroupRow(query) {
|
|
229
|
+
const controls = query.controls;
|
|
230
|
+
const select = controls?.$select;
|
|
231
|
+
if (!controls || controls.$groupBy?.length) return void 0;
|
|
232
|
+
if (!select?.computedAliases.length) return void 0;
|
|
233
|
+
const row = {};
|
|
234
|
+
for (const expr of select.aggregates ?? []) row[(0, _atscript_db_agg.resolveAlias)(expr)] = expr.$fn === "count" || expr.$fn === "countDistinct" ? 0 : null;
|
|
235
|
+
for (const e of select.exprAggregates ?? []) row[e.alias] = null;
|
|
236
|
+
for (const fl of select.firstLast ?? []) row[fl.alias] = null;
|
|
237
|
+
for (const e of select.exprs ?? []) row[e.alias] = (0, _atscript_db.evaluateExpr)(e.expr, (name) => row[name]);
|
|
238
|
+
return row;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A pipeline yielding at most one document iff ANY input row matches the
|
|
242
|
+
* query's filter (and search) — the probe that tells "no rows matched" (the
|
|
243
|
+
* one empty group exists) from "the real group was removed by `$having`".
|
|
244
|
+
*/
|
|
245
|
+
function buildMatchedProbe(query, searchStage, filterOptions) {
|
|
246
|
+
const { pipeline } = buildPrefix(query, searchStage, filterOptions);
|
|
247
|
+
pipeline.push({ $limit: 1 }, { $project: { _id: 1 } });
|
|
248
|
+
return pipeline;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The pipeline that applies the query's `$having` (and, for the row query, `$skip`
|
|
252
|
+
* and `$limit`) to the {@link emptyGroupRow} — over a single synthetic document
|
|
253
|
+
* (a `$facet` emits exactly one document, even from an empty input), so the
|
|
254
|
+
* server evaluates `$having` exactly as it does for a real group. `undefined`
|
|
255
|
+
* when there is nothing to apply (the row stands). Its result is empty when the
|
|
256
|
+
* row is filtered out.
|
|
257
|
+
*/
|
|
258
|
+
function emptyGroupStages(query, row, forCount) {
|
|
259
|
+
const controls = query.controls;
|
|
260
|
+
const having = controls?.$having;
|
|
261
|
+
const skip = forCount ? 0 : controls?.$skip ?? 0;
|
|
262
|
+
const limit = forCount ? 0 : controls?.$limit ?? 0;
|
|
263
|
+
if (!having && !skip && !limit) return void 0;
|
|
264
|
+
const stages = [
|
|
265
|
+
{ $match: { _id: { $in: [] } } },
|
|
266
|
+
{ $facet: { one: [] } },
|
|
267
|
+
{ $replaceRoot: { newRoot: { $literal: row } } }
|
|
268
|
+
];
|
|
269
|
+
if (having) stages.push({ $match: require_mongo_accumulator.buildMongoFilter(having) });
|
|
270
|
+
if (skip) stages.push({ $skip: skip });
|
|
271
|
+
if (limit) stages.push({ $limit: limit });
|
|
272
|
+
return stages;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* `allowDiskUse` for a pipeline that sorts before it groups (`first` / `last`
|
|
276
|
+
* order every matching row by `$rowOrder` first, which may exceed the
|
|
277
|
+
* in-memory sort limit); `undefined` otherwise.
|
|
278
|
+
*/
|
|
279
|
+
function aggregateOptions(pipeline) {
|
|
280
|
+
const group = pipeline.findIndex((stage) => "$group" in stage);
|
|
281
|
+
return pipeline.slice(0, group < 0 ? 0 : group).some((stage) => "$sort" in stage) ? { allowDiskUse: true } : void 0;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
181
284
|
* Builds a count-only pipeline: the number of groups that survive `$having`
|
|
182
285
|
* (all groups when there is none). With `$having` it runs the same grouped
|
|
183
286
|
* stages as the row pipeline — the accumulators must run so an alias
|
|
@@ -189,15 +292,20 @@ function buildAggregatePipeline(query, searchStage) {
|
|
|
189
292
|
* `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
|
|
190
293
|
* the same query — the counted groups are exactly the rows the search matched.
|
|
191
294
|
*/
|
|
192
|
-
function buildCountPipeline(query, searchStage) {
|
|
295
|
+
function buildCountPipeline(query, searchStage, filterOptions) {
|
|
193
296
|
const { pipeline } = buildGroupedStages(query, {
|
|
194
297
|
accumulators: Boolean(query.controls?.$having),
|
|
195
|
-
searchStage
|
|
298
|
+
searchStage,
|
|
299
|
+
filterOptions
|
|
196
300
|
});
|
|
197
301
|
pipeline.push({ $count: "count" });
|
|
198
302
|
return pipeline;
|
|
199
303
|
}
|
|
200
304
|
//#endregion
|
|
305
|
+
exports.aggregateOptions = aggregateOptions;
|
|
201
306
|
exports.bucketExpression = bucketExpression;
|
|
202
307
|
exports.buildAggregatePipeline = buildAggregatePipeline;
|
|
203
308
|
exports.buildCountPipeline = buildCountPipeline;
|
|
309
|
+
exports.buildMatchedProbe = buildMatchedProbe;
|
|
310
|
+
exports.emptyGroupRow = emptyGroupRow;
|
|
311
|
+
exports.emptyGroupStages = emptyGroupStages;
|
package/dist/agg.d.cts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { r as TMongoFilterOptions } from "./mongo-filter-CGd9ryOD.cjs";
|
|
1
2
|
import { DbQuery, TResolvedBucket } from "@atscript/db";
|
|
2
3
|
import { Document } from "mongodb";
|
|
3
4
|
|
|
4
5
|
//#region src/agg.d.ts
|
|
5
6
|
/**
|
|
6
7
|
* The `$group._id` expression of a calendar bucket: the ISO local date
|
|
7
|
-
* `YYYY-MM-DD` of the bucket's first day in `b.tz`,
|
|
8
|
+
* `YYYY-MM-DD` of the bucket's first day in `b.tz` (for `hour`, the local
|
|
9
|
+
* `YYYY-MM-DDTHH:00`), or `null`.
|
|
8
10
|
*
|
|
9
11
|
* The only zone-aware step is instant → local date (`timezone` on
|
|
10
12
|
* `$dateToString` / `$dateToParts`, never ambiguous). `$dateTrunc` is
|
|
@@ -27,9 +29,42 @@ declare function bucketExpression(b: TResolvedBucket): Document;
|
|
|
27
29
|
*
|
|
28
30
|
* `searchStage` is the resolved `$search` / `$text` stage (see
|
|
29
31
|
* `buildAggregateSearchStage`); unlike the leaf runner this path adds no
|
|
30
|
-
* relevance `$sort` and no default `$limit`.
|
|
32
|
+
* relevance `$sort` and no default `$limit`. `filterOptions` renders a
|
|
33
|
+
* filter with relational predicates (`mongoFilterStages`).
|
|
31
34
|
*/
|
|
32
|
-
declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document): Document[];
|
|
35
|
+
declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
36
|
+
/**
|
|
37
|
+
* The row an UNGROUPED aggregate yields over no input rows: a pipeline's
|
|
38
|
+
* `$group` emits nothing there, while SQL (and the memory adapter) always
|
|
39
|
+
* yield the one group — counts 0, every other aggregate, `first` / `last` and
|
|
40
|
+
* the expressions over them `null`. `undefined` when the query is grouped or
|
|
41
|
+
* computes nothing. The row is BEFORE `$having` / `$skip` — see
|
|
42
|
+
* {@link emptyGroupStages}.
|
|
43
|
+
*/
|
|
44
|
+
declare function emptyGroupRow(query: DbQuery): Document | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* A pipeline yielding at most one document iff ANY input row matches the
|
|
47
|
+
* query's filter (and search) — the probe that tells "no rows matched" (the
|
|
48
|
+
* one empty group exists) from "the real group was removed by `$having`".
|
|
49
|
+
*/
|
|
50
|
+
declare function buildMatchedProbe(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
51
|
+
/**
|
|
52
|
+
* The pipeline that applies the query's `$having` (and, for the row query, `$skip`
|
|
53
|
+
* and `$limit`) to the {@link emptyGroupRow} — over a single synthetic document
|
|
54
|
+
* (a `$facet` emits exactly one document, even from an empty input), so the
|
|
55
|
+
* server evaluates `$having` exactly as it does for a real group. `undefined`
|
|
56
|
+
* when there is nothing to apply (the row stands). Its result is empty when the
|
|
57
|
+
* row is filtered out.
|
|
58
|
+
*/
|
|
59
|
+
declare function emptyGroupStages(query: DbQuery, row: Document, forCount: boolean): Document[] | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* `allowDiskUse` for a pipeline that sorts before it groups (`first` / `last`
|
|
62
|
+
* order every matching row by `$rowOrder` first, which may exceed the
|
|
63
|
+
* in-memory sort limit); `undefined` otherwise.
|
|
64
|
+
*/
|
|
65
|
+
declare function aggregateOptions(pipeline: Document[]): {
|
|
66
|
+
allowDiskUse: true;
|
|
67
|
+
} | undefined;
|
|
33
68
|
/**
|
|
34
69
|
* Builds a count-only pipeline: the number of groups that survive `$having`
|
|
35
70
|
* (all groups when there is none). With `$having` it runs the same grouped
|
|
@@ -42,6 +77,6 @@ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document):
|
|
|
42
77
|
* `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
|
|
43
78
|
* the same query — the counted groups are exactly the rows the search matched.
|
|
44
79
|
*/
|
|
45
|
-
declare function buildCountPipeline(query: DbQuery, searchStage?: Document): Document[];
|
|
80
|
+
declare function buildCountPipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
46
81
|
//#endregion
|
|
47
|
-
export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
|
|
82
|
+
export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
|
package/dist/agg.d.mts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { r as TMongoFilterOptions } from "./mongo-filter-CGd9ryOD.mjs";
|
|
1
2
|
import { DbQuery, TResolvedBucket } from "@atscript/db";
|
|
2
3
|
import { Document } from "mongodb";
|
|
3
4
|
|
|
4
5
|
//#region src/agg.d.ts
|
|
5
6
|
/**
|
|
6
7
|
* The `$group._id` expression of a calendar bucket: the ISO local date
|
|
7
|
-
* `YYYY-MM-DD` of the bucket's first day in `b.tz`,
|
|
8
|
+
* `YYYY-MM-DD` of the bucket's first day in `b.tz` (for `hour`, the local
|
|
9
|
+
* `YYYY-MM-DDTHH:00`), or `null`.
|
|
8
10
|
*
|
|
9
11
|
* The only zone-aware step is instant → local date (`timezone` on
|
|
10
12
|
* `$dateToString` / `$dateToParts`, never ambiguous). `$dateTrunc` is
|
|
@@ -27,9 +29,42 @@ declare function bucketExpression(b: TResolvedBucket): Document;
|
|
|
27
29
|
*
|
|
28
30
|
* `searchStage` is the resolved `$search` / `$text` stage (see
|
|
29
31
|
* `buildAggregateSearchStage`); unlike the leaf runner this path adds no
|
|
30
|
-
* relevance `$sort` and no default `$limit`.
|
|
32
|
+
* relevance `$sort` and no default `$limit`. `filterOptions` renders a
|
|
33
|
+
* filter with relational predicates (`mongoFilterStages`).
|
|
31
34
|
*/
|
|
32
|
-
declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document): Document[];
|
|
35
|
+
declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
36
|
+
/**
|
|
37
|
+
* The row an UNGROUPED aggregate yields over no input rows: a pipeline's
|
|
38
|
+
* `$group` emits nothing there, while SQL (and the memory adapter) always
|
|
39
|
+
* yield the one group — counts 0, every other aggregate, `first` / `last` and
|
|
40
|
+
* the expressions over them `null`. `undefined` when the query is grouped or
|
|
41
|
+
* computes nothing. The row is BEFORE `$having` / `$skip` — see
|
|
42
|
+
* {@link emptyGroupStages}.
|
|
43
|
+
*/
|
|
44
|
+
declare function emptyGroupRow(query: DbQuery): Document | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* A pipeline yielding at most one document iff ANY input row matches the
|
|
47
|
+
* query's filter (and search) — the probe that tells "no rows matched" (the
|
|
48
|
+
* one empty group exists) from "the real group was removed by `$having`".
|
|
49
|
+
*/
|
|
50
|
+
declare function buildMatchedProbe(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
51
|
+
/**
|
|
52
|
+
* The pipeline that applies the query's `$having` (and, for the row query, `$skip`
|
|
53
|
+
* and `$limit`) to the {@link emptyGroupRow} — over a single synthetic document
|
|
54
|
+
* (a `$facet` emits exactly one document, even from an empty input), so the
|
|
55
|
+
* server evaluates `$having` exactly as it does for a real group. `undefined`
|
|
56
|
+
* when there is nothing to apply (the row stands). Its result is empty when the
|
|
57
|
+
* row is filtered out.
|
|
58
|
+
*/
|
|
59
|
+
declare function emptyGroupStages(query: DbQuery, row: Document, forCount: boolean): Document[] | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* `allowDiskUse` for a pipeline that sorts before it groups (`first` / `last`
|
|
62
|
+
* order every matching row by `$rowOrder` first, which may exceed the
|
|
63
|
+
* in-memory sort limit); `undefined` otherwise.
|
|
64
|
+
*/
|
|
65
|
+
declare function aggregateOptions(pipeline: Document[]): {
|
|
66
|
+
allowDiskUse: true;
|
|
67
|
+
} | undefined;
|
|
33
68
|
/**
|
|
34
69
|
* Builds a count-only pipeline: the number of groups that survive `$having`
|
|
35
70
|
* (all groups when there is none). With `$having` it runs the same grouped
|
|
@@ -42,6 +77,6 @@ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document):
|
|
|
42
77
|
* `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
|
|
43
78
|
* the same query — the counted groups are exactly the rows the search matched.
|
|
44
79
|
*/
|
|
45
|
-
declare function buildCountPipeline(query: DbQuery, searchStage?: Document): Document[];
|
|
80
|
+
declare function buildCountPipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
|
|
46
81
|
//#endregion
|
|
47
|
-
export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
|
|
82
|
+
export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
|
package/dist/agg.mjs
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as orNull, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, s as mongoFilterStages, t as buildAccumulator, u as notNullExpr } from "./mongo-accumulator-BvJNCVoc.mjs";
|
|
2
|
+
import { evaluateExpr } from "@atscript/db";
|
|
2
3
|
import { resolveAlias } from "@atscript/db/agg";
|
|
3
4
|
import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
|
|
4
5
|
//#region src/agg.ts
|
|
6
|
+
/**
|
|
7
|
+
* MongoDB aggregation pipeline builder.
|
|
8
|
+
* Dynamically imported by MongoAdapter.aggregate() on first call.
|
|
9
|
+
*
|
|
10
|
+
* Constructs MongoDB aggregation pipelines from translated DbQuery objects
|
|
11
|
+
* containing $groupBy, $select (with AggregateExpr), $having, $sort, etc.
|
|
12
|
+
*/
|
|
5
13
|
/** Maps an AggregateExpr to its MongoDB `$group` accumulator (see `buildAccumulator`). */
|
|
6
14
|
function toAccumulator(expr) {
|
|
7
15
|
return buildAccumulator(expr.$fn, expr.$field === "*" ? "*" : `$${expr.$field}`);
|
|
8
16
|
}
|
|
17
|
+
/** An operand of query-time arithmetic: the field as a double (IEEE, like the SQL adapters). */
|
|
18
|
+
const asDouble = (path) => ({ $toDouble: `$${path}` });
|
|
19
|
+
/** Prefix of the hidden `$group` field counting a sum's non-null values. */
|
|
20
|
+
const NON_NULL_PREFIX = "__as_n_";
|
|
9
21
|
const DAY_MS = 864e5;
|
|
10
22
|
const ISO_DATE = "%Y-%m-%d";
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
const LOCAL_DATE_FORMATS = {
|
|
23
|
+
/** Units `$dateToString` labels straight from the instant in the zone (literal `01` for day / month). */
|
|
24
|
+
const LOCAL_LABEL_FORMATS = {
|
|
25
|
+
hour: "%Y-%m-%dT%H:00",
|
|
16
26
|
day: ISO_DATE,
|
|
17
27
|
month: "%Y-%m-01",
|
|
18
28
|
year: "%Y-01-01"
|
|
@@ -40,7 +50,8 @@ function naiveFirstDay(b) {
|
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* The `$group._id` expression of a calendar bucket: the ISO local date
|
|
43
|
-
* `YYYY-MM-DD` of the bucket's first day in `b.tz`,
|
|
53
|
+
* `YYYY-MM-DD` of the bucket's first day in `b.tz` (for `hour`, the local
|
|
54
|
+
* `YYYY-MM-DDTHH:00`), or `null`.
|
|
44
55
|
*
|
|
45
56
|
* The only zone-aware step is instant → local date (`timezone` on
|
|
46
57
|
* `$dateToString` / `$dateToParts`, never ambiguous). `$dateTrunc` is
|
|
@@ -57,7 +68,7 @@ function naiveFirstDay(b) {
|
|
|
57
68
|
function bucketExpression(b) {
|
|
58
69
|
const source = `$${b.field}`;
|
|
59
70
|
const date = { $toDate: { $toLong: source } };
|
|
60
|
-
const format =
|
|
71
|
+
const format = LOCAL_LABEL_FORMATS[b.unit];
|
|
61
72
|
const label = format ? { $dateToString: {
|
|
62
73
|
date,
|
|
63
74
|
format,
|
|
@@ -100,10 +111,11 @@ function bucketExpression(b) {
|
|
|
100
111
|
* position in front of the filter `$match`. Resolving it needs adapter state,
|
|
101
112
|
* so the caller passes it in and this module stays a pure translation.
|
|
102
113
|
*/
|
|
103
|
-
function buildPrefix(query, searchStage) {
|
|
114
|
+
function buildPrefix(query, searchStage, filterOptions) {
|
|
104
115
|
const controls = query.controls || {};
|
|
105
116
|
const groupBy = controls.$groupBy ?? [];
|
|
106
|
-
const
|
|
117
|
+
const filterStages = mongoFilterStages(query.filter, filterOptions);
|
|
118
|
+
const pipeline = searchStage ? [searchStage, ...filterStages] : filterStages;
|
|
107
119
|
const groupId = {};
|
|
108
120
|
const groupKeys = [];
|
|
109
121
|
for (const [index, key] of groupBy.entries()) {
|
|
@@ -131,8 +143,8 @@ function buildPrefix(query, searchStage) {
|
|
|
131
143
|
* (no accumulators, no `$project`, no `$having`) — the cheapest shape for a
|
|
132
144
|
* plain group count, where no alias has to be resolvable.
|
|
133
145
|
*/
|
|
134
|
-
function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
135
|
-
const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage);
|
|
146
|
+
function buildGroupedStages(query, { accumulators, searchStage, filterOptions }) {
|
|
147
|
+
const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage, filterOptions);
|
|
136
148
|
const groupStage = { _id: groupId };
|
|
137
149
|
if (!accumulators) {
|
|
138
150
|
pipeline.push({ $group: groupStage });
|
|
@@ -141,15 +153,41 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
|
141
153
|
controls
|
|
142
154
|
};
|
|
143
155
|
}
|
|
156
|
+
const firstLast = controls.$select?.firstLast ?? [];
|
|
157
|
+
const rowOrder = controls.$select?.rowOrder ?? [];
|
|
158
|
+
if (firstLast.length > 0 && rowOrder.length > 0) pipeline.push({ $sort: Object.fromEntries(rowOrder.map((k) => [k.column, k.desc ? -1 : 1])) });
|
|
144
159
|
const project = { _id: 0 };
|
|
145
160
|
for (const [field, idKey] of groupKeys) project[field] = `$_id.${idKey}`;
|
|
161
|
+
const nullWhenEmpty = (alias, nonNull) => {
|
|
162
|
+
const count = `${NON_NULL_PREFIX}${alias}`;
|
|
163
|
+
groupStage[count] = { $sum: { $cond: [
|
|
164
|
+
nonNull,
|
|
165
|
+
1,
|
|
166
|
+
0
|
|
167
|
+
] } };
|
|
168
|
+
return { $cond: [
|
|
169
|
+
{ $eq: [`$${count}`, 0] },
|
|
170
|
+
null,
|
|
171
|
+
`$${alias}`
|
|
172
|
+
] };
|
|
173
|
+
};
|
|
146
174
|
for (const expr of controls.$select?.aggregates ?? []) {
|
|
147
175
|
const alias = resolveAlias(expr);
|
|
148
176
|
groupStage[alias] = toAccumulator(expr);
|
|
149
|
-
project[alias] = expr.$fn === "countDistinct" ? distinctCountExpr(`$${alias}`) : 1;
|
|
177
|
+
project[alias] = expr.$fn === "countDistinct" ? distinctCountExpr(`$${alias}`) : expr.$fn === "sum" ? nullWhenEmpty(alias, notNullExpr(`$${expr.$field}`)) : 1;
|
|
178
|
+
}
|
|
179
|
+
for (const e of controls.$select?.exprAggregates ?? []) {
|
|
180
|
+
const value = exprToMongo(e.expr, asDouble);
|
|
181
|
+
groupStage[e.alias] = { [`$${e.fn}`]: value };
|
|
182
|
+
project[e.alias] = e.fn === "sum" ? nullWhenEmpty(e.alias, notNullExpr(value)) : 1;
|
|
183
|
+
}
|
|
184
|
+
for (const fl of firstLast) {
|
|
185
|
+
groupStage[fl.alias] = { [`$${fl.fn}`]: orNull(`$${fl.column}`) };
|
|
186
|
+
project[fl.alias] = 1;
|
|
150
187
|
}
|
|
151
188
|
pipeline.push({ $group: groupStage });
|
|
152
189
|
pipeline.push({ $project: project });
|
|
190
|
+
for (const e of controls.$select?.exprs ?? []) pipeline.push({ $addFields: { [e.alias]: exprToMongo(e.expr, asDouble) } });
|
|
153
191
|
if (controls.$having) pipeline.push({ $match: buildMongoFilter(controls.$having) });
|
|
154
192
|
return {
|
|
155
193
|
pipeline,
|
|
@@ -164,12 +202,14 @@ function buildGroupedStages(query, { accumulators, searchStage }) {
|
|
|
164
202
|
*
|
|
165
203
|
* `searchStage` is the resolved `$search` / `$text` stage (see
|
|
166
204
|
* `buildAggregateSearchStage`); unlike the leaf runner this path adds no
|
|
167
|
-
* relevance `$sort` and no default `$limit`.
|
|
205
|
+
* relevance `$sort` and no default `$limit`. `filterOptions` renders a
|
|
206
|
+
* filter with relational predicates (`mongoFilterStages`).
|
|
168
207
|
*/
|
|
169
|
-
function buildAggregatePipeline(query, searchStage) {
|
|
208
|
+
function buildAggregatePipeline(query, searchStage, filterOptions) {
|
|
170
209
|
const { pipeline, controls } = buildGroupedStages(query, {
|
|
171
210
|
accumulators: true,
|
|
172
|
-
searchStage
|
|
211
|
+
searchStage,
|
|
212
|
+
filterOptions
|
|
173
213
|
});
|
|
174
214
|
if (controls.$sort) pipeline.push({ $sort: controls.$sort });
|
|
175
215
|
if (controls.$skip) pipeline.push({ $skip: controls.$skip });
|
|
@@ -177,6 +217,69 @@ function buildAggregatePipeline(query, searchStage) {
|
|
|
177
217
|
return pipeline;
|
|
178
218
|
}
|
|
179
219
|
/**
|
|
220
|
+
* The row an UNGROUPED aggregate yields over no input rows: a pipeline's
|
|
221
|
+
* `$group` emits nothing there, while SQL (and the memory adapter) always
|
|
222
|
+
* yield the one group — counts 0, every other aggregate, `first` / `last` and
|
|
223
|
+
* the expressions over them `null`. `undefined` when the query is grouped or
|
|
224
|
+
* computes nothing. The row is BEFORE `$having` / `$skip` — see
|
|
225
|
+
* {@link emptyGroupStages}.
|
|
226
|
+
*/
|
|
227
|
+
function emptyGroupRow(query) {
|
|
228
|
+
const controls = query.controls;
|
|
229
|
+
const select = controls?.$select;
|
|
230
|
+
if (!controls || controls.$groupBy?.length) return void 0;
|
|
231
|
+
if (!select?.computedAliases.length) return void 0;
|
|
232
|
+
const row = {};
|
|
233
|
+
for (const expr of select.aggregates ?? []) row[resolveAlias(expr)] = expr.$fn === "count" || expr.$fn === "countDistinct" ? 0 : null;
|
|
234
|
+
for (const e of select.exprAggregates ?? []) row[e.alias] = null;
|
|
235
|
+
for (const fl of select.firstLast ?? []) row[fl.alias] = null;
|
|
236
|
+
for (const e of select.exprs ?? []) row[e.alias] = evaluateExpr(e.expr, (name) => row[name]);
|
|
237
|
+
return row;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A pipeline yielding at most one document iff ANY input row matches the
|
|
241
|
+
* query's filter (and search) — the probe that tells "no rows matched" (the
|
|
242
|
+
* one empty group exists) from "the real group was removed by `$having`".
|
|
243
|
+
*/
|
|
244
|
+
function buildMatchedProbe(query, searchStage, filterOptions) {
|
|
245
|
+
const { pipeline } = buildPrefix(query, searchStage, filterOptions);
|
|
246
|
+
pipeline.push({ $limit: 1 }, { $project: { _id: 1 } });
|
|
247
|
+
return pipeline;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The pipeline that applies the query's `$having` (and, for the row query, `$skip`
|
|
251
|
+
* and `$limit`) to the {@link emptyGroupRow} — over a single synthetic document
|
|
252
|
+
* (a `$facet` emits exactly one document, even from an empty input), so the
|
|
253
|
+
* server evaluates `$having` exactly as it does for a real group. `undefined`
|
|
254
|
+
* when there is nothing to apply (the row stands). Its result is empty when the
|
|
255
|
+
* row is filtered out.
|
|
256
|
+
*/
|
|
257
|
+
function emptyGroupStages(query, row, forCount) {
|
|
258
|
+
const controls = query.controls;
|
|
259
|
+
const having = controls?.$having;
|
|
260
|
+
const skip = forCount ? 0 : controls?.$skip ?? 0;
|
|
261
|
+
const limit = forCount ? 0 : controls?.$limit ?? 0;
|
|
262
|
+
if (!having && !skip && !limit) return void 0;
|
|
263
|
+
const stages = [
|
|
264
|
+
{ $match: { _id: { $in: [] } } },
|
|
265
|
+
{ $facet: { one: [] } },
|
|
266
|
+
{ $replaceRoot: { newRoot: { $literal: row } } }
|
|
267
|
+
];
|
|
268
|
+
if (having) stages.push({ $match: buildMongoFilter(having) });
|
|
269
|
+
if (skip) stages.push({ $skip: skip });
|
|
270
|
+
if (limit) stages.push({ $limit: limit });
|
|
271
|
+
return stages;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* `allowDiskUse` for a pipeline that sorts before it groups (`first` / `last`
|
|
275
|
+
* order every matching row by `$rowOrder` first, which may exceed the
|
|
276
|
+
* in-memory sort limit); `undefined` otherwise.
|
|
277
|
+
*/
|
|
278
|
+
function aggregateOptions(pipeline) {
|
|
279
|
+
const group = pipeline.findIndex((stage) => "$group" in stage);
|
|
280
|
+
return pipeline.slice(0, group < 0 ? 0 : group).some((stage) => "$sort" in stage) ? { allowDiskUse: true } : void 0;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
180
283
|
* Builds a count-only pipeline: the number of groups that survive `$having`
|
|
181
284
|
* (all groups when there is none). With `$having` it runs the same grouped
|
|
182
285
|
* stages as the row pipeline — the accumulators must run so an alias
|
|
@@ -188,13 +291,14 @@ function buildAggregatePipeline(query, searchStage) {
|
|
|
188
291
|
* `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
|
|
189
292
|
* the same query — the counted groups are exactly the rows the search matched.
|
|
190
293
|
*/
|
|
191
|
-
function buildCountPipeline(query, searchStage) {
|
|
294
|
+
function buildCountPipeline(query, searchStage, filterOptions) {
|
|
192
295
|
const { pipeline } = buildGroupedStages(query, {
|
|
193
296
|
accumulators: Boolean(query.controls?.$having),
|
|
194
|
-
searchStage
|
|
297
|
+
searchStage,
|
|
298
|
+
filterOptions
|
|
195
299
|
});
|
|
196
300
|
pipeline.push({ $count: "count" });
|
|
197
301
|
return pipeline;
|
|
198
302
|
}
|
|
199
303
|
//#endregion
|
|
200
|
-
export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
|
|
304
|
+
export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
|