@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,211 @@
|
|
|
1
|
+
// ── Decorator registration core (Bun 1.3.x standard-decorator workaround) ─────
|
|
2
|
+
//
|
|
3
|
+
// Bun 1.3.x mis-compiles standard TC39 field decorators: deferred work is corrupted
|
|
4
|
+
// and shared across every class defined in the same file. Specifically —
|
|
5
|
+
// • field initializers and field `addInitializer` callbacks are cross-wired (a class
|
|
6
|
+
// runs another class's initializers, and names captured in their closures resolve
|
|
7
|
+
// to the LAST class defined in the file);
|
|
8
|
+
// • `context.metadata` is a single object shared by every class in the file;
|
|
9
|
+
// • `Symbol.metadata` is never assigned to the class.
|
|
10
|
+
//
|
|
11
|
+
// The ONE thing Bun compiles correctly is the decorator BODY: it runs synchronously at
|
|
12
|
+
// class-definition time with the correct `context.name`, but without a reference to the
|
|
13
|
+
// class. So each field/relation decorator captures its name+config in the body and
|
|
14
|
+
// ENQUEUES a registration closure. The `@table` class decorator runs synchronously right
|
|
15
|
+
// after a class's members — and it DOES receive the class — so it drains the queue into
|
|
16
|
+
// the concrete class. Result: every model anchors its columns/relations via `@table`.
|
|
17
|
+
|
|
18
|
+
import { relationRegistry } from "../relations/RelationRegistry.ts";
|
|
19
|
+
import type { RelationMetadata } from "../relations/RelationRegistry.ts";
|
|
20
|
+
import type { ColumnOptions } from "./column.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-class OWN column definitions. Readers walk the prototype chain to merge inherited.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export const columnRegistry = new Map<Function, Map<string, ColumnOptions>>();
|
|
27
|
+
|
|
28
|
+
// ── Definition-time queue ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
interface PendingMember {
|
|
31
|
+
/** Field name (captured correctly in the decorator body). */
|
|
32
|
+
name: string;
|
|
33
|
+
apply: (ctor: Function) => void;
|
|
34
|
+
}
|
|
35
|
+
let _pending: PendingMember[] = [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enqueue a member registration from a decorator body (name captured correctly there).
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export function enqueueMember(name: string, apply: (ctor: Function) => void): void {
|
|
42
|
+
_pending.push({ name, apply });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Drain queued member registrations into `ctor`. Called by the `@table` class decorator,
|
|
47
|
+
* which runs synchronously immediately after the class's member decorators — so the queue
|
|
48
|
+
* contains exactly that class's members and nothing else.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export function drainPendingMembers(ctor: Function): void {
|
|
52
|
+
if (_pending.length === 0) return;
|
|
53
|
+
const batch = _pending;
|
|
54
|
+
_pending = [];
|
|
55
|
+
for (const e of batch) e.apply(ctor);
|
|
56
|
+
registerModelName(ctor);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Per-class registration (invoked from drained closures) ────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Record a column definition for `ctor`, and mirror any `cast` onto the class's own
|
|
63
|
+
* `static casts` map (seeded from the parent so a subclass extends rather than mutates it).
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export function registerColumn(ctor: Function, name: string, options: ColumnOptions): void {
|
|
67
|
+
let m = columnRegistry.get(ctor);
|
|
68
|
+
if (!m) {
|
|
69
|
+
m = new Map();
|
|
70
|
+
columnRegistry.set(ctor, m);
|
|
71
|
+
}
|
|
72
|
+
m.set(name, options);
|
|
73
|
+
|
|
74
|
+
if (options.cast) {
|
|
75
|
+
const c = ctor as { casts?: Record<string, ColumnOptions["cast"]> };
|
|
76
|
+
// Give the class its OWN casts object (seeded from the parent) the first time we add
|
|
77
|
+
// to it, so a subclass extends rather than mutates the parent's casts.
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(ctor, "casts")) {
|
|
79
|
+
const parent = (
|
|
80
|
+
Object.getPrototypeOf(ctor) as { casts?: Record<string, ColumnOptions["cast"]> }
|
|
81
|
+
)?.casts;
|
|
82
|
+
c.casts = { ...(parent ?? {}) };
|
|
83
|
+
}
|
|
84
|
+
c.casts![name] = options.cast;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Record relation metadata for `ctor`. Invoked from a drained decorator closure.
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
export function registerRelation(ctor: Function, name: string, meta: RelationMetadata): void {
|
|
93
|
+
let m = relationRegistry.get(ctor);
|
|
94
|
+
if (!m) {
|
|
95
|
+
m = new Map();
|
|
96
|
+
relationRegistry.set(ctor, m);
|
|
97
|
+
}
|
|
98
|
+
m.set(name, meta);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Convention registration (used by the auto-discovery loader) ───────────────
|
|
102
|
+
|
|
103
|
+
const _registeredModels = new WeakSet<Function>();
|
|
104
|
+
/**
|
|
105
|
+
* class name → model class, for observer/policy association by name.
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export const modelsByName = new Map<string, Function>();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Look up a model class by its (unqualified) class name.
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
export function modelByName(name: string): Function | undefined {
|
|
115
|
+
return modelsByName.get(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Index a model class under its name. Called by @table's drain and by registerModel(). */
|
|
119
|
+
function registerModelName(ctor: Function): void {
|
|
120
|
+
const name = (ctor as { name?: string }).name;
|
|
121
|
+
if (name) modelsByName.set(name, ctor);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register a model class discovered by convention — the loader calls this for every class
|
|
126
|
+
* in `app/models/`, so `@table` is not required:
|
|
127
|
+
* - drains the class's buffered @column/@relation entries by matching a probe instance's
|
|
128
|
+
* own field names (Bun's deferred-decorator bug prevents binding name→class at definition
|
|
129
|
+
* without an anchor; the loader IS the anchor, and the probe reveals which buffered
|
|
130
|
+
* entries belong to this class — so convention models must use real fields, e.g.
|
|
131
|
+
* `@column() name!: string`, not `@column() declare name: string`);
|
|
132
|
+
* - indexes it under `modelsByName`.
|
|
133
|
+
*
|
|
134
|
+
* The convention table name is applied separately by the loader's models concern (which has
|
|
135
|
+
* the inflector); explicit `@table("...")` / `static table` always wins. Idempotent and a
|
|
136
|
+
* safe no-op on already-`@table`'d models.
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
export function registerModel(ctor: Function): void {
|
|
140
|
+
if (_registeredModels.has(ctor)) return;
|
|
141
|
+
_registeredModels.add(ctor);
|
|
142
|
+
|
|
143
|
+
if (_pending.length > 0) {
|
|
144
|
+
let own = new Set<string>();
|
|
145
|
+
try {
|
|
146
|
+
own = new Set(Object.keys(new (ctor as new () => object)()));
|
|
147
|
+
} catch {
|
|
148
|
+
/* not constructible without args — can't claim columns by probe */
|
|
149
|
+
}
|
|
150
|
+
if (own.size) {
|
|
151
|
+
// A class's field decorators run consecutively at definition, so its buffered
|
|
152
|
+
// entries form a contiguous run; claim from the first own-matching entry until the
|
|
153
|
+
// first entry that belongs to another class.
|
|
154
|
+
const start = _pending.findIndex((e) => own.has(e.name));
|
|
155
|
+
if (start !== -1) {
|
|
156
|
+
let end = start;
|
|
157
|
+
while (end < _pending.length && own.has(_pending[end]!.name)) end++;
|
|
158
|
+
const claimed = _pending.splice(start, end - start);
|
|
159
|
+
for (const e of claimed) e.apply(ctor);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
registerModelName(ctor);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Readers (prototype-chain merge; child wins) ───────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Merged column definitions (own + inherited) for a class, or null if none.
|
|
171
|
+
* @internal
|
|
172
|
+
*/
|
|
173
|
+
export function columnsFor(ctor: Function): Map<string, ColumnOptions> | null {
|
|
174
|
+
const merged = new Map<string, ColumnOptions>();
|
|
175
|
+
let cls: Function | null = ctor;
|
|
176
|
+
while (cls && cls !== Function.prototype) {
|
|
177
|
+
const c = columnRegistry.get(cls);
|
|
178
|
+
if (c) for (const [k, v] of c) if (!merged.has(k)) merged.set(k, v);
|
|
179
|
+
cls = Object.getPrototypeOf(cls) as Function | null;
|
|
180
|
+
}
|
|
181
|
+
return merged.size ? merged : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Merged relation metadata (imperative mixins + @decorators, own + inherited).
|
|
186
|
+
* @internal
|
|
187
|
+
*/
|
|
188
|
+
export function relationsFor(ctor: Function): Map<string, RelationMetadata> {
|
|
189
|
+
const merged = new Map<string, RelationMetadata>();
|
|
190
|
+
let cls: Function | null = ctor;
|
|
191
|
+
while (cls && cls !== Function.prototype) {
|
|
192
|
+
const r = relationRegistry.get(cls);
|
|
193
|
+
if (r) for (const [k, v] of r) if (!merged.has(k)) merged.set(k, v);
|
|
194
|
+
cls = Object.getPrototypeOf(cls) as Function | null;
|
|
195
|
+
}
|
|
196
|
+
return merged;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Names of reactive (json/array cast) columns for a class (own + inherited).
|
|
201
|
+
* @internal
|
|
202
|
+
*/
|
|
203
|
+
export function reactiveColumnsFor(ctor: Function): string[] {
|
|
204
|
+
const cols = columnsFor(ctor);
|
|
205
|
+
if (!cols) return [];
|
|
206
|
+
const out: string[] = [];
|
|
207
|
+
for (const [name, opts] of cols) {
|
|
208
|
+
if (opts.cast === "json" || opts.cast === "array" || opts.type === "json") out.push(name);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RelationMetadata } from "../relations/RelationRegistry.ts";
|
|
2
|
+
import { enqueueMember, registerRelation } from "./_metadata.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single plumbing for every relation field decorator (standard TC39 decorators).
|
|
6
|
+
*
|
|
7
|
+
* Registration is captured in the decorator BODY (which runs at definition time with the
|
|
8
|
+
* correct `context.name`) and ENQUEUED — we can't use a field initializer/addInitializer,
|
|
9
|
+
* which Bun 1.3.x cross-wires across classes in a file. The `@table` class decorator
|
|
10
|
+
* drains the queue into the concrete class, invoking the relation FACTORY (`metaFor`)
|
|
11
|
+
* with that class so morph/through values depending on the class name resolve correctly.
|
|
12
|
+
*/
|
|
13
|
+
export function makeRelationDecorator(
|
|
14
|
+
metaFor: (ctor: Function, field: string) => RelationMetadata,
|
|
15
|
+
) {
|
|
16
|
+
return function (_value: unknown, context: ClassFieldDecoratorContext): void {
|
|
17
|
+
const name = String(context.name);
|
|
18
|
+
enqueueMember(name, (ctor) => registerRelation(ctor, name, metaFor(ctor, name)));
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { makeRelationDecorator } from "./_registerRelation.ts";
|
|
2
|
+
|
|
3
|
+
/** Options for {@link belongsTo}. */
|
|
4
|
+
export interface BelongsToOptions {
|
|
5
|
+
/** FK column on this (child) model pointing to the related row (e.g. `user_id`). */
|
|
6
|
+
foreignKey: string;
|
|
7
|
+
/** Key on the related model the FK references. Defaults to `'id'`. */
|
|
8
|
+
localKey?: string;
|
|
9
|
+
/** Return a default (unsaved) related model instead of null when absent. */
|
|
10
|
+
withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declare the inverse of a one-to-many / one-to-one: this model belongs to a
|
|
15
|
+
* single related row referenced by a foreign key on this model's table (e.g. a
|
|
16
|
+
* `Post` belongs to a `User`).
|
|
17
|
+
*
|
|
18
|
+
* @param related - Lazy factory returning the related model class.
|
|
19
|
+
* @param options - Foreign/local key configuration, plus optional `withDefault`.
|
|
20
|
+
* @category Relationships
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* class Post extends BaseModel {
|
|
25
|
+
* \@belongsTo(() => User, { foreignKey: 'user_id' })
|
|
26
|
+
* author!: BelongsTo<User>;
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function belongsTo(related: () => unknown, options: BelongsToOptions) {
|
|
31
|
+
return makeRelationDecorator(() => ({
|
|
32
|
+
type: "belongsTo" as const,
|
|
33
|
+
related,
|
|
34
|
+
foreignKey: options.foreignKey,
|
|
35
|
+
localKey: options.localKey ?? "id",
|
|
36
|
+
...(options.withDefault !== undefined ? { withDefault: options.withDefault } : {}),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { makeReactive } from "../ReactiveProxy.ts";
|
|
2
|
+
import type { CastContract } from "../../casts/Cast.ts";
|
|
3
|
+
import {
|
|
4
|
+
enqueueMember,
|
|
5
|
+
registerColumn,
|
|
6
|
+
columnRegistry,
|
|
7
|
+
columnsFor,
|
|
8
|
+
reactiveColumnsFor,
|
|
9
|
+
} from "./_metadata.ts";
|
|
10
|
+
|
|
11
|
+
// Re-exported here for the public API — populated at class-definition time via @table.
|
|
12
|
+
export { columnRegistry };
|
|
13
|
+
|
|
14
|
+
// ── Column type definitions ───────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* String shorthand accepted by `@column("...")`.
|
|
18
|
+
* Each value maps to a fully-resolved `ColumnOptions` object.
|
|
19
|
+
*
|
|
20
|
+
* | Shorthand | Equivalent options |
|
|
21
|
+
* |-------------|--------------------------------------------------|
|
|
22
|
+
* | `"string"` | `{ type: "string" }` |
|
|
23
|
+
* | `"text"` | `{ type: "string" }` (large text, alias) |
|
|
24
|
+
* | `"integer"` | `{ type: "number", cast: "integer" }` |
|
|
25
|
+
* | `"number"` | `{ type: "number" }` |
|
|
26
|
+
* | `"float"` | `{ type: "number", cast: "float" }` |
|
|
27
|
+
* | `"boolean"` | `{ type: "boolean", cast: "boolean" }` |
|
|
28
|
+
* | `"datetime"`| `{ type: "datetime", cast: "datetime" }` |
|
|
29
|
+
* | `"date"` | `{ type: "datetime", cast: "date" }` |
|
|
30
|
+
* | `"json"` | `{ type: "json", cast: "json" }` |
|
|
31
|
+
* | `"array"` | `{ type: "json", cast: "array" }` |
|
|
32
|
+
*/
|
|
33
|
+
export type ColumnShorthand =
|
|
34
|
+
| "string"
|
|
35
|
+
| "text"
|
|
36
|
+
| "integer"
|
|
37
|
+
| "number"
|
|
38
|
+
| "float"
|
|
39
|
+
| "boolean"
|
|
40
|
+
| "datetime"
|
|
41
|
+
| "date"
|
|
42
|
+
| "json"
|
|
43
|
+
| "array";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Full option object accepted by `@column({ ... })`.
|
|
47
|
+
*
|
|
48
|
+
* Every string shorthand ({@link ColumnShorthand}) resolves to one of these; use
|
|
49
|
+
* the object form directly when you need `nullable`, `default`, a custom `cast`,
|
|
50
|
+
* or to mark a `primary` key.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* The registered `type` drives schema generation / auto-migration, while `cast`
|
|
54
|
+
* drives runtime serialization of the attribute value (see the field docs below).
|
|
55
|
+
* A `cast` is also mirrored onto the model class's `static casts` map at
|
|
56
|
+
* registration time.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* @column({ type: "string", nullable: true, default: "guest" })
|
|
61
|
+
* nickname?: string | null;
|
|
62
|
+
*
|
|
63
|
+
* @column({ type: "json", cast: "array", default: [] })
|
|
64
|
+
* tags!: string[];
|
|
65
|
+
*
|
|
66
|
+
* @column({ cast: new MoneyCast() })
|
|
67
|
+
* price!: number;
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export interface ColumnOptions {
|
|
71
|
+
/** Logical storage type; drives schema generation and auto-migration. @default "string" */
|
|
72
|
+
type?: "string" | "number" | "boolean" | "datetime" | "json";
|
|
73
|
+
/** Mark this column as the table's primary key. */
|
|
74
|
+
primary?: boolean;
|
|
75
|
+
/** Allow SQL `NULL` for this column. */
|
|
76
|
+
nullable?: boolean;
|
|
77
|
+
/** Default value applied when none is provided. */
|
|
78
|
+
default?: unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Shorthand cast types automatically serialize/deserialize the column value.
|
|
81
|
+
* Can also be a custom object with `get`/`set` functions for full control.
|
|
82
|
+
*
|
|
83
|
+
* - 'datetime' — Carbon on read, ISO string on write (recommended for dates)
|
|
84
|
+
* - 'date' — native Date on read, ISO string on write
|
|
85
|
+
* - 'array' / 'json' — JSON.parse on read, JSON.stringify on write
|
|
86
|
+
* - 'boolean' — coerces 0/1 integers; writes 0 or 1
|
|
87
|
+
* - 'integer' — parseInt on both read and write
|
|
88
|
+
* - 'float' — parseFloat on both read and write
|
|
89
|
+
* - 'enum' — pass-through; pairs with `enumValues` for TS enum columns
|
|
90
|
+
*/
|
|
91
|
+
cast?:
|
|
92
|
+
| "datetime"
|
|
93
|
+
| "array"
|
|
94
|
+
| "json"
|
|
95
|
+
| "date"
|
|
96
|
+
| "boolean"
|
|
97
|
+
| "integer"
|
|
98
|
+
| "float"
|
|
99
|
+
| "enum"
|
|
100
|
+
| "immutable_datetime"
|
|
101
|
+
| `decimal:${number}`
|
|
102
|
+
| {
|
|
103
|
+
get?: (dbValue: unknown) => unknown;
|
|
104
|
+
set?: (jsValue: unknown) => unknown;
|
|
105
|
+
}
|
|
106
|
+
| CastContract<unknown>;
|
|
107
|
+
/** Enum object (e.g. the imported TS enum) used alongside cast: 'enum'. */
|
|
108
|
+
enumValues?: Record<string, string | number>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map each ColumnShorthand to its resolved ColumnOptions.
|
|
113
|
+
*/
|
|
114
|
+
const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
115
|
+
string: { type: "string" },
|
|
116
|
+
text: { type: "string" },
|
|
117
|
+
integer: { type: "number", cast: "integer" },
|
|
118
|
+
number: { type: "number" },
|
|
119
|
+
float: { type: "number", cast: "float" },
|
|
120
|
+
boolean: { type: "boolean", cast: "boolean" },
|
|
121
|
+
datetime: { type: "datetime", cast: "datetime" },
|
|
122
|
+
date: { type: "datetime", cast: "date" },
|
|
123
|
+
json: { type: "json", cast: "json" },
|
|
124
|
+
array: { type: "json", cast: "array" },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function resolveOptions(arg?: ColumnShorthand | ColumnOptions): ColumnOptions {
|
|
128
|
+
if (arg === undefined) return { type: "string" };
|
|
129
|
+
if (typeof arg === "string") {
|
|
130
|
+
const a = arg as string;
|
|
131
|
+
if (a.startsWith("decimal:"))
|
|
132
|
+
return { type: "number", cast: a as ColumnOptions["cast"] } as ColumnOptions;
|
|
133
|
+
if (a === "immutable_datetime")
|
|
134
|
+
return {
|
|
135
|
+
type: "datetime",
|
|
136
|
+
cast: "immutable_datetime" as ColumnOptions["cast"],
|
|
137
|
+
} as ColumnOptions;
|
|
138
|
+
return SHORTHAND_MAP[a as ColumnShorthand] ?? { type: "string" };
|
|
139
|
+
}
|
|
140
|
+
return arg;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Reactivity ────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function shouldReactive(options: ColumnOptions): boolean {
|
|
146
|
+
return options.cast === "json" || options.cast === "array" || options.type === "json";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function wrapReactive(
|
|
150
|
+
instance: unknown,
|
|
151
|
+
key: string,
|
|
152
|
+
options: ColumnOptions,
|
|
153
|
+
value: unknown,
|
|
154
|
+
): unknown {
|
|
155
|
+
if (!shouldReactive(options)) return value;
|
|
156
|
+
const ctor = (instance as { constructor?: { reactiveCasts?: boolean } }).constructor;
|
|
157
|
+
if (!ctor || !ctor.reactiveCasts) return value;
|
|
158
|
+
return makeReactive(instance as any, key, value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function defineReactiveProperty(
|
|
162
|
+
instance: unknown,
|
|
163
|
+
key: string,
|
|
164
|
+
options: ColumnOptions,
|
|
165
|
+
initialValue?: unknown,
|
|
166
|
+
): void {
|
|
167
|
+
if (!shouldReactive(options)) return;
|
|
168
|
+
|
|
169
|
+
const privateKey = `_zerotal_${key}`;
|
|
170
|
+
const self = instance as Record<string, unknown>;
|
|
171
|
+
const seed = initialValue !== undefined ? initialValue : self[key];
|
|
172
|
+
self[privateKey] = wrapReactive(instance, key, options, seed);
|
|
173
|
+
|
|
174
|
+
Object.defineProperty(instance, key, {
|
|
175
|
+
get() {
|
|
176
|
+
return (this as Record<string, unknown>)[privateKey];
|
|
177
|
+
},
|
|
178
|
+
set(value: unknown) {
|
|
179
|
+
const next = wrapReactive(this, key, options, value);
|
|
180
|
+
(this as Record<string, unknown>)[privateKey] = next;
|
|
181
|
+
if ((this as { _exists?: boolean })._exists) {
|
|
182
|
+
(this as { markDirty: (k: string) => void }).markDirty(key);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
enumerable: true,
|
|
186
|
+
configurable: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── @column() overloads ───────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Standard TC39 class-field decorator returned by {@link column}. */
|
|
193
|
+
type ColumnDecorator = (value: undefined, context: ClassFieldDecoratorContext) => void;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Map a model property to a database column.
|
|
197
|
+
*
|
|
198
|
+
* @remarks
|
|
199
|
+
* Applied to a field of a `BaseModel` subclass, `@column` registers that property
|
|
200
|
+
* in the ORM's column metadata (`columnRegistry`), so it participates in
|
|
201
|
+
* hydration, dirty-tracking, persistence, and schema generation / auto-migration.
|
|
202
|
+
*
|
|
203
|
+
* The argument may be omitted (defaults to a `"string"` column), given as a
|
|
204
|
+
* {@link ColumnShorthand} string, or given as a full {@link ColumnOptions} object.
|
|
205
|
+
* Options control the storage `type`, `primary` key, `nullable`, `default`, and —
|
|
206
|
+
* most importantly — the `cast` that serializes the value between DB and model
|
|
207
|
+
* (`json`/`array`, `datetime`/`date`, `boolean`, `integer`/`float`, `decimal:n`,
|
|
208
|
+
* `enum`, or a custom `{ get, set }` / {@link CastContract}). A declared `cast` is
|
|
209
|
+
* also mirrored onto the class's `static casts` map.
|
|
210
|
+
*
|
|
211
|
+
* Timestamp and primary-key conventions themselves are configured on the class via
|
|
212
|
+
* `@table` (see {@link table}); `@column` only maps individual fields.
|
|
213
|
+
*
|
|
214
|
+
* Registration is anchored at class-definition time by the `@table` decorator (a
|
|
215
|
+
* Bun 1.3.x standard-decorator workaround), so a model that declares columns must
|
|
216
|
+
* also carry `@table` — or be auto-discovered from `app/models/`.
|
|
217
|
+
*
|
|
218
|
+
* @param arg - A {@link ColumnShorthand} string, a {@link ColumnOptions} object, or nothing (defaults to `"string"`).
|
|
219
|
+
* @returns A class-field decorator that registers the property as a column.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* @table("users")
|
|
224
|
+
* export class User extends BaseModel {
|
|
225
|
+
* @column({ primary: true }) id!: number;
|
|
226
|
+
*
|
|
227
|
+
* // No args — defaults to a string column
|
|
228
|
+
* @column() name!: string;
|
|
229
|
+
*
|
|
230
|
+
* // String shorthand
|
|
231
|
+
* @column("integer") age!: number;
|
|
232
|
+
* @column("datetime") createdAt!: Carbon;
|
|
233
|
+
*
|
|
234
|
+
* // Cast a JSON column to/from an array (reactive when `static reactiveCasts`)
|
|
235
|
+
* @column({ type: "json", cast: "array", default: [] }) roles!: string[];
|
|
236
|
+
*
|
|
237
|
+
* // Nullable with a custom cast object
|
|
238
|
+
* @column({ nullable: true, cast: { get: (v) => v && new URL(String(v)), set: (u) => u?.href } })
|
|
239
|
+
* website?: URL | null;
|
|
240
|
+
* }
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
export function column(): ColumnDecorator;
|
|
244
|
+
export function column(type: ColumnShorthand): ColumnDecorator;
|
|
245
|
+
export function column(options: ColumnOptions): ColumnDecorator;
|
|
246
|
+
export function column(arg?: ColumnShorthand | ColumnOptions): ColumnDecorator {
|
|
247
|
+
const options = resolveOptions(arg);
|
|
248
|
+
// The decorator BODY runs synchronously at definition time with the correct
|
|
249
|
+
// `context.name` (the only thing Bun 1.3.x compiles reliably for field decorators).
|
|
250
|
+
// We can't defer to a field initializer or addInitializer — Bun cross-wires those
|
|
251
|
+
// across classes. Instead capture name+options here and enqueue the registration; the
|
|
252
|
+
// `@table` class decorator drains the queue into the concrete class (see _metadata.ts).
|
|
253
|
+
return function (_value: undefined, context: ClassFieldDecoratorContext): void {
|
|
254
|
+
const name = String(context.name);
|
|
255
|
+
enqueueMember(name, (ctor) => registerColumn(ctor, name, options));
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Install reactive accessors (json/array `reactiveCasts` columns) on a fresh instance.
|
|
261
|
+
* Called from the model's fill()/hydrate paths — registration now happens at decoration
|
|
262
|
+
* time, so the per-instance reactive setup is done here rather than in a field initializer.
|
|
263
|
+
* Idempotent.
|
|
264
|
+
*
|
|
265
|
+
* @param instance - The freshly constructed / hydrated model instance to wire.
|
|
266
|
+
* @internal
|
|
267
|
+
*/
|
|
268
|
+
export function installReactiveAccessors(instance: object): void {
|
|
269
|
+
const reactive = reactiveColumnsFor(instance.constructor as Function);
|
|
270
|
+
if (!reactive.length) return;
|
|
271
|
+
const cols = columnsFor(instance.constructor as Function);
|
|
272
|
+
for (const name of reactive) {
|
|
273
|
+
const desc = Object.getOwnPropertyDescriptor(instance, name);
|
|
274
|
+
if (desc && typeof desc.get === "function") continue; // already installed
|
|
275
|
+
const opts = cols?.get(name);
|
|
276
|
+
if (opts) defineReactiveProperty(instance, name, opts);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { makeRelationDecorator } from "./_registerRelation.ts";
|
|
2
|
+
|
|
3
|
+
/** Options for {@link hasMany}. */
|
|
4
|
+
export interface HasManyOptions {
|
|
5
|
+
/** FK column on the related table pointing back to the parent (e.g. `user_id`). */
|
|
6
|
+
foreignKey: string;
|
|
7
|
+
/** Parent local key the FK references. Defaults to `'id'`. */
|
|
8
|
+
localKey?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Declare a one-to-many relation: the parent owns many related rows, matched by a
|
|
13
|
+
* foreign key on the related table (e.g. a `User` has many `Post`s).
|
|
14
|
+
*
|
|
15
|
+
* @param related - Lazy factory returning the related model class.
|
|
16
|
+
* @param options - Foreign/local key configuration.
|
|
17
|
+
* @category Relationships
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* class User extends BaseModel {
|
|
22
|
+
* \@hasMany(() => Post, { foreignKey: 'user_id' })
|
|
23
|
+
* posts!: HasMany<Post>;
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function hasMany(related: () => unknown, options: HasManyOptions) {
|
|
28
|
+
return makeRelationDecorator(() => ({
|
|
29
|
+
type: "hasMany" as const,
|
|
30
|
+
related,
|
|
31
|
+
foreignKey: options.foreignKey,
|
|
32
|
+
localKey: options.localKey ?? "id",
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { makeRelationDecorator } from "./_registerRelation.ts";
|
|
2
|
+
|
|
3
|
+
export interface HasManyThroughOptions {
|
|
4
|
+
/** FK on the through table referencing the parent (e.g. 'country_id' on users). */
|
|
5
|
+
firstKey: string;
|
|
6
|
+
/** FK on the related table referencing the through model (e.g. 'user_id' on posts). */
|
|
7
|
+
secondKey: string;
|
|
8
|
+
/** Parent local key the firstKey references. Default 'id'. */
|
|
9
|
+
localKey?: string;
|
|
10
|
+
/** Through local key the secondKey references. Default 'id'. */
|
|
11
|
+
throughLocalKey?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Declare a has-many-through relation: reach the related rows across one
|
|
16
|
+
* intermediate ("through") model — e.g. `Country` —(users.country_id)→ `User`
|
|
17
|
+
* —(posts.user_id)→ `Post`, so a country has many posts through its users.
|
|
18
|
+
*
|
|
19
|
+
* @remarks Not supported by {@link has} / {@link whereHas}; use eager
|
|
20
|
+
* {@link ModelQueryBuilder.with | with()} to load it.
|
|
21
|
+
*
|
|
22
|
+
* @param related - Lazy factory returning the far/related model class.
|
|
23
|
+
* @param through - Lazy factory returning the intermediate model class.
|
|
24
|
+
* @param options - First/second key configuration across the two hops.
|
|
25
|
+
* @category Relationships
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* class Country extends BaseModel {
|
|
30
|
+
* \@hasManyThrough(() => Post, () => User, { firstKey: 'country_id', secondKey: 'user_id' })
|
|
31
|
+
* posts!: HasMany<Post>;
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function hasManyThrough(
|
|
36
|
+
related: () => unknown,
|
|
37
|
+
through: () => unknown,
|
|
38
|
+
options: HasManyThroughOptions,
|
|
39
|
+
) {
|
|
40
|
+
return makeRelationDecorator(() => ({
|
|
41
|
+
type: "hasManyThrough" as const,
|
|
42
|
+
related,
|
|
43
|
+
through,
|
|
44
|
+
firstKey: options.firstKey,
|
|
45
|
+
secondKey: options.secondKey,
|
|
46
|
+
foreignKey: options.secondKey,
|
|
47
|
+
localKey: options.localKey ?? "id",
|
|
48
|
+
throughLocalKey: options.throughLocalKey ?? "id",
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { makeRelationDecorator } from "./_registerRelation.ts";
|
|
2
|
+
|
|
3
|
+
/** Options for {@link hasOne}. */
|
|
4
|
+
export interface HasOneOptions {
|
|
5
|
+
/** FK column on the related table pointing back to the parent (e.g. `user_id`). */
|
|
6
|
+
foreignKey: string;
|
|
7
|
+
/** Parent local key the FK references. Defaults to `'id'`. */
|
|
8
|
+
localKey?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Declare a one-to-one relation: the parent owns a single related row, matched by
|
|
13
|
+
* a foreign key on the related table (e.g. a `User` has one `Profile`).
|
|
14
|
+
*
|
|
15
|
+
* @param related - Lazy factory returning the related model class.
|
|
16
|
+
* @param options - Foreign/local key configuration.
|
|
17
|
+
* @category Relationships
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* class User extends BaseModel {
|
|
22
|
+
* \@hasOne(() => Profile, { foreignKey: 'user_id' })
|
|
23
|
+
* profile!: HasOne<Profile>;
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function hasOne(related: () => unknown, options: HasOneOptions) {
|
|
28
|
+
return makeRelationDecorator(() => ({
|
|
29
|
+
type: "hasOne" as const,
|
|
30
|
+
related,
|
|
31
|
+
foreignKey: options.foreignKey,
|
|
32
|
+
localKey: options.localKey ?? "id",
|
|
33
|
+
}));
|
|
34
|
+
}
|