uql-orm 0.36.1 → 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.
Files changed (54) 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 +31 -4
  10. package/dist/dialect/abstractSqlDialect.js +64 -15
  11. package/dist/dialect/aliases.d.ts +30 -0
  12. package/dist/dialect/aliases.js +32 -0
  13. package/dist/dialect/jsonSql.d.ts +0 -20
  14. package/dist/dialect/jsonSql.js +0 -20
  15. package/dist/dialect/mysqlLikeSqlDialect.d.ts +6 -0
  16. package/dist/dialect/mysqlLikeSqlDialect.js +20 -1
  17. package/dist/dialect/pgLikeSqlDialect.d.ts +2 -0
  18. package/dist/dialect/pgLikeSqlDialect.js +4 -1
  19. package/dist/dialect/queryJoins.d.ts +6 -0
  20. package/dist/dialect/queryJoins.js +13 -0
  21. package/dist/http/contract.d.ts +0 -7
  22. package/dist/http/handler.d.ts +2 -2
  23. package/dist/http/handler.js +1 -1
  24. package/dist/http/query.js +3 -2
  25. package/dist/mongo/mongoDialect.d.ts +15 -4
  26. package/dist/mongo/mongoDialect.js +65 -16
  27. package/dist/mongo/mongodbQuerier.d.ts +13 -2
  28. package/dist/mongo/mongodbQuerier.js +46 -2
  29. package/dist/postgres/postgresDialect.d.ts +7 -0
  30. package/dist/postgres/postgresDialect.js +13 -0
  31. package/dist/querier/abstractQuerier.d.ts +34 -18
  32. package/dist/querier/abstractQuerier.js +43 -30
  33. package/dist/querier/abstractQuerierPool.d.ts +9 -7
  34. package/dist/querier/abstractQuerierPool.js +6 -0
  35. package/dist/querier/abstractSqlQuerier.d.ts +30 -2
  36. package/dist/querier/abstractSqlQuerier.js +61 -8
  37. package/dist/querier/relationCount.d.ts +16 -0
  38. package/dist/querier/relationCount.js +104 -0
  39. package/dist/sqlite/sqliteDialect.d.ts +6 -1
  40. package/dist/sqlite/sqliteDialect.js +12 -1
  41. package/dist/type/dialect.d.ts +10 -3
  42. package/dist/type/index.d.ts +1 -0
  43. package/dist/type/index.js +1 -0
  44. package/dist/type/querier.d.ts +21 -14
  45. package/dist/type/query.d.ts +77 -16
  46. package/dist/type/query.js +17 -0
  47. package/dist/type/universalQuerier.d.ts +85 -48
  48. package/dist/type/wire.d.ts +37 -0
  49. package/dist/type/wire.js +1 -0
  50. package/dist/util/dialect.util.d.ts +6 -0
  51. package/dist/util/dialect.util.js +15 -0
  52. package/dist/util/relationQuery.util.d.ts +9 -0
  53. package/dist/util/relationQuery.util.js +13 -0
  54. package/package.json +4 -4
@@ -1,10 +1,11 @@
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
+ 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';
@@ -91,8 +92,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
91
92
  }
92
93
  returningId(entity) {
93
94
  const meta = getMeta(entity);
94
- const idKey = (meta.id ?? 'id');
95
- const idName = this.columnOf(meta, idKey);
95
+ const idName = this.columnOf(meta, meta.id);
96
96
  return `RETURNING ${this.escapeId(idName)} ${this.escapeId('id')}`;
97
97
  }
98
98
  search(ctx, entity, q = {}, opts = {}, joins = NO_JOINS) {
@@ -171,7 +171,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
171
171
  appendTextSearch(_ctx, _entity, _meta, _search) {
172
172
  throw new TypeError(`${this.dialectName} does not support $text full-text search`);
173
173
  }
174
- select(ctx, entity, q, opts = {}, joins = NO_JOINS) {
174
+ select(ctx, entity, q, opts = {}, joins = NO_JOINS, totalAlias) {
175
175
  const meta = getMeta(entity);
176
176
  const { alias, ref } = this.tableRef(meta);
177
177
  const prefix = this.resolveRelationAwarePrefix(alias, meta, opts, q.$populate, joins);
@@ -186,6 +186,9 @@ export class AbstractSqlDialect extends IndexSqlDialect {
186
186
  this.appendVectorProjection(ctx, meta, key, val);
187
187
  }
188
188
  }
189
+ if (totalAlias) {
190
+ ctx.append(`, ${this.totalOverExpr} ${this.escapeId(totalAlias, true)}`);
191
+ }
189
192
  ctx.append(` FROM ${ref}`);
190
193
  // Add JOINs AFTER FROM clause
191
194
  this.selectRelationJoins(ctx, meta, alias, joins);
@@ -691,6 +694,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
691
694
  const relation = meta.relations[key];
692
695
  if (relation) {
693
696
  const relPath = path ? `${path}.${key}` : key;
697
+ const countDirection = parseSortByCount(value);
698
+ if (countDirection !== undefined) {
699
+ // A correlated count, not a join: a parent has many of these, so what is being ordered by
700
+ // is how many, and `SELECT DISTINCT` cannot order by an expression it did not select.
701
+ if (opts.distinct) {
702
+ throw new TypeError(`cannot $sort by '${relPath}.$count' with $distinct: it is not a selected column`);
703
+ }
704
+ columns.push(this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relation, { prefix }, 'COUNT(*)', {})) + this.resolveSortDirection(countDirection));
705
+ continue;
706
+ }
694
707
  const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, opts.joins ?? NO_JOINS, `cannot $sort by relation '${relPath}': this statement joins no relations`);
695
708
  // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join
696
709
  // brought in for the sort alone has nothing to order by. Populating it selects its columns.
@@ -735,6 +748,12 @@ export class AbstractSqlDialect extends IndexSqlDialect {
735
748
  }
736
749
  /** Whether this engine has row locks at all. The SQLite family locks the database instead. */
737
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;
738
757
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
739
758
  supportsLockOf = true;
740
759
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
@@ -767,11 +786,37 @@ export class AbstractSqlDialect extends IndexSqlDialect {
767
786
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
768
787
  ctx.append(` FOR UPDATE${target}${suffix}`);
769
788
  }
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.
789
+ // `QueryFilter`, not `QueryPage`: a count orders nothing and pages nothing, and an `OFFSET`
790
+ // would push its single row out of the result set. The type only stops a statically-checked
791
+ // caller though - the HTTP handler hands this `q` straight from the wire - so `$where` is read
792
+ // off it explicitly rather than forwarding `q` itself into `search()`, which would honor a
793
+ // `$sort`/`$skip`/`$limit` an untyped caller snuck in regardless of what TypeScript allowed them.
772
794
  count(ctx, entity, q, opts) {
773
- this.select(ctx, entity, { $select: [raw('COUNT(*)', 'count')] });
774
- this.search(ctx, entity, q, opts);
795
+ this.select(ctx, entity, { $select: [raw('COUNT(*)', COUNT_ALIAS)] });
796
+ this.search(ctx, entity, { $where: q.$where }, opts);
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
+ }
813
+ /**
814
+ * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
815
+ * keep one; the rest throw, because falling back to `COUNT(*)` would run exactly the scan the
816
+ * caller reached for this to avoid, and only say so by taking a long time.
817
+ */
818
+ estimatedCount(_ctx, _entity) {
819
+ throw new TypeError(`${this.dialectName} does not support estimatedCount`);
775
820
  }
776
821
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
777
822
  * comes from query data, so anything outside this map must be rejected rather than passed
@@ -882,11 +927,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
882
927
  }
883
928
  });
884
929
  }
885
- find(ctx, entity, q = {}, opts) {
930
+ /**
931
+ * How many rows the filter matched, on every row of the page. A window function runs before
932
+ * LIMIT/OFFSET, so what it counts is the whole match rather than the page cut out of it.
933
+ */
934
+ totalOverExpr = 'COUNT(*) OVER ()';
935
+ find(ctx, entity, q = {}, opts, totalAlias) {
886
936
  // The one statement that can join, so the one that resolves the join set; everything else renders
887
937
  // against `NO_JOINS` and rejects a `$sort` that would need one.
888
938
  const joins = resolveQueryJoins(getMeta(entity), q);
889
- this.select(ctx, entity, q, opts, joins);
939
+ this.select(ctx, entity, q, opts, joins, totalAlias);
890
940
  this.search(ctx, entity, q, opts, joins);
891
941
  // Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
892
942
  // to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
@@ -1388,8 +1438,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1388
1438
  * invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
1389
1439
  * not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
1390
1440
  */
1391
- appendRelationSubquery(ctx, entity, rel, opts, projection, val) {
1392
- const meta = getMeta(entity);
1441
+ appendRelationSubquery(ctx, meta, rel, opts, projection, val) {
1393
1442
  // Aliases, not paths, everywhere a column is prefixed; `tableRef` declares them in the FROM.
1394
1443
  const parentAlias = this.resolveTableAlias(meta);
1395
1444
  const references = rel.references;
@@ -1429,11 +1478,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1429
1478
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
1430
1479
  compareRelation(ctx, entity, val, rel, opts) {
1431
1480
  ctx.append('EXISTS ');
1432
- this.appendRelationSubquery(ctx, entity, rel, opts, '1', val);
1481
+ this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, '1', val);
1433
1482
  }
1434
1483
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
1435
1484
  compareRelationSize(ctx, entity, sizeVal, rel, opts) {
1436
- this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, entity, rel, opts, 'COUNT(*)', {}), sizeVal);
1485
+ this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, 'COUNT(*)', {}), sizeVal);
1437
1486
  }
1438
1487
  /**
1439
1488
  * Build a complete `$size` comparison expression.
@@ -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
@@ -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";
@@ -2,7 +2,8 @@ import { getMeta } from '../entity/index.js';
2
2
  import { getFieldKeys } from '../util/index.js';
3
3
  import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
4
4
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
- import { JSON_PULL_ALIAS, jsonAssignCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.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
  /**
@@ -33,6 +34,24 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
33
34
  supportsTimestamptz: false,
34
35
  defaultStringAsText: false,
35
36
  };
37
+ /**
38
+ * `information_schema` keeps InnoDB's own row estimate, which is live enough to answer before
39
+ * anything has been analyzed. `DATABASE()` where the entity names no schema, so the estimate comes
40
+ * from the connection's own database rather than a same-named table in another one.
41
+ */
42
+ estimatedCount(ctx, entity) {
43
+ const meta = getMeta(entity);
44
+ const schema = this.resolveSchema(meta);
45
+ ctx.append(`SELECT TABLE_ROWS ${this.escapeId(COUNT_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
46
+ if (schema) {
47
+ ctx.addValue(schema);
48
+ }
49
+ else {
50
+ ctx.append('DATABASE()');
51
+ }
52
+ ctx.append(' AND TABLE_NAME = ');
53
+ ctx.addValue(this.resolveTableAlias(meta));
54
+ }
36
55
  /** `OFFSET` is only legal after a `LIMIT` here, so a bare `$skip` needs one. */
37
56
  pager(ctx, opts) {
38
57
  if (opts.$limit === undefined && opts.$skip !== undefined) {
@@ -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,
@@ -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 {
@@ -18,10 +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
- private static readonly REL_COUNT_KEY;
24
- private static readonly REL_NESTED_KEY;
25
21
  private static readonly VECTOR_INDEX_TYPES;
26
22
  /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
27
23
  private static readonly MAX_NUM_CANDIDATES;
@@ -60,6 +56,12 @@ export declare class MongoDialect extends AbstractDialect {
60
56
  * relation subquery can no more read out-of-scope rows than a direct query on the target can.
61
57
  */
62
58
  private appendRelationLookup;
59
+ /**
60
+ * The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
61
+ * straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
62
+ * for existence, or a `$count` - so a filter and an ordering build the same stage.
63
+ */
64
+ private relationLookup;
63
65
  /**
64
66
  * ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
65
67
  * lookup - the junction's filters apply too, since a soft-deleted link is not a link.
@@ -112,6 +114,15 @@ export declare class MongoDialect extends AbstractDialect {
112
114
  sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
113
115
  /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
114
116
  private collectSort;
117
+ /**
118
+ * The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
119
+ * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
120
+ * orders by. A parent with no related row gets no lookup result at all, which is a zero.
121
+ */
122
+ sortCountStages<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined, opts?: QueryOptions): {
123
+ readonly stages: MongoAggregationPipelineEntry<Document>[];
124
+ readonly fields: string[];
125
+ };
115
126
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
116
127
  sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
117
128
  /**