@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- 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
|
+
}
|