uql-orm 0.26.3 → 0.28.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 (41) hide show
  1. package/README.md +4 -4
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/cockroachdb/cockroachDialect.d.ts +5 -0
  5. package/dist/cockroachdb/cockroachDialect.js +5 -0
  6. package/dist/dialect/abstractSqlDialect.d.ts +81 -40
  7. package/dist/dialect/abstractSqlDialect.js +317 -305
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -1
  9. package/dist/dialect/mysqlLikeSqlDialect.js +4 -3
  10. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  11. package/dist/dialect/pgLikeSqlDialect.js +1 -3
  12. package/dist/dialect/queryContext.d.ts +7 -5
  13. package/dist/dialect/queryContext.js +11 -6
  14. package/dist/dialect/queryJoins.d.ts +44 -0
  15. package/dist/dialect/queryJoins.js +73 -0
  16. package/dist/dialect/vectorSqlDialect.d.ts +1 -1
  17. package/dist/http/query.js +10 -0
  18. package/dist/maria/mariaDialect.d.ts +2 -0
  19. package/dist/maria/mariaDialect.js +2 -0
  20. package/dist/mongo/mongoDialect.d.ts +14 -2
  21. package/dist/mongo/mongoDialect.js +56 -18
  22. package/dist/mongo/mongodbQuerier.js +6 -2
  23. package/dist/querier/abstractSqlQuerier.d.ts +11 -0
  24. package/dist/querier/abstractSqlQuerier.js +21 -0
  25. package/dist/sqlite/sqliteDialect.d.ts +3 -1
  26. package/dist/sqlite/sqliteDialect.js +5 -3
  27. package/dist/type/dialect.d.ts +11 -3
  28. package/dist/type/entity.d.ts +4 -4
  29. package/dist/type/index.d.ts +1 -0
  30. package/dist/type/index.js +1 -0
  31. package/dist/type/query.d.ts +15 -4
  32. package/dist/type/queryLock.d.ts +21 -0
  33. package/dist/type/queryLock.js +19 -0
  34. package/dist/type/utility.d.ts +9 -5
  35. package/dist/util/dialect.util.d.ts +1 -2
  36. package/dist/util/dialect.util.js +0 -3
  37. package/dist/util/relationQuery.util.d.ts +15 -3
  38. package/dist/util/relationQuery.util.js +37 -2
  39. package/dist/util/sql.util.d.ts +1 -2
  40. package/dist/util/sql.util.js +1 -12
  41. package/package.json +4 -4
@@ -55,9 +55,12 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
55
55
  */
56
56
  protected indexKeyword(index: IndexSchema): string;
57
57
  protected readonly indexFeatures: Set<IndexFeature>;
58
+ /**
59
+ * No `FOR NO KEY UPDATE`/`FOR KEY SHARE`: those are PostgreSQL's weaker pair and the family has
60
+ * no equivalent, so asking for one is rejected rather than served a stronger lock.
61
+ */
58
62
  protected indexAccessMethod(index: IndexSchema): string;
59
63
  protected numericCast(expr: string): string;
60
- protected ilikeExpr(f: string, ph: string): string;
61
64
  protected neExpr(field: string, ph: string): string;
62
65
  /** How a surviving element is fed back into the array a `$pull` rebuilds. */
63
66
  protected jsonPullElem(alias: string): string;
@@ -102,15 +102,16 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
102
102
  return index.type === 'fulltext' ? 'FULLTEXT INDEX' : super.indexKeyword(index);
103
103
  }
104
104
  indexFeatures = new Set(['expression', 'prefixLength']);
105
+ /**
106
+ * No `FOR NO KEY UPDATE`/`FOR KEY SHARE`: those are PostgreSQL's weaker pair and the family has
107
+ * no equivalent, so asking for one is rejected rather than served a stronger lock.
108
+ */
105
109
  indexAccessMethod(index) {
106
110
  return index.type && index.type !== 'fulltext' ? ` USING ${index.type}` : '';
107
111
  }
108
112
  numericCast(expr) {
109
113
  return `CAST(${expr} AS DECIMAL)`;
110
114
  }
111
- ilikeExpr(f, ph) {
112
- return `${f} LIKE ${ph}`;
113
- }
114
115
  neExpr(field, ph) {
115
116
  // MySQL/MariaDB null-safe inequality: true when values differ or one side is NULL.
116
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,44 @@
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
+ readonly path: string;
10
+ readonly entity: Type<object>;
11
+ readonly meta: EntityMeta<object>;
12
+ readonly relation: RelationMeta;
13
+ readonly query: RelationQuery;
14
+ readonly required: boolean;
15
+ readonly projected: boolean;
16
+ /** `undefined` at the first level, where the parent is the queried entity itself. */
17
+ readonly parent: QueryJoin | undefined;
18
+ };
19
+ /**
20
+ * Every relation a statement joins, in the order the joins are emitted. Flat rather than a tree: a
21
+ * parent is always resolved before its children, so iterating it in order visits them the same way
22
+ * recursion would, and looking an alias up - which is what `$sort` needs - is a plain `get`.
23
+ */
24
+ export type QueryJoins = ReadonlyMap<string, QueryJoin>;
25
+ export declare const NO_JOINS: QueryJoins;
26
+ /** What rendering an `ORDER BY` needs beyond the map itself: where columns live, and what is joined. */
27
+ export type QuerySortOptions = {
28
+ /** Alias the queried entity's own columns are qualified by, when the statement qualifies them. */
29
+ readonly prefix?: string;
30
+ readonly joins?: QueryJoins;
31
+ readonly distinct?: boolean;
32
+ };
33
+ /**
34
+ * What the statement joins, from the whole query rather than from `$populate` alone: ordering by a
35
+ * related column needs that relation joined just as much as selecting it does. The two sources meet
36
+ * here, so the columns, the `ORDER BY` and the row lock cannot disagree about what is in the
37
+ * statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
38
+ */
39
+ export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
40
+ /**
41
+ * A nested `$sort` map, as opposed to a direction or a vector search. Shared with the `ORDER BY`
42
+ * renderer so what counts as a relation sort is decided once, not once per side.
43
+ */
44
+ export declare function isSortMap(value: unknown): value is QuerySortMap<object>;
@@ -0,0 +1,73 @@
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
+ path,
30
+ entity,
31
+ meta: getMeta(entity),
32
+ relation,
33
+ query,
34
+ required,
35
+ projected,
36
+ parent,
37
+ };
38
+ joins.set(path, join);
39
+ return join;
40
+ }
41
+ function addPopulateJoins(joins, meta, populate, parent) {
42
+ for (const key of getRelationRequestSummary(meta, populate).joinableKeys) {
43
+ const relation = meta.relations[key];
44
+ if (!relation)
45
+ continue;
46
+ const { query, required } = parseRelationAtKey(key, populate);
47
+ const join = addJoin(joins, parent, key, relation, query, required, true);
48
+ addPopulateJoins(joins, join.meta, query.$populate, join);
49
+ }
50
+ }
51
+ function addSortJoins(joins, meta, sort, parent) {
52
+ if (!sort) {
53
+ return;
54
+ }
55
+ for (const key of getKeys(sort)) {
56
+ const relation = meta.relations[key];
57
+ const value = sort[key];
58
+ // A to-many, or a value that is not a map of the relation's own fields, cannot be joined and is
59
+ // reported where the `ORDER BY` is rendered - the one place that knows how to name it.
60
+ if (!relation || isToManyRelation(relation) || !isSortMap(value)) {
61
+ continue;
62
+ }
63
+ const join = addJoin(joins, parent, key, relation, {}, false, false);
64
+ addSortJoins(joins, join.meta, value, join);
65
+ }
66
+ }
67
+ /**
68
+ * A nested `$sort` map, as opposed to a direction or a vector search. Shared with the `ORDER BY`
69
+ * renderer so what counts as a relation sort is decided once, not once per side.
70
+ */
71
+ export function isSortMap(value) {
72
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !('$vector' in value);
73
+ }
@@ -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`.
@@ -14,6 +14,13 @@ const JSON_QUERY_KEYS = [
14
14
  * every entry to a real query/option key, so a typo or a renamed option fails to compile.
15
15
  */
16
16
  const ALLOWED_QUERY_KEYS = new Set([...JSON_QUERY_KEYS, '$skip', '$limit', 'hardDelete', 'count']);
17
+ /**
18
+ * Keys that mean something locally but that this transport can never honor, so they are rejected
19
+ * rather than dropped like the rest. Each request runs on its own auto-committing connection, so a
20
+ * row lock taken here is released before the response is written: honoring `$lock` is impossible,
21
+ * and ignoring it would hand the caller a read they believe is serialized and is not.
22
+ */
23
+ const REJECTED_QUERY_KEYS = new Set(['$lock']);
17
24
  /**
18
25
  * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
19
26
  * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
@@ -21,6 +28,9 @@ const ALLOWED_QUERY_KEYS = new Set([...JSON_QUERY_KEYS, '$skip', '$limit', 'hard
21
28
  export function parseQueryParams(params = {}) {
22
29
  const query = {};
23
30
  for (const key of getKeys(params)) {
31
+ if (REJECTED_QUERY_KEYS.has(key)) {
32
+ throw Object.assign(new TypeError(`'${key}' is not supported over HTTP`), { status: 400 });
33
+ }
24
34
  if (ALLOWED_QUERY_KEYS.has(key)) {
25
35
  query[key] = params[key];
26
36
  }
@@ -9,6 +9,8 @@ export declare class MariaDialect extends MysqlLikeSqlDialect {
9
9
  * family shares and drops expressions.
10
10
  */
11
11
  protected readonly indexFeatures: Set<IndexFeature>;
12
+ /** MariaDB has no `FOR ... OF`, so a lock cannot be narrowed to one table of a join. */
13
+ readonly supportsLockOf = false;
12
14
  /** Unlike MySQL: `VECTOR(n)` takes its dimension, and its vector index is declared inline. */
13
15
  protected readonly featureOverrides: Partial<DialectFeatures>;
14
16
  /** MariaDB 10.5+ supports `INSERT ... RETURNING`, so the ids are exact per row. */
@@ -11,6 +11,8 @@ export class MariaDialect extends MysqlLikeSqlDialect {
11
11
  * family shares and drops expressions.
12
12
  */
13
13
  indexFeatures = new Set(['prefixLength']);
14
+ /** MariaDB has no `FOR ... OF`, so a lock cannot be narrowed to one table of a join. */
15
+ supportsLockOf = false;
14
16
  /** Unlike MySQL: `VECTOR(n)` takes its dimension, and its vector index is declared inline. */
15
17
  featureOverrides = {
16
18
  vectorSupportsLength: true,
@@ -64,6 +64,12 @@ export declare class MongoDialect extends AbstractDialect {
64
64
  private compareRelationCount;
65
65
  /** Whether a query subtracts `key` from the projection, via `$exclude` or a negative `$select`. */
66
66
  private subtractsKey;
67
+ /**
68
+ * MongoDB has no row-level lock to map `$lock` onto: its concurrency control is the transaction
69
+ * plus atomic document updates. Rejected rather than ignored, like `raw()` below, since a dropped
70
+ * lock silently removes the mutual exclusion the caller asked for.
71
+ */
72
+ assertNoLock<E>(q: Query<E>): void;
67
73
  /** `raw()` renders SQL, so it has no MongoDB equivalent - say so instead of emitting `{}`. */
68
74
  private assertNoRaw;
69
75
  /**
@@ -89,14 +95,20 @@ export declare class MongoDialect extends AbstractDialect {
89
95
  */
90
96
  private transformElemMatch;
91
97
  select<E extends Document>(entity: Type<E>, select?: QuerySelectValue<E>, exclude?: QueryExclude<E>): Record<string, 0 | 1>;
98
+ /**
99
+ * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
100
+ * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
101
+ * means a *populated* to-one: a lookup adds a field to the result, so one added for the sort alone
102
+ * would change what the caller gets back, and MongoDB's lookups do not nest.
103
+ */
92
104
  sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>): Sort;
105
+ /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
106
+ sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
93
107
  /**
94
108
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
95
109
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
96
110
  */
97
111
  private aliasSort;
98
- /** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
99
- private sortBy;
100
112
  /**
101
113
  * {@link columnOf} for a possibly dotted key: only the root is a field key, the rest addresses an
102
114
  * embedded path (`kind.city` -> `<kind's column>.city`).
@@ -2,7 +2,7 @@ import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { getMeta } from '../entity/index.js';
4
4
  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';
5
+ import { asSelectMap, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorObject, isToManyRelation, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, } from '../util/index.js';
6
6
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
7
7
  export const mongoDialectFeatures = {
8
8
  explicitJsonCast: false,
@@ -221,6 +221,16 @@ export class MongoDialect extends AbstractDialect {
221
221
  const at = (map) => map?.[key];
222
222
  return at(exclude) === true || at(select) === false;
223
223
  }
224
+ /**
225
+ * MongoDB has no row-level lock to map `$lock` onto: its concurrency control is the transaction
226
+ * plus atomic document updates. Rejected rather than ignored, like `raw()` below, since a dropped
227
+ * lock silently removes the mutual exclusion the caller asked for.
228
+ */
229
+ assertNoLock(q) {
230
+ if (q.$lock !== undefined) {
231
+ throw new TypeError('$lock (row-level locking) is not supported on MongoDB');
232
+ }
233
+ }
224
234
  /** `raw()` renders SQL, so it has no MongoDB equivalent - say so instead of emitting `{}`. */
225
235
  assertNoRaw(value) {
226
236
  if (value instanceof QueryRaw) {
@@ -351,27 +361,47 @@ export class MongoDialect extends AbstractDialect {
351
361
  }
352
362
  return projection;
353
363
  }
364
+ /**
365
+ * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
366
+ * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
367
+ * means a *populated* to-one: a lookup adds a field to the result, so one added for the sort alone
368
+ * would change what the caller gets back, and MongoDB's lookups do not nest.
369
+ */
354
370
  sort(entity, sort) {
355
371
  const meta = getMeta(entity);
356
- return this.sortBy(sort, (key) => {
357
- if (meta.relations[key]) {
358
- throw new TypeError(`sorting by relation '${key}' is not supported on MongoDB`);
372
+ const normalized = {};
373
+ for (const [key, value] of Object.entries(sort ?? {})) {
374
+ const relation = meta.relations[key];
375
+ if (!relation) {
376
+ normalized[this.pathOf(meta, key)] = sortDirection(value);
377
+ continue;
378
+ }
379
+ if (isToManyRelation(relation)) {
380
+ throw new TypeError(`cannot $sort by '${key}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
359
381
  }
360
- return this.pathOf(meta, key);
361
- });
382
+ const relMeta = getMeta(relation.entity());
383
+ for (const [relKey, relValue] of Object.entries(value ?? {})) {
384
+ if (relMeta.relations[relKey]) {
385
+ throw new TypeError(`cannot $sort by '${key}.${relKey}' on MongoDB: its lookups reach one level, so a nested relation is not joined`);
386
+ }
387
+ normalized[`${key}.${this.pathOf(relMeta, relKey)}`] = sortDirection(relValue);
388
+ }
389
+ }
390
+ return normalized;
391
+ }
392
+ /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
393
+ sortsRelations(entity, sort) {
394
+ const meta = getMeta(entity);
395
+ return Object.keys(sort ?? {}).some((key) => Boolean(meta.relations[key]));
362
396
  }
363
397
  /**
364
398
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
365
399
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
366
400
  */
367
401
  aliasSort(sort) {
368
- return this.sortBy(sort, (alias) => alias);
369
- }
370
- /** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
371
- sortBy(sort, mapKey) {
372
402
  const normalized = {};
373
- for (const [key, dir] of Object.entries(buildSortMap(sort))) {
374
- normalized[mapKey(key)] = dir === 'desc' || dir === -1 ? -1 : 1;
403
+ for (const [alias, dir] of Object.entries(sort ?? {})) {
404
+ normalized[alias] = sortDirection(dir);
375
405
  }
376
406
  return normalized;
377
407
  }
@@ -389,11 +419,15 @@ export class MongoDialect extends AbstractDialect {
389
419
  aggregationPipeline(entity, q, relationSummary, opts) {
390
420
  const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
391
421
  const sort = this.sort(entity, q.$sort);
422
+ // Ordering by a related field reads what the lookups produced, so it cannot ride along with the
423
+ // `$match` the way an ordering by the parent's own columns does.
424
+ const sortsRelations = this.sortsRelations(entity, q.$sort);
425
+ const sortStage = hasKeys(sort) ? [{ $sort: sort }] : [];
392
426
  const match = {};
393
427
  if (hasKeys(filter)) {
394
428
  match.$match = filter;
395
429
  }
396
- if (hasKeys(sort)) {
430
+ if (!sortsRelations && sortStage.length) {
397
431
  match.$sort = sort;
398
432
  }
399
433
  // Lookups that a relation condition needs come first, then the match that reads them, then the
@@ -411,9 +445,10 @@ export class MongoDialect extends AbstractDialect {
411
445
  ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
412
446
  ];
413
447
  // A `$required` relation drops parents when it unwinds, so paging has to come after it - as it
414
- // does after an INNER JOIN. Otherwise paging first is equivalent and spares the lookups.
448
+ // does after an INNER JOIN. So does a sort that reads one, or a page would be cut from unordered
449
+ // rows. Otherwise paging first is equivalent and spares the lookups.
415
450
  const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
416
- pipeline.push(...(dropsParents ? [...relStages, ...pager] : [...pager, ...relStages]));
451
+ pipeline.push(...(dropsParents || sortsRelations ? [...relStages, ...sortStage, ...pager] : [...pager, ...relStages]));
417
452
  const projection = this.pipelineProjection(entity, q, relationSummary);
418
453
  if (projection) {
419
454
  pipeline.push({ $project: projection });
@@ -453,7 +488,7 @@ export class MongoDialect extends AbstractDialect {
453
488
  const relOpts = meta.relations[relKey];
454
489
  if (!relOpts)
455
490
  continue;
456
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
491
+ if (isToManyRelation(relOpts)) {
457
492
  // '1m' and 'mm' are resolved in a higher layer: they need a second query each.
458
493
  continue;
459
494
  }
@@ -758,11 +793,10 @@ export class MongoDialect extends AbstractDialect {
758
793
  extractVectorSort(sort) {
759
794
  if (!sort)
760
795
  return undefined;
761
- const raw = buildSortMap(sort);
762
796
  let vectorKey;
763
797
  let vectorSearch;
764
798
  const regularSort = {};
765
- for (const [key, value] of Object.entries(raw)) {
799
+ for (const [key, value] of Object.entries(sort)) {
766
800
  if (isVectorSearch(value)) {
767
801
  vectorKey = key;
768
802
  vectorSearch = value;
@@ -810,3 +844,7 @@ export class MongoDialect extends AbstractDialect {
810
844
  return { $vectorSearch: stage };
811
845
  }
812
846
  }
847
+ /** `-1` for the two descending spellings, `1` for everything else - MongoDB knows no other value. */
848
+ function sortDirection(value) {
849
+ return value === 'desc' || value === -1 ? -1 : 1;
850
+ }
@@ -18,6 +18,7 @@ export class MongodbQuerier extends AbstractQuerier {
18
18
  });
19
19
  }
20
20
  async internalFindMany(entity, q, opts) {
21
+ this.dialect.assertNoLock(q);
21
22
  return this.timed('internalFindMany', undefined, async () => {
22
23
  const meta = getMeta(entity);
23
24
  const vectorSort = this.dialect.extractVectorSort(q.$sort);
@@ -31,8 +32,11 @@ export class MongodbQuerier extends AbstractQuerier {
31
32
  else {
32
33
  const relationSummary = getRelationRequestSummary(meta, q.$populate);
33
34
  // A relation condition needs `$lookup`, so it forces the aggregation path just like populating
34
- // one does; a plain `find` cursor cannot express it.
35
- if (relationSummary.requestedKeys.length || this.dialect.constrainsRelations(entity, q.$where)) {
35
+ // one does - and so does ordering by a relation, which reads what a lookup produced. A plain
36
+ // `find` cursor can express none of the three.
37
+ if (relationSummary.requestedKeys.length ||
38
+ this.dialect.constrainsRelations(entity, q.$where) ||
39
+ this.dialect.sortsRelations(entity, q.$sort)) {
36
40
  const pipeline = this.dialect.aggregationPipeline(entity, q, relationSummary, opts);
37
41
  documents = await this.runPipeline(entity, meta, pipeline);
38
42
  await this.fillToManyRelations(entity, documents, q.$populate);
@@ -33,6 +33,17 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
33
33
  protected lazyConnect(): Promise<void>;
34
34
  all<T>(query: string, values?: unknown[]): Promise<T[]>;
35
35
  run(query: string, values?: unknown[]): Promise<QueryUpdateResult>;
36
+ /**
37
+ * `$lock` outside a transaction is always a bug, and a silent one. Every engine accepts
38
+ * `SELECT ... FOR UPDATE` in autocommit and then releases the lock as the statement commits,
39
+ * before the caller has seen a row: the SQL is correct, nothing is omitted, and no layer below
40
+ * this one can tell. The dialect cannot check it either, being stateless and shared by every
41
+ * connection of the pool, so this is the only place it can be caught.
42
+ *
43
+ * The capability check runs first on purpose: "this engine has no row locks" is the more
44
+ * actionable answer, and on SQLite it is the answer either way.
45
+ */
46
+ protected assertLockable<E>(entity: Type<E>, q: Query<E>): void;
36
47
  protected internalFindMany<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
37
48
  protected internalFindManyStream<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncGenerator<Awaited<E>, void, unknown>;
38
49
  /**
@@ -51,7 +51,27 @@ export class AbstractSqlQuerier extends AbstractQuerier {
51
51
  return this.timed(query, values, () => this.internalRun(query, this.dialect.normalizeValues(values)));
52
52
  });
53
53
  }
54
+ /**
55
+ * `$lock` outside a transaction is always a bug, and a silent one. Every engine accepts
56
+ * `SELECT ... FOR UPDATE` in autocommit and then releases the lock as the statement commits,
57
+ * before the caller has seen a row: the SQL is correct, nothing is omitted, and no layer below
58
+ * this one can tell. The dialect cannot check it either, being stateless and shared by every
59
+ * connection of the pool, so this is the only place it can be caught.
60
+ *
61
+ * The capability check runs first on purpose: "this engine has no row locks" is the more
62
+ * actionable answer, and on SQLite it is the answer either way.
63
+ */
64
+ assertLockable(entity, q) {
65
+ if (!q.$lock) {
66
+ return;
67
+ }
68
+ this.dialect.assertLockSupported(entity, q);
69
+ if (!this.hasOpenTransaction) {
70
+ throw new TypeError('$lock requires an open transaction');
71
+ }
72
+ }
54
73
  async internalFindMany(entity, q, opts) {
74
+ this.assertLockable(entity, q);
55
75
  const ctx = this.dialect.createContext();
56
76
  this.dialect.find(ctx, entity, q, opts);
57
77
  const res = await this.all(ctx.sql, ctx.values);
@@ -60,6 +80,7 @@ export class AbstractSqlQuerier extends AbstractQuerier {
60
80
  return founds;
61
81
  }
62
82
  async *internalFindManyStream(entity, q, opts) {
83
+ this.assertLockable(entity, q);
63
84
  const meta = getMeta(entity);
64
85
  const { toManyKeys } = getRelationRequestSummary(meta, q.$populate);
65
86
  if (toManyKeys.length) {
@@ -12,6 +12,8 @@ export declare class SqliteDialect extends AbstractSqlDialect {
12
12
  readonly rollbackTransactionCommand = "ROLLBACK";
13
13
  readonly isolationLevelStrategy = "none";
14
14
  readonly alterColumnSyntax = "none";
15
+ /** SQLite locks the whole database, not rows, so `$lock` has nothing to map onto. */
16
+ readonly supportsRowLocks = false;
15
17
  readonly booleanLiteral = "integer";
16
18
  readonly insertIdSource = "returning";
17
19
  /**
@@ -25,7 +27,7 @@ export declare class SqliteDialect extends AbstractSqlDialect {
25
27
  * when declared, else `NULL` (which is also how SQLite auto-generates INTEGER PRIMARY KEYs).
26
28
  */
27
29
  protected appendDefaultInsertValue(ctx: QueryContext, field: FieldOptions | undefined): void;
28
- protected ilikeExpr(f: string, ph: string): string;
30
+ protected readonly caseInsensitiveMatch = "native";
29
31
  protected get neOp(): string;
30
32
  normalizeValue(value: unknown): unknown;
31
33
  /**
@@ -26,6 +26,8 @@ export class SqliteDialect extends AbstractSqlDialect {
26
26
  rollbackTransactionCommand = 'ROLLBACK';
27
27
  isolationLevelStrategy = 'none';
28
28
  alterColumnSyntax = 'none';
29
+ /** SQLite locks the whole database, not rows, so `$lock` has nothing to map onto. */
30
+ supportsRowLocks = false;
29
31
  booleanLiteral = 'integer';
30
32
  // SQLite supports `RETURNING` (including on `INSERT ... ON CONFLICT`), so IDs are exact per row.
31
33
  insertIdSource = 'returning';
@@ -51,9 +53,9 @@ export class SqliteDialect extends AbstractSqlDialect {
51
53
  ctx.append('NULL');
52
54
  }
53
55
  }
54
- ilikeExpr(f, ph) {
55
- return `${f} LIKE ${ph}`;
56
- }
56
+ // SQLite's `LIKE` already ignores case on both sides, for ASCII - and only ASCII, with or without
57
+ // `NOCASE`, so folding the pattern here would break the accented text the engine leaves alone.
58
+ caseInsensitiveMatch = 'native';
57
59
  get neOp() {
58
60
  return 'IS NOT';
59
61
  }
@@ -7,9 +7,11 @@ import type { Type } from './utility.js';
7
7
  */
8
8
  export type QueryComparisonOptions = QueryOptions & {
9
9
  /**
10
- * use precedence for the comparison or not.
10
+ * Whether this fragment is rendered as an operand of an enclosing `AND`/`OR`/`NOT`. An operand
11
+ * parenthesizes itself when it emits more than one term, so no fragment ever depends on the
12
+ * engine's operator precedence. Only the `WHERE` clause as a whole is not an operand.
11
13
  */
12
- usePrecedence?: boolean;
14
+ operand?: boolean;
13
15
  };
14
16
  /**
15
17
  * query filter options.
@@ -31,6 +33,12 @@ export interface QueryContext {
31
33
  * alias would let the inner occurrence shadow the outer one it needs to correlate against.
32
34
  */
33
35
  nextAlias(prefix: string): string;
36
+ /**
37
+ * A context for a fragment of this same statement: it renders its own SQL in isolation while
38
+ * sharing the bound values and the generated aliases, so both stay unique and correctly numbered
39
+ * across the statement. See {@link AbstractSqlDialect.buildFragment}.
40
+ */
41
+ createFragment(): QueryContext;
34
42
  readonly sql: string;
35
43
  readonly values: unknown[];
36
44
  }
@@ -148,7 +156,7 @@ export interface QueryDialect {
148
156
  * @param forbidQualified don't escape dots
149
157
  * @param addDot use a dot as suffix
150
158
  */
151
- escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string;
159
+ escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
152
160
  /**
153
161
  * escape a value.
154
162
  * @param val the value to escape