uql-orm 0.68.1 → 0.70.0

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.
Files changed (56) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +7 -7
  2. package/dist/browser/type/clientQuerier.d.ts +5 -5
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +6 -6
  5. package/dist/cockroachdb/cockroachDialect.js +2 -2
  6. package/dist/dialect/abstractDialect.d.ts +8 -2
  7. package/dist/dialect/abstractDialect.js +17 -1
  8. package/dist/dialect/abstractSqlDialect.d.ts +42 -4
  9. package/dist/dialect/abstractSqlDialect.js +164 -48
  10. package/dist/dialect/aliases.d.ts +15 -4
  11. package/dist/dialect/aliases.js +15 -4
  12. package/dist/dialect/mysqlLikeSqlDialect.js +3 -2
  13. package/dist/dialect/pgLikeSqlDialect.js +1 -0
  14. package/dist/dialect/queryJoins.d.ts +8 -1
  15. package/dist/dialect/queryJoins.js +33 -10
  16. package/dist/entity/decorator/members.d.ts +44 -2
  17. package/dist/entity/decorator/members.js +0 -5
  18. package/dist/entity/metadata/definition.d.ts +8 -3
  19. package/dist/entity/metadata/definition.js +10 -3
  20. package/dist/migrate/codegen/entityCodeGenerator.js +4 -2
  21. package/dist/migrate/introspection/postgresIntrospector.d.ts +6 -0
  22. package/dist/migrate/introspection/postgresIntrospector.js +7 -1
  23. package/dist/migrate/migrator.d.ts +2 -1
  24. package/dist/migrate/migrator.js +5 -3
  25. package/dist/mongo/mongoDialect.d.ts +49 -19
  26. package/dist/mongo/mongoDialect.js +238 -81
  27. package/dist/mongo/mongodbQuerier.d.ts +2 -4
  28. package/dist/mongo/mongodbQuerier.js +16 -14
  29. package/dist/mssql/mssqlDialect.js +3 -2
  30. package/dist/postgres/postgresDialect.js +2 -2
  31. package/dist/querier/abstractQuerier.d.ts +16 -11
  32. package/dist/querier/abstractQuerier.js +28 -8
  33. package/dist/querier/abstractQuerierPool.d.ts +9 -9
  34. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  35. package/dist/querier/abstractSqlQuerier.js +3 -3
  36. package/dist/sqlite/sqliteDialect.js +1 -0
  37. package/dist/turso/tursoDialect.d.ts +1 -1
  38. package/dist/turso/tursoDialect.js +6 -2
  39. package/dist/type/dialect.d.ts +35 -2
  40. package/dist/type/entity.d.ts +120 -4
  41. package/dist/type/migration.d.ts +7 -0
  42. package/dist/type/query.d.ts +7 -10
  43. package/dist/type/queryAggregate.d.ts +77 -42
  44. package/dist/type/queryAggregate.js +4 -21
  45. package/dist/type/queryRaw.d.ts +19 -1
  46. package/dist/type/queryRaw.js +18 -0
  47. package/dist/type/universalQuerier.d.ts +9 -9
  48. package/dist/util/dialect.util.d.ts +6 -2
  49. package/dist/util/dialect.util.js +21 -7
  50. package/dist/util/field.util.d.ts +15 -1
  51. package/dist/util/field.util.js +18 -1
  52. package/dist/util/object.util.d.ts +1 -4
  53. package/dist/util/object.util.js +0 -3
  54. package/dist/util/raw.d.ts +2 -2
  55. package/dist/util/raw.js +29 -2
  56. package/package.json +1 -1
@@ -1,11 +1,11 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
4
- import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
3
+ import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortCountField, } from '../dialect/aliases.js';
4
+ import { groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
6
6
  import { COUNT_RESULT_KEY } from '../type/query.js';
7
7
  import { QueryRaw } from '../type/queryRaw.js';
8
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
8
+ import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
9
9
  /** Default {@link DialectFeatures} for MongoDB. */
10
10
  export const mongoDialectFeatures = {
11
11
  ifNotExists: false,
@@ -22,6 +22,7 @@ export const mongoDialectFeatures = {
22
22
  stringSizing: 'bounded-text',
23
23
  supportsUnsigned: false,
24
24
  serverSideCursors: false,
25
+ correlatedWrites: false,
25
26
  };
26
27
  /** What `toWireId` converts: the hex spelling of an `ObjectId`, and nothing looser. */
27
28
  const HEX_24 = /^[0-9a-f]{24}$/i;
@@ -61,30 +62,27 @@ export class MongoDialect extends AbstractDialect {
61
62
  return this.renderFilter(entity, this.scopedWhere(meta, where, opts));
62
63
  }
63
64
  /**
64
- * A `$where` that may constrain relations, as the `$lookup` stages it needs and the `$match` reading
65
- * them: each condition a lookup into a temporary field (`unset` names them), so `$or` keeps its meaning.
65
+ * The stages every pipeline starts with: the `$lookup` each relation condition needs, into a temporary
66
+ * field so `$or` keeps its meaning, the `$match` reading them, and the temporaries taken back out. Each
67
+ * relation aggregate the `$where` or `named` reads is left on the document, once, under its column.
66
68
  */
67
- whereWithRelations(entity, where = {}, opts = {}) {
69
+ matchStages(entity, where = {}, opts = {}, named = []) {
68
70
  const meta = getMeta(entity);
69
71
  const lookups = { stages: [], temps: [] };
70
- const filter = this.renderFilter(entity, this.scopedWhere(meta, where, opts), lookups);
71
- return { stages: lookups.stages, filter, unset: lookups.temps };
72
- }
73
- /** Whether a `$where` constrains any relation, and so needs the aggregation path rather than a cursor. */
74
- constrainsRelations(entity, where) {
75
- if (!where) {
76
- return false;
72
+ for (const key of named) {
73
+ this.appendAggregateField(meta, key, lookups);
77
74
  }
78
- const meta = getMeta(entity);
79
- const whereMap = where;
80
- return someKey(whereMap, (key) => MongoDialect.isGroupOp(key)
81
- ? (whereMap[key] ?? []).some((it) => !(it instanceof QueryRaw) && this.constrainsRelations(entity, it))
82
- : Boolean(meta.relations[key]));
75
+ const filter = this.renderFilter(entity, this.scopedWhere(meta, where, opts), lookups);
76
+ return [
77
+ ...lookups.stages,
78
+ ...(hasKeys(filter) ? [{ $match: filter }] : []),
79
+ ...(lookups.temps.length ? [{ $unset: lookups.temps }] : []),
80
+ ];
83
81
  }
84
82
  /**
85
83
  * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
86
- * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
87
- * given - a plain `find`/`updateMany` filter has nowhere to put them.
84
+ * recursion). A relation, or a relation aggregate, needs `$lookup` stages, so it is only accepted
85
+ * when `lookups` is given - a plain `find`/`updateMany` filter has nowhere to put them.
88
86
  */
89
87
  renderFilter(entity, where = {}, lookups) {
90
88
  const meta = getMeta(entity);
@@ -110,6 +108,12 @@ export class MongoDialect extends AbstractDialect {
110
108
  else {
111
109
  this.assertNoRaw(val);
112
110
  this.assertKnownPathRoot(meta, key);
111
+ if (aggregateOf(meta.fields[key])) {
112
+ if (!lookups) {
113
+ throw new TypeError(`filtering by relation aggregate '${key}' is not supported here on MongoDB`);
114
+ }
115
+ this.appendAggregateField(meta, key, lookups);
116
+ }
113
117
  const isReference = !!meta.fields[key]?.references;
114
118
  key = this.pathOf(meta, key);
115
119
  if ((key === MongoDialect.ID_KEY || isReference) && !isOperatorObject(val)) {
@@ -165,7 +169,7 @@ export class MongoDialect extends AbstractDialect {
165
169
  appendRelationLookup(filter, meta, relKey, val, lookups) {
166
170
  const temp = `${REL_TEMP_PREFIX}${lookups.temps.length}`;
167
171
  const sizeVal = parseRelationSize(val);
168
- const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_ALIAS }];
172
+ const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: AGGREGATE_VALUE_ALIAS }];
169
173
  const where = (sizeVal === undefined ? val : {});
170
174
  lookups.temps.push(temp);
171
175
  lookups.stages.push(this.relationLookup(meta, meta.relations[relKey], where, temp, tail));
@@ -423,6 +427,15 @@ export class MongoDialect extends AbstractDialect {
423
427
  const selectMap = asSelectMap(select);
424
428
  // Projected by column, not by field key; `normalizeId` maps them back on the way out.
425
429
  const projection = normalizeScalarFieldSelection(meta, selectMap, exclude).reduce((acc, key) => {
430
+ // A computed field writing SQL leaves the document nothing to project: refused asked for by
431
+ // name, skipped swept in with the rest. A relation aggregate is on it by now, like any column.
432
+ const field = meta.fields[key];
433
+ if (field?.computed && !aggregateOf(field)) {
434
+ if (selectMap && key in selectMap) {
435
+ assertReadable(meta, key);
436
+ }
437
+ return acc;
438
+ }
426
439
  acc[this.columnOf(meta, key)] = 1;
427
440
  return acc;
428
441
  }, {});
@@ -498,18 +511,70 @@ export class MongoDialect extends AbstractDialect {
498
511
  continue;
499
512
  }
500
513
  const temp = sortCountField(key);
501
- stages.push(this.tallyLookup(meta, relOpts, {}, temp), { $addFields: { [temp]: this.tally(temp) } });
514
+ stages.push(...this.aggregateStages(meta, { relation: key, op: '$count' }, `${REL_TEMP_PREFIX}${temp}`, temp));
502
515
  fields.push(temp);
503
516
  }
504
517
  return { stages, fields };
505
518
  }
506
- /** The correlated lookup counting a relation's rows, which `where` narrows, into `temp`. */
507
- tallyLookup(meta, relOpts, where, temp) {
508
- return this.relationLookup(meta, relOpts, where, temp, [{ $count: COUNT_ALIAS }]);
519
+ /** Whether a read answers with a relation aggregate, which only the pipeline can build. */
520
+ readsAggregates(entity, q) {
521
+ return this.aggregateKeys(entity, q).length > 0;
522
+ }
523
+ /** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
524
+ aggregateKeys(entity, q) {
525
+ const meta = getMeta(entity);
526
+ const projected = normalizeScalarFieldSelection(meta, asSelectMap(q.$select), q.$exclude);
527
+ return [...projected, ...Object.keys(q.$sort ?? {})].filter((key) => aggregateOf(meta.fields[key]));
528
+ }
529
+ /**
530
+ * The relation aggregate `key` computes, put on the document under its column by the stages
531
+ * {@link aggregateStages} builds from the same spec SQL renders as a subquery. Once however many
532
+ * clauses read it; a key computing none adds nothing.
533
+ */
534
+ appendAggregateField(meta, key, lookups) {
535
+ const spec = aggregateOf(meta.fields[key]);
536
+ const temp = `${REL_TEMP_PREFIX}${key}`;
537
+ if (!spec || lookups.temps.includes(temp)) {
538
+ return;
539
+ }
540
+ lookups.temps.push(temp);
541
+ lookups.stages.push(...this.aggregateStages(meta, spec, temp, this.columnOf(meta, key)));
542
+ }
543
+ /**
544
+ * One relation aggregate on the document under `field`: the correlated lookup that reads the related
545
+ * rows - narrowed, ordered and capped as the spec says - ending in the tally or total it wants, and
546
+ * the `$addFields` reading that back, `0` or `null` where the lookup matched nothing.
547
+ *
548
+ * Every aggregate MongoDB answers is built here: a `$count` a query asks for, an ordering by one, and
549
+ * a field a `computed` declares, which is the same spec the SQL dialects render as one subquery.
550
+ */
551
+ aggregateStages(meta, spec, temp, field) {
552
+ const relOpts = relationOf(meta, spec.relation);
553
+ const query = spec.query ?? {};
554
+ const tail = [
555
+ ...(query.$sort ? [{ $sort: this.sort(relOpts.entity(), query.$sort) }] : []),
556
+ ...this.pagerStages(query),
557
+ spec.field
558
+ ? {
559
+ $group: {
560
+ _id: null,
561
+ [AGGREGATE_VALUE_ALIAS]: { [spec.op]: `$${this.columnOf(getMeta(relOpts.entity()), spec.field)}` },
562
+ },
563
+ }
564
+ : { $count: AGGREGATE_VALUE_ALIAS },
565
+ ];
566
+ return [
567
+ this.relationLookup(meta, relOpts, query.$where ?? {}, temp, tail),
568
+ { $addFields: { [field]: this.tally(temp, spec.op) } },
569
+ ];
509
570
  }
510
- /** The tally a lookup left in `temp`, which holds no row at all where nothing matched: a zero. */
511
- tally(temp) {
512
- return { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] };
571
+ /**
572
+ * The value a lookup left in `temp`, which holds no row at all where nothing matched: `0` for the
573
+ * aggregates that count something, and `null` for the ones with no value to report.
574
+ */
575
+ tally(temp, op = '$count') {
576
+ const empty = op === '$count' || op === '$sum' ? 0 : null;
577
+ return { $ifNull: [{ $arrayElemAt: [`$${temp}.${AGGREGATE_VALUE_ALIAS}`, 0] }, empty] };
513
578
  }
514
579
  /**
515
580
  * The lookups reading each to-many a query populates, and the tally of each `$count`, onto the fields
@@ -523,12 +588,11 @@ export class MongoDialect extends AbstractDialect {
523
588
  for (const relKey of getRelationRequestSummary(meta, q.$populate).toManyKeys) {
524
589
  stages.push(...this.toManyLookup(meta, relKey, parseRelationAtKey(relKey, q.$populate).query, temps));
525
590
  }
526
- for (const { relKey, relation, where } of countedRelations(meta, q.$count)) {
591
+ for (const { relKey, where } of countedRelations(meta, q.$count)) {
527
592
  const temp = `${REL_TEMP_PREFIX}count_${relKey}`;
528
593
  temps.push(temp);
529
- stages.push(this.tallyLookup(meta, relation, where, temp), {
530
- $addFields: { [`${COUNT_RESULT_KEY}.${relKey}`]: this.tally(temp) },
531
- });
594
+ const spec = { relation: relKey, op: '$count', query: { $where: where } };
595
+ stages.push(...this.aggregateStages(meta, spec, temp, `${COUNT_RESULT_KEY}.${relKey}`));
532
596
  }
533
597
  return temps.length ? [...stages, { $unset: temps }] : stages;
534
598
  }
@@ -575,7 +639,7 @@ export class MongoDialect extends AbstractDialect {
575
639
  return false;
576
640
  }
577
641
  const meta = getMeta(entity);
578
- return someKey(sort, (key) => Boolean(meta.relations[key]));
642
+ return someKey(sort, (key) => !!meta.relations[key]);
579
643
  }
580
644
  /**
581
645
  * Aggregate results are keyed by `$group`/`$select` alias rather than by column, so an aggregate
@@ -595,18 +659,14 @@ export class MongoDialect extends AbstractDialect {
595
659
  pathOf(meta, key) {
596
660
  const dot = key.indexOf('.');
597
661
  if (dot < 0) {
662
+ assertReadable(meta, key);
598
663
  return this.columnOf(meta, key);
599
664
  }
600
665
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
601
666
  }
602
667
  aggregationPipeline(entity, q, opts) {
603
- // Lookups that a relation condition needs come first, then the match that reads them, then the
604
- // temporary fields are dropped so they never reach the caller.
605
- const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
606
668
  return [
607
- ...stages,
608
- ...(hasKeys(filter) ? [{ $match: filter }] : []),
609
- ...(unset.length ? [{ $unset: unset }] : []),
669
+ ...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
610
670
  ...this.readStages(entity, q, {
611
671
  sort: this.sort(entity, q.$sort, q.$populate),
612
672
  pager: this.pagerStages(q),
@@ -955,33 +1015,19 @@ export class MongoDialect extends AbstractDialect {
955
1015
  * Build MongoDB aggregation pipeline stages from a QueryAggregate.
956
1016
  */
957
1017
  buildAggregateStages(entity, q, opts) {
958
- const pipeline = [];
959
- // $match stage (WHERE equivalent - before grouping)
960
- if (q.$where) {
961
- const filter = this.where(entity, q.$where, opts);
962
- if (hasKeys(filter)) {
963
- pipeline.push({ $match: filter });
964
- }
965
- }
966
- // $group stage
967
- const { groupId, groupAccumulators, distinctReducers } = this.buildGroupSpec(getMeta(entity), parseGroupMap(q.$group, q.$select));
968
- pipeline.push({ $group: { _id: hasKeys(groupId) ? groupId : null, ...groupAccumulators } });
969
- // Project stage - rename _id fields back to their original names, and reduce collected distinct
970
- // sets. Needed whenever there are group keys OR any distinct alias.
971
- if (hasKeys(groupId) || distinctReducers.size) {
972
- const project = { _id: 0 };
973
- for (const alias of Object.keys(groupId)) {
974
- project[alias] = `$_id.${alias}`;
975
- }
976
- for (const alias of Object.keys(groupAccumulators)) {
977
- const reduceOp = distinctReducers.get(alias);
978
- project[alias] = reduceOp ? { [reduceOp]: `$${alias}` } : 1;
979
- }
980
- pipeline.push({ $project: project });
981
- }
1018
+ const meta = getMeta(entity);
1019
+ const joins = resolveGroupJoins(meta, q.$group);
1020
+ const { groupId, accumulators, columns, named } = this.buildGroupSpec(meta, parseGroupMap(q.$group, q.$select), joins);
1021
+ const pipeline = [
1022
+ ...this.matchStages(entity, q.$where, opts, named),
1023
+ ...this.lookupStages(meta, joins),
1024
+ { $group: { _id: hasKeys(groupId) ? groupId : null, ...accumulators } },
1025
+ // `$group` answers with `_id` even when grouping by nothing, and with what `columns` read to the end.
1026
+ { $project: { _id: 0, ...columns } },
1027
+ ];
982
1028
  // Everything the pipeline emits, which is all `$having` and `$sort` may name. The `$project`
983
1029
  // above has already dropped the rest, so an unchecked key matched nothing or ordered by nothing.
984
- const emitted = new Set([...Object.keys(groupId), ...Object.keys(groupAccumulators)]);
1030
+ const emitted = new Set(Object.keys(columns));
985
1031
  // $match stage for HAVING (post-group filtering)
986
1032
  if (q.$having) {
987
1033
  assertAggregateColumns(q.$having, emitted, '$having');
@@ -1002,39 +1048,139 @@ export class MongoDialect extends AbstractDialect {
1002
1048
  return pipeline;
1003
1049
  }
1004
1050
  /**
1005
- * Resolve parsed group entries into the `_id` keys and accumulators of a `$group` stage.
1006
- * `distinctReducers` maps each DISTINCT alias (collected via `$addToSet`) to the `$project`
1007
- * operator that reduces its set: `$size` for `$count`, `$sum`/`$avg` for the numeric ops.
1051
+ * Resolve parsed group entries into the `_id` keys and accumulators of a `$group` stage, and the
1052
+ * `columns` its `$project` reads each result from: a group key out of `_id`, a DISTINCT set by its
1053
+ * size, and a `$sum` as null where it read no value, as SQL answers it. `named` is every field it reads,
1054
+ * filters included, so each relation aggregate among them is put on the document first.
1008
1055
  */
1009
- buildGroupSpec(meta, groupEntries) {
1056
+ buildGroupSpec(meta, groupEntries, joins) {
1010
1057
  if (!groupEntries.length) {
1011
1058
  throw new TypeError('aggregate requires at least one $group column or $select function');
1012
1059
  }
1013
1060
  const groupId = {};
1014
- const groupAccumulators = {};
1015
- const distinctReducers = new Map();
1061
+ const accumulators = {};
1062
+ const columns = {};
1063
+ const named = [];
1016
1064
  for (const entry of groupEntries) {
1017
1065
  // Aliases stay as the caller wrote them ($project maps them back); the *refs* address columns.
1018
- const ref = entry.kind === 'key' ? `$${this.columnOf(meta, entry.alias)}` : `$${this.columnOf(meta, entry.fieldRef)}`;
1019
1066
  if (entry.kind === 'key') {
1020
- groupId[entry.alias] = ref;
1067
+ groupId[entry.alias] = `$${this.groupedPath(meta, joins, entry.path, named)}`;
1068
+ columns[entry.alias] = `$_id.${entry.alias}`;
1069
+ continue;
1021
1070
  }
1022
- else if (entry.distinct) {
1023
- // Collect the set now; reduce it in $project: `$size` counts it, `$sum`/`$avg` reduce the array.
1024
- groupAccumulators[entry.alias] = { $addToSet: ref };
1025
- distinctReducers.set(entry.alias, entry.op === '$count' ? '$size' : entry.op);
1071
+ named.push(entry.fieldRef);
1072
+ const ref = `$${this.columnOf(meta, entry.fieldRef)}`;
1073
+ const test = entry.where && this.whereExpression(meta, entry.where, named);
1074
+ // What the accumulator reads from a row its own `$where` passes, and from one it does not.
1075
+ const read = (passed, failed) => (test ? { $cond: [test, passed, failed] } : passed);
1076
+ columns[entry.alias] = 1;
1077
+ if (entry.distinct) {
1078
+ accumulators[entry.alias] = { $addToSet: read(ref, '$$REMOVE') };
1079
+ columns[entry.alias] = { $size: `$${entry.alias}` };
1026
1080
  }
1027
1081
  else if (entry.op === '$count') {
1028
1082
  // COUNT(*) counts every row; COUNT(field) counts non-null values, matching SQL.
1029
- groupAccumulators[entry.alias] =
1030
- entry.fieldRef === '*' ? { $sum: 1 } : { $sum: { $cond: [{ $ne: [ref, null] }, 1, 0] } };
1083
+ accumulators[entry.alias] = { $sum: entry.fieldRef === '*' ? read(1, 0) : read(MongoDialect.countOf(ref), 0) };
1031
1084
  }
1032
1085
  else {
1033
- // `$sum`, `$avg`, `$min` and `$max` are MongoDB accumulators of the same name.
1034
- groupAccumulators[entry.alias] = { [entry.op]: ref };
1086
+ // `$sum`, `$avg`, `$min` and `$max` are MongoDB accumulators of the same name, and skip a null.
1087
+ accumulators[entry.alias] = { [entry.op]: read(ref, null) };
1088
+ }
1089
+ if (entry.op === '$sum' && !entry.distinct) {
1090
+ const counted = `${SUM_COUNT_ALIAS}_${entry.alias}`;
1091
+ accumulators[counted] = { $sum: read(MongoDialect.countOf(ref), 0) };
1092
+ columns[entry.alias] = { $cond: [{ $eq: [`$${counted}`, 0] }, null, `$${entry.alias}`] };
1093
+ }
1094
+ }
1095
+ return { groupId, accumulators, columns, named };
1096
+ }
1097
+ /** `1` where `ref` holds a value, `0` where it is null or missing, which an expression tells apart. */
1098
+ static countOf(ref) {
1099
+ return { $cond: [MongoDialect.isNullExpr(ref), 0, 1] };
1100
+ }
1101
+ /** Whether `ref` is null or missing: an expression compares a missing field as neither. */
1102
+ static isNullExpr(ref) {
1103
+ return { $eq: [{ $ifNull: [ref, null] }, null] };
1104
+ }
1105
+ /** The field a grouped path reads, on the document or on the joined one its lookup unwound. */
1106
+ groupedPath(meta, joins, path, named) {
1107
+ const { key, join } = groupPathField(joins, path);
1108
+ if (!join) {
1109
+ named.push(key);
1110
+ return this.columnOf(meta, key);
1111
+ }
1112
+ if (aggregateOf(join.meta.fields[key])) {
1113
+ throw new TypeError(`cannot $group by '${path.join('.')}' on MongoDB: a joined row's relation aggregate is not read`);
1114
+ }
1115
+ return `${join.path}.${this.columnOf(join.meta, key)}`;
1116
+ }
1117
+ /**
1118
+ * An aggregate's own `$where` as the expression a `$cond` tests, which a query filter is not: the
1119
+ * comparisons and the logical operators translate, anything else is refused by name. `named` gathers
1120
+ * the fields it reads, so a relation aggregate among them is on the document first.
1121
+ */
1122
+ whereExpression(meta, where, named) {
1123
+ const terms = getKeys(where)
1124
+ .filter((key) => where[key] !== undefined)
1125
+ .map((key) => {
1126
+ if (MongoDialect.isGroupOp(key)) {
1127
+ const { join, negate } = MongoDialect.GROUP_OPS[key];
1128
+ const clauses = MongoDialect.groupClauses(key, where[key]).map((clause) => {
1129
+ if (clause instanceof QueryRaw) {
1130
+ throw new TypeError('raw SQL is not supported in an aggregate $where on MongoDB');
1131
+ }
1132
+ return this.whereExpression(meta, clause, named);
1133
+ });
1134
+ return negate ? { $not: [{ [join]: clauses }] } : { [join]: clauses };
1135
+ }
1136
+ if (key.startsWith('$')) {
1137
+ throw new TypeError(`aggregate $where operator '${key}' is not supported on MongoDB`);
1035
1138
  }
1139
+ const val = where[key];
1140
+ named.push(key);
1141
+ const path = this.pathOf(meta, key);
1142
+ const wire = (value) => path === MongoDialect.ID_KEY || meta.fields[key]?.references ? this.toWireId(value) : value;
1143
+ return this.fieldExpression(`$${path}`, val, wire);
1144
+ });
1145
+ return terms.length === 1 ? terms[0] : { $and: terms };
1146
+ }
1147
+ /** One field's condition as an expression: a value it equals, a list it is in, or a map of comparisons. */
1148
+ fieldExpression(ref, val, wire) {
1149
+ const equals = (value) => (value === null ? MongoDialect.isNullExpr(ref) : { $eq: [ref, wire(value)] });
1150
+ if (!isOperatorMap(val)) {
1151
+ return Array.isArray(val) ? { $in: [ref, wire(val)] } : equals(val);
1036
1152
  }
1037
- return { groupId, groupAccumulators, distinctReducers };
1153
+ // A null or missing field compares below every value in an expression, where SQL leaves it unmatched.
1154
+ const present = { $not: [MongoDialect.isNullExpr(ref)] };
1155
+ const terms = Object.entries(val).map(([op, operand]) => {
1156
+ switch (op) {
1157
+ case '$eq':
1158
+ return equals(operand);
1159
+ case '$ne':
1160
+ return { $not: [equals(operand)] };
1161
+ case '$gt':
1162
+ case '$gte':
1163
+ return { [op]: [ref, wire(operand)] };
1164
+ case '$lt':
1165
+ case '$lte':
1166
+ return { $and: [present, { [op]: [ref, wire(operand)] }] };
1167
+ case '$in':
1168
+ return { $in: [ref, wire(operand)] };
1169
+ case '$nin':
1170
+ return { $not: [{ $in: [ref, wire(operand)] }] };
1171
+ case '$between': {
1172
+ const [min, max] = operand;
1173
+ return { $and: [{ $gte: [ref, wire(min)] }, { $lte: [ref, wire(max)] }] };
1174
+ }
1175
+ case '$isNull':
1176
+ return operand ? MongoDialect.isNullExpr(ref) : present;
1177
+ case '$isNotNull':
1178
+ return operand ? present : MongoDialect.isNullExpr(ref);
1179
+ default:
1180
+ throw new TypeError(`aggregate $where operator '${op}' is not supported on MongoDB`);
1181
+ }
1182
+ });
1183
+ return terms.length === 1 ? terms[0] : { $and: terms };
1038
1184
  }
1039
1185
  buildHavingFilter(having) {
1040
1186
  const filter = {};
@@ -1113,3 +1259,14 @@ export class MongoDialect extends AbstractDialect {
1113
1259
  function sortDirection(value) {
1114
1260
  return value === 'desc' || value === -1 ? -1 : 1;
1115
1261
  }
1262
+ /**
1263
+ * A `computed` field writing SQL is refused wherever a query names it, since no document engine
1264
+ * evaluates SQL and answering with the property name would hand back `undefined` for every row. One
1265
+ * computing a relation aggregate is read: `appendAggregateField` builds it.
1266
+ */
1267
+ function assertReadable(meta, key) {
1268
+ const field = meta.fields[key];
1269
+ if (field?.computed && !aggregateOf(field)) {
1270
+ throw new TypeError(`cannot read '${meta.entity.name}.${key}' on MongoDB: a 'computed' field writing SQL is not something a document engine evaluates`);
1271
+ }
1272
+ }
@@ -18,8 +18,8 @@ export declare class MongodbQuerier extends AbstractQuerier {
18
18
  */
19
19
  private readCursor;
20
20
  /**
21
- * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter
22
- * or order by a relation, and the grouping `$distinct` is.
21
+ * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter or
22
+ * order by a relation, one to build a relation aggregate, and the grouping `$distinct` is.
23
23
  */
24
24
  private readsThroughPipeline;
25
25
  private buildScalarProjection;
@@ -44,8 +44,6 @@ export declare class MongodbQuerier extends AbstractQuerier {
44
44
  * filter - the reason {@link UniversalQuerier.estimatedCount} takes none either.
45
45
  */
46
46
  estimatedCount<E extends Document>(entity: Type<E>): Promise<number>;
47
- /** A `find` filter cannot host the `$lookup` a relation condition needs, so such a write names its rows by id. */
48
- protected settlesWrite<E extends Document>(entity: Type<E>, q: QuerySearch<E>): boolean;
49
47
  internalInsertMany<E extends Document>(entity: Type<E>, rows: EntityData<E>[]): Promise<void>;
50
48
  internalUpdateMany<E extends Document>(entity: Type<E>, qm: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<number>;
51
49
  /**
@@ -1,4 +1,4 @@
1
- import { COUNT_ALIAS } from '../dialect/aliases.js';
1
+ import { AGGREGATE_VALUE_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { fieldOf, getMeta, namesKey, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
@@ -68,15 +68,16 @@ export class MongodbQuerier extends AbstractQuerier {
68
68
  : this.buildFindCursor(entity, q, opts);
69
69
  }
70
70
  /**
71
- * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter
72
- * or order by a relation, and the grouping `$distinct` is.
71
+ * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter or
72
+ * order by a relation, one to build a relation aggregate, and the grouping `$distinct` is.
73
73
  */
74
74
  readsThroughPipeline(entity, q) {
75
75
  return (!!q.$distinct ||
76
76
  hasKeys(q.$count) ||
77
77
  populatesRelations(getMeta(entity), q.$populate) ||
78
78
  this.dialect.constrainsRelations(entity, q.$where) ||
79
- this.dialect.sortsRelations(entity, q.$sort));
79
+ this.dialect.sortsRelations(entity, q.$sort) ||
80
+ this.dialect.readsAggregates(entity, q));
80
81
  }
81
82
  buildScalarProjection(entity, q) {
82
83
  return this.dialect.select(entity, q.$select, q.$exclude);
@@ -142,10 +143,10 @@ export class MongodbQuerier extends AbstractQuerier {
142
143
  const [founds, counted] = await Promise.all([
143
144
  this.internalFindMany(entity, q, opts),
144
145
  this.execute((session) => this.collection(entity)
145
- .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_ALIAS }], { session })
146
+ .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: AGGREGATE_VALUE_ALIAS }], { session })
146
147
  .toArray()),
147
148
  ]);
148
- return [founds, counted[0]?.[COUNT_ALIAS] ?? 0];
149
+ return [founds, counted[0]?.[AGGREGATE_VALUE_ALIAS] ?? 0];
149
150
  }
150
151
  /** The pipeline `countDocuments` runs, spelled out so a relation condition gets its lookups and a page its stages. */
151
152
  async internalCount(entity, q, opts) {
@@ -153,10 +154,15 @@ export class MongodbQuerier extends AbstractQuerier {
153
154
  return 0;
154
155
  }
155
156
  return this.timed('internalCount', undefined, async () => {
156
- const { stages, filter } = this.dialect.whereWithRelations(entity, q.$where, opts);
157
- const pipeline = [...stages, { $match: filter }, ...this.dialect.pagerStages(q), { $count: COUNT_ALIAS }];
158
- const [counted] = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
159
- return counted?.[COUNT_ALIAS] ?? 0;
157
+ const pipeline = [
158
+ ...this.dialect.matchStages(entity, q.$where, opts),
159
+ ...this.dialect.pagerStages(q),
160
+ { $count: AGGREGATE_VALUE_ALIAS },
161
+ ];
162
+ const [counted] = await this.execute((session) => this.collection(entity)
163
+ .aggregate(pipeline, { session })
164
+ .toArray());
165
+ return counted?.[AGGREGATE_VALUE_ALIAS] ?? 0;
160
166
  });
161
167
  }
162
168
  /**
@@ -166,10 +172,6 @@ export class MongodbQuerier extends AbstractQuerier {
166
172
  async estimatedCount(entity) {
167
173
  return this.timed('estimatedCount', undefined, async () => this.execute((session) => this.collection(entity).estimatedDocumentCount({ session })));
168
174
  }
169
- /** A `find` filter cannot host the `$lookup` a relation condition needs, so such a write names its rows by id. */
170
- settlesWrite(entity, q) {
171
- return super.settlesWrite(entity, q) || this.dialect.constrainsRelations(entity, q.$where);
172
- }
173
175
  async internalInsertMany(entity, rows) {
174
176
  return this.timed('internalInsertMany', undefined, async () => {
175
177
  const meta = getMeta(entity);
@@ -1,5 +1,5 @@
1
1
  import { relationTermKey } from '../dialect/abstractSqlDialect.js';
2
- import { COUNT_ALIAS, JSON_PULL_ALIAS } from '../dialect/aliases.js';
2
+ import { AGGREGATE_VALUE_ALIAS, JSON_PULL_ALIAS } from '../dialect/aliases.js';
3
3
  import { BYTES_PREFIX } from '../dialect/hydrateColumn.js';
4
4
  import { jsonArraySlotArgs, jsonPath, jsonSlotArgs } from '../dialect/jsonSql.js';
5
5
  import { MergeSqlDialect } from '../dialect/mergeSqlDialect.js';
@@ -29,6 +29,7 @@ const MSSQL_FEATURES = {
29
29
  stringSizing: 'varchar',
30
30
  supportsUnsigned: false,
31
31
  serverSideCursors: false,
32
+ correlatedWrites: true,
32
33
  rowLocks: true,
33
34
  rowLockWithWindow: true,
34
35
  rowLockOf: true,
@@ -227,7 +228,7 @@ export class MsSqlDialect extends MergeSqlDialect {
227
228
  /** The estimate the engine already keeps per partition, live without a stats refresh. */
228
229
  estimatedCount(ctx, entity) {
229
230
  const meta = getMeta(entity);
230
- ctx.append(`SELECT SUM(p.rows) ${this.escapeId(COUNT_ALIAS, true)} FROM sys.partitions p` +
231
+ ctx.append(`SELECT SUM(p.rows) ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM sys.partitions p` +
231
232
  ` JOIN sys.objects o ON o.object_id = p.object_id` +
232
233
  ` JOIN sys.schemas s ON s.schema_id = o.schema_id` +
233
234
  ` WHERE p.index_id IN (0, 1) AND o.name = `);
@@ -1,4 +1,4 @@
1
- import { COUNT_ALIAS } from '../dialect/aliases.js';
1
+ import { AGGREGATE_VALUE_ALIAS } from '../dialect/aliases.js';
2
2
  import { PG_FEATURES, PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
3
3
  import { getMeta } from '../entity/index.js';
4
4
  /** PostgreSQL, under every driver: `pg`, Neon, PGlite, `bun:sql`. Adds pgvector and the `xmax` upsert `created`. */
@@ -18,7 +18,7 @@ export class PostgresDialect extends PgLikeSqlDialect {
18
18
  * back raw it would read as a negative row count.
19
19
  */
20
20
  estimatedCount(ctx, entity) {
21
- ctx.append(`SELECT GREATEST(reltuples, 0)::bigint ${this.escapeId(COUNT_ALIAS, true)} FROM pg_class WHERE oid = to_regclass(`);
21
+ ctx.append(`SELECT GREATEST(reltuples, 0)::bigint ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM pg_class WHERE oid = to_regclass(`);
22
22
  ctx.addValue(this.escapedTableName(getMeta(entity)));
23
23
  ctx.append(')');
24
24
  }