uql-orm 0.68.0 → 0.69.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 (37) hide show
  1. package/dist/browser/http/http.js +5 -8
  2. package/dist/browser/querier/httpQuerier.d.ts +9 -9
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +9 -8
  5. package/dist/d1/d1Querier.d.ts +5 -5
  6. package/dist/d1/d1QuerierPool.d.ts +3 -3
  7. package/dist/dialect/abstractSqlDialect.d.ts +31 -4
  8. package/dist/dialect/abstractSqlDialect.js +99 -21
  9. package/dist/dialect/aliases.d.ts +8 -0
  10. package/dist/dialect/aliases.js +8 -0
  11. package/dist/entity/decorator/members.d.ts +29 -1
  12. package/dist/entity/decorator/members.js +0 -5
  13. package/dist/entity/metadata/definition.js +10 -3
  14. package/dist/http/query.d.ts +10 -2
  15. package/dist/http/query.js +26 -1
  16. package/dist/migrate/introspection/postgresIntrospector.d.ts +6 -0
  17. package/dist/migrate/introspection/postgresIntrospector.js +7 -1
  18. package/dist/mongo/mongoDialect.d.ts +29 -3
  19. package/dist/mongo/mongoDialect.js +111 -14
  20. package/dist/mongo/mongodbQuerier.d.ts +2 -2
  21. package/dist/mongo/mongodbQuerier.js +4 -3
  22. package/dist/type/dialect.d.ts +29 -2
  23. package/dist/type/entity.d.ts +94 -7
  24. package/dist/type/query.d.ts +30 -34
  25. package/dist/type/queryRaw.d.ts +19 -1
  26. package/dist/type/queryRaw.js +18 -0
  27. package/dist/type/queryWhere.d.ts +20 -20
  28. package/dist/type/universalQuerier.d.ts +10 -10
  29. package/dist/type/wire.d.ts +9 -0
  30. package/dist/util/dialect.util.js +2 -2
  31. package/dist/util/field.util.d.ts +15 -1
  32. package/dist/util/field.util.js +18 -1
  33. package/dist/util/object.util.d.ts +1 -4
  34. package/dist/util/object.util.js +0 -3
  35. package/dist/util/raw.d.ts +2 -2
  36. package/dist/util/raw.js +29 -2
  37. package/package.json +2 -2
@@ -1,9 +1,9 @@
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, COUNT_ALIAS, COUNTED_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
9
  import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
@@ -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
  });
@@ -1296,7 +1296,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1296
1296
  }
1297
1297
  const decoded = [];
1298
1298
  for (const [key, field] of Object.entries(meta.fields)) {
1299
- const kind = this.hydrateKind(field);
1299
+ const kind = this.aggregateHydrateKind(meta, field) ?? this.hydrateKind(field);
1300
1300
  if (kind) {
1301
1301
  decoded.push([key, kind]);
1302
1302
  }
@@ -1304,28 +1304,41 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1304
1304
  this.hydratable.set(entity, [meta.revision, decoded]);
1305
1305
  return decoded;
1306
1306
  }
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
- */
1307
+ /** The same for an aggregate's row, per query: each alias decodes by {@link aggregateKind}. */
1311
1308
  hydratableAggregates(entity, q) {
1312
1309
  const { fields } = getMeta(entity);
1313
1310
  const decoded = [];
1314
1311
  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.
1312
+ // A grouped column is the field itself; an aggregate reads the one it aggregates, `'*'` for a tally.
1321
1313
  const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
1322
- const kind = this.hydrateKind(fields[key]);
1314
+ const field = fields[key];
1315
+ const kind = entry.kind === 'fn' ? this.aggregateKind(entry.op, field) : this.hydrateKind(field);
1323
1316
  if (kind) {
1324
1317
  decoded.push([entry.alias, kind]);
1325
1318
  }
1326
1319
  }
1327
1320
  return decoded;
1328
1321
  }
1322
+ /**
1323
+ * What an aggregate's value decodes as, which the engine widens beyond the column it read: a tally
1324
+ * (`COUNT` to Postgres's `bigint`) and a mean (`AVG` to `numeric`) are numbers whatever they counted,
1325
+ * and the rest read as the column they aggregate - so a `SUM` over a wide integer stays exact rather
1326
+ * than rounding through a float, which is what every driver's BIGINT decoding promises.
1327
+ *
1328
+ * One rule for both readers: a relation aggregate a field declares, and an aggregate a query names.
1329
+ */
1330
+ aggregateKind(op, field) {
1331
+ return op === '$count' || op === '$avg' ? 'number' : this.hydrateKind(field);
1332
+ }
1333
+ /** What a relation aggregate a field declares decodes as: {@link aggregateKind} over the target's column. */
1334
+ aggregateHydrateKind(meta, field) {
1335
+ const spec = aggregateOf(field);
1336
+ if (!spec) {
1337
+ return undefined;
1338
+ }
1339
+ const target = getMeta(relationOf(meta, spec.relation).entity());
1340
+ return this.aggregateKind(spec.op, spec.field ? target.fields[spec.field] : undefined);
1341
+ }
1329
1342
  /** What one column decodes as, the inverse of {@link persistKind}. `BigInt` first, since it shares the numeric family. */
1330
1343
  hydrateKind(field) {
1331
1344
  const type = field?.type;
@@ -1519,14 +1532,18 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1519
1532
  * invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
1520
1533
  * not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
1521
1534
  */
1522
- appendRelationSubquery(ctx, meta, relKey, rel, opts, projection, val) {
1535
+ appendRelationSubquery(ctx, meta, relKey, rel, opts, read) {
1523
1536
  const relatedEntity = rel.entity();
1524
1537
  const relatedMeta = getMeta(relatedEntity);
1525
1538
  const parent = opts.prefix ?? this.resolveTableAlias(meta);
1526
1539
  // 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 `);
1540
+ const targetWhere = this.scopedWhere(relatedMeta, read.query?.$where ?? {});
1529
1541
  if (rel.through) {
1542
+ // The rows here are the junction's own, so a column of the far side is read as a page instead.
1543
+ if (read.field) {
1544
+ 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`);
1545
+ }
1546
+ ctx.append(`(SELECT ${read.op === 'exists' ? '1' : 'COUNT(*)'} FROM `);
1530
1547
  const junction = this.junctionRows(ctx, meta, rel, rel.through(), parent);
1531
1548
  ctx.append(junction.from);
1532
1549
  if (hasKeys(targetWhere)) {
@@ -1539,13 +1556,74 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1539
1556
  }
1540
1557
  }
1541
1558
  else {
1559
+ // The alias is claimed before the SELECT is written, since an aggregate names a column of it.
1542
1560
  const related = this.tableRef(relatedMeta, ctx.claimAlias(relKey, parent));
1561
+ ctx.append(`(SELECT ${this.aggregateProjection(read, related.alias, relatedMeta)} FROM `);
1543
1562
  ctx.append(related.ref);
1544
1563
  ctx.append(` WHERE ${this.correlation(meta, rel, parent, related.alias, relatedMeta)}`);
1545
1564
  this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: related.alias, clause: 'AND' });
1546
1565
  }
1547
1566
  ctx.append(')');
1548
1567
  }
1568
+ /**
1569
+ * What a relation subquery selects: the literals a relation operator reads, or an aggregate over one
1570
+ * of the target's columns. `count` and `sum` answer `0` on a parent with no rows, which is what makes
1571
+ * them the two a trigger could keep; the rest answer `NULL`, and the field's type says so.
1572
+ */
1573
+ aggregateProjection(projection, alias, relatedMeta) {
1574
+ if (projection.op === 'exists') {
1575
+ return '1';
1576
+ }
1577
+ return this.aggregateCall(projection.op, projection.field ? this.escapedColumn(alias, relatedMeta, projection.field) : '');
1578
+ }
1579
+ /**
1580
+ * A relation aggregate as the correlated subquery a `computed` field reads, the same one `$count`
1581
+ * emits: `(user) => user.resources.count()` renders here, wherever the field is named.
1582
+ */
1583
+ appendRelationAggregate(ctx, entity, aggregate, prefix) {
1584
+ const meta = getMeta(entity);
1585
+ const rel = relationOf(meta, aggregate.relation);
1586
+ const parent = prefix || this.resolveTableAlias(meta);
1587
+ if (!aggregate.query?.$limit && aggregate.query?.$skip === undefined) {
1588
+ this.appendRelationSubquery(ctx, meta, aggregate.relation, rel, { prefix: parent }, aggregate);
1589
+ return;
1590
+ }
1591
+ // Capped: the rows it reads are a page of the relation, so they are read first - ordered, since an
1592
+ // order is what picks them - and the aggregate runs over that page.
1593
+ const pageAlias = this.escapeId(AGGREGATE_PAGE_ALIAS);
1594
+ const value = this.escapeId(AGGREGATE_VALUE_ALIAS);
1595
+ ctx.append(`(SELECT ${this.aggregateCall(aggregate.op, `${pageAlias}.${value}`)} FROM (`);
1596
+ this.appendRelationPage(ctx, meta, rel, aggregate, parent);
1597
+ ctx.append(`) ${pageAlias})`);
1598
+ }
1599
+ /**
1600
+ * The page a capped aggregate reads: an ordinary read of the related entity under its own alias,
1601
+ * narrowed to the parent's rows, carrying out the one column the aggregate runs over. Its `$sort`,
1602
+ * `$limit` and `$skip` are the relation's own, and its filters apply as they do to any read.
1603
+ */
1604
+ appendRelationPage(ctx, meta, rel, aggregate, parent) {
1605
+ const entity = rel.entity();
1606
+ const alias = ctx.claimAlias(aggregate.relation, parent);
1607
+ const correlation = raw(({ ctx: pageCtx }) => this.appendCorrelation(pageCtx, meta, rel, parent, alias));
1608
+ const { $where, ...page } = aggregate.query ?? {};
1609
+ // `1` where nothing is aggregated: a tally counts the rows the page holds, whatever they carry.
1610
+ const read = aggregate.field ? refs(entity)[aggregate.field] : raw `1`;
1611
+ const query = {
1612
+ ...page,
1613
+ $select: [read.as(AGGREGATE_VALUE_ALIAS)],
1614
+ $where: { ...$where, $and: [...($where?.$and ?? []), correlation] },
1615
+ };
1616
+ const joins = resolveQueryJoins(getMeta(entity), query, (path) => ctx.claimAlias(path));
1617
+ this.read(ctx, entity, query, { alias }, joins);
1618
+ }
1619
+ /** One aggregate over an operand, `COALESCE`d where the aggregate answers `0` on no rows rather than null. */
1620
+ aggregateCall(op, operand) {
1621
+ if (op === '$count') {
1622
+ return 'COUNT(*)';
1623
+ }
1624
+ const call = `${AbstractSqlDialect.AGGREGATE_FN[op]}(${operand})`;
1625
+ return op === '$sum' ? `COALESCE(${call}, 0)` : call;
1626
+ }
1549
1627
  /**
1550
1628
  * One equality per key of the parent, anded: a composite correlates on every column, and matching on
1551
1629
  * part of one would find the rows of a different parent. `parentJoins` keeps the two ends the right
@@ -1571,7 +1649,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1571
1649
  /** Each relation a read's `$count` tallies, as a correlated count of its select list. */
1572
1650
  selectRelationCounts(ctx, meta, count, parent) {
1573
1651
  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));
1652
+ const sql = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relKey, relation, { prefix: parent }, { op: '$count', query: { $where: where } }));
1575
1653
  return { sql, key: `${COUNT_RESULT_KEY}.${relKey}` };
1576
1654
  });
1577
1655
  }
@@ -1670,11 +1748,11 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1670
1748
  /** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
1671
1749
  compareRelation(ctx, entity, relKey, val, rel, opts) {
1672
1750
  ctx.append('EXISTS ');
1673
- this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, '1', val);
1751
+ this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, { op: 'exists', query: { $where: val } });
1674
1752
  }
1675
1753
  /** Filter by relation size: the same subquery, counting instead of testing for existence. */
1676
1754
  compareRelationSize(ctx, entity, relKey, sizeVal, rel, opts) {
1677
- const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, 'COUNT(*)', {});
1755
+ const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, { op: '$count' });
1678
1756
  ctx.append(this.sizeCondition(ctx, count, sizeVal));
1679
1757
  }
1680
1758
  /**
@@ -4,6 +4,14 @@ export declare const COUNT_ALIAS = "_uql_count";
4
4
  export declare const TOTAL_ALIAS = "_uql_total";
5
5
  /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
6
6
  export declare const COUNTED_ROWS_ALIAS = "_uql_rows";
7
+ /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
8
+ export declare const AGGREGATE_PAGE_ALIAS = "_uql_page";
9
+ /**
10
+ * What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
11
+ * and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
12
+ * are written and read here.
13
+ */
14
+ export declare const AGGREGATE_VALUE_ALIAS = "_uql_value";
7
15
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
8
16
  export declare const RELATION_ROW_ALIAS = "_uql_row";
9
17
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -6,6 +6,14 @@ export const COUNT_ALIAS = '_uql_count';
6
6
  export const TOTAL_ALIAS = '_uql_total';
7
7
  /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
8
8
  export const COUNTED_ROWS_ALIAS = '_uql_rows';
9
+ /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
10
+ export const AGGREGATE_PAGE_ALIAS = '_uql_page';
11
+ /**
12
+ * What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
13
+ * and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
14
+ * are written and read here.
15
+ */
16
+ export const AGGREGATE_VALUE_ALIAS = '_uql_value';
9
17
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
10
18
  export const RELATION_ROW_ALIAS = '_uql_row';
11
19
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -1,4 +1,4 @@
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 } 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;
@@ -30,6 +30,34 @@ export declare function Field<This, O extends FieldOptions<DeclaredValue<O>, Thi
30
30
  } | {
31
31
  references: EntityGetter;
32
32
  }) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined, This>;
33
+ /**
34
+ * Declares a field a relation aggregate computes, `@Field({ computed: (user) => user.resources.count() })`.
35
+ * The aggregate says what the field holds, so it takes no `type`, and only `count` and `sum` - the two a
36
+ * row change turns into a delta - may be `stored`.
37
+ * @example `@Field({ computed: (user) => user.resources.count() }) readonly resourceCount?: number;`
38
+ */
39
+ export declare function Field<This, O extends AggregateOptions<This> & RejectKeys<Exclude<keyof O, keyof FieldOptions>>>(opts: O): AggregateDecorator<AggregateValue<O>, This>;
40
+ /**
41
+ * A field the aggregate itself types: `stored: true` only where a trigger could keep it, and every other
42
+ * option as a column takes it.
43
+ */
44
+ type AggregateOptions<E> = (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
45
+ readonly computed: EntityAggregate<E>;
46
+ readonly stored?: false;
47
+ }) | (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
48
+ readonly computed: {
49
+ agg(refs: ComputedRefs<E>): RelationAggregate<unknown, true>;
50
+ }['agg'];
51
+ readonly stored: true;
52
+ });
53
+ /**
54
+ * {@link MemberDecorator} for a field an aggregate types, which the property has to hold *exactly*: a
55
+ * decorator context takes a property narrower than its value, so nothing else would stop a `number`
56
+ * from holding what `max()` reads, which is `number | null` on a parent with no rows.
57
+ */
58
+ type AggregateDecorator<V, O> = <P extends V | undefined>(value: undefined, context: ClassFieldDecoratorContext<O, P> & ([V] extends [P] ? unknown : {
59
+ readonly __propertyMustAdmit: V;
60
+ })) => void;
33
61
  /**
34
62
  * A key the type level cannot name, reported on each `@Id` that leaves it unnamed. Where no `idKey`
35
63
  * 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 { SOFT_DELETE_FILTER } from '../../type/index.js';
1
+ import { RelationAggregate, SOFT_DELETE_FILTER } from '../../type/index.js';
2
2
  import { isInlinedExpression } from '../../util/field.util.js';
3
3
  import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
4
4
  import { ownRegistrations } from '../decorator/bag.js';
@@ -17,6 +17,14 @@ function globalMap(key) {
17
17
  const metas = globalMap('uql-orm/entity/metadata/v1');
18
18
  export function defineField(entity, key, opts = {}) {
19
19
  const meta = ensureWritableMeta(entity);
20
+ const { computed, ...rest } = opts;
21
+ const sql = computed === undefined ? undefined : entitySql(computed);
22
+ // A relation aggregate reads as a correlated subquery, which no engine accepts in a generated column:
23
+ // keeping one on the row takes the triggers a write fires, which are not built yet.
24
+ if (opts.stored && sql instanceof RelationAggregate) {
25
+ throw new TypeError(`'${entity.name}.${key}' cannot be 'stored': a relation aggregate reads as a subquery, which no ` +
26
+ "engine keeps in a generated column. Drop 'stored' to have it read on each query.");
27
+ }
20
28
  // A stored computed column is a real column and still needs a type; only an inlined one is exempt,
21
29
  // its expression being spliced in rather than declared.
22
30
  if (!opts.type && !opts.references && !isInlinedExpression(opts)) {
@@ -31,13 +39,12 @@ export function defineField(entity, key, opts = {}) {
31
39
  // Flagged when the author gave `references` but no `type`, so schema generation knows to resolve the
32
40
  // column from the referenced primary key (picking up its `columnType`, length and chained keys)
33
41
  // instead of treating whatever ends up in `type` as deliberate.
34
- const { computed, ...rest } = opts;
35
42
  const resolved = rest.type ? rest : { ...rest, typeFromReference: true };
36
43
  meta.fields[fieldKey] = {
37
44
  ...meta.fields[fieldKey],
38
45
  name: key,
39
46
  ...resolved,
40
- ...(computed && { computed: entitySql(computed) }),
47
+ ...(sql && { computed: sql }),
41
48
  };
42
49
  return meta;
43
50
  }
@@ -1,11 +1,19 @@
1
- import type { Query } from '../type/index.js';
1
+ import type { WireQuery } from '../type/index.js';
2
2
  /**
3
3
  * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
4
4
  * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
5
5
  */
6
- export declare function parseQueryParams(params?: Record<string, unknown>): Query<unknown>;
6
+ export declare function parseQueryParams(params?: Record<string, unknown>): WireQuery<unknown>;
7
7
  /**
8
8
  * Serialize a UQL query object into a percent-encoded query string where object values
9
9
  * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.
10
10
  */
11
11
  export declare function stringifyQuery(query?: Record<string, unknown>): string;
12
+ /**
13
+ * What leaves the browser, as JSON, refusing what JSON keeps nothing of rather than letting the server
14
+ * build a statement around the remains. A `raw` fragment renders SQL against a dialect the client does not
15
+ * have and arrives as `{}`; binary arrives as an object keyed by index. A `Date` is not among them - it
16
+ * serializes to ISO 8601, which is what a date column reads. This is what a cast, or a JavaScript caller,
17
+ * hits where the client's types already refuse a fragment.
18
+ */
19
+ export declare function wireJson(value: unknown): string;
@@ -1,5 +1,8 @@
1
1
  // the clause lists themselves, not the barrel: this module is in the browser bundle's graph
2
2
  import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_NUMBER_CLAUSES, QUERY_ROOT_OBJECT_CLAUSES, } from '../type/query.js';
3
+ // the brand alone, not the class: importing `QueryRaw` for an `instanceof` kept it, and `ColumnRef`
4
+ // with it, in the browser bundle, which is on a size budget
5
+ import { RAW_VALUE } from '../type/queryRaw.js';
3
6
  // the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
4
7
  import { getKeys, isWhereMap } from '../util/object.util.js';
5
8
  /**
@@ -82,8 +85,30 @@ export function stringifyQuery(query) {
82
85
  if (value === undefined) {
83
86
  continue;
84
87
  }
85
- params.append(key, typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));
88
+ params.append(key, typeof value === 'object' && value !== null ? wireJson(value) : String(value));
86
89
  }
87
90
  const qs = params.toString();
88
91
  return qs ? `?${qs}` : '';
89
92
  }
93
+ /**
94
+ * What leaves the browser, as JSON, refusing what JSON keeps nothing of rather than letting the server
95
+ * build a statement around the remains. A `raw` fragment renders SQL against a dialect the client does not
96
+ * have and arrives as `{}`; binary arrives as an object keyed by index. A `Date` is not among them - it
97
+ * serializes to ISO 8601, which is what a date column reads. This is what a cast, or a JavaScript caller,
98
+ * hits where the client's types already refuse a fragment.
99
+ */
100
+ export function wireJson(value) {
101
+ return JSON.stringify(value, (_key, held) => {
102
+ if (typeof held !== 'object' || held === null) {
103
+ return held;
104
+ }
105
+ if (RAW_VALUE in held) {
106
+ throw new TypeError('raw SQL cannot travel over HTTP: what leaves the browser is JSON');
107
+ }
108
+ // A blob is a field value, so no type parameter reaches it: this is the only place it is caught.
109
+ if (held instanceof ArrayBuffer || ArrayBuffer.isView(held)) {
110
+ throw new TypeError('binary cannot travel over HTTP: what leaves the browser is JSON');
111
+ }
112
+ return held;
113
+ });
114
+ }
@@ -14,6 +14,12 @@ export declare class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrosp
14
14
  protected getTableNamesQuery(): string;
15
15
  protected tableExistsQuery(): string;
16
16
  protected parseTableExistsResult([row]: RawRow[]): boolean;
17
+ /**
18
+ * The comment reads through `to_regclass` rather than a `::regclass` cast: a name resolves against
19
+ * the live catalogue while `information_schema` answers from this statement's snapshot, so a table
20
+ * another connection has just dropped is still listed here and the cast would raise on it. Whole
21
+ * database scans meet that table every time something else is migrating.
22
+ */
17
23
  protected getColumnsQuery(_tableName: string): string;
18
24
  /**
19
25
  * `attname` where the entry is a column, `pg_get_indexdef` for that one position where it is an
@@ -37,6 +37,12 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
37
37
  parseTableExistsResult([row]) {
38
38
  return row['exists'] === true;
39
39
  }
40
+ /**
41
+ * The comment reads through `to_regclass` rather than a `::regclass` cast: a name resolves against
42
+ * the live catalogue while `information_schema` answers from this statement's snapshot, so a table
43
+ * another connection has just dropped is still listed here and the cast would raise on it. Whole
44
+ * database scans meet that table every time something else is migrating.
45
+ */
40
46
  getColumnsQuery(_tableName) {
41
47
  return /*sql*/ `
42
48
  SELECT
@@ -68,7 +74,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
68
74
  HAVING COUNT(*) = 1 AND MIN(kcu.column_name) = c.column_name
69
75
  ) AS is_unique,
70
76
  pg_catalog.col_description(
71
- (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
77
+ to_regclass(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name)),
72
78
  c.ordinal_position
73
79
  ) AS column_comment
74
80
  FROM information_schema.columns c
@@ -139,9 +139,35 @@ export declare class MongoDialect extends AbstractDialect {
139
139
  readonly stages: MongoAggregationPipelineEntry<Document>[];
140
140
  readonly fields: string[];
141
141
  };
142
- /** The correlated lookup counting a relation's rows, which `where` narrows, into `temp`. */
143
- private tallyLookup;
144
- /** The tally a lookup left in `temp`, which holds no row at all where nothing matched: a zero. */
142
+ /** Whether a read answers with a relation aggregate, which only the pipeline can build. */
143
+ readsAggregates<E extends Document>(entity: Type<E>, q: Query<E>): boolean;
144
+ /**
145
+ * The relation aggregates one query reads: the ones its projection carries, plus any its `$where` or
146
+ * `$sort` names, which a read materializes whether or not it answers with them.
147
+ */
148
+ private aggregateKeys;
149
+ /**
150
+ * The stages a relation aggregate a query names needs: the correlated lookup that reads the related
151
+ * rows - narrowed, ordered and capped as the field declared - ending in the tally or total it wants,
152
+ * and the `$addFields` that puts the value on the document under the field's own name.
153
+ *
154
+ * The same spec the SQL dialects render as a correlated subquery: a relation aggregate is data, so a
155
+ * document engine builds it out of stages rather than being refused a language it does not speak.
156
+ */
157
+ private aggregateFieldStages;
158
+ /**
159
+ * One relation aggregate on the document under `field`: the correlated lookup that reads the related
160
+ * rows - narrowed, ordered and capped as the spec says - ending in the tally or total it wants, and
161
+ * the `$addFields` reading that back, `0` or `null` where the lookup matched nothing.
162
+ *
163
+ * Every aggregate MongoDB answers is built here: a `$count` a query asks for, an ordering by one, and
164
+ * a field a `computed` declares, which is the same spec the SQL dialects render as one subquery.
165
+ */
166
+ private aggregateStages;
167
+ /**
168
+ * The value a lookup left in `temp`, which holds no row at all where nothing matched: `0` for the
169
+ * aggregates that count something, and `null` for the ones with no value to report.
170
+ */
145
171
  private tally;
146
172
  /**
147
173
  * The lookups reading each to-many a query populates, and the tally of each `$count`, onto the fields