@atscript/db-mongo 0.1.147 → 0.1.149

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,12 +1,24 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_accumulator = require("./mongo-accumulator-BJcQs2OV.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
24
  /** Units `$dateToString` labels straight from the instant in the zone (literal `01` for day / month). */
@@ -142,15 +154,41 @@ function buildGroupedStages(query, { accumulators, searchStage, filterOptions })
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,
@@ -180,6 +218,69 @@ function buildAggregatePipeline(query, searchStage, filterOptions) {
180
218
  return pipeline;
181
219
  }
182
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
+ /**
183
284
  * Builds a count-only pipeline: the number of groups that survive `$having`
184
285
  * (all groups when there is none). With `$having` it runs the same grouped
185
286
  * stages as the row pipeline — the accumulators must run so an alias
@@ -201,6 +302,10 @@ function buildCountPipeline(query, searchStage, filterOptions) {
201
302
  return pipeline;
202
303
  }
203
304
  //#endregion
305
+ exports.aggregateOptions = aggregateOptions;
204
306
  exports.bucketExpression = bucketExpression;
205
307
  exports.buildAggregatePipeline = buildAggregatePipeline;
206
308
  exports.buildCountPipeline = buildCountPipeline;
309
+ exports.buildMatchedProbe = buildMatchedProbe;
310
+ exports.emptyGroupRow = emptyGroupRow;
311
+ exports.emptyGroupStages = emptyGroupStages;
package/dist/agg.d.cts CHANGED
@@ -33,6 +33,38 @@ declare function bucketExpression(b: TResolvedBucket): Document;
33
33
  * filter with relational predicates (`mongoFilterStages`).
34
34
  */
35
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;
36
68
  /**
37
69
  * Builds a count-only pipeline: the number of groups that survive `$having`
38
70
  * (all groups when there is none). With `$having` it runs the same grouped
@@ -47,4 +79,4 @@ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document,
47
79
  */
48
80
  declare function buildCountPipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
49
81
  //#endregion
50
- export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
82
+ export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
package/dist/agg.d.mts CHANGED
@@ -33,6 +33,38 @@ declare function bucketExpression(b: TResolvedBucket): Document;
33
33
  * filter with relational predicates (`mongoFilterStages`).
34
34
  */
35
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;
36
68
  /**
37
69
  * Builds a count-only pipeline: the number of groups that survive `$having`
38
70
  * (all groups when there is none). With `$having` it runs the same grouped
@@ -47,4 +79,4 @@ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document,
47
79
  */
48
80
  declare function buildCountPipeline(query: DbQuery, searchStage?: Document, filterOptions?: TMongoFilterOptions): Document[];
49
81
  //#endregion
50
- export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
82
+ export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
package/dist/agg.mjs CHANGED
@@ -1,11 +1,23 @@
1
- import { i as buildMongoFilter, n as distinctCountExpr, s as mongoFilterStages, t as buildAccumulator, u as orNull } from "./mongo-accumulator-Y_xVYo3C.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
23
  /** Units `$dateToString` labels straight from the instant in the zone (literal `01` for day / month). */
@@ -141,15 +153,41 @@ function buildGroupedStages(query, { accumulators, searchStage, filterOptions })
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,
@@ -179,6 +217,69 @@ function buildAggregatePipeline(query, searchStage, filterOptions) {
179
217
  return pipeline;
180
218
  }
181
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
+ /**
182
283
  * Builds a count-only pipeline: the number of groups that survive `$having`
183
284
  * (all groups when there is none). With `$having` it runs the same grouped
184
285
  * stages as the row pipeline — the accumulators must run so an alias
@@ -200,4 +301,4 @@ function buildCountPipeline(query, searchStage, filterOptions) {
200
301
  return pipeline;
201
302
  }
202
303
  //#endregion
203
- export { bucketExpression, buildAggregatePipeline, buildCountPipeline };
304
+ export { aggregateOptions, bucketExpression, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages };
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_accumulator = require("./mongo-accumulator-BJcQs2OV.cjs");
2
+ const require_mongo_accumulator = require("./mongo-accumulator-BKMdRf3s.cjs");
3
3
  let _atscript_db = require("@atscript/db");
4
4
  let mongodb = require("mongodb");
5
5
  let _atscript_db_agg = require("@atscript/db/agg");
@@ -1803,6 +1803,11 @@ function objectIdFromStorage(value) {
1803
1803
  * predicates (see {@link MongoAdapter.supportsRelationFilters}).
1804
1804
  */
1805
1805
  const REL_WRITE_BATCH = 1e3;
1806
+ /** Whether a bulk-write error carries a write-concern failure besides (or instead of) write errors. */
1807
+ function hasWriteConcernError(error) {
1808
+ const e = error;
1809
+ return Boolean(e.writeConcernError || e.result?.getWriteConcernError?.() || e.result?.writeConcernErrors?.length);
1810
+ }
1806
1811
  var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1807
1812
  db;
1808
1813
  client;
@@ -1915,17 +1920,28 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1915
1920
  });
1916
1921
  }
1917
1922
  async aggregate(query) {
1918
- const { buildAggregatePipeline, buildCountPipeline } = await Promise.resolve().then(() => require("./agg.cjs"));
1923
+ const { aggregateOptions, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages } = await Promise.resolve().then(() => require("./agg.cjs"));
1924
+ const emptyGroup = async (forCount) => {
1925
+ const row = emptyGroupRow(query);
1926
+ if (!row) return void 0;
1927
+ const probe = buildMatchedProbe(query, searchStage, this._predicateFilterOpts);
1928
+ if ((await wrapInvalidQuery(() => this.aggregatePipeline(probe).toArray())).length > 0) return;
1929
+ const stages = emptyGroupStages(query, row, forCount);
1930
+ if (!stages) return row;
1931
+ return (await wrapInvalidQuery(() => this.aggregatePipeline(stages).toArray()))[0];
1932
+ };
1919
1933
  const searchStage = buildAggregateSearchStage(this, query.controls);
1920
1934
  if (query.controls?.$count) {
1921
1935
  const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
1922
1936
  this._log("aggregate (count)", pipeline);
1923
- const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1924
- return result.length > 0 ? result : [{ count: 0 }];
1937
+ const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1938
+ return result.length > 0 ? result : [{ count: await emptyGroup(true) ? 1 : 0 }];
1925
1939
  }
1926
1940
  const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
1927
1941
  this._log("aggregate", pipeline);
1928
- return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1942
+ const rows = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1943
+ const empty = rows.length === 0 ? await emptyGroup(false) : void 0;
1944
+ return empty ? [empty] : rows;
1929
1945
  }
1930
1946
  get idType() {
1931
1947
  const idProp = this._table.type.type.props.get("_id");
@@ -1965,10 +1981,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1965
1981
  calendarBucketUnits() {
1966
1982
  return _atscript_db.ALL_BUCKET_UNITS;
1967
1983
  }
1968
- /** Every aggregate function, `countDistinct` included. */
1984
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1969
1985
  aggregateFns() {
1970
1986
  return _atscript_db.ALL_AGGREGATE_FNS;
1971
1987
  }
1988
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1989
+ supportsAggregateExpressions() {
1990
+ return true;
1991
+ }
1972
1992
  /** Computed view columns and first-row joins. */
1973
1993
  viewCapabilities() {
1974
1994
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -2426,13 +2446,17 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2426
2446
  try {
2427
2447
  return await fn();
2428
2448
  } catch (error) {
2429
- if (error instanceof mongodb.MongoServerError && error.code === 11e3) throw new _atscript_db.DbError("CONFLICT", [{
2430
- path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2431
- message: error.message
2432
- }]);
2433
- throw error;
2449
+ return this._mapConstraintError(error);
2434
2450
  }
2435
2451
  }
2452
+ /** Rethrows a duplicate-key (11000) server error as `CONFLICT`, anything else as is. */
2453
+ _mapConstraintError(error) {
2454
+ if (error instanceof mongodb.MongoServerError && error.code === 11e3) throw new _atscript_db.DbError("CONFLICT", [{
2455
+ path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2456
+ message: error.message
2457
+ }]);
2458
+ throw error;
2459
+ }
2436
2460
  /** An update-shaped write under {@link _wrapDuplicateKeyError}, reduced to `TDbUpdateResult`. */
2437
2461
  async _wrapUpdate(fn) {
2438
2462
  const { matchedCount, modifiedCount } = await this._wrapDuplicateKeyError(fn);
@@ -2456,6 +2480,98 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2456
2480
  return { insertedId: objectIdFromStorage(this._resolveInsertedId(data, result.insertedId)) };
2457
2481
  }
2458
2482
  async insertMany(data) {
2483
+ await this._prepareInsertBatch(data);
2484
+ this._log("insertMany", `${data.length} docs`);
2485
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2486
+ return {
2487
+ insertedCount: result.insertedCount,
2488
+ insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2489
+ };
2490
+ }
2491
+ supportsInsertIgnore() {
2492
+ return true;
2493
+ }
2494
+ /**
2495
+ * Conflict-ignoring batch insert (the core already removed in-batch duplicates).
2496
+ *
2497
+ * Outside a transaction: `insertMany(…, { ordered: false })`; duplicate-key
2498
+ * write errors (11000) mark the skipped rows, every other write error
2499
+ * rethrows after the batch (rows already written stay — as for a plain
2500
+ * non-transactional `insertMany`).
2501
+ *
2502
+ * Inside a transaction a duplicate key would abort the transaction even with
2503
+ * `ordered: false`, so the stored keys are looked up first (inside the
2504
+ * session), the matches are skipped and the rest is inserted ordered. A
2505
+ * residual 11000 (e.g. a collation-equal value) throws `CONFLICT`.
2506
+ */
2507
+ async insertManyIgnore(data) {
2508
+ if (data.length === 0) return [];
2509
+ await this._prepareInsertBatch(data);
2510
+ this._log("insertManyIgnore", `${data.length} docs`);
2511
+ const idOf = (item) => objectIdFromStorage(this._resolveInsertedId(item, item._id));
2512
+ if (this._getTransactionState()) {
2513
+ const skipped = await this._findStoredKeyConflicts(data);
2514
+ const survivors = data.filter((_, i) => !skipped.has(i));
2515
+ if (survivors.length > 0) await this._wrapDuplicateKeyError(() => this.collection.insertMany(survivors, {
2516
+ ordered: true,
2517
+ ...this._getSessionOpts()
2518
+ }));
2519
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2520
+ }
2521
+ const skipped = /* @__PURE__ */ new Set();
2522
+ try {
2523
+ await this.collection.insertMany(data, {
2524
+ ordered: false,
2525
+ ...this._getSessionOpts()
2526
+ });
2527
+ } catch (error) {
2528
+ if (!(error instanceof mongodb.MongoBulkWriteError)) this._mapConstraintError(error);
2529
+ const writeErrors = [error.writeErrors].flat().filter(Boolean);
2530
+ if (writeErrors.length === 0 || hasWriteConcernError(error)) throw error;
2531
+ for (const writeError of writeErrors) {
2532
+ if (writeError.code !== 11e3) throw error;
2533
+ skipped.add(writeError.index);
2534
+ }
2535
+ }
2536
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2537
+ }
2538
+ /**
2539
+ * Indices of `data` rows whose primary (`_id`) or unique-index key tuple is
2540
+ * already stored (rows with a null / missing key component never collide).
2541
+ * One `$or` query per chunk covers every key set.
2542
+ */
2543
+ async _findStoredKeyConflicts(data) {
2544
+ const keySets = [["_id"], ...this._table.uniqueKeySets.filter((f) => !(f.length === 1 && f[0] === "_id"))];
2545
+ const rowTuples = data.map((row) => keySets.map((fields) => (0, _atscript_db.uniqueKeyTuple)(row, fields)));
2546
+ const projection = Object.fromEntries(keySets.flat().map((f) => [f, 1]));
2547
+ const stored = keySets.map(() => /* @__PURE__ */ new Set());
2548
+ const CHUNK = 1e3;
2549
+ for (let offset = 0; offset < data.length; offset += CHUNK) {
2550
+ const clauses = [];
2551
+ keySets.forEach((fields, k) => {
2552
+ const rows = data.slice(offset, offset + CHUNK).filter((_, i) => rowTuples[offset + i][k]);
2553
+ if (rows.length === 0) return;
2554
+ if (fields.length === 1) clauses.push({ [fields[0]]: { $in: rows.map((row) => (0, _atscript_db.getPath)(row, fields[0])) } });
2555
+ else for (const row of rows) clauses.push(Object.fromEntries(fields.map((f) => [f, (0, _atscript_db.getPath)(row, f)])));
2556
+ });
2557
+ if (clauses.length === 0) continue;
2558
+ const found = await this.collection.find({ $or: clauses }, {
2559
+ projection,
2560
+ ...this._getSessionOpts()
2561
+ }).toArray();
2562
+ for (const doc of found) keySets.forEach((fields, k) => {
2563
+ const tuple = (0, _atscript_db.uniqueKeyTuple)(doc, fields);
2564
+ if (tuple !== void 0) stored[k].add(tuple);
2565
+ });
2566
+ }
2567
+ const skipped = /* @__PURE__ */ new Set();
2568
+ rowTuples.forEach((tuples, i) => {
2569
+ if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
2570
+ });
2571
+ return skipped;
2572
+ }
2573
+ /** Version default + `@db.default.increment` allocation shared by every batch insert. */
2574
+ async _prepareInsertBatch(data) {
2459
2575
  const versionColumn = this._table.versionColumnPhysical;
2460
2576
  if (versionColumn !== void 0) {
2461
2577
  for (const item of data) if (!(versionColumn in item)) item[versionColumn] = 0;
@@ -2465,12 +2581,6 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2465
2581
  for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
2466
2582
  if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
2467
2583
  }
2468
- this._log("insertMany", `${data.length} docs`);
2469
- const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2470
- return {
2471
- insertedCount: result.insertedCount,
2472
- insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2473
- };
2474
2584
  }
2475
2585
  async findOne(query) {
2476
2586
  if ((0, _atscript_db.containsRelationFilter)(query.filter)) {
@@ -2894,7 +3004,7 @@ function buildMongoUpdateDoc(data, ops, versionColumn) {
2894
3004
  function createAdapter(connection, _options) {
2895
3005
  const client = new mongodb.MongoClient(connection);
2896
3006
  const db = client.db();
2897
- return new _atscript_db.DbSpace(() => new MongoAdapter(db, client));
3007
+ return new _atscript_db.DbSpace(() => new MongoAdapter(db, client), { onClose: () => client.close() });
2898
3008
  }
2899
3009
  //#endregion
2900
3010
  exports.CollectionPatcher = CollectionPatcher;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as buildMongoFilter, c as mongoFilterStages, i as TMongoFilterPlan, l as planStages, n as TMongoFieldCollation, o as buildMongoQuery, r as TMongoFilterOptions, s as collationOfAdapter, t as REL_FILTER_TEMP_PREFIX } from "./mongo-filter-CGd9ryOD.cjs";
2
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
3
  import { AggregateOptions, AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
4
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
5
5
  import { AggregateFn, BucketUnit } from "@uniqu/core";
@@ -316,8 +316,10 @@ declare class MongoAdapter extends BaseDbAdapter {
316
316
  supportsNativePatch(): boolean;
317
317
  /** Every unit, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
318
318
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
319
- /** Every aggregate function, `countDistinct` included. */
319
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
320
320
  aggregateFns(): ReadonlySet<AggregateFn>;
321
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
322
+ supportsAggregateExpressions(): boolean;
321
323
  /** Computed view columns and first-row joins. */
322
324
  viewCapabilities(): ReadonlySet<TViewCapability>;
323
325
  /**
@@ -481,10 +483,35 @@ declare class MongoAdapter extends BaseDbAdapter {
481
483
  * so a unique-index violation is a `CONFLICT` whichever statement hit it.
482
484
  */
483
485
  private _wrapDuplicateKeyError;
486
+ /** Rethrows a duplicate-key (11000) server error as `CONFLICT`, anything else as is. */
487
+ private _mapConstraintError;
484
488
  /** An update-shaped write under {@link _wrapDuplicateKeyError}, reduced to `TDbUpdateResult`. */
485
489
  private _wrapUpdate;
486
490
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
487
491
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
492
+ supportsInsertIgnore(): boolean;
493
+ /**
494
+ * Conflict-ignoring batch insert (the core already removed in-batch duplicates).
495
+ *
496
+ * Outside a transaction: `insertMany(…, { ordered: false })`; duplicate-key
497
+ * write errors (11000) mark the skipped rows, every other write error
498
+ * rethrows after the batch (rows already written stay — as for a plain
499
+ * non-transactional `insertMany`).
500
+ *
501
+ * Inside a transaction a duplicate key would abort the transaction even with
502
+ * `ordered: false`, so the stored keys are looked up first (inside the
503
+ * session), the matches are skipped and the rest is inserted ordered. A
504
+ * residual 11000 (e.g. a collation-equal value) throws `CONFLICT`.
505
+ */
506
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
507
+ /**
508
+ * Indices of `data` rows whose primary (`_id`) or unique-index key tuple is
509
+ * already stored (rows with a null / missing key component never collide).
510
+ * One `$or` query per chunk covers every key set.
511
+ */
512
+ private _findStoredKeyConflicts;
513
+ /** Version default + `@db.default.increment` allocation shared by every batch insert. */
514
+ private _prepareInsertBatch;
488
515
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
489
516
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
490
517
  count(query: DbQuery): Promise<number>;
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as buildMongoFilter, c as mongoFilterStages, i as TMongoFilterPlan, l as planStages, n as TMongoFieldCollation, o as buildMongoQuery, r as TMongoFilterOptions, s as collationOfAdapter, t as REL_FILTER_TEMP_PREFIX } from "./mongo-filter-CGd9ryOD.mjs";
2
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
2
+ import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbCollation, TDbDeleteResult, TDbFieldMeta, TDbForeignKey, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbRelation, TDbUpdateResult, TExistingTableOption, TFieldOps, TMetadataOverrides, TPrimaryKeyChange, TSearchIndexInfo, TSyncColumnResult, TTableResolver, TViewCapability, TableMetadata, WithRelation, getKeyProps } from "@atscript/db";
3
3
  import { AggregateOptions, AggregationCursor, ClientSession, Collection, Db, Document, Filter, MongoClient, ObjectId, UpdateFilter, UpdateOptions } from "mongodb";
4
4
  import { AggregateFn, BucketUnit } from "@uniqu/core";
5
5
  import { TAtscriptAnnotatedType, TMetadataMap, TValidatorOptions, TValidatorPlugin, Validator } from "@atscript/typescript/utils";
@@ -316,8 +316,10 @@ declare class MongoAdapter extends BaseDbAdapter {
316
316
  supportsNativePatch(): boolean;
317
317
  /** Every unit, over MongoDB's bundled time zone database (see `agg.ts` `bucketExpression`). */
318
318
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
319
- /** Every aggregate function, `countDistinct` included. */
319
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
320
320
  aggregateFns(): ReadonlySet<AggregateFn>;
321
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
322
+ supportsAggregateExpressions(): boolean;
321
323
  /** Computed view columns and first-row joins. */
322
324
  viewCapabilities(): ReadonlySet<TViewCapability>;
323
325
  /**
@@ -481,10 +483,35 @@ declare class MongoAdapter extends BaseDbAdapter {
481
483
  * so a unique-index violation is a `CONFLICT` whichever statement hit it.
482
484
  */
483
485
  private _wrapDuplicateKeyError;
486
+ /** Rethrows a duplicate-key (11000) server error as `CONFLICT`, anything else as is. */
487
+ private _mapConstraintError;
484
488
  /** An update-shaped write under {@link _wrapDuplicateKeyError}, reduced to `TDbUpdateResult`. */
485
489
  private _wrapUpdate;
486
490
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
487
491
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
492
+ supportsInsertIgnore(): boolean;
493
+ /**
494
+ * Conflict-ignoring batch insert (the core already removed in-batch duplicates).
495
+ *
496
+ * Outside a transaction: `insertMany(…, { ordered: false })`; duplicate-key
497
+ * write errors (11000) mark the skipped rows, every other write error
498
+ * rethrows after the batch (rows already written stay — as for a plain
499
+ * non-transactional `insertMany`).
500
+ *
501
+ * Inside a transaction a duplicate key would abort the transaction even with
502
+ * `ordered: false`, so the stored keys are looked up first (inside the
503
+ * session), the matches are skipped and the rest is inserted ordered. A
504
+ * residual 11000 (e.g. a collation-equal value) throws `CONFLICT`.
505
+ */
506
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
507
+ /**
508
+ * Indices of `data` rows whose primary (`_id`) or unique-index key tuple is
509
+ * already stored (rows with a null / missing key component never collide).
510
+ * One `$or` query per chunk covers every key set.
511
+ */
512
+ private _findStoredKeyConflicts;
513
+ /** Version default + `@db.default.increment` allocation shared by every batch insert. */
514
+ private _prepareInsertBatch;
488
515
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
489
516
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
490
517
  count(query: DbQuery): Promise<number>;
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { a as buildMongoQuery, c as planStages, d as queryNodeToExpr, f as correlate, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator, u as orNull } from "./mongo-accumulator-Y_xVYo3C.mjs";
2
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, isAtscriptDbView, isFieldRef, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, vectorIndexNotFoundMessage } from "@atscript/db";
3
- import { MongoClient, MongoServerError, ObjectId } from "mongodb";
1
+ import { a as buildMongoQuery, c as planStages, d as orNull, f as queryNodeToExpr, i as buildMongoFilter, l as exprToMongo, n as distinctCountExpr, o as collationOfAdapter, p as correlate, r as REL_FILTER_TEMP_PREFIX, s as mongoFilterStages, t as buildAccumulator } from "./mongo-accumulator-BvJNCVoc.mjs";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, andFilters, bucketTimeZoneUnavailable, computeInsights, containsRelationFilter, createFailureCollector, geoIndexNotFoundMessage, getDbFieldOp, getKeyProps, getPath, isAtscriptDbView, isFieldRef, relationStaticFilter, resolveDesignType, searchIndexNotFoundMessage, tableNameOf, translateQueryTree, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
3
+ import { MongoBulkWriteError, MongoClient, MongoServerError, ObjectId } from "mongodb";
4
4
  import { resolveAggregateSearch } from "@atscript/db/agg";
5
5
  //#region src/lib/path-utils.ts
6
6
  function joinPath(prefix, segment) {
@@ -1802,6 +1802,11 @@ function objectIdFromStorage(value) {
1802
1802
  * predicates (see {@link MongoAdapter.supportsRelationFilters}).
1803
1803
  */
1804
1804
  const REL_WRITE_BATCH = 1e3;
1805
+ /** Whether a bulk-write error carries a write-concern failure besides (or instead of) write errors. */
1806
+ function hasWriteConcernError(error) {
1807
+ const e = error;
1808
+ return Boolean(e.writeConcernError || e.result?.getWriteConcernError?.() || e.result?.writeConcernErrors?.length);
1809
+ }
1805
1810
  var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1806
1811
  db;
1807
1812
  client;
@@ -1914,17 +1919,28 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1914
1919
  });
1915
1920
  }
1916
1921
  async aggregate(query) {
1917
- const { buildAggregatePipeline, buildCountPipeline } = await import("./agg.mjs");
1922
+ const { aggregateOptions, buildAggregatePipeline, buildCountPipeline, buildMatchedProbe, emptyGroupRow, emptyGroupStages } = await import("./agg.mjs");
1923
+ const emptyGroup = async (forCount) => {
1924
+ const row = emptyGroupRow(query);
1925
+ if (!row) return void 0;
1926
+ const probe = buildMatchedProbe(query, searchStage, this._predicateFilterOpts);
1927
+ if ((await wrapInvalidQuery(() => this.aggregatePipeline(probe).toArray())).length > 0) return;
1928
+ const stages = emptyGroupStages(query, row, forCount);
1929
+ if (!stages) return row;
1930
+ return (await wrapInvalidQuery(() => this.aggregatePipeline(stages).toArray()))[0];
1931
+ };
1918
1932
  const searchStage = buildAggregateSearchStage(this, query.controls);
1919
1933
  if (query.controls?.$count) {
1920
1934
  const pipeline = buildCountPipeline(query, searchStage, this._predicateFilterOpts);
1921
1935
  this._log("aggregate (count)", pipeline);
1922
- const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1923
- return result.length > 0 ? result : [{ count: 0 }];
1936
+ const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1937
+ return result.length > 0 ? result : [{ count: await emptyGroup(true) ? 1 : 0 }];
1924
1938
  }
1925
1939
  const pipeline = buildAggregatePipeline(query, searchStage, this._predicateFilterOpts);
1926
1940
  this._log("aggregate", pipeline);
1927
- return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1941
+ const rows = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline, aggregateOptions(pipeline)).toArray());
1942
+ const empty = rows.length === 0 ? await emptyGroup(false) : void 0;
1943
+ return empty ? [empty] : rows;
1928
1944
  }
1929
1945
  get idType() {
1930
1946
  const idProp = this._table.type.type.props.get("_id");
@@ -1964,10 +1980,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1964
1980
  calendarBucketUnits() {
1965
1981
  return ALL_BUCKET_UNITS;
1966
1982
  }
1967
- /** Every aggregate function, `countDistinct` included. */
1983
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
1968
1984
  aggregateFns() {
1969
1985
  return ALL_AGGREGATE_FNS;
1970
1986
  }
1987
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
1988
+ supportsAggregateExpressions() {
1989
+ return true;
1990
+ }
1971
1991
  /** Computed view columns and first-row joins. */
1972
1992
  viewCapabilities() {
1973
1993
  return ALL_VIEW_CAPABILITIES;
@@ -2425,13 +2445,17 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2425
2445
  try {
2426
2446
  return await fn();
2427
2447
  } catch (error) {
2428
- if (error instanceof MongoServerError && error.code === 11e3) throw new DbError("CONFLICT", [{
2429
- path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2430
- message: error.message
2431
- }]);
2432
- throw error;
2448
+ return this._mapConstraintError(error);
2433
2449
  }
2434
2450
  }
2451
+ /** Rethrows a duplicate-key (11000) server error as `CONFLICT`, anything else as is. */
2452
+ _mapConstraintError(error) {
2453
+ if (error instanceof MongoServerError && error.code === 11e3) throw new DbError("CONFLICT", [{
2454
+ path: error.keyPattern ? Object.keys(error.keyPattern)[0] ?? "" : "",
2455
+ message: error.message
2456
+ }]);
2457
+ throw error;
2458
+ }
2435
2459
  /** An update-shaped write under {@link _wrapDuplicateKeyError}, reduced to `TDbUpdateResult`. */
2436
2460
  async _wrapUpdate(fn) {
2437
2461
  const { matchedCount, modifiedCount } = await this._wrapDuplicateKeyError(fn);
@@ -2455,6 +2479,98 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2455
2479
  return { insertedId: objectIdFromStorage(this._resolveInsertedId(data, result.insertedId)) };
2456
2480
  }
2457
2481
  async insertMany(data) {
2482
+ await this._prepareInsertBatch(data);
2483
+ this._log("insertMany", `${data.length} docs`);
2484
+ const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2485
+ return {
2486
+ insertedCount: result.insertedCount,
2487
+ insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2488
+ };
2489
+ }
2490
+ supportsInsertIgnore() {
2491
+ return true;
2492
+ }
2493
+ /**
2494
+ * Conflict-ignoring batch insert (the core already removed in-batch duplicates).
2495
+ *
2496
+ * Outside a transaction: `insertMany(…, { ordered: false })`; duplicate-key
2497
+ * write errors (11000) mark the skipped rows, every other write error
2498
+ * rethrows after the batch (rows already written stay — as for a plain
2499
+ * non-transactional `insertMany`).
2500
+ *
2501
+ * Inside a transaction a duplicate key would abort the transaction even with
2502
+ * `ordered: false`, so the stored keys are looked up first (inside the
2503
+ * session), the matches are skipped and the rest is inserted ordered. A
2504
+ * residual 11000 (e.g. a collation-equal value) throws `CONFLICT`.
2505
+ */
2506
+ async insertManyIgnore(data) {
2507
+ if (data.length === 0) return [];
2508
+ await this._prepareInsertBatch(data);
2509
+ this._log("insertManyIgnore", `${data.length} docs`);
2510
+ const idOf = (item) => objectIdFromStorage(this._resolveInsertedId(item, item._id));
2511
+ if (this._getTransactionState()) {
2512
+ const skipped = await this._findStoredKeyConflicts(data);
2513
+ const survivors = data.filter((_, i) => !skipped.has(i));
2514
+ if (survivors.length > 0) await this._wrapDuplicateKeyError(() => this.collection.insertMany(survivors, {
2515
+ ordered: true,
2516
+ ...this._getSessionOpts()
2517
+ }));
2518
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2519
+ }
2520
+ const skipped = /* @__PURE__ */ new Set();
2521
+ try {
2522
+ await this.collection.insertMany(data, {
2523
+ ordered: false,
2524
+ ...this._getSessionOpts()
2525
+ });
2526
+ } catch (error) {
2527
+ if (!(error instanceof MongoBulkWriteError)) this._mapConstraintError(error);
2528
+ const writeErrors = [error.writeErrors].flat().filter(Boolean);
2529
+ if (writeErrors.length === 0 || hasWriteConcernError(error)) throw error;
2530
+ for (const writeError of writeErrors) {
2531
+ if (writeError.code !== 11e3) throw error;
2532
+ skipped.add(writeError.index);
2533
+ }
2534
+ }
2535
+ return data.map((item, i) => skipped.has(i) ? null : { insertedId: idOf(item) });
2536
+ }
2537
+ /**
2538
+ * Indices of `data` rows whose primary (`_id`) or unique-index key tuple is
2539
+ * already stored (rows with a null / missing key component never collide).
2540
+ * One `$or` query per chunk covers every key set.
2541
+ */
2542
+ async _findStoredKeyConflicts(data) {
2543
+ const keySets = [["_id"], ...this._table.uniqueKeySets.filter((f) => !(f.length === 1 && f[0] === "_id"))];
2544
+ const rowTuples = data.map((row) => keySets.map((fields) => uniqueKeyTuple(row, fields)));
2545
+ const projection = Object.fromEntries(keySets.flat().map((f) => [f, 1]));
2546
+ const stored = keySets.map(() => /* @__PURE__ */ new Set());
2547
+ const CHUNK = 1e3;
2548
+ for (let offset = 0; offset < data.length; offset += CHUNK) {
2549
+ const clauses = [];
2550
+ keySets.forEach((fields, k) => {
2551
+ const rows = data.slice(offset, offset + CHUNK).filter((_, i) => rowTuples[offset + i][k]);
2552
+ if (rows.length === 0) return;
2553
+ if (fields.length === 1) clauses.push({ [fields[0]]: { $in: rows.map((row) => getPath(row, fields[0])) } });
2554
+ else for (const row of rows) clauses.push(Object.fromEntries(fields.map((f) => [f, getPath(row, f)])));
2555
+ });
2556
+ if (clauses.length === 0) continue;
2557
+ const found = await this.collection.find({ $or: clauses }, {
2558
+ projection,
2559
+ ...this._getSessionOpts()
2560
+ }).toArray();
2561
+ for (const doc of found) keySets.forEach((fields, k) => {
2562
+ const tuple = uniqueKeyTuple(doc, fields);
2563
+ if (tuple !== void 0) stored[k].add(tuple);
2564
+ });
2565
+ }
2566
+ const skipped = /* @__PURE__ */ new Set();
2567
+ rowTuples.forEach((tuples, i) => {
2568
+ if (tuples.some((t, k) => t !== void 0 && stored[k].has(t))) skipped.add(i);
2569
+ });
2570
+ return skipped;
2571
+ }
2572
+ /** Version default + `@db.default.increment` allocation shared by every batch insert. */
2573
+ async _prepareInsertBatch(data) {
2458
2574
  const versionColumn = this._table.versionColumnPhysical;
2459
2575
  if (versionColumn !== void 0) {
2460
2576
  for (const item of data) if (!(versionColumn in item)) item[versionColumn] = 0;
@@ -2464,12 +2580,6 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2464
2580
  for (const item of data) for (const f of this._fieldsNeedingIncrement(item)) allFields.add(f);
2465
2581
  if (allFields.size > 0) await this._assignBatchIncrements(data, allFields);
2466
2582
  }
2467
- this._log("insertMany", `${data.length} docs`);
2468
- const result = await this._wrapDuplicateKeyError(() => this.collection.insertMany(data, this._getSessionOpts()));
2469
- return {
2470
- insertedCount: result.insertedCount,
2471
- insertedIds: data.map((item, i) => objectIdFromStorage(this._resolveInsertedId(item, result.insertedIds[i])))
2472
- };
2473
2583
  }
2474
2584
  async findOne(query) {
2475
2585
  if (containsRelationFilter(query.filter)) {
@@ -2893,7 +3003,7 @@ function buildMongoUpdateDoc(data, ops, versionColumn) {
2893
3003
  function createAdapter(connection, _options) {
2894
3004
  const client = new MongoClient(connection);
2895
3005
  const db = client.db();
2896
- return new DbSpace(() => new MongoAdapter(db, client));
3006
+ return new DbSpace(() => new MongoAdapter(db, client), { onClose: () => client.close() });
2897
3007
  }
2898
3008
  //#endregion
2899
3009
  export { CollectionPatcher, INDEX_PREFIX, MongoAdapter, REL_FILTER_TEMP_PREFIX, buildMongoFilter, buildMongoQuery, collationOfAdapter, createAdapter, mongoFilterStages, mongoIndexKey, planStages, validateMongoIdPlugin };
@@ -585,6 +585,12 @@ Object.defineProperty(exports, "mongoFilterStages", {
585
585
  return mongoFilterStages;
586
586
  }
587
587
  });
588
+ Object.defineProperty(exports, "notNullExpr", {
589
+ enumerable: true,
590
+ get: function() {
591
+ return notNullExpr;
592
+ }
593
+ });
588
594
  Object.defineProperty(exports, "orNull", {
589
595
  enumerable: true,
590
596
  get: function() {
@@ -531,4 +531,4 @@ function distinctCountExpr(set) {
531
531
  return { $size: set };
532
532
  }
533
533
  //#endregion
534
- export { buildMongoQuery as a, planStages as c, queryNodeToExpr as d, correlate as f, buildMongoFilter as i, exprToMongo as l, distinctCountExpr as n, collationOfAdapter as o, REL_FILTER_TEMP_PREFIX as r, mongoFilterStages as s, buildAccumulator as t, orNull as u };
534
+ export { buildMongoQuery as a, planStages as c, orNull as d, queryNodeToExpr as f, buildMongoFilter as i, exprToMongo as l, distinctCountExpr as n, collationOfAdapter as o, correlate as p, REL_FILTER_TEMP_PREFIX as r, mongoFilterStages as s, buildAccumulator as t, notNullExpr as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.147",
3
+ "version": "0.1.149",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -46,19 +46,19 @@
46
46
  "access": "public"
47
47
  },
48
48
  "devDependencies": {
49
- "@atscript/core": "^0.1.99",
50
- "@atscript/typescript": "^0.1.99",
51
- "@uniqu/core": "^0.1.12",
49
+ "@atscript/core": "^0.1.100",
50
+ "@atscript/typescript": "^0.1.100",
51
+ "@uniqu/core": "^0.1.13",
52
52
  "mongodb": "^6.17.0",
53
53
  "mongodb-memory-server-core": "^10.0.0",
54
- "unplugin-atscript": "^0.1.99"
54
+ "unplugin-atscript": "^0.1.100"
55
55
  },
56
56
  "peerDependencies": {
57
- "@atscript/core": "^0.1.99",
58
- "@atscript/typescript": "^0.1.99",
59
- "@uniqu/core": "^0.1.12",
57
+ "@atscript/core": "^0.1.100",
58
+ "@atscript/typescript": "^0.1.100",
59
+ "@uniqu/core": "^0.1.13",
60
60
  "mongodb": "^6.17.0",
61
- "@atscript/db": "^0.1.147"
61
+ "@atscript/db": "^0.1.149"
62
62
  },
63
63
  "scripts": {
64
64
  "postinstall": "asc -f dts",