@zerotal/orm 1.0.4 → 1.3.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 +71 -0
- package/README.md +3 -3
- package/package.json +3 -3
- package/src/commands/MakeModelCommand.ts +12 -2
- package/src/errors/MigrationError.ts +24 -3
- package/src/index.ts +13 -11
- package/src/model/BaseModel.ts +82 -12
- package/src/model/ModelQueryBuilder.ts +17 -2
- package/src/model/SoftDeletes.ts +3 -3
- package/src/model/State.ts +3 -3
- package/src/model/decorators/column.ts +57 -8
- package/src/model/decorators/table.ts +2 -2
- package/src/model/mixins.ts +126 -501
- 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,77 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.3.0] — 2026-08-09
|
|
12
|
+
|
|
13
|
+
### Changed — BREAKING
|
|
14
|
+
|
|
15
|
+
- **`BaseModelWith(...)` is replaced by the `Model.using(...)` static.** Mixin composition is now
|
|
16
|
+
a property of the base class rather than a helper shipped alongside it, so there is one idiom to
|
|
17
|
+
learn and nothing extra to import.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// before
|
|
21
|
+
import { BaseModelWith } from "@zerotal/orm";
|
|
22
|
+
class User extends BaseModelWith(Authenticatable, Permissions, Roles) {}
|
|
23
|
+
|
|
24
|
+
// after
|
|
25
|
+
import { Model } from "@zerotal/orm";
|
|
26
|
+
class User extends Model.using(Authenticatable, Permissions, Roles) {}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
|
|
30
|
+
|
|
31
|
+
How mixins are **authored** is unchanged — `<T extends Constructor>(Base: T) => class extends Base`
|
|
32
|
+
still works exactly as before, and every shipped mixin (`SoftDeletes`, `State`, `Authenticatable`,
|
|
33
|
+
`Roles`, `Permissions`, `Notifiable`, `Tenantable`, `Auditable`, …) keeps its signature. The
|
|
34
|
+
`Constructor` and `Mixin` types are still exported; `Compose` (the type of `Model.using`) joins
|
|
35
|
+
them. Mixin authors declaring columns still call `registerColumn` imperatively.
|
|
36
|
+
|
|
37
|
+
`with` was deliberately **not** used for this. It is reserved for the eager-load static
|
|
38
|
+
(`User.with("posts")`), the one conspicuous gap in the model's existing query-forwarder family
|
|
39
|
+
(`where`, `whereIn`, `orderBy`, `latest`, `first`, `paginate`, `find`, `all`, `count`, …).
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`Model` is now the canonical name for the base class; `BaseModel` is the alias.** They are the
|
|
44
|
+
same class object and both remain exported, so no code breaks — but `class User extends Model {}`
|
|
45
|
+
is the documented form from here, mirroring Flow's `class PostsPage extends Component {}`.
|
|
46
|
+
`BaseModel` was previously the canonical name and `Model` an unused compat alias added in 1.0.2.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`using` composes onto any class in the chain, not just the root.** An app-level base model can
|
|
51
|
+
now carry mixins without being flattened out of the prototype chain — `AppModel.using(SoftDeletes)`
|
|
52
|
+
keeps `AppModel` and its statics in the lineage. `BaseModelWith` hardcoded `BaseModel`, so this
|
|
53
|
+
previously required hand-nesting.
|
|
54
|
+
- **Composition chains.** The composed class carries `using` itself, so
|
|
55
|
+
`Model.using(a, b).using(c, d)` works past the 8-mixin overload set — which is why the overload
|
|
56
|
+
set shrank from 20 hand-written arities to 8 without losing any capability.
|
|
57
|
+
|
|
58
|
+
## [1.1.0] — 2026-08-08
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **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.
|
|
63
|
+
- **`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.
|
|
64
|
+
- **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`.
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- `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.
|
|
70
|
+
- `@column("string", { nullable: true })` — a two-argument form. The shorthand keeps its type and cast; the options cannot contradict them.
|
|
71
|
+
- `@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.
|
|
72
|
+
- 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.
|
|
73
|
+
- `"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.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **`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.
|
|
78
|
+
- `static fillable` / `static guarded` accept `readonly string[]`.
|
|
79
|
+
- A migration that fails with "already exists" now says that `database.synchronize` is the usual cause, since the raw driver error names nothing actionable.
|
|
80
|
+
- `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.
|
|
81
|
+
|
|
11
82
|
## [1.0.3] — 2026-08-07
|
|
12
83
|
|
|
13
84
|
### Changed
|
package/README.md
CHANGED
|
@@ -43,11 +43,11 @@ export default DatabaseConfig({
|
|
|
43
43
|
### Define a model
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
|
-
import {
|
|
46
|
+
import { Model, column, table, belongsTo, hasMany } from "@zerotal/orm";
|
|
47
47
|
import type { Columns } from "@zerotal/orm";
|
|
48
48
|
|
|
49
49
|
@(table("posts").withTimestamps().withSoftDeletes())
|
|
50
|
-
export class Post extends
|
|
50
|
+
export class Post extends Model {
|
|
51
51
|
static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];
|
|
52
52
|
|
|
53
53
|
@column("string") title!: string;
|
|
@@ -144,7 +144,7 @@ This package exposes two subpath entry points:
|
|
|
144
144
|
|
|
145
145
|
Main exports from the default entry point:
|
|
146
146
|
|
|
147
|
-
- **Models** — `
|
|
147
|
+
- **Models** — `Model` (aka `BaseModel`), `ModelQueryBuilder`, `DB`, `QueryBuilder`
|
|
148
148
|
- **Decorators** — `column`, `table`, `belongsTo`, `hasMany`, `hasOne`, `manyToMany`, `morphTo`, `morphMany`, `morphOne`, `hasManyThrough`, `hasOneThrough`, `morphToMany`, `morphedByMany`
|
|
149
149
|
- **Schema / migrations** — `Schema`, `Blueprint`, `Migration`, `MigrationRunner`, `SchemaInspector`, `ModelInspector`, `SchemaDiffer`, `synchronizeSchema`
|
|
150
150
|
- **Seeding** — `Seeder`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.3.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.3.0",
|
|
34
|
+
"@zerotal/validator": "1.3.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/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A Bun-native Active Record ORM built on `Bun.sql`.
|
|
3
3
|
*
|
|
4
|
-
* Models extend {@link
|
|
4
|
+
* Models extend {@link Model}: you declare columns with {@link column | `@column`},
|
|
5
5
|
* relationships with decorators like {@link hasMany | `@hasMany`} and
|
|
6
6
|
* {@link belongsTo | `@belongsTo`}, and then query and persist through the model's
|
|
7
7
|
* static and instance methods. Under the hood a {@link QueryBuilder} routes every
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
* using the {@link Schema} facade and the {@link Blueprint} table builder.
|
|
12
12
|
*
|
|
13
13
|
* Mass assignment is **guarded by default** — a model with neither `fillable` nor
|
|
14
|
-
* `guarded` declared rejects all attributes in {@link
|
|
14
|
+
* `guarded` declared rejects all attributes in {@link Model.fill | `fill()`}.
|
|
15
15
|
* Soft deletes and state machines are opt-in mixins composed via
|
|
16
|
-
*
|
|
16
|
+
* `Model.using(...)`. The ORM's CLI commands (`migrate`, `make:model`, …) live
|
|
17
17
|
* under the `@zerotal/orm/commands` subpath.
|
|
18
18
|
*
|
|
19
19
|
* @example Define a model
|
|
20
20
|
* ```ts
|
|
21
|
-
* import {
|
|
21
|
+
* import { Model, column, hasMany, type HasMany } from "@zerotal/orm";
|
|
22
22
|
* import { Post } from "./Post.ts";
|
|
23
23
|
*
|
|
24
|
-
* export class User extends
|
|
24
|
+
* export class User extends Model {
|
|
25
25
|
* @column({ primary: true }) id!: number;
|
|
26
26
|
* @column() email!: string;
|
|
27
27
|
* @column() name!: string;
|
|
@@ -70,12 +70,14 @@
|
|
|
70
70
|
|
|
71
71
|
// @zerotal/orm — public API barrel
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
export {
|
|
75
|
-
|
|
76
|
-
//
|
|
73
|
+
// `Model` is the canonical base class; `BaseModel` is the same class under its original name.
|
|
74
|
+
export { Model, BaseModel } from "./model/BaseModel.ts";
|
|
75
|
+
// Mixin authoring types. Compose them onto a model with the `Model.using(...)` static —
|
|
76
|
+
// `class User extends Model.using(Authenticatable, Roles)`.
|
|
77
|
+
export type { Constructor, Mixin, Compose } from "./model/mixins.ts";
|
|
78
|
+
// State-machine behaviour is an opt-in mixin — compose with `Model.using(State)`.
|
|
77
79
|
export { State } from "./model/State.ts";
|
|
78
|
-
// Soft deletes are opt-in — compose with `
|
|
80
|
+
// Soft deletes are opt-in — compose with `Model.using(SoftDeletes)`.
|
|
79
81
|
export { SoftDeletes } from "./model/SoftDeletes.ts";
|
|
80
82
|
export type {
|
|
81
83
|
StateDefinition,
|
|
@@ -143,7 +145,7 @@ export { column, columnRegistry } from "./model/decorators/column.ts";
|
|
|
143
145
|
export type { ColumnOptions, ColumnShorthand } from "./model/decorators/column.ts";
|
|
144
146
|
export { registerModel, modelByName, modelsByName } from "./model/decorators/_metadata.ts";
|
|
145
147
|
// Imperative column registration — for mixin authors composing model behaviour with
|
|
146
|
-
//
|
|
148
|
+
// Model.using (the @column decorator can't run inside a returned class expression).
|
|
147
149
|
export { registerColumn, columnsFor } from "./model/decorators/_metadata.ts";
|
|
148
150
|
export { table } from "./model/decorators/table.ts";
|
|
149
151
|
export type { TableDecoratorBuilder, TableOptions } from "./model/decorators/table.ts";
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -33,9 +33,11 @@ import {
|
|
|
33
33
|
} from "../errors/index.ts";
|
|
34
34
|
import { type ManyToMany } from "./relations/RelationRegistry.ts";
|
|
35
35
|
import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
|
|
36
|
+
import { _compose } from "./mixins.ts";
|
|
37
|
+
import type { Compose } from "./mixins.ts";
|
|
36
38
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
37
39
|
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
38
|
-
import type { InsertPayload, UpdatePayload } from "./payload.ts";
|
|
40
|
+
import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
|
|
39
41
|
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
40
42
|
|
|
41
43
|
let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
|
|
@@ -311,8 +313,13 @@ function applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
|
311
313
|
return value;
|
|
312
314
|
case "array":
|
|
313
315
|
case "json":
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
// Encode unconditionally. Skipping strings was meant to avoid double-encoding a
|
|
317
|
+
// value that was already JSON text, but that is indistinguishable from a string
|
|
318
|
+
// someone means to store — and guessing wrong changed the value's *type* between
|
|
319
|
+
// write and read: `"62812345678"` went in as bare characters and came back out of
|
|
320
|
+
// `JSON.parse` as a number. Encoding both ways symmetrically is the only version
|
|
321
|
+
// of this that round-trips.
|
|
322
|
+
return JSON.stringify(value);
|
|
316
323
|
case "date":
|
|
317
324
|
if (value instanceof Carbon) return _serializeDate(value.toDate());
|
|
318
325
|
if (value instanceof Date) return _serializeDate(value);
|
|
@@ -366,7 +373,9 @@ function _serializeForWrite(
|
|
|
366
373
|
serializedVal = applyCastSet(val, castOpt);
|
|
367
374
|
} else if (colType === "boolean" && val !== null && val !== undefined) {
|
|
368
375
|
serializedVal = val ? 1 : 0;
|
|
369
|
-
} else if (colType === "json" && val !== null
|
|
376
|
+
} else if (colType === "json" && val !== null) {
|
|
377
|
+
// See applyCastSet: strings are encoded too, so the column always holds valid JSON
|
|
378
|
+
// and a read returns the type that was written.
|
|
370
379
|
serializedVal = JSON.stringify(val);
|
|
371
380
|
} else {
|
|
372
381
|
serializedVal = val;
|
|
@@ -460,7 +469,7 @@ export interface ScopeApplicator {
|
|
|
460
469
|
* — TypeScript will catch typos and non-existent column references.
|
|
461
470
|
*
|
|
462
471
|
* @example
|
|
463
|
-
* \@table("users")
|
|
472
|
+
* \@table("users")
|
|
464
473
|
* export class User extends BaseModel {
|
|
465
474
|
* static fillable: Columns<User>[] = ["name", "email", "role"];
|
|
466
475
|
* static hidden: Columns<User>[] = ["password"];
|
|
@@ -516,7 +525,7 @@ export type Columns<T> = {
|
|
|
516
525
|
* @example
|
|
517
526
|
* Defining a model with `@column`:
|
|
518
527
|
* ```ts
|
|
519
|
-
* @table("users")
|
|
528
|
+
* @table("users")
|
|
520
529
|
* export class User extends BaseModel {
|
|
521
530
|
* static fillable: Columns<User>[] = ["name", "email", "password"];
|
|
522
531
|
* static hidden: Columns<User>[] = ["password"];
|
|
@@ -560,6 +569,37 @@ export class BaseModel {
|
|
|
560
569
|
*/
|
|
561
570
|
declare readonly __isZerotalModel: true;
|
|
562
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Compose one or more model mixins onto this class, folding them left-to-right, so reusable
|
|
574
|
+
* model behaviour (auth contract, roles, permissions, soft deletes, tenancy, …) stacks flat
|
|
575
|
+
* instead of nesting.
|
|
576
|
+
*
|
|
577
|
+
* @remarks
|
|
578
|
+
* Each mixin receives the accumulated base and returns an extended class, so this class's full
|
|
579
|
+
* static surface (`query()`, `find()`, `create()`, scopes, …) and every mixin's instance and
|
|
580
|
+
* static members flow through to the composed class — fully type-checked. Prefer this over
|
|
581
|
+
* hand-nesting mixins (`Roles(Permissions(AuthUser))`), which reads inside-out and repeats the
|
|
582
|
+
* base.
|
|
583
|
+
*
|
|
584
|
+
* `using` composes onto whatever class it is called on, so it also works on an intermediate
|
|
585
|
+
* model base, and the composed class carries `using` itself, so `Model.using(a, b).using(c)`
|
|
586
|
+
* chains past the 8-mixin overload set.
|
|
587
|
+
*
|
|
588
|
+
* Mixin authors declaring columns must call {@link registerColumn} imperatively — the `@column`
|
|
589
|
+
* decorator cannot run inside a returned class expression.
|
|
590
|
+
*
|
|
591
|
+
* @param mixins - Mixin factories applied in order; each receives the class the previous one produced.
|
|
592
|
+
* @returns A model class extending this one with every mixin applied.
|
|
593
|
+
*
|
|
594
|
+
* @example
|
|
595
|
+
* ```ts
|
|
596
|
+
* class User extends Model.using(Authenticatable, Permissions, Roles) {}
|
|
597
|
+
* ```
|
|
598
|
+
*
|
|
599
|
+
* @category Composition
|
|
600
|
+
*/
|
|
601
|
+
static using: Compose = _compose;
|
|
602
|
+
|
|
563
603
|
/**
|
|
564
604
|
* Database table this model maps to. Usually set for you by the `@table("…")`
|
|
565
605
|
* decorator; assign directly to override.
|
|
@@ -660,7 +700,7 @@ export class BaseModel {
|
|
|
660
700
|
*
|
|
661
701
|
* @category Attributes & mass assignment
|
|
662
702
|
*/
|
|
663
|
-
static fillable?: string[];
|
|
703
|
+
static fillable?: readonly string[];
|
|
664
704
|
|
|
665
705
|
/**
|
|
666
706
|
* Denylist of camelCase field names blocked from create() / fill().
|
|
@@ -669,7 +709,7 @@ export class BaseModel {
|
|
|
669
709
|
*
|
|
670
710
|
* @category Attributes & mass assignment
|
|
671
711
|
*/
|
|
672
|
-
static guarded?: string[];
|
|
712
|
+
static guarded?: readonly string[];
|
|
673
713
|
|
|
674
714
|
/**
|
|
675
715
|
* Disable mass-assignment protection for this model — every attribute passed
|
|
@@ -1198,6 +1238,11 @@ export class BaseModel {
|
|
|
1198
1238
|
* Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
|
|
1199
1239
|
* instance and {@link save} it, returning the persisted model.
|
|
1200
1240
|
*
|
|
1241
|
+
* When the model declares `static fillable` as a literal tuple (`as const`), the
|
|
1242
|
+
* payload type is narrowed to exactly those columns — so a column deliberately kept
|
|
1243
|
+
* out of `fillable` is neither required nor accepted here, instead of being demanded
|
|
1244
|
+
* by the type and rejected by {@link fill} at runtime.
|
|
1245
|
+
*
|
|
1201
1246
|
* @throws {MassAssignmentError} when `data` contains a non-fillable key.
|
|
1202
1247
|
*
|
|
1203
1248
|
* @example
|
|
@@ -1205,7 +1250,10 @@ export class BaseModel {
|
|
|
1205
1250
|
*
|
|
1206
1251
|
* @category Persistence
|
|
1207
1252
|
*/
|
|
1208
|
-
static async create<T extends BaseModel
|
|
1253
|
+
static async create<T extends BaseModel, F extends string = string>(
|
|
1254
|
+
this: ModelCtor<T> & { fillable?: readonly F[] | undefined },
|
|
1255
|
+
data: FillablePayload<T, F>,
|
|
1256
|
+
): Promise<T> {
|
|
1209
1257
|
const inst = new this();
|
|
1210
1258
|
inst.fill(data as UpdatePayload<T>);
|
|
1211
1259
|
return inst.save() as Promise<T>;
|
|
@@ -1604,9 +1652,12 @@ export class BaseModel {
|
|
|
1604
1652
|
// On INSERT: hash every hashable field that holds a non-empty string.
|
|
1605
1653
|
// On UPDATE: only hash hashable fields whose value changed since the last
|
|
1606
1654
|
// save (avoids re-hashing an already-stored bcrypt hash).
|
|
1655
|
+
// One keyed view of the instance for the whole method: the hashable pass and the
|
|
1656
|
+
// insert's default-filling both need to read and write columns by name.
|
|
1657
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1658
|
+
|
|
1607
1659
|
const hashable = ModelClass.hashable;
|
|
1608
1660
|
if (hashable && hashable.length > 0) {
|
|
1609
|
-
const self = this as unknown as Record<string, unknown>;
|
|
1610
1661
|
if (!this._exists) {
|
|
1611
1662
|
for (const key of hashable) {
|
|
1612
1663
|
const val = self[key];
|
|
@@ -1635,7 +1686,24 @@ export class BaseModel {
|
|
|
1635
1686
|
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1636
1687
|
const r: Record<string, unknown> = {};
|
|
1637
1688
|
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
1638
|
-
|
|
1689
|
+
// A declared field that was never assigned is `undefined`, and writing that
|
|
1690
|
+
// as an explicit NULL made `@column({ default: … })` inert: the INSERT named
|
|
1691
|
+
// the column, so the database never applied its own default and a NOT NULL
|
|
1692
|
+
// column failed outright. Fall back to the declared default, and if there
|
|
1693
|
+
// isn't one, omit the column entirely so the database decides.
|
|
1694
|
+
//
|
|
1695
|
+
// Only `undefined` is treated this way. An explicit `null` is a deliberate
|
|
1696
|
+
// "store NULL" and still writes one.
|
|
1697
|
+
let effective = val;
|
|
1698
|
+
if (effective === undefined) {
|
|
1699
|
+
const declared = colReg?.get(key)?.default;
|
|
1700
|
+
if (declared === undefined) continue; // omit → database default / NULL
|
|
1701
|
+
effective = typeof declared === "function" ? (declared as () => unknown)() : declared;
|
|
1702
|
+
// Keep the instance consistent with the row we are about to write, so the
|
|
1703
|
+
// value is readable straight after save() without a reload.
|
|
1704
|
+
self[key] = effective;
|
|
1705
|
+
}
|
|
1706
|
+
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
|
|
1639
1707
|
}
|
|
1640
1708
|
if (ModelClass.timestamps) {
|
|
1641
1709
|
const now = _serializeDate(new Date());
|
|
@@ -2495,5 +2563,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
|
2495
2563
|
(inst as unknown as { _original: Record<string, unknown> })._original = orig;
|
|
2496
2564
|
}
|
|
2497
2565
|
|
|
2498
|
-
//
|
|
2566
|
+
// `Model` is the canonical name at the declaration site — `class User extends Model.using(…)`
|
|
2567
|
+
// mirrors Flow's `class PostsPage extends Component.using(…)`. `BaseModel` remains exported as
|
|
2568
|
+
// an alias (same class object) for code that references the base class by that name.
|
|
2499
2569
|
export { BaseModel as Model };
|
|
@@ -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
|
|
package/src/model/SoftDeletes.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// `deletedAt`, `forceDelete()`, `restore()`, `trashed()`, and the `withTrashed()` /
|
|
5
5
|
// `onlyTrashed()` query scopes. A hard-delete model has none of these.
|
|
6
6
|
//
|
|
7
|
-
// import {
|
|
7
|
+
// import { Model, SoftDeletes } from "@zerotal/orm";
|
|
8
8
|
//
|
|
9
9
|
// @table("posts")
|
|
10
|
-
// class Post extends
|
|
10
|
+
// class Post extends Model.using(SoftDeletes) {
|
|
11
11
|
// @column() title!: string;
|
|
12
12
|
// }
|
|
13
13
|
//
|
|
@@ -47,7 +47,7 @@ interface SoftDeleteModelClass<T extends BaseModel> {
|
|
|
47
47
|
* @example
|
|
48
48
|
* ```ts
|
|
49
49
|
* @table("posts")
|
|
50
|
-
* class Post extends
|
|
50
|
+
* class Post extends Model.using(SoftDeletes) {
|
|
51
51
|
* @column() title!: string;
|
|
52
52
|
* }
|
|
53
53
|
*
|
package/src/model/State.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// workflow carry the API — `transitionTo` / `forceState` / `onTransition` / the
|
|
5
5
|
// `states` + `stateField` statics never appear on models that don't use them.
|
|
6
6
|
//
|
|
7
|
-
// import {
|
|
7
|
+
// import { Model, State } from "@zerotal/orm";
|
|
8
8
|
//
|
|
9
9
|
// const States = {
|
|
10
10
|
// pending: { canTransitionTo: ["active", "cancelled"] as const },
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// cancelled: { canTransitionTo: [] as const },
|
|
15
15
|
// } as const;
|
|
16
16
|
//
|
|
17
|
-
// class Subscription extends
|
|
17
|
+
// class Subscription extends Model.using(State) {
|
|
18
18
|
// static states = States;
|
|
19
19
|
// @column() status!: keyof typeof States;
|
|
20
20
|
// }
|
|
@@ -132,7 +132,7 @@ interface StateModelClass {
|
|
|
132
132
|
* cancelled: { canTransitionTo: [] as const },
|
|
133
133
|
* } as const;
|
|
134
134
|
*
|
|
135
|
-
* class Subscription extends
|
|
135
|
+
* class Subscription extends Model.using(State) {
|
|
136
136
|
* static states = States;
|
|
137
137
|
* @column() status!: keyof typeof States;
|
|
138
138
|
* }
|
|
@@ -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
|
|
@@ -46,7 +46,7 @@ interface TableConfig {
|
|
|
46
46
|
* export class Post extends BaseModel { ... }
|
|
47
47
|
*
|
|
48
48
|
* Soft deletes are opt-in via the `SoftDeletes` mixin, not `@table`:
|
|
49
|
-
* `class Post extends
|
|
49
|
+
* `class Post extends Model.using(SoftDeletes) {}` — see /docs/orm/lifecycle.
|
|
50
50
|
*/
|
|
51
51
|
export interface TableDecoratorBuilder {
|
|
52
52
|
/** Apply the decorator to a class constructor (called automatically by TS). */
|
|
@@ -77,7 +77,7 @@ export interface TableDecoratorBuilder {
|
|
|
77
77
|
* ```
|
|
78
78
|
*
|
|
79
79
|
* Timestamps are **on by default** — use `.withoutTimestamps()` to opt out. Soft
|
|
80
|
-
* deletes are **opt-in via the `SoftDeletes` mixin**: `extends
|
|
80
|
+
* deletes are **opt-in via the `SoftDeletes` mixin**: `extends Model.using(SoftDeletes)`.
|
|
81
81
|
*
|
|
82
82
|
* `@table` is the single, required way to configure a model: besides setting the
|
|
83
83
|
* table name and options, it anchors the class's `@column`/relation registrations at
|