uql-orm 0.68.1 → 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.
@@ -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
  }
@@ -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
@@ -1,10 +1,10 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import { COUNT_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
3
+ import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, sortCountField } from '../dialect/aliases.js';
4
4
  import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
6
6
  import { COUNT_RESULT_KEY } from '../type/query.js';
7
- import { QueryRaw } from '../type/queryRaw.js';
7
+ import { QueryRaw, RelationAggregate } from '../type/queryRaw.js';
8
8
  import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, someKey, targetKeyColumns, } from '../util/index.js';
9
9
  /** Default {@link DialectFeatures} for MongoDB. */
10
10
  export const mongoDialectFeatures = {
@@ -165,7 +165,7 @@ export class MongoDialect extends AbstractDialect {
165
165
  appendRelationLookup(filter, meta, relKey, val, lookups) {
166
166
  const temp = `${REL_TEMP_PREFIX}${lookups.temps.length}`;
167
167
  const sizeVal = parseRelationSize(val);
168
- const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_ALIAS }];
168
+ const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: AGGREGATE_VALUE_ALIAS }];
169
169
  const where = (sizeVal === undefined ? val : {});
170
170
  lookups.temps.push(temp);
171
171
  lookups.stages.push(this.relationLookup(meta, meta.relations[relKey], where, temp, tail));
@@ -423,6 +423,20 @@ export class MongoDialect extends AbstractDialect {
423
423
  const selectMap = asSelectMap(select);
424
424
  // Projected by column, not by field key; `normalizeId` maps them back on the way out.
425
425
  const projection = normalizeScalarFieldSelection(meta, selectMap, exclude).reduce((acc, key) => {
426
+ // Swept in with the rest of the entity's fields, a computed one is skipped; asked for by name
427
+ // it is refused, since the document holds nothing to project under it.
428
+ const field = meta.fields[key];
429
+ if (field?.computed) {
430
+ // An aggregate is on the document by the time this projects, under the field's own name; SQL
431
+ // is refused where it was asked for by name, and skipped where it was swept in with the rest.
432
+ if (aggregateOf(field)) {
433
+ acc[key] = 1;
434
+ }
435
+ else if (selectMap && key in selectMap) {
436
+ assertReadable(meta, key);
437
+ }
438
+ return acc;
439
+ }
426
440
  acc[this.columnOf(meta, key)] = 1;
427
441
  return acc;
428
442
  }, {});
@@ -498,18 +512,81 @@ export class MongoDialect extends AbstractDialect {
498
512
  continue;
499
513
  }
500
514
  const temp = sortCountField(key);
501
- stages.push(this.tallyLookup(meta, relOpts, {}, temp), { $addFields: { [temp]: this.tally(temp) } });
515
+ stages.push(...this.aggregateStages(meta, { relation: key, op: '$count' }, `${REL_TEMP_PREFIX}${temp}`, temp));
502
516
  fields.push(temp);
503
517
  }
504
518
  return { stages, fields };
505
519
  }
506
- /** The correlated lookup counting a relation's rows, which `where` narrows, into `temp`. */
507
- tallyLookup(meta, relOpts, where, temp) {
508
- return this.relationLookup(meta, relOpts, where, temp, [{ $count: COUNT_ALIAS }]);
520
+ /** Whether a read answers with a relation aggregate, which only the pipeline can build. */
521
+ readsAggregates(entity, q) {
522
+ return this.aggregateKeys(entity, q).length > 0;
523
+ }
524
+ /**
525
+ * The relation aggregates one query reads: the ones its projection carries, plus any its `$where` or
526
+ * `$sort` names, which a read materializes whether or not it answers with them.
527
+ */
528
+ aggregateKeys(entity, q) {
529
+ const meta = getMeta(entity);
530
+ const projected = normalizeScalarFieldSelection(meta, asSelectMap(q.$select), q.$exclude);
531
+ const named = [...Object.keys(q.$where ?? {}), ...Object.keys(q.$sort ?? {})];
532
+ return [...new Set([...projected, ...named])].flatMap((key) => {
533
+ const spec = aggregateOf(meta.fields[key]);
534
+ return spec ? [[key, spec]] : [];
535
+ });
536
+ }
537
+ /**
538
+ * The stages a relation aggregate a query names needs: the correlated lookup that reads the related
539
+ * rows - narrowed, ordered and capped as the field declared - ending in the tally or total it wants,
540
+ * and the `$addFields` that puts the value on the document under the field's own name.
541
+ *
542
+ * The same spec the SQL dialects render as a correlated subquery: a relation aggregate is data, so a
543
+ * document engine builds it out of stages rather than being refused a language it does not speak.
544
+ */
545
+ aggregateFieldStages(meta, aggregates) {
546
+ const stages = [];
547
+ const temps = [];
548
+ for (const [key, spec] of aggregates) {
549
+ const temp = `${REL_TEMP_PREFIX}${key}`;
550
+ stages.push(...this.aggregateStages(meta, spec, temp, key));
551
+ temps.push(temp);
552
+ }
553
+ return { stages, temps };
554
+ }
555
+ /**
556
+ * One relation aggregate on the document under `field`: the correlated lookup that reads the related
557
+ * rows - narrowed, ordered and capped as the spec says - ending in the tally or total it wants, and
558
+ * the `$addFields` reading that back, `0` or `null` where the lookup matched nothing.
559
+ *
560
+ * Every aggregate MongoDB answers is built here: a `$count` a query asks for, an ordering by one, and
561
+ * a field a `computed` declares, which is the same spec the SQL dialects render as one subquery.
562
+ */
563
+ aggregateStages(meta, spec, temp, field) {
564
+ const relOpts = relationOf(meta, spec.relation);
565
+ const query = spec.query ?? {};
566
+ const tail = [
567
+ ...(query.$sort ? [{ $sort: this.sort(relOpts.entity(), query.$sort) }] : []),
568
+ ...this.pagerStages(query),
569
+ spec.field
570
+ ? {
571
+ $group: {
572
+ _id: null,
573
+ [AGGREGATE_VALUE_ALIAS]: { [spec.op]: `$${this.columnOf(getMeta(relOpts.entity()), spec.field)}` },
574
+ },
575
+ }
576
+ : { $count: AGGREGATE_VALUE_ALIAS },
577
+ ];
578
+ return [
579
+ this.relationLookup(meta, relOpts, query.$where ?? {}, temp, tail),
580
+ { $addFields: { [field]: this.tally(temp, spec.op) } },
581
+ ];
509
582
  }
510
- /** The tally a lookup left in `temp`, which holds no row at all where nothing matched: a zero. */
511
- tally(temp) {
512
- return { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_ALIAS}`, 0] }, 0] };
583
+ /**
584
+ * The value a lookup left in `temp`, which holds no row at all where nothing matched: `0` for the
585
+ * aggregates that count something, and `null` for the ones with no value to report.
586
+ */
587
+ tally(temp, op = '$count') {
588
+ const empty = op === '$count' || op === '$sum' ? 0 : null;
589
+ return { $ifNull: [{ $arrayElemAt: [`$${temp}.${AGGREGATE_VALUE_ALIAS}`, 0] }, empty] };
513
590
  }
514
591
  /**
515
592
  * The lookups reading each to-many a query populates, and the tally of each `$count`, onto the fields
@@ -523,12 +600,11 @@ export class MongoDialect extends AbstractDialect {
523
600
  for (const relKey of getRelationRequestSummary(meta, q.$populate).toManyKeys) {
524
601
  stages.push(...this.toManyLookup(meta, relKey, parseRelationAtKey(relKey, q.$populate).query, temps));
525
602
  }
526
- for (const { relKey, relation, where } of countedRelations(meta, q.$count)) {
603
+ for (const { relKey, where } of countedRelations(meta, q.$count)) {
527
604
  const temp = `${REL_TEMP_PREFIX}count_${relKey}`;
528
605
  temps.push(temp);
529
- stages.push(this.tallyLookup(meta, relation, where, temp), {
530
- $addFields: { [`${COUNT_RESULT_KEY}.${relKey}`]: this.tally(temp) },
531
- });
606
+ const spec = { relation: relKey, op: '$count', query: { $where: where } };
607
+ stages.push(...this.aggregateStages(meta, spec, temp, `${COUNT_RESULT_KEY}.${relKey}`));
532
608
  }
533
609
  return temps.length ? [...stages, { $unset: temps }] : stages;
534
610
  }
@@ -595,6 +671,7 @@ export class MongoDialect extends AbstractDialect {
595
671
  pathOf(meta, key) {
596
672
  const dot = key.indexOf('.');
597
673
  if (dot < 0) {
674
+ assertReadable(meta, key);
598
675
  return this.columnOf(meta, key);
599
676
  }
600
677
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
@@ -602,8 +679,12 @@ export class MongoDialect extends AbstractDialect {
602
679
  aggregationPipeline(entity, q, opts) {
603
680
  // Lookups that a relation condition needs come first, then the match that reads them, then the
604
681
  // temporary fields are dropped so they never reach the caller.
682
+ // Every relation aggregate the query reads comes first: a `$match`, a `$sort` and the projection all
683
+ // name it as a field of the document, which is what these stages make true.
684
+ const aggregates = this.aggregateFieldStages(getMeta(entity), this.aggregateKeys(entity, q));
605
685
  const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
606
686
  return [
687
+ ...aggregates.stages,
607
688
  ...stages,
608
689
  ...(hasKeys(filter) ? [{ $match: filter }] : []),
609
690
  ...(unset.length ? [{ $unset: unset }] : []),
@@ -611,6 +692,7 @@ export class MongoDialect extends AbstractDialect {
611
692
  sort: this.sort(entity, q.$sort, q.$populate),
612
693
  pager: this.pagerStages(q),
613
694
  }),
695
+ ...(aggregates.temps.length ? [{ $unset: aggregates.temps }] : []),
614
696
  ];
615
697
  }
616
698
  /** The `$skip`/`$limit` stages of a page, each checked: `/http` hands a page over untyped. */
@@ -1113,3 +1195,18 @@ export class MongoDialect extends AbstractDialect {
1113
1195
  function sortDirection(value) {
1114
1196
  return value === 'desc' || value === -1 ? -1 : 1;
1115
1197
  }
1198
+ /** The relation aggregate a field computes, where it computes one rather than writing SQL. */
1199
+ function aggregateOf(field) {
1200
+ return field?.computed instanceof RelationAggregate ? field.computed.spec : undefined;
1201
+ }
1202
+ /**
1203
+ * A `computed` field writing SQL is refused wherever a query names it, since no document engine
1204
+ * evaluates SQL and answering with the property name would hand back `undefined` for every row. One
1205
+ * computing a relation aggregate is read: `aggregateFieldStages` builds it.
1206
+ */
1207
+ function assertReadable(meta, key) {
1208
+ const field = meta.fields[key];
1209
+ if (field?.computed && !aggregateOf(field)) {
1210
+ throw new TypeError(`cannot read '${meta.entity.name}.${key}' on MongoDB: a 'computed' field writing SQL is not something a document engine evaluates`);
1211
+ }
1212
+ }
@@ -18,8 +18,8 @@ export declare class MongodbQuerier extends AbstractQuerier {
18
18
  */
19
19
  private readCursor;
20
20
  /**
21
- * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter
22
- * or order by a relation, and the grouping `$distinct` is.
21
+ * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter or
22
+ * order by a relation, one to build a relation aggregate, and the grouping `$distinct` is.
23
23
  */
24
24
  private readsThroughPipeline;
25
25
  private buildScalarProjection;
@@ -68,15 +68,16 @@ export class MongodbQuerier extends AbstractQuerier {
68
68
  : this.buildFindCursor(entity, q, opts);
69
69
  }
70
70
  /**
71
- * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter
72
- * or order by a relation, and the grouping `$distinct` is.
71
+ * Whether a read needs stages a `find` cursor cannot express: a lookup to populate, count, filter or
72
+ * order by a relation, one to build a relation aggregate, and the grouping `$distinct` is.
73
73
  */
74
74
  readsThroughPipeline(entity, q) {
75
75
  return (!!q.$distinct ||
76
76
  hasKeys(q.$count) ||
77
77
  populatesRelations(getMeta(entity), q.$populate) ||
78
78
  this.dialect.constrainsRelations(entity, q.$where) ||
79
- this.dialect.sortsRelations(entity, q.$sort));
79
+ this.dialect.sortsRelations(entity, q.$sort) ||
80
+ this.dialect.readsAggregates(entity, q));
80
81
  }
81
82
  buildScalarProjection(entity, q) {
82
83
  return this.dialect.select(entity, q.$select, q.$exclude);
@@ -1,6 +1,6 @@
1
1
  import type { EntityMeta, UpdatePayload } from './entity.js';
2
- import type { Query, QueryConflictPaths, QueryOptions, QueryPage, QuerySearch } from './query.js';
3
- import type { QueryAggMap, QueryAggregate, QueryGroupMap } from './queryAggregate.js';
2
+ import type { Query, QueryConflictPaths, QueryOptions, QueryPage, QuerySearch, RelationQuery } from './query.js';
3
+ import type { QueryAggMap, QueryAggregate, QueryAggregateOp, QueryGroupMap } from './queryAggregate.js';
4
4
  import type { Type } from './utility.js';
5
5
  /**
6
6
  * comparison options.
@@ -208,7 +208,34 @@ export interface SqlQueryDialect {
208
208
  * Default: '?' for MySQL/MariaDB/SQLite, '$n' for PostgreSQL.
209
209
  */
210
210
  placeholder(index: number): string;
211
+ /**
212
+ * A relation aggregate as a correlated subquery, correlated to the row under `prefix`: what a field
213
+ * declaring `computed: (user) => user.resources.count()` renders as, wherever a clause names it.
214
+ */
215
+ appendRelationAggregate<E>(ctx: QueryContext, entity: Type<E>, aggregate: RelationAggregateSpec, prefix: string): void;
211
216
  }
217
+ /**
218
+ * The aggregates a relation reads, spelled as the query language spells them, so a `computed` field, an
219
+ * aggregate query and MongoDB's own `$group` all name one the same way. `$count` and `$sum` are the two
220
+ * a row change turns into a delta.
221
+ */
222
+ export type RelationAggregateOp = QueryAggregateOp;
223
+ /** What a relation aggregate reads: how many rows, or one of the target's columns. */
224
+ export type RelationAggregateProjection = {
225
+ readonly op: '$count';
226
+ readonly field?: never;
227
+ } | {
228
+ readonly op: Exclude<RelationAggregateOp, '$count'>;
229
+ readonly field: string;
230
+ };
231
+ /** A relation aggregate as a `computed` field holds it: what it reads, off which relation, filtered how. */
232
+ export type RelationAggregateSpec = RelationAggregateProjection & {
233
+ readonly relation: string;
234
+ /** Which of the related rows it reads, and the page it caps them to, as the field declared them. */
235
+ readonly query?: RelationSubqueryQuery;
236
+ };
237
+ /** The rows a relation subquery reads: which ones, in what order, and the page capping them. */
238
+ export type RelationSubqueryQuery = Pick<RelationQuery, '$where' | '$sort' | '$limit' | '$skip'>;
212
239
  /**
213
240
  * Supported SQL dialect identifiers.
214
241
  */
@@ -1,6 +1,6 @@
1
1
  import type { EnumValues, ForeignKeyAction, IndexType } from '../schema/types.js';
2
- import type { FilterOptions } from './query.js';
3
- import type { ColumnRef, QueryRaw } from './queryRaw.js';
2
+ import type { FilterOptions, RelationQuery } from './query.js';
3
+ import type { ColumnRef, QueryRaw, RelationAggregate } from './queryRaw.js';
4
4
  import type { QueryWhere } from './queryWhere.js';
5
5
  import type { Except, IsMany, Json, Scalar, Type, Unpacked } from './utility.js';
6
6
  import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vector.js';
@@ -22,6 +22,15 @@ export type FieldKey<E> = {
22
22
  }[Key<E>];
23
23
  /** The relation names of an entity: every key but its fields and its methods, so the two sets cannot drift. */
24
24
  export type RelationKey<E> = Exclude<Key<E>, FieldKey<E> | MethodKey<E>>;
25
+ /**
26
+ * To-one relations only: a parent holds many rows of a to-many, so there is no single value to order it
27
+ * by, and joining one in would duplicate the parent instead. Order those inside `$populate`.
28
+ */
29
+ export type ToOneRelationKey<E> = {
30
+ [K in RelationKey<E>]: IsMany<E[K]> extends true ? never : K;
31
+ }[RelationKey<E>];
32
+ /** The relation names a parent holds many rows of: what a populated query fills, and what an aggregate reads. */
33
+ export type ToManyRelationKey<E> = Exclude<RelationKey<E>, ToOneRelationKey<E>>;
25
34
  /** Whether `T` carries the `Json` brand, read off its marker key: a primitive matches `Json<infer P>` too. */
26
35
  type IsJson<T> = '__json' extends keyof T ? true : false;
27
36
  /** The payload `P` of a branded `Json<P>`, or `never` for any other type. */
@@ -201,8 +210,11 @@ export type FieldOptions<V = TsTypeOf<FieldType>, E = unknown> = {
201
210
  /**
202
211
  * An expression the database computes, never written: spliced into each read, or with `stored` a
203
212
  * generated column, `computed: (user) => raw`${user.first} || ' ' || ${user.last}``.
213
+ *
214
+ * A relation aggregate is the other form, `computed: (user) => user.resources.count()`, read as the
215
+ * subquery a `$count` reads. Both resolve to SQL at registration, so everything downstream sees one.
204
216
  */
205
- readonly computed?: EntitySql<E>;
217
+ readonly computed?: ComputedSql<E>;
206
218
  /** Whether {@link FieldOptions.computed} is a generated column rather than spliced into each read; no query changes either way. */
207
219
  readonly stored?: boolean;
208
220
  readonly updatable?: boolean;
@@ -258,7 +270,22 @@ export type FieldOptionsFor<V, E = unknown> = (FieldOptions<NonNullable<V>, E> &
258
270
  }) | (FieldOptions<NonNullable<V>, E> & {
259
271
  readonly references: EntityGetter;
260
272
  readonly type?: TypeFor<V>;
273
+ }) | AggregateOptionsFor<V, E>;
274
+ /**
275
+ * A field a relation aggregate computes: the aggregate types it, so it declares no `type`, and only the
276
+ * two a row change turns into a delta - `count` and `sum` - may be `stored`.
277
+ */
278
+ type AggregateOptionsFor<V, E> = Except<FieldOptions<NonNullable<V>, E>, 'computed' | 'stored' | 'type'> & ({
279
+ readonly computed: AggregateReading<E, V, boolean>;
280
+ readonly stored?: false;
281
+ } | {
282
+ readonly computed: AggregateReading<E, V, true>;
283
+ readonly stored: true;
261
284
  });
285
+ /** An aggregate reading what the property holds, bivariant the way {@link EntitySql} is. */
286
+ type AggregateReading<E, V, S extends boolean> = {
287
+ agg(refs: ComputedRefs<E>): RelationAggregate<null extends V ? NonNullable<V> | null : NonNullable<V>, S>;
288
+ }['agg'];
262
289
  /** The entity a relation points at: `Company` for `company?: Company` and `companies?: Company[]` alike. */
263
290
  export type RelationTarget<V> = Extract<Unpacked<V>, object>;
264
291
  /**
@@ -360,6 +387,66 @@ export type RefMap<E, F extends keyof E = FieldKey<E>> = {
360
387
  export type EntitySql<E> = QueryRaw | {
361
388
  sql(refs: RefMap<E>): QueryRaw;
362
389
  }['sql'];
390
+ /** The fields of `C` a `sum` or an `avg` can add up. */
391
+ type NumericKey<C> = {
392
+ readonly [K in FieldKey<C>]-?: [NonNullable<C[K]>] extends [number | bigint] ? K : never;
393
+ }[FieldKey<C>];
394
+ /** One field of `C`, read off its refs: `(item) => item.amount`. */
395
+ type PickRef<C, K extends keyof C> = (refs: RefMap<C>) => ColumnRef<K & string>;
396
+ /**
397
+ * A relation as a `computed` field reads it, its aggregates typed against the related entity. `count`
398
+ * and `sum` are the two a row change turns into a delta, so they alone may be `stored`; the rest read
399
+ * as a subquery and are `null` where the relation holds no row.
400
+ */
401
+ export type RelationRef<C> = {
402
+ count(q?: AggregateFilter<C>): RelationAggregate<number, true>;
403
+ count(q: AggregatePage<C>): RelationAggregate<number, false>;
404
+ sum<K extends NumericKey<C>>(pick: PickRef<C, K>, q?: AggregateFilter<C>): RelationAggregate<NonNullable<C[K]>, true>;
405
+ sum<K extends NumericKey<C>>(pick: PickRef<C, K>, q: AggregateTopRows<C>): RelationAggregate<NonNullable<C[K]>, false>;
406
+ min<K extends FieldKey<C>>(pick: PickRef<C, K>, q?: AggregateRows<C>): RelationAggregate<NonNullable<C[K]> | null, false>;
407
+ max<K extends FieldKey<C>>(pick: PickRef<C, K>, q?: AggregateRows<C>): RelationAggregate<NonNullable<C[K]> | null, false>;
408
+ avg<K extends NumericKey<C>>(pick: PickRef<C, K>, q?: AggregateRows<C>): RelationAggregate<number | null, false>;
409
+ };
410
+ /**
411
+ * What an aggregate reads of the related rows. The predicate is an {@link EntityPredicate} rather than a
412
+ * full `$where` so that `stored: true` changes no call site: a trigger sees one row, and can evaluate
413
+ * nothing that traverses a relation or opens a subquery.
414
+ */
415
+ export type AggregateFilter<C> = {
416
+ readonly $where?: EntityPredicate<C>;
417
+ };
418
+ /**
419
+ * A tally capped to a page of the related rows, as `count` itself takes one. It needs no `$sort` and
420
+ * accepts none: an order picks *which* rows a page holds, never how many.
421
+ */
422
+ export type AggregatePage<C> = AggregateFilter<C> & Pick<RelationQuery<C>, '$limit' | '$skip'>;
423
+ /**
424
+ * The rows a value aggregate reads where it reads only some of them - "the five largest" - which only
425
+ * an order defines, so `$sort` and `$limit` come together. Keyed off the relation read they are, so
426
+ * the page an aggregate takes and the page a `$populate` takes cannot drift apart.
427
+ */
428
+ export type AggregateTopRows<C> = AggregateFilter<C> & Required<Pick<RelationQuery<C>, '$sort' | '$limit'>> & Pick<RelationQuery<C>, '$skip'>;
429
+ /** Either of those, for the aggregates that are never `stored` and so need no second signature. */
430
+ export type AggregateRows<C> = AggregateFilter<C> | AggregateTopRows<C>;
431
+ /** What a `computed` callback reads: the entity's fields as columns, its to-many relations as aggregates. */
432
+ export type ComputedRefs<E, F extends keyof E = FieldKey<E>, R extends keyof E = ToManyRelationKey<E>> = RefMap<E, F> & {
433
+ readonly [K in R]-?: RelationRef<RelationTarget<E[K]>>;
434
+ };
435
+ /** A relation aggregate a definition writes, bivariant the way {@link EntitySql} is. */
436
+ export type EntityAggregate<E> = {
437
+ agg(refs: ComputedRefs<E>): RelationAggregate;
438
+ }['agg'];
439
+ /**
440
+ * SQL a `computed` field writes. One callback shape for every arm, aggregate or not: overload
441
+ * resolution picks a contextual parameter type per arm only while they agree on one.
442
+ */
443
+ export type ComputedSql<E> = QueryRaw | {
444
+ sql(refs: ComputedRefs<E>): QueryRaw;
445
+ }['sql'];
446
+ /** The value a field's options declare it holds, where an aggregate is what declares it. */
447
+ export type AggregateValue<O> = O extends {
448
+ readonly computed: (...args: never[]) => RelationAggregate<infer V>;
449
+ } ? V : never;
363
450
  /** A predicate DDL can hold: the entity's own fields, without a relation, `$text` or a sub-query. */
364
451
  export type EntityPredicate<E> = QueryWhere<E> & {
365
452
  readonly [K in RelationKey<E>]?: never;
@@ -1,4 +1,4 @@
1
- import type { FieldKey, JsonFieldPaths, RelationKey, RelationTarget, WrittenId } from './entity.js';
1
+ import type { FieldKey, JsonFieldPaths, RelationKey, RelationTarget, ToManyRelationKey, WrittenId } from './entity.js';
2
2
  import type { QueryLock } from './queryLock.js';
3
3
  import type { QueryRaw } from './queryRaw.js';
4
4
  import type { QueryWhere } from './queryWhere.js';
@@ -116,15 +116,6 @@ export type QuerySortDirection = -1 | 1 | 'asc' | 'desc';
116
116
  * Accepted value for a field in `$sort` - either a direction or a vector similarity search.
117
117
  */
118
118
  export type QuerySortValue = QuerySortDirection | QueryVectorSearch;
119
- /**
120
- * To-one relations only: a parent holds many rows of a to-many, so there is no single value to order
121
- * it by, and joining one in would duplicate the parent instead. Order those inside `$populate`.
122
- */
123
- type ToOneRelationKey<E> = {
124
- [K in RelationKey<E>]: IsMany<E[K]> extends true ? never : K;
125
- }[RelationKey<E>];
126
- /** The relation names a parent holds many rows of, which a populated query fills with a list. */
127
- type ToManyRelationKey<E> = Exclude<RelationKey<E>, ToOneRelationKey<E>>;
128
119
  /**
129
120
  * Ordering parents by how many rows a to-many relation holds - "the ten users with the most posts".
130
121
  * The tally is computed per parent as a correlated count, never by loading the rows.
@@ -1,4 +1,4 @@
1
- import type { QueryContext, SqlQueryDialect } from './dialect.js';
1
+ import type { QueryContext, RelationAggregateSpec, SqlQueryDialect } from './dialect.js';
2
2
  import type { Type } from './utility.js';
3
3
  /** What a `raw` callback receives. See {@link QueryRawFn}. */
4
4
  export type QueryRawRenderOptions = {
@@ -44,3 +44,21 @@ export declare class ColumnRef<K extends string = string> extends QueryRaw {
44
44
  readonly key: K;
45
45
  constructor(key: K, value: QueryRawFn);
46
46
  }
47
+ /**
48
+ * A relation aggregate as SQL, read off a `computed` field's refs: `(user) => user.resources.count()`.
49
+ * It renders as the correlated subquery a `$count` reads, so a field holding one is read, filtered and
50
+ * sorted like any other.
51
+ *
52
+ * `V` is the value it reads and `Storable` whether a trigger could keep it, both carried in phantom
53
+ * fields so the aggregate a field declares decides the property's type and refuses `stored: true` on
54
+ * one no delta can maintain.
55
+ */
56
+ export declare class RelationAggregate<V = unknown, Storable extends boolean = boolean> extends QueryRaw {
57
+ /** What it reads, kept beside the SQL so a read decodes the value the way the target's field does. */
58
+ readonly spec: RelationAggregateSpec;
59
+ private readonly __value;
60
+ private readonly __storable;
61
+ constructor(
62
+ /** What it reads, kept beside the SQL so a read decodes the value the way the target's field does. */
63
+ spec: RelationAggregateSpec, value: QueryRawFn);
64
+ }
@@ -30,3 +30,21 @@ export class ColumnRef extends QueryRaw {
30
30
  this.key = key;
31
31
  }
32
32
  }
33
+ /**
34
+ * A relation aggregate as SQL, read off a `computed` field's refs: `(user) => user.resources.count()`.
35
+ * It renders as the correlated subquery a `$count` reads, so a field holding one is read, filtered and
36
+ * sorted like any other.
37
+ *
38
+ * `V` is the value it reads and `Storable` whether a trigger could keep it, both carried in phantom
39
+ * fields so the aggregate a field declares decides the property's type and refuses `stored: true` on
40
+ * one no delta can maintain.
41
+ */
42
+ export class RelationAggregate extends QueryRaw {
43
+ spec;
44
+ constructor(
45
+ /** What it reads, kept beside the SQL so a read decodes the value the way the target's field does. */
46
+ spec, value) {
47
+ super(value);
48
+ this.spec = spec;
49
+ }
50
+ }
@@ -2,8 +2,8 @@ import { getContext, UqlSecurityError } from '../context/context.js';
2
2
  import { soleIdOf } from '../entity/metadata/definition.js';
3
3
  import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index.js';
4
4
  import { VECTOR_INDEX_TYPES } from '../type/vector.js';
5
- import { isDatabaseWritten } from './field.util.js';
6
- import { entityName, getFieldKeys, getKeys, hasKeys, isOperatorObject, isScalarId, isRecord, isWhereMap, someKey, } from './object.util.js';
5
+ import { getFieldKeys, isDatabaseWritten } from './field.util.js';
6
+ import { entityName, getKeys, hasKeys, isOperatorObject, isScalarId, isRecord, isWhereMap, someKey, } from './object.util.js';
7
7
  /** The keys of `payload` a write persists as columns. */
8
8
  export function filterFieldKeys(meta, payload, callbackKey) {
9
9
  return getKeys(payload).filter((key) => {
@@ -1,4 +1,4 @@
1
- import { type ColumnFamily, type EntityMeta, type FieldOptions } from '../type/index.js';
1
+ import { type ColumnFamily, type EntityMeta, type FieldKey, type FieldOptions, type RelationAggregateSpec } from '../type/index.js';
2
2
  /** The family of a logical field type, or `undefined` where it names none. */
3
3
  export declare function columnFamily(type: unknown): ColumnFamily | undefined;
4
4
  /**
@@ -12,6 +12,12 @@ export declare function isIntegerColumn(field: Pick<FieldOptions, 'type' | 'colu
12
12
  * because an inlined field has no column to name, while a stored one is read like any other.
13
13
  */
14
14
  export declare function isInlinedExpression<F extends FieldOptions>(field: F): field is F & Required<Pick<F, 'computed'>>;
15
+ /**
16
+ * The relation aggregate a field computes, where it computes one rather than writing SQL: what it
17
+ * reads, off which relation, narrowed and capped how. Every engine renders it from this - a correlated
18
+ * subquery on SQL, a lookup on MongoDB - so both read the same declaration rather than parsing SQL.
19
+ */
20
+ export declare function aggregateOf(field: FieldOptions | undefined): RelationAggregateSpec | undefined;
15
21
  /**
16
22
  * Whether the database supplies this field's value, so no insert or update may write it: a stored
17
23
  * computed column *is* a real column, read like one, but writing to it is an error on every engine.
@@ -25,3 +31,11 @@ export declare function isSoleIdField<E>(meta: EntityMeta<E>, field: FieldOption
25
31
  * states its width. The one answer the create statement and the diff both read.
26
32
  */
27
33
  export declare function isAutoIncrement(field: FieldOptions, isPrimaryKey: boolean): boolean;
34
+ /**
35
+ * The fields a read answers with where it names none. A relation aggregate is left out unless it asks
36
+ * for `eager: true`: it reads the related rows, which is what a relation does, and a relation is loaded
37
+ * only when a query asks for it. Naming one in `$select` reads it, whatever the default.
38
+ */
39
+ export declare function getFieldKeys<E>(fields: {
40
+ [K in FieldKey<E>]?: FieldOptions;
41
+ }): FieldKey<E>[];