uql-orm 0.68.1 → 0.70.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 (56) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +7 -7
  2. package/dist/browser/type/clientQuerier.d.ts +5 -5
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +6 -6
  5. package/dist/cockroachdb/cockroachDialect.js +2 -2
  6. package/dist/dialect/abstractDialect.d.ts +8 -2
  7. package/dist/dialect/abstractDialect.js +17 -1
  8. package/dist/dialect/abstractSqlDialect.d.ts +42 -4
  9. package/dist/dialect/abstractSqlDialect.js +164 -48
  10. package/dist/dialect/aliases.d.ts +15 -4
  11. package/dist/dialect/aliases.js +15 -4
  12. package/dist/dialect/mysqlLikeSqlDialect.js +3 -2
  13. package/dist/dialect/pgLikeSqlDialect.js +1 -0
  14. package/dist/dialect/queryJoins.d.ts +8 -1
  15. package/dist/dialect/queryJoins.js +33 -10
  16. package/dist/entity/decorator/members.d.ts +44 -2
  17. package/dist/entity/decorator/members.js +0 -5
  18. package/dist/entity/metadata/definition.d.ts +8 -3
  19. package/dist/entity/metadata/definition.js +10 -3
  20. package/dist/migrate/codegen/entityCodeGenerator.js +4 -2
  21. package/dist/migrate/introspection/postgresIntrospector.d.ts +6 -0
  22. package/dist/migrate/introspection/postgresIntrospector.js +7 -1
  23. package/dist/migrate/migrator.d.ts +2 -1
  24. package/dist/migrate/migrator.js +5 -3
  25. package/dist/mongo/mongoDialect.d.ts +49 -19
  26. package/dist/mongo/mongoDialect.js +238 -81
  27. package/dist/mongo/mongodbQuerier.d.ts +2 -4
  28. package/dist/mongo/mongodbQuerier.js +16 -14
  29. package/dist/mssql/mssqlDialect.js +3 -2
  30. package/dist/postgres/postgresDialect.js +2 -2
  31. package/dist/querier/abstractQuerier.d.ts +16 -11
  32. package/dist/querier/abstractQuerier.js +28 -8
  33. package/dist/querier/abstractQuerierPool.d.ts +9 -9
  34. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  35. package/dist/querier/abstractSqlQuerier.js +3 -3
  36. package/dist/sqlite/sqliteDialect.js +1 -0
  37. package/dist/turso/tursoDialect.d.ts +1 -1
  38. package/dist/turso/tursoDialect.js +6 -2
  39. package/dist/type/dialect.d.ts +35 -2
  40. package/dist/type/entity.d.ts +120 -4
  41. package/dist/type/migration.d.ts +7 -0
  42. package/dist/type/query.d.ts +7 -10
  43. package/dist/type/queryAggregate.d.ts +77 -42
  44. package/dist/type/queryAggregate.js +4 -21
  45. package/dist/type/queryRaw.d.ts +19 -1
  46. package/dist/type/queryRaw.js +18 -0
  47. package/dist/type/universalQuerier.d.ts +9 -9
  48. package/dist/util/dialect.util.d.ts +6 -2
  49. package/dist/util/dialect.util.js +21 -7
  50. package/dist/util/field.util.d.ts +15 -1
  51. package/dist/util/field.util.js +18 -1
  52. package/dist/util/object.util.d.ts +1 -4
  53. package/dist/util/object.util.js +0 -3
  54. package/dist/util/raw.d.ts +2 -2
  55. package/dist/util/raw.js +29 -2
  56. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
2
2
  import { COUNT_RESULT_KEY, parseQueryLock, QueryRaw, RAW_ALIAS, VECTOR_QUERY_KEYS, } from '../type/index.js';
3
3
  import { isInlinedExpression } from '../util/field.util.js';
4
- import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, raw, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
4
+ import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, aggregateOf, raw, refs, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { escapeAnsiSqlLiteral } from '../util/sqlLiteral.js';
6
- import { COUNT_ALIAS, COUNTED_ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn } from './aliases.js';
6
+ import { AGGREGATE_PAGE_ALIAS, AGGREGATE_VALUE_ALIAS, ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn, } from './aliases.js';
7
7
  import { holdsOperator, isJsonScalar, jsonCompareMode, jsonElemExists, jsonPath, } from './jsonSql.js';
8
8
  import { SqlQueryContext } from './queryContext.js';
9
- import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
9
+ import { groupPathField, NO_JOINS, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
10
  import { resolveVectorCast } from './vectorCast.js';
11
11
  import { VectorSqlDialect } from './vectorSqlDialect.js';
12
12
  /** The key a term answers under in a populated relation's row, which a raw expression has only once aliased. */
@@ -795,7 +795,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
795
795
  }
796
796
  columns.push({
797
797
  key: keyPath,
798
- expr: this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, key, relation, { prefix }, 'COUNT(*)', {})),
798
+ expr: this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, key, relation, { prefix }, { op: '$count' })),
799
799
  direction: this.resolveSortDirection(countDirection),
800
800
  output: false,
801
801
  });
@@ -916,7 +916,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
916
916
  count(ctx, entity, q, opts) {
917
917
  const { $where, $skip, $limit } = q;
918
918
  if ($skip === undefined && $limit === undefined) {
919
- this.select(ctx, entity, { $select: [raw `COUNT(*)`.as(COUNT_ALIAS)] });
919
+ this.select(ctx, entity, { $select: [raw `COUNT(*)`.as(AGGREGATE_VALUE_ALIAS)] });
920
920
  this.search(ctx, entity, { $where }, opts);
921
921
  return;
922
922
  }
@@ -936,9 +936,9 @@ export class AbstractSqlDialect extends VectorSqlDialect {
936
936
  }
937
937
  /** `SELECT COUNT(*)` over the rows `rows` appends, as a derived table. */
938
938
  countRows(ctx, rows) {
939
- ctx.append(`SELECT COUNT(*) ${this.escapeId(COUNT_ALIAS, true)} FROM (`);
939
+ ctx.append(`SELECT COUNT(*) ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM (`);
940
940
  rows();
941
- ctx.append(`) ${this.escapeId(COUNTED_ROWS_ALIAS, true)}`);
941
+ ctx.append(`) ${this.escapeId(ROWS_ALIAS, true)}`);
942
942
  }
943
943
  /**
944
944
  * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
@@ -958,35 +958,40 @@ export class AbstractSqlDialect extends VectorSqlDialect {
958
958
  };
959
959
  aggregate(ctx, entity, q, opts = {}) {
960
960
  const meta = getMeta(entity);
961
- const tableName = this.escapedTableName(meta);
961
+ const entries = parseGroupMap(q.$group, q.$select);
962
+ if (!entries.length) {
963
+ throw new TypeError('aggregate requires at least one $group column or $select function');
964
+ }
965
+ const table = this.tableRef(meta, this.readOptions(ctx, meta).alias);
966
+ const joins = resolveGroupJoins(meta, q.$group, (path) => ctx.claimAlias(path));
967
+ const prefix = joins.size ? table.alias : undefined;
968
+ const reads = entries.map((entry) => ({ entry, value: this.aggregateValue(ctx, entity, entry, joins, prefix) }));
969
+ // Only bare columns are read inline: SQL Server refuses a subquery inside an aggregate or a
970
+ // `GROUP BY`, and a filter's bound values would repeat in `HAVING`. A derived table answers by alias.
971
+ const derived = reads.some(({ value }) => !value.bare);
972
+ const named = (sql, alias) => sql === this.escapeId(alias) ? sql : `${sql} ${this.escapeId(alias)}`;
962
973
  const groupKeys = [];
963
974
  const selectParts = [];
964
975
  // Every column the statement emits, mapped to the SQL that references it. `$having` and `$sort`
965
976
  // may name these and nothing else, so one map answers both "is this legal?" and "what do I
966
977
  // emit for it?".
967
978
  const emittedColumns = {};
968
- for (const entry of parseGroupMap(q.$group, q.$select)) {
979
+ for (const { entry, value } of reads) {
980
+ const column = derived ? this.escapeId(entry.alias) : value.sql;
981
+ const expr = entry.kind === 'key' ? column : this.aggregateFn(entry.op, value.sql === '*' ? '*' : column, entry.distinct);
969
982
  if (entry.kind === 'key') {
970
- const field = meta.fields[entry.alias];
971
- const columnName = this.resolveColumnName(entry.alias, field);
972
- const escaped = this.escapeId(columnName);
973
- groupKeys.push(escaped);
974
- emittedColumns[entry.alias] = escaped;
975
- selectParts.push(columnName !== entry.alias ? `${escaped} ${this.escapeId(entry.alias)}` : escaped);
976
- }
977
- else {
978
- const sqlFn = AbstractSqlDialect.AGGREGATE_FN[entry.op];
979
- const sqlArg = entry.fieldRef === '*' ? '*' : this.escapeId(this.columnOf(meta, entry.fieldRef));
980
- const expr = `${sqlFn}(${entry.distinct ? 'DISTINCT ' : ''}${sqlArg})`;
981
- emittedColumns[entry.alias] = expr;
982
- selectParts.push(`${expr} ${this.escapeId(entry.alias)}`);
983
+ groupKeys.push(expr);
983
984
  }
985
+ emittedColumns[entry.alias] = expr;
986
+ selectParts.push(named(expr, entry.alias));
984
987
  }
985
- if (!selectParts.length) {
986
- throw new TypeError('aggregate requires at least one $group column or $select function');
988
+ const columns = reads.flatMap(({ entry, value }) => (value.sql === '*' ? [] : [named(value.sql, entry.alias)]));
989
+ ctx.append(`SELECT ${selectParts.join(', ')} FROM ${derived ? `(SELECT ${columns.join(', ')} FROM ` : ''}${table.ref}`);
990
+ this.selectRelationJoins(ctx, meta, table.alias, joins);
991
+ this.where(ctx, entity, q.$where, { ...opts, prefix });
992
+ if (derived) {
993
+ ctx.append(`) ${this.escapeId(ROWS_ALIAS, true)}`);
987
994
  }
988
- ctx.append(`SELECT ${selectParts.join(', ')} FROM ${tableName}`);
989
- this.where(ctx, entity, q.$where, opts);
990
995
  if (groupKeys.length) {
991
996
  ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
992
997
  }
@@ -996,6 +1001,34 @@ export class AbstractSqlDialect extends VectorSqlDialect {
996
1001
  const sorted = this.aggregateSort(ctx, q.$sort, emittedColumns);
997
1002
  this.pager(ctx, q, sorted);
998
1003
  }
1004
+ /**
1005
+ * What one entry reads: a grouped field, through the join its path passes, or an aggregate's argument,
1006
+ * narrowed by its own `$where` to `CASE WHEN … THEN … END`. Bare where it is a column or `'*'`.
1007
+ */
1008
+ aggregateValue(ctx, entity, entry, joins, prefix) {
1009
+ if (entry.kind === 'key') {
1010
+ const { key, join } = groupPathField(joins, entry.path);
1011
+ return join
1012
+ ? this.aggregateOperand(ctx, join.entity, key, join.alias)
1013
+ : this.aggregateOperand(ctx, entity, key, prefix);
1014
+ }
1015
+ const arg = entry.fieldRef === '*' ? undefined : this.aggregateOperand(ctx, entity, entry.fieldRef, prefix);
1016
+ const { where } = entry;
1017
+ const condition = where &&
1018
+ this.buildFragment(ctx, (fragment) => this.renderWhere(fragment, entity, where, { clause: false, prefix }));
1019
+ if (!condition) {
1020
+ return arg ?? { sql: '*', bare: true };
1021
+ }
1022
+ return { sql: `CASE WHEN ${condition} THEN ${arg?.sql ?? '1'} END`, bare: false };
1023
+ }
1024
+ /** A field as an aggregate reads it: its column, or the expression an inlined one stands for. */
1025
+ aggregateOperand(ctx, entity, key, prefix) {
1026
+ const field = getMeta(entity).fields[key];
1027
+ return {
1028
+ sql: this.resolveOperandField(ctx, entity, key, { prefix }),
1029
+ bare: field === undefined || !isInlinedExpression(field),
1030
+ };
1031
+ }
999
1032
  /**
1000
1033
  * ORDER BY for aggregate queries - handles both entity-field and alias references. A grouped
1001
1034
  * statement has no joins to address, so a relation key is rejected rather than emitted as an alias
@@ -1296,7 +1329,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1296
1329
  }
1297
1330
  const decoded = [];
1298
1331
  for (const [key, field] of Object.entries(meta.fields)) {
1299
- const kind = this.hydrateKind(field);
1332
+ const kind = this.fieldKind(meta, field);
1300
1333
  if (kind) {
1301
1334
  decoded.push([key, kind]);
1302
1335
  }
@@ -1304,28 +1337,46 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1304
1337
  this.hydratable.set(entity, [meta.revision, decoded]);
1305
1338
  return decoded;
1306
1339
  }
1307
- /**
1308
- * The same for an aggregate's row, per query: a count or total is a number however the engine widened
1309
- * it, and `$min`/`$max` or a grouped column decodes as its field does.
1310
- */
1340
+ /** The same for an aggregate's row, per query: each alias decodes by {@link aggregateKind}. */
1311
1341
  hydratableAggregates(entity, q) {
1312
- const { fields } = getMeta(entity);
1342
+ const meta = getMeta(entity);
1343
+ const joins = resolveGroupJoins(meta, q.$group);
1313
1344
  const decoded = [];
1314
1345
  for (const entry of parseGroupMap(q.$group, q.$select)) {
1315
- if (entry.kind === 'fn' && entry.op !== '$min' && entry.op !== '$max') {
1316
- decoded.push([entry.alias, 'number']);
1317
- continue;
1318
- }
1319
- // `$min`/`$max` read the field they aggregate; a `$group` column is that field. Only `$count`
1320
- // takes `'*'`, and it went down the numeric path above, so there is always a field to look up.
1321
- const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
1322
- const kind = this.hydrateKind(fields[key]);
1346
+ const kind = entry.kind === 'fn'
1347
+ ? this.aggregateKind(entry.op, this.fieldKind(meta, meta.fields[entry.fieldRef]))
1348
+ : this.groupedKind(meta, joins, entry.path);
1323
1349
  if (kind) {
1324
1350
  decoded.push([entry.alias, kind]);
1325
1351
  }
1326
1352
  }
1327
1353
  return decoded;
1328
1354
  }
1355
+ /**
1356
+ * What an aggregate's value decodes as, which the engine widens beyond the column it read: a tally
1357
+ * (`COUNT` to Postgres's `bigint`) and a mean (`AVG` to `numeric`) are numbers whatever they counted,
1358
+ * and the rest read as the column they aggregate - so a `SUM` over a wide integer stays exact rather
1359
+ * than rounding through a float, which is what every driver's BIGINT decoding promises.
1360
+ *
1361
+ * One rule for both readers: a relation aggregate a field declares, and an aggregate a query names.
1362
+ */
1363
+ aggregateKind(op, fieldKind) {
1364
+ return op === '$count' || op === '$avg' ? 'number' : fieldKind;
1365
+ }
1366
+ /** What a grouped path decodes as: the kind of the field it reaches, through its join or on the entity. */
1367
+ groupedKind(meta, joins, path) {
1368
+ const { key, join } = groupPathField(joins, path);
1369
+ return join ? this.fieldKind(join.meta, join.meta.fields[key]) : this.fieldKind(meta, meta.fields[key]);
1370
+ }
1371
+ /** What a field decodes as: its column's kind, or a relation aggregate's {@link aggregateKind} over the target's column. */
1372
+ fieldKind(meta, field) {
1373
+ const spec = aggregateOf(field);
1374
+ if (!spec) {
1375
+ return this.hydrateKind(field);
1376
+ }
1377
+ const target = getMeta(relationOf(meta, spec.relation).entity());
1378
+ return this.aggregateKind(spec.op, spec.field ? this.fieldKind(target, target.fields[spec.field]) : undefined);
1379
+ }
1329
1380
  /** What one column decodes as, the inverse of {@link persistKind}. `BigInt` first, since it shares the numeric family. */
1330
1381
  hydrateKind(field) {
1331
1382
  const type = field?.type;
@@ -1382,8 +1433,6 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1382
1433
  * `$pull` reads the column and every later one its expression once, so no value binds twice.
1383
1434
  */
1384
1435
  formatJsonUpdate(ctx, escapedCol, value, field) {
1385
- // Centralizes the one narrowing cast: the payload's keys are typed against the entity's JSON
1386
- // payload, which the dialects do not need - they only build SQL from keys and values.
1387
1436
  const { $pull, $set, $push, $unset } = value;
1388
1437
  let expr = escapedCol;
1389
1438
  if (hasKeys($pull)) {
@@ -1519,14 +1568,18 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1519
1568
  * invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
1520
1569
  * not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
1521
1570
  */
1522
- appendRelationSubquery(ctx, meta, relKey, rel, opts, projection, val) {
1571
+ appendRelationSubquery(ctx, meta, relKey, rel, opts, read) {
1523
1572
  const relatedEntity = rel.entity();
1524
1573
  const relatedMeta = getMeta(relatedEntity);
1525
1574
  const parent = opts.prefix ?? this.resolveTableAlias(meta);
1526
1575
  // Resolved before any SQL is emitted: it also decides whether the junction form reaches the target.
1527
- const targetWhere = this.scopedWhere(relatedMeta, val);
1528
- ctx.append(`(SELECT ${projection} FROM `);
1576
+ const targetWhere = this.scopedWhere(relatedMeta, read.query?.$where ?? {});
1529
1577
  if (rel.through) {
1578
+ // The rows here are the junction's own, so a column of the far side is read as a page instead.
1579
+ if (read.field) {
1580
+ throw new TypeError(`cannot read ${read.op}('${read.field}') over the many-to-many '${relKey}' without a page: its rows are the junction's, so name a '$sort' and a '$limit' to read the target's own`);
1581
+ }
1582
+ ctx.append(`(SELECT ${read.op === 'exists' ? '1' : 'COUNT(*)'} FROM `);
1530
1583
  const junction = this.junctionRows(ctx, meta, rel, rel.through(), parent);
1531
1584
  ctx.append(junction.from);
1532
1585
  if (hasKeys(targetWhere)) {
@@ -1539,13 +1592,76 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1539
1592
  }
1540
1593
  }
1541
1594
  else {
1595
+ // The alias is claimed before the SELECT is written, since an aggregate names a column of it.
1542
1596
  const related = this.tableRef(relatedMeta, ctx.claimAlias(relKey, parent));
1597
+ ctx.append(`(SELECT ${this.aggregateProjection(read, related.alias, relatedMeta)} FROM `);
1543
1598
  ctx.append(related.ref);
1544
1599
  ctx.append(` WHERE ${this.correlation(meta, rel, parent, related.alias, relatedMeta)}`);
1545
1600
  this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: related.alias, clause: 'AND' });
1546
1601
  }
1547
1602
  ctx.append(')');
1548
1603
  }
1604
+ /**
1605
+ * What a relation subquery selects: the literals a relation operator reads, or an aggregate over one
1606
+ * of the target's columns. `count` and `sum` answer `0` on a parent with no rows, which is what makes
1607
+ * them the two a trigger could keep; the rest answer `NULL`, and the field's type says so.
1608
+ */
1609
+ aggregateProjection(projection, alias, relatedMeta) {
1610
+ if (projection.op === 'exists') {
1611
+ return '1';
1612
+ }
1613
+ return this.aggregateCall(projection.op, projection.field ? this.escapedColumn(alias, relatedMeta, projection.field) : '');
1614
+ }
1615
+ /**
1616
+ * A relation aggregate as the correlated subquery a `computed` field reads, the same one `$count`
1617
+ * emits: `(user) => user.resources.count()` renders here, wherever the field is named.
1618
+ */
1619
+ appendRelationAggregate(ctx, entity, aggregate, prefix) {
1620
+ const meta = getMeta(entity);
1621
+ const rel = relationOf(meta, aggregate.relation);
1622
+ const parent = prefix || this.resolveTableAlias(meta);
1623
+ const { $limit, $skip } = aggregate.query ?? {};
1624
+ if ($limit === undefined && $skip === undefined) {
1625
+ this.appendRelationSubquery(ctx, meta, aggregate.relation, rel, { prefix: parent }, aggregate);
1626
+ return;
1627
+ }
1628
+ // Capped: the rows it reads are a page of the relation, so they are read first - ordered, since an
1629
+ // order is what picks them - and the aggregate runs over that page.
1630
+ const pageAlias = this.escapeId(AGGREGATE_PAGE_ALIAS);
1631
+ const value = this.escapeId(AGGREGATE_VALUE_ALIAS);
1632
+ ctx.append(`(SELECT ${this.aggregateCall(aggregate.op, `${pageAlias}.${value}`)} FROM (`);
1633
+ this.appendRelationPage(ctx, meta, rel, aggregate, parent);
1634
+ ctx.append(`) ${pageAlias})`);
1635
+ }
1636
+ /**
1637
+ * The page a capped aggregate reads: an ordinary read of the related entity under its own alias,
1638
+ * narrowed to the parent's rows, carrying out the one column the aggregate runs over. Its `$sort`,
1639
+ * `$limit` and `$skip` are the relation's own, and its filters apply as they do to any read.
1640
+ */
1641
+ appendRelationPage(ctx, meta, rel, aggregate, parent) {
1642
+ const entity = rel.entity();
1643
+ const alias = ctx.claimAlias(aggregate.relation, parent);
1644
+ const correlation = raw(({ ctx: pageCtx }) => this.appendCorrelation(pageCtx, meta, rel, parent, alias));
1645
+ const { $where, ...page } = aggregate.query ?? {};
1646
+ // `1` where nothing is aggregated: a tally counts the rows the page holds, whatever they carry.
1647
+ const read = aggregate.field ? refs(entity)[aggregate.field] : raw `1`;
1648
+ const query = {
1649
+ ...page,
1650
+ $select: [read.as(AGGREGATE_VALUE_ALIAS)],
1651
+ $where: { ...$where, $and: [...($where?.$and ?? []), correlation] },
1652
+ };
1653
+ const joins = resolveQueryJoins(getMeta(entity), query, (path) => ctx.claimAlias(path));
1654
+ this.read(ctx, entity, query, { alias }, joins);
1655
+ }
1656
+ /** A relation aggregate over an operand, reading `0` on a parent with no rows where its type says so. */
1657
+ aggregateCall(op, operand) {
1658
+ const call = this.aggregateFn(op, op === '$count' ? '*' : operand);
1659
+ return op === '$sum' ? `COALESCE(${call}, 0)` : call;
1660
+ }
1661
+ /** One aggregate function call, the one spelling every statement that aggregates writes. */
1662
+ aggregateFn(op, operand, distinct) {
1663
+ return `${AbstractSqlDialect.AGGREGATE_FN[op]}(${distinct ? 'DISTINCT ' : ''}${operand})`;
1664
+ }
1549
1665
  /**
1550
1666
  * One equality per key of the parent, anded: a composite correlates on every column, and matching on
1551
1667
  * part of one would find the rows of a different parent. `parentJoins` keeps the two ends the right
@@ -1571,7 +1687,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1571
1687
  /** Each relation a read's `$count` tallies, as a correlated count of its select list. */
1572
1688
  selectRelationCounts(ctx, meta, count, parent) {
1573
1689
  return countedRelations(meta, count).map(({ relKey, relation, where }) => {
1574
- const sql = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relKey, relation, { prefix: parent }, 'COUNT(*)', where));
1690
+ const sql = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relKey, relation, { prefix: parent }, { op: '$count', query: { $where: where } }));
1575
1691
  return { sql, key: `${COUNT_RESULT_KEY}.${relKey}` };
1576
1692
  });
1577
1693
  }
@@ -1670,11 +1786,11 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1670
1786
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
1671
1787
  compareRelation(ctx, entity, relKey, val, rel, opts) {
1672
1788
  ctx.append('EXISTS ');
1673
- this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, '1', val);
1789
+ this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, { op: 'exists', query: { $where: val } });
1674
1790
  }
1675
1791
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
1676
1792
  compareRelationSize(ctx, entity, relKey, sizeVal, rel, opts) {
1677
- const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, 'COUNT(*)', {});
1793
+ const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, { op: '$count' });
1678
1794
  ctx.append(this.sizeCondition(ctx, count, sizeVal));
1679
1795
  }
1680
1796
  /**
@@ -1,9 +1,20 @@
1
- /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
2
- export declare const COUNT_ALIAS = "_uql_count";
3
1
  /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
4
2
  export declare const TOTAL_ALIAS = "_uql_total";
5
- /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
6
- export declare const COUNTED_ROWS_ALIAS = "_uql_rows";
3
+ /**
4
+ * The derived table a statement wraps the rows it counts or aggregates in: a page, a `$distinct` set, or
5
+ * rows computing an inlined field an aggregate names. MySQL requires the alias.
6
+ */
7
+ export declare const ROWS_ALIAS = "_uql_rows";
8
+ /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
9
+ export declare const AGGREGATE_PAGE_ALIAS = "_uql_page";
10
+ /**
11
+ * The one value a statement that aggregates answers under: a `COUNT(*)` or an estimate, the column a
12
+ * capped page carries out for the aggregate wrapping it, and the field a MongoDB `$group` or `$count`
13
+ * leaves its result in. One name, since both ends of each are written and read here.
14
+ */
15
+ export declare const AGGREGATE_VALUE_ALIAS = "_uql_value";
16
+ /** Beside each MongoDB `$sum`, how many values it read: `$sum` answers 0 over none, where SQL answers null. */
17
+ export declare const SUM_COUNT_ALIAS = "_uql_count";
7
18
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
8
19
  export declare const RELATION_ROW_ALIAS = "_uql_row";
9
20
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -1,11 +1,22 @@
1
1
  // Every identifier UQL invents, `_uql`-prefixed to stay off a user's own, collected in one place:
2
2
  // the ends writing and reading one sit in different modules, and a drift between them fails silently.
3
- /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
4
- export const COUNT_ALIAS = '_uql_count';
5
3
  /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
6
4
  export const TOTAL_ALIAS = '_uql_total';
7
- /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
8
- export const COUNTED_ROWS_ALIAS = '_uql_rows';
5
+ /**
6
+ * The derived table a statement wraps the rows it counts or aggregates in: a page, a `$distinct` set, or
7
+ * rows computing an inlined field an aggregate names. MySQL requires the alias.
8
+ */
9
+ export const ROWS_ALIAS = '_uql_rows';
10
+ /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
11
+ export const AGGREGATE_PAGE_ALIAS = '_uql_page';
12
+ /**
13
+ * The one value a statement that aggregates answers under: a `COUNT(*)` or an estimate, the column a
14
+ * capped page carries out for the aggregate wrapping it, and the field a MongoDB `$group` or `$count`
15
+ * leaves its result in. One name, since both ends of each are written and read here.
16
+ */
17
+ export const AGGREGATE_VALUE_ALIAS = '_uql_value';
18
+ /** Beside each MongoDB `$sum`, how many values it read: `$sum` answers 0 over none, where SQL answers null. */
19
+ export const SUM_COUNT_ALIAS = '_uql_count';
9
20
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
10
21
  export const RELATION_ROW_ALIAS = '_uql_row';
11
22
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -2,7 +2,7 @@ import { getMeta } from '../entity/index.js';
2
2
  import { textSearchFields } from '../util/index.js';
3
3
  import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
4
4
  import { AbstractSqlDialect, } from './abstractSqlDialect.js';
5
- import { COUNT_ALIAS } from './aliases.js';
5
+ import { AGGREGATE_VALUE_ALIAS } from './aliases.js';
6
6
  import { BYTES_PREFIX } from './hydrateColumn.js';
7
7
  import { jsonSetCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
8
8
  import { aggregatesRelations } from './queryJoins.js';
@@ -27,6 +27,7 @@ export const MYSQL_FEATURES = {
27
27
  stringSizing: 'varchar',
28
28
  supportsUnsigned: true,
29
29
  serverSideCursors: false,
30
+ correlatedWrites: true,
30
31
  rowLocks: true,
31
32
  rowLockWithWindow: true,
32
33
  rowLockOf: true,
@@ -49,7 +50,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
49
50
  estimatedCount(ctx, entity) {
50
51
  const meta = getMeta(entity);
51
52
  const schema = this.resolveSchema(meta);
52
- ctx.append(`SELECT TABLE_ROWS ${this.escapeId(COUNT_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
53
+ ctx.append(`SELECT TABLE_ROWS ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
53
54
  if (schema) {
54
55
  ctx.addValue(schema);
55
56
  }
@@ -29,6 +29,7 @@ export const PG_FEATURES = {
29
29
  stringSizing: 'bounded-text',
30
30
  supportsUnsigned: false,
31
31
  serverSideCursors: true,
32
+ correlatedWrites: true,
32
33
  rowLocks: true,
33
34
  rowLockWithWindow: false,
34
35
  rowLockOf: true,
@@ -1,4 +1,4 @@
1
- import type { EntityMeta, Query, QuerySortMap, RelationMeta, RelationQuery, Type } from '../type/index.js';
1
+ import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, RelationMeta, RelationQuery, Type } from '../type/index.js';
2
2
  /**
3
3
  * One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
4
4
  * `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
@@ -39,6 +39,13 @@ export type QuerySortOptions = {
39
39
  * columns, the `ORDER BY` and the lock agree. `claimAlias` names each join's table, parents first.
40
40
  */
41
41
  export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>, claimAlias?: (path: string) => string): QueryJoins;
42
+ /** What an aggregate joins: each to-one relation a `$group` path passes through. */
43
+ export declare function resolveGroupJoins<E>(meta: EntityMeta<E>, group: QueryGroupMap<E> | undefined, claimAlias?: (path: string) => string): QueryJoins;
44
+ /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
45
+ export declare function groupPathField(joins: QueryJoins, path: readonly string[]): {
46
+ readonly key: string;
47
+ readonly join: QueryJoin | undefined;
48
+ };
42
49
  /**
43
50
  * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
44
51
  * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
@@ -1,5 +1,5 @@
1
1
  import { getMeta, relationOf } from '../entity/index.js';
2
- import { getKeys, getRelationRequestSummary, isToManyRelation, parseRelationAtKey } from '../util/index.js';
2
+ import { getKeys, getRelationRequestSummary, isRecord, isToManyRelation, parseRelationAtKey } from '../util/index.js';
3
3
  export const NO_JOINS = new Map();
4
4
  /**
5
5
  * What the statement joins, from `$populate` and from a `$sort` by a to-one relation's field, so the
@@ -11,9 +11,31 @@ export function resolveQueryJoins(meta, q, claimAlias = (path) => path) {
11
11
  }
12
12
  const joins = new Map();
13
13
  addPopulateJoins(joins, claimAlias, meta, q.$populate);
14
- addSortJoins(joins, claimAlias, meta, q.$sort);
14
+ addPathJoins(joins, claimAlias, meta, q.$sort);
15
15
  return joins;
16
16
  }
17
+ /** What an aggregate joins: each to-one relation a `$group` path passes through. */
18
+ export function resolveGroupJoins(meta, group, claimAlias = (path) => path) {
19
+ const joins = new Map();
20
+ for (const ref of Object.values(group ?? {})) {
21
+ if (isRecord(ref)) {
22
+ addPathJoins(joins, claimAlias, meta, ref);
23
+ }
24
+ }
25
+ return joins;
26
+ }
27
+ /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
28
+ export function groupPathField(joins, path) {
29
+ const key = path[path.length - 1];
30
+ if (path.length === 1) {
31
+ return { key, join: undefined };
32
+ }
33
+ const join = joins.get(path.slice(0, -1).join('.'));
34
+ if (!join) {
35
+ throw new TypeError(`cannot $group by '${path.join('.')}': only a to-one relation's field groups, since a to-many multiplies the rows it joins`);
36
+ }
37
+ return { key, join };
38
+ }
17
39
  /**
18
40
  * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
19
41
  * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
@@ -71,21 +93,22 @@ function addPopulateJoins(joins, claimAlias, meta, populate, parent) {
71
93
  addPopulateJoins(joins, claimAlias, join.meta, query.$populate, join);
72
94
  }
73
95
  }
74
- function addSortJoins(joins, claimAlias, meta, sort, parent) {
75
- if (!sort) {
96
+ /** The to-one relations a nested map of fields passes through, a `$sort` or a `$group` path, as joins adding no columns. */
97
+ function addPathJoins(joins, claimAlias, meta, map, parent) {
98
+ if (!map) {
76
99
  return;
77
100
  }
78
- for (const key of getKeys(sort)) {
101
+ for (const key of getKeys(map)) {
79
102
  const relation = meta.relations[key];
80
- const value = sort[key];
103
+ const value = map[key];
81
104
  // A to-many, or a value that is not a map of the relation's own fields, cannot be joined and is
82
- // reported where the `ORDER BY` is rendered - the one place that knows how to name it.
105
+ // reported where the statement names it - the one place that knows how to.
83
106
  if (!relation || isToManyRelation(relation) || !isSortMap(value)) {
84
107
  continue;
85
108
  }
86
109
  const join = addJoin(joins, claimAlias, parent, key, relation, {}, false, false);
87
- // `E` stated: inferred from a `QuerySortMap<object>`, it lands on the nested relation's target.
88
- addSortJoins(joins, claimAlias, join.meta, value, join);
110
+ // `E` stated: inferred from the nested map, it lands on the nested relation's target.
111
+ addPathJoins(joins, claimAlias, join.meta, value, join);
89
112
  }
90
113
  }
91
114
  /** The join a sort may address at `path` with the relation's own sort map, or why it may not; `unjoinable` is the dialect's remedy. */
@@ -102,7 +125,7 @@ export function resolveSortableJoin(relation, path, value, joins, unjoinable) {
102
125
  }
103
126
  return { join, sort: value };
104
127
  }
105
- /** A nested `$sort` map, as opposed to a direction or a vector search. */
128
+ /** A nested map of fields, as opposed to a `$sort` direction or vector search, or a `$group` field's `true`. */
106
129
  function isSortMap(value) {
107
130
  return typeof value === 'object' && value !== null && !Array.isArray(value) && !('$vector' in value);
108
131
  }
@@ -1,7 +1,19 @@
1
- import type { EntityGetter, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, TsTypeOf } from '../../type/index.js';
1
+ import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf, Writable } from '../../type/index.js';
2
2
  import type { RejectIncompatible } from '../../util/index.js';
3
3
  /** A member decorator that also constrains the property it may be applied to, on a class `O`. */
4
4
  type MemberDecorator<V, O = unknown> = (value: undefined, context: ClassFieldDecoratorContext<O, V>) => void;
5
+ /**
6
+ * A {@link MemberDecorator} whose property has to admit `M` as well as hold `V`. A decorator context is
7
+ * covariant in its value, so on its own it takes a property narrower than the field reads: a `string`
8
+ * where a nullable column reads `string | null`, a `number` where `max()` reads `number | null`.
9
+ *
10
+ * `M` is what each arm insists on rather than the whole of `V`, because a column type that names a
11
+ * family - `jsonb`, whose document the property shapes, or `numeric`, either number kind - is meant to
12
+ * be narrowed. A column insists on its `null`; an aggregate, whose value is exact, insists on all of it.
13
+ */
14
+ type AdmittingDecorator<V, M, O> = <P extends V | undefined>(value: undefined, context: ClassFieldDecoratorContext<O, P> & ([Writable<M>] extends [Writable<P>] ? unknown : {
15
+ readonly __propertyMustAdmit: M;
16
+ })) => void;
5
17
  /**
6
18
  * The property type a set of field options describes: the declared `type`, narrowed by `enum` to the
7
19
  * values that type admits (so `enum: [2]` stays off a `String`), or else the referenced key's own type,
@@ -16,6 +28,16 @@ type DeclaredValue<O> = O extends {
16
28
  } ? HasCompositeKey<E> extends true ? {
17
29
  readonly __compositeKeyNeedsAColumnPerKey: true;
18
30
  } : IdValue<E> : never;
31
+ /**
32
+ * The `null` a column reads back: every one holds it unless `nullable: false` says otherwise, so the
33
+ * property admits it too. A key holds none, whether `@Id` or `@Field({ isId: true })` declares it, since
34
+ * it is NOT NULL on every engine.
35
+ */
36
+ type NullOf<O> = O extends {
37
+ readonly nullable: false;
38
+ } | {
39
+ readonly isId: true;
40
+ } ? never : null;
19
41
  /** The enum's members, or a named complaint where they widened for lack of `as const`, which would check nothing. */
20
42
  type EnumValue<Members, Declared> = Declared extends Members ? {
21
43
  readonly __enumNeedsAsConst: true;
@@ -29,7 +51,27 @@ export declare function Field<This, O extends FieldOptions<DeclaredValue<O>, Thi
29
51
  type: FieldType;
30
52
  } | {
31
53
  references: EntityGetter;
32
- }) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined, This>;
54
+ }) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): AdmittingDecorator<DeclaredValue<O> | NullOf<O>, NullOf<O>, This>;
55
+ /**
56
+ * Declares a field a relation aggregate computes, `@Field({ computed: (user) => user.resources.count() })`.
57
+ * The aggregate says what the field holds, so it takes no `type`, and only `count` and `sum` - the two a
58
+ * row change turns into a delta - may be `stored`.
59
+ * @example `@Field({ computed: (user) => user.resources.count() }) readonly resourceCount?: number;`
60
+ */
61
+ export declare function Field<This, O extends AggregateOptions<This> & RejectKeys<Exclude<keyof O, keyof FieldOptions>>>(opts: O): AdmittingDecorator<AggregateValue<O>, AggregateValue<O>, This>;
62
+ /**
63
+ * A field the aggregate itself types: `stored: true` only where a trigger could keep it, and every other
64
+ * option as a column takes it.
65
+ */
66
+ type AggregateOptions<E> = (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
67
+ readonly computed: EntityAggregate<E>;
68
+ readonly stored?: false;
69
+ }) | (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
70
+ readonly computed: {
71
+ agg(refs: ComputedRefs<E>): RelationAggregate<unknown, true>;
72
+ }['agg'];
73
+ readonly stored: true;
74
+ });
33
75
  /**
34
76
  * A key the type level cannot name, reported on each `@Id` that leaves it unnamed. Where no `idKey`
35
77
  * brand and no conventional name applies, `IdKey` falls back to every field, and `IdValue`,
@@ -1,10 +1,5 @@
1
1
  import { relationRegistration } from '../metadata/definition.js';
2
2
  import { memberRegistrations } from './bag.js';
3
- /**
4
- * Declares a persisted field, its `type` checked against the property's.
5
- * @example `@Field({ type: String }) name?: string;`
6
- * @example `@Field({ references: () => User }) userId?: string;`
7
- */
8
3
  export function Field(opts) {
9
4
  return (_value, context) => {
10
5
  memberRegistrations(context.metadata).fields[String(context.name)] = opts;
@@ -1,4 +1,4 @@
1
- import type { EntityData, EntityIndexInput, EntityMembers, EntityMeta, EntityOptions, FieldMeta, FieldOptions, FilterName, FilterOptions, HookEvent, IdKey, RelationKey, RelationMeta, RelationOptions, RelationRegistration, Type, WrittenId } from '../../type/index.js';
1
+ import type { EntityIndexInput, EntityMembers, EntityMeta, EntityOptions, FieldMeta, FieldOptions, FilterName, FilterOptions, HookEvent, IdKey, RelationKey, RelationMeta, RelationOptions, RelationRegistration, Type, WrittenId } from '../../type/index.js';
2
2
  export declare function defineField<E>(entity: Type<E>, key: string, opts?: FieldOptions): EntityMeta<E>;
3
3
  export declare function defineId<E>(entity: Type<E>, key: string, opts: FieldOptions): EntityMeta<E>;
4
4
  /** `T` is the relation's target, independent of the owner `E`. */
@@ -39,10 +39,14 @@ export declare function soleIdOf<E>(meta: EntityMeta<E>, what: string): IdKey<E>
39
39
  export declare function fieldOf<E>(meta: EntityMeta<E>, key: string): FieldMeta;
40
40
  /** The relation `key` names, for a caller that took `key` from the metadata itself. */
41
41
  export declare function relationOf<E>(meta: EntityMeta<E>, key: RelationKey<E>): RelationMeta;
42
+ /** A row of `E` as far as reading its key goes: a record or a write, whatever its values. */
43
+ type KeyedRow<E> = {
44
+ readonly [K in keyof E]?: unknown;
45
+ };
42
46
  /** Whether the row names every column of its primary key, `0` and `''` included. */
43
- export declare function namesKey<E>(meta: EntityMeta<E>, row: EntityData<E>): boolean;
47
+ export declare function namesKey<E>(meta: EntityMeta<E>, row: KeyedRow<E>): boolean;
44
48
  /** A row's primary key: its value, or a map of every column on a composite, checked at run time. */
45
- export declare function idOf<E>(meta: EntityMeta<E>, row: EntityData<E>): WrittenId<E>;
49
+ export declare function idOf<E>(meta: EntityMeta<E>, row: KeyedRow<E>): WrittenId<E>;
46
50
  /**
47
51
  * Forgets an entity, and reports whether there was one - for a registry that grows at runtime, where a
48
52
  * deleted content type would otherwise keep its metadata for the life of the process. Nothing rewrites
@@ -58,3 +62,4 @@ export declare function getMeta<E>(entity: Type<E>): EntityMeta<E>;
58
62
  * schema build constrains and a junction joins by, settling the relations holding them first.
59
63
  */
60
64
  export declare function foreignKeysOf<E>(meta: EntityMeta<E>): RelationMeta[];
65
+ export {};