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
@@ -8,7 +8,7 @@ import { getKeys, isAutoIncrement, isSoleIdField, qualifyName } from '../util/in
8
8
  import { derivedCheckName, derivedForeignKeyName, derivedPrimaryKeyName } from '../util/sql.util.js';
9
9
  import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
10
10
  import { indexDdlFor } from './ddl/index.js';
11
- import { fullColumnDefinitionToNode, tableDefinitionToNode } from './generator/definitionToNode.js';
11
+ import { columnForeignKey, columnIndex, fullColumnDefinitionToNode, tableDefinitionToNode, } from './generator/definitionToNode.js';
12
12
  import { indexNodeToSchema } from './generator/indexNodeToSchema.js';
13
13
  /**
14
14
  * Unified SQL schema generator.
@@ -61,6 +61,17 @@ export class SqlSchemaGenerator {
61
61
  serialType(type) {
62
62
  return `${this.canonicalTypeToSql(type)} ${this.dialect.autoIncrementSuffix}`;
63
63
  }
64
+ /**
65
+ * The SQL type a column is spelled with: the engine's generated-key form for an auto-increment key,
66
+ * the canonical type otherwise.
67
+ *
68
+ * One method because both paths that spell a column need the same answer - written twice, with a
69
+ * comment asking the two to stay in sync, is how the generated key and the column referencing it
70
+ * came to disagree in the first place.
71
+ */
72
+ columnSqlType(col) {
73
+ return col.isPrimaryKey && col.isAutoIncrement ? this.serialType(col.type) : this.canonicalTypeToSql(col.type);
74
+ }
64
75
  canonicalTypeToSql(type) {
65
76
  return canonicalToSql(type, this.dialect);
66
77
  }
@@ -142,7 +153,9 @@ export class SqlSchemaGenerator {
142
153
  // Add new columns
143
154
  if (diff.columnsToAdd?.length) {
144
155
  for (const column of diff.columnsToAdd) {
156
+ this.assertColumnAddable(diff.tableName, column);
145
157
  statements.push(`ALTER TABLE ${tableName} ADD COLUMN ${this.generateColumnDefinitionFromSchema(column)};`);
158
+ statements.push(...this.generateColumnCommentStatement(diff.tableName, column, diff.schema));
146
159
  }
147
160
  }
148
161
  // Alter existing columns
@@ -278,6 +291,12 @@ export class SqlSchemaGenerator {
278
291
  */
279
292
  renderColumn(column) {
280
293
  let def = `${this.escapeId(column.name)} ${column.type}`;
294
+ // Before the constraints, which every engine here accepts and is where each documents it. The
295
+ // clause takes the place of a `DEFAULT`, which is mutually exclusive with it; everything else -
296
+ // `NOT NULL`, `UNIQUE`, an enum `CHECK`, a comment - a generated column carries like any other.
297
+ if (column.generatedAs) {
298
+ def += ` GENERATED ALWAYS AS (${column.generatedAs}) STORED`;
299
+ }
281
300
  if (!column.nullable && !column.isPrimaryKey) {
282
301
  def += ' NOT NULL';
283
302
  }
@@ -290,7 +309,7 @@ export class SqlSchemaGenerator {
290
309
  }
291
310
  def += this.defaultClause(column);
292
311
  if (column.comment) {
293
- def += this.generateColumnComment(column.name, column.comment);
312
+ def += this.generateColumnComment(column.comment);
294
313
  }
295
314
  return def;
296
315
  }
@@ -349,15 +368,40 @@ export class SqlSchemaGenerator {
349
368
  }
350
369
  return [`ALTER TABLE ${table} ${this.dialect.alterColumnSyntax} ${newDefinition};`];
351
370
  }
371
+ /** The inline ` COMMENT '...'` a column declaration carries, where the engine takes one there. */
372
+ generateColumnComment(comment) {
373
+ return this.features.commentSyntax === 'inline' ? ` COMMENT ${this.dialect.escape(comment)}` : '';
374
+ }
375
+ /**
376
+ * The `COMMENT ON` statements a table and its columns need, on an engine that carries a comment
377
+ * that way. Empty on the others: MySQL writes them inline, SQLite has no comments at all.
378
+ *
379
+ * Emitted after the `CREATE TABLE` rather than folded into it, which is what `COMMENT ON` requires -
380
+ * and what makes a comment reach Postgres, where it was previously read as unsupported and dropped.
381
+ */
382
+ generateCommentStatements(table) {
383
+ if (this.features.commentSyntax !== 'statement') {
384
+ return [];
385
+ }
386
+ const tableRef = this.dialect.escapeQualifiedId(table.name, table.schema);
387
+ const statements = table.comment ? [`COMMENT ON TABLE ${tableRef} IS ${this.dialect.escape(table.comment)};`] : [];
388
+ for (const col of table.columns.values()) {
389
+ statements.push(...this.generateColumnCommentStatement(table.name, col, table.schema));
390
+ }
391
+ return statements;
392
+ }
352
393
  /**
353
- * Generate column comment clause (if supported)
394
+ * The `COMMENT ON COLUMN` one column needs, on an engine that carries a comment that way.
395
+ *
396
+ * Shared by `CREATE TABLE` and every path that adds a column: written only for the former, a column
397
+ * added later reached the database undocumented, the way its enum `CHECK` used to.
354
398
  */
355
- generateColumnComment(columnName, comment) {
356
- if (this.features.columnComment) {
357
- const escapedComment = comment.replace(/'/g, "''");
358
- return ` COMMENT '${escapedComment}'`;
399
+ generateColumnCommentStatement(tableName, column, schema) {
400
+ if (!column.comment || this.features.commentSyntax !== 'statement') {
401
+ return [];
359
402
  }
360
- return '';
403
+ const tableRef = this.dialect.escapeQualifiedId(tableName, schema);
404
+ return [`COMMENT ON COLUMN ${tableRef}.${this.escapeId(column.name)} IS ${this.dialect.escape(column.comment)};`];
361
405
  }
362
406
  /**
363
407
  * Compare an entity with a database table node and return the differences.
@@ -468,20 +512,10 @@ export class SqlSchemaGenerator {
468
512
  defaultsEqual: (expected, actual) => this.isDefaultValueEqual(actual, expected),
469
513
  };
470
514
  }
515
+ /** Spread, not copied field by field, so a field the node gains cannot go missing here. */
471
516
  columnNodeToSchema(col) {
472
- return {
473
- name: col.name,
474
- // The same rule `generateColumnFromNode` renders by, so a column added to an existing table
475
- // gets the type it would have had if the table were created from scratch.
476
- type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType(col.type) : this.canonicalTypeToSql(col.type),
477
- nullable: col.nullable,
478
- defaultValue: col.defaultValue,
479
- isPrimaryKey: col.isPrimaryKey,
480
- isAutoIncrement: col.isAutoIncrement,
481
- isUnique: col.isUnique,
482
- comment: col.comment,
483
- enum: col.enum,
484
- };
517
+ const { table: _table, referencedBy: _referencedBy, references: _references, ...column } = col;
518
+ return { ...column, type: this.columnSqlType(col) };
485
519
  }
486
520
  /**
487
521
  * Compare two default values for equality
@@ -540,11 +574,8 @@ export class SqlSchemaGenerator {
540
574
  });
541
575
  for (const rel of table.outgoingRelations) {
542
576
  if (rel.from.columns.length > 0) {
543
- const fromCols = rel.from.columns.map((c) => this.escapeId(c.name)).join(', ');
544
- const toCols = rel.to.columns.map((c) => this.escapeId(c.name)).join(', ');
545
- const constraintName = rel.name ? `CONSTRAINT ${this.escapeId(rel.name)} ` : '';
546
- constraints.push(`${constraintName}FOREIGN KEY (${fromCols}) REFERENCES ${this.dialect.escapeQualifiedId(rel.to.table.name, rel.to.table.schema)} (${toCols})` +
547
- ` ON DELETE ${rel.onDelete ?? this.defaultForeignKeyAction} ON UPDATE ${rel.onUpdate ?? this.defaultForeignKeyAction}`);
577
+ const refTable = this.dialect.escapeQualifiedId(rel.to.table.name, rel.to.table.schema);
578
+ constraints.push(this.foreignKeyConstraint(table.name, foreignKeyOf(rel), refTable));
548
579
  }
549
580
  }
550
581
  const ifNotExists = options.ifNotExists && this.features.ifNotExists ? 'IF NOT EXISTS ' : '';
@@ -558,6 +589,9 @@ export class SqlSchemaGenerator {
558
589
  if (this.dialect.tableOptions) {
559
590
  createSql += ` ${this.dialect.tableOptions}`;
560
591
  }
592
+ if (table.comment && this.features.commentSyntax === 'inline') {
593
+ createSql += ` COMMENT=${this.dialect.escape(table.comment)}`;
594
+ }
561
595
  createSql += ';';
562
596
  const statements = [];
563
597
  if (this.dialect.vectorExtension) {
@@ -567,6 +601,7 @@ export class SqlSchemaGenerator {
567
601
  }
568
602
  }
569
603
  statements.push(createSql);
604
+ statements.push(...this.generateCommentStatements(table));
570
605
  for (const idx of table.indexes) {
571
606
  statements.push(this.generateCreateIndexFromNode(idx));
572
607
  }
@@ -577,10 +612,7 @@ export class SqlSchemaGenerator {
577
612
  * so only a lone primary key column carries `PRIMARY KEY` inline.
578
613
  */
579
614
  generateColumnFromNode(col) {
580
- return this.renderColumn({
581
- ...col,
582
- type: col.isPrimaryKey && col.isAutoIncrement ? this.serialType(col.type) : this.canonicalTypeToSql(col.type),
583
- });
615
+ return this.renderColumn({ ...col, type: this.columnSqlType(col) });
584
616
  }
585
617
  /**
586
618
  * Generate CREATE INDEX SQL from an IndexNode.
@@ -600,9 +632,28 @@ export class SqlSchemaGenerator {
600
632
  }
601
633
  return `ALTER TABLE ${this.escapeId(oldName)} RENAME TO ${this.escapeId(newName)};`;
602
634
  }
635
+ /**
636
+ * `ADD COLUMN`, plus the constraint and index the column declares.
637
+ *
638
+ * `CREATE TABLE` lifts a column's `references` and `index` onto the table it is building; this had
639
+ * no lift, so a hand-written `addColumn(...).references(...).index()` emitted the column alone and
640
+ * dropped both without a word. Several statements in one string is what `generateAlterColumnSql`
641
+ * already returns, and `execute` splits them.
642
+ */
603
643
  generateAddColumnSql(tableName, column) {
644
+ this.assertColumnAddable(tableName, column);
604
645
  const colSql = this.generateColumnFromNode(fullColumnDefinitionToNode(column, tableName));
605
- return `ALTER TABLE ${this.escapeId(tableName)} ADD COLUMN ${colSql};`;
646
+ const statements = [`ALTER TABLE ${this.escapeId(tableName)} ADD COLUMN ${colSql};`];
647
+ const foreignKey = columnForeignKey(column);
648
+ if (foreignKey) {
649
+ statements.push(...this.addForeignKeyStatements(tableName, [foreignKey]));
650
+ }
651
+ const index = columnIndex(tableName, column);
652
+ if (index) {
653
+ statements.push(this.generateCreateIndex(tableName, index));
654
+ }
655
+ statements.push(...this.generateColumnCommentStatement(tableName, column));
656
+ return statements.join('\n');
606
657
  }
607
658
  generateAlterColumnSql(tableName, columnName, column) {
608
659
  const node = fullColumnDefinitionToNode(column, tableName);
@@ -614,16 +665,26 @@ export class SqlSchemaGenerator {
614
665
  generateRenameColumnSql(tableName, oldName, newName) {
615
666
  return `ALTER TABLE ${this.escapeId(tableName)} RENAME COLUMN ${this.escapeId(oldName)} TO ${this.escapeId(newName)};`;
616
667
  }
617
- generateAddForeignKeySql(tableName, foreignKey) {
668
+ /**
669
+ * `CONSTRAINT <name> FOREIGN KEY (...) REFERENCES ... ON DELETE ... ON UPDATE ...`.
670
+ *
671
+ * One spelling for the two places that need it - inline in a `CREATE TABLE`, and after `ADD` in an
672
+ * `ALTER`. Written twice, the two drifted over which end they qualified with a schema.
673
+ */
674
+ foreignKeyConstraint(tableName, foreignKey, refTableSql) {
618
675
  const fkCols = foreignKey.columns.map((c) => this.escapeId(c)).join(', ');
619
676
  const refCols = foreignKey.references.columns.map((c) => this.escapeId(c)).join(', ');
620
- const constraintName = this.escapeId(constraintNameOf(tableName, foreignKey));
677
+ return (`CONSTRAINT ${this.escapeId(constraintNameOf(tableName, foreignKey))} ` +
678
+ `FOREIGN KEY (${fkCols}) REFERENCES ${refTableSql} (${refCols}) ` +
679
+ `ON DELETE ${foreignKey.onDelete ?? this.defaultForeignKeyAction} ` +
680
+ `ON UPDATE ${foreignKey.onUpdate ?? this.defaultForeignKeyAction}`);
681
+ }
682
+ generateAddForeignKeySql(tableName, foreignKey) {
621
683
  if (!this.features.foreignKeyAlter) {
622
684
  throw new TypeError(`Dialect ${this.dialect} does not support adding foreign keys to existing tables`);
623
685
  }
624
- return (`ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${constraintName} ` +
625
- `FOREIGN KEY (${fkCols}) REFERENCES ${this.escapeId(foreignKey.references.table)} (${refCols}) ` +
626
- `ON DELETE ${foreignKey.onDelete ?? this.defaultForeignKeyAction} ON UPDATE ${foreignKey.onUpdate ?? this.defaultForeignKeyAction};`);
686
+ const constraint = this.foreignKeyConstraint(tableName, foreignKey, this.escapeId(foreignKey.references.table));
687
+ return `ALTER TABLE ${this.escapeId(tableName)} ADD ${constraint};`;
627
688
  }
628
689
  generateDropForeignKeySql(tableName, constraintName) {
629
690
  return `ALTER TABLE ${this.escapeId(tableName)} ${this.dialect.dropForeignKeySyntax} ${this.escapeId(constraintName)};`;
@@ -659,6 +720,18 @@ export class SqlSchemaGenerator {
659
720
  }
660
721
  return `ALTER TABLE ${table} DROP CONSTRAINT ${this.escapeId(constraintName)};`;
661
722
  }
723
+ /**
724
+ * A column an `ALTER` can carry. Only a generated one is ever refused, and only where the engine
725
+ * takes it in a `CREATE TABLE` but not afterwards.
726
+ */
727
+ assertColumnAddable(tableName, column) {
728
+ if (!column.generatedAs || this.features.generatedColumnAdd) {
729
+ return;
730
+ }
731
+ throw new TypeError(`${this.dialect}: Cannot add the computed column "${column.name}" to the existing table ` +
732
+ `"${tableName}" - this database only accepts one in a CREATE TABLE. Drop \`stored\` to have the ` +
733
+ 'expression spliced into each statement instead, or recreate the table in a written migration.');
734
+ }
662
735
  assertPrimaryKeyAlterable(tableName) {
663
736
  if (this.features.primaryKeyAlter) {
664
737
  return;
@@ -17,7 +17,8 @@ export const mongoDialectFeatures = {
17
17
  renameColumn: false,
18
18
  foreignKeyAlter: false,
19
19
  primaryKeyAlter: false,
20
- columnComment: false,
20
+ generatedColumnAdd: false,
21
+ commentSyntax: 'none',
21
22
  vectorIndexRequiresNotNull: false,
22
23
  vectorSupportsLength: false,
23
24
  supportsTimestamptz: false,
@@ -1,6 +1,7 @@
1
1
  import type { Document, MongoClient } from 'mongodb';
2
2
  import { AbstractQuerier } from '../querier/index.js';
3
3
  import type { EntityData, ExtraOptions, IdValue, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
4
+ import { type ParentPartition } from '../util/index.js';
4
5
  import type { MongoDialect } from './mongoDialect.js';
5
6
  export declare class MongodbQuerier extends AbstractQuerier {
6
7
  readonly dialect: MongoDialect;
@@ -10,6 +11,21 @@ export declare class MongodbQuerier extends AbstractQuerier {
10
11
  constructor(dialect: MongoDialect, conn: MongoClient, extra?: ExtraOptions | undefined);
11
12
  private execute;
12
13
  protected internalFindMany<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
14
+ /**
15
+ * Every parent's own bounded page. One `$unionWith` per parent after the first, so the whole page is
16
+ * one round trip - measured ~6x faster than a query each (11.0 ms -> 1.9 ms at 50 parents, 87.5 ms
17
+ * -> 14.1 ms at 500), because `execute` serializes on the session and a query each is N round trips
18
+ * rather than N concurrent ones.
19
+ *
20
+ * Both arms return documents with their own relations already filled, so this only chooses between
21
+ * them: leaving that to the caller once meant the arm that fills its own did it twice.
22
+ * [The design](../../../../architecture/populate-limits.md).
23
+ */
24
+ protected internalFindManyPerParent<E extends Document>(entity: Type<E>, q: Query<E>, { joins, parents }: ParentPartition): Promise<E[]>;
25
+ /** Every parent's page as one `$unionWith` pipeline. */
26
+ private readInOnePipeline;
27
+ /** A query each, for what one pipeline cannot carry. `internalFindMany` fills its own relations. */
28
+ private readEachInTurn;
13
29
  protected internalFindManyStream<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncGenerator<E, void, unknown>;
14
30
  private buildScalarProjection;
15
31
  /** Build a MongoDB FindCursor with filter, projection, sort, skip, and limit from the query. */
@@ -2,7 +2,7 @@ import { COUNT_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { getMeta, idOf, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, queryChildrenOf, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
7
7
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
8
8
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -11,6 +11,12 @@ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys,
11
11
  function asksForNoRows(q) {
12
12
  return q.$limit === 0;
13
13
  }
14
+ /**
15
+ * What MongoDB accepts in one pipeline. Bisected against a real server: 1000 top-level stages are
16
+ * accepted and 1001 refused (`Pipeline length must be no longer than 1000 stages`), and a
17
+ * `$unionWith`'s own sub-pipeline stages do not count toward it.
18
+ */
19
+ const MAX_PIPELINE_STAGES = 1000;
14
20
  export class MongodbQuerier extends AbstractQuerier {
15
21
  dialect;
16
22
  conn;
@@ -63,6 +69,49 @@ export class MongodbQuerier extends AbstractQuerier {
63
69
  return documents;
64
70
  });
65
71
  }
72
+ /**
73
+ * Every parent's own bounded page. One `$unionWith` per parent after the first, so the whole page is
74
+ * one round trip - measured ~6x faster than a query each (11.0 ms -> 1.9 ms at 50 parents, 87.5 ms
75
+ * -> 14.1 ms at 500), because `execute` serializes on the session and a query each is N round trips
76
+ * rather than N concurrent ones.
77
+ *
78
+ * Both arms return documents with their own relations already filled, so this only chooses between
79
+ * them: leaving that to the caller once meant the arm that fills its own did it twice.
80
+ * [The design](../../../../architecture/populate-limits.md).
81
+ */
82
+ async internalFindManyPerParent(entity, q, { joins, parents }) {
83
+ const queries = parents.map((parent) => queryChildrenOf(q, joins, parent));
84
+ // A vector sort needs a pipeline of its own shape, which only `internalFindMany` builds.
85
+ if (this.dialect.extractVectorSort(q.$sort)) {
86
+ return this.readEachInTurn(entity, queries);
87
+ }
88
+ const pipelines = queries.map((it) => this.dialect.aggregationPipeline(entity, it));
89
+ // Counted, not estimated: the leading branch's own length grows with every `$lookup` a populate
90
+ // adds, so a fixed parent budget would let a richer query overflow at the server instead.
91
+ const stages = (pipelines[0]?.length ?? 0) + pipelines.length - 1;
92
+ return stages > MAX_PIPELINE_STAGES
93
+ ? this.readEachInTurn(entity, queries)
94
+ : this.readInOnePipeline(entity, q, pipelines);
95
+ }
96
+ /** Every parent's page as one `$unionWith` pipeline. */
97
+ async readInOnePipeline(entity, q, pipelines) {
98
+ const meta = getMeta(entity);
99
+ const [first, ...rest] = pipelines;
100
+ const documents = await this.runPipeline(entity, meta, [
101
+ ...first,
102
+ ...rest.map((pipeline) => ({ $unionWith: { coll: meta.name, pipeline } })),
103
+ ]);
104
+ await this.fillToManyRelations(entity, documents, q.$populate);
105
+ return documents;
106
+ }
107
+ /** A query each, for what one pipeline cannot carry. `internalFindMany` fills its own relations. */
108
+ async readEachInTurn(entity, queries) {
109
+ const documents = [];
110
+ for (const query of queries) {
111
+ documents.push(...(await this.internalFindMany(entity, query)));
112
+ }
113
+ return documents;
114
+ }
66
115
  async *internalFindManyStream(entity, q, opts) {
67
116
  if (asksForNoRows(q)) {
68
117
  return;
@@ -1,5 +1,5 @@
1
1
  import type { EntityData, EntityId, ExtraOptions, FieldKey, IdValue, Querier, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryFindResult, QueryGroupMap, QueryOneProjected, QueryOptions, QueryPage, QueryPopulate, QueryProjected, QuerySearch, QueryStreamProjected, QueryUpdateResult, RawRow, RelationKey, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
2
- import { LoggerWrapper, type ParentJoin } from '../util/index.js';
2
+ import { LoggerWrapper, type ParentJoin, type ParentPartition } from '../util/index.js';
3
3
  /**
4
4
  * Base class for all database queriers.
5
5
  * It provides a standardized way to execute tasks serially to prevent race conditions on database connections.
@@ -64,6 +64,14 @@ export declare abstract class AbstractQuerier implements Querier {
64
64
  $entity: Type<E>;
65
65
  }, opts?: QueryOptions): AsyncIterable<QueryFindResult<E, S, V, X, P>>;
66
66
  findManyStream<E extends object, const S extends FieldKey<E> = never, const V = true, const X extends FieldKey<E> = never, const P extends RelationKey<E> = never>(entity: Type<E>, q: QueryStreamProjected<E, S, V, X, P>, opts?: QueryOptions): AsyncIterable<QueryFindResult<E, S, V, X, P>>;
67
+ /**
68
+ * The children of every parent in `parents`, at most `$limit` each after `$skip` - what a to-many
69
+ * `$populate` carrying either one means. One statement, not one per parent.
70
+ *
71
+ * Abstract rather than defaulted: a default would be N queries, which is the N+1 that batched
72
+ * population exists to prevent, and it would be invisible to whichever backend forgot to override.
73
+ */
74
+ protected abstract internalFindManyPerParent<E extends object>(entity: Type<E>, q: Query<E>, partition: ParentPartition): Promise<E[]>;
67
75
  protected abstract internalFindManyStream<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): AsyncIterable<E>;
68
76
  /**
69
77
  * Find multiple records and return both the records and total count.
@@ -137,6 +145,15 @@ export declare abstract class AbstractQuerier implements Querier {
137
145
  protected fillToManyRelations<E>(entity: Type<E>, payload: E[], populate?: QueryPopulate<E>): Promise<void>;
138
146
  private fillToManyThroughRelation;
139
147
  private fillToManyOneToMany;
148
+ /**
149
+ * The children of a whole page of parents, however the relation asked for them: one bounded branch
150
+ * per parent when it wants a share of its own, otherwise a single flat statement over an `IN (...)`
151
+ * list, which is both correct and cheaper.
152
+ *
153
+ * The one place that decision is made - a one-to-many and the junction of a many-to-many differ in
154
+ * what they query, never in how the page is spread over its parents.
155
+ */
156
+ private findChildrenOf;
140
157
  protected putChildrenInParents<E>(parents: E[], children: RawRow[], joins: readonly ParentJoin[], relKey: keyof E & string): void;
141
158
  protected insertRelations<E extends object>(entity: Type<E>, payload: E[]): Promise<void>;
142
159
  protected updateRelations<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { assertSoleId, getMeta, idOf, soleIdOf } from '../entity/index.js';
2
- import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, parentJoins, parentRowKey, parentsIn, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
2
+ import { asSelectMap, augmentWhere, childrenOf, clone, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, isScalarId, joinedColumns, joinedRowKey, LoggerWrapper, isBoundedPerParent, parentJoins, parentRowKey, queryChildrenOfAll, parseRelationAtKey, parseRelationQueryValue, runHooks, someKey, targetKeyColumns, withoutSoftDeleteFilter, } from '../util/index.js';
3
3
  import { enrichError } from './queryError.js';
4
4
  import { fillRelationCounts, withIdForCounts } from './relationCount.js';
5
5
  /**
@@ -299,22 +299,44 @@ export class AbstractQuerier {
299
299
  const throughEntity = relOpts.through();
300
300
  const throughMeta = getMeta(throughEntity);
301
301
  const targetRelKey = getKeys(throughMeta.relations).find((key) => throughMeta.relations[key]?.references.some(({ local }) => local === targetColumn));
302
- // A relation query names the target's columns, not the join table's, so the projection and the
303
- // filter belong on the populate below - resolved there against the entity that has them. Spread
304
- // onto the through query they asked `ItemTag` for `Tag`'s columns: `$where`/`$sort` failed with
305
- // "no such column", and `$exclude` collided with the `$select` this builds.
306
- const { $select: _select, $exclude: _exclude, $where: _where, ...throughQuery } = relationQuery;
307
- const throughFounds = await this.findMany(throughEntity, {
308
- ...throughQuery,
302
+ if (!targetRelKey) {
303
+ // Asserted rather than assumed: used as a key regardless, it spells the literal string
304
+ // `undefined`, and the statement asks the junction for a relation of that name.
305
+ throw new TypeError(`'${meta.name}.${relKey}' goes through '${throughMeta.name}', which declares no relation on its ` +
306
+ `'${targetColumn}' column. Give it one, so the target's rows can be read through it.`);
307
+ }
308
+ // A relation query names the target's columns, not the junction's, so its projection and filter
309
+ // belong on the populate below, resolved against the entity that has them. Spread onto the
310
+ // junction query instead they asked `ItemTag` for `Tag`'s columns and failed with "no such
311
+ // column".
312
+ //
313
+ // Ordering and paging split the other way: they describe the statement with one row per pairing,
314
+ // which is the junction's. Left on the populate they reached a to-one join, which rejects all
315
+ // four by name - so a many-to-many carrying any of them threw rather than paging.
316
+ //
317
+ // Those four are not a coincidence: they are exactly the clauses a joined relation rejects, for
318
+ // the same reason - each needs a statement with many rows per parent, which only the junction's
319
+ // is. The `satisfies` ties the two lists together, so a fifth clause added there fails to compile
320
+ // here rather than quietly staying on the populate and throwing again.
321
+ const { $sort, $limit, $skip, $distinct, ...targetQuery } = relationQuery;
322
+ const junctionClauses = {
323
+ $limit,
324
+ $skip,
325
+ $distinct,
326
+ // Qualified by the relation that reaches them, since the columns it names are the target's.
327
+ $sort: $sort && { [targetRelKey]: $sort },
328
+ };
329
+ const junctionQuery = {
309
330
  $select: joinedColumns(joins),
331
+ ...junctionClauses,
310
332
  $populate: {
311
333
  [targetRelKey]: {
312
- ...relationQuery,
334
+ ...targetQuery,
313
335
  $required: true,
314
336
  },
315
337
  },
316
- $where: parentsIn(joins, payload),
317
- });
338
+ };
339
+ const throughFounds = await this.findChildrenOf(throughEntity, junctionQuery, joins, payload, meta.fields);
318
340
  // The junction's own columns carried onto the target's row, which is where `putChildrenInParents`
319
341
  // reads them back from - a junction row holds the parent's key under `joined`, not under `parent`.
320
342
  const founds = throughFounds.map((it) => ({
@@ -336,9 +358,23 @@ export class AbstractQuerier {
336
358
  }
337
359
  delete exclude?.[joined];
338
360
  }
339
- relationQuery.$where = { ...relationQuery.$where, ...parentsIn(joins, payload) };
340
- const founds = await this.findMany(relEntity, relationQuery);
341
- this.putChildrenInParents(payload, founds, joins, relKey);
361
+ this.putChildrenInParents(payload, await this.findChildrenOf(relEntity, relationQuery, joins, payload, meta.fields), joins, relKey);
362
+ }
363
+ /**
364
+ * The children of a whole page of parents, however the relation asked for them: one bounded branch
365
+ * per parent when it wants a share of its own, otherwise a single flat statement over an `IN (...)`
366
+ * list, which is both correct and cheaper.
367
+ *
368
+ * The one place that decision is made - a one-to-many and the junction of a many-to-many differ in
369
+ * what they query, never in how the page is spread over its parents.
370
+ */
371
+ async findChildrenOf(entity, query, joins, parents, parentFields) {
372
+ const founds = isBoundedPerParent(query)
373
+ ? await this.internalFindManyPerParent(entity, query, { joins, parents, parentFields })
374
+ : await this.findMany(entity, queryChildrenOfAll(query, joins, parents));
375
+ // Read back as rows rather than as the entity they hydrate to: what follows regroups them by the
376
+ // join columns, which a projected entity type does not carry.
377
+ return founds;
342
378
  }
343
379
  putChildrenInParents(parents, children, joins, relKey) {
344
380
  const childrenByParentId = {};
@@ -1,5 +1,6 @@
1
1
  import type { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import type { EntityData, ExtraOptions, IdValue, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, QueryUpdateResult, SqlQuerier, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
3
+ import { type ParentPartition } from '../util/index.js';
3
4
  import type { BuildUpdateResultPayload } from '../util/sql.util.js';
4
5
  import { AbstractQuerier } from './abstractQuerier.js';
5
6
  export declare abstract class AbstractSqlQuerier extends AbstractQuerier implements SqlQuerier {
@@ -57,6 +58,19 @@ export declare abstract class AbstractSqlQuerier extends AbstractQuerier impleme
57
58
  */
58
59
  private applyVectorTuning;
59
60
  protected internalFindMany<E extends object>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<E[]>;
61
+ /**
62
+ * One bounded subquery per parent, concatenated with `UNION ALL`, so each parent gets its own
63
+ * `$limit` rather than a share of one. Universal, and reads `parents x (skip + limit)` rows where a
64
+ * `ROW_NUMBER` window reads every matching child. [The design](../../../../architecture/populate-limits.md).
65
+ *
66
+ * Each branch is a wrapped derived table: SQLite rejects `ORDER BY`/`LIMIT` on a bare parenthesised
67
+ * compound branch, and the wrapper costs nothing elsewhere.
68
+ *
69
+ * Unlike {@link selectRows} this asserts no lock and tunes no vector search: `$lock` and
70
+ * `$candidates` describe the statement, and `parseRelationQueryValue` refuses both on a relation
71
+ * query, so neither can reach here.
72
+ */
73
+ protected internalFindManyPerParent<E extends object>(entity: Type<E>, q: Query<E>, partition: ParentPartition): Promise<E[]>;
60
74
  /**
61
75
  * One statement for both: the page carries its own unpaged total in an extra column. An empty page
62
76
  * has no row to carry it, which is the one case still needing a count of its own - a `$skip` past
@@ -104,6 +104,23 @@ export class AbstractSqlQuerier extends AbstractQuerier {
104
104
  async internalFindMany(entity, q, opts) {
105
105
  return this.hydrateRows(entity, q, await this.selectRows(entity, q, opts));
106
106
  }
107
+ /**
108
+ * One bounded subquery per parent, concatenated with `UNION ALL`, so each parent gets its own
109
+ * `$limit` rather than a share of one. Universal, and reads `parents x (skip + limit)` rows where a
110
+ * `ROW_NUMBER` window reads every matching child. [The design](../../../../architecture/populate-limits.md).
111
+ *
112
+ * Each branch is a wrapped derived table: SQLite rejects `ORDER BY`/`LIMIT` on a bare parenthesised
113
+ * compound branch, and the wrapper costs nothing elsewhere.
114
+ *
115
+ * Unlike {@link selectRows} this asserts no lock and tunes no vector search: `$lock` and
116
+ * `$candidates` describe the statement, and `parseRelationQueryValue` refuses both on a relation
117
+ * query, so neither can reach here.
118
+ */
119
+ async internalFindManyPerParent(entity, q, partition) {
120
+ const ctx = this.dialect.createContext();
121
+ this.dialect.findPerParent(ctx, entity, q, partition);
122
+ return this.hydrateRows(entity, q, await this.all(ctx.sql, ctx.values));
123
+ }
107
124
  /**
108
125
  * One statement for both: the page carries its own unpaged total in an extra column. An empty page
109
126
  * has no row to carry it, which is the one case still needing a count of its own - a `$skip` past
@@ -150,7 +150,38 @@ export declare class SchemaAST implements ISchemaAST {
150
150
  indexCount: number;
151
151
  };
152
152
  /**
153
- * Convert schema to a plain object for serialization/debugging.
154
- */
155
- toJSON(): object;
153
+ * The schema as a plain object, for serialization and debugging. The graph links are what is left
154
+ * out - they are cycles, and nothing else is: listing the fields to keep instead dropped every
155
+ * option a column had gained since, `defaultValue` and `enum` included.
156
+ */
157
+ toJSON(): {
158
+ tables: {
159
+ name: string;
160
+ columns: {
161
+ name: string;
162
+ type: import("./types.js").CanonicalType;
163
+ nullable: boolean;
164
+ defaultValue?: unknown;
165
+ isPrimaryKey: boolean;
166
+ isAutoIncrement: boolean;
167
+ isUnique: boolean;
168
+ enum?: import("./types.js").EnumValues;
169
+ generatedAs?: string;
170
+ comment?: string;
171
+ }[];
172
+ indexes: {
173
+ name: string;
174
+ columns: string[];
175
+ unique: boolean;
176
+ }[];
177
+ }[];
178
+ relationships: {
179
+ name: string;
180
+ type: RelationshipType;
181
+ from: string;
182
+ to: string;
183
+ onDelete: import("./types.js").ForeignKeyAction | undefined;
184
+ onUpdate: import("./types.js").ForeignKeyAction | undefined;
185
+ }[];
186
+ };
156
187
  }
@@ -385,20 +385,15 @@ export class SchemaAST {
385
385
  };
386
386
  }
387
387
  /**
388
- * Convert schema to a plain object for serialization/debugging.
388
+ * The schema as a plain object, for serialization and debugging. The graph links are what is left
389
+ * out - they are cycles, and nothing else is: listing the fields to keep instead dropped every
390
+ * option a column had gained since, `defaultValue` and `enum` included.
389
391
  */
390
392
  toJSON() {
391
393
  return {
392
394
  tables: Array.from(this.tables.values()).map((t) => ({
393
395
  name: t.name,
394
- columns: Array.from(t.columns.values()).map((c) => ({
395
- name: c.name,
396
- type: c.type,
397
- nullable: c.nullable,
398
- isPrimaryKey: c.isPrimaryKey,
399
- isAutoIncrement: c.isAutoIncrement,
400
- isUnique: c.isUnique,
401
- })),
396
+ columns: Array.from(t.columns.values()).map(({ table: _table, referencedBy: _referencedBy, references: _references, ...column }) => column),
402
397
  indexes: t.indexes.map((i) => ({
403
398
  name: i.name,
404
399
  columns: i.entries.map((entry) => entry.column),
@@ -6,6 +6,8 @@
6
6
  * - Database introspection results (TableSchema[])
7
7
  */
8
8
  import { getMeta, soleIdOf } from '../entity/metadata/definition.js';
9
+ import { ddlText } from '../util/ddlExpression.util.js';
10
+ import { computedExpression, isInlinedExpression } from '../util/field.util.js';
9
11
  import { isSoleIdField } from '../util/field.util.js';
10
12
  import { isAutoIncrement } from '../util/field.util.js';
11
13
  import { derivedForeignKeyName, derivedIndexName, qualifyName } from '../util/sql.util.js';
@@ -82,8 +84,8 @@ function addTableFromEntity(ctx, meta) {
82
84
  const field = fields[key];
83
85
  if (!field)
84
86
  continue;
85
- // Skip virtual fields
86
- if (field.virtual)
87
+ // An inlined expression has no column; a stored one is a column like any other.
88
+ if (isInlinedExpression(field))
87
89
  continue;
88
90
  const columnName = ctx.resolveColumnName(key, field);
89
91
  const type = resolveColumnCanonicalType(field);
@@ -99,6 +101,7 @@ function addTableFromEntity(ctx, meta) {
99
101
  isPrimaryKey,
100
102
  isAutoIncrement: isAutoIncrement(field, isSoleKey),
101
103
  isUnique: field.unique ?? false,
104
+ generatedAs: ddlText(computedExpression(field), `the computed column '${columnName}'`),
102
105
  comment: field.comment,
103
106
  enum: field.enum,
104
107
  table,