@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 CHANGED
@@ -1,19 +1,29 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_accumulator = require("./mongo-accumulator-BalE9LkU.cjs");
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
- * Units whose first day `$dateToString` renders straight from the instant in
14
- * the zone: the local day, then the literal `01` for day-of-month / month.
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`, or `null`.
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 = LOCAL_DATE_FORMATS[b.unit];
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 pipeline = searchStage ? [searchStage, { $match: require_mongo_accumulator.buildMongoFilter(query.filter) }] : [{ $match: require_mongo_accumulator.buildMongoFilter(query.filter) }];
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`, or `null`.
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`, or `null`.
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 { i as orNull, n as distinctCountExpr, r as buildMongoFilter, t as buildAccumulator } from "./mongo-accumulator-Dc0w4t0l.mjs";
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
- * Units whose first day `$dateToString` renders straight from the instant in
13
- * the zone: the local day, then the literal `01` for day-of-month / month.
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`, or `null`.
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 = LOCAL_DATE_FORMATS[b.unit];
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 pipeline = searchStage ? [searchStage, { $match: buildMongoFilter(query.filter) }] : [{ $match: buildMongoFilter(query.filter) }];
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 };