uql-orm 0.69.0 → 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 (44) 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 +4 -4
  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 +16 -5
  9. package/dist/dialect/abstractSqlDialect.js +82 -44
  10. package/dist/dialect/aliases.d.ts +10 -7
  11. package/dist/dialect/aliases.js +10 -7
  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 +25 -11
  17. package/dist/entity/metadata/definition.d.ts +8 -3
  18. package/dist/migrate/codegen/entityCodeGenerator.js +4 -2
  19. package/dist/migrate/migrator.d.ts +2 -1
  20. package/dist/migrate/migrator.js +5 -3
  21. package/dist/mongo/mongoDialect.d.ts +31 -27
  22. package/dist/mongo/mongoDialect.js +172 -112
  23. package/dist/mongo/mongodbQuerier.d.ts +0 -2
  24. package/dist/mongo/mongodbQuerier.js +12 -11
  25. package/dist/mssql/mssqlDialect.js +3 -2
  26. package/dist/postgres/postgresDialect.js +2 -2
  27. package/dist/querier/abstractQuerier.d.ts +16 -11
  28. package/dist/querier/abstractQuerier.js +28 -8
  29. package/dist/querier/abstractQuerierPool.d.ts +9 -9
  30. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  31. package/dist/querier/abstractSqlQuerier.js +3 -3
  32. package/dist/sqlite/sqliteDialect.js +1 -0
  33. package/dist/turso/tursoDialect.d.ts +1 -1
  34. package/dist/turso/tursoDialect.js +6 -2
  35. package/dist/type/dialect.d.ts +6 -0
  36. package/dist/type/entity.d.ts +31 -2
  37. package/dist/type/migration.d.ts +7 -0
  38. package/dist/type/query.d.ts +6 -0
  39. package/dist/type/queryAggregate.d.ts +77 -42
  40. package/dist/type/queryAggregate.js +4 -21
  41. package/dist/type/universalQuerier.d.ts +9 -9
  42. package/dist/util/dialect.util.d.ts +6 -2
  43. package/dist/util/dialect.util.js +19 -5
  44. package/package.json +1 -1
@@ -33,20 +33,15 @@ export declare class MongoDialect extends AbstractDialect {
33
33
  columnOf<E>(meta: EntityMeta<E>, key: string): string;
34
34
  where<E extends Document>(entity: Type<E>, where?: QueryWhere<E>, opts?: QueryOptions): Filter<E>;
35
35
  /**
36
- * A `$where` that may constrain relations, as the `$lookup` stages it needs and the `$match` reading
37
- * them: each condition a lookup into a temporary field (`unset` names them), so `$or` keeps its meaning.
36
+ * The stages every pipeline starts with: the `$lookup` each relation condition needs, into a temporary
37
+ * field so `$or` keeps its meaning, the `$match` reading them, and the temporaries taken back out. Each
38
+ * relation aggregate the `$where` or `named` reads is left on the document, once, under its column.
38
39
  */
39
- whereWithRelations<E extends Document>(entity: Type<E>, where?: QueryWhere<E>, opts?: QueryOptions): {
40
- readonly stages: MongoAggregationPipelineEntry<Document>[];
41
- readonly filter: Filter<E>;
42
- readonly unset: string[];
43
- };
44
- /** Whether a `$where` constrains any relation, and so needs the aggregation path rather than a cursor. */
45
- constrainsRelations<E extends Document>(entity: Type<E>, where: QueryWhere<E> | undefined): boolean;
40
+ matchStages<E extends Document>(entity: Type<E>, where?: QueryWhere<E>, opts?: QueryOptions, named?: readonly string[]): MongoAggregationPipelineEntry<Document>[];
46
41
  /**
47
42
  * Renders a `$where` tree without applying entity filters (used for same-scope group-operator
48
- * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
49
- * given - a plain `find`/`updateMany` filter has nowhere to put them.
43
+ * recursion). A relation, or a relation aggregate, needs `$lookup` stages, so it is only accepted
44
+ * when `lookups` is given - a plain `find`/`updateMany` filter has nowhere to put them.
50
45
  */
51
46
  protected renderFilter<E extends Document>(entity: Type<E>, where?: QueryWhere<E>, lookups?: RelationLookups): Filter<E>;
52
47
  /**
@@ -141,20 +136,14 @@ export declare class MongoDialect extends AbstractDialect {
141
136
  };
142
137
  /** Whether a read answers with a relation aggregate, which only the pipeline can build. */
143
138
  readsAggregates<E extends Document>(entity: Type<E>, q: Query<E>): boolean;
144
- /**
145
- * The relation aggregates one query reads: the ones its projection carries, plus any its `$where` or
146
- * `$sort` names, which a read materializes whether or not it answers with them.
147
- */
139
+ /** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
148
140
  private aggregateKeys;
149
141
  /**
150
- * The stages a relation aggregate a query names needs: the correlated lookup that reads the related
151
- * rows - narrowed, ordered and capped as the field declared - ending in the tally or total it wants,
152
- * and the `$addFields` that puts the value on the document under the field's own name.
153
- *
154
- * The same spec the SQL dialects render as a correlated subquery: a relation aggregate is data, so a
155
- * document engine builds it out of stages rather than being refused a language it does not speak.
142
+ * The relation aggregate `key` computes, put on the document under its column by the stages
143
+ * {@link aggregateStages} builds from the same spec SQL renders as a subquery. Once however many
144
+ * clauses read it; a key computing none adds nothing.
156
145
  */
157
- private aggregateFieldStages;
146
+ private appendAggregateField;
158
147
  /**
159
148
  * One relation aggregate on the document under `field`: the correlated lookup that reads the related
160
149
  * rows - narrowed, ordered and capped as the spec says - ending in the tally or total it wants, and
@@ -258,13 +247,28 @@ export declare class MongoDialect extends AbstractDialect {
258
247
  /**
259
248
  * Build MongoDB aggregation pipeline stages from a QueryAggregate.
260
249
  */
261
- buildAggregateStages<E extends Document, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Record<string, unknown>[];
250
+ buildAggregateStages<E extends Document, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): MongoAggregationPipelineEntry<Document>[];
262
251
  /**
263
- * Resolve parsed group entries into the `_id` keys and accumulators of a `$group` stage.
264
- * `distinctReducers` maps each DISTINCT alias (collected via `$addToSet`) to the `$project`
265
- * operator that reduces its set: `$size` for `$count`, `$sum`/`$avg` for the numeric ops.
252
+ * Resolve parsed group entries into the `_id` keys and accumulators of a `$group` stage, and the
253
+ * `columns` its `$project` reads each result from: a group key out of `_id`, a DISTINCT set by its
254
+ * size, and a `$sum` as null where it read no value, as SQL answers it. `named` is every field it reads,
255
+ * filters included, so each relation aggregate among them is put on the document first.
266
256
  */
267
257
  private buildGroupSpec;
258
+ /** `1` where `ref` holds a value, `0` where it is null or missing, which an expression tells apart. */
259
+ private static countOf;
260
+ /** Whether `ref` is null or missing: an expression compares a missing field as neither. */
261
+ private static isNullExpr;
262
+ /** The field a grouped path reads, on the document or on the joined one its lookup unwound. */
263
+ private groupedPath;
264
+ /**
265
+ * An aggregate's own `$where` as the expression a `$cond` tests, which a query filter is not: the
266
+ * comparisons and the logical operators translate, anything else is refused by name. `named` gathers
267
+ * the fields it reads, so a relation aggregate among them is on the document first.
268
+ */
269
+ private whereExpression;
270
+ /** One field's condition as an expression: a value it equals, a list it is in, or a map of comparisons. */
271
+ private fieldExpression;
268
272
  private buildHavingFilter;
269
273
  /**
270
274
  * Separate vector sort entries from regular sort entries.
@@ -275,7 +279,7 @@ export declare class MongoDialect extends AbstractDialect {
275
279
  * Build a `$vectorSearch` aggregation pipeline stage.
276
280
  * Merges `$where` into `$vectorSearch.filter` for optimal pre-filtering.
277
281
  */
278
- buildVectorSearchStage<E extends Document>(entity: Type<E>, key: string, search: QueryVectorSearch, where: QueryWhere<E> | undefined, limit: number, opts?: QueryOptions, candidates?: number): Record<string, unknown>;
282
+ buildVectorSearchStage<E extends Document>(entity: Type<E>, key: string, search: QueryVectorSearch, where: QueryWhere<E> | undefined, limit: number, opts?: QueryOptions, candidates?: number): MongoAggregationPipelineEntry<Document>;
279
283
  }
280
284
  export type MongoAggregationPipelineEntry<E extends Document> = {
281
285
  $lookup?: MongoAggregationLookup;
@@ -1,11 +1,11 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import { AGGREGATE_VALUE_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
- import { QueryRaw, RelationAggregate } 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';
7
+ import { QueryRaw } from '../type/queryRaw.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)) {
@@ -423,16 +427,11 @@ 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) => {
426
- // Swept in with the rest of the entity's fields, a computed one is skipped; asked for by name
427
- // it is refused, since the document holds nothing to project under it.
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.
428
432
  const field = meta.fields[key];
429
- if (field?.computed) {
430
- // An aggregate is on the document by the time this projects, under the field's own name; SQL
431
- // is refused where it was asked for by name, and skipped where it was swept in with the rest.
432
- if (aggregateOf(field)) {
433
- acc[key] = 1;
434
- }
435
- else if (selectMap && key in selectMap) {
433
+ if (field?.computed && !aggregateOf(field)) {
434
+ if (selectMap && key in selectMap) {
436
435
  assertReadable(meta, key);
437
436
  }
438
437
  return acc;
@@ -521,36 +520,25 @@ export class MongoDialect extends AbstractDialect {
521
520
  readsAggregates(entity, q) {
522
521
  return this.aggregateKeys(entity, q).length > 0;
523
522
  }
524
- /**
525
- * The relation aggregates one query reads: the ones its projection carries, plus any its `$where` or
526
- * `$sort` names, which a read materializes whether or not it answers with them.
527
- */
523
+ /** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
528
524
  aggregateKeys(entity, q) {
529
525
  const meta = getMeta(entity);
530
526
  const projected = normalizeScalarFieldSelection(meta, asSelectMap(q.$select), q.$exclude);
531
- const named = [...Object.keys(q.$where ?? {}), ...Object.keys(q.$sort ?? {})];
532
- return [...new Set([...projected, ...named])].flatMap((key) => {
533
- const spec = aggregateOf(meta.fields[key]);
534
- return spec ? [[key, spec]] : [];
535
- });
527
+ return [...projected, ...Object.keys(q.$sort ?? {})].filter((key) => aggregateOf(meta.fields[key]));
536
528
  }
537
529
  /**
538
- * The stages a relation aggregate a query names needs: the correlated lookup that reads the related
539
- * rows - narrowed, ordered and capped as the field declared - ending in the tally or total it wants,
540
- * and the `$addFields` that puts the value on the document under the field's own name.
541
- *
542
- * The same spec the SQL dialects render as a correlated subquery: a relation aggregate is data, so a
543
- * document engine builds it out of stages rather than being refused a language it does not speak.
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.
544
533
  */
545
- aggregateFieldStages(meta, aggregates) {
546
- const stages = [];
547
- const temps = [];
548
- for (const [key, spec] of aggregates) {
549
- const temp = `${REL_TEMP_PREFIX}${key}`;
550
- stages.push(...this.aggregateStages(meta, spec, temp, key));
551
- temps.push(temp);
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;
552
539
  }
553
- return { stages, temps };
540
+ lookups.temps.push(temp);
541
+ lookups.stages.push(...this.aggregateStages(meta, spec, temp, this.columnOf(meta, key)));
554
542
  }
555
543
  /**
556
544
  * One relation aggregate on the document under `field`: the correlated lookup that reads the related
@@ -651,7 +639,7 @@ export class MongoDialect extends AbstractDialect {
651
639
  return false;
652
640
  }
653
641
  const meta = getMeta(entity);
654
- return someKey(sort, (key) => Boolean(meta.relations[key]));
642
+ return someKey(sort, (key) => !!meta.relations[key]);
655
643
  }
656
644
  /**
657
645
  * Aggregate results are keyed by `$group`/`$select` alias rather than by column, so an aggregate
@@ -677,22 +665,12 @@ export class MongoDialect extends AbstractDialect {
677
665
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
678
666
  }
679
667
  aggregationPipeline(entity, q, opts) {
680
- // Lookups that a relation condition needs come first, then the match that reads them, then the
681
- // temporary fields are dropped so they never reach the caller.
682
- // Every relation aggregate the query reads comes first: a `$match`, a `$sort` and the projection all
683
- // name it as a field of the document, which is what these stages make true.
684
- const aggregates = this.aggregateFieldStages(getMeta(entity), this.aggregateKeys(entity, q));
685
- const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
686
668
  return [
687
- ...aggregates.stages,
688
- ...stages,
689
- ...(hasKeys(filter) ? [{ $match: filter }] : []),
690
- ...(unset.length ? [{ $unset: unset }] : []),
669
+ ...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
691
670
  ...this.readStages(entity, q, {
692
671
  sort: this.sort(entity, q.$sort, q.$populate),
693
672
  pager: this.pagerStages(q),
694
673
  }),
695
- ...(aggregates.temps.length ? [{ $unset: aggregates.temps }] : []),
696
674
  ];
697
675
  }
698
676
  /** The `$skip`/`$limit` stages of a page, each checked: `/http` hands a page over untyped. */
@@ -1037,33 +1015,19 @@ export class MongoDialect extends AbstractDialect {
1037
1015
  * Build MongoDB aggregation pipeline stages from a QueryAggregate.
1038
1016
  */
1039
1017
  buildAggregateStages(entity, q, opts) {
1040
- const pipeline = [];
1041
- // $match stage (WHERE equivalent - before grouping)
1042
- if (q.$where) {
1043
- const filter = this.where(entity, q.$where, opts);
1044
- if (hasKeys(filter)) {
1045
- pipeline.push({ $match: filter });
1046
- }
1047
- }
1048
- // $group stage
1049
- const { groupId, groupAccumulators, distinctReducers } = this.buildGroupSpec(getMeta(entity), parseGroupMap(q.$group, q.$select));
1050
- pipeline.push({ $group: { _id: hasKeys(groupId) ? groupId : null, ...groupAccumulators } });
1051
- // Project stage - rename _id fields back to their original names, and reduce collected distinct
1052
- // sets. Needed whenever there are group keys OR any distinct alias.
1053
- if (hasKeys(groupId) || distinctReducers.size) {
1054
- const project = { _id: 0 };
1055
- for (const alias of Object.keys(groupId)) {
1056
- project[alias] = `$_id.${alias}`;
1057
- }
1058
- for (const alias of Object.keys(groupAccumulators)) {
1059
- const reduceOp = distinctReducers.get(alias);
1060
- project[alias] = reduceOp ? { [reduceOp]: `$${alias}` } : 1;
1061
- }
1062
- pipeline.push({ $project: project });
1063
- }
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
+ ];
1064
1028
  // Everything the pipeline emits, which is all `$having` and `$sort` may name. The `$project`
1065
1029
  // above has already dropped the rest, so an unchecked key matched nothing or ordered by nothing.
1066
- const emitted = new Set([...Object.keys(groupId), ...Object.keys(groupAccumulators)]);
1030
+ const emitted = new Set(Object.keys(columns));
1067
1031
  // $match stage for HAVING (post-group filtering)
1068
1032
  if (q.$having) {
1069
1033
  assertAggregateColumns(q.$having, emitted, '$having');
@@ -1084,39 +1048,139 @@ export class MongoDialect extends AbstractDialect {
1084
1048
  return pipeline;
1085
1049
  }
1086
1050
  /**
1087
- * Resolve parsed group entries into the `_id` keys and accumulators of a `$group` stage.
1088
- * `distinctReducers` maps each DISTINCT alias (collected via `$addToSet`) to the `$project`
1089
- * 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.
1090
1055
  */
1091
- buildGroupSpec(meta, groupEntries) {
1056
+ buildGroupSpec(meta, groupEntries, joins) {
1092
1057
  if (!groupEntries.length) {
1093
1058
  throw new TypeError('aggregate requires at least one $group column or $select function');
1094
1059
  }
1095
1060
  const groupId = {};
1096
- const groupAccumulators = {};
1097
- const distinctReducers = new Map();
1061
+ const accumulators = {};
1062
+ const columns = {};
1063
+ const named = [];
1098
1064
  for (const entry of groupEntries) {
1099
1065
  // Aliases stay as the caller wrote them ($project maps them back); the *refs* address columns.
1100
- const ref = entry.kind === 'key' ? `$${this.columnOf(meta, entry.alias)}` : `$${this.columnOf(meta, entry.fieldRef)}`;
1101
1066
  if (entry.kind === 'key') {
1102
- groupId[entry.alias] = ref;
1067
+ groupId[entry.alias] = `$${this.groupedPath(meta, joins, entry.path, named)}`;
1068
+ columns[entry.alias] = `$_id.${entry.alias}`;
1069
+ continue;
1103
1070
  }
1104
- else if (entry.distinct) {
1105
- // Collect the set now; reduce it in $project: `$size` counts it, `$sum`/`$avg` reduce the array.
1106
- groupAccumulators[entry.alias] = { $addToSet: ref };
1107
- 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}` };
1108
1080
  }
1109
1081
  else if (entry.op === '$count') {
1110
1082
  // COUNT(*) counts every row; COUNT(field) counts non-null values, matching SQL.
1111
- groupAccumulators[entry.alias] =
1112
- 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) };
1113
1084
  }
1114
1085
  else {
1115
- // `$sum`, `$avg`, `$min` and `$max` are MongoDB accumulators of the same name.
1116
- 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 };
1117
1135
  }
1136
+ if (key.startsWith('$')) {
1137
+ throw new TypeError(`aggregate $where operator '${key}' is not supported on MongoDB`);
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);
1118
1152
  }
1119
- 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 };
1120
1184
  }
1121
1185
  buildHavingFilter(having) {
1122
1186
  const filter = {};
@@ -1195,14 +1259,10 @@ export class MongoDialect extends AbstractDialect {
1195
1259
  function sortDirection(value) {
1196
1260
  return value === 'desc' || value === -1 ? -1 : 1;
1197
1261
  }
1198
- /** The relation aggregate a field computes, where it computes one rather than writing SQL. */
1199
- function aggregateOf(field) {
1200
- return field?.computed instanceof RelationAggregate ? field.computed.spec : undefined;
1201
- }
1202
1262
  /**
1203
1263
  * A `computed` field writing SQL is refused wherever a query names it, since no document engine
1204
1264
  * evaluates SQL and answering with the property name would hand back `undefined` for every row. One
1205
- * computing a relation aggregate is read: `aggregateFieldStages` builds it.
1265
+ * computing a relation aggregate is read: `appendAggregateField` builds it.
1206
1266
  */
1207
1267
  function assertReadable(meta, key) {
1208
1268
  const field = meta.fields[key];
@@ -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';
@@ -143,10 +143,10 @@ export class MongodbQuerier extends AbstractQuerier {
143
143
  const [founds, counted] = await Promise.all([
144
144
  this.internalFindMany(entity, q, opts),
145
145
  this.execute((session) => this.collection(entity)
146
- .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_ALIAS }], { session })
146
+ .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: AGGREGATE_VALUE_ALIAS }], { session })
147
147
  .toArray()),
148
148
  ]);
149
- return [founds, counted[0]?.[COUNT_ALIAS] ?? 0];
149
+ return [founds, counted[0]?.[AGGREGATE_VALUE_ALIAS] ?? 0];
150
150
  }
151
151
  /** The pipeline `countDocuments` runs, spelled out so a relation condition gets its lookups and a page its stages. */
152
152
  async internalCount(entity, q, opts) {
@@ -154,10 +154,15 @@ export class MongodbQuerier extends AbstractQuerier {
154
154
  return 0;
155
155
  }
156
156
  return this.timed('internalCount', undefined, async () => {
157
- const { stages, filter } = this.dialect.whereWithRelations(entity, q.$where, opts);
158
- const pipeline = [...stages, { $match: filter }, ...this.dialect.pagerStages(q), { $count: COUNT_ALIAS }];
159
- const [counted] = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
160
- 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;
161
166
  });
162
167
  }
163
168
  /**
@@ -167,10 +172,6 @@ export class MongodbQuerier extends AbstractQuerier {
167
172
  async estimatedCount(entity) {
168
173
  return this.timed('estimatedCount', undefined, async () => this.execute((session) => this.collection(entity).estimatedDocumentCount({ session })));
169
174
  }
170
- /** A `find` filter cannot host the `$lookup` a relation condition needs, so such a write names its rows by id. */
171
- settlesWrite(entity, q) {
172
- return super.settlesWrite(entity, q) || this.dialect.constrainsRelations(entity, q.$where);
173
- }
174
175
  async internalInsertMany(entity, rows) {
175
176
  return this.timed('internalInsertMany', undefined, async () => {
176
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
  }