uql-orm 0.36.1 → 0.37.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 (48) hide show
  1. package/dist/browser/http/http.d.ts +1 -1
  2. package/dist/browser/querier/httpQuerier.d.ts +12 -7
  3. package/dist/browser/querier/httpQuerier.js +6 -1
  4. package/dist/browser/type/clientQuerier.d.ts +10 -23
  5. package/dist/browser/uql-browser.min.js +2 -2
  6. package/dist/browser/uql-browser.min.js.map +7 -7
  7. package/dist/cockroachdb/cockroachDialect.d.ts +8 -1
  8. package/dist/cockroachdb/cockroachDialect.js +12 -0
  9. package/dist/dialect/abstractSqlDialect.d.ts +18 -2
  10. package/dist/dialect/abstractSqlDialect.js +45 -12
  11. package/dist/dialect/mysqlLikeSqlDialect.d.ts +6 -0
  12. package/dist/dialect/mysqlLikeSqlDialect.js +19 -1
  13. package/dist/dialect/queryJoins.d.ts +6 -0
  14. package/dist/dialect/queryJoins.js +13 -0
  15. package/dist/http/contract.d.ts +0 -7
  16. package/dist/http/handler.d.ts +2 -2
  17. package/dist/http/handler.js +1 -1
  18. package/dist/http/query.js +3 -2
  19. package/dist/mongo/mongoDialect.d.ts +21 -1
  20. package/dist/mongo/mongoDialect.js +70 -11
  21. package/dist/mongo/mongodbQuerier.d.ts +13 -2
  22. package/dist/mongo/mongodbQuerier.js +44 -3
  23. package/dist/postgres/postgresDialect.d.ts +7 -0
  24. package/dist/postgres/postgresDialect.js +13 -0
  25. package/dist/querier/abstractQuerier.d.ts +34 -18
  26. package/dist/querier/abstractQuerier.js +39 -25
  27. package/dist/querier/abstractQuerierPool.d.ts +9 -7
  28. package/dist/querier/abstractQuerierPool.js +6 -0
  29. package/dist/querier/abstractSqlQuerier.d.ts +20 -2
  30. package/dist/querier/abstractSqlQuerier.js +44 -8
  31. package/dist/querier/relationCount.d.ts +16 -0
  32. package/dist/querier/relationCount.js +111 -0
  33. package/dist/sqlite/sqliteDialect.d.ts +6 -1
  34. package/dist/sqlite/sqliteDialect.js +10 -0
  35. package/dist/type/dialect.d.ts +4 -3
  36. package/dist/type/index.d.ts +1 -0
  37. package/dist/type/index.js +1 -0
  38. package/dist/type/querier.d.ts +21 -14
  39. package/dist/type/query.d.ts +77 -16
  40. package/dist/type/query.js +17 -0
  41. package/dist/type/universalQuerier.d.ts +85 -48
  42. package/dist/type/wire.d.ts +37 -0
  43. package/dist/type/wire.js +1 -0
  44. package/dist/util/dialect.util.d.ts +16 -0
  45. package/dist/util/dialect.util.js +25 -0
  46. package/dist/util/relationQuery.util.d.ts +9 -0
  47. package/dist/util/relationQuery.util.js +13 -0
  48. package/package.json +4 -4
@@ -1,6 +1,6 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
3
- import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, populatesRelations, raw, someValue, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
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
5
  import { IndexSqlDialect } from './indexSqlDialect.js';
6
6
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
@@ -8,6 +8,11 @@ import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } fr
8
8
  import { SqlQueryContext } from './queryContext.js';
9
9
  import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
10
  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';
11
16
  export class AbstractSqlDialect extends IndexSqlDialect {
12
17
  /**
13
18
  * How this engine declares a namespace, so a generated migration creates the schemas its tables
@@ -171,7 +176,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
171
176
  appendTextSearch(_ctx, _entity, _meta, _search) {
172
177
  throw new TypeError(`${this.dialectName} does not support $text full-text search`);
173
178
  }
174
- select(ctx, entity, q, opts = {}, joins = NO_JOINS) {
179
+ select(ctx, entity, q, opts = {}, joins = NO_JOINS, totalAlias) {
175
180
  const meta = getMeta(entity);
176
181
  const { alias, ref } = this.tableRef(meta);
177
182
  const prefix = this.resolveRelationAwarePrefix(alias, meta, opts, q.$populate, joins);
@@ -186,6 +191,9 @@ export class AbstractSqlDialect extends IndexSqlDialect {
186
191
  this.appendVectorProjection(ctx, meta, key, val);
187
192
  }
188
193
  }
194
+ if (totalAlias) {
195
+ ctx.append(`, ${this.totalOverExpr} ${this.escapeId(totalAlias, true)}`);
196
+ }
189
197
  ctx.append(` FROM ${ref}`);
190
198
  // Add JOINs AFTER FROM clause
191
199
  this.selectRelationJoins(ctx, meta, alias, joins);
@@ -691,6 +699,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
691
699
  const relation = meta.relations[key];
692
700
  if (relation) {
693
701
  const relPath = path ? `${path}.${key}` : key;
702
+ const countDirection = parseSortByCount(value);
703
+ if (countDirection !== undefined) {
704
+ // A correlated count, not a join: a parent has many of these, so what is being ordered by
705
+ // is how many, and `SELECT DISTINCT` cannot order by an expression it did not select.
706
+ if (opts.distinct) {
707
+ throw new TypeError(`cannot $sort by '${relPath}.$count' with $distinct: it is not a selected column`);
708
+ }
709
+ columns.push(this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relation, { prefix }, 'COUNT(*)', {})) + this.resolveSortDirection(countDirection));
710
+ continue;
711
+ }
694
712
  const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, opts.joins ?? NO_JOINS, `cannot $sort by relation '${relPath}': this statement joins no relations`);
695
713
  // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join
696
714
  // brought in for the sort alone has nothing to order by. Populating it selects its columns.
@@ -767,11 +785,22 @@ export class AbstractSqlDialect extends IndexSqlDialect {
767
785
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
768
786
  ctx.append(` FOR UPDATE${target}${suffix}`);
769
787
  }
770
- // `QueryFilter`, not `QuerySearch`: a count orders nothing and pages nothing, and an `OFFSET`
771
- // would push its single row out of the result set. The parameter type is what keeps them away.
788
+ // `QueryFilter`, not `QueryPage`: a count orders nothing and pages nothing, and an `OFFSET`
789
+ // would push its single row out of the result set. The type only stops a statically-checked
790
+ // caller though - the HTTP handler hands this `q` straight from the wire - so `$where` is read
791
+ // off it explicitly rather than forwarding `q` itself into `search()`, which would honor a
792
+ // `$sort`/`$skip`/`$limit` an untyped caller snuck in regardless of what TypeScript allowed them.
772
793
  count(ctx, entity, q, opts) {
773
- this.select(ctx, entity, { $select: [raw('COUNT(*)', 'count')] });
774
- this.search(ctx, entity, q, opts);
794
+ this.select(ctx, entity, { $select: [raw('COUNT(*)', COUNT_ALIAS)] });
795
+ this.search(ctx, entity, { $where: q.$where }, opts);
796
+ }
797
+ /**
798
+ * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
799
+ * keep one; the rest throw, because falling back to `COUNT(*)` would run exactly the scan the
800
+ * caller reached for this to avoid, and only say so by taking a long time.
801
+ */
802
+ estimatedCount(_ctx, _entity) {
803
+ throw new TypeError(`${this.dialectName} does not support estimatedCount`);
775
804
  }
776
805
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
777
806
  * comes from query data, so anything outside this map must be rejected rather than passed
@@ -882,11 +911,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
882
911
  }
883
912
  });
884
913
  }
885
- find(ctx, entity, q = {}, opts) {
914
+ /**
915
+ * How many rows the filter matched, on every row of the page. A window function runs before
916
+ * LIMIT/OFFSET, so what it counts is the whole match rather than the page cut out of it.
917
+ */
918
+ totalOverExpr = 'COUNT(*) OVER ()';
919
+ find(ctx, entity, q = {}, opts, totalAlias) {
886
920
  // The one statement that can join, so the one that resolves the join set; everything else renders
887
921
  // against `NO_JOINS` and rejects a `$sort` that would need one.
888
922
  const joins = resolveQueryJoins(getMeta(entity), q);
889
- this.select(ctx, entity, q, opts, joins);
923
+ this.select(ctx, entity, q, opts, joins, totalAlias);
890
924
  this.search(ctx, entity, q, opts, joins);
891
925
  // Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
892
926
  // to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
@@ -1388,8 +1422,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1388
1422
  * invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
1389
1423
  * not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
1390
1424
  */
1391
- appendRelationSubquery(ctx, entity, rel, opts, projection, val) {
1392
- const meta = getMeta(entity);
1425
+ appendRelationSubquery(ctx, meta, rel, opts, projection, val) {
1393
1426
  // Aliases, not paths, everywhere a column is prefixed; `tableRef` declares them in the FROM.
1394
1427
  const parentAlias = this.resolveTableAlias(meta);
1395
1428
  const references = rel.references;
@@ -1429,11 +1462,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1429
1462
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
1430
1463
  compareRelation(ctx, entity, val, rel, opts) {
1431
1464
  ctx.append('EXISTS ');
1432
- this.appendRelationSubquery(ctx, entity, rel, opts, '1', val);
1465
+ this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, '1', val);
1433
1466
  }
1434
1467
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
1435
1468
  compareRelationSize(ctx, entity, sizeVal, rel, opts) {
1436
- this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, entity, rel, opts, 'COUNT(*)', {}), sizeVal);
1469
+ this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, 'COUNT(*)', {}), sizeVal);
1437
1470
  }
1438
1471
  /**
1439
1472
  * Build a complete `$size` comparison expression.
@@ -13,6 +13,12 @@ import { AbstractSqlDialect } from './abstractSqlDialect.js';
13
13
  export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
14
14
  /** Default {@link DialectFeatures} for MySQL-compatible SQL dialects. */
15
15
  protected readonly featureDefaults: DialectFeatures;
16
+ /**
17
+ * `information_schema` keeps InnoDB's own row estimate, which is live enough to answer before
18
+ * anything has been analyzed. `DATABASE()` where the entity names no schema, so the estimate comes
19
+ * from the connection's own database rather than a same-named table in another one.
20
+ */
21
+ estimatedCount<E>(ctx: QueryContext, entity: Type<E>): void;
16
22
  /** `OFFSET` is only legal after a `LIMIT` here, so a bare `$skip` needs one. */
17
23
  pager(ctx: QueryContext, opts: QueryPager): void;
18
24
  readonly serialPrimaryKey = "BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY";
@@ -1,7 +1,7 @@
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 } from './abstractSqlDialect.js';
4
+ import { AbstractSqlDialect, COUNT_ALIAS } from './abstractSqlDialect.js';
5
5
  import { JSON_PULL_ALIAS, jsonAssignCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
6
6
  /** The row count MySQL's manual gives for "all rows from the offset on": the largest `BIGINT UNSIGNED`. */
7
7
  const MAX_LIMIT = BigInt.asUintN(64, -1n);
@@ -33,6 +33,24 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
33
33
  supportsTimestamptz: false,
34
34
  defaultStringAsText: false,
35
35
  };
36
+ /**
37
+ * `information_schema` keeps InnoDB's own row estimate, which is live enough to answer before
38
+ * anything has been analyzed. `DATABASE()` where the entity names no schema, so the estimate comes
39
+ * from the connection's own database rather than a same-named table in another one.
40
+ */
41
+ estimatedCount(ctx, entity) {
42
+ const meta = getMeta(entity);
43
+ const schema = this.resolveSchema(meta);
44
+ ctx.append(`SELECT TABLE_ROWS ${this.escapeId(COUNT_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
45
+ if (schema) {
46
+ ctx.addValue(schema);
47
+ }
48
+ else {
49
+ ctx.append('DATABASE()');
50
+ }
51
+ ctx.append(' AND TABLE_NAME = ');
52
+ ctx.addValue(this.resolveTableAlias(meta));
53
+ }
36
54
  /** `OFFSET` is only legal after a `LIMIT` here, so a bare `$skip` needs one. */
37
55
  pager(ctx, opts) {
38
56
  if (opts.$limit === undefined && opts.$skip !== undefined) {
@@ -40,6 +40,12 @@ export type QuerySortOptions = {
40
40
  * statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
41
41
  */
42
42
  export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
43
+ /**
44
+ * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
45
+ * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
46
+ * it counts the parents the read will never hand back.
47
+ */
48
+ export declare function hasRequiredJoin<E>(meta: EntityMeta<E>, q: Query<E>): boolean;
43
49
  /**
44
50
  * The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
45
51
  * Every backend answers this the same way - a to-many has no single value to order by, a relation
@@ -16,6 +16,19 @@ export function resolveQueryJoins(meta, q) {
16
16
  addSortJoins(joins, meta, q.$sort);
17
17
  return joins;
18
18
  }
19
+ /**
20
+ * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
21
+ * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
22
+ * it counts the parents the read will never hand back.
23
+ */
24
+ export function hasRequiredJoin(meta, q) {
25
+ for (const join of resolveQueryJoins(meta, q).values()) {
26
+ if (join.required) {
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
19
32
  function addJoin(joins, parent, key, relation, query, required, projected) {
20
33
  const path = parent ? `${parent.path}.${key}` : key;
21
34
  const existing = joins.get(path);
@@ -78,13 +78,6 @@ export type RouteMatch = {
78
78
  * Resolve a (method, sub-path) pair to a CRUD operation. Literal sub-paths win over `:id`.
79
79
  */
80
80
  export declare function matchRoute(method: string, subPath: string | undefined): RouteMatch | undefined;
81
- export type RequestSuccessResponse<E> = {
82
- data: E;
83
- count?: number;
84
- };
85
- export type RequestCountedSuccessResponse<E> = RequestSuccessResponse<E> & {
86
- count: number;
87
- };
88
81
  export type RequestErrorResponse = {
89
82
  readonly error: {
90
83
  readonly message: string;
@@ -1,5 +1,5 @@
1
- import type { EntityMeta, QuerierPool, Query, Type, UqlContext } from '../type/index.js';
2
- import { type CrudOperation, type HttpMethod, type RequestSuccessResponse } from './contract.js';
1
+ import type { EntityMeta, QuerierPool, Query, RequestSuccessResponse, Type, UqlContext } from '../type/index.js';
2
+ import { type CrudOperation, type HttpMethod } from './contract.js';
3
3
  /**
4
4
  * Framework-normalized request: adapters (express, fetch, ...) reduce their native
5
5
  * request to this shape and get back a status + JSON body.
@@ -1,6 +1,6 @@
1
1
  import { withContext } from '../context/context.js';
2
2
  import { getEntities, getMeta } from '../entity/index.js';
3
- import { entityPath, matchRoute, } from './contract.js';
3
+ import { entityPath, matchRoute } from './contract.js';
4
4
  import { parseQueryParams } from './query.js';
5
5
  /** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
6
6
  function tableOf(entity) {
@@ -1,5 +1,5 @@
1
1
  // the clause lists themselves, not the barrel: this module is in the browser bundle's graph
2
- import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES } from '../type/query.js';
2
+ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_OBJECT_CLAUSES, } from '../type/query.js';
3
3
  // the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
4
4
  import { getKeys } from '../util/object.util.js';
5
5
  /**
@@ -10,6 +10,7 @@ import { getKeys } from '../util/object.util.js';
10
10
  */
11
11
  const ALLOWED_QUERY_KEYS = new Set([
12
12
  ...QUERY_OBJECT_CLAUSES,
13
+ ...QUERY_ROOT_OBJECT_CLAUSES,
13
14
  ...QUERY_NUMBER_CLAUSES,
14
15
  ...QUERY_BOOLEAN_CLAUSES,
15
16
  'hardDelete',
@@ -36,7 +37,7 @@ export function parseQueryParams(params = {}) {
36
37
  query[key] = params[key];
37
38
  }
38
39
  }
39
- for (const key of QUERY_OBJECT_CLAUSES) {
40
+ for (const key of [...QUERY_OBJECT_CLAUSES, ...QUERY_ROOT_OBJECT_CLAUSES]) {
40
41
  const value = query[key];
41
42
  if (typeof value === 'string') {
42
43
  try {
@@ -20,7 +20,12 @@ export declare class MongoDialect extends AbstractDialect {
20
20
  private static readonly ID_KEY;
21
21
  /** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
22
22
  private static readonly REL_TEMP_PREFIX;
23
- private static readonly REL_COUNT_KEY;
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;
24
29
  private static readonly REL_NESTED_KEY;
25
30
  private static readonly VECTOR_INDEX_TYPES;
26
31
  /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
@@ -60,6 +65,12 @@ export declare class MongoDialect extends AbstractDialect {
60
65
  * relation subquery can no more read out-of-scope rows than a direct query on the target can.
61
66
  */
62
67
  private appendRelationLookup;
68
+ /**
69
+ * The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
70
+ * straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
71
+ * for existence, or a `$count` - so a filter and an ordering build the same stage.
72
+ */
73
+ private relationLookup;
63
74
  /**
64
75
  * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
65
76
  * lookup - the junction's filters apply too, since a soft-deleted link is not a link.
@@ -112,6 +123,15 @@ export declare class MongoDialect extends AbstractDialect {
112
123
  sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
113
124
  /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
114
125
  private collectSort;
126
+ /**
127
+ * The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
128
+ * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
129
+ * orders by. A parent with no related row gets no lookup result at all, which is a zero.
130
+ */
131
+ sortCountStages<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined, opts?: QueryOptions): {
132
+ readonly stages: MongoAggregationPipelineEntry<Document>[];
133
+ readonly fields: string[];
134
+ };
115
135
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
116
136
  sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
117
137
  /**
@@ -3,7 +3,7 @@ import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
4
4
  import { getMeta } from '../entity/index.js';
5
5
  import { QueryRaw } from '../type/queryRaw.js';
6
- import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, someKey, } from '../util/index.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
7
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
8
8
  export const mongoDialectFeatures = {
9
9
  explicitJsonCast: false,
@@ -29,7 +29,14 @@ export class MongoDialect extends AbstractDialect {
29
29
  static ID_KEY = '_id';
30
30
  /** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
31
31
  static REL_TEMP_PREFIX = '__uql_rel_';
32
- static REL_COUNT_KEY = 'n';
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
+ }
33
40
  static REL_NESTED_KEY = '__uql_target';
34
41
  static VECTOR_INDEX_TYPES = new Set(['vectorSearch', 'hnsw', 'ivfflat', 'vector']);
35
42
  /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
@@ -145,7 +152,7 @@ export class MongoDialect extends AbstractDialect {
145
152
  const temp = `${MongoDialect.REL_TEMP_PREFIX}${lookups.temps.length}`;
146
153
  const sizeVal = parseRelationSize(val);
147
154
  // `$count` for a size test, `$limit: 1` for existence: neither returns the matched documents.
148
- const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: MongoDialect.REL_COUNT_KEY }];
155
+ const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_AGG_ALIAS }];
149
156
  // Scope first, render once - merging the target's filters into an already-rendered filter would
150
157
  // leave their own keys unmapped. The caller's filter bypass is deliberately *not* passed down:
151
158
  // `withDeleted()` or `hardDelete` on the parent must not un-hide trashed rows of the target, the
@@ -153,7 +160,18 @@ export class MongoDialect extends AbstractDialect {
153
160
  const targetCondition = (sizeVal === undefined ? val : {});
154
161
  const targetScope = this.renderFilter(relEntity, this.scopedWhereMap(relMeta, targetCondition), opts);
155
162
  lookups.temps.push(temp);
156
- lookups.stages.push(relOpts.cardinality === 'mm' && relOpts.through
163
+ lookups.stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts));
164
+ return sizeVal === undefined
165
+ ? { [`${temp}.0`]: { $exists: true } }
166
+ : { $expr: this.compareRelationCount(temp, sizeVal) };
167
+ }
168
+ /**
169
+ * The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
170
+ * straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
171
+ * for existence, or a `$count` - so a filter and an ordering build the same stage.
172
+ */
173
+ relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
174
+ return relOpts.cardinality === 'mm' && relOpts.through
157
175
  ? this.junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts)
158
176
  : {
159
177
  $lookup: {
@@ -162,10 +180,7 @@ export class MongoDialect extends AbstractDialect {
162
180
  pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), ...tail],
163
181
  as: temp,
164
182
  },
165
- });
166
- return sizeVal === undefined
167
- ? { [`${temp}.0`]: { $exists: true } }
168
- : { $expr: this.compareRelationCount(temp, sizeVal) };
183
+ };
169
184
  }
170
185
  /**
171
186
  * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
@@ -204,7 +219,7 @@ export class MongoDialect extends AbstractDialect {
204
219
  * the `$ifNull` fallback to 0, so `{ $size: 0 }` matches parents with no related row at all.
205
220
  */
206
221
  compareRelationCount(temp, sizeVal) {
207
- const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${MongoDialect.REL_COUNT_KEY}`, 0] }, 0] };
222
+ const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] };
208
223
  if (typeof sizeVal === 'number') {
209
224
  return { $eq: [count, sizeVal] };
210
225
  }
@@ -397,10 +412,47 @@ export class MongoDialect extends AbstractDialect {
397
412
  // one: ordering by a relation nothing looked up reads a field that is not there, which MongoDB
398
413
  // ranks as all-equal rather than rejecting. The SQL dialects can add the join themselves.
399
414
  const relPath = `${path}${key}`;
415
+ const countDirection = parseSortByCount(value);
416
+ if (countDirection !== undefined) {
417
+ // The tally rides on a field {@link sortCountStages} adds, which only the queried entity's
418
+ // own pipeline has: a nested one is built inside its parent's `$lookup`, where there is no
419
+ // parent document left to hang it off.
420
+ if (path) {
421
+ throw new TypeError(`$sort by '${relPath}.$count' is only supported on the queried entity`);
422
+ }
423
+ out[MongoDialect.sortCountField(key)] = sortDirection(countDirection);
424
+ continue;
425
+ }
400
426
  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`);
401
427
  this.collectSort(join.meta, relationSort, joins, `${relPath}.`, out);
402
428
  }
403
429
  }
430
+ /**
431
+ * The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
432
+ * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
433
+ * orders by. A parent with no related row gets no lookup result at all, which is a zero.
434
+ */
435
+ sortCountStages(entity, sort, opts) {
436
+ const meta = getMeta(entity);
437
+ const stages = [];
438
+ const fields = [];
439
+ for (const [key, value] of Object.entries(sort ?? {})) {
440
+ const relOpts = meta.relations[key];
441
+ if (!relOpts || parseSortByCount(value) === undefined) {
442
+ continue;
443
+ }
444
+ const relEntity = relOpts.entity();
445
+ const relMeta = getMeta(relEntity);
446
+ const temp = MongoDialect.sortCountField(key);
447
+ const targetScope = this.renderFilter(relEntity, this.scopedWhereMap(relMeta, {}), opts);
448
+ const tail = [{ $count: COUNT_AGG_ALIAS }];
449
+ stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts), {
450
+ $addFields: { [temp]: { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] } },
451
+ });
452
+ fields.push(temp);
453
+ }
454
+ return { stages, fields };
455
+ }
404
456
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
405
457
  sortsRelations(entity, sort) {
406
458
  if (!sort) {
@@ -459,7 +511,10 @@ export class MongoDialect extends AbstractDialect {
459
511
  readStages(entity, q, opts, extra = {}) {
460
512
  const meta = getMeta(entity);
461
513
  const joins = resolveQueryJoins(meta, q);
462
- const lookups = this.lookupStages(meta, joins, undefined, opts);
514
+ // The tally an ordering by a relation's size reads, and the field it parks it on: both belong
515
+ // with the lookups, since the `$sort` right after them is what they exist for.
516
+ const counted = this.sortCountStages(entity, q.$sort, opts);
517
+ const lookups = [...this.lookupStages(meta, joins, undefined, opts), ...counted.stages];
463
518
  const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
464
519
  const pager = extra.pager ?? [];
465
520
  // Merged into the query's own projection rather than standing in for one: a query that asked
@@ -471,10 +526,14 @@ export class MongoDialect extends AbstractDialect {
471
526
  // which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
472
527
  // it has run, so ordering by an unpopulated relation costs the same nothing it does there.
473
528
  const sortOnly = [...joins.values()].filter((join) => !join.projected).map((join) => join.path);
474
- const unset = sortOnly.length ? [{ $unset: sortOnly }] : [];
529
+ const dropped = [...sortOnly, ...counted.fields];
530
+ const unset = dropped.length ? [{ $unset: dropped }] : [];
475
531
  // The grouping collapses rows onto the columns it projects, which leaves nothing for an ordering
476
532
  // that reads a lookup those columns do not carry. Refused rather than answered all-equal, and in
477
533
  // the same terms the SQL dialects refuse `SELECT DISTINCT` ordered by an unselected column.
534
+ if (q.$distinct && counted.fields.length) {
535
+ throw new TypeError(`cannot $sort by a relation's $count with $distinct: the grouping keeps only the columns it projects`);
536
+ }
478
537
  if (q.$distinct && sortOnly.length) {
479
538
  throw new TypeError(`cannot $sort by relation '${sortOnly[0]}' with $distinct unless '${sortOnly[0]}' is populated: the grouping keeps only the columns it projects`);
480
539
  }
@@ -1,6 +1,6 @@
1
1
  import type { Document, MongoClient } from 'mongodb';
2
2
  import { AbstractQuerier } from '../querier/index.js';
3
- import type { EntityData, ExtraOptions, IdValue, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
+ import type { EntityData, ExtraOptions, IdValue, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
4
4
  import type { MongoDialect } from './mongoDialect.js';
5
5
  export declare class MongodbQuerier extends AbstractQuerier {
6
6
  readonly dialect: MongoDialect;
@@ -22,7 +22,18 @@ export declare class MongodbQuerier extends AbstractQuerier {
22
22
  */
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
- protected internalCount<E extends Document>(entity: Type<E>, qm?: QuerySearch<E>, opts?: QueryOptions): Promise<number>;
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.
29
+ */
30
+ protected internalFindManyAndCount<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
31
+ protected internalCount<E extends Document>(entity: Type<E>, qm?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
32
+ /**
33
+ * The collection's metadata count, which the driver exposes as its own call and which takes no
34
+ * filter - the reason {@link UniversalQuerier.estimatedCount} takes none either.
35
+ */
36
+ estimatedCount<E extends Document>(entity: Type<E>): Promise<number>;
26
37
  /**
27
38
  * The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
28
39
  * Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
@@ -1,6 +1,15 @@
1
+ import { hasRequiredJoin } from '../dialect/queryJoins.js';
1
2
  import { getMeta } from '../entity/index.js';
2
3
  import { AbstractQuerier, enrichError } from '../querier/index.js';
3
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
4
+ import { COUNT_AGG_ALIAS, clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ /**
6
+ * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
7
+ * as *unlimited*, so a read that passed it straight to the driver came back with the whole
8
+ * collection. The reads answer it here instead, since no cursor can express it.
9
+ */
10
+ function asksForNoRows(q) {
11
+ return q.$limit === 0;
12
+ }
4
13
  export class MongodbQuerier extends AbstractQuerier {
5
14
  dialect;
6
15
  conn;
@@ -19,6 +28,9 @@ export class MongodbQuerier extends AbstractQuerier {
19
28
  }
20
29
  async internalFindMany(entity, q, opts) {
21
30
  this.dialect.assertNoLock(q);
31
+ if (asksForNoRows(q)) {
32
+ return [];
33
+ }
22
34
  return this.timed('internalFindMany', undefined, async () => {
23
35
  const meta = getMeta(entity);
24
36
  const vectorSort = this.dialect.extractVectorSort(q.$sort);
@@ -51,6 +63,9 @@ export class MongodbQuerier extends AbstractQuerier {
51
63
  });
52
64
  }
53
65
  async *internalFindManyStream(entity, q, opts) {
66
+ if (asksForNoRows(q)) {
67
+ return;
68
+ }
54
69
  const meta = getMeta(entity);
55
70
  const { joinableKeys, toManyKeys } = getRelationRequestSummary(meta, q.$populate);
56
71
  if (joinableKeys.length || toManyKeys.length) {
@@ -99,6 +114,7 @@ export class MongodbQuerier extends AbstractQuerier {
99
114
  if (q.$skip) {
100
115
  cursor.skip(q.$skip);
101
116
  }
117
+ // Only a positive limit reaches the driver; `asksForNoRows` took the zero.
102
118
  if (q.$limit) {
103
119
  cursor.limit(q.$limit);
104
120
  }
@@ -134,14 +150,32 @@ export class MongodbQuerier extends AbstractQuerier {
134
150
  return this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
135
151
  });
136
152
  }
153
+ /**
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.
157
+ */
158
+ async internalFindManyAndCount(entity, q, opts) {
159
+ if (!hasRequiredJoin(getMeta(entity), q)) {
160
+ return super.internalFindManyAndCount(entity, q, opts);
161
+ }
162
+ const { $sort: _sort, $skip: _skip, $limit: _limit, ...unpaged } = q;
163
+ const [founds, counted] = await Promise.all([
164
+ this.internalFindMany(entity, q, opts),
165
+ this.execute((session) => this.collection(entity)
166
+ .aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_AGG_ALIAS }], { session })
167
+ .toArray()),
168
+ ]);
169
+ return [founds, counted[0]?.[COUNT_AGG_ALIAS] ?? 0];
170
+ }
137
171
  async internalCount(entity, qm = {}, opts) {
138
172
  return this.timed('internalCount', undefined, async () => {
139
173
  if (this.dialect.constrainsRelations(entity, qm.$where)) {
140
174
  const { stages, filter } = this.dialect.whereWithRelations(entity, qm.$where, opts);
141
175
  const [counted] = await this.execute((session) => this.collection(entity)
142
- .aggregate([...stages, { $match: filter }, { $count: 'n' }], { session })
176
+ .aggregate([...stages, { $match: filter }, { $count: COUNT_AGG_ALIAS }], { session })
143
177
  .toArray());
144
- return counted?.n ?? 0;
178
+ return counted?.[COUNT_AGG_ALIAS] ?? 0;
145
179
  }
146
180
  const filter = this.dialect.where(entity, qm.$where, opts);
147
181
  return this.execute((session) => this.collection(entity).countDocuments(filter, {
@@ -149,6 +183,13 @@ export class MongodbQuerier extends AbstractQuerier {
149
183
  }));
150
184
  });
151
185
  }
186
+ /**
187
+ * The collection's metadata count, which the driver exposes as its own call and which takes no
188
+ * filter - the reason {@link UniversalQuerier.estimatedCount} takes none either.
189
+ */
190
+ async estimatedCount(entity) {
191
+ return this.timed('estimatedCount', undefined, async () => this.execute((session) => this.collection(entity).estimatedDocumentCount({ session })));
192
+ }
152
193
  /**
153
194
  * The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
154
195
  * Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
@@ -11,4 +11,11 @@ export declare class PostgresDialect extends PgLikeSqlDialect {
11
11
  readonly dialectName: SqlDialectName;
12
12
  readonly vectorExtension: string | undefined;
13
13
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void;
14
+ /**
15
+ * `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
16
+ * (mid-migration, say) where the cast throws. `GREATEST` because a table nothing has analyzed yet
17
+ * carries `reltuples = -1`, Postgres' "no statistic" (not `NULL`, and not `0`) since PG 14 - handed
18
+ * back raw it would read as a negative row count.
19
+ */
20
+ estimatedCount<E>(ctx: QueryContext, entity: Type<E>): void;
14
21
  }
@@ -1,4 +1,6 @@
1
+ import { COUNT_ALIAS } from '../dialect/abstractSqlDialect.js';
1
2
  import { PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
3
+ import { getMeta } from '../entity/index.js';
2
4
  /**
3
5
  * PostgreSQL dialect. For node-pg use PgDialect. Neon, Bun SQL, and Cockroach use driver-specific
4
6
  * subclasses. Shared Postgres-wire AST/quoting/JSONB/full-text-search/vector-search logic
@@ -13,4 +15,15 @@ export class PostgresDialect extends PgLikeSqlDialect {
13
15
  // The xmax system column is 0 for a newly inserted row and non-zero for an updated one (MVCC).
14
16
  super.upsert(ctx, entity, conflictPaths, payload, `, (xmax = 0) AS ${this.escapeId('_created')}`);
15
17
  }
18
+ /**
19
+ * `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
20
+ * (mid-migration, say) where the cast throws. `GREATEST` because a table nothing has analyzed yet
21
+ * carries `reltuples = -1`, Postgres' "no statistic" (not `NULL`, and not `0`) since PG 14 - handed
22
+ * back raw it would read as a negative row count.
23
+ */
24
+ estimatedCount(ctx, entity) {
25
+ ctx.append(`SELECT GREATEST(reltuples, 0)::bigint ${this.escapeId(COUNT_ALIAS, true)} FROM pg_class WHERE oid = to_regclass(`);
26
+ ctx.addValue(this.escapedTableName(getMeta(entity)));
27
+ ctx.append(')');
28
+ }
16
29
  }