uql-orm 0.27.0 → 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.
@@ -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,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`.
@@ -95,14 +95,20 @@ export declare class MongoDialect extends AbstractDialect {
95
95
  */
96
96
  private transformElemMatch;
97
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
+ */
98
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;
99
107
  /**
100
108
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
101
109
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
102
110
  */
103
111
  private aliasSort;
104
- /** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
105
- private sortBy;
106
112
  /**
107
113
  * {@link columnOf} for a possibly dotted key: only the root is a field key, the rest addresses an
108
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,
@@ -361,27 +361,47 @@ export class MongoDialect extends AbstractDialect {
361
361
  }
362
362
  return projection;
363
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
+ */
364
370
  sort(entity, sort) {
365
371
  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`);
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.`);
381
+ }
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);
369
388
  }
370
- return this.pathOf(meta, key);
371
- });
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]));
372
396
  }
373
397
  /**
374
398
  * Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
375
399
  * `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
376
400
  */
377
401
  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
402
  const normalized = {};
383
- for (const [key, dir] of Object.entries(buildSortMap(sort))) {
384
- normalized[mapKey(key)] = dir === 'desc' || dir === -1 ? -1 : 1;
403
+ for (const [alias, dir] of Object.entries(sort ?? {})) {
404
+ normalized[alias] = sortDirection(dir);
385
405
  }
386
406
  return normalized;
387
407
  }
@@ -399,11 +419,15 @@ export class MongoDialect extends AbstractDialect {
399
419
  aggregationPipeline(entity, q, relationSummary, opts) {
400
420
  const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
401
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 }] : [];
402
426
  const match = {};
403
427
  if (hasKeys(filter)) {
404
428
  match.$match = filter;
405
429
  }
406
- if (hasKeys(sort)) {
430
+ if (!sortsRelations && sortStage.length) {
407
431
  match.$sort = sort;
408
432
  }
409
433
  // Lookups that a relation condition needs come first, then the match that reads them, then the
@@ -421,9 +445,10 @@ export class MongoDialect extends AbstractDialect {
421
445
  ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
422
446
  ];
423
447
  // 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.
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.
425
450
  const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
426
- pipeline.push(...(dropsParents ? [...relStages, ...pager] : [...pager, ...relStages]));
451
+ pipeline.push(...(dropsParents || sortsRelations ? [...relStages, ...sortStage, ...pager] : [...pager, ...relStages]));
427
452
  const projection = this.pipelineProjection(entity, q, relationSummary);
428
453
  if (projection) {
429
454
  pipeline.push({ $project: projection });
@@ -463,7 +488,7 @@ export class MongoDialect extends AbstractDialect {
463
488
  const relOpts = meta.relations[relKey];
464
489
  if (!relOpts)
465
490
  continue;
466
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
491
+ if (isToManyRelation(relOpts)) {
467
492
  // '1m' and 'mm' are resolved in a higher layer: they need a second query each.
468
493
  continue;
469
494
  }
@@ -768,11 +793,10 @@ export class MongoDialect extends AbstractDialect {
768
793
  extractVectorSort(sort) {
769
794
  if (!sort)
770
795
  return undefined;
771
- const raw = buildSortMap(sort);
772
796
  let vectorKey;
773
797
  let vectorSearch;
774
798
  const regularSort = {};
775
- for (const [key, value] of Object.entries(raw)) {
799
+ for (const [key, value] of Object.entries(sort)) {
776
800
  if (isVectorSearch(value)) {
777
801
  vectorKey = key;
778
802
  vectorSearch = value;
@@ -820,3 +844,7 @@ export class MongoDialect extends AbstractDialect {
820
844
  return { $vectorSearch: stage };
821
845
  }
822
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
+ }
@@ -32,8 +32,11 @@ export class MongodbQuerier extends AbstractQuerier {
32
32
  else {
33
33
  const relationSummary = getRelationRequestSummary(meta, q.$populate);
34
34
  // A relation condition needs `$lookup`, so it forces the aggregation path just like populating
35
- // one does; a plain `find` cursor cannot express it.
36
- 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)) {
37
40
  const pipeline = this.dialect.aggregationPipeline(entity, q, relationSummary, opts);
38
41
  documents = await this.runPipeline(entity, meta, pipeline);
39
42
  await this.fillToManyRelations(entity, documents, q.$populate);
@@ -27,7 +27,7 @@ export declare class SqliteDialect extends AbstractSqlDialect {
27
27
  * when declared, else `NULL` (which is also how SQLite auto-generates INTEGER PRIMARY KEYs).
28
28
  */
29
29
  protected appendDefaultInsertValue(ctx: QueryContext, field: FieldOptions | undefined): void;
30
- protected ilikeExpr(f: string, ph: string): string;
30
+ protected readonly caseInsensitiveMatch = "native";
31
31
  protected get neOp(): string;
32
32
  normalizeValue(value: unknown): unknown;
33
33
  /**
@@ -53,9 +53,9 @@ export class SqliteDialect extends AbstractSqlDialect {
53
53
  ctx.append('NULL');
54
54
  }
55
55
  }
56
- ilikeExpr(f, ph) {
57
- return `${f} LIKE ${ph}`;
58
- }
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';
59
59
  get neOp() {
60
60
  return 'IS NOT';
61
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
@@ -126,7 +126,7 @@ export type QuerySortMap<E> = {
126
126
  } & {
127
127
  [P in JsonFieldPaths<E>]?: QuerySortDirection;
128
128
  } & {
129
- [K in RelationKey<E>]?: QuerySortMap<NonNullable<Unpacked<E[K]>>>;
129
+ [K in RelationKey<E> as NonNullable<E[K]> extends readonly unknown[] ? never : K]?: QuerySortMap<NonNullable<E[K]>>;
130
130
  };
131
131
  /**
132
132
  * pager options.
@@ -1,4 +1,4 @@
1
- import { type CascadeType, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
1
+ import { type CascadeType, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: E, callbackKey: CallbackKey): FieldKey<E>[];
4
4
  /**
@@ -47,7 +47,6 @@ export declare function isCascadable(action: CascadeType, configuration?: boolea
47
47
  */
48
48
  export declare function asSelectMap<E>(select: QuerySelectValue<E> | undefined): QuerySelect<E> | undefined;
49
49
  export declare function normalizeScalarFieldSelection<E>(meta: EntityMeta<E>, select?: QuerySelect<E>, exclude?: QueryExclude<E>): FieldKey<E>[];
50
- export declare function buildSortMap<E>(sort: QuerySortMap<E> | undefined): QuerySortMap<E>;
51
50
  /** Type guard: checks whether a sort value is a vector similarity search. */
52
51
  export declare function isVectorSearch(value: unknown): value is QueryVectorSearch;
53
52
  /** Type guard: checks whether an update payload value is a JSON operator object. */
@@ -141,9 +141,6 @@ export function normalizeScalarFieldSelection(meta, select, exclude) {
141
141
  const excluded = excludedFields;
142
142
  return allFields.filter((it) => !excluded.has(it));
143
143
  }
144
- export function buildSortMap(sort) {
145
- return (sort ?? {});
146
- }
147
144
  /** Type guard: checks whether a sort value is a vector similarity search. */
148
145
  export function isVectorSearch(value) {
149
146
  return value !== null && typeof value === 'object' && '$vector' in value;
@@ -1,12 +1,23 @@
1
- import type { EntityMeta, Except, Query, QueryPopulate, RelationKey } from '../type/index.js';
1
+ import type { EntityMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
2
2
  export type RelationRequestSummary<E> = {
3
3
  readonly requestedKeys: RelationKey<E>[];
4
4
  readonly joinableKeys: RelationKey<E>[];
5
5
  readonly toManyKeys: RelationKey<E>[];
6
6
  };
7
+ /** Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. */
8
+ export declare function isToManyRelation(relation: RelationMeta): boolean;
9
+ /**
10
+ * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
11
+ * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
12
+ * to drop them without a word. `satisfies` ties each key to {@link RelationQuery}, so renaming one
13
+ * breaks this list at compile time rather than quietly stopping the check.
14
+ */
15
+ declare const JOINED_RELATION_REJECTIONS: readonly [readonly ["$sort", "a join brings one row per parent, so there is nothing to order"], readonly ["$limit", "a join brings one row per parent, so there is nothing to page"], readonly ["$skip", "a join brings one row per parent, so there is nothing to page"], readonly ["$distinct", "it applies to the whole statement, not to one of its joins"]];
16
+ /** A key only a to-many's own query can carry, and that a joined relation therefore rejects. */
17
+ export type JoinedRelationRejectedKey = (typeof JOINED_RELATION_REJECTIONS)[number][0];
7
18
  export declare function getRelationRequestSummary<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): RelationRequestSummary<E>;
8
19
  /** True when `$populate` includes at least one relation key. */
9
- export declare function isPopulatingRelations<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): boolean;
20
+ export declare function populatesRelations<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): boolean;
10
21
  export type RelationQuery<E extends object = object> = Except<Query<E>, '$lock'> & {
11
22
  $required?: boolean;
12
23
  };
@@ -20,3 +31,4 @@ export declare function parseRelationQueryValue<E extends object = object>(value
20
31
  /** Parses the relation payload for `relKey` */
21
32
  export declare function parseRelationAtKey<E>(relKey: RelationKey<E>, populate?: QueryPopulate<E>): ParsedRelationQuery;
22
33
  export declare function forEachRequestedRelation<E extends object>(meta: EntityMeta<E>, populate: QueryPopulate<E> | undefined, fn: (relKey: RelationKey<E>, rawValue: unknown) => void): void;
34
+ export {};
@@ -1,4 +1,31 @@
1
1
  import { getKeys } from './object.util.js';
2
+ /** Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. */
3
+ export function isToManyRelation(relation) {
4
+ return relation.cardinality === '1m' || relation.cardinality === 'mm';
5
+ }
6
+ /**
7
+ * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
8
+ * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
9
+ * to drop them without a word. `satisfies` ties each key to {@link RelationQuery}, so renaming one
10
+ * breaks this list at compile time rather than quietly stopping the check.
11
+ */
12
+ const JOINED_RELATION_REJECTIONS = [
13
+ ['$sort', 'a join brings one row per parent, so there is nothing to order'],
14
+ ['$limit', 'a join brings one row per parent, so there is nothing to page'],
15
+ ['$skip', 'a join brings one row per parent, so there is nothing to page'],
16
+ ['$distinct', 'it applies to the whole statement, not to one of its joins'],
17
+ ];
18
+ const JOINED_RELATION_REJECTED_KEYS = new Map(JOINED_RELATION_REJECTIONS);
19
+ function assertJoinableRelationQuery(relKey, value) {
20
+ if (!value || typeof value !== 'object') {
21
+ return;
22
+ }
23
+ for (const [key, reason] of JOINED_RELATION_REJECTED_KEYS) {
24
+ if (key in value) {
25
+ throw new TypeError(`'${key}' is not supported inside $populate of the to-one relation '${relKey}': ${reason}.`);
26
+ }
27
+ }
28
+ }
2
29
  export function getRelationRequestSummary(meta, populate) {
3
30
  const requestedKeys = [];
4
31
  const joinableKeys = [];
@@ -12,17 +39,20 @@ export function getRelationRequestSummary(meta, populate) {
12
39
  if (!relOpts)
13
40
  continue;
14
41
  requestedKeys.push(key);
15
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
42
+ if (isToManyRelation(relOpts)) {
16
43
  toManyKeys.push(key);
17
44
  }
18
45
  else {
46
+ // Validated where the cardinality is decided, so every backend and every nesting level rejects
47
+ // the same shapes - the SQL dialects, MongoDB's lookups, and whatever reads this summary next.
48
+ assertJoinableRelationQuery(key, populate[key]);
19
49
  joinableKeys.push(key);
20
50
  }
21
51
  }
22
52
  return { requestedKeys, joinableKeys, toManyKeys };
23
53
  }
24
54
  /** True when `$populate` includes at least one relation key. */
25
- export function isPopulatingRelations(meta, populate) {
55
+ export function populatesRelations(meta, populate) {
26
56
  if (!populate)
27
57
  return false;
28
58
  return getKeys(populate).some((key) => populate[key] && key in meta.relations);
@@ -1,6 +1,5 @@
1
1
  import type { InsertIdSource, QueryUpdateResult, RawRow } from '../type/index.js';
2
2
  import type { PrimaryKey } from '../type/utility.js';
3
- export declare function flatObject<E extends object>(obj: E, pre?: string): E;
4
3
  export declare function unflatObjects<T extends object>(objects: RawRow[]): T[];
5
4
  /**
6
5
  * Unflattens a single raw row using pre-computed attribute paths.
@@ -17,7 +16,7 @@ export declare function obtainAttrsPaths<T extends object>(row: T): {
17
16
  * @param forbidQualified whether to forbid qualified identifiers (containing dots)
18
17
  * @param addDot whether to add a dot suffix
19
18
  */
20
- export declare function escapeSqlId(val: string, escapeIdChar?: '`' | '"', forbidQualified?: boolean, addDot?: boolean): string;
19
+ export declare function escapeSqlId(val: string | undefined, escapeIdChar?: '`' | '"', forbidQualified?: boolean, addDot?: boolean): string;
21
20
  /**
22
21
  * Payload for building a QueryUpdateResult.
23
22
  */
@@ -1,17 +1,6 @@
1
- import { getKeys, hasKeys } from './object.util.js';
1
+ import { hasKeys } from './object.util.js';
2
2
  /** Pre-computed regex for each SQL identifier escape character to avoid per-call allocation. */
3
3
  const escapeIdRegexCache = { '`': /`/g, '"': /"/g };
4
- export function flatObject(obj, pre) {
5
- return getKeys(obj).reduce((acc, key) => flatObjectEntry(acc, key, obj[key], typeof obj[key] === 'object' ? '' : pre), {});
6
- }
7
- function flatObjectEntry(map, key, val, pre) {
8
- const prefix = pre ? `${pre}.${key}` : key;
9
- if (typeof val === 'object' && val !== null) {
10
- return getKeys(val).reduce((acc, prop) => flatObjectEntry(acc, prop, val[prop], prefix), map);
11
- }
12
- map[prefix] = val;
13
- return map;
14
- }
15
4
  export function unflatObjects(objects) {
16
5
  if (!Array.isArray(objects) || !objects.length) {
17
6
  return objects;