uql-orm 0.37.0 → 0.37.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.
@@ -1,4 +1,4 @@
1
- import { COUNT_ALIAS } from '../dialect/abstractSqlDialect.js';
1
+ import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
3
3
  import { getMeta } from '../entity/index.js';
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryFilter, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
1
+ import { type EntityMeta, type FieldKey, type FieldOptions, type IsolationLevel, type JsonColumnType, type JsonUpdateOp, type Query, type QueryAggMap, type QueryAggregate, type QueryBuildFn, type QueryComparisonOptions, type QueryConflictPaths, type QueryContext, type QueryDialect, type QueryExclude, type QueryFilter, type QueryGroupMap, type QueryHavingMap, type QueryOptions, type QueryPager, QueryRaw, type QueryRawFnOptions, type QuerySearch, type QuerySelectOptions, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryWhere, type QueryWhereArray, type QueryWhereFieldOperatorMap, type QueryWhereMap, type QueryWhereOptions, type RelationMeta, type SqlDialectName, type SqlQueryDialect, type Type, type UpdatePayload } from '../type/index.js';
2
2
  import type { HydrateKind } from './hydrateColumn.js';
3
3
  import { IndexSqlDialect } from './indexSqlDialect.js';
4
4
  import { type QueryJoins, type QuerySortOptions } from './queryJoins.js';
@@ -7,11 +7,6 @@ type PersistKind = 'plain' | 'json' | 'vector';
7
7
  /** One entry of {@link AbstractSqlDialect.hydratableFields}: a field key and how it decodes. */
8
8
  type HydratableField = readonly [string, HydrateKind];
9
9
  export type { HydrateKind };
10
- /**
11
- * The column a counting statement answers in. Shared rather than spelled at each end: the dialects
12
- * emit it and `runCount` reads it back, and a rename on one side alone would quietly count zero.
13
- */
14
- export declare const COUNT_ALIAS = "count";
15
10
  export declare abstract class AbstractSqlDialect extends IndexSqlDialect implements QueryDialect, SqlQueryDialect {
16
11
  abstract readonly dialectName: SqlDialectName;
17
12
  abstract readonly escapeIdChar: '"' | '`';
@@ -49,7 +44,7 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
49
44
  * bound value on `$n`-placeholder dialects once `ctx` isn't otherwise empty. Generated aliases are
50
45
  * shared for the same reason - see {@link SqlQueryContext}.
51
46
  */
52
- protected buildFragment(ctx: QueryContext, build: (fragmentCtx: QueryContext) => void): string;
47
+ protected buildFragment(ctx: QueryContext, build: QueryBuildFn): string;
53
48
  addValue(values: unknown[], value: unknown): string;
54
49
  /**
55
50
  * Normalizes a parameter value for the database driver.
@@ -236,6 +231,12 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
236
231
  pager(ctx: QueryContext, opts: QueryPager): void;
237
232
  /** Whether this engine has row locks at all. The SQLite family locks the database instead. */
238
233
  readonly supportsRowLocks: boolean;
234
+ /**
235
+ * Whether a `FOR UPDATE` may share a statement with a window function. The MySQL family runs the
236
+ * pair; the Postgres family rejects it outright ("FOR UPDATE is not allowed with window functions"),
237
+ * which is what a paged read carrying its own `COUNT(*) OVER ()` total becomes under a `$lock`.
238
+ */
239
+ readonly supportsWindowWithRowLock: boolean;
239
240
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
240
241
  readonly supportsLockOf: boolean;
241
242
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
@@ -247,6 +248,16 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
247
248
  */
248
249
  protected appendLock<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, joins?: QueryJoins): void;
249
250
  count<E>(ctx: QueryContext, entity: Type<E>, q: QueryFilter<E>, opts?: QueryOptions): void;
251
+ /**
252
+ * How many rows a `$distinct` read returns, which `COUNT(*)` cannot answer: the deduplication
253
+ * happens after it counts, and a window function is no better - it counts before `DISTINCT` too.
254
+ * So the deduplicated set is made a derived table and its rows are counted. Every engine here
255
+ * supports one; MySQL is the reason it is aliased.
256
+ *
257
+ * The inner query takes the projection and the filter but never the page: the caller is asking how
258
+ * many rows there are beyond the page it already has.
259
+ */
260
+ countDistinct<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: QueryOptions): void;
250
261
  /**
251
262
  * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
252
263
  * keep one; the rest throw, because falling back to `COUNT(*)` would run exactly the scan the
@@ -2,17 +2,13 @@ import { getMeta } from '../entity/index.js';
2
2
  import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
3
3
  import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, populatesRelations, raw, someValue, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
+ import { COUNT_ALIAS, DISTINCT_DERIVED_ALIAS, JSON_ELEM_ALIAS_PREFIX } from './aliases.js';
5
6
  import { IndexSqlDialect } from './indexSqlDialect.js';
6
7
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
7
- import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } from './jsonSql.js';
8
+ import { isJsonbOp, jsonCompareMode, jsonElemExists } from './jsonSql.js';
8
9
  import { SqlQueryContext } from './queryContext.js';
9
10
  import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
11
  import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
11
- /**
12
- * The column a counting statement answers in. Shared rather than spelled at each end: the dialects
13
- * emit it and `runCount` reads it back, and a rename on one side alone would quietly count zero.
14
- */
15
- export const COUNT_ALIAS = 'count';
16
12
  export class AbstractSqlDialect extends IndexSqlDialect {
17
13
  /**
18
14
  * How this engine declares a namespace, so a generated migration creates the schemas its tables
@@ -96,8 +92,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
96
92
  }
97
93
  returningId(entity) {
98
94
  const meta = getMeta(entity);
99
- const idKey = (meta.id ?? 'id');
100
- const idName = this.columnOf(meta, idKey);
95
+ const idName = this.columnOf(meta, meta.id);
101
96
  return `RETURNING ${this.escapeId(idName)} ${this.escapeId('id')}`;
102
97
  }
103
98
  search(ctx, entity, q = {}, opts = {}, joins = NO_JOINS) {
@@ -753,6 +748,12 @@ export class AbstractSqlDialect extends IndexSqlDialect {
753
748
  }
754
749
  /** Whether this engine has row locks at all. The SQLite family locks the database instead. */
755
750
  supportsRowLocks = true;
751
+ /**
752
+ * Whether a `FOR UPDATE` may share a statement with a window function. The MySQL family runs the
753
+ * pair; the Postgres family rejects it outright ("FOR UPDATE is not allowed with window functions"),
754
+ * which is what a paged read carrying its own `COUNT(*) OVER ()` total becomes under a `$lock`.
755
+ */
756
+ supportsWindowWithRowLock = true;
756
757
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
757
758
  supportsLockOf = true;
758
759
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
@@ -794,6 +795,21 @@ export class AbstractSqlDialect extends IndexSqlDialect {
794
795
  this.select(ctx, entity, { $select: [raw('COUNT(*)', COUNT_ALIAS)] });
795
796
  this.search(ctx, entity, { $where: q.$where }, opts);
796
797
  }
798
+ /**
799
+ * How many rows a `$distinct` read returns, which `COUNT(*)` cannot answer: the deduplication
800
+ * happens after it counts, and a window function is no better - it counts before `DISTINCT` too.
801
+ * So the deduplicated set is made a derived table and its rows are counted. Every engine here
802
+ * supports one; MySQL is the reason it is aliased.
803
+ *
804
+ * The inner query takes the projection and the filter but never the page: the caller is asking how
805
+ * many rows there are beyond the page it already has.
806
+ */
807
+ countDistinct(ctx, entity, q, opts) {
808
+ ctx.append(`SELECT COUNT(*) ${this.escapeId(COUNT_ALIAS, true)} FROM (`);
809
+ this.select(ctx, entity, q, opts);
810
+ this.search(ctx, entity, { $where: q.$where }, opts);
811
+ ctx.append(`) ${this.escapeId(DISTINCT_DERIVED_ALIAS, true)}`);
812
+ }
797
813
  /**
798
814
  * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
799
815
  * keep one; the rest throw, because falling back to `COUNT(*)` would run exactly the scan the
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Every identifier UQL invents for itself: a column a statement answers in, a derived table it wraps
3
+ * a set in, a temporary field a pipeline parks a value on.
4
+ *
5
+ * All of them share the `_uql` prefix, which is what keeps them off a user's own column or field, and
6
+ * all of them are declared here rather than beside the code that emits them: the end that writes one
7
+ * and the end that reads it back are usually in different modules, and a drift between the two fails
8
+ * silently - a count of zero, or an ordering that ranks everything equal. Collected in one file so
9
+ * the whole reserved namespace can be read at a glance before a new name is added to it.
10
+ */
11
+ /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
12
+ export declare const COUNT_ALIAS = "_uql_count";
13
+ /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
14
+ export declare const TOTAL_ALIAS = "_uql_total";
15
+ /** The derived table a `$distinct` count wraps its deduplicated set in. MySQL requires the alias. */
16
+ export declare const DISTINCT_DERIVED_ALIAS = "_uql_distinct";
17
+ /** Prefix for the alias an exploded JSON array element is read through. */
18
+ export declare const JSON_ELEM_ALIAS_PREFIX = "_uql_elem";
19
+ /** The alias a `$pull` reads its surviving elements through, kept distinct from {@link JSON_ELEM_ALIAS_PREFIX}. */
20
+ export declare const JSON_PULL_ALIAS = "_uql_pull";
21
+ /** Prefix for the field a MongoDB relation lookup parks its result on, one per condition. */
22
+ export declare const REL_TEMP_PREFIX = "_uql_rel_";
23
+ /** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
24
+ export declare const REL_NESTED_KEY = "_uql_target";
25
+ /**
26
+ * Where a `$sort` by a relation's size parks its tally until the ordering has run. A function, so the
27
+ * `$sort` that names the field and the stage that produces it cannot spell it differently - MongoDB
28
+ * ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
29
+ */
30
+ export declare function sortCountField(relKey: string): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Every identifier UQL invents for itself: a column a statement answers in, a derived table it wraps
3
+ * a set in, a temporary field a pipeline parks a value on.
4
+ *
5
+ * All of them share the `_uql` prefix, which is what keeps them off a user's own column or field, and
6
+ * all of them are declared here rather than beside the code that emits them: the end that writes one
7
+ * and the end that reads it back are usually in different modules, and a drift between the two fails
8
+ * silently - a count of zero, or an ordering that ranks everything equal. Collected in one file so
9
+ * the whole reserved namespace can be read at a glance before a new name is added to it.
10
+ */
11
+ /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
12
+ export const COUNT_ALIAS = '_uql_count';
13
+ /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
14
+ export const TOTAL_ALIAS = '_uql_total';
15
+ /** The derived table a `$distinct` count wraps its deduplicated set in. MySQL requires the alias. */
16
+ export const DISTINCT_DERIVED_ALIAS = '_uql_distinct';
17
+ /** Prefix for the alias an exploded JSON array element is read through. */
18
+ export const JSON_ELEM_ALIAS_PREFIX = '_uql_elem';
19
+ /** The alias a `$pull` reads its surviving elements through, kept distinct from {@link JSON_ELEM_ALIAS_PREFIX}. */
20
+ export const JSON_PULL_ALIAS = '_uql_pull';
21
+ /** Prefix for the field a MongoDB relation lookup parks its result on, one per condition. */
22
+ export const REL_TEMP_PREFIX = '_uql_rel_';
23
+ /** The field a ManyToMany lookup nests its target match under, inside the junction's own pipeline. */
24
+ export const REL_NESTED_KEY = '_uql_target';
25
+ /**
26
+ * Where a `$sort` by a relation's size parks its tally until the ordering has run. A function, so the
27
+ * `$sort` that names the field and the stage that produces it cannot spell it differently - MongoDB
28
+ * ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
29
+ */
30
+ export function sortCountField(relKey) {
31
+ return `_uql_sort_count_${relKey}`;
32
+ }
@@ -1,24 +1,4 @@
1
1
  import type { FieldOptions } from '../type/index.js';
2
- /**
3
- * Alias prefix for the derived table a dialect explodes a JSON array into to test `$all`/
4
- * `$elemMatch` (e.g. SQLite's `json_each(col) AS _uql_elem_1`). Passed to
5
- * {@link QueryContext.nextAlias} for a fresh, uniquely-numbered name per call - `$elemMatch`/`$all`
6
- * can recurse into this on a nested array, so a single fixed alias would let the inner occurrence
7
- * shadow the outer one it needs to correlate against (confirmed on SQLite and MySQL: reusing one
8
- * literal alias at two nesting depths silently returned zero rows instead of the matching ones).
9
- * Leading underscore keeps it a valid unquoted identifier on every dialect (unlike a leading `$`,
10
- * which Postgres/SQLite only allow after the first character) while staying an unlikely real
11
- * column/relation/`$select` alias name.
12
- */
13
- export declare const JSON_ELEM_ALIAS_PREFIX = "_uql_elem";
14
- /**
15
- * Alias for the derived table a dialect explodes a JSON array into to evaluate `$pull` (e.g.
16
- * SQLite's `json_each(col) AS _uql_pull`). Kept distinct from {@link JSON_ELEM_ALIAS_PREFIX} since
17
- * the two subqueries have different shapes (an `EXISTS` boolean vs. a `json_group_array` rebuild)
18
- * and could in principle both be in scope if this ever supports nesting one inside the other's
19
- * condition.
20
- */
21
- export declare const JSON_PULL_ALIAS = "_uql_pull";
22
2
  /**
23
3
  * A `'$.a.b'` JSON path literal, each dot-separated segment escaped. `suffix` appends an accessor
24
4
  * such as `[#]` or `[*]`. Shared across dialects unchanged: no dialect escapes a JSON path key
@@ -1,24 +1,4 @@
1
1
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
2
- /**
3
- * Alias prefix for the derived table a dialect explodes a JSON array into to test `$all`/
4
- * `$elemMatch` (e.g. SQLite's `json_each(col) AS _uql_elem_1`). Passed to
5
- * {@link QueryContext.nextAlias} for a fresh, uniquely-numbered name per call - `$elemMatch`/`$all`
6
- * can recurse into this on a nested array, so a single fixed alias would let the inner occurrence
7
- * shadow the outer one it needs to correlate against (confirmed on SQLite and MySQL: reusing one
8
- * literal alias at two nesting depths silently returned zero rows instead of the matching ones).
9
- * Leading underscore keeps it a valid unquoted identifier on every dialect (unlike a leading `$`,
10
- * which Postgres/SQLite only allow after the first character) while staying an unlikely real
11
- * column/relation/`$select` alias name.
12
- */
13
- export const JSON_ELEM_ALIAS_PREFIX = '_uql_elem';
14
- /**
15
- * Alias for the derived table a dialect explodes a JSON array into to evaluate `$pull` (e.g.
16
- * SQLite's `json_each(col) AS _uql_pull`). Kept distinct from {@link JSON_ELEM_ALIAS_PREFIX} since
17
- * the two subqueries have different shapes (an `EXISTS` boolean vs. a `json_group_array` rebuild)
18
- * and could in principle both be in scope if this ever supports nesting one inside the other's
19
- * condition.
20
- */
21
- export const JSON_PULL_ALIAS = '_uql_pull';
22
2
  /**
23
3
  * A `'$.a.b'` JSON path literal, each dot-separated segment escaped. `suffix` appends an accessor
24
4
  * such as `[#]` or `[*]`. Shared across dialects unchanged: no dialect escapes a JSON path key
@@ -1,8 +1,9 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { getFieldKeys } from '../util/index.js';
3
3
  import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
4
- import { AbstractSqlDialect, COUNT_ALIAS } from './abstractSqlDialect.js';
5
- import { JSON_PULL_ALIAS, jsonAssignCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
4
+ import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
+ import { COUNT_ALIAS, JSON_PULL_ALIAS } from './aliases.js';
6
+ import { jsonAssignCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
6
7
  /** The row count MySQL's manual gives for "all rows from the offset on": the largest `BIGINT UNSIGNED`. */
7
8
  const MAX_LIMIT = BigInt.asUintN(64, -1n);
8
9
  /**
@@ -10,6 +10,8 @@ import { AbstractSqlDialect } from './abstractSqlDialect.js';
10
10
  * syntax; CockroachDB's vector type and `CREATE VECTOR INDEX` syntax are both native).
11
11
  */
12
12
  export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
13
+ /** `FOR UPDATE` and a window function cannot share a statement here. See the base declaration. */
14
+ readonly supportsWindowWithRowLock = false;
13
15
  /** Default {@link DialectFeatures} for Postgres-wire dialects. */
14
16
  protected readonly featureDefaults: DialectFeatures;
15
17
  readonly escapeIdChar = "\"";
@@ -1,7 +1,8 @@
1
1
  import { QueryRaw, } from '../type/index.js';
2
2
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
3
3
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
4
- import { JSON_PULL_ALIAS, jsonSetTarget } from './jsonSql.js';
4
+ import { JSON_PULL_ALIAS } from './aliases.js';
5
+ import { jsonSetTarget } from './jsonSql.js';
5
6
  import { resolveVectorCast, toSparsevecLiteral } from './vectorCast.js';
6
7
  /**
7
8
  * Shared AST/quoting/JSONB/full-text-search/vector-search implementation between Postgres and
@@ -13,6 +14,8 @@ import { resolveVectorCast, toSparsevecLiteral } from './vectorCast.js';
13
14
  * syntax; CockroachDB's vector type and `CREATE VECTOR INDEX` syntax are both native).
14
15
  */
15
16
  export class PgLikeSqlDialect extends AbstractSqlDialect {
17
+ /** `FOR UPDATE` and a window function cannot share a statement here. See the base declaration. */
18
+ supportsWindowWithRowLock = false;
16
19
  /** Default {@link DialectFeatures} for Postgres-wire dialects. */
17
20
  featureDefaults = {
18
21
  explicitJsonCast: false,
@@ -18,15 +18,6 @@ export declare class MongoDialect extends AbstractDialect {
18
18
  readonly dialectName = "mongodb";
19
19
  readonly insertIdSource = "returning";
20
20
  private static readonly ID_KEY;
21
- /** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
22
- private static readonly REL_TEMP_PREFIX;
23
- /**
24
- * Where a `$sort` by a relation's size parks its tally, until the ordering has run. One spelling
25
- * for both ends: the `$sort` names this field and {@link sortCountStages} produces it, and MongoDB
26
- * ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
27
- */
28
- private static sortCountField;
29
- private static readonly REL_NESTED_KEY;
30
21
  private static readonly VECTOR_INDEX_TYPES;
31
22
  /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
32
23
  private static readonly MAX_NUM_CANDIDATES;
@@ -1,9 +1,10 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
+ import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
3
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
4
5
  import { getMeta } from '../entity/index.js';
5
6
  import { QueryRaw } from '../type/queryRaw.js';
6
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, COUNT_AGG_ALIAS, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, someKey, } from '../util/index.js';
7
+ import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, someKey, } from '../util/index.js';
7
8
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
8
9
  export const mongoDialectFeatures = {
9
10
  explicitJsonCast: false,
@@ -27,17 +28,6 @@ export class MongoDialect extends AbstractDialect {
27
28
  // The MongoDB driver reports the exact `_id` of every inserted document (`insertedIds`).
28
29
  insertIdSource = 'returning';
29
30
  static ID_KEY = '_id';
30
- /** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
31
- static REL_TEMP_PREFIX = '__uql_rel_';
32
- /**
33
- * Where a `$sort` by a relation's size parks its tally, until the ordering has run. One spelling
34
- * for both ends: the `$sort` names this field and {@link sortCountStages} produces it, and MongoDB
35
- * ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
36
- */
37
- static sortCountField(relKey) {
38
- return `__uql_sort_count_${relKey}`;
39
- }
40
- static REL_NESTED_KEY = '__uql_target';
41
31
  static VECTOR_INDEX_TYPES = new Set(['vectorSearch', 'hnsw', 'ivfflat', 'vector']);
42
32
  /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
43
33
  static MAX_NUM_CANDIDATES = 10_000;
@@ -149,10 +139,10 @@ export class MongoDialect extends AbstractDialect {
149
139
  const relOpts = meta.relations[relKey];
150
140
  const relEntity = relOpts.entity();
151
141
  const relMeta = getMeta(relEntity);
152
- const temp = `${MongoDialect.REL_TEMP_PREFIX}${lookups.temps.length}`;
142
+ const temp = `${REL_TEMP_PREFIX}${lookups.temps.length}`;
153
143
  const sizeVal = parseRelationSize(val);
154
144
  // `$count` for a size test, `$limit: 1` for existence: neither returns the matched documents.
155
- const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_AGG_ALIAS }];
145
+ const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_ALIAS }];
156
146
  // Scope first, render once - merging the target's filters into an already-rendered filter would
157
147
  // leave their own keys unmapped. The caller's filter bypass is deliberately *not* passed down:
158
148
  // `withDeleted()` or `hardDelete` on the parent must not un-hide trashed rows of the target, the
@@ -190,7 +180,7 @@ export class MongoDialect extends AbstractDialect {
190
180
  const throughEntity = relOpts.through();
191
181
  const throughMeta = getMeta(throughEntity);
192
182
  const junctionScope = this.renderFilter(throughEntity, this.scopedWhereMap(throughMeta, {}), opts);
193
- const nested = MongoDialect.REL_NESTED_KEY;
183
+ const nested = REL_NESTED_KEY;
194
184
  return {
195
185
  $lookup: {
196
186
  from: this.resolveTableName(throughMeta),
@@ -219,7 +209,7 @@ export class MongoDialect extends AbstractDialect {
219
209
  * the `$ifNull` fallback to 0, so `{ $size: 0 }` matches parents with no related row at all.
220
210
  */
221
211
  compareRelationCount(temp, sizeVal) {
222
- const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] };
212
+ const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] };
223
213
  if (typeof sizeVal === 'number') {
224
214
  return { $eq: [count, sizeVal] };
225
215
  }
@@ -420,7 +410,7 @@ export class MongoDialect extends AbstractDialect {
420
410
  if (path) {
421
411
  throw new TypeError(`$sort by '${relPath}.$count' is only supported on the queried entity`);
422
412
  }
423
- out[MongoDialect.sortCountField(key)] = sortDirection(countDirection);
413
+ out[sortCountField(key)] = sortDirection(countDirection);
424
414
  continue;
425
415
  }
426
416
  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`);
@@ -443,11 +433,11 @@ export class MongoDialect extends AbstractDialect {
443
433
  }
444
434
  const relEntity = relOpts.entity();
445
435
  const relMeta = getMeta(relEntity);
446
- const temp = MongoDialect.sortCountField(key);
436
+ const temp = sortCountField(key);
447
437
  const targetScope = this.renderFilter(relEntity, this.scopedWhereMap(relMeta, {}), opts);
448
- const tail = [{ $count: COUNT_AGG_ALIAS }];
438
+ const tail = [{ $count: COUNT_ALIAS }];
449
439
  stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts), {
450
- $addFields: { [temp]: { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] } },
440
+ $addFields: { [temp]: { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] } },
451
441
  });
452
442
  fields.push(temp);
453
443
  }
@@ -23,9 +23,9 @@ export declare class MongodbQuerier extends AbstractQuerier {
23
23
  private buildVectorPipeline;
24
24
  protected internalAggregate<E extends Document, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
25
25
  /**
26
- * A `$required` relation drops parents that have no match, so the total has to be taken after the
27
- * `$unwind` that drops them - which only the read pipeline builds. Every other query counts through
28
- * {@link internalCount}, which needs no pipeline of its own.
26
+ * A `$required` relation drops parents that have no match, and `$distinct` collapses them, so both
27
+ * totals have to be taken after the stage that does it - which only the read pipeline builds. Every
28
+ * other query counts through {@link internalCount}, which needs no pipeline of its own.
29
29
  */
30
30
  protected internalFindManyAndCount<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
31
31
  protected internalCount<E extends Document>(entity: Type<E>, qm?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
@@ -1,7 +1,8 @@
1
+ import { COUNT_ALIAS } from '../dialect/aliases.js';
1
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
2
3
  import { getMeta } from '../entity/index.js';
3
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
4
- import { COUNT_AGG_ALIAS, clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
5
6
  /**
6
7
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
7
8
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -151,31 +152,33 @@ export class MongodbQuerier extends AbstractQuerier {
151
152
  });
152
153
  }
153
154
  /**
154
- * A `$required` relation drops parents that have no match, so the total has to be taken after the
155
- * `$unwind` that drops them - which only the read pipeline builds. Every other query counts through
156
- * {@link internalCount}, which needs no pipeline of its own.
155
+ * A `$required` relation drops parents that have no match, and `$distinct` collapses them, so both
156
+ * totals have to be taken after the stage that does it - which only the read pipeline builds. Every
157
+ * other query counts through {@link internalCount}, which needs no pipeline of its own.
157
158
  */
158
159
  async internalFindManyAndCount(entity, q, opts) {
159
- if (!hasRequiredJoin(getMeta(entity), q)) {
160
+ if (!q.$distinct && !hasRequiredJoin(getMeta(entity), q)) {
160
161
  return super.internalFindManyAndCount(entity, q, opts);
161
162
  }
162
163
  const { $sort: _sort, $skip: _skip, $limit: _limit, ...unpaged } = q;
163
164
  const [founds, counted] = await Promise.all([
164
165
  this.internalFindMany(entity, q, opts),
165
166
  this.execute((session) => this.collection(entity)
166
- .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_AGG_ALIAS }], { session })
167
+ .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_ALIAS }], { session })
167
168
  .toArray()),
168
169
  ]);
169
- return [founds, counted[0]?.[COUNT_AGG_ALIAS] ?? 0];
170
+ return [founds, counted[0]?.[COUNT_ALIAS] ?? 0];
170
171
  }
171
172
  async internalCount(entity, qm = {}, opts) {
172
173
  return this.timed('internalCount', undefined, async () => {
173
174
  if (this.dialect.constrainsRelations(entity, qm.$where)) {
174
175
  const { stages, filter } = this.dialect.whereWithRelations(entity, qm.$where, opts);
175
176
  const [counted] = await this.execute((session) => this.collection(entity)
176
- .aggregate([...stages, { $match: filter }, { $count: COUNT_AGG_ALIAS }], { session })
177
+ .aggregate([...stages, { $match: filter }, { $count: COUNT_ALIAS }], {
178
+ session,
179
+ })
177
180
  .toArray());
178
- return counted?.[COUNT_AGG_ALIAS] ?? 0;
181
+ return counted?.[COUNT_ALIAS] ?? 0;
179
182
  }
180
183
  const filter = this.dialect.where(entity, qm.$where, opts);
181
184
  return this.execute((session) => this.collection(entity).countDocuments(filter, {
@@ -1,4 +1,4 @@
1
- import { COUNT_ALIAS } from '../dialect/abstractSqlDialect.js';
1
+ import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
3
3
  import { getMeta } from '../entity/index.js';
4
4
  /**
@@ -218,13 +218,12 @@ export class AbstractQuerier {
218
218
  const toInsert = [];
219
219
  const toUpdate = [];
220
220
  const existingIds = [];
221
- const idKey = (meta.id ?? 'id');
222
221
  for (const it of payload) {
223
- const id = it[idKey];
222
+ const id = it[meta.id];
224
223
  if (!id) {
225
224
  toInsert.push(it);
226
225
  }
227
- else if (!someKey(it, (key) => key !== idKey)) {
226
+ else if (!someKey(it, (key) => key !== meta.id)) {
228
227
  existingIds.push(id);
229
228
  }
230
229
  else {
@@ -234,9 +233,9 @@ export class AbstractQuerier {
234
233
  const [insertedIds, updatedIds] = await Promise.all([
235
234
  toInsert.length ? this.insertMany(entity, toInsert) : [],
236
235
  Promise.all(toUpdate.map(async (it) => {
237
- const id = it[idKey];
236
+ const id = it[meta.id];
238
237
  const data = { ...it };
239
- delete data[idKey];
238
+ delete data[meta.id];
240
239
  await this.updateOneById(entity, id, data);
241
240
  return id;
242
241
  })),
@@ -52,8 +52,18 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
52
52
  * the end, or a filter nothing matched.
53
53
  *
54
54
  * A `$required` relation needs no special case: the window counts what the INNER JOIN left, which
55
- * is exactly the total a caller of a filtered read is asking for.
55
+ * is exactly the total a caller of a filtered read is asking for. A `$lock` is the one clause an
56
+ * engine may refuse to have in the same statement, which {@link AbstractSqlDialect.supportsWindowWithRowLock}
57
+ * answers; where it does, the total comes from a count of its own. A `$distinct` read needs one
58
+ * too, and a deduplicating one: see {@link AbstractSqlDialect.countDistinct}.
56
59
  */
60
+ /**
61
+ * How to count when the total cannot ride along in the read's own `COUNT(*) OVER ()` column, or
62
+ * `undefined` when it can. Two clauses rule the window out: `$distinct`, because a window counts
63
+ * before the deduplication and so overstates the page, and `$lock` on an engine that refuses the
64
+ * pair outright. Both then cost a second statement; only the counting differs.
65
+ */
66
+ private countedSeparately;
57
67
  protected internalFindManyAndCount<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
58
68
  private selectRows;
59
69
  private hydrateRows;
@@ -1,14 +1,9 @@
1
- import { COUNT_ALIAS } from '../dialect/abstractSqlDialect.js';
1
+ import { COUNT_ALIAS, TOTAL_ALIAS } from '../dialect/aliases.js';
2
2
  import { decodeColumn } from '../dialect/hydrateColumn.js';
3
3
  import { getMeta } from '../entity/index.js';
4
4
  import { buildUpdateResult, cascadesOnDelete, clone, getInsertFieldKeys, getRelationRequestSummary, hasKeys, idOnlyQuery, isAutoIncrement, isPagedQuery, obtainAttrsPaths, throwNoPendingTransaction, throwPendingTransaction, unflatObject, unflatObjects, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { AbstractQuerier } from './abstractQuerier.js';
6
6
  import { enrichError } from './queryError.js';
7
- /**
8
- * The column a paged read carries its unpaged total in. Prefixed the way the other internal aliases
9
- * are, since it rides in the same row as the entity's own columns and must not shadow one.
10
- */
11
- const TOTAL_ALIAS = '_uql_total';
12
7
  export class AbstractSqlQuerier extends AbstractQuerier {
13
8
  dialect;
14
9
  extra;
@@ -90,9 +85,31 @@ export class AbstractSqlQuerier extends AbstractQuerier {
90
85
  * the end, or a filter nothing matched.
91
86
  *
92
87
  * A `$required` relation needs no special case: the window counts what the INNER JOIN left, which
93
- * is exactly the total a caller of a filtered read is asking for.
88
+ * is exactly the total a caller of a filtered read is asking for. A `$lock` is the one clause an
89
+ * engine may refuse to have in the same statement, which {@link AbstractSqlDialect.supportsWindowWithRowLock}
90
+ * answers; where it does, the total comes from a count of its own. A `$distinct` read needs one
91
+ * too, and a deduplicating one: see {@link AbstractSqlDialect.countDistinct}.
94
92
  */
93
+ /**
94
+ * How to count when the total cannot ride along in the read's own `COUNT(*) OVER ()` column, or
95
+ * `undefined` when it can. Two clauses rule the window out: `$distinct`, because a window counts
96
+ * before the deduplication and so overstates the page, and `$lock` on an engine that refuses the
97
+ * pair outright. Both then cost a second statement; only the counting differs.
98
+ */
99
+ countedSeparately(entity, q, opts) {
100
+ if (q.$distinct) {
101
+ return (ctx) => this.dialect.countDistinct(ctx, entity, q, opts);
102
+ }
103
+ if (q.$lock && !this.dialect.supportsWindowWithRowLock) {
104
+ return (ctx) => this.dialect.count(ctx, entity, q, opts);
105
+ }
106
+ return undefined;
107
+ }
95
108
  async internalFindManyAndCount(entity, q, opts) {
109
+ const separately = this.countedSeparately(entity, q, opts);
110
+ if (separately) {
111
+ return Promise.all([this.internalFindMany(entity, q, opts), this.runCount(separately)]);
112
+ }
96
113
  const rows = await this.selectRows(entity, q, opts, TOTAL_ALIAS);
97
114
  const total = rows.length ? Number(rows[0][TOTAL_ALIAS]) : await this.internalCount(entity, q, opts);
98
115
  for (const row of rows) {
@@ -1,14 +1,7 @@
1
+ import { COUNT_ALIAS } from '../dialect/aliases.js';
1
2
  import { getMeta } from '../entity/index.js';
2
3
  import { COUNT_RESULT_KEY } from '../type/index.js';
3
- import { asSelectMap, COUNT_AGG_ALIAS, getKeys, parentKeyColumn, targetKeyColumn } from '../util/index.js';
4
- /**
5
- * The one place the counting path crosses from the caller's type to a structural one: a relation's
6
- * entity comes from metadata, which names its columns only as strings. Everything past this is
7
- * checked against {@link CountedRow} normally.
8
- */
9
- function countable(entity) {
10
- return entity;
11
- }
4
+ import { asSelectMap, getKeys, parentKeyColumn, targetKeyColumn } from '../util/index.js';
12
5
  /**
13
6
  * A `$count` groups its tallies by the parent's id, so the id has to outlive the projection - the
14
7
  * same reason populating a relation keeps it. A whitelisting `$select` gains the key and an
@@ -78,10 +71,10 @@ export async function fillRelationCounts(querier, entity, payload, count) {
78
71
  async function countPerParent(querier, relOpts, ids, where) {
79
72
  const through = relOpts.through;
80
73
  if (through) {
81
- return countThroughPerParent(querier, relOpts, countable(through()), ids, where);
74
+ return countThroughPerParent(querier, relOpts, through(), ids, where);
82
75
  }
83
76
  const foreign = parentKeyColumn(relOpts);
84
- return groupedCount(querier, countable(relOpts.entity()), foreign, { ...where, [foreign]: ids });
77
+ return groupedCount(querier, relOpts.entity(), foreign, { ...where, [foreign]: ids });
85
78
  }
86
79
  /**
87
80
  * A many-to-many counts its junction rows, one per pairing. A filter names the target's columns,
@@ -92,7 +85,7 @@ async function countThroughPerParent(querier, relOpts, throughEntity, ids, where
92
85
  const local = parentKeyColumn(relOpts);
93
86
  const throughWhere = { [local]: ids };
94
87
  if (where) {
95
- const target = countable(relOpts.entity());
88
+ const target = relOpts.entity();
96
89
  const targetId = getMeta(target).id;
97
90
  const targets = await querier.findMany(target, { $select: { [targetId]: true }, $where: where });
98
91
  throughWhere[targetKeyColumn(relOpts)] = targets.map((it) => it[targetId]);
@@ -101,11 +94,11 @@ async function countThroughPerParent(querier, relOpts, throughEntity, ids, where
101
94
  }
102
95
  /** `SELECT <key>, COUNT(*) ... GROUP BY <key>`, as a lookup from parent key to tally. */
103
96
  async function groupedCount(querier, entity, groupKey, where) {
104
- const $agg = { [COUNT_AGG_ALIAS]: { $count: '*' } };
97
+ const $agg = { [COUNT_ALIAS]: { $count: '*' } };
105
98
  const rows = await querier.aggregate(entity, { $group: { [groupKey]: true }, $agg, $where: where });
106
99
  const byParent = {};
107
100
  for (const row of rows) {
108
- byParent[String(row[groupKey])] = Number(row[COUNT_AGG_ALIAS]);
101
+ byParent[String(row[groupKey])] = Number(row[COUNT_ALIAS]);
109
102
  }
110
103
  return byParent;
111
104
  }
@@ -1,5 +1,6 @@
1
1
  import { AbstractSqlDialect } from '../dialect/abstractSqlDialect.js';
2
- import { JSON_ELEM_ALIAS_PREFIX, JSON_PULL_ALIAS, jsonAssignCall, jsonElemExists, jsonPath, jsonRemoveCall, jsonSetTarget, } from '../dialect/jsonSql.js';
2
+ import { JSON_ELEM_ALIAS_PREFIX, JSON_PULL_ALIAS } from '../dialect/aliases.js';
3
+ import { jsonAssignCall, jsonElemExists, jsonPath, jsonRemoveCall, jsonSetTarget } from '../dialect/jsonSql.js';
3
4
  export class SqliteDialect extends AbstractSqlDialect {
4
5
  /** Default {@link DialectFeatures} for SQLite and SQLite-derived dialects. */
5
6
  featureDefaults = {
@@ -22,6 +22,12 @@ export type QueryWhereOptions = QueryComparisonOptions & {
22
22
  */
23
23
  clause?: 'WHERE' | 'AND' | false;
24
24
  };
25
+ /**
26
+ * Emits a statement, or a fragment of one, into the context it is handed. What a caller passes when
27
+ * it knows *what* to build but not *where*: the statement is assembled into whichever context the
28
+ * receiver opens, so the two ends cannot disagree about which one it went into.
29
+ */
30
+ export type QueryBuildFn = (ctx: QueryContext) => void;
25
31
  export interface QueryContext {
26
32
  append(sql: string): this;
27
33
  addValue(value: unknown): this;
@@ -1,14 +1,4 @@
1
1
  import { type CascadeType, type EntityData, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
- /**
3
- * The alias an internally-built count answers under - a batched relation tally, a MongoDB relation
4
- * lookup, a total taken past a `$required` unwind. Every one of those picks the alias and reads it
5
- * back a few lines later, so they share the spelling rather than each repeating it twice.
6
- *
7
- * Prefixed like every other internal alias here: a batched tally selects it alongside the column it
8
- * groups by, so a bare name would collide with a real column of that name and answer the grouped
9
- * value twice over, with no statement failing to say so.
10
- */
11
- export declare const COUNT_AGG_ALIAS = "_uql_count";
12
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
13
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E>, callbackKey: CallbackKey): FieldKey<E>[];
14
4
  /**
@@ -1,16 +1,6 @@
1
1
  import { getContext, UqlSecurityError } from '../context/context.js';
2
2
  import { QueryRaw, resolveAggregateOp, } from '../type/index.js';
3
3
  import { getFieldKeys, getKeys, hasKeys, someKey } from './object.util.js';
4
- /**
5
- * The alias an internally-built count answers under - a batched relation tally, a MongoDB relation
6
- * lookup, a total taken past a `$required` unwind. Every one of those picks the alias and reads it
7
- * back a few lines later, so they share the spelling rather than each repeating it twice.
8
- *
9
- * Prefixed like every other internal alias here: a batched tally selects it alongside the column it
10
- * groups by, so a bare name would collide with a real column of that name and answer the grouped
11
- * value twice over, with no statement failing to say so.
12
- */
13
- export const COUNT_AGG_ALIAS = '_uql_count';
14
4
  export function filterFieldKeys(meta, payload, callbackKey) {
15
5
  return getKeys(payload).filter((key) => {
16
6
  const fieldOpts = meta.fields[key];
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.37.0",
6
+ "version": "0.37.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"