uql-orm 0.24.7 → 0.25.1

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.
package/README.md CHANGED
@@ -51,7 +51,7 @@ from the browser to the server. The same object runs on every supported database
51
51
  ## Why UQL?
52
52
 
53
53
  - **The fastest.** Wins [all 8 categories](https://uql-orm.dev/benchmark) of our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), beating even query builders like Knex and Kysely: ~2.4× faster than the runner-up on average, reaching over 4.6M ops/s on simple SELECTs.
54
- - **Light.** Zero runtime dependencies, 288 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
54
+ - **Light.** Zero runtime dependencies, 305 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
55
55
  - **Queries are data (JSON), not method chains.** Plain JSON in, typed rows out. There's no DSL to learn and nothing to compile.
56
56
  - **Type-safe to the leaf.** Every key is autocompleted and checked against your entity, down to the fields of a populated relation. Operators are gated per field type, and [JSON/JSONB](https://uql-orm.dev/querying/json) dot-paths resolve each path's value type, so `$like` on a numeric column, or a typo'd path, is a compile error instead of a runtime surprise.
57
57
  - **No codegen, no build step.** Entities are TypeScript classes, so your code *is* the schema. There's no `.prisma` file to regenerate and no generated client to keep in sync.
@@ -534,7 +534,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
534
534
  }
535
535
  }
536
536
  appendInNin(ctx, field, op, val) {
537
- this.appendFieldSql(ctx, field, this.formatIn(ctx, Array.isArray(val) ? val : [], op === '$nin'));
537
+ if (!Array.isArray(val)) {
538
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
539
+ throw TypeError(`${op} expects an array, got ${val === null ? 'null' : typeof val}`);
540
+ }
541
+ this.appendFieldSql(ctx, field, this.formatIn(ctx, val, op === '$nin'));
538
542
  }
539
543
  /**
540
544
  * Build a comparison condition for a JSON field.
File without changes
@@ -10,6 +10,7 @@
10
10
  * - JSDoc comments for sync-added fields
11
11
  */
12
12
  import { canonicalToTypeScript } from '../../schema/canonicalType.js';
13
+ import { DEFAULT_FOREIGN_KEY_ACTION, } from '../../schema/types.js';
13
14
  import { camelCase, pascalCase, singularize } from '../../util/string.util.js';
14
15
  import { buildFieldOptionsSource } from './fieldOptionsSource.js';
15
16
  /**
@@ -234,8 +235,15 @@ export class EntityCodeGenerator {
234
235
  }
235
236
  lines.push(' */');
236
237
  }
237
- // Decorator
238
- lines.push(` @${decoratorName}({ entity: () => ${relatedClassName} })`);
238
+ // Decorator. `onDelete`/`onUpdate` only when introspection found a real referential action, so a
239
+ // round-trip through an unconstrained column stays as terse as before.
240
+ const fkActions = [];
241
+ if (rel.onDelete && rel.onDelete !== DEFAULT_FOREIGN_KEY_ACTION)
242
+ fkActions.push(`onDelete: '${rel.onDelete}'`);
243
+ if (rel.onUpdate && rel.onUpdate !== DEFAULT_FOREIGN_KEY_ACTION)
244
+ fkActions.push(`onUpdate: '${rel.onUpdate}'`);
245
+ const fkActionsSource = fkActions.length ? `, ${fkActions.join(', ')}` : '';
246
+ lines.push(` @${decoratorName}({ entity: () => ${relatedClassName}${fkActionsSource} })`);
239
247
  // Property
240
248
  lines.push(` ${propertyName}?: ${relatedClassName};`);
241
249
  return lines.join('\n');
@@ -1,6 +1,6 @@
1
1
  import { AbstractDialect } from '../../dialect/abstractDialect.js';
2
2
  import type { ForeignKeyAction, IndexNode, TableNode } from '../../schema/types.js';
3
- import type { FieldOptions, IndexSchema, InsertIdSource, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../../type/index.js';
3
+ import type { CreateSchemaOptions, FieldOptions, IndexSchema, InsertIdSource, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../../type/index.js';
4
4
  import type { TableDefinition } from '../builder/types.js';
5
5
  export declare class MongoSchemaGenerator extends AbstractDialect implements SchemaGenerator {
6
6
  protected readonly defaultForeignKeyAction?: ForeignKeyAction | undefined;
@@ -8,6 +8,14 @@ export declare class MongoSchemaGenerator extends AbstractDialect implements Sch
8
8
  protected readonly featureDefaults: import("../../type/dialect.js").DialectFeatures;
9
9
  readonly insertIdSource: InsertIdSource;
10
10
  constructor(namingStrategy?: NamingStrategy, defaultForeignKeyAction?: ForeignKeyAction | undefined);
11
+ /**
12
+ * A document store has no cross-collection constraint, so unlike the SQL generator there is nothing to
13
+ * defer and no order to respect: this is each collection and nothing more. `foreignKeys` is accepted
14
+ * and ignored for the same reason.
15
+ */
16
+ generateCreateSchema(entities: readonly Type<unknown>[], options?: CreateSchemaOptions): string[];
17
+ generateDropSchema(entities: readonly Type<unknown>[]): string[];
18
+ private selected;
11
19
  generateCreateTable<E>(entity: Type<E>, _options?: {
12
20
  ifNotExists?: boolean;
13
21
  }): string[];
@@ -13,6 +13,24 @@ export class MongoSchemaGenerator extends AbstractDialect {
13
13
  super({ namingStrategy });
14
14
  this.defaultForeignKeyAction = defaultForeignKeyAction;
15
15
  }
16
+ /**
17
+ * A document store has no cross-collection constraint, so unlike the SQL generator there is nothing to
18
+ * defer and no order to respect: this is each collection and nothing more. `foreignKeys` is accepted
19
+ * and ignored for the same reason.
20
+ */
21
+ generateCreateSchema(entities, options) {
22
+ return this.selected(entities, options?.only).flatMap((entity) => this.generateCreateTable(entity, options));
23
+ }
24
+ generateDropSchema(entities) {
25
+ return this.selected(entities).map((entity) => this.generateDropTable(this.resolveTableName(entity, getMeta(entity))));
26
+ }
27
+ selected(entities, only) {
28
+ if (!only) {
29
+ return entities;
30
+ }
31
+ const wanted = new Set(only);
32
+ return entities.filter((entity) => wanted.has(this.resolveTableName(entity, getMeta(entity))));
33
+ }
16
34
  generateCreateTable(entity, _options) {
17
35
  const meta = getMeta(entity);
18
36
  const collectionName = this.resolveTableName(entity, meta);
@@ -104,6 +104,15 @@ export declare class Migrator {
104
104
  safe?: boolean;
105
105
  drop?: boolean;
106
106
  }): Promise<string[]>;
107
+ /**
108
+ * New tables are emitted together, never one at a time: a single-entity AST has no other table for a
109
+ * relation to resolve against, so every cross-entity foreign key was dropped and generated schemas
110
+ * carried none. Spanning the graph is also what lets a cyclic relation (any `createdBy`
111
+ * back-reference) be created at all.
112
+ *
113
+ * Empty in, empty out, so a diff with no new tables does not build an AST for the whole graph.
114
+ */
115
+ private createSchema;
107
116
  /**
108
117
  * Each pending diff with the entity it came from, since resolving that is async and every caller
109
118
  * needs it. What to emit stays with the caller: a sync narrows the forward direction to what the
@@ -234,20 +234,22 @@ export class Migrator {
234
234
  * Generate a migration based on entity schema differences
235
235
  */
236
236
  async generateFromEntities(name) {
237
- const upStatements = [];
237
+ const creating = [];
238
+ const altering = [];
238
239
  const downStatements = [];
239
240
  for (const { diff, entity } of await this.pendingDiffs()) {
240
241
  if (diff.type === 'create') {
241
242
  if (entity) {
242
- upStatements.push(...this.generator.generateCreateTable(entity));
243
+ creating.push(diff.tableName);
243
244
  downStatements.push(this.generator.generateDropTable(diff.tableName, { ifExists: true }));
244
245
  }
245
246
  }
246
247
  else if (diff.type === 'alter') {
247
- upStatements.push(...this.generator.generateAlterTable(diff));
248
+ altering.push(...this.generator.generateAlterTable(diff));
248
249
  downStatements.push(...this.generator.generateAlterTableDown(diff));
249
250
  }
250
251
  }
252
+ const upStatements = [...this.createSchema(creating), ...altering];
251
253
  if (upStatements.length === 0) {
252
254
  this.logger.logInfo('No schema changes detected.');
253
255
  return '';
@@ -317,21 +319,18 @@ export class Migrator {
317
319
  */
318
320
  async syncForce() {
319
321
  await this.ensureSchemaGenerator();
322
+ // Both directions span the whole entity set rather than looping an entity at a time. A per-entity
323
+ // AST cannot resolve a cross-entity foreign key, so the old create loop silently produced a schema
324
+ // with no referential integrity; and the old drop loop went in reverse *declaration* order, which
325
+ // says nothing about the relation graph and is rejected as soon as the constraints are really there.
326
+ const statements = [
327
+ ...this.generator.generateDropSchema(this.entities, { ifExists: true, cascade: true }),
328
+ ...this.generator.generateCreateSchema(this.entities),
329
+ ];
320
330
  await withSqlQuerierForMigrations(this.pool, 'Migrator', (querier) => querier.transaction(async () => {
321
- // Drop all tables first (in reverse order for foreign keys)
322
- for (const entity of [...this.entities].reverse()) {
323
- const tableName = this.generator.resolveTableName(entity, getMeta(entity));
324
- const dropSql = this.generator.generateDropTable(tableName, { ifExists: true });
325
- this.logger.logSchema(`Executing: ${dropSql}`);
326
- await querier.run(dropSql);
327
- }
328
- // Create all tables
329
- for (const entity of this.entities) {
330
- const createStmts = this.generator.generateCreateTable(entity);
331
- for (const createSql of createStmts) {
332
- this.logger.logSchema(`Executing: ${createSql}`);
333
- await querier.run(createSql);
334
- }
331
+ for (const sql of statements) {
332
+ this.logger.logSchema(`Executing: ${sql}`);
333
+ await querier.run(sql);
335
334
  }
336
335
  }));
337
336
  this.logger.logSchema('Schema sync (force) completed');
@@ -353,17 +352,29 @@ export class Migrator {
353
352
  * statements rather than a summary of a second, differently-computed diff.
354
353
  */
355
354
  async planSync(options = {}) {
356
- const statements = [];
355
+ const creating = [];
356
+ const altering = [];
357
357
  for (const { diff, entity } of await this.pendingDiffs()) {
358
358
  if (diff.type === 'create') {
359
359
  if (entity)
360
- statements.push(...this.generator.generateCreateTable(entity));
360
+ creating.push(diff.tableName);
361
361
  }
362
362
  else if (diff.type === 'alter') {
363
- statements.push(...this.generator.generateAlterTable(this.filterDiff(diff, options)));
363
+ altering.push(...this.generator.generateAlterTable(this.filterDiff(diff, options)));
364
364
  }
365
365
  }
366
- return statements;
366
+ return [...this.createSchema(creating), ...altering];
367
+ }
368
+ /**
369
+ * New tables are emitted together, never one at a time: a single-entity AST has no other table for a
370
+ * relation to resolve against, so every cross-entity foreign key was dropped and generated schemas
371
+ * carried none. Spanning the graph is also what lets a cyclic relation (any `createdBy`
372
+ * back-reference) be created at all.
373
+ *
374
+ * Empty in, empty out, so a diff with no new tables does not build an AST for the whole graph.
375
+ */
376
+ createSchema(tableNames) {
377
+ return tableNames.length ? this.generator.generateCreateSchema(this.entities, { only: tableNames }) : [];
367
378
  }
368
379
  /**
369
380
  * Each pending diff with the entity it came from, since resolving that is async and every caller
@@ -1,6 +1,6 @@
1
1
  import { type AbstractDialect, AbstractSqlDialect } from '../dialect/index.js';
2
2
  import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
3
- import type { ColumnSchema, DialectFeatures, EntityMeta, FieldOptions, IndexSchema, NamingStrategy, SchemaDiff, SqlDdlGenerator, Type } from '../type/index.js';
3
+ import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, FieldOptions, IndexSchema, NamingStrategy, SchemaDiff, SqlDdlGenerator, Type } from '../type/index.js';
4
4
  import type { FullColumnDefinition, TableDefinition, TableForeignKeyDefinition } from './builder/types.js';
5
5
  /**
6
6
  * Unified SQL schema generator.
@@ -27,13 +27,24 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
27
27
  */
28
28
  protected getCanonicalType(field: FieldOptions, fieldType?: unknown): CanonicalType;
29
29
  protected canonicalTypeToSql(type: CanonicalType): string;
30
- generateCreateTable<E>(entity: Type<E>, options?: {
31
- ifNotExists?: boolean;
32
- }): string[];
33
- generateDropTable(tableName: string, options?: {
34
- ifExists?: boolean;
35
- cascade?: boolean;
36
- }): string;
30
+ /**
31
+ * Every `CREATE TABLE` for `entities`, then their foreign keys.
32
+ *
33
+ * Two phases rather than inline constraints, because a relation graph is routinely cyclic: any
34
+ * `createdBy`-style back-reference makes `A` reference `B` while `B` references `A`, and no create
35
+ * order satisfies that. TypeORM's schema builder splits for the same reason (`createNewTables()`
36
+ * then `createForeignKeys()`). SQLite is the exception and keeps them inline: it cannot `ALTER` a
37
+ * foreign key in, but it resolves targets lazily, so a forward reference is fine there.
38
+ */
39
+ generateCreateSchema(entities: readonly Type<unknown>[], options?: CreateSchemaOptions): string[];
40
+ generateDropSchema(entities: readonly Type<unknown>[], options?: DropSchemaOptions): string[];
41
+ /**
42
+ * The tables of `entities` in dependency order, optionally narrowed to `only`. The AST always spans
43
+ * every entity even when narrowed, so a relation pointing at a table outside the subset still
44
+ * resolves instead of being silently dropped.
45
+ */
46
+ private orderedTables;
47
+ generateDropTable(tableName: string, options?: DropSchemaOptions): string;
37
48
  generateAlterTable(diff: SchemaDiff): string[];
38
49
  generateAlterTableDown(diff: SchemaDiff): string[];
39
50
  generateCreateIndex(tableName: string, index: IndexSchema, options?: {
@@ -49,11 +49,54 @@ export class SqlSchemaGenerator {
49
49
  canonicalTypeToSql(type) {
50
50
  return canonicalToSql(type, this.dialect);
51
51
  }
52
- generateCreateTable(entity, options = {}) {
53
- const builder = new SchemaASTBuilder(this.dialect.namingStrategy);
54
- const ast = builder.fromEntities([entity]);
55
- const tableNode = ast.getTables()[0];
56
- return this.generateCreateTableFromNode(tableNode, options);
52
+ /**
53
+ * Every `CREATE TABLE` for `entities`, then their foreign keys.
54
+ *
55
+ * Two phases rather than inline constraints, because a relation graph is routinely cyclic: any
56
+ * `createdBy`-style back-reference makes `A` reference `B` while `B` references `A`, and no create
57
+ * order satisfies that. TypeORM's schema builder splits for the same reason (`createNewTables()`
58
+ * then `createForeignKeys()`). SQLite is the exception and keeps them inline: it cannot `ALTER` a
59
+ * foreign key in, but it resolves targets lazily, so a forward reference is fine there.
60
+ */
61
+ generateCreateSchema(entities, options = {}) {
62
+ const tables = this.orderedTables(entities, 'create', options.only);
63
+ const withForeignKeys = options.foreignKeys ?? true;
64
+ // Inline only where a constraint cannot be added afterwards, which is what makes the cyclic case
65
+ // work everywhere else.
66
+ const inline = withForeignKeys && !this.features.foreignKeyAlter;
67
+ const statements = tables.flatMap((table) => this.generateCreateTableFromNode(inline ? table : { ...table, outgoingRelations: [] }, options));
68
+ if (withForeignKeys && !inline) {
69
+ for (const table of tables) {
70
+ for (const rel of table.outgoingRelations) {
71
+ statements.push(this.generateAddForeignKeySql(table.name, {
72
+ name: rel.name,
73
+ columns: rel.from.columns.map((c) => c.name),
74
+ referencesTable: rel.to.table.name,
75
+ referencesColumns: rel.to.columns.map((c) => c.name),
76
+ onDelete: rel.onDelete ?? this.defaultForeignKeyAction,
77
+ onUpdate: rel.onUpdate ?? this.defaultForeignKeyAction,
78
+ }));
79
+ }
80
+ }
81
+ }
82
+ return statements;
83
+ }
84
+ generateDropSchema(entities, options = {}) {
85
+ return this.orderedTables(entities, 'drop').map((table) => this.generateDropTable(table.name, options));
86
+ }
87
+ /**
88
+ * The tables of `entities` in dependency order, optionally narrowed to `only`. The AST always spans
89
+ * every entity even when narrowed, so a relation pointing at a table outside the subset still
90
+ * resolves instead of being silently dropped.
91
+ */
92
+ orderedTables(entities, direction, only) {
93
+ const ast = new SchemaASTBuilder(this.dialect.namingStrategy).fromEntities(entities);
94
+ const tables = direction === 'create' ? ast.getCreateOrder() : ast.getDropOrder();
95
+ if (!only) {
96
+ return tables;
97
+ }
98
+ const wanted = new Set(only);
99
+ return tables.filter((table) => wanted.has(table.name));
57
100
  }
58
101
  generateDropTable(tableName, options = {}) {
59
102
  const ifExists = options.ifExists ? 'IF EXISTS ' : '';
@@ -231,10 +231,12 @@ export class AbstractSqlQuerier extends AbstractQuerier {
231
231
  return 0;
232
232
  }
233
233
  const ids = founds.map((it) => it[meta.id]);
234
+ // Children first: they hold the foreign key, so deleting the parent ahead of them is rejected
235
+ // outright by any schema that declares the constraint without `ON DELETE CASCADE`.
236
+ await this.deleteRelations(entity, ids, opts);
234
237
  const deleteCtx = this.dialect.createContext();
235
238
  this.dialect.delete(deleteCtx, entity, { $where: ids }, opts);
236
239
  const { changes = 0 } = await this.run(deleteCtx.sql, deleteCtx.values);
237
- await this.deleteRelations(entity, ids, opts);
238
240
  return changes;
239
241
  }
240
242
  get hasOpenTransaction() {
@@ -41,7 +41,7 @@ export declare class SchemaASTBuilder {
41
41
  /**
42
42
  * Build AST from entity classes (decorated with @Entity, @Field, etc.)
43
43
  */
44
- fromEntities(entities: Type<unknown>[], options?: BuildFromEntitiesOptions): SchemaAST;
44
+ fromEntities(entities: readonly Type<unknown>[], options?: BuildFromEntitiesOptions): SchemaAST;
45
45
  /**
46
46
  * Resolve the canonical type for a field, inheriting from the referenced
47
47
  * entity's primary key when the field is a foreign-key reference
@@ -181,8 +181,13 @@ export class SchemaASTBuilder {
181
181
  type: relation.cardinality === 'm1' ? 'ManyToOne' : 'OneToOne',
182
182
  from: { table, columns: [localColumn] },
183
183
  to: { table: relatedTable, columns: [foreignColumn] },
184
- onDelete: options.defaultForeignKeyAction ?? this.defaultForeignKeyAction,
185
- onUpdate: options.defaultForeignKeyAction ?? this.defaultForeignKeyAction,
184
+ // Falls back to the FK column's own `onDelete`, which is what makes a bare `@Field({
185
+ // references, onDelete })` work with no relation declared at all.
186
+ onDelete: relation.onDelete ??
187
+ localField.onDelete ??
188
+ options.defaultForeignKeyAction ??
189
+ this.defaultForeignKeyAction,
190
+ onUpdate: relation.onUpdate ?? options.defaultForeignKeyAction ?? this.defaultForeignKeyAction,
186
191
  confidence: 1.0,
187
192
  inferredFrom: 'entity_decorator',
188
193
  };
@@ -24,6 +24,7 @@ export class NodeSqliteQuerierPool extends AbstractLocalSqliteQuerierPool {
24
24
  ...(extensions?.length ? { allowExtension: true } : undefined),
25
25
  });
26
26
  nodeDb.exec('PRAGMA journal_mode = WAL');
27
+ nodeDb.exec('PRAGMA foreign_keys = ON');
27
28
  return adaptNodeSqlite(nodeDb);
28
29
  }
29
30
  }
@@ -11,5 +11,10 @@ export type Sqlite3PoolOptions = Options & LocalSqlitePoolOptions;
11
11
  export declare class Sqlite3QuerierPool extends AbstractLocalSqliteQuerierPool<Sqlite3PoolOptions> {
12
12
  readonly filename: string | Buffer;
13
13
  constructor(filename?: string | Buffer, opts?: Sqlite3PoolOptions, extra?: ExtraOptions);
14
+ /**
15
+ * SQLite ships with foreign keys unenforced, per connection, for backward compatibility. UQL emits the
16
+ * constraints in its DDL, so leaving them off means a declared `onDelete: 'CASCADE'` silently does
17
+ * nothing and a dangling reference is accepted. Enabled here on every driver, as TypeORM also does.
18
+ */
14
19
  protected createDb(): Promise<SqliteDatabase>;
15
20
  }
@@ -9,6 +9,11 @@ export class Sqlite3QuerierPool extends AbstractLocalSqliteQuerierPool {
9
9
  super(opts, extra);
10
10
  this.filename = filename;
11
11
  }
12
+ /**
13
+ * SQLite ships with foreign keys unenforced, per connection, for backward compatibility. UQL emits the
14
+ * constraints in its DDL, so leaving them off means a declared `onDelete: 'CASCADE'` silently does
15
+ * nothing and a dangling reference is accepted. Enabled here on every driver, as TypeORM also does.
16
+ */
12
17
  async createDb() {
13
18
  // `bun:sqlite` rejects option keys it does not know, and rejects an options object carrying no
14
19
  // open flags, so `extensions` is stripped out and what remains of it collapses back to nothing.
@@ -19,11 +24,13 @@ export class Sqlite3QuerierPool extends AbstractLocalSqliteQuerierPool {
19
24
  const { adaptBunSqlite } = await import('./bunSqliteAdapter.bun.js');
20
25
  const bunDb = new BunDatabase(this.filename, opts);
21
26
  bunDb.run('PRAGMA journal_mode = WAL');
27
+ bunDb.run('PRAGMA foreign_keys = ON');
22
28
  return adaptBunSqlite(bunDb);
23
29
  }
24
30
  const { default: BetterSqlite3 } = await import('better-sqlite3');
25
31
  const db = new BetterSqlite3(this.filename, opts);
26
32
  db.pragma('journal_mode = WAL');
33
+ db.pragma('foreign_keys = ON');
27
34
  return db;
28
35
  }
29
36
  }
@@ -30,6 +30,7 @@ export class TursoLocalQuerierPool extends AbstractSqlQuerierPool {
30
30
  // Annotated rather than cast, so the structural contract is checked against the real driver.
31
31
  const db = await connect(this.filename, this.opts);
32
32
  await db.pragma('journal_mode = WAL');
33
+ await db.pragma('foreign_keys = ON');
33
34
  return db;
34
35
  }
35
36
  async end() {
@@ -1,4 +1,4 @@
1
- import type { IndexType } from '../schema/types.js';
1
+ import type { ForeignKeyAction, IndexType } from '../schema/types.js';
2
2
  import type { FilterOptions } from './query.js';
3
3
  import type { QueryRaw } from './queryRaw.js';
4
4
  import type { DistributiveOmit, Json, Scalar, Type, Unpacked } from './utility.js';
@@ -237,6 +237,13 @@ export type FieldOptions<V = TsTypeOf<FieldType>> = {
237
237
  * Entity that this field references (for foreign keys).
238
238
  */
239
239
  readonly references?: EntityGetter;
240
+ /**
241
+ * Referential action for the generated foreign key. Delete side only: `onUpdate` below already means
242
+ * a value callback. Reach for `@ManyToOne({ onDelete, onUpdate })` when the update side matters too, or
243
+ * when this disagrees with a relation also declared on the same column (the relation wins).
244
+ * @example `@Field({ references: () => Company, onDelete: 'CASCADE' }) companyId?: string;`
245
+ */
246
+ readonly onDelete?: ForeignKeyAction;
240
247
  readonly virtual?: QueryRaw;
241
248
  readonly updatable?: boolean;
242
249
  readonly eager?: boolean;
@@ -362,6 +369,15 @@ export type RelationOptions<E = any> = {
362
369
  entity: EntityGetter<E>;
363
370
  cardinality: RelationCardinality;
364
371
  readonly cascade?: boolean | CascadeType;
372
+ /**
373
+ * Referential actions for the generated foreign key, letting the database cascade instead of the ORM's
374
+ * `cascade` (pick one; declaring both leaves the FK nothing to do). Read from the owning side
375
+ * (`@ManyToOne`, or a `@OneToOne` without `mappedBy`). `onDelete` falls back to the FK field's own
376
+ * `@Field({ onDelete })` when unset here; `onUpdate` has no such fallback since that key already means
377
+ * a value callback on `FieldOptions`.
378
+ */
379
+ readonly onDelete?: ForeignKeyAction;
380
+ readonly onUpdate?: ForeignKeyAction;
365
381
  mappedBy?: RelationMappedBy<E>;
366
382
  /**
367
383
  * The pivot entity of a many-to-many. Unconstrained by `E`: a pivot holds foreign keys to both
@@ -392,7 +408,7 @@ type RelationOwnerJoin<E> = Required<Pick<RelationOptions<E>, 'through'>> | Requ
392
408
  * without one of the three, resolution has no columns to join on and throws.
393
409
  */
394
410
  type RelationJoin<E> = RelationOwnerJoin<E> | Required<Pick<RelationOptions<E>, 'mappedBy'>>;
395
- type RelationOptionsOwner<E> = Pick<RelationOptions<E>, 'entity' | 'references' | 'cascade'>;
411
+ type RelationOptionsOwner<E> = Pick<RelationOptions<E>, 'entity' | 'references' | 'cascade' | 'onDelete' | 'onUpdate'>;
396
412
  type RelationOptionsInverseSide<E> = Pick<RelationOptions<E>, 'entity' | 'cascade'> & Required<Pick<RelationOptions<E>, 'mappedBy'>>;
397
413
  type RelationOptionsThroughOwner<E> = Pick<RelationOptions<E>, 'entity' | 'cascade'> & RelationOwnerJoin<E>;
398
414
  /**
@@ -174,22 +174,43 @@ export interface SchemaDiff {
174
174
  readonly foreignKeysToAdd?: ForeignKeySchema[];
175
175
  readonly foreignKeysToDrop?: string[];
176
176
  }
177
+ export interface CreateSchemaOptions {
178
+ readonly ifNotExists?: boolean;
179
+ /**
180
+ * Restrict which tables are created, for an incremental migration adding one table to a schema that
181
+ * already exists. Constraints still resolve against the full entity graph.
182
+ */
183
+ readonly only?: readonly string[];
184
+ /**
185
+ * Emit the tables without their foreign keys. Only the integration fixtures want this, and only until
186
+ * their data stops relying on dangling references; a migration always wants the constraints.
187
+ */
188
+ readonly foreignKeys?: boolean;
189
+ }
190
+ export interface DropSchemaOptions {
191
+ readonly ifExists?: boolean;
192
+ readonly cascade?: boolean;
193
+ }
177
194
  /**
178
195
  * Interface for generating DDL statements from entity metadata
179
196
  */
180
197
  export interface SchemaGenerator {
181
198
  /**
182
- * DDL to create an entity’s table: one element per `querier.run` (optional `CREATE EXTENSION`, `CREATE TABLE`, each `CREATE INDEX`, …).
183
- * Join with `'\n'` if you need a single script blob.
199
+ * The whole schema for `entities`: every table, then the foreign keys between them.
200
+ *
201
+ * There is deliberately no per-entity counterpart. One entity means an AST holding one table, so every
202
+ * cross-entity foreign key has nothing to resolve against and is dropped: all three call sites that
203
+ * used to work that way emitted schemas with no referential integrity.
184
204
  */
185
- generateCreateTable<E>(entity: Type<E>, options?: {
186
- ifNotExists?: boolean;
187
- }): string[];
205
+ generateCreateSchema(entities: readonly Type<unknown>[], options?: CreateSchemaOptions): string[];
206
+ /**
207
+ * Every `DROP TABLE` for `entities`, dependents first. The inverse of {@link generateCreateSchema},
208
+ * and the reason it takes the whole set: dropping in any order that ignores the relation graph is
209
+ * rejected once the foreign keys are really there.
210
+ */
211
+ generateDropSchema(entities: readonly Type<unknown>[], options?: DropSchemaOptions): string[];
188
212
  /** Generate DROP TABLE statement. */
189
- generateDropTable(tableName: string, options?: {
190
- ifExists?: boolean;
191
- cascade?: boolean;
192
- }): string;
213
+ generateDropTable(tableName: string, options?: DropSchemaOptions): string;
193
214
  /**
194
215
  * Generate ALTER TABLE statements based on schema diff
195
216
  */
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "Extremely fast, type-safe TypeScript ORM - one API for every database",
5
5
  "license": "MIT",
6
- "version": "0.24.7",
6
+ "version": "0.25.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -58,7 +58,7 @@
58
58
  ],
59
59
  "scripts": {
60
60
  "prepack": "bun run verify-dist.ts && cp ../../README.md .",
61
- "postpack": "rm README.md",
61
+ "postpack": "rm README.md && npm pkg delete gitHead",
62
62
  "compile.browser": "bun build src/browser/index.ts --minify --sourcemap=linked --format=esm --target=browser --outdir=dist/browser --entry-naming 'uql-browser.min.[ext]'",
63
63
  "build": "bun run clean && tsc -b tsconfig.build.json && bun run compile.browser && bun run verify-dist.ts",
64
64
  "start": "tsc --watch",
@@ -141,8 +141,8 @@
141
141
  "mariadb": "^3.5.3",
142
142
  "mongodb": "^7.5.0",
143
143
  "mysql2": "^3.23.2",
144
- "pg": "^8.22.0",
145
- "pg-query-stream": "^4.16.0",
144
+ "pg": "^8.23.0",
145
+ "pg-query-stream": "^4.17.0",
146
146
  "rxjs": "^7.8.2",
147
147
  "sqlite-vec": "^0.1.9",
148
148
  "ws": "^8.21.3"
@@ -197,6 +197,5 @@
197
197
  ],
198
198
  "publishConfig": {
199
199
  "access": "public"
200
- },
201
- "gitHead": "26ffc7e2968761bc95a0bb1ffe0aeb37600ba1f1"
200
+ }
202
201
  }
package/LICENSE.md DELETED
@@ -1,22 +0,0 @@
1
- Copyright (c) 2015-present UQL Contributors
2
-
3
- MIT License
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining
6
- a copy of this software and associated documentation files (the
7
- "Software"), to deal in the Software without restriction, including
8
- without limitation the rights to use, copy, modify, merge, publish,
9
- distribute, sublicense, and/or sell copies of the Software, and to
10
- permit persons to whom the Software is furnished to do so, subject to
11
- the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be
14
- included in all copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.