@zerotal/orm 1.0.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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
@@ -0,0 +1,517 @@
1
+ import { getDialect } from "../db/dialects/index.ts";
2
+ import type { DialectName } from "../db/dialects/types.ts";
3
+
4
+ // ── Type-State Phantom Type ───────────────────────────────────────────────────
5
+ //
6
+ // `Locked` accumulates applied modifier traits. Once a trait is in the union
7
+ // the corresponding method(s) return `never`, giving a compile-time error for
8
+ // any illegal re-application:
9
+ //
10
+ // col.nullable().nullable() → TS error (same lock)
11
+ // col.nullable().notNullable() → TS error (shared 'nullability' lock)
12
+ // col.nullable().unique() → ✅ different trait, always fine
13
+ //
14
+ // Inside each method body we return `this as any`. TypeScript cannot reduce
15
+ // a deferred conditional in a generic body, so `as any` is the standard idiom
16
+ // used by Kysely, Zod, and Prisma for exactly this pattern.
17
+
18
+ /* eslint-disable @typescript-eslint/no-explicit-any */
19
+
20
+ // ── ColumnBuilder ─────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Fluent per-column builder returned by every column-type method on
24
+ * {@link Blueprint} (e.g. `table.string('email')`). Modifier methods mutate the
25
+ * builder and return it for chaining; the column is compiled to a SQL fragment
26
+ * only when the surrounding {@link Blueprint} calls {@link ColumnBuilder.toColumnSQL}.
27
+ *
28
+ * @typeParam Locked - A phantom string union that accumulates the "traits" already
29
+ * applied. Once a trait is present, methods sharing that trait return `never`, so
30
+ * illegal re-application (e.g. `.nullable().notNullable()`) is a compile-time error
31
+ * rather than a runtime one. The lock a method participates in is noted as
32
+ * `@locked` in its docs; distinct traits can always be combined freely.
33
+ *
34
+ * @remarks
35
+ * SQLite is the primary dialect. Modifiers that have no SQLite representation
36
+ * (`unsigned`, `comment`, `after`, `before`, `useCurrentOnUpdate`) are accepted
37
+ * and tracked but emit no SQL there. Column modifications via {@link ColumnBuilder.alter}
38
+ * are dialect-specific and unsupported on SQLite (see {@link Blueprint.toAlterSQL}).
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * table.string('email').unique().notNullable();
43
+ * table.integer('age').unsigned().check('age >= 0').default(0);
44
+ * table.uuid('id').primary();
45
+ * table.dateTime('created_at').useCurrent();
46
+ * ```
47
+ */
48
+ export class ColumnBuilder<Locked extends string = never> {
49
+ private _isNullable = false;
50
+ private _isUnique = false;
51
+ private _hasDefault = false;
52
+ private _default: unknown = undefined;
53
+ private _needsIndex = false;
54
+ private _isUnsigned = false;
55
+ private _isPrimary = false;
56
+ private _isAutoIncr = false;
57
+ private _useCurrent = false;
58
+ private _check: string | null = null;
59
+ private _generated: { expr: string; mode: "STORED" | "VIRTUAL" } | null = null;
60
+ private _isAlter = false;
61
+
62
+ constructor(
63
+ readonly name: string,
64
+ private _sqlType: string,
65
+ isPrimary = false,
66
+ isAutoIncrement = false,
67
+ ) {
68
+ this._isPrimary = isPrimary;
69
+ this._isAutoIncr = isAutoIncrement;
70
+ }
71
+
72
+ // ── Modifiers ─────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Allow NULL.
76
+ * @locked `nullability` — shared with `notNullable()`.
77
+ * @category Nullability & defaults
78
+ */
79
+ nullable(): "nullability" extends Locked ? never : ColumnBuilder<Locked | "nullability"> {
80
+ this._isNullable = true;
81
+ return this as any;
82
+ }
83
+
84
+ /**
85
+ * Enforce NOT NULL explicitly (the default for non-PK columns).
86
+ * @locked `nullability` — shared with `nullable()`.
87
+ * @category Nullability & defaults
88
+ */
89
+ notNullable(): "nullability" extends Locked ? never : ColumnBuilder<Locked | "nullability"> {
90
+ this._isNullable = false;
91
+ return this as any;
92
+ }
93
+
94
+ /**
95
+ * Add a `DEFAULT` clause. `null` serialises to `NULL`, JS booleans to `1` / `0`,
96
+ * strings are single-quoted (with quotes escaped), everything else stringified.
97
+ * @param value - The default value.
98
+ * @locked `default`
99
+ * @category Nullability & defaults
100
+ */
101
+ default(value: unknown): "default" extends Locked ? never : ColumnBuilder<Locked | "default"> {
102
+ this._default = value;
103
+ this._hasDefault = true;
104
+ this._useCurrent = false;
105
+ return this as any;
106
+ }
107
+
108
+ /**
109
+ * Alias for {@link ColumnBuilder.default} — identical behaviour, common alternative name.
110
+ * @locked `default`
111
+ * @category Nullability & defaults
112
+ */
113
+ defaultTo(value: unknown): "default" extends Locked ? never : ColumnBuilder<Locked | "default"> {
114
+ return this.default(value) as any;
115
+ }
116
+
117
+ /**
118
+ * Set the column default to `CURRENT_TIMESTAMP`.
119
+ * Useful for `created_at`-style columns without `timestamps()`.
120
+ * @locked `default`
121
+ * @category Nullability & defaults
122
+ */
123
+ useCurrent(): "default" extends Locked ? never : ColumnBuilder<Locked | "default"> {
124
+ this._useCurrent = true;
125
+ this._hasDefault = false;
126
+ return this as any;
127
+ }
128
+
129
+ /**
130
+ * On MySQL / MariaDB: automatically update the column to `CURRENT_TIMESTAMP`
131
+ * on every row change. No-op on SQLite and PostgreSQL.
132
+ * @category Nullability & defaults
133
+ */
134
+
135
+ useCurrentOnUpdate(): this {
136
+ return this;
137
+ }
138
+
139
+ /**
140
+ * Add inline `UNIQUE` to the column definition.
141
+ * @locked `unique`
142
+ * @category Constraints
143
+ */
144
+ unique(): "unique" extends Locked ? never : ColumnBuilder<Locked | "unique"> {
145
+ this._isUnique = true;
146
+ return this as any;
147
+ }
148
+
149
+ /**
150
+ * Request a standalone `CREATE INDEX` on this column after the table is created.
151
+ * Ignored when the column is also `unique()` (a unique index already covers it).
152
+ * @locked `index`
153
+ * @category Constraints
154
+ */
155
+ index(): "index" extends Locked ? never : ColumnBuilder<Locked | "index"> {
156
+ this._needsIndex = true;
157
+ return this as any;
158
+ }
159
+
160
+ /**
161
+ * Mark the column as unsigned. Tracked for documentation and multi-DB
162
+ * compatibility — SQLite has no `UNSIGNED` type so no SQL is emitted.
163
+ * @locked `unsigned`
164
+ * @category Modifiers
165
+ */
166
+ unsigned(): "unsigned" extends Locked ? never : ColumnBuilder<Locked | "unsigned"> {
167
+ this._isUnsigned = true;
168
+ return this as any;
169
+ }
170
+
171
+ /**
172
+ * Promote this column to the primary key. Use this when you need a PK
173
+ * without auto-increment (e.g. UUID PKs).
174
+ * @locked `primary`
175
+ * @category Constraints
176
+ */
177
+ primary(): "primary" extends Locked ? never : ColumnBuilder<Locked | "primary"> {
178
+ this._isPrimary = true;
179
+ return this as any;
180
+ }
181
+
182
+ /**
183
+ * Add a `CHECK (expression)` constraint.
184
+ *
185
+ * @param expression - A raw SQL boolean expression; not escaped or validated.
186
+ * @example
187
+ * ```ts
188
+ * table.integer('age').unsigned().check('age >= 0');
189
+ * table.string('status').check("status IN ('active','inactive')");
190
+ * ```
191
+ * @locked `check`
192
+ * @category Constraints
193
+ */
194
+ check(expression: string): "check" extends Locked ? never : ColumnBuilder<Locked | "check"> {
195
+ this._check = expression;
196
+ return this as any;
197
+ }
198
+
199
+ /**
200
+ * Define a generated stored column (computed and physically stored).
201
+ * Requires SQLite ≥ 3.31.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * table.string('full_name').storedAs("first_name || ' ' || last_name");
206
+ * ```
207
+ * @locked `generated`
208
+ * @category Modifiers
209
+ */
210
+ storedAs(
211
+ expression: string,
212
+ ): "generated" extends Locked ? never : ColumnBuilder<Locked | "generated"> {
213
+ this._generated = { expr: expression, mode: "STORED" };
214
+ return this as any;
215
+ }
216
+
217
+ /**
218
+ * Define a generated virtual column (computed on read, never stored).
219
+ * Requires SQLite ≥ 3.31.
220
+ * @locked `generated`
221
+ * @category Modifiers
222
+ */
223
+ virtualAs(
224
+ expression: string,
225
+ ): "generated" extends Locked ? never : ColumnBuilder<Locked | "generated"> {
226
+ this._generated = { expr: expression, mode: "VIRTUAL" };
227
+ return this as any;
228
+ }
229
+
230
+ /**
231
+ * Mark this column as a modification of an existing column rather than a
232
+ * new addition. Used inside {@link Schema.table} callbacks:
233
+ *
234
+ * ```ts
235
+ * Schema.table('users', (table) => {
236
+ * table.string('password').nullable().alter();
237
+ * });
238
+ * ```
239
+ *
240
+ * On MySQL / MariaDB emits `MODIFY COLUMN`; on PostgreSQL each attribute change
241
+ * becomes a separate `ALTER COLUMN` sub-command. On SQLite this is a no-op — a
242
+ * console warning is emitted and the statement is skipped (structural changes
243
+ * require a full table rebuild).
244
+ * @category Modifiers
245
+ */
246
+ alter(): this {
247
+ this._isAlter = true;
248
+ return this;
249
+ }
250
+
251
+ /**
252
+ * Alias of {@link ColumnBuilder.alter} — mark this as a modification of an existing column.
253
+ * @category Modifiers
254
+ */
255
+ change(): this {
256
+ return this.alter();
257
+ }
258
+
259
+ /**
260
+ * Attach a column comment. No-op on SQLite; intended to emit a `COMMENT` on
261
+ * MySQL/Postgres.
262
+ * @category Modifiers
263
+ */
264
+ comment(_text: string): this {
265
+ return this;
266
+ }
267
+
268
+ /**
269
+ * Place this column after `column` in the table (MySQL / MariaDB only).
270
+ * Accepted but ignored on SQLite and PostgreSQL.
271
+ * @category Modifiers
272
+ */
273
+
274
+ after(_column: string): this {
275
+ return this;
276
+ }
277
+
278
+ /**
279
+ * Place this column before `column` in the table (MySQL / MariaDB only).
280
+ * No-op on SQLite / PostgreSQL.
281
+ * @category Modifiers
282
+ */
283
+
284
+ before(_column: string): this {
285
+ return this;
286
+ }
287
+
288
+ // ── Internal ──────────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Whether a standalone `CREATE INDEX` should be emitted for this column
292
+ * (`index()` was called and the column is not `unique()`).
293
+ * @internal
294
+ */
295
+ get wantsIndex(): boolean {
296
+ return this._needsIndex && !this._isUnique;
297
+ }
298
+
299
+ /**
300
+ * Whether this column is the table primary key (set via constructor or `primary()`).
301
+ * @internal
302
+ */
303
+ get isPrimary(): boolean {
304
+ return this._isPrimary;
305
+ }
306
+
307
+ /**
308
+ * True when this column should modify an existing column rather than add a new one.
309
+ * @internal
310
+ */
311
+ get isAlter(): boolean {
312
+ return this._isAlter;
313
+ }
314
+
315
+ /**
316
+ * Compile this column into its SQL fragment, e.g.
317
+ * `email TEXT NOT NULL DEFAULT 'x' UNIQUE`. Consumed by {@link Blueprint}.
318
+ * @internal
319
+ */
320
+ toColumnSQL(dialect: DialectName = "sqlite"): string {
321
+ // Auto-increment is the one column shape no two engines spell alike, so the whole
322
+ // fragment comes from the dialect rather than being assembled here. Emitting SQLite's
323
+ // `INTEGER PRIMARY KEY AUTOINCREMENT` unconditionally made the first migrate against
324
+ // PostgreSQL a syntax error and against MySQL a 1064.
325
+ if (this._isAutoIncr) return getDialect(dialect).autoIncrementColumn(this.name);
326
+
327
+ const parts: string[] = [`${this.name} ${this._sqlType}`];
328
+
329
+ if (this._isPrimary) parts.push("PRIMARY KEY");
330
+ if (!this._isNullable && !this._isPrimary) parts.push("NOT NULL");
331
+
332
+ if (this._useCurrent) parts.push("DEFAULT CURRENT_TIMESTAMP");
333
+ else if (this._hasDefault) parts.push(`DEFAULT ${this._serializeDefault()}`);
334
+
335
+ if (this._isUnique) parts.push("UNIQUE");
336
+ if (this._check) parts.push(`CHECK (${this._check})`);
337
+ if (this._generated)
338
+ parts.push(`GENERATED ALWAYS AS (${this._generated.expr}) ${this._generated.mode}`);
339
+
340
+ return parts.join(" ");
341
+ }
342
+
343
+ private _serializeDefault(): string {
344
+ const v = this._default;
345
+ if (v === null) return "NULL";
346
+ if (typeof v === "boolean") return v ? "1" : "0";
347
+ if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
348
+ return String(v);
349
+ }
350
+ }
351
+
352
+ // ── ForeignIdColumnBuilder ────────────────────────────────────────────────────
353
+
354
+ /**
355
+ * Returned by `foreignId()` and `foreignUuid()`. Extends `ColumnBuilder` with
356
+ * a `.constrained()` helper that wires the foreign-key constraint, inferring
357
+ * the referenced table from the column name.
358
+ *
359
+ * @example
360
+ * table.foreignId('user_id').constrained(); // → users.id
361
+ * table.foreignId('author_id').constrained('users'); // → users.id
362
+ * table.foreignId('author_id').constrained('users', 'uuid'); // → users.uuid
363
+ * table.foreignId('user_id').nullable().constrained().nullOnDelete();
364
+ */
365
+ export class ForeignIdColumnBuilder extends ColumnBuilder {
366
+ constructor(
367
+ name: string,
368
+ sqlType: string,
369
+ private readonly _addFk: (col: string) => ForeignKeyBuilder,
370
+ ) {
371
+ super(name, sqlType);
372
+ }
373
+
374
+ /**
375
+ * Add a `FOREIGN KEY` constraint for this column. The referenced table is
376
+ * inferred from the column name (`user_id` → `users`) unless supplied
377
+ * explicitly; note the inference is naive — it strips a trailing `_id` and
378
+ * appends `s`, so irregular plurals (`category_id` → `categorys`) need an
379
+ * explicit table argument.
380
+ *
381
+ * @param table - Referenced table; defaults to the name inferred from the column.
382
+ * @param column - Referenced column; defaults to `"id"`.
383
+ * @returns The {@link ForeignKeyBuilder} so `.onDelete()` etc. can be chained.
384
+ * @category Foreign keys
385
+ */
386
+ constrained(table?: string, column = "id"): ForeignKeyBuilder {
387
+ const tbl = table ?? _inferTable(this.name);
388
+ return this._addFk(this.name).references(column).on(tbl);
389
+ }
390
+ }
391
+
392
+ function _inferTable(column: string): string {
393
+ // 'user_id' → 'user' → 'users', 'category_id' → 'categories' (naive +s)
394
+ const base = column.replace(/_id$/, "");
395
+ return base.endsWith("s") ? base : base + "s";
396
+ }
397
+
398
+ // ── ForeignKeyBuilder ─────────────────────────────────────────────────────────
399
+
400
+ /** Referential action for a foreign key's `ON DELETE` / `ON UPDATE` clause. */
401
+ export type FKAction = "CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION";
402
+
403
+ /**
404
+ * Builds a table-level `FOREIGN KEY` constraint. Obtained from
405
+ * {@link Blueprint.foreign}, or indirectly via
406
+ * {@link ForeignIdColumnBuilder.constrained}. Configure the referenced
407
+ * table/column and referential actions by chaining, then the constraint is
408
+ * compiled by {@link ForeignKeyBuilder.toConstraintSQL} when the table is built.
409
+ *
410
+ * @example
411
+ * ```ts
412
+ * table.foreign('user_id').references('id').on('users').onDelete('CASCADE');
413
+ * table.foreign('user_id').references('users.id').nullOnDelete();
414
+ * ```
415
+ */
416
+ export class ForeignKeyBuilder {
417
+ private _refTable = "";
418
+ private _refColumn = "id";
419
+ private _onDelete?: FKAction;
420
+ private _onUpdate?: FKAction;
421
+
422
+ constructor(private readonly _column: string) {}
423
+
424
+ /**
425
+ * Set the referenced column. Accepts either a bare column name (`'id'`) or a
426
+ * `'table.column'` shorthand that also sets the referenced table:
427
+ *
428
+ * ```ts
429
+ * table.foreign('user_id').references('users.id').onDelete('CASCADE');
430
+ * table.foreign('user_id').references('id').on('users').onDelete('CASCADE');
431
+ * ```
432
+ *
433
+ * @param columnOrTableDotColumn - `"col"` or `"table.col"`.
434
+ * @category Foreign keys
435
+ */
436
+ references(columnOrTableDotColumn: string): this {
437
+ if (columnOrTableDotColumn.includes(".")) {
438
+ const [tbl, col] = columnOrTableDotColumn.split(".", 2);
439
+ this._refTable = tbl ?? "";
440
+ this._refColumn = col ?? "id";
441
+ } else {
442
+ this._refColumn = columnOrTableDotColumn;
443
+ }
444
+ return this;
445
+ }
446
+
447
+ /**
448
+ * Set the referenced table.
449
+ * @category Foreign keys
450
+ */
451
+ on(table: string): this {
452
+ this._refTable = table;
453
+ return this;
454
+ }
455
+
456
+ /**
457
+ * Set the `ON DELETE` referential action.
458
+ * @category Foreign keys
459
+ */
460
+ onDelete(action: FKAction): this {
461
+ this._onDelete = action;
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Set the `ON UPDATE` referential action.
467
+ * @category Foreign keys
468
+ */
469
+ onUpdate(action: FKAction): this {
470
+ this._onUpdate = action;
471
+ return this;
472
+ }
473
+
474
+ /**
475
+ * Shorthand for `.onDelete('CASCADE')`.
476
+ * @category Foreign keys
477
+ */
478
+ cascadeOnDelete(): this {
479
+ return this.onDelete("CASCADE");
480
+ }
481
+
482
+ /**
483
+ * Shorthand for `.onUpdate('CASCADE')`.
484
+ * @category Foreign keys
485
+ */
486
+ cascadeOnUpdate(): this {
487
+ return this.onUpdate("CASCADE");
488
+ }
489
+
490
+ /**
491
+ * Shorthand for `.onDelete('SET NULL')`.
492
+ * @category Foreign keys
493
+ */
494
+ nullOnDelete(): this {
495
+ return this.onDelete("SET NULL");
496
+ }
497
+
498
+ /**
499
+ * Shorthand for `.onDelete('RESTRICT')`.
500
+ * @category Foreign keys
501
+ */
502
+ restrictOnDelete(): this {
503
+ return this.onDelete("RESTRICT");
504
+ }
505
+
506
+ /**
507
+ * Compile this foreign key into its inline constraint SQL, e.g.
508
+ * `FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`.
509
+ * @internal
510
+ */
511
+ toConstraintSQL(): string {
512
+ let sql = `FOREIGN KEY (${this._column}) REFERENCES ${this._refTable}(${this._refColumn})`;
513
+ if (this._onDelete) sql += ` ON DELETE ${this._onDelete}`;
514
+ if (this._onUpdate) sql += ` ON UPDATE ${this._onUpdate}`;
515
+ return sql;
516
+ }
517
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Base class for all database migrations.
3
+ *
4
+ * A migration bundles a forward change ({@link Migration.up}) with the change that
5
+ * undoes it ({@link Migration.down}). {@link MigrationRunner} loads migration files,
6
+ * runs each pending `up()` inside its own transaction, and calls `down()` on
7
+ * rollback. Each migration file should export a default class extending `Migration`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { Migration, Schema } from '@zerotal/orm';
12
+ *
13
+ * export default class CreateUsersTable extends Migration {
14
+ * async up(): Promise<void> {
15
+ * await Schema.create('users', (table) => {
16
+ * table.increments('id');
17
+ * table.string('name');
18
+ * table.string('email').unique();
19
+ * table.timestamps();
20
+ * });
21
+ * }
22
+ *
23
+ * async down(): Promise<void> {
24
+ * await Schema.drop('users');
25
+ * }
26
+ * }
27
+ * ```
28
+ */
29
+ export abstract class Migration {
30
+ /** Apply the migration's forward schema change. */
31
+ abstract up(): Promise<void>;
32
+ /** Reverse everything {@link Migration.up} did, for rollback. */
33
+ abstract down(): Promise<void>;
34
+ }
@@ -0,0 +1,108 @@
1
+ import type { DiffResult, NewColumn } from "./SchemaDiffer.ts";
2
+ import { columnDbName, type ModelColumn, type ModelSchema } from "./ModelInspector.ts";
3
+
4
+ // ── Type mapping ──────────────────────────────────────────────────────────────
5
+
6
+ // Maps @column({ type }) to the Blueprint method that generates the right SQL.
7
+ const BLUEPRINT_METHOD: Record<string, string> = {
8
+ string: "string",
9
+ number: "integer",
10
+ boolean: "boolean",
11
+ datetime: "dateTime",
12
+ json: "json",
13
+ };
14
+
15
+ // ── Code generation helpers ───────────────────────────────────────────────────
16
+
17
+ function blueprintCall(col: ModelColumn, indent: string): string {
18
+ const method = BLUEPRINT_METHOD[col.type ?? "string"] ?? "string";
19
+ let line = `${indent}table.${method}('${columnDbName(col.name)}')`;
20
+ if (col.nullable) line += ".nullable()";
21
+ if (col.default !== undefined) line += `.default(${JSON.stringify(col.default)})`;
22
+ line += ";";
23
+ return line;
24
+ }
25
+
26
+ function createTableBlock(schema: ModelSchema): string {
27
+ const lines: string[] = [];
28
+ lines.push(` await Schema.create('${schema.table}', (table) => {`);
29
+ lines.push(` table.increments('${columnDbName(schema.primaryKey)}');`);
30
+
31
+ for (const col of schema.columns) {
32
+ if (col.primary) continue; // increments() already covers the PK
33
+ lines.push(blueprintCall(col, " "));
34
+ }
35
+
36
+ if (schema.timestamps) lines.push(" table.timestamps();");
37
+ if (schema.softDeletes) lines.push(" table.softDeletes();");
38
+
39
+ lines.push(" });");
40
+ return lines.join("\n");
41
+ }
42
+
43
+ function alterTableBlocks(newColumns: NewColumn[]): string {
44
+ // Group new columns by table so we emit one ALTER TABLE per table.
45
+ const byTable = new Map<string, NewColumn[]>();
46
+ for (const nc of newColumns) {
47
+ let bucket = byTable.get(nc.table);
48
+ if (!bucket) {
49
+ bucket = [];
50
+ byTable.set(nc.table, bucket);
51
+ }
52
+ bucket.push(nc);
53
+ }
54
+
55
+ const blocks: string[] = [];
56
+ for (const [table, cols] of byTable.entries()) {
57
+ const lines: string[] = [];
58
+ lines.push(` await Schema.table('${table}', (table) => {`);
59
+ for (const nc of cols) lines.push(blueprintCall(nc.column, " "));
60
+ lines.push(" });");
61
+ blocks.push(lines.join("\n"));
62
+ }
63
+ return blocks.join("\n");
64
+ }
65
+
66
+ // ── Public API ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Generate the complete `.ts` source for a migration file from a `DiffResult`.
70
+ *
71
+ * The generated file:
72
+ * - Creates new tables with `Schema.create()`
73
+ * - Adds new columns with `Schema.table()` (ALTER TABLE ADD COLUMN)
74
+ * - Drops created tables in `down()` (column additions are left for manual rollback)
75
+ */
76
+ export function generateMigrationContent(className: string, diff: DiffResult): string {
77
+ const upParts: string[] = [];
78
+
79
+ for (const { schema } of diff.newTables) {
80
+ upParts.push(createTableBlock(schema));
81
+ }
82
+
83
+ if (diff.newColumns.length > 0) {
84
+ upParts.push(alterTableBlocks(diff.newColumns));
85
+ }
86
+
87
+ const downParts: string[] = [];
88
+ // Reverse order so dependent tables are dropped before parent tables.
89
+ for (const { schema } of [...diff.newTables].reverse()) {
90
+ downParts.push(` await Schema.dropIfExists('${schema.table}');`);
91
+ }
92
+
93
+ const upBody = upParts.length ? upParts.join("\n") : " // no-op";
94
+ const downBody = downParts.length ? downParts.join("\n") : " // no-op";
95
+
96
+ return `import { Migration, Schema } from '@zerotal/orm';
97
+
98
+ export default class ${className} extends Migration {
99
+ override async up(): Promise<void> {
100
+ ${upBody}
101
+ }
102
+
103
+ override async down(): Promise<void> {
104
+ ${downBody}
105
+ }
106
+ }
107
+ `;
108
+ }