uql-orm 0.45.1 → 0.47.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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/bunSql/bunSqlQuerier.d.ts +7 -0
  3. package/dist/bunSql/bunSqlQuerier.js +7 -0
  4. package/dist/bunSql/bunSqlQuerierPool.d.ts +1 -0
  5. package/dist/bunSql/bunSqlQuerierPool.js +6 -1
  6. package/dist/bunSql/index.d.ts +1 -0
  7. package/dist/bunSql/index.js +1 -0
  8. package/dist/dialect/abstractSqlDialect.d.ts +22 -2
  9. package/dist/dialect/abstractSqlDialect.js +55 -18
  10. package/dist/dialect/aliases.d.ts +4 -0
  11. package/dist/dialect/aliases.js +4 -0
  12. package/dist/dialect/mysqlLikeSqlDialect.js +2 -1
  13. package/dist/dialect/pgLikeSqlDialect.d.ts +14 -0
  14. package/dist/dialect/pgLikeSqlDialect.js +42 -2
  15. package/dist/entity/metadata/definition.js +8 -1
  16. package/dist/migrate/builder/columnBuilder.d.ts +14 -0
  17. package/dist/migrate/builder/columnBuilder.js +28 -9
  18. package/dist/migrate/builder/tableBuilder.js +8 -19
  19. package/dist/migrate/builder/types.d.ts +16 -33
  20. package/dist/migrate/codegen/entityCodeGenerator.js +4 -5
  21. package/dist/migrate/codegen/fieldOptionsSource.d.ts +2 -0
  22. package/dist/migrate/codegen/fieldOptionsSource.js +49 -33
  23. package/dist/migrate/codegen/indexDecoratorSource.js +1 -9
  24. package/dist/migrate/codegen/sourceLiteral.d.ts +12 -0
  25. package/dist/migrate/codegen/sourceLiteral.js +17 -0
  26. package/dist/migrate/generator/definitionToNode.d.ts +21 -0
  27. package/dist/migrate/generator/definitionToNode.js +47 -25
  28. package/dist/migrate/introspection/baseSqlIntrospector.d.ts +4 -2
  29. package/dist/migrate/introspection/baseSqlIntrospector.js +11 -11
  30. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  31. package/dist/migrate/introspection/mongoIntrospector.js +2 -2
  32. package/dist/migrate/introspection/sqliteIntrospector.d.ts +5 -0
  33. package/dist/migrate/introspection/sqliteIntrospector.js +6 -2
  34. package/dist/migrate/schemaGenerator.d.ts +48 -2
  35. package/dist/migrate/schemaGenerator.js +109 -36
  36. package/dist/mongo/mongoDialect.js +2 -1
  37. package/dist/mongo/mongodbQuerier.d.ts +16 -0
  38. package/dist/mongo/mongodbQuerier.js +50 -1
  39. package/dist/querier/abstractQuerier.d.ts +18 -1
  40. package/dist/querier/abstractQuerier.js +50 -14
  41. package/dist/querier/abstractSqlQuerier.d.ts +14 -0
  42. package/dist/querier/abstractSqlQuerier.js +17 -0
  43. package/dist/schema/schemaAST.d.ts +34 -3
  44. package/dist/schema/schemaAST.js +4 -9
  45. package/dist/schema/schemaASTBuilder.js +5 -2
  46. package/dist/schema/schemaASTDiffer.js +8 -4
  47. package/dist/schema/types.d.ts +2 -0
  48. package/dist/sqlite/sqliteDialect.js +2 -1
  49. package/dist/type/dialect.d.ts +21 -2
  50. package/dist/type/entity.d.ts +21 -0
  51. package/dist/type/migration.d.ts +11 -18
  52. package/dist/util/dialect.util.js +5 -4
  53. package/dist/util/field.util.d.ts +23 -0
  54. package/dist/util/field.util.js +28 -0
  55. package/dist/util/fieldOption.util.d.ts +16 -3
  56. package/dist/util/fieldOption.util.js +23 -4
  57. package/dist/util/relationQuery.util.d.ts +34 -1
  58. package/dist/util/relationQuery.util.js +40 -3
  59. package/package.json +1 -1
@@ -196,10 +196,14 @@ function diffColumn(tableName, source, target, opts) {
196
196
  if (source.isUnique !== target.isUnique) {
197
197
  differences.push(`unique: ${target.isUnique} → ${source.isUnique}`);
198
198
  }
199
- // Auto-increment is deliberately not compared. No engine turns a column into an identity, or out of
200
- // one, without rewriting the table, and there is no DDL here that does it - so a difference could
201
- // only ever be reported, never settled, and the statements emitted for it (a bare `ALTER COLUMN
202
- // TYPE`) do not change it. The same rule `describeIndexDifferences` follows for what it cannot read.
199
+ // Four things are deliberately not compared, all for one reason: a difference here could only be
200
+ // reported, never settled, because no statement this generator emits would change it.
201
+ // - `isAutoIncrement`: no engine makes a column an identity, or unmakes one, without rewriting the
202
+ // table, and the alter emitted for it is a bare `ALTER COLUMN TYPE`.
203
+ // - `enum`: a check, and a database reprints one from its parse tree. See the roadmap.
204
+ // - `generatedAs`: only Postgres 17 and the MySQL family can rewrite an expression in place.
205
+ // - `comment`: fixable on every engine but SQLite, and the only one of the four worth revisiting.
206
+ // The same rule `describeIndexDifferences` follows for what it cannot read.
203
207
  // Compare default values (if both defined)
204
208
  if (!opts.defaultsEqual(source.defaultValue, target.defaultValue)) {
205
209
  differences.push(`default: ${target.defaultValue ?? 'NULL'} → ${source.defaultValue ?? 'NULL'}`);
@@ -106,6 +106,8 @@ export interface ColumnNode {
106
106
  readonly isUnique: boolean;
107
107
  /** The values the column accepts. See {@link EnumValues}. */
108
108
  readonly enum?: EnumValues;
109
+ /** The SQL an engine-generated column is computed from, as `GENERATED ALWAYS AS (...) STORED`. */
110
+ readonly generatedAs?: string;
109
111
  /** Column comment/description */
110
112
  readonly comment?: string;
111
113
  /** Reference to the parent table */
@@ -14,7 +14,8 @@ export class SqliteDialect extends AbstractSqlDialect {
14
14
  renameColumn: true,
15
15
  foreignKeyAlter: false, // SQLite does not support adding FKs to existing tables
16
16
  primaryKeyAlter: false, // nor changing a key: the only route is rebuilding the table
17
- columnComment: false, // SQLite does not support column comments
17
+ generatedColumnAdd: false, // accepted in a CREATE TABLE, rejected in an ALTER
18
+ commentSyntax: 'none',
18
19
  vectorIndexRequiresNotNull: false,
19
20
  vectorSupportsLength: false,
20
21
  supportsTimestamptz: false,
@@ -97,8 +97,27 @@ export interface EngineFeatures {
97
97
  * than emitting DDL the engine rejects.
98
98
  */
99
99
  readonly primaryKeyAlter: boolean;
100
- /** Whether the dialect supports inline COMMENT on columns (MySQL/MariaDB). */
101
- readonly columnComment: boolean;
100
+ /**
101
+ * Whether a stored generated column can be added to a table that already exists. False on SQLite,
102
+ * which takes one in a `CREATE TABLE` and rejects the same column in an `ALTER` ("cannot add a
103
+ * STORED column"), since filling it would rewrite every row - so a sync that would add one is
104
+ * refused by name rather than by the driver.
105
+ *
106
+ * A boolean rather than a mode: SQLite would accept a `VIRTUAL` column here, but emitting one where
107
+ * the entity said `stored` makes the same entity a stored column on a new database and a virtual one
108
+ * on an old, which nothing diffs and no one can see. Refusal has one form; only what UQL *emits*
109
+ * earns a mode, which is what makes {@link EngineFeatures.commentSyntax} three-way.
110
+ */
111
+ readonly generatedColumnAdd: boolean;
112
+ /**
113
+ * How this engine carries a comment on a table or a column, if at all: `inline` writes it into the
114
+ * declaration (MySQL, MariaDB), `statement` needs a `COMMENT ON` of its own (Postgres, CockroachDB),
115
+ * `none` has no such thing (SQLite, MongoDB).
116
+ *
117
+ * One knob rather than a boolean because the answer is three-way. Read as a boolean, Postgres landed
118
+ * on the same branch as SQLite and a documented column silently lost its comment.
119
+ */
120
+ readonly commentSyntax: 'inline' | 'statement' | 'none';
102
121
  /**
103
122
  * Whether every column of a vector index has to be `NOT NULL`, which MariaDB 12.3 enforces ("All
104
123
  * parts of a VECTOR index must be NOT NULL") and CockroachDB 26.3 does not - so being indexed, not
@@ -344,7 +344,28 @@ export type FieldOptions<V = TsTypeOf<FieldType>> = {
344
344
  * @example `@Field({ type: String, enum: ['draft', 'paid'] as const })`
345
345
  */
346
346
  readonly enum?: EnumValues;
347
+ /**
348
+ * @deprecated Renamed to {@link FieldOptions.computed}, which also takes `stored`. `npx uql-codemod`
349
+ * rewrites it. Giving both throws.
350
+ */
347
351
  readonly virtual?: QueryRaw;
352
+ /**
353
+ * An expression the database computes, rather than a value the caller writes. Never part of an
354
+ * insert or update either way.
355
+ *
356
+ * Unstored, it is spliced into each statement that reads the field, so nothing is persisted and any
357
+ * expression will do. With `stored`, it becomes a real column - `GENERATED ALWAYS AS (...) STORED` -
358
+ * which the engine keeps up to date, so it can be indexed and read like any other.
359
+ *
360
+ * @example `@Field({ type: String, computed: raw`"first" || ' ' || "last"`, stored: true })`
361
+ */
362
+ readonly computed?: QueryRaw;
363
+ /**
364
+ * Whether {@link FieldOptions.computed} is a column the database keeps, rather than an expression
365
+ * spliced into each statement. The dial to flip after profiling: `$select`, `$where` and `$sort`
366
+ * read the field the same way either side of it, so no call site changes.
367
+ */
368
+ readonly stored?: boolean;
348
369
  readonly updatable?: boolean;
349
370
  readonly eager?: boolean;
350
371
  readonly onInsert?: OnFieldCallback<V>;
@@ -2,7 +2,7 @@ import type { VectorCast } from '../dialect/vectorCast.js';
2
2
  import type { FullColumnDefinition, TableDefinition } from '../migrate/builder/types.js';
3
3
  import type { IndexFacet } from '../schema/indexDifferences.js';
4
4
  import type { SchemaAST } from '../schema/schemaAST.js';
5
- import type { EnumValues, ForeignKeyAction, IndexNode, IndexType, TableNode } from '../schema/types.js';
5
+ import type { ColumnNode, ForeignKeyAction, IndexNode, IndexType, TableNode } from '../schema/types.js';
6
6
  import type { EntityMeta, FieldOptions, IndexColumnSchema, LoggingOptions, SqlQuerier, Type, VectorIndexOptions } from './index.js';
7
7
  /**
8
8
  * Defines a migration using a simple object literal
@@ -95,10 +95,14 @@ export interface MigrationResult {
95
95
  readonly error?: Error;
96
96
  }
97
97
  /**
98
- * Represents a column in a database table schema
98
+ * A column as a statement describes one: {@link ColumnNode} with the engine's type spelling in place
99
+ * of the canonical one, and without the graph links.
100
+ *
101
+ * Derived so a field the node gains reaches every path that renders a column. Listed field by field,
102
+ * this dropped `enum` and then `generatedAs`, and a column added to an existing table arrived without
103
+ * the constraint or the expression the entity declared.
99
104
  */
100
- export interface ColumnSchema {
101
- readonly name: string;
105
+ export interface ColumnSchema extends Omit<ColumnNode, 'type' | 'table' | 'referencedBy' | 'references'> {
102
106
  /**
103
107
  * The engine's own type spelling, as introspection read it (`tinyint(1)`, `DATETIME`, `VARCHAR`).
104
108
  * Deliberately not a {@link CanonicalType}: the diff has to compare what the engine would *store*,
@@ -107,22 +111,10 @@ export interface ColumnSchema {
107
111
  * every sync for those columns. Use `sqlToCanonical` to interpret it.
108
112
  */
109
113
  readonly type: string;
110
- readonly nullable: boolean;
111
- readonly defaultValue?: unknown;
112
- readonly isPrimaryKey: boolean;
113
- readonly isAutoIncrement: boolean;
114
- readonly isUnique: boolean;
114
+ /** Bounds introspection reports beside the type, where the engine states them separately. */
115
115
  readonly length?: number;
116
116
  readonly precision?: number;
117
117
  readonly scale?: number;
118
- readonly comment?: string;
119
- /**
120
- * The values the column accepts, rendered as an inline `CHECK`. Carried only so a column *added* to
121
- * an existing table is constrained the way one created with its table is; an alter drops it, since
122
- * MySQL adds a second check rather than replacing the first. Introspection never sets it: a database
123
- * reports a check as a constraint, not as a property of the column.
124
- */
125
- readonly enum?: EnumValues;
126
118
  }
127
119
  /**
128
120
  * Represents a database table schema
@@ -383,7 +375,8 @@ export interface SchemaIntrospector {
383
375
  /**
384
376
  * Introspect entire database schema and return SchemaAST.
385
377
  */
386
- introspect(): Promise<SchemaAST>;
378
+ /** The whole database, or just the tables named. Names nothing matches are left out. */
379
+ introspect(tables?: readonly string[]): Promise<SchemaAST>;
387
380
  /**
388
381
  * Get all table names in the database
389
382
  */
@@ -2,19 +2,20 @@ 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';
5
6
  import { entityName, getFieldKeys, getKeys, hasKeys, isScalarId, someKey } from './object.util.js';
6
7
  export function filterFieldKeys(meta, payload, callbackKey) {
7
8
  return getKeys(payload).filter((key) => {
8
9
  const fieldOpts = meta.fields[key];
9
- return fieldOpts && !fieldOpts.virtual && (callbackKey !== 'onUpdate' || fieldOpts.updatable !== false);
10
+ return fieldOpts && !isDatabaseWritten(fieldOpts) && (callbackKey !== 'onUpdate' || fieldOpts.updatable !== false);
10
11
  });
11
12
  }
12
- /** Whether `key` is a real, non-virtual field that `record` provides a defined value for. */
13
+ /** Whether `key` is a field the caller writes, and `record` provides a defined value for. */
13
14
  function isInsertableField(meta, record, key) {
14
15
  const field = meta.fields[key];
15
- return !!field && !field.virtual && record[key] !== undefined;
16
+ return !!field && !isDatabaseWritten(field) && record[key] !== undefined;
16
17
  }
17
- /** Appends `record`'s not-yet-`seen` insertable keys (real, non-virtual, defined value) to `keys`. */
18
+ /** Appends `record`'s not-yet-`seen` insertable keys (real, caller-written, defined value) to `keys`. */
18
19
  function addInsertFieldKeys(meta, record, seen, keys) {
19
20
  for (const key of getKeys(record)) {
20
21
  if (!seen.has(key) && isInsertableField(meta, record, key)) {
@@ -1,4 +1,5 @@
1
1
  import type { EntityMeta, FieldOptions } from '../type/index.js';
2
+ import type { QueryRaw } from '../type/queryRaw.js';
2
3
  /**
3
4
  * The kind of column a field lands on, which is what decides whether an option means anything on it:
4
5
  * `length` is a string's, `precision` a number's, `dimensions` a vector's. Named in the words an
@@ -22,6 +23,28 @@ export declare const COLUMN_TYPES_BY_FAMILY: {
22
23
  };
23
24
  /** The family of a logical field type, or `undefined` where it names none. */
24
25
  export declare function columnFamily(type: unknown): ColumnFamily | undefined;
26
+ /**
27
+ * The expression the database computes for this field, whichever key declared it.
28
+ *
29
+ * `virtual` is `computed` under its old name and is read here so both spell one behaviour. Giving
30
+ * both is refused at registration rather than resolved, since only the author knows which was meant.
31
+ */
32
+ export declare function computedExpression(field: FieldOptions): QueryRaw | undefined;
33
+ /**
34
+ * Whether the field's expression is spliced into each statement that reads it, rather than stored.
35
+ *
36
+ * One of the two questions `virtual` used to answer alone. Every read site asks this - the DDL skip,
37
+ * the projection, the `$where` operand, the `ORDER BY` operand - because an inlined field has no
38
+ * column to name, while a stored one is read exactly like any other.
39
+ */
40
+ export declare function isInlinedExpression(field: FieldOptions): boolean;
41
+ /**
42
+ * Whether the database supplies this field's value, so no insert or update may write it.
43
+ *
44
+ * The other question, and the one that makes `stored` more than a rename: a stored computed column
45
+ * *is* a real column, so it is read like one - but writing to it is an error on every engine.
46
+ */
47
+ export declare function isDatabaseWritten(field: FieldOptions): boolean;
25
48
  /**
26
49
  * Whether the field is the entity's *whole* primary key - the only kind a serial can stand in for,
27
50
  * and the only one that may state `PRIMARY KEY` in its own column definition.
@@ -46,6 +46,34 @@ for (const family of getKeys(COLUMN_TYPES_BY_FAMILY)) {
46
46
  export function columnFamily(type) {
47
47
  return FAMILY_OF.get(typeof type === 'string' ? type.toLowerCase() : type);
48
48
  }
49
+ /**
50
+ * The expression the database computes for this field, whichever key declared it.
51
+ *
52
+ * `virtual` is `computed` under its old name and is read here so both spell one behaviour. Giving
53
+ * both is refused at registration rather than resolved, since only the author knows which was meant.
54
+ */
55
+ export function computedExpression(field) {
56
+ return field.computed ?? field.virtual;
57
+ }
58
+ /**
59
+ * Whether the field's expression is spliced into each statement that reads it, rather than stored.
60
+ *
61
+ * One of the two questions `virtual` used to answer alone. Every read site asks this - the DDL skip,
62
+ * the projection, the `$where` operand, the `ORDER BY` operand - because an inlined field has no
63
+ * column to name, while a stored one is read exactly like any other.
64
+ */
65
+ export function isInlinedExpression(field) {
66
+ return computedExpression(field) !== undefined && field.stored !== true;
67
+ }
68
+ /**
69
+ * Whether the database supplies this field's value, so no insert or update may write it.
70
+ *
71
+ * The other question, and the one that makes `stored` more than a rename: a stored computed column
72
+ * *is* a real column, so it is read like one - but writing to it is an error on every engine.
73
+ */
74
+ export function isDatabaseWritten(field) {
75
+ return computedExpression(field) !== undefined;
76
+ }
49
77
  /**
50
78
  * Whether the field is the entity's *whole* primary key - the only kind a serial can stand in for,
51
79
  * and the only one that may state `PRIMARY KEY` in its own column definition.
@@ -15,6 +15,8 @@ declare const FIELD_OPTION_FAMILY: {
15
15
  readonly onDelete: '*';
16
16
  readonly enum: '*';
17
17
  readonly virtual: '*';
18
+ readonly computed: '*';
19
+ readonly stored: '*';
18
20
  readonly updatable: '*';
19
21
  readonly eager: '*';
20
22
  readonly onInsert: '*';
@@ -37,8 +39,15 @@ declare const FIELD_OPTION_FAMILY: {
37
39
  * rather than on each option that dies, because it is one fact rather than nineteen - and because an
38
40
  * option added without a thought then lands on the safe side of it.
39
41
  */
40
- declare const VIRTUAL_READS: readonly ["type", "virtual", "enum", "eager", "distance"];
41
- type VirtualRead = (typeof VIRTUAL_READS)[number];
42
+ declare const INLINE_READS: readonly ["type", "virtual", "computed", "stored", "enum", "eager", "distance"];
43
+ type InlineRead = (typeof INLINE_READS)[number];
44
+ /**
45
+ * What a column the *database* writes cannot use. A stored computed column is a real column - it has
46
+ * DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
47
+ * `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
48
+ */
49
+ declare const GENERATED_WRITES: readonly ["updatable", "onInsert", "onUpdate", "softDelete", "defaultValue", "autoIncrement"];
50
+ type GeneratedWrite = (typeof GENERATED_WRITES)[number];
42
51
  /**
43
52
  * The first option `opts` cannot use, phrased as the tail of `'Entity.field' ...`, or `undefined`
44
53
  * where every option applies. The runtime half of the decorators' check, so the imperative API and
@@ -55,8 +64,12 @@ type FamilyOf<O> = O extends {
55
64
  type FamilyOfType<T> = T extends NumericColumnType | NumberConstructor | BigIntConstructor ? 'numeric' : T extends StringColumnType | StringConstructor ? 'string' : T extends VectorColumnType ? 'vector' : T extends JsonColumnType ? 'json' : T extends DateColumnType | DateConstructor ? 'date' : T extends BooleanColumnType | BooleanConstructor ? 'boolean' : T extends BlobColumnType ? 'blob' : ColumnFamily;
56
65
  /** What the field's own values leave unread, matching {@link deadOn} line for line. */
57
66
  type DeadOptions<O> = (O extends {
67
+ readonly stored: true;
68
+ } ? GeneratedWrite : O extends {
58
69
  readonly virtual: QueryRaw;
59
- } ? Exclude<keyof FieldOptions, VirtualRead> : never) | (O extends {
70
+ } | {
71
+ readonly computed: QueryRaw;
72
+ } ? Exclude<keyof FieldOptions, InlineRead> : never) | (O extends {
60
73
  readonly isId: true;
61
74
  readonly nullable: true;
62
75
  } ? 'nullable' : never) | (O extends {
@@ -1,4 +1,4 @@
1
- import { columnFamily } from './field.util.js';
1
+ import { columnFamily, isInlinedExpression } from './field.util.js';
2
2
  import { getKeys } from './object.util.js';
3
3
  /**
4
4
  * The column family each field option means anything on, or `'*'` where it applies to every column.
@@ -15,6 +15,8 @@ const FIELD_OPTION_FAMILY = {
15
15
  onDelete: '*',
16
16
  enum: '*',
17
17
  virtual: '*',
18
+ computed: '*',
19
+ stored: '*',
18
20
  updatable: '*',
19
21
  eager: '*',
20
22
  onInsert: '*',
@@ -37,21 +39,38 @@ const FIELD_OPTION_FAMILY = {
37
39
  * rather than on each option that dies, because it is one fact rather than nineteen - and because an
38
40
  * option added without a thought then lands on the safe side of it.
39
41
  */
40
- const VIRTUAL_READS = [
42
+ const INLINE_READS = [
41
43
  'type',
42
44
  'virtual',
45
+ 'computed',
46
+ 'stored',
43
47
  'enum',
44
48
  'eager',
45
49
  'distance',
46
50
  ];
51
+ /**
52
+ * What a column the *database* writes cannot use. A stored computed column is a real column - it has
53
+ * DDL, an index, a comment, a name - so only the write half is dead on one: the engine fills it, and
54
+ * `GENERATED ALWAYS AS` and `DEFAULT` are mutually exclusive on every engine that has both.
55
+ */
56
+ const GENERATED_WRITES = [
57
+ 'updatable',
58
+ 'onInsert',
59
+ 'onUpdate',
60
+ 'softDelete',
61
+ 'defaultValue',
62
+ 'autoIncrement',
63
+ ];
47
64
  /**
48
65
  * Whatever leaves `key` unread, named for the message, or `undefined` where the field reads it. Only
49
66
  * `nullable: true` contradicts a key: `nullable: false` says what the key already is, and rejecting
50
67
  * an accurate statement teaches an author to distrust the check.
51
68
  */
52
69
  function deadOn(opts, key) {
53
- if (opts.virtual !== undefined && !VIRTUAL_READS.some((read) => read === key))
54
- return 'a virtual field';
70
+ if (isInlinedExpression(opts) && !INLINE_READS.some((read) => read === key))
71
+ return 'an inlined computed field';
72
+ if (opts.stored === true && GENERATED_WRITES.some((write) => write === key))
73
+ return 'a stored computed column';
55
74
  if (opts.isId === true && key === 'nullable' && opts.nullable === true)
56
75
  return 'a primary key';
57
76
  if (opts.updatable === false && key === 'onUpdate')
@@ -1,4 +1,4 @@
1
- import type { EntityMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
1
+ import type { EntityMeta, FieldMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
2
2
  export type RelationRequestSummary<E> = {
3
3
  readonly requestedKeys: RelationKey<E>[];
4
4
  readonly joinableKeys: RelationKey<E>[];
@@ -51,6 +51,39 @@ export declare function joinedRowKey(joins: readonly ParentJoin[], row: unknown)
51
51
  * cheaper than the row-value comparison no engine spells the same way.
52
52
  */
53
53
  export declare function parentsIn(joins: readonly ParentJoin[], parents: readonly unknown[]): Record<string, unknown[]>;
54
+ /**
55
+ * The parents a bounded to-many read fans out over: the rows themselves, the columns matching them to
56
+ * their children.
57
+ */
58
+ export type ParentPartition = {
59
+ readonly joins: readonly ParentJoin[];
60
+ readonly parents: readonly unknown[];
61
+ /** The parent's own fields: a `LATERAL` row source has to spell its key column's type. */
62
+ readonly parentFields: Readonly<Record<string, FieldMeta | undefined>>;
63
+ };
64
+ /**
65
+ * Whether a to-many's own query asks for a share *per parent* rather than a slice of the whole page.
66
+ * Only `$limit`/`$skip` do: without one, a single flat statement over an `IN (...)` list is both
67
+ * correct and cheaper.
68
+ */
69
+ export declare function isBoundedPerParent(query: Pick<RelationQuery, '$limit' | '$skip'>): boolean;
70
+ /**
71
+ * `query` narrowed to one parent's children: what a single branch of a bounded per-parent read asks
72
+ * for. Shared by the backends so how the parent's filter merges into the relation's own is decided
73
+ * once - both spelled it out, and a rule that ever needs more than a spread would have to change twice.
74
+ */
75
+ export declare function queryChildrenOf<E>(query: Query<E>, joins: readonly ParentJoin[], parent: unknown): Query<E>;
76
+ /**
77
+ * `query` narrowed to the children of a whole page of parents, which is the flat read a relation with
78
+ * no share of its own takes. Over-selects on a composite key exactly as {@link parentsIn} does.
79
+ */
80
+ export declare function queryChildrenOfAll<E>(query: Query<E>, joins: readonly ParentJoin[], parents: readonly unknown[]): Query<E>;
81
+ /**
82
+ * `query` with `filter` merged into its own `$where`: the one rule for narrowing a relation's query to
83
+ * the parents it is being read for, whether the filter names their keys as values or, for a correlated
84
+ * shape, as a reference to a row source.
85
+ */
86
+ export declare function queryNarrowedTo<E>(query: Query<E>, filter: Record<string, unknown>): Query<E>;
54
87
  /**
55
88
  * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
56
89
  * column a single key contributes, an OR of key maps for several.
@@ -62,6 +62,45 @@ export function joinedRowKey(joins, row) {
62
62
  export function parentsIn(joins, parents) {
63
63
  return Object.fromEntries(joins.map(({ parent, joined }) => [joined, parents.map((it) => read(it, parent))]));
64
64
  }
65
+ /**
66
+ * Whether a to-many's own query asks for a share *per parent* rather than a slice of the whole page.
67
+ * Only `$limit`/`$skip` do: without one, a single flat statement over an `IN (...)` list is both
68
+ * correct and cheaper.
69
+ */
70
+ export function isBoundedPerParent(query) {
71
+ return query.$limit !== undefined || query.$skip !== undefined;
72
+ }
73
+ /**
74
+ * The `$where` naming exactly one parent's children: every joined column equal to that parent's value.
75
+ * What a per-parent bounded read filters each of its branches by, and the composite half of
76
+ * {@link childrenOf}.
77
+ */
78
+ function childOf(joins, parent) {
79
+ return Object.fromEntries(joins.map(({ parent: key, joined }) => [joined, read(parent, key)]));
80
+ }
81
+ /**
82
+ * `query` narrowed to one parent's children: what a single branch of a bounded per-parent read asks
83
+ * for. Shared by the backends so how the parent's filter merges into the relation's own is decided
84
+ * once - both spelled it out, and a rule that ever needs more than a spread would have to change twice.
85
+ */
86
+ export function queryChildrenOf(query, joins, parent) {
87
+ return queryNarrowedTo(query, childOf(joins, parent));
88
+ }
89
+ /**
90
+ * `query` narrowed to the children of a whole page of parents, which is the flat read a relation with
91
+ * no share of its own takes. Over-selects on a composite key exactly as {@link parentsIn} does.
92
+ */
93
+ export function queryChildrenOfAll(query, joins, parents) {
94
+ return queryNarrowedTo(query, parentsIn(joins, parents));
95
+ }
96
+ /**
97
+ * `query` with `filter` merged into its own `$where`: the one rule for narrowing a relation's query to
98
+ * the parents it is being read for, whether the filter names their keys as values or, for a correlated
99
+ * shape, as a reference to a row source.
100
+ */
101
+ export function queryNarrowedTo(query, filter) {
102
+ return { ...query, $where: { ...query.$where, ...filter } };
103
+ }
65
104
  /**
66
105
  * The `$where` naming exactly the children of the rows `parentIds` identifies: an `IN` over the one
67
106
  * column a single key contributes, an OR of key maps for several.
@@ -74,9 +113,7 @@ export function childrenOf(joins, parentIds) {
74
113
  if (joins.length === 1) {
75
114
  return { [first.joined]: parentIds };
76
115
  }
77
- return {
78
- $or: parentIds.map((id) => Object.fromEntries(joins.map(({ parent, joined }) => [joined, read(id, parent)]))),
79
- };
116
+ return { $or: parentIds.map((id) => childOf(joins, id)) };
80
117
  }
81
118
  function read(row, key) {
82
119
  return row[key];
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.45.1",
6
+ "version": "0.47.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"