uql-orm 0.56.0 → 0.58.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 (110) hide show
  1. package/README.md +7 -9
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +4 -4
  4. package/dist/cockroachdb/cockroachDialect.d.ts +5 -2
  5. package/dist/cockroachdb/cockroachDialect.js +2 -10
  6. package/dist/d1/d1SqliteDialect.d.ts +1 -0
  7. package/dist/d1/d1SqliteDialect.js +2 -0
  8. package/dist/dialect/abstractSqlDialect.d.ts +196 -33
  9. package/dist/dialect/abstractSqlDialect.js +410 -203
  10. package/dist/dialect/aliases.d.ts +10 -7
  11. package/dist/dialect/aliases.js +12 -7
  12. package/dist/dialect/hydrateColumn.d.ts +8 -2
  13. package/dist/dialect/hydrateColumn.js +33 -1
  14. package/dist/dialect/jsonSql.d.ts +13 -5
  15. package/dist/dialect/jsonSql.js +24 -7
  16. package/dist/dialect/mysqlLikeSqlDialect.d.ts +31 -3
  17. package/dist/dialect/mysqlLikeSqlDialect.js +57 -5
  18. package/dist/dialect/pgLikeSqlDialect.d.ts +20 -20
  19. package/dist/dialect/pgLikeSqlDialect.js +23 -48
  20. package/dist/dialect/pgVectorMetrics.d.ts +13 -0
  21. package/dist/dialect/pgVectorMetrics.js +17 -0
  22. package/dist/dialect/queryContext.d.ts +3 -7
  23. package/dist/dialect/queryContext.js +13 -8
  24. package/dist/dialect/queryJoins.d.ts +8 -4
  25. package/dist/dialect/queryJoins.js +26 -11
  26. package/dist/dialect/vectorSqlDialect.d.ts +2 -2
  27. package/dist/dialect/vectorSqlDialect.js +2 -3
  28. package/dist/entity/decorator/bag.d.ts +2 -2
  29. package/dist/entity/decorator/entity.d.ts +8 -9
  30. package/dist/entity/decorator/entity.js +6 -7
  31. package/dist/entity/decorator/members.d.ts +7 -6
  32. package/dist/entity/decorator/members.js +2 -1
  33. package/dist/entity/metadata/definition.d.ts +16 -11
  34. package/dist/entity/metadata/definition.js +54 -42
  35. package/dist/http/handler.d.ts +2 -2
  36. package/dist/http/handler.js +0 -1
  37. package/dist/maria/mariaDialect.d.ts +13 -6
  38. package/dist/maria/mariaDialect.js +29 -9
  39. package/dist/migrate/cli.d.ts +2 -3
  40. package/dist/migrate/cli.js +2 -2
  41. package/dist/migrate/codegen/entityCodeGenerator.js +6 -4
  42. package/dist/migrate/codegen/entityTypes.d.ts +1 -1
  43. package/dist/migrate/codegen/entityTypes.js +4 -3
  44. package/dist/migrate/codegen/indexDecoratorSource.d.ts +5 -4
  45. package/dist/migrate/codegen/indexDecoratorSource.js +17 -13
  46. package/dist/migrate/codegen/sourceLiteral.d.ts +2 -0
  47. package/dist/migrate/codegen/sourceLiteral.js +4 -0
  48. package/dist/migrate/ddl/index.d.ts +1 -5
  49. package/dist/migrate/ddl/index.js +14 -25
  50. package/dist/migrate/ddl/indexDdl.d.ts +11 -2
  51. package/dist/migrate/ddl/indexDdl.js +17 -1
  52. package/dist/migrate/ddl/mssqlIndexDdl.d.ts +10 -0
  53. package/dist/migrate/ddl/mssqlIndexDdl.js +10 -0
  54. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +10 -17
  55. package/dist/migrate/ddl/mysqlIndexDdl.js +16 -27
  56. package/dist/migrate/ddl/pgIndexDdl.d.ts +18 -8
  57. package/dist/migrate/ddl/pgIndexDdl.js +29 -12
  58. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +3 -3
  59. package/dist/migrate/migrator.d.ts +4 -4
  60. package/dist/migrate/schemaGenerator.d.ts +8 -8
  61. package/dist/migrate/schemaGenerator.js +5 -7
  62. package/dist/migrate/schemaGeneratorAsync.d.ts +2 -3
  63. package/dist/mongo/mongoDialect.d.ts +31 -18
  64. package/dist/mongo/mongoDialect.js +146 -104
  65. package/dist/mongo/mongodbQuerier.d.ts +10 -17
  66. package/dist/mongo/mongodbQuerier.js +31 -106
  67. package/dist/mssql/mssqlDialect.d.ts +16 -0
  68. package/dist/mssql/mssqlDialect.js +26 -4
  69. package/dist/mysql/mysqlDialect.d.ts +2 -0
  70. package/dist/mysql/mysqlDialect.js +4 -0
  71. package/dist/querier/abstractQuerier.d.ts +20 -36
  72. package/dist/querier/abstractQuerier.js +35 -129
  73. package/dist/querier/abstractQuerierPool.d.ts +2 -2
  74. package/dist/querier/abstractSqlQuerier.d.ts +4 -17
  75. package/dist/querier/abstractSqlQuerier.js +40 -50
  76. package/dist/schema/canonicalType.js +4 -4
  77. package/dist/schema/indexDifferences.js +4 -4
  78. package/dist/schema/schemaASTBuilder.d.ts +3 -3
  79. package/dist/schema/schemaASTBuilder.js +32 -3
  80. package/dist/schema/schemaASTDiffer.js +5 -5
  81. package/dist/sqlite/sqliteDialect.d.ts +20 -1
  82. package/dist/sqlite/sqliteDialect.js +38 -7
  83. package/dist/turso/tursoDialect.d.ts +2 -0
  84. package/dist/turso/tursoDialect.js +2 -0
  85. package/dist/type/config.d.ts +3 -3
  86. package/dist/type/dialect.d.ts +4 -5
  87. package/dist/type/entity.d.ts +110 -69
  88. package/dist/type/migration.d.ts +7 -7
  89. package/dist/type/migratorDialect.d.ts +4 -0
  90. package/dist/type/querier.d.ts +6 -6
  91. package/dist/type/querierPool.d.ts +2 -2
  92. package/dist/type/query.d.ts +41 -72
  93. package/dist/type/query.js +10 -5
  94. package/dist/type/queryAggregate.d.ts +43 -34
  95. package/dist/type/queryAggregate.js +1 -1
  96. package/dist/type/queryWhere.d.ts +12 -9
  97. package/dist/type/universalQuerier.d.ts +4 -4
  98. package/dist/util/dialect.util.d.ts +4 -4
  99. package/dist/util/dialect.util.js +24 -15
  100. package/dist/util/field.util.d.ts +5 -0
  101. package/dist/util/field.util.js +19 -0
  102. package/dist/util/object.util.d.ts +2 -0
  103. package/dist/util/object.util.js +4 -0
  104. package/dist/util/relationQuery.util.d.ts +12 -65
  105. package/dist/util/relationQuery.util.js +27 -81
  106. package/dist/util/rowKey.util.d.ts +1 -11
  107. package/dist/util/rowKey.util.js +1 -13
  108. package/package.json +1 -1
  109. package/dist/querier/relationCount.d.ts +0 -16
  110. package/dist/querier/relationCount.js +0 -121
@@ -3,8 +3,9 @@ import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
6
+ import { COUNT_RESULT_KEY } from '../type/query.js';
6
7
  import { QueryRaw } from '../type/queryRaw.js';
7
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
8
+ import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
8
9
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
9
10
  export const mongoDialectFeatures = {
10
11
  explicitJsonCast: false,
@@ -72,7 +73,7 @@ export class MongoDialect extends AbstractDialect {
72
73
  whereWithRelations(entity, where = {}, opts = {}) {
73
74
  const meta = getMeta(entity);
74
75
  const lookups = { stages: [], temps: [] };
75
- const filter = this.renderFilter(entity, this.scopedWhere(meta, where, opts), opts, lookups);
76
+ const filter = this.renderFilter(entity, this.scopedWhere(meta, where, opts), lookups);
76
77
  return { stages: lookups.stages, filter, unset: lookups.temps };
77
78
  }
78
79
  /** Whether a `$where` constrains any relation, and so needs the aggregation path rather than a cursor. */
@@ -91,14 +92,14 @@ export class MongoDialect extends AbstractDialect {
91
92
  * recursion). Relation keys need `$lookup` stages, so they are only accepted when `lookups` is
92
93
  * given - a plain `find`/`updateMany` filter has nowhere to put them.
93
94
  */
94
- renderFilter(entity, where = {}, opts, lookups) {
95
+ renderFilter(entity, where = {}, lookups) {
95
96
  const meta = getMeta(entity);
96
97
  const filter = {};
97
98
  for (const [rawKey, rawVal] of Object.entries(where)) {
98
99
  let key = rawKey;
99
100
  let val = rawVal;
100
101
  if (MongoDialect.isGroupOp(key)) {
101
- this.appendLogicalOperator(filter, entity, key, val, opts, lookups);
102
+ this.appendLogicalOperator(filter, entity, key, val, lookups);
102
103
  }
103
104
  else if (key === '$text') {
104
105
  // MongoDB's text index declares which fields it covers, so `$fields` cannot narrow the search
@@ -110,7 +111,7 @@ export class MongoDialect extends AbstractDialect {
110
111
  if (!lookups) {
111
112
  throw new TypeError(`filtering by relation '${key}' is not supported here on MongoDB`);
112
113
  }
113
- Object.assign(filter, this.appendRelationLookup(meta, key, val, opts, lookups));
114
+ Object.assign(filter, this.appendRelationLookup(meta, key, val, lookups));
114
115
  }
115
116
  else {
116
117
  this.assertNoRaw(val);
@@ -140,12 +141,12 @@ export class MongoDialect extends AbstractDialect {
140
141
  * rejects an empty `$and`/`$or`/`$nor` outright, where the SQL dialects contribute no term.
141
142
  * Negations accumulate into the one `$nor`, since `NOT a AND NOT b` is `$nor: [a, b]`.
142
143
  */
143
- appendLogicalOperator(filter, entity, key, val, opts, lookups) {
144
+ appendLogicalOperator(filter, entity, key, val, lookups) {
144
145
  const { join, negate } = MongoDialect.GROUP_OPS[key];
145
146
  const parts = MongoDialect.groupClauses(key, val)
146
147
  .map((filterIt) => {
147
148
  this.assertNoRaw(filterIt);
148
- return this.renderFilter(entity, filterIt, opts, lookups);
149
+ return this.renderFilter(entity, filterIt, lookups);
149
150
  })
150
151
  .filter((part) => Object.keys(part).length > 0);
151
152
  if (!parts.length) {
@@ -164,80 +165,72 @@ export class MongoDialect extends AbstractDialect {
164
165
  * `$size`. The target's (and, for ManyToMany, the junction's) own filters scope the lookup, so a
165
166
  * relation subquery can no more read out-of-scope rows than a direct query on the target can.
166
167
  */
167
- appendRelationLookup(meta, relKey, val, opts, lookups) {
168
- const relOpts = meta.relations[relKey];
169
- const relEntity = relOpts.entity();
170
- const relMeta = getMeta(relEntity);
168
+ appendRelationLookup(meta, relKey, val, lookups) {
171
169
  const temp = `${REL_TEMP_PREFIX}${lookups.temps.length}`;
172
170
  const sizeVal = parseRelationSize(val);
173
- // `$count` for a size test, `$limit: 1` for existence: neither returns the matched documents.
174
171
  const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_ALIAS }];
175
- // Scope first, render once - merging the target's filters into an already-rendered filter would
176
- // leave their own keys unmapped. The caller's filter bypass is deliberately *not* passed down:
177
- // `withDeleted()` or `hardDelete` on the parent must not un-hide trashed rows of the target, the
178
- // same rule the SQL dialects' relation subqueries follow.
179
- const targetCondition = (sizeVal === undefined ? val : {});
180
- const targetScope = this.renderFilter(relEntity, this.scopedWhere(relMeta, targetCondition), opts);
172
+ const where = (sizeVal === undefined ? val : {});
181
173
  lookups.temps.push(temp);
182
- lookups.stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts));
174
+ lookups.stages.push(this.relationLookup(meta, meta.relations[relKey], where, temp, tail));
183
175
  return sizeVal === undefined
184
176
  ? { [`${temp}.0`]: { $exists: true } }
185
177
  : { $expr: this.compareRelationCount(temp, sizeVal) };
186
178
  }
187
179
  /**
188
- * The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
189
- * straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
190
- * for existence, or a `$count` - so a filter and an ordering build the same stage.
180
+ * The correlated `$lookup` for the target rows of one relation `where` narrows, as `temp`: straight at
181
+ * the target, or for a many-to-many from inside its junction's rows. The caller's filter bypass is not
182
+ * passed down, as on the SQL dialects. `tail` decides what the lookup leaves behind - a row to test for
183
+ * existence, or a `$count` - so a filter and an ordering build the same stage.
191
184
  */
192
- relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
193
- return relOpts.cardinality === 'mm' && relOpts.through
194
- ? this.junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts)
195
- : {
196
- $lookup: {
197
- from: this.resolveTableName(relMeta),
198
- ...this.joinKeys(meta, relMeta, relOpts),
199
- pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), ...tail],
200
- as: temp,
201
- },
185
+ relationLookup(meta, relOpts, where, temp, tail) {
186
+ const relEntity = relOpts.entity();
187
+ const relMeta = getMeta(relEntity);
188
+ const targetScope = this.renderFilter(relEntity, this.scopedWhere(relMeta, where));
189
+ const targetMatch = hasKeys(targetScope) ? [{ $match: targetScope }] : [];
190
+ const from = this.resolveTableName(relMeta);
191
+ if (!relOpts.through) {
192
+ return {
193
+ $lookup: { from, ...this.joinKeys(meta, relMeta, relOpts), pipeline: [...targetMatch, ...tail], as: temp },
202
194
  };
195
+ }
196
+ const junction = this.junctionOf(meta, relOpts, relMeta, relOpts.through());
197
+ const target = {
198
+ $lookup: {
199
+ from,
200
+ localField: junction.target,
201
+ foreignField: MongoDialect.ID_KEY,
202
+ pipeline: [...targetMatch, { $limit: 1 }],
203
+ as: REL_NESTED_KEY,
204
+ },
205
+ };
206
+ return {
207
+ $lookup: {
208
+ ...junction.lookup,
209
+ pipeline: [...junction.scope, target, { $match: { [`${REL_NESTED_KEY}.0`]: { $exists: true } } }, ...tail],
210
+ as: temp,
211
+ },
212
+ };
203
213
  }
204
214
  /**
205
- * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
206
- * lookup - the junction's filters apply too, since a soft-deleted link is not a link.
215
+ * The junction a many-to-many reaches its targets through: the lookup keys matching a parent's rows of
216
+ * it, its own filters, since a soft-deleted link is not a link, and the field holding each target's id.
217
+ * Each end is one field matched against one `_id`, so both sides must be sole-keyed.
207
218
  */
208
- junctionLookup(meta, relOpts, relMeta, targetScope, temp, tail, opts) {
209
- const throughEntity = relOpts.through();
210
- const throughMeta = getMeta(throughEntity);
211
- const junctionScope = this.renderFilter(throughEntity, this.scopedWhere(throughMeta, {}), opts);
212
- const nested = REL_NESTED_KEY;
213
- // Both ends are one column here - each `$lookup` matches one field against `_id` - so both sides
214
- // must be sole-keyed. Sliced rather than indexed positionally: `references[1]` is the parent's
215
- // *second* column on a composite, a real column of the wrong side.
219
+ junctionOf(meta, relOpts, relMeta, through) {
220
+ const throughMeta = getMeta(through);
216
221
  assertSoleId(meta, 'MongoDB');
217
222
  assertSoleId(relMeta, 'MongoDB');
218
223
  const [parentJoin] = parentJoins(relOpts, meta.ids.length);
219
224
  const [targetColumn] = targetKeyColumns(relOpts, meta.ids.length);
225
+ const scope = this.renderFilter(through, this.scopedWhere(throughMeta, {}));
220
226
  return {
221
- $lookup: {
227
+ lookup: {
222
228
  from: this.resolveTableName(throughMeta),
223
229
  localField: MongoDialect.ID_KEY,
224
230
  foreignField: this.columnOf(throughMeta, parentJoin.joined),
225
- pipeline: [
226
- ...(hasKeys(junctionScope) ? [{ $match: junctionScope }] : []),
227
- {
228
- $lookup: {
229
- from: this.resolveTableName(relMeta),
230
- localField: this.columnOf(throughMeta, targetColumn),
231
- foreignField: MongoDialect.ID_KEY,
232
- pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), { $limit: 1 }],
233
- as: nested,
234
- },
235
- },
236
- { $match: { [`${nested}.0`]: { $exists: true } } },
237
- ...tail,
238
- ],
239
- as: temp,
240
231
  },
232
+ scope: hasKeys(scope) ? [{ $match: scope }] : [],
233
+ target: this.columnOf(throughMeta, targetColumn),
241
234
  };
242
235
  }
243
236
  /**
@@ -245,7 +238,7 @@ export class MongoDialect extends AbstractDialect {
245
238
  * the `$ifNull` fallback to 0, so `{ $size: 0 }` matches parents with no related row at all.
246
239
  */
247
240
  compareRelationCount(temp, sizeVal) {
248
- const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] };
241
+ const count = this.tally(temp);
249
242
  if (typeof sizeVal === 'number') {
250
243
  return { $eq: [count, sizeVal] };
251
244
  }
@@ -295,7 +288,7 @@ export class MongoDialect extends AbstractDialect {
295
288
  mapTableNameRow(row) {
296
289
  return row.table_name;
297
290
  }
298
- /** String operators → { pattern: (v) => regex, caseInsensitive } */
291
+ /** String operators -> { pattern: (v) => regex, caseInsensitive } */
299
292
  static REGEX_OP_MAP = new Map([
300
293
  ['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
301
294
  ['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
@@ -340,7 +333,7 @@ export class MongoDialect extends AbstractDialect {
340
333
  result[op] = val;
341
334
  continue;
342
335
  }
343
- // String/pattern → regex operators (8 variants including $like/$ilike)
336
+ // String/pattern -> regex operators (8 variants including $like/$ilike)
344
337
  const regexEntry = MongoDialect.REGEX_OP_MAP.get(op);
345
338
  if (regexEntry) {
346
339
  result['$regex'] = regexEntry.wrap(val);
@@ -468,7 +461,7 @@ export class MongoDialect extends AbstractDialect {
468
461
  * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
469
462
  * orders by. A parent with no related row gets no lookup result at all, which is a zero.
470
463
  */
471
- sortCountStages(entity, sort, opts) {
464
+ sortCountStages(entity, sort) {
472
465
  const meta = getMeta(entity);
473
466
  const stages = [];
474
467
  const fields = [];
@@ -477,18 +470,78 @@ export class MongoDialect extends AbstractDialect {
477
470
  if (!relOpts || parseSortByCount(value) === undefined) {
478
471
  continue;
479
472
  }
480
- const relEntity = relOpts.entity();
481
- const relMeta = getMeta(relEntity);
482
473
  const temp = sortCountField(key);
483
- const targetScope = this.renderFilter(relEntity, this.scopedWhere(relMeta, {}), opts);
484
- const tail = [{ $count: COUNT_ALIAS }];
485
- stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts), {
486
- $addFields: { [temp]: { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] } },
487
- });
474
+ stages.push(this.tallyLookup(meta, relOpts, {}, temp), { $addFields: { [temp]: this.tally(temp) } });
488
475
  fields.push(temp);
489
476
  }
490
477
  return { stages, fields };
491
478
  }
479
+ /** The correlated lookup counting a relation's rows, which `where` narrows, into `temp`. */
480
+ tallyLookup(meta, relOpts, where, temp) {
481
+ return this.relationLookup(meta, relOpts, where, temp, [{ $count: COUNT_ALIAS }]);
482
+ }
483
+ /** The tally a lookup left in `temp`, which holds no row at all where nothing matched: a zero. */
484
+ tally(temp) {
485
+ return { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] };
486
+ }
487
+ /**
488
+ * The lookups reading each to-many a query populates, and the tally of each `$count`, onto the fields
489
+ * its rows answer under, and the fields they parked a junction's pairings or a tally on taken back out.
490
+ * [The design](../../../../architecture/relations-in-one-statement.md).
491
+ */
492
+ relationReadStages(entity, q) {
493
+ const meta = getMeta(entity);
494
+ const stages = [];
495
+ const temps = [];
496
+ for (const relKey of getRelationRequestSummary(meta, q.$populate).toManyKeys) {
497
+ stages.push(...this.toManyLookup(meta, relKey, parseRelationAtKey(relKey, q.$populate).query, temps));
498
+ }
499
+ for (const { relKey, relation, where } of countedRelations(meta, q.$count)) {
500
+ const temp = `${REL_TEMP_PREFIX}count_${relKey}`;
501
+ temps.push(temp);
502
+ stages.push(this.tallyLookup(meta, relation, where, temp), {
503
+ $addFields: { [`${COUNT_RESULT_KEY}.${relKey}`]: this.tally(temp) },
504
+ });
505
+ }
506
+ return temps.length ? [...stages, { $unset: temps }] : stages;
507
+ }
508
+ /**
509
+ * A to-many's rows as a lookup running their own read, whose filters, ordering and page apply per
510
+ * parent inside it. A many-to-many reads its targets, each once, by the ids its junction pairs the
511
+ * parent with, parked in a temporary field of the parent's.
512
+ */
513
+ toManyLookup(meta, relKey, query, temps) {
514
+ const relOpts = relationOf(meta, relKey);
515
+ const relEntity = relOpts.entity();
516
+ const relMeta = getMeta(relEntity);
517
+ const read = this.aggregationPipeline(relEntity, query);
518
+ const pipeline = read.length ? { pipeline: read } : {};
519
+ const from = this.resolveTableName(relMeta);
520
+ if (!relOpts.through) {
521
+ return [{ $lookup: { from, ...this.joinKeys(meta, relMeta, relOpts), ...pipeline, as: relKey } }];
522
+ }
523
+ const junction = this.junctionOf(meta, relOpts, relMeta, relOpts.through());
524
+ const temp = `${REL_TEMP_PREFIX}${relKey}`;
525
+ temps.push(temp);
526
+ return [
527
+ {
528
+ $lookup: {
529
+ ...junction.lookup,
530
+ pipeline: [...junction.scope, { $project: { [junction.target]: 1 } }],
531
+ as: temp,
532
+ },
533
+ },
534
+ {
535
+ $lookup: {
536
+ from,
537
+ localField: `${temp}.${junction.target}`,
538
+ foreignField: MongoDialect.ID_KEY,
539
+ ...pipeline,
540
+ as: relKey,
541
+ },
542
+ },
543
+ ];
544
+ }
492
545
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
493
546
  sortsRelations(entity, sort) {
494
547
  if (!sort) {
@@ -498,7 +551,7 @@ export class MongoDialect extends AbstractDialect {
498
551
  return someKey(sort, (key) => Boolean(meta.relations[key]));
499
552
  }
500
553
  /**
501
- * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
554
+ * Aggregate results are keyed by `$group`/`$select` alias rather than by column, so an aggregate
502
555
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
503
556
  */
504
557
  aliasSort(sort) {
@@ -527,7 +580,7 @@ export class MongoDialect extends AbstractDialect {
527
580
  ...stages,
528
581
  ...(hasKeys(filter) ? [{ $match: filter }] : []),
529
582
  ...(unset.length ? [{ $unset: unset }] : []),
530
- ...this.readStages(entity, q, opts, {
583
+ ...this.readStages(entity, q, {
531
584
  sort: this.sort(entity, q.$sort, q.$populate),
532
585
  pager: [
533
586
  ...(q.$skip === undefined ? [] : [{ $skip: assertNonNegativeInteger(q.$skip, '$skip') }]),
@@ -544,13 +597,15 @@ export class MongoDialect extends AbstractDialect {
544
597
  * Shared by the plain pipeline and the `$vectorSearch` one, which each used to spell the order out
545
598
  * for themselves and each got a different part of it wrong.
546
599
  */
547
- readStages(entity, q, opts, extra = {}) {
600
+ readStages(entity, q, extra = {}) {
548
601
  const meta = getMeta(entity);
549
602
  const joins = resolveQueryJoins(meta, q);
550
603
  // The tally an ordering by a relation's size reads, and the field it parks it on: both belong
551
604
  // with the lookups, since the `$sort` right after them is what they exist for.
552
- const counted = this.sortCountStages(entity, q.$sort, opts);
553
- const lookups = [...this.lookupStages(meta, joins, undefined, opts), ...counted.stages];
605
+ const counted = this.sortCountStages(entity, q.$sort);
606
+ const lookups = [...this.lookupStages(meta, joins), ...counted.stages];
607
+ // Each to-many and each `$count`, which neither drop nor reorder a row, so they read the page alone.
608
+ const related = this.relationReadStages(entity, q);
554
609
  const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
555
610
  const pager = extra.pager ?? [];
556
611
  // Merged into the query's own projection rather than standing in for one: a query that asked
@@ -578,7 +633,7 @@ export class MongoDialect extends AbstractDialect {
578
633
  // ordering and the page have to run after it to address the set the caller actually receives.
579
634
  const dedup = q.$distinct ? this.distinctStages(projected) : [];
580
635
  if (dedup.length) {
581
- return [...lookups, ...project, ...dedup, ...sort, ...pager];
636
+ return [...lookups, ...related, ...project, ...dedup, ...sort, ...pager];
582
637
  }
583
638
  // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
584
639
  // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
@@ -587,6 +642,7 @@ export class MongoDialect extends AbstractDialect {
587
642
  lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
588
643
  return [
589
644
  ...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
645
+ ...related,
590
646
  ...unset,
591
647
  ...project,
592
648
  ];
@@ -610,42 +666,28 @@ export class MongoDialect extends AbstractDialect {
610
666
  }
611
667
  /**
612
668
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
613
- * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
614
- * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
615
- * empty, and is why the pipeline emitted no projection at all and returned every column.
669
+ * each populated relation and the tallies. It goes last, after the lookups have read the join keys -
670
+ * projecting any earlier is what used to leave `$populate` empty, and is why the pipeline emitted no
671
+ * projection at all and returned every column.
616
672
  */
617
673
  pipelineProjection(entity, q) {
618
674
  if (!q.$select && !q.$exclude) {
619
675
  return undefined;
620
676
  }
621
677
  const projection = this.select(entity, q.$select, q.$exclude);
622
- const summary = getRelationRequestSummary(getMeta(entity), q.$populate);
623
- for (const relKey of summary.joinableKeys) {
678
+ for (const relKey of getRelationRequestSummary(getMeta(entity), q.$populate).requestedKeys) {
624
679
  projection[relKey] = 1;
625
680
  }
626
- // Only ever undoes an exclusion: a relation cannot be filled onto a parent with no key.
627
- if (summary.requestedKeys.length && projection[MongoDialect.ID_KEY] === 0) {
628
- delete projection[MongoDialect.ID_KEY];
681
+ if (q.$count) {
682
+ projection[COUNT_RESULT_KEY] = 1;
629
683
  }
630
684
  return projection;
631
685
  }
632
- /**
633
- * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
634
- * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
635
- */
636
- relationStages(entity, q, opts) {
637
- // The whole query, not `$populate` alone: an ordering by a related field needs that relation
638
- // looked up just as much as selecting it does. A `$lookup` does put a field on the document
639
- // where a SQL join is invisible, so the ones only the ordering asked for are unset again by
640
- // {@link readStages} before the caller sees the row.
641
- const meta = getMeta(entity);
642
- return this.lookupStages(meta, resolveQueryJoins(meta, q), undefined, opts);
643
- }
644
686
  /**
645
687
  * The `$lookup`/`$unwind` pair for each relation joined below `parent`, its own relations nested
646
688
  * inside its pipeline and resolved before the projection that reads them.
647
689
  */
648
- lookupStages(parentMeta, joins, parent, opts) {
690
+ lookupStages(parentMeta, joins, parent) {
649
691
  const pipeline = [];
650
692
  // Every join at this level hangs off `parent`, so its metadata is `parentMeta` - no branch, and
651
693
  // no union of two unrelated entity types to resolve the join column through.
@@ -654,19 +696,19 @@ export class MongoDialect extends AbstractDialect {
654
696
  continue;
655
697
  }
656
698
  // Unconditional, not gated by an explicit relation-level `$where`: the related entity's own
657
- // filters (in particular `security: true` ones) must apply even to a bare
658
- // `$populate: { rel: true }`, exactly like the SQL dialects' JOIN ON-clause filters.
659
- const relationFilter = this.where(join.entity, join.query.$where ?? {}, opts);
699
+ // filters (in particular `security: true` ones) apply even to a bare `$populate: { rel: true }`,
700
+ // and the caller's bypass never reaches them, exactly like the SQL dialects' JOIN ON-clause filters.
701
+ const relationFilter = this.where(join.entity, join.query.$where ?? {});
660
702
  // The relation's own projection runs inside the lookup, where its keys resolve against the
661
703
  // related entity. Left out, `$populate: { rel: { $select } }` returned all of `rel`'s columns.
662
704
  const relationProjection = this.pipelineProjection(join.entity, join.query);
663
705
  // MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
664
- // how a joined document keeps its own id - as it does on the SQL dialects, and as a nested
665
- // to-many fill needs.
706
+ // how a joined document keeps its own id, as it does on the SQL dialects.
666
707
  delete relationProjection?.[MongoDialect.ID_KEY];
667
708
  const lookupPipeline = [
668
709
  ...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
669
- ...this.lookupStages(join.meta, joins, join, opts),
710
+ ...this.lookupStages(join.meta, joins, join),
711
+ ...this.relationReadStages(join.entity, join.query),
670
712
  ...(relationProjection ? [{ $project: relationProjection }] : []),
671
713
  ];
672
714
  pipeline.push({
@@ -916,7 +958,7 @@ export class MongoDialect extends AbstractDialect {
916
958
  }
917
959
  }
918
960
  // $group stage
919
- const { groupId, groupAccumulators, distinctReducers } = this.buildGroupSpec(getMeta(entity), parseGroupMap(q.$group, q.$agg));
961
+ const { groupId, groupAccumulators, distinctReducers } = this.buildGroupSpec(getMeta(entity), parseGroupMap(q.$group, q.$select));
920
962
  pipeline.push({ $group: { _id: hasKeys(groupId) ? groupId : null, ...groupAccumulators } });
921
963
  // Project stage - rename _id fields back to their original names, and reduce collected distinct
922
964
  // sets. Needed whenever there are group keys OR any distinct alias.
@@ -966,7 +1008,7 @@ export class MongoDialect extends AbstractDialect {
966
1008
  */
967
1009
  buildGroupSpec(meta, groupEntries) {
968
1010
  if (!groupEntries.length) {
969
- throw new TypeError('aggregate requires at least one $group column or $agg function');
1011
+ throw new TypeError('aggregate requires at least one $group column or $select function');
970
1012
  }
971
1013
  const groupId = {};
972
1014
  const groupAccumulators = {};
@@ -1,7 +1,6 @@
1
1
  import type { Document, MongoClient } from 'mongodb';
2
2
  import { AbstractQuerier } from '../querier/index.js';
3
3
  import type { EntityData, ExtraOptions, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
4
- import { type ParentPartition } from '../util/index.js';
5
4
  import type { MongoDialect } from './mongoDialect.js';
6
5
  export declare class MongodbQuerier extends AbstractQuerier {
7
6
  readonly dialect: MongoDialect;
@@ -11,27 +10,21 @@ export declare class MongodbQuerier extends AbstractQuerier {
11
10
  constructor(dialect: MongoDialect, conn: MongoClient, extra?: ExtraOptions | undefined);
12
11
  private execute;
13
12
  protected internalFindMany<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
13
+ protected internalFindManyStream<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncGenerator<E, void, unknown>;
14
14
  /**
15
- * Every parent's own bounded page. One `$unionWith` per parent after the first, so the whole page is
16
- * one round trip - measured ~6x faster than a query each (11.0 ms -> 1.9 ms at 50 parents, 87.5 ms
17
- * -> 14.1 ms at 500), because `execute` serializes on the session and a query each is N round trips
18
- * rather than N concurrent ones.
19
- *
20
- * Both arms return documents with their own relations already filled, so this only chooses between
21
- * them: leaving that to the caller once meant the arm that fills its own did it twice.
22
- * [The design](../../../../architecture/populate-limits.md).
15
+ * The cursor a read runs on: the aggregation pipeline for a clause only a stage can express - a
16
+ * lookup, a grouping, a vector search - and the plain `find` cursor for everything else. One routing
17
+ * for a read and a stream alike, so both load the same relations.
23
18
  */
24
- protected internalFindManyPerParent<E extends Document>(entity: Type<E>, q: Query<E>, { joins, parents }: ParentPartition): Promise<E[]>;
25
- /** Every parent's page as one `$unionWith` pipeline. */
26
- private readInOnePipeline;
27
- /** A query each, for what one pipeline cannot carry. `internalFindMany` fills its own relations. */
28
- private readEachInTurn;
29
- protected internalFindManyStream<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncGenerator<E, void, unknown>;
19
+ private readCursor;
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.
23
+ */
24
+ private readsThroughPipeline;
30
25
  private buildScalarProjection;
31
26
  /** Build a MongoDB FindCursor with filter, projection, sort, skip, and limit from the query. */
32
27
  private buildFindCursor;
33
- /** Execute an aggregation pipeline and normalize `_id` → `id`. */
34
- private runPipeline;
35
28
  /**
36
29
  * Build an aggregation pipeline for vector similarity search.
37
30
  * `$vectorSearch` is always the first stage; `$where` is merged into its `filter`.