uql-orm 0.27.0 → 0.28.1

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.
@@ -61,7 +61,6 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
61
61
  */
62
62
  protected indexAccessMethod(index: IndexSchema): string;
63
63
  protected numericCast(expr: string): string;
64
- protected ilikeExpr(f: string, ph: string): string;
65
64
  protected neExpr(field: string, ph: string): string;
66
65
  /** How a surviving element is fed back into the array a `$pull` rebuilds. */
67
66
  protected jsonPullElem(alias: string): string;
@@ -112,9 +112,6 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
112
112
  numericCast(expr) {
113
113
  return `CAST(${expr} AS DECIMAL)`;
114
114
  }
115
- ilikeExpr(f, ph) {
116
- return `${f} LIKE ${ph}`;
117
- }
118
115
  neExpr(field, ph) {
119
116
  // MySQL/MariaDB null-safe inequality: true when values differ or one side is NULL.
120
117
  return `NOT (${field} <=> ${ph})`;
@@ -63,7 +63,7 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
63
63
  protected jsonElemFrom(jsonField: string, fields: readonly string[], alias: string, asJson?: boolean): string;
64
64
  protected jsonElemRef(alias: string, field?: string, asJson?: boolean): string;
65
65
  protected get regexpOp(): string;
66
- protected ilikeExpr(f: string, ph: string): string;
66
+ protected readonly caseInsensitiveMatch = "ilike";
67
67
  protected get neOp(): string;
68
68
  protected formatIn(ctx: QueryContext, values: unknown[], negate: boolean): string;
69
69
  protected numericCast(expr: string): string;
@@ -155,9 +155,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
155
155
  get regexpOp() {
156
156
  return '~';
157
157
  }
158
- ilikeExpr(f, ph) {
159
- return `${f} ILIKE ${ph}`;
160
- }
158
+ caseInsensitiveMatch = 'ilike';
161
159
  get neOp() {
162
160
  return 'IS DISTINCT FROM';
163
161
  }
@@ -8,6 +8,7 @@ import type { QueryContext, QueryDialect } from '../type/index.js';
8
8
  */
9
9
  export declare class SqlQueryContext implements QueryContext {
10
10
  readonly dialect: QueryDialect;
11
+ private readonly statement?;
11
12
  private readonly sqlChunks;
12
13
  private readonly params;
13
14
  private aliasCounter;
@@ -17,8 +18,11 @@ export declare class SqlQueryContext implements QueryContext {
17
18
  * fragment context built via {@link AbstractSqlDialect.buildFragment}, so a bound value's
18
19
  * placeholder is numbered correctly against the real query from the moment it's added, rather
19
20
  * than needing to be reconciled after the fact.
21
+ * @param statement The context this one renders a fragment of, which owns the alias counter: a
22
+ * fragment is part of one statement, so its aliases have to be unique across the whole of it.
20
23
  */
21
- constructor(dialect: QueryDialect, params?: unknown[]);
24
+ constructor(dialect: QueryDialect, params?: unknown[], statement?: SqlQueryContext | undefined);
25
+ createFragment(): QueryContext;
22
26
  /**
23
27
  * Appends raw SQL string fragments to the query.
24
28
  *
@@ -43,10 +47,8 @@ export declare class SqlQueryContext implements QueryContext {
43
47
  */
44
48
  pushValue(...values: unknown[]): this;
45
49
  /**
46
- * A fresh alias unique within this context, e.g. `nextAlias('_uql_elem')` -> `'_uql_elem_1'`,
47
- * `'_uql_elem_2'`, ... A fragment context (see the constructor) counts independently of the `ctx`
48
- * it shares values with - fine today since no fragment-building hook also generates aliases, but
49
- * worth widening (share this counter too, the same way `params` is shared) if one ever does.
50
+ * A fresh alias unique within the statement being built, e.g. `nextAlias('_uql_elem')` ->
51
+ * `'_uql_elem_1'`, `'_uql_elem_2'`, ...
50
52
  */
51
53
  nextAlias(prefix: string): string;
52
54
  /**
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export class SqlQueryContext {
9
9
  dialect;
10
+ statement;
10
11
  sqlChunks = [];
11
12
  params;
12
13
  aliasCounter = 0;
@@ -16,11 +17,17 @@ export class SqlQueryContext {
16
17
  * fragment context built via {@link AbstractSqlDialect.buildFragment}, so a bound value's
17
18
  * placeholder is numbered correctly against the real query from the moment it's added, rather
18
19
  * than needing to be reconciled after the fact.
20
+ * @param statement The context this one renders a fragment of, which owns the alias counter: a
21
+ * fragment is part of one statement, so its aliases have to be unique across the whole of it.
19
22
  */
20
- constructor(dialect, params = []) {
23
+ constructor(dialect, params = [], statement) {
21
24
  this.dialect = dialect;
25
+ this.statement = statement;
22
26
  this.params = params;
23
27
  }
28
+ createFragment() {
29
+ return new SqlQueryContext(this.dialect, this.params, this.statement ?? this);
30
+ }
24
31
  /**
25
32
  * Appends raw SQL string fragments to the query.
26
33
  *
@@ -56,13 +63,11 @@ export class SqlQueryContext {
56
63
  return this;
57
64
  }
58
65
  /**
59
- * A fresh alias unique within this context, e.g. `nextAlias('_uql_elem')` -> `'_uql_elem_1'`,
60
- * `'_uql_elem_2'`, ... A fragment context (see the constructor) counts independently of the `ctx`
61
- * it shares values with - fine today since no fragment-building hook also generates aliases, but
62
- * worth widening (share this counter too, the same way `params` is shared) if one ever does.
66
+ * A fresh alias unique within the statement being built, e.g. `nextAlias('_uql_elem')` ->
67
+ * `'_uql_elem_1'`, `'_uql_elem_2'`, ...
63
68
  */
64
69
  nextAlias(prefix) {
65
- return `${prefix}_${++this.aliasCounter}`;
70
+ return this.statement ? this.statement.nextAlias(prefix) : `${prefix}_${++this.aliasCounter}`;
66
71
  }
67
72
  /**
68
73
  * Returns the complete SQL query string by joining all accumulated chunks.
@@ -0,0 +1,53 @@
1
+ import type { EntityMeta, Query, QuerySortMap, RelationMeta, Type } from '../type/index.js';
2
+ import { type RelationQuery } from '../util/index.js';
3
+ /**
4
+ * One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
5
+ * `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
6
+ * `$sort` needs - which joins the same way, filters included, but adds nothing to the result.
7
+ */
8
+ export type QueryJoin = {
9
+ /** The relation key on its parent, which is how MongoDB names the field a `$lookup` adds. */
10
+ readonly key: string;
11
+ /** Dotted path from the queried entity, which is how the SQL dialects alias the join. */
12
+ readonly path: string;
13
+ readonly entity: Type<object>;
14
+ readonly meta: EntityMeta<object>;
15
+ readonly relation: RelationMeta;
16
+ readonly query: RelationQuery;
17
+ readonly required: boolean;
18
+ readonly projected: boolean;
19
+ /** `undefined` at the first level, where the parent is the queried entity itself. */
20
+ readonly parent: QueryJoin | undefined;
21
+ };
22
+ /**
23
+ * Every relation a statement joins, in the order the joins are emitted. Flat rather than a tree: a
24
+ * parent is always resolved before its children, so iterating it in order visits them the same way
25
+ * recursion would, and looking an alias up - which is what `$sort` needs - is a plain `get`.
26
+ */
27
+ export type QueryJoins = ReadonlyMap<string, QueryJoin>;
28
+ export declare const NO_JOINS: QueryJoins;
29
+ /** What rendering an `ORDER BY` needs beyond the map itself: where columns live, and what is joined. */
30
+ export type QuerySortOptions = {
31
+ /** Alias the queried entity's own columns are qualified by, when the statement qualifies them. */
32
+ readonly prefix?: string;
33
+ readonly joins?: QueryJoins;
34
+ readonly distinct?: boolean;
35
+ };
36
+ /**
37
+ * What the statement joins, from the whole query rather than from `$populate` alone: ordering by a
38
+ * related column needs that relation joined just as much as selecting it does. The two sources meet
39
+ * here, so the columns, the `ORDER BY` and the row lock cannot disagree about what is in the
40
+ * statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
41
+ */
42
+ export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
43
+ /**
44
+ * The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
45
+ * Every backend answers this the same way - a to-many has no single value to order by, a relation
46
+ * sort is a map of that relation's fields, and the path has to be joined - so it is answered once
47
+ * here rather than per dialect, where the three checks had already drifted apart twice. Only the
48
+ * remedy for an unjoined path is the dialect's business, which is what `unjoinable` says.
49
+ */
50
+ export declare function resolveSortableJoin(relation: RelationMeta, path: string, value: unknown, joins: QueryJoins, unjoinable: string): {
51
+ readonly join: QueryJoin;
52
+ readonly sort: QuerySortMap<object>;
53
+ };
@@ -0,0 +1,91 @@
1
+ import { getMeta } from '../entity/index.js';
2
+ import { getKeys, getRelationRequestSummary, isToManyRelation, parseRelationAtKey, } from '../util/index.js';
3
+ export const NO_JOINS = new Map();
4
+ /**
5
+ * What the statement joins, from the whole query rather than from `$populate` alone: ordering by a
6
+ * related column needs that relation joined just as much as selecting it does. The two sources meet
7
+ * here, so the columns, the `ORDER BY` and the row lock cannot disagree about what is in the
8
+ * statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
9
+ */
10
+ export function resolveQueryJoins(meta, q) {
11
+ if (!q.$populate && !q.$sort) {
12
+ return NO_JOINS;
13
+ }
14
+ const joins = new Map();
15
+ addPopulateJoins(joins, meta, q.$populate);
16
+ addSortJoins(joins, meta, q.$sort);
17
+ return joins;
18
+ }
19
+ function addJoin(joins, parent, key, relation, query, required, projected) {
20
+ const path = parent ? `${parent.path}.${key}` : key;
21
+ const existing = joins.get(path);
22
+ // `$populate` runs first, so an already-joined relation keeps its columns and its `$required`
23
+ // INNER join: sorting by it asks for nothing a populated join does not already provide.
24
+ if (existing) {
25
+ return existing;
26
+ }
27
+ const entity = relation.entity();
28
+ const join = {
29
+ key,
30
+ path,
31
+ entity,
32
+ meta: getMeta(entity),
33
+ relation,
34
+ query,
35
+ required,
36
+ projected,
37
+ parent,
38
+ };
39
+ joins.set(path, join);
40
+ return join;
41
+ }
42
+ function addPopulateJoins(joins, meta, populate, parent) {
43
+ for (const key of getRelationRequestSummary(meta, populate).joinableKeys) {
44
+ const relation = meta.relations[key];
45
+ if (!relation)
46
+ continue;
47
+ const { query, required } = parseRelationAtKey(key, populate);
48
+ const join = addJoin(joins, parent, key, relation, query, required, true);
49
+ addPopulateJoins(joins, join.meta, query.$populate, join);
50
+ }
51
+ }
52
+ function addSortJoins(joins, meta, sort, parent) {
53
+ if (!sort) {
54
+ return;
55
+ }
56
+ for (const key of getKeys(sort)) {
57
+ const relation = meta.relations[key];
58
+ const value = sort[key];
59
+ // A to-many, or a value that is not a map of the relation's own fields, cannot be joined and is
60
+ // reported where the `ORDER BY` is rendered - the one place that knows how to name it.
61
+ if (!relation || isToManyRelation(relation) || !isSortMap(value)) {
62
+ continue;
63
+ }
64
+ const join = addJoin(joins, parent, key, relation, {}, false, false);
65
+ addSortJoins(joins, join.meta, value, join);
66
+ }
67
+ }
68
+ /**
69
+ * The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
70
+ * Every backend answers this the same way - a to-many has no single value to order by, a relation
71
+ * sort is a map of that relation's fields, and the path has to be joined - so it is answered once
72
+ * here rather than per dialect, where the three checks had already drifted apart twice. Only the
73
+ * remedy for an unjoined path is the dialect's business, which is what `unjoinable` says.
74
+ */
75
+ export function resolveSortableJoin(relation, path, value, joins, unjoinable) {
76
+ if (isToManyRelation(relation)) {
77
+ throw new TypeError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
78
+ }
79
+ if (!isSortMap(value)) {
80
+ throw new TypeError(`$sort by relation '${path}' expects a map of its fields, got ${String(value)}`);
81
+ }
82
+ const join = joins.get(path);
83
+ if (!join) {
84
+ throw new TypeError(unjoinable);
85
+ }
86
+ return { join, sort: value };
87
+ }
88
+ /** A nested `$sort` map, as opposed to a direction or a vector search. */
89
+ function isSortMap(value) {
90
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !('$vector' in value);
91
+ }
@@ -22,7 +22,7 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
22
22
  */
23
23
  protected readonly vectorDistanceFns: ReadonlyMap<VectorDistance, string>;
24
24
  /** Quotes an identifier; supplied by the SQL dialect built on top of this layer. */
25
- abstract escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string;
25
+ abstract escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
26
26
  /**
27
27
  * Resolve common parameters for a vector similarity ORDER BY expression.
28
28
  * Shared by all dialect overrides of `appendVectorSort`.
@@ -1,4 +1,4 @@
1
- import { getKeys, hasKeys, lowerFirst, normalizeIndexColumn, upperFirst } from '../../util/index.js';
1
+ import { getKeys, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst } from '../../util/index.js';
2
2
  import { ownRegistrations } from '../decorator/bag.js';
3
3
  // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
4
4
  // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
@@ -225,7 +225,7 @@ function fillOwningSide(at, meta, relKey, relOpts) {
225
225
  ];
226
226
  return;
227
227
  }
228
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
228
+ if (isToManyRelation(relOpts)) {
229
229
  throw new TypeError(`${at} is a to-many relation with no way to join: it needs 'mappedBy' (the field on the other side), ` +
230
230
  "'through' (a junction entity), or 'references' (the columns).");
231
231
  }
@@ -1,7 +1,15 @@
1
1
  import { type Document, type Filter, ObjectId, type Sort, type UpdateFilter } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import type { DialectFeatures, EntityMeta, FieldValue, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
- import { type CallbackKey, type RelationRequestSummary } from '../util/index.js';
3
+ import type { DialectFeatures, EntityMeta, FieldValue, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPopulate, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
+ import { type CallbackKey } from '../util/index.js';
5
+ /** What a read pipeline contributes to {@link MongoDialect.readStages} beyond the query itself. */
6
+ type MongoReadStages = {
7
+ /** Ordering, which runs after the lookups when it reads one of their fields. */
8
+ readonly sort?: Sort;
9
+ readonly pager?: MongoAggregationPipelineEntry<Document>[];
10
+ /** Keys merged into the query's projection, when it has one: a vector search's score. */
11
+ readonly project?: Record<string, 1>;
12
+ };
5
13
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
6
14
  export declare const mongoDialectFeatures: DialectFeatures;
7
15
  export declare class MongoDialect extends AbstractDialect {
@@ -95,32 +103,54 @@ export declare class MongoDialect extends AbstractDialect {
95
103
  */
96
104
  private transformElemMatch;
97
105
  select<E extends Document>(entity: Type<E>, select?: QuerySelectValue<E>, exclude?: QueryExclude<E>): Record<string, 0 | 1>;
98
- sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>): Sort;
106
+ /**
107
+ * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
108
+ * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
109
+ * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
110
+ * added for the sort alone would change what the caller gets back.
111
+ */
112
+ sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
113
+ /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
114
+ private collectSort;
115
+ /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
116
+ sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
99
117
  /**
100
118
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
101
119
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
102
120
  */
103
121
  private aliasSort;
104
- /** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
105
- private sortBy;
106
122
  /**
107
123
  * {@link columnOf} for a possibly dotted key: only the root is a field key, the rest addresses an
108
124
  * embedded path (`kind.city` -> `<kind's column>.city`).
109
125
  */
110
126
  private pathOf;
111
- aggregationPipeline<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
127
+ aggregationPipeline<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
128
+ /**
129
+ * What a read runs after its entry stage, in the one order that works: the lookups its relations
130
+ * need, the ordering and paging that may read them, and the projection last of all - it names the
131
+ * fields the lookups add, and no stage after it could read what it dropped.
132
+ *
133
+ * Shared by the plain pipeline and the `$vectorSearch` one, which each used to spell the order out
134
+ * for themselves and each got a different part of it wrong.
135
+ */
136
+ readStages<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions, extra?: MongoReadStages): MongoAggregationPipelineEntry<Document>[];
112
137
  /**
113
138
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
114
139
  * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
115
140
  * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
116
141
  * empty, and is why the pipeline emitted no projection at all and returned every column.
117
142
  */
118
- pipelineProjection<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>): Record<string, 0 | 1> | undefined;
143
+ pipelineProjection<E extends Document>(entity: Type<E>, q: Query<E>): Record<string, 0 | 1> | undefined;
119
144
  /**
120
145
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
121
146
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
122
147
  */
123
- relationStages<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
148
+ relationStages<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
149
+ /**
150
+ * The `$lookup`/`$unwind` pair for each relation joined below `parent`, its own relations nested
151
+ * inside its pipeline and resolved before the projection that reads them.
152
+ */
153
+ private lookupStages;
124
154
  /**
125
155
  * The correlated join for a single-valued or one-to-many relation. MongoDB runs a lookup's `pipeline`
126
156
  * after its own localField/foreignField match, so the target's filters layer on top of the join
@@ -1,8 +1,9 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
+ import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
3
4
  import { getMeta } from '../entity/index.js';
4
5
  import { QueryRaw } from '../type/queryRaw.js';
5
- import { asSelectMap, buildQueryWhereAsMap, buildSortMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, } from '../util/index.js';
6
+ import { asSelectMap, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, } from '../util/index.js';
6
7
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
7
8
  export const mongoDialectFeatures = {
8
9
  explicitJsonCast: false,
@@ -361,27 +362,49 @@ export class MongoDialect extends AbstractDialect {
361
362
  }
362
363
  return projection;
363
364
  }
364
- sort(entity, sort) {
365
+ /**
366
+ * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
367
+ * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
368
+ * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
369
+ * added for the sort alone would change what the caller gets back.
370
+ */
371
+ sort(entity, sort, populate) {
365
372
  const meta = getMeta(entity);
366
- return this.sortBy(sort, (key) => {
367
- if (meta.relations[key]) {
368
- throw new TypeError(`sorting by relation '${key}' is not supported on MongoDB`);
373
+ const normalized = {};
374
+ // The same join set the lookups are built from, so what an `ORDER BY` may address and what the
375
+ // pipeline actually produces cannot drift apart.
376
+ this.collectSort(meta, sort, resolveQueryJoins(meta, { $populate: populate }), '', normalized);
377
+ return normalized;
378
+ }
379
+ /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
380
+ collectSort(meta, sort, joins, path, out) {
381
+ for (const [key, value] of Object.entries(sort ?? {})) {
382
+ const relation = meta.relations[key];
383
+ if (!relation) {
384
+ out[path + this.pathOf(meta, key)] = sortDirection(value);
385
+ continue;
369
386
  }
370
- return this.pathOf(meta, key);
371
- });
387
+ // A `$lookup` is what puts the relation's fields on the document, and only `$populate` asks for
388
+ // one: ordering by a relation nothing looked up reads a field that is not there, which MongoDB
389
+ // ranks as all-equal rather than rejecting. The SQL dialects can add the join themselves.
390
+ const relPath = `${path}${key}`;
391
+ const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, joins, `cannot $sort by relation '${relPath}' on MongoDB unless it is populated: only $populate adds its fields to the document`);
392
+ this.collectSort(join.meta, relationSort, joins, `${relPath}.`, out);
393
+ }
394
+ }
395
+ /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
396
+ sortsRelations(entity, sort) {
397
+ const meta = getMeta(entity);
398
+ return Object.keys(sort ?? {}).some((key) => Boolean(meta.relations[key]));
372
399
  }
373
400
  /**
374
401
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
375
402
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
376
403
  */
377
404
  aliasSort(sort) {
378
- return this.sortBy(sort, (alias) => alias);
379
- }
380
- /** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
381
- sortBy(sort, mapKey) {
382
405
  const normalized = {};
383
- for (const [key, dir] of Object.entries(buildSortMap(sort))) {
384
- normalized[mapKey(key)] = dir === 'desc' || dir === -1 ? -1 : 1;
406
+ for (const [alias, dir] of Object.entries(sort ?? {})) {
407
+ normalized[alias] = sortDirection(dir);
385
408
  }
386
409
  return normalized;
387
410
  }
@@ -396,39 +419,47 @@ export class MongoDialect extends AbstractDialect {
396
419
  }
397
420
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
398
421
  }
399
- aggregationPipeline(entity, q, relationSummary, opts) {
400
- const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
401
- const sort = this.sort(entity, q.$sort);
402
- const match = {};
403
- if (hasKeys(filter)) {
404
- match.$match = filter;
405
- }
406
- if (hasKeys(sort)) {
407
- match.$sort = sort;
408
- }
422
+ aggregationPipeline(entity, q, opts) {
409
423
  // Lookups that a relation condition needs come first, then the match that reads them, then the
410
424
  // temporary fields are dropped so they never reach the caller.
411
- const pipeline = [...stages];
412
- if (hasKeys(match)) {
413
- pipeline.push(match);
414
- }
415
- if (unset.length) {
416
- pipeline.push({ $unset: unset });
417
- }
418
- const relStages = this.relationStages(entity, q, relationSummary, opts);
419
- const pager = [
420
- ...(q.$skip === undefined ? [] : [{ $skip: q.$skip }]),
421
- ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
425
+ const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
426
+ return [
427
+ ...stages,
428
+ ...(hasKeys(filter) ? [{ $match: filter }] : []),
429
+ ...(unset.length ? [{ $unset: unset }] : []),
430
+ ...this.readStages(entity, q, opts, {
431
+ sort: this.sort(entity, q.$sort, q.$populate),
432
+ pager: [
433
+ ...(q.$skip === undefined ? [] : [{ $skip: q.$skip }]),
434
+ ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
435
+ ],
436
+ }),
437
+ ];
438
+ }
439
+ /**
440
+ * What a read runs after its entry stage, in the one order that works: the lookups its relations
441
+ * need, the ordering and paging that may read them, and the projection last of all - it names the
442
+ * fields the lookups add, and no stage after it could read what it dropped.
443
+ *
444
+ * Shared by the plain pipeline and the `$vectorSearch` one, which each used to spell the order out
445
+ * for themselves and each got a different part of it wrong.
446
+ */
447
+ readStages(entity, q, opts, extra = {}) {
448
+ const lookups = this.relationStages(entity, q, opts);
449
+ const projection = this.pipelineProjection(entity, q);
450
+ const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
451
+ const pager = extra.pager ?? [];
452
+ // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
453
+ // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
454
+ // first is equivalent and spares the lookups the rows it cuts.
455
+ const lookupsFirst = this.sortsRelations(entity, q.$sort) ||
456
+ lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
457
+ return [
458
+ ...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
459
+ // Merged into the query's own projection rather than standing in for one: a query that asked
460
+ // for no columns wants the whole document, not just the field this adds to it.
461
+ ...(projection ? [{ $project: { ...projection, ...extra.project } }] : []),
422
462
  ];
423
- // A `$required` relation drops parents when it unwinds, so paging has to come after it - as it
424
- // does after an INNER JOIN. Otherwise paging first is equivalent and spares the lookups.
425
- const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
426
- pipeline.push(...(dropsParents ? [...relStages, ...pager] : [...pager, ...relStages]));
427
- const projection = this.pipelineProjection(entity, q, relationSummary);
428
- if (projection) {
429
- pipeline.push({ $project: projection });
430
- }
431
- return pipeline;
432
463
  }
433
464
  /**
434
465
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
@@ -436,12 +467,12 @@ export class MongoDialect extends AbstractDialect {
436
467
  * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
437
468
  * empty, and is why the pipeline emitted no projection at all and returned every column.
438
469
  */
439
- pipelineProjection(entity, q, relationSummary) {
470
+ pipelineProjection(entity, q) {
440
471
  if (!q.$select && !q.$exclude) {
441
472
  return undefined;
442
473
  }
443
474
  const projection = this.select(entity, q.$select, q.$exclude);
444
- const summary = relationSummary ?? getRelationRequestSummary(getMeta(entity), q.$populate);
475
+ const summary = getRelationRequestSummary(getMeta(entity), q.$populate);
445
476
  for (const relKey of summary.joinableKeys) {
446
477
  projection[relKey] = 1;
447
478
  }
@@ -455,46 +486,51 @@ export class MongoDialect extends AbstractDialect {
455
486
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
456
487
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
457
488
  */
458
- relationStages(entity, q, relationSummary, opts) {
489
+ relationStages(entity, q, opts) {
490
+ // Resolved from `$populate` alone, deliberately: on the SQL dialects a `$sort` can add a join of
491
+ // its own because a join is invisible in the result, while a `$lookup` puts a field on the
492
+ // document. Same join model, and this backend takes the part of it that it can carry.
459
493
  const meta = getMeta(entity);
494
+ return this.lookupStages(meta, resolveQueryJoins(meta, { $populate: q.$populate }), undefined, opts);
495
+ }
496
+ /**
497
+ * The `$lookup`/`$unwind` pair for each relation joined below `parent`, its own relations nested
498
+ * inside its pipeline and resolved before the projection that reads them.
499
+ */
500
+ lookupStages(parentMeta, joins, parent, opts) {
460
501
  const pipeline = [];
461
- const relKeys = (relationSummary ?? getRelationRequestSummary(meta, q.$populate)).joinableKeys;
462
- for (const relKey of relKeys) {
463
- const relOpts = meta.relations[relKey];
464
- if (!relOpts)
465
- continue;
466
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
467
- // '1m' and 'mm' are resolved in a higher layer: they need a second query each.
502
+ // Every join at this level hangs off `parent`, so its metadata is `parentMeta` - no branch, and
503
+ // no union of two unrelated entity types to resolve the join column through.
504
+ for (const join of joins.values()) {
505
+ if (join.parent !== parent) {
468
506
  continue;
469
507
  }
470
- const relEntity = relOpts.entity();
471
- const relMeta = getMeta(relEntity);
472
- const { query: relQuery, required } = parseRelationAtKey(relKey, q.$populate);
473
508
  // Unconditional, not gated by an explicit relation-level `$where`: the related entity's own
474
509
  // filters (in particular `security: true` ones) must apply even to a bare
475
510
  // `$populate: { rel: true }`, exactly like the SQL dialects' JOIN ON-clause filters.
476
- const relationFilter = this.where(relEntity, relQuery.$where ?? {}, opts);
511
+ const relationFilter = this.where(join.entity, join.query.$where ?? {}, opts);
477
512
  // The relation's own projection runs inside the lookup, where its keys resolve against the
478
513
  // related entity. Left out, `$populate: { rel: { $select } }` returned all of `rel`'s columns.
479
- const relationProjection = this.pipelineProjection(relEntity, relQuery);
514
+ const relationProjection = this.pipelineProjection(join.entity, join.query);
480
515
  // MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
481
516
  // how a joined document keeps its own id - as it does on the SQL dialects, and as a nested
482
517
  // to-many fill needs.
483
518
  delete relationProjection?.[MongoDialect.ID_KEY];
484
519
  const lookupPipeline = [
485
520
  ...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
521
+ ...this.lookupStages(join.meta, joins, join, opts),
486
522
  ...(relationProjection ? [{ $project: relationProjection }] : []),
487
523
  ];
488
524
  pipeline.push({
489
525
  $lookup: {
490
- from: this.resolveTableName(relEntity, relMeta),
491
- ...this.joinKeys(meta, relMeta, relOpts),
526
+ from: this.resolveTableName(join.entity, join.meta),
527
+ ...this.joinKeys(parentMeta, join.meta, join.relation),
492
528
  ...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
493
- as: relKey,
529
+ as: join.key,
494
530
  },
495
531
  });
496
532
  // `$required` drops parents with no match, the aggregation equivalent of an INNER JOIN.
497
- pipeline.push({ $unwind: { path: `$${relKey}`, preserveNullAndEmptyArrays: !required } });
533
+ pipeline.push({ $unwind: { path: `$${join.key}`, preserveNullAndEmptyArrays: !join.required } });
498
534
  }
499
535
  return pipeline;
500
536
  }
@@ -768,11 +804,10 @@ export class MongoDialect extends AbstractDialect {
768
804
  extractVectorSort(sort) {
769
805
  if (!sort)
770
806
  return undefined;
771
- const raw = buildSortMap(sort);
772
807
  let vectorKey;
773
808
  let vectorSearch;
774
809
  const regularSort = {};
775
- for (const [key, value] of Object.entries(raw)) {
810
+ for (const [key, value] of Object.entries(sort)) {
776
811
  if (isVectorSearch(value)) {
777
812
  vectorKey = key;
778
813
  vectorSearch = value;
@@ -820,3 +855,7 @@ export class MongoDialect extends AbstractDialect {
820
855
  return { $vectorSearch: stage };
821
856
  }
822
857
  }
858
+ /** `-1` for the two descending spellings, `1` for everything else - MongoDB knows no other value. */
859
+ function sortDirection(value) {
860
+ return value === 'desc' || value === -1 ? -1 : 1;
861
+ }