@zerotal/orm 1.0.3 → 1.1.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 +24 -0
- package/package.json +3 -3
- package/src/commands/MakeModelCommand.ts +12 -2
- package/src/errors/MigrationError.ts +24 -3
- package/src/model/BaseModel.ts +46 -11
- package/src/model/ModelQueryBuilder.ts +17 -2
- package/src/model/decorators/column.ts +57 -8
- package/src/model/payload.ts +30 -0
- package/src/schema/Blueprint.ts +13 -0
- package/src/schema/MigrationCodegen.ts +39 -0
- package/src/schema/ModelInspector.ts +7 -1
- package/src/schema/Schema.ts +13 -0
- package/src/schema/autoMigrate.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.1.0] — 2026-08-08
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **A `json` column returns the type it was given.** Writing skipped `JSON.stringify` for values that were already strings, so a string went into the column as bare characters — `62812345678`, not `"62812345678"` — and the read side's `JSON.parse` turned it back into a number. A `json`-cast setting holding an account number came back as a number, and only for *some* values, since a string that fails to parse fell through unchanged. Encoding is now symmetric in both directions, and `where()` against a `json` column encodes the same way, so a query finds what a write stores. **Upgrade note:** rows written by an older version hold bare scalars, so a string column may still read back as a number, and a `where()` on a string will not match those older rows — they are stored unquoted. Only affects bare scalars in `json`/`array` columns; objects and arrays were always encoded and are untouched.
|
|
16
|
+
- **`bun zt make:model` generates a file that parses.** The stub emitted `@table('posts').withTimestamps()`, which is not valid decorator syntax — the grammar allows a call at the end of the chain, not in the middle — so every generated model failed with `Expected "class" but found "."`. The stub now emits plain `@table('posts')`; timestamps are on by default and the chained form needs outer parentheses, `@(table("x").withoutTimestamps())`. The same broken form is corrected in the `BaseModel` docblocks, and every generated stub is now parsed by a test rather than checked for substrings.
|
|
17
|
+
- **A column `default` is applied on insert.** A declared field that was never assigned was written as an explicit `NULL`, so the INSERT named the column, the database never applied its own default, and a `NOT NULL` column failed outright — on a model and migration that both declared `default: 0`. `undefined` now means "I didn't say": the declared default is used, or the column is omitted so the database decides. An explicit `null` still stores `NULL`.
|
|
18
|
+
- **A `Date` compared against a timestamp column matches again.** Bound values are serialised through the column's cast metadata, but the framework-managed `created_at` / `updated_at` / `deleted_at` carry no `@column` registration — so a `Date` was bound raw and matched nothing. `where("created_at", ">=", monthStart)` is the commonest reporting query there is, and it silently returned zero rows: a dashboard reading "0 this month" looks like a quiet month, not a broken query.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `Schema.alter(...)` as an alias of `Schema.table(...)`, and `table.datetime(...)` as an alias of `table.dateTime(...)`. Both are the names other frameworks use, neither was a type error because the blueprint callback is loosely typed, and both therefore failed as a `TypeError` mid-migration — after earlier statements had already run, leaving the schema half-changed.
|
|
23
|
+
- `@column("string", { nullable: true })` — a two-argument form. The shorthand keeps its type and cast; the options cannot contradict them.
|
|
24
|
+
- `@column({ unique: true })` and `@column({ index: true })`, carried through to both `migrate:generate` and `synchronize`. Uniqueness is usually a correctness property, and it was not expressible at all.
|
|
25
|
+
- Generated migrations index any `*_id` column. The reference cannot always be inferred, but the index can, and an unindexed foreign key is a table scan on every join.
|
|
26
|
+
- `"text"` is its own storage type rather than an alias for `"string"`, so a real `TEXT` column is expressible — the distinction matters on Postgres and MySQL.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **`create()` narrows its payload to the mass-assignable columns** when a model declares `fillable` as a literal tuple (`as const`). A required column deliberately kept out of `fillable` was demanded by `InsertPayload` and refused by `fill()` at runtime: the type required exactly what the runtime forbade, and there was no spelling of `create()` that satisfied both. Models without a literal list are unaffected.
|
|
31
|
+
- `static fillable` / `static guarded` accept `readonly string[]`.
|
|
32
|
+
- A migration that fails with "already exists" now says that `database.synchronize` is the usual cause, since the raw driver error names nothing actionable.
|
|
33
|
+
- `make:model` generates `fillable` as a literal tuple and documents that a nullable column is declared `?: T | undefined` — under the scaffold's `exactOptionalPropertyTypes`, `?: T` cannot be assigned `undefined`, so the field could never be cleared.
|
|
34
|
+
|
|
11
35
|
## [1.0.3] — 2026-08-07
|
|
12
36
|
|
|
13
37
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.0
|
|
34
|
-
"@zerotal/validator": "1.0
|
|
33
|
+
"@zerotal/core": "1.1.0",
|
|
34
|
+
"@zerotal/validator": "1.1.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0"
|
|
@@ -7,13 +7,23 @@ import { createMigrationFile } from "./MakeMigrationCommand.ts";
|
|
|
7
7
|
export function modelStub(name: string, tableName: string): string {
|
|
8
8
|
return `import { BaseModel, column, table } from '@zerotal/orm';
|
|
9
9
|
|
|
10
|
-
@table('${tableName}')
|
|
10
|
+
@table('${tableName}')
|
|
11
11
|
export class ${name} extends BaseModel {
|
|
12
12
|
// Models guard every attribute by default. List the columns that may be
|
|
13
13
|
// mass-assigned from user input via create() / fill().
|
|
14
|
-
|
|
14
|
+
//
|
|
15
|
+
// \`as const\` is what lets create() narrow its payload to exactly these columns,
|
|
16
|
+
// so a column kept out of this list is neither required by the type nor accepted
|
|
17
|
+
// at runtime — instead of being demanded by one and refused by the other.
|
|
18
|
+
static override fillable = ['name'] as const;
|
|
15
19
|
|
|
16
20
|
@column() name!: string;
|
|
21
|
+
|
|
22
|
+
// A nullable column is declared \`?: T | undefined\`, not \`?: T\`. The scaffold
|
|
23
|
+
// enables exactOptionalPropertyTypes, under which \`?: T\` means "may be absent,
|
|
24
|
+
// but never undefined" — so clearing the field, which is the whole point of a
|
|
25
|
+
// nullable column, would not typecheck.
|
|
26
|
+
// @column({ nullable: true }) note?: string | undefined;
|
|
17
27
|
}
|
|
18
28
|
`;
|
|
19
29
|
}
|
|
@@ -9,10 +9,31 @@ import { ZerotalError } from "@zerotal/core";
|
|
|
9
9
|
*/
|
|
10
10
|
export class MigrationError extends ZerotalError {
|
|
11
11
|
constructor(migrationName: string, cause: Error) {
|
|
12
|
-
super(
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
super(
|
|
13
|
+
`Migration '${migrationName}' failed: ${cause.message}` + _syncHint(cause),
|
|
14
|
+
"E_MIGRATION_FAILED",
|
|
15
|
+
500,
|
|
16
|
+
{ migration: migrationName },
|
|
17
|
+
);
|
|
15
18
|
// Preserve the original error so its stack and message survive re-throwing.
|
|
16
19
|
this.cause = cause;
|
|
17
20
|
}
|
|
18
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "table X already exists" on a migration that is supposed to create it almost always
|
|
25
|
+
* means boot-time schema sync got there first: `database.synchronize` built the table
|
|
26
|
+
* from the model before the migration ran. The two cannot both own a table, and the
|
|
27
|
+
* raw driver error gives no hint that a config flag is responsible — so name it here,
|
|
28
|
+
* at the only moment anyone is looking.
|
|
29
|
+
*/
|
|
30
|
+
function _syncHint(cause: Error): string {
|
|
31
|
+
if (!/already exists/i.test(cause.message)) return "";
|
|
32
|
+
return (
|
|
33
|
+
`\n\n This usually means database.synchronize created the table from your model ` +
|
|
34
|
+
`before the migration ran.\n` +
|
|
35
|
+
` A table can have one owner: set synchronize: false in config/database.ts and let ` +
|
|
36
|
+
`migrations build the schema,\n` +
|
|
37
|
+
` or delete the migration and let synchronize own it.`
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { type ManyToMany } from "./relations/RelationRegistry.ts";
|
|
|
35
35
|
import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
|
|
36
36
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
37
37
|
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
38
|
-
import type { InsertPayload, UpdatePayload } from "./payload.ts";
|
|
38
|
+
import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
|
|
39
39
|
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
40
40
|
|
|
41
41
|
let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
|
|
@@ -311,8 +311,13 @@ function applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
|
311
311
|
return value;
|
|
312
312
|
case "array":
|
|
313
313
|
case "json":
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
// Encode unconditionally. Skipping strings was meant to avoid double-encoding a
|
|
315
|
+
// value that was already JSON text, but that is indistinguishable from a string
|
|
316
|
+
// someone means to store — and guessing wrong changed the value's *type* between
|
|
317
|
+
// write and read: `"62812345678"` went in as bare characters and came back out of
|
|
318
|
+
// `JSON.parse` as a number. Encoding both ways symmetrically is the only version
|
|
319
|
+
// of this that round-trips.
|
|
320
|
+
return JSON.stringify(value);
|
|
316
321
|
case "date":
|
|
317
322
|
if (value instanceof Carbon) return _serializeDate(value.toDate());
|
|
318
323
|
if (value instanceof Date) return _serializeDate(value);
|
|
@@ -366,7 +371,9 @@ function _serializeForWrite(
|
|
|
366
371
|
serializedVal = applyCastSet(val, castOpt);
|
|
367
372
|
} else if (colType === "boolean" && val !== null && val !== undefined) {
|
|
368
373
|
serializedVal = val ? 1 : 0;
|
|
369
|
-
} else if (colType === "json" && val !== null
|
|
374
|
+
} else if (colType === "json" && val !== null) {
|
|
375
|
+
// See applyCastSet: strings are encoded too, so the column always holds valid JSON
|
|
376
|
+
// and a read returns the type that was written.
|
|
370
377
|
serializedVal = JSON.stringify(val);
|
|
371
378
|
} else {
|
|
372
379
|
serializedVal = val;
|
|
@@ -460,7 +467,7 @@ export interface ScopeApplicator {
|
|
|
460
467
|
* — TypeScript will catch typos and non-existent column references.
|
|
461
468
|
*
|
|
462
469
|
* @example
|
|
463
|
-
* \@table("users")
|
|
470
|
+
* \@table("users")
|
|
464
471
|
* export class User extends BaseModel {
|
|
465
472
|
* static fillable: Columns<User>[] = ["name", "email", "role"];
|
|
466
473
|
* static hidden: Columns<User>[] = ["password"];
|
|
@@ -516,7 +523,7 @@ export type Columns<T> = {
|
|
|
516
523
|
* @example
|
|
517
524
|
* Defining a model with `@column`:
|
|
518
525
|
* ```ts
|
|
519
|
-
* @table("users")
|
|
526
|
+
* @table("users")
|
|
520
527
|
* export class User extends BaseModel {
|
|
521
528
|
* static fillable: Columns<User>[] = ["name", "email", "password"];
|
|
522
529
|
* static hidden: Columns<User>[] = ["password"];
|
|
@@ -660,7 +667,7 @@ export class BaseModel {
|
|
|
660
667
|
*
|
|
661
668
|
* @category Attributes & mass assignment
|
|
662
669
|
*/
|
|
663
|
-
static fillable?: string[];
|
|
670
|
+
static fillable?: readonly string[];
|
|
664
671
|
|
|
665
672
|
/**
|
|
666
673
|
* Denylist of camelCase field names blocked from create() / fill().
|
|
@@ -669,7 +676,7 @@ export class BaseModel {
|
|
|
669
676
|
*
|
|
670
677
|
* @category Attributes & mass assignment
|
|
671
678
|
*/
|
|
672
|
-
static guarded?: string[];
|
|
679
|
+
static guarded?: readonly string[];
|
|
673
680
|
|
|
674
681
|
/**
|
|
675
682
|
* Disable mass-assignment protection for this model — every attribute passed
|
|
@@ -1198,6 +1205,11 @@ export class BaseModel {
|
|
|
1198
1205
|
* Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
|
|
1199
1206
|
* instance and {@link save} it, returning the persisted model.
|
|
1200
1207
|
*
|
|
1208
|
+
* When the model declares `static fillable` as a literal tuple (`as const`), the
|
|
1209
|
+
* payload type is narrowed to exactly those columns — so a column deliberately kept
|
|
1210
|
+
* out of `fillable` is neither required nor accepted here, instead of being demanded
|
|
1211
|
+
* by the type and rejected by {@link fill} at runtime.
|
|
1212
|
+
*
|
|
1201
1213
|
* @throws {MassAssignmentError} when `data` contains a non-fillable key.
|
|
1202
1214
|
*
|
|
1203
1215
|
* @example
|
|
@@ -1205,7 +1217,10 @@ export class BaseModel {
|
|
|
1205
1217
|
*
|
|
1206
1218
|
* @category Persistence
|
|
1207
1219
|
*/
|
|
1208
|
-
static async create<T extends BaseModel
|
|
1220
|
+
static async create<T extends BaseModel, F extends string = string>(
|
|
1221
|
+
this: ModelCtor<T> & { fillable?: readonly F[] | undefined },
|
|
1222
|
+
data: FillablePayload<T, F>,
|
|
1223
|
+
): Promise<T> {
|
|
1209
1224
|
const inst = new this();
|
|
1210
1225
|
inst.fill(data as UpdatePayload<T>);
|
|
1211
1226
|
return inst.save() as Promise<T>;
|
|
@@ -1604,9 +1619,12 @@ export class BaseModel {
|
|
|
1604
1619
|
// On INSERT: hash every hashable field that holds a non-empty string.
|
|
1605
1620
|
// On UPDATE: only hash hashable fields whose value changed since the last
|
|
1606
1621
|
// save (avoids re-hashing an already-stored bcrypt hash).
|
|
1622
|
+
// One keyed view of the instance for the whole method: the hashable pass and the
|
|
1623
|
+
// insert's default-filling both need to read and write columns by name.
|
|
1624
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1625
|
+
|
|
1607
1626
|
const hashable = ModelClass.hashable;
|
|
1608
1627
|
if (hashable && hashable.length > 0) {
|
|
1609
|
-
const self = this as unknown as Record<string, unknown>;
|
|
1610
1628
|
if (!this._exists) {
|
|
1611
1629
|
for (const key of hashable) {
|
|
1612
1630
|
const val = self[key];
|
|
@@ -1635,7 +1653,24 @@ export class BaseModel {
|
|
|
1635
1653
|
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1636
1654
|
const r: Record<string, unknown> = {};
|
|
1637
1655
|
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
1638
|
-
|
|
1656
|
+
// A declared field that was never assigned is `undefined`, and writing that
|
|
1657
|
+
// as an explicit NULL made `@column({ default: … })` inert: the INSERT named
|
|
1658
|
+
// the column, so the database never applied its own default and a NOT NULL
|
|
1659
|
+
// column failed outright. Fall back to the declared default, and if there
|
|
1660
|
+
// isn't one, omit the column entirely so the database decides.
|
|
1661
|
+
//
|
|
1662
|
+
// Only `undefined` is treated this way. An explicit `null` is a deliberate
|
|
1663
|
+
// "store NULL" and still writes one.
|
|
1664
|
+
let effective = val;
|
|
1665
|
+
if (effective === undefined) {
|
|
1666
|
+
const declared = colReg?.get(key)?.default;
|
|
1667
|
+
if (declared === undefined) continue; // omit → database default / NULL
|
|
1668
|
+
effective = typeof declared === "function" ? (declared as () => unknown)() : declared;
|
|
1669
|
+
// Keep the instance consistent with the row we are about to write, so the
|
|
1670
|
+
// value is readable straight after save() without a reload.
|
|
1671
|
+
self[key] = effective;
|
|
1672
|
+
}
|
|
1673
|
+
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
|
|
1639
1674
|
}
|
|
1640
1675
|
if (ModelClass.timestamps) {
|
|
1641
1676
|
const now = _serializeDate(new Date());
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SQLInstance } from "../db/sql-types.ts";
|
|
2
2
|
import { Carbon } from "@zerotal/core/carbon";
|
|
3
|
-
import { QueryBuilder, OPERATORS, _inlineValue } from "../db/QueryBuilder.ts";
|
|
3
|
+
import { QueryBuilder, OPERATORS, _inlineValue, dialectFor } from "../db/QueryBuilder.ts";
|
|
4
4
|
import {
|
|
5
5
|
toCamelKey as _toCamel,
|
|
6
6
|
toSnakeColumn as _toSnakeColumn,
|
|
@@ -1261,11 +1261,26 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1261
1261
|
if (colType === "boolean") {
|
|
1262
1262
|
return value ? 1 : 0;
|
|
1263
1263
|
}
|
|
1264
|
-
if (colType === "json"
|
|
1264
|
+
if (colType === "json") {
|
|
1265
|
+
// Match how writes encode: strings are stringified too, so a column holding
|
|
1266
|
+
// `"051001"` is found by `where("value", "051001")` rather than only by a caller
|
|
1267
|
+
// who knows to pre-encode the quotes themselves.
|
|
1265
1268
|
return JSON.stringify(value);
|
|
1266
1269
|
}
|
|
1267
1270
|
// Carbon instances always serialize to ISO string regardless of column metadata.
|
|
1268
1271
|
if (value instanceof Carbon) return value.toDatabase();
|
|
1272
|
+
// …and so does a plain Date. Without this, a comparison against a column the cast
|
|
1273
|
+
// lookup above can't see — most importantly the framework-managed `created_at` /
|
|
1274
|
+
// `updated_at` / `deleted_at`, which carry no `@column` metadata — bound the Date
|
|
1275
|
+
// object itself and matched nothing. `where("created_at", ">=", monthStart)` is the
|
|
1276
|
+
// commonest reporting query there is, and it silently returned zero rows: a
|
|
1277
|
+
// dashboard reading "0 this month" looks like a quiet month, not a broken query.
|
|
1278
|
+
// A Date in a comparison is unambiguous intent, so serialize it the way writes do.
|
|
1279
|
+
if (value instanceof Date) {
|
|
1280
|
+
return dialectFor(this._sql) === "mysql"
|
|
1281
|
+
? value.toISOString().replace("T", " ").slice(0, 19)
|
|
1282
|
+
: value.toISOString();
|
|
1283
|
+
}
|
|
1269
1284
|
return value;
|
|
1270
1285
|
}
|
|
1271
1286
|
|
|
@@ -19,8 +19,8 @@ export { columnRegistry };
|
|
|
19
19
|
*
|
|
20
20
|
* | Shorthand | Equivalent options |
|
|
21
21
|
* |-------------|--------------------------------------------------|
|
|
22
|
-
* | `"string"` | `{ type: "string" }`
|
|
23
|
-
* | `"text"` | `{ type: "
|
|
22
|
+
* | `"string"` | `{ type: "string" }` (VARCHAR) |
|
|
23
|
+
* | `"text"` | `{ type: "text" }` (unbounded TEXT) |
|
|
24
24
|
* | `"integer"` | `{ type: "number", cast: "integer" }` |
|
|
25
25
|
* | `"number"` | `{ type: "number" }` |
|
|
26
26
|
* | `"float"` | `{ type: "number", cast: "float" }` |
|
|
@@ -68,14 +68,32 @@ export type ColumnShorthand =
|
|
|
68
68
|
* ```
|
|
69
69
|
*/
|
|
70
70
|
export interface ColumnOptions {
|
|
71
|
-
/**
|
|
72
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Logical storage type; drives schema generation and auto-migration.
|
|
73
|
+
*
|
|
74
|
+
* `"string"` is a bounded VARCHAR; `"text"` is the unbounded TEXT type — a real
|
|
75
|
+
* distinction on Postgres and MySQL, where a long body in a VARCHAR(255) is an
|
|
76
|
+
* error rather than a slow column.
|
|
77
|
+
*
|
|
78
|
+
* @default "string"
|
|
79
|
+
*/
|
|
80
|
+
type?: "string" | "text" | "number" | "boolean" | "datetime" | "json";
|
|
73
81
|
/** Mark this column as the table's primary key. */
|
|
74
82
|
primary?: boolean;
|
|
75
83
|
/** Allow SQL `NULL` for this column. */
|
|
76
84
|
nullable?: boolean;
|
|
77
85
|
/** Default value applied when none is provided. */
|
|
78
86
|
default?: unknown;
|
|
87
|
+
/**
|
|
88
|
+
* Add a unique index on this column during schema generation / auto-migration.
|
|
89
|
+
*
|
|
90
|
+
* Declared here rather than left to a hand-written migration because uniqueness is
|
|
91
|
+
* usually a correctness property (a webhook idempotency key, an invoice number),
|
|
92
|
+
* and `migrate:generate` can only emit constraints it can see declared.
|
|
93
|
+
*/
|
|
94
|
+
unique?: boolean;
|
|
95
|
+
/** Add a plain (non-unique) index on this column. */
|
|
96
|
+
index?: boolean;
|
|
79
97
|
/**
|
|
80
98
|
* Shorthand cast types automatically serialize/deserialize the column value.
|
|
81
99
|
* Can also be a custom object with `get`/`set` functions for full control.
|
|
@@ -113,7 +131,7 @@ export interface ColumnOptions {
|
|
|
113
131
|
*/
|
|
114
132
|
const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
115
133
|
string: { type: "string" },
|
|
116
|
-
text: { type: "
|
|
134
|
+
text: { type: "text" },
|
|
117
135
|
integer: { type: "number", cast: "integer" },
|
|
118
136
|
number: { type: "number" },
|
|
119
137
|
float: { type: "number", cast: "float" },
|
|
@@ -124,7 +142,31 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
|
124
142
|
array: { type: "json", cast: "array" },
|
|
125
143
|
};
|
|
126
144
|
|
|
127
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the decorator's arguments to one options object.
|
|
147
|
+
*
|
|
148
|
+
* `extra` is the second argument of the `@column("string", { nullable: true })` form —
|
|
149
|
+
* the shorthand covers the common case and nullability is the most common modifier, so
|
|
150
|
+
* writing `@column({ type: "number", cast: "integer", nullable: true })` just to say
|
|
151
|
+
* "nullable int" is noise. The shorthand's own `type`/`cast` win: `extra` is typed as
|
|
152
|
+
* `Omit<ColumnOptions, "type">`, so it cannot contradict the type it is modifying.
|
|
153
|
+
*/
|
|
154
|
+
function resolveOptions(
|
|
155
|
+
arg?: ColumnShorthand | ColumnOptions,
|
|
156
|
+
extra?: Omit<ColumnOptions, "type">,
|
|
157
|
+
): ColumnOptions {
|
|
158
|
+
const base = _resolveBase(arg);
|
|
159
|
+
if (!extra) return base;
|
|
160
|
+
// Spread `base` last for type/cast so the shorthand keeps ownership of them, and never
|
|
161
|
+
// write an explicit `undefined` — under exactOptionalPropertyTypes that is a distinct,
|
|
162
|
+
// and invalid, value rather than an absent key.
|
|
163
|
+
const merged: ColumnOptions = { ...extra };
|
|
164
|
+
if (base.type !== undefined) merged.type = base.type;
|
|
165
|
+
if (extra.cast === undefined && base.cast !== undefined) merged.cast = base.cast;
|
|
166
|
+
return merged;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _resolveBase(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
|
|
128
170
|
if (arg === undefined) return { type: "string" };
|
|
129
171
|
if (typeof arg === "string") {
|
|
130
172
|
const a = arg as string;
|
|
@@ -243,8 +285,15 @@ type ColumnDecorator = (value: undefined, context: ClassFieldDecoratorContext) =
|
|
|
243
285
|
export function column(): ColumnDecorator;
|
|
244
286
|
export function column(type: ColumnShorthand): ColumnDecorator;
|
|
245
287
|
export function column(options: ColumnOptions): ColumnDecorator;
|
|
246
|
-
export function column(
|
|
247
|
-
|
|
288
|
+
export function column(
|
|
289
|
+
type: ColumnShorthand,
|
|
290
|
+
options: Omit<ColumnOptions, "type">,
|
|
291
|
+
): ColumnDecorator;
|
|
292
|
+
export function column(
|
|
293
|
+
arg?: ColumnShorthand | ColumnOptions,
|
|
294
|
+
extra?: Omit<ColumnOptions, "type">,
|
|
295
|
+
): ColumnDecorator {
|
|
296
|
+
const options = resolveOptions(arg, extra);
|
|
248
297
|
// The decorator BODY runs synchronously at definition time with the correct
|
|
249
298
|
// `context.name` (the only thing Bun 1.3.x compiles reliably for field decorators).
|
|
250
299
|
// We can't defer to a field initializer or addInitializer — Bun cross-wires those
|
package/src/model/payload.ts
CHANGED
|
@@ -92,6 +92,36 @@ type ColumnKeys<T> = WritableKeys<T> &
|
|
|
92
92
|
*/
|
|
93
93
|
export type InsertPayload<T> = Omit<Pick<T, ColumnKeys<T>>, AutoManagedKeys>;
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The insert payload narrowed to a model's mass-assignable columns.
|
|
97
|
+
*
|
|
98
|
+
* `create()` funnels into `fill()`, which throws {@link MassAssignmentError} for any key
|
|
99
|
+
* outside `fillable`. Without this narrowing the type demands fields the runtime forbids:
|
|
100
|
+
* a non-optional column deliberately kept out of `fillable` — a compliance flag that must
|
|
101
|
+
* never come from a request body — is *required* by `InsertPayload`, and supplying it
|
|
102
|
+
* throws. There was no way to satisfy both.
|
|
103
|
+
*
|
|
104
|
+
* `Fillable` is inferred from the model's `static fillable`. Declare it `as const` (or as
|
|
105
|
+
* a literal tuple) and the payload becomes exactly the assignable columns: the flag is no
|
|
106
|
+
* longer required, and passing it is a compile error rather than a runtime one. When
|
|
107
|
+
* `fillable` is absent or widened to `string[]`, `Fillable` is `string` and the payload is
|
|
108
|
+
* the unnarrowed {@link InsertPayload}, so existing models are unaffected.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* class Customer extends BaseModel {
|
|
112
|
+
* static fillable = ['name', 'email'] as const;
|
|
113
|
+
* @column() name!: string;
|
|
114
|
+
* @column() email!: string;
|
|
115
|
+
* @column({ type: 'boolean', cast: 'boolean', default: false }) legalHold!: boolean;
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* await Customer.create({ name: 'Ada', email: 'ada@example.com' }); // ok — legalHold not required
|
|
119
|
+
* await Customer.create({ name: 'Ada', email: 'a@b.c', legalHold: true }); // compile error
|
|
120
|
+
*/
|
|
121
|
+
export type FillablePayload<T, Fillable extends string> = string extends Fillable
|
|
122
|
+
? InsertPayload<T>
|
|
123
|
+
: Pick<InsertPayload<T>, Extract<keyof InsertPayload<T>, Fillable>>;
|
|
124
|
+
|
|
95
125
|
/**
|
|
96
126
|
* Data shape accepted when updating an existing record.
|
|
97
127
|
* Every field is optional — pass only what should change.
|
package/src/schema/Blueprint.ts
CHANGED
|
@@ -332,6 +332,19 @@ export class Blueprint {
|
|
|
332
332
|
return this._add(new ColumnBuilder(name, "TEXT"));
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Alias of {@link Blueprint.dateTime}, spelled the way the column *type* is.
|
|
337
|
+
*
|
|
338
|
+
* The type string is lowercase (`@column({ type: "datetime" })`) while the builder
|
|
339
|
+
* method is camelCase, so reaching for `table.datetime(...)` is the natural mistake —
|
|
340
|
+
* and the blueprint is loosely typed, so it surfaced as a `TypeError` mid-migration
|
|
341
|
+
* rather than a compile error.
|
|
342
|
+
* @category Column types
|
|
343
|
+
*/
|
|
344
|
+
datetime(name: string): ColumnBuilder {
|
|
345
|
+
return this.dateTime(name);
|
|
346
|
+
}
|
|
347
|
+
|
|
335
348
|
/**
|
|
336
349
|
* Timestamp column. Alias of {@link Blueprint.dateTime} — stored as `TEXT` (ISO-8601).
|
|
337
350
|
* @category Column types
|
|
@@ -6,12 +6,23 @@ import { columnDbName, type ModelColumn, type ModelSchema } from "./ModelInspect
|
|
|
6
6
|
// Maps @column({ type }) to the Blueprint method that generates the right SQL.
|
|
7
7
|
const BLUEPRINT_METHOD: Record<string, string> = {
|
|
8
8
|
string: "string",
|
|
9
|
+
text: "text",
|
|
9
10
|
number: "integer",
|
|
10
11
|
boolean: "boolean",
|
|
11
12
|
datetime: "dateTime",
|
|
12
13
|
json: "json",
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A column named `*_id` (or `*Id` before snake-casing) is a foreign key by convention,
|
|
18
|
+
* and an unindexed foreign key is a table scan on every join and every cascade check.
|
|
19
|
+
* The reference itself can't always be inferred — the target table is a guess — but the
|
|
20
|
+
* index can, and it is the half that matters for performance.
|
|
21
|
+
*/
|
|
22
|
+
function _looksLikeForeignKey(dbName: string): boolean {
|
|
23
|
+
return dbName.endsWith("_id") && dbName !== "_id";
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
// ── Code generation helpers ───────────────────────────────────────────────────
|
|
16
27
|
|
|
17
28
|
function blueprintCall(col: ModelColumn, indent: string): string {
|
|
@@ -23,6 +34,26 @@ function blueprintCall(col: ModelColumn, indent: string): string {
|
|
|
23
34
|
return line;
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The index lines for a table: everything declared via `@column({ unique | index })`,
|
|
39
|
+
* plus an inferred index on each foreign-key-shaped column that doesn't already have one.
|
|
40
|
+
*/
|
|
41
|
+
function indexLines(columns: ModelColumn[], indent: string): string[] {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
for (const col of columns) {
|
|
44
|
+
if (col.primary) continue; // the PK is already indexed by increments()
|
|
45
|
+
const dbName = columnDbName(col.name);
|
|
46
|
+
if (col.unique) {
|
|
47
|
+
lines.push(`${indent}table.unique('${dbName}');`);
|
|
48
|
+
} else if (col.index) {
|
|
49
|
+
lines.push(`${indent}table.index('${dbName}');`);
|
|
50
|
+
} else if (_looksLikeForeignKey(dbName)) {
|
|
51
|
+
lines.push(`${indent}table.index('${dbName}');`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
26
57
|
function createTableBlock(schema: ModelSchema): string {
|
|
27
58
|
const lines: string[] = [];
|
|
28
59
|
lines.push(` await Schema.create('${schema.table}', (table) => {`);
|
|
@@ -36,6 +67,8 @@ function createTableBlock(schema: ModelSchema): string {
|
|
|
36
67
|
if (schema.timestamps) lines.push(" table.timestamps();");
|
|
37
68
|
if (schema.softDeletes) lines.push(" table.softDeletes();");
|
|
38
69
|
|
|
70
|
+
lines.push(...indexLines(schema.columns, " "));
|
|
71
|
+
|
|
39
72
|
lines.push(" });");
|
|
40
73
|
return lines.join("\n");
|
|
41
74
|
}
|
|
@@ -57,6 +90,12 @@ function alterTableBlocks(newColumns: NewColumn[]): string {
|
|
|
57
90
|
const lines: string[] = [];
|
|
58
91
|
lines.push(` await Schema.table('${table}', (table) => {`);
|
|
59
92
|
for (const nc of cols) lines.push(blueprintCall(nc.column, " "));
|
|
93
|
+
lines.push(
|
|
94
|
+
...indexLines(
|
|
95
|
+
cols.map((nc) => nc.column),
|
|
96
|
+
" ",
|
|
97
|
+
),
|
|
98
|
+
);
|
|
60
99
|
lines.push(" });");
|
|
61
100
|
blocks.push(lines.join("\n"));
|
|
62
101
|
}
|
|
@@ -6,10 +6,14 @@ import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
|
|
|
6
6
|
|
|
7
7
|
export interface ModelColumn {
|
|
8
8
|
name: string;
|
|
9
|
-
type: ColumnOptions["type"]; // 'string' | 'number' | 'boolean' | 'datetime' | 'json'
|
|
9
|
+
type: ColumnOptions["type"]; // 'string' | 'text' | 'number' | 'boolean' | 'datetime' | 'json'
|
|
10
10
|
nullable: boolean;
|
|
11
11
|
primary: boolean;
|
|
12
12
|
default: unknown;
|
|
13
|
+
/** `@column({ unique: true })` — emit a unique index for this column. */
|
|
14
|
+
unique?: boolean;
|
|
15
|
+
/** `@column({ index: true })` — emit a plain index for this column. */
|
|
16
|
+
index?: boolean;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export interface ModelSchema {
|
|
@@ -59,6 +63,8 @@ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
|
|
|
59
63
|
nullable: opts.nullable ?? false,
|
|
60
64
|
primary: opts.primary ?? false,
|
|
61
65
|
default: opts.default,
|
|
66
|
+
unique: opts.unique ?? false,
|
|
67
|
+
index: opts.index ?? false,
|
|
62
68
|
});
|
|
63
69
|
}
|
|
64
70
|
return columns;
|
package/src/schema/Schema.ts
CHANGED
|
@@ -101,6 +101,19 @@ export const Schema = {
|
|
|
101
101
|
}
|
|
102
102
|
},
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Alias of {@link Schema.table}, for modifying an existing table.
|
|
106
|
+
*
|
|
107
|
+
* `alter` is the name Laravel and Knex use, so it is the first thing reached for — and
|
|
108
|
+
* because the blueprint callback is loosely typed, `Schema.alter(...)` was not a type
|
|
109
|
+
* error, only a `TypeError` at run time. A migration that fails there has already run
|
|
110
|
+
* whatever statements preceded it, leaving the schema half-changed, which is a worse
|
|
111
|
+
* outcome than one that never starts.
|
|
112
|
+
*/
|
|
113
|
+
async alter(name: string, callback: (bp: Blueprint) => void): Promise<void> {
|
|
114
|
+
await Schema.table(name, callback);
|
|
115
|
+
},
|
|
116
|
+
|
|
104
117
|
/** `DROP TABLE table_name` */
|
|
105
118
|
async drop(table: string): Promise<void> {
|
|
106
119
|
await ddl(`DROP TABLE ${table}`);
|
|
@@ -9,6 +9,7 @@ import type { ModelColumn } from "./ModelInspector.ts";
|
|
|
9
9
|
// `migrate:generate` would - but applies it directly instead of writing a migration file.
|
|
10
10
|
const BLUEPRINT_METHOD: Record<string, string> = {
|
|
11
11
|
string: "string",
|
|
12
|
+
text: "text",
|
|
12
13
|
number: "integer",
|
|
13
14
|
boolean: "boolean",
|
|
14
15
|
datetime: "dateTime",
|
|
@@ -21,15 +22,22 @@ type TableBuilder = Record<string, (name: string) => ColumnBuilder> & {
|
|
|
21
22
|
timestamps(): void;
|
|
22
23
|
softDeletes(): void;
|
|
23
24
|
dropColumn(...names: string[]): unknown;
|
|
25
|
+
unique(columns: string | string[], name?: string): unknown;
|
|
26
|
+
index(columns: string | string[], name?: string): unknown;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
function applyColumn(table: TableBuilder, col: ModelColumn): void {
|
|
27
30
|
const method = BLUEPRINT_METHOD[col.type ?? "string"] ?? "string";
|
|
28
31
|
// Models declare columns in camelCase; the ORM reads/writes snake_case — emit snake_case
|
|
29
32
|
// so synchronize produces columns the runtime can actually read (e.g. two_factor_secret).
|
|
30
|
-
const
|
|
33
|
+
const dbName = columnDbName(col.name);
|
|
34
|
+
const builder = table[method]!(dbName);
|
|
31
35
|
if (col.nullable) builder.nullable();
|
|
32
36
|
if (col.default !== undefined) builder.default(col.default);
|
|
37
|
+
// Declared constraints travel with the column, so a synced schema carries the same
|
|
38
|
+
// uniqueness guarantee the model asserts rather than only the column's storage type.
|
|
39
|
+
if (col.unique) table.unique(dbName);
|
|
40
|
+
else if (col.index) table.index(dbName);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/** Options for {@link synchronizeSchema}. */
|