@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,2499 @@
|
|
|
1
|
+
import type { SQLInstance } from "../db/sql-types.ts";
|
|
2
|
+
import type { PaginateResult } from "../db/types.ts";
|
|
3
|
+
import { RequestContext, FrameworkEvents } from "@zerotal/core";
|
|
4
|
+
import { ModelChanged } from "../events.ts";
|
|
5
|
+
import { Carbon } from "@zerotal/core/carbon";
|
|
6
|
+
import { toCamelKey as toCamel, toSnakeColumn as toSnake } from "../support/identifiers.ts";
|
|
7
|
+
import { resolveContainerConnection } from "../db/resolver.ts";
|
|
8
|
+
import { currentOrmContext } from "./OrmContext.ts";
|
|
9
|
+
import {
|
|
10
|
+
ModelQueryBuilder,
|
|
11
|
+
_globalScopeRegistry,
|
|
12
|
+
aggregateAttribute,
|
|
13
|
+
countAttribute,
|
|
14
|
+
type GlobalScopeCallback,
|
|
15
|
+
} from "./ModelQueryBuilder.ts";
|
|
16
|
+
import {
|
|
17
|
+
QueryBuilder,
|
|
18
|
+
_setQueryBuilderDialect,
|
|
19
|
+
_runSegments,
|
|
20
|
+
_assertIdentifier,
|
|
21
|
+
dialectFor,
|
|
22
|
+
registerConnectionDialect,
|
|
23
|
+
type Dialect,
|
|
24
|
+
} from "../db/QueryBuilder.ts";
|
|
25
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
|
+
import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
|
|
27
|
+
import { registerObserver, type ModelObserver } from "./Observer.ts";
|
|
28
|
+
import { makeReactive } from "./ReactiveProxy.ts";
|
|
29
|
+
import {
|
|
30
|
+
ModelNotFoundError,
|
|
31
|
+
RelationNotLoadedError,
|
|
32
|
+
MassAssignmentError,
|
|
33
|
+
} from "../errors/index.ts";
|
|
34
|
+
import { type ManyToMany } from "./relations/RelationRegistry.ts";
|
|
35
|
+
import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
|
|
36
|
+
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
37
|
+
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
38
|
+
import type { InsertPayload, UpdatePayload } from "./payload.ts";
|
|
39
|
+
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
40
|
+
|
|
41
|
+
let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
|
|
42
|
+
|
|
43
|
+
const _writeDialect = new AsyncLocalStorage<Dialect>();
|
|
44
|
+
|
|
45
|
+
// ── Model event dispatch (`dispatchesEvents` bridge) ──────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// Maps internal hook names to the event keys used in `dispatchesEvents`.
|
|
48
|
+
const _HOOK_TO_EVENT: Partial<Record<HookName, string>> = {
|
|
49
|
+
beforeCreate: "creating",
|
|
50
|
+
afterCreate: "created",
|
|
51
|
+
beforeUpdate: "updating",
|
|
52
|
+
afterUpdate: "updated",
|
|
53
|
+
beforeSave: "saving",
|
|
54
|
+
afterSave: "saved",
|
|
55
|
+
beforeDelete: "deleting",
|
|
56
|
+
afterDelete: "deleted",
|
|
57
|
+
afterFind: "retrieved",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// App-level dispatcher, wired by DatabaseProvider to the container's event bus. No-op when
|
|
61
|
+
// unset (ORM used standalone / no emitter bound), keeping the ORM decoupled from core events.
|
|
62
|
+
let _eventDispatcher: ((event: object) => void) | undefined;
|
|
63
|
+
|
|
64
|
+
/** @internal Set the model-event dispatcher (called by DatabaseProvider). */
|
|
65
|
+
export function _setModelEventDispatcher(fn: ((event: object) => void) | undefined): void {
|
|
66
|
+
_eventDispatcher = fn;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The persisting lifecycle hooks that correspond to a row change, for monitor telemetry.
|
|
70
|
+
const _HOOK_TO_OP: Partial<Record<HookName, ModelChanged["operation"]>> = {
|
|
71
|
+
afterCreate: "created",
|
|
72
|
+
afterUpdate: "updated",
|
|
73
|
+
afterDelete: "deleted",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Dispatch the mapped event (if any) after a lifecycle hook runs. Wired via HookRegistry so
|
|
77
|
+
// it honours the hook-suppression context (factory seeding mutes events automatically).
|
|
78
|
+
HookRegistry.onAfterRun = (ModelClass, hook, model): void => {
|
|
79
|
+
// Per-model change telemetry (created/updated/deleted) for the monitor's models
|
|
80
|
+
// watcher. Fires for every model regardless of `dispatchesEvents`, but only when
|
|
81
|
+
// hooks aren't suppressed (so factory seeding doesn't flood it).
|
|
82
|
+
const op = _HOOK_TO_OP[hook];
|
|
83
|
+
if (op) {
|
|
84
|
+
const cls = ModelClass as typeof BaseModel;
|
|
85
|
+
FrameworkEvents.emit(new ModelChanged(cls.name, cls.table ?? "", op));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// `dispatchesEvents` bridge to the app event bus.
|
|
89
|
+
if (!_eventDispatcher) return;
|
|
90
|
+
const key = _HOOK_TO_EVENT[hook];
|
|
91
|
+
if (!key) return;
|
|
92
|
+
const map = (ModelClass as typeof BaseModel).dispatchesEvents;
|
|
93
|
+
const EventClass = map?.[key];
|
|
94
|
+
if (EventClass) _eventDispatcher(new EventClass(model));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register a named connection that models can select via `static connection`.
|
|
99
|
+
* Stored on the current OrmContext (execution-scoped), not a global.
|
|
100
|
+
*/
|
|
101
|
+
export function registerModelConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
|
|
102
|
+
currentOrmContext().namedConnections.set(name, conn);
|
|
103
|
+
registerConnectionDialect(conn as unknown as object, dialect ?? _dialect);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @internal — clear named connections (tests). Prefer resetOrmContext(). */
|
|
107
|
+
export function _clearModelConnections(): void {
|
|
108
|
+
currentOrmContext().namedConnections.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal Set an execution-scoped override connection for all models (tests / withDatabase). */
|
|
112
|
+
export function _setBaseModelConnection(conn: SQLInstance | null): void {
|
|
113
|
+
currentOrmContext().overrideConnection = conn;
|
|
114
|
+
if (conn) registerConnectionDialect(conn as unknown as object, _dialect);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @internal Set the active SQL dialect used for identifier quoting and date serialization. */
|
|
118
|
+
export function _setBaseModelDialect(dialect: "sqlite" | "postgres" | "mysql"): void {
|
|
119
|
+
_dialect = dialect;
|
|
120
|
+
_setQueryBuilderDialect(dialect);
|
|
121
|
+
const ov = currentOrmContext().overrideConnection;
|
|
122
|
+
if (ov) {
|
|
123
|
+
registerConnectionDialect(ov as unknown as object, dialect);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @internal Return the active SQL dialect. */
|
|
128
|
+
export function _getDialect(): "sqlite" | "postgres" | "mysql" {
|
|
129
|
+
return _dialect;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns the active connection — override if set, otherwise via the injected resolver.
|
|
134
|
+
*
|
|
135
|
+
* @internal
|
|
136
|
+
*/
|
|
137
|
+
export function _getModelConnection(): SQLInstance {
|
|
138
|
+
const override = currentOrmContext().overrideConnection;
|
|
139
|
+
if (override) return override;
|
|
140
|
+
const conn = resolveContainerConnection();
|
|
141
|
+
if (conn) return conn;
|
|
142
|
+
throw new Error("[Zerotal ORM] No database connection. Is DatabaseProvider registered?");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Context-aware connection resolver — an additive, AsyncLocalStorage-safe hook used
|
|
147
|
+
* by features that route queries to a connection chosen by the *current execution
|
|
148
|
+
* context* rather than a global override. The canonical user is `@zerotal/tenancy`'s
|
|
149
|
+
* multi-database strategy: it returns the active tenant's connection so every model
|
|
150
|
+
* query transparently hits the right database. Returns `null` to defer to the normal
|
|
151
|
+
* resolution. Defaults to a no-op, so it changes nothing until something registers it.
|
|
152
|
+
*/
|
|
153
|
+
export type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null;
|
|
154
|
+
let _contextConnectionResolver: ContextConnectionResolver | null = null;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Register a context-aware connection resolver (pass `null` to clear). Consulted by
|
|
158
|
+
* `_resolveConn` after explicit transactions and `static connection` named bindings,
|
|
159
|
+
* but before the default connection — so transactions and pinned connections still win.
|
|
160
|
+
*/
|
|
161
|
+
export function registerConnectionResolver(fn: ContextConnectionResolver | null): void {
|
|
162
|
+
_contextConnectionResolver = fn;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the connection for a model operation.
|
|
167
|
+
* Priority: ALS transaction > RequestContext._transaction > `static connection` named
|
|
168
|
+
* binding > context resolver (e.g. tenancy multi-db) > configured/default connection.
|
|
169
|
+
* The configured connection itself may be an override set by withDatabase() in tests.
|
|
170
|
+
*
|
|
171
|
+
* @internal
|
|
172
|
+
*/
|
|
173
|
+
export function _resolveConn(ModelClass?: typeof BaseModel): SQLInstance {
|
|
174
|
+
const tx =
|
|
175
|
+
TransactionContext.getStore() ??
|
|
176
|
+
(RequestContext.tryGet()?._transaction as SQLInstance | undefined);
|
|
177
|
+
if (tx) return tx;
|
|
178
|
+
const name = ModelClass?.connection;
|
|
179
|
+
if (name) {
|
|
180
|
+
const named = currentOrmContext().namedConnections.get(name);
|
|
181
|
+
if (named) return named;
|
|
182
|
+
}
|
|
183
|
+
if (_contextConnectionResolver) {
|
|
184
|
+
const resolved = _contextConnectionResolver(ModelClass);
|
|
185
|
+
if (resolved) return resolved;
|
|
186
|
+
}
|
|
187
|
+
return _getModelConnection();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Column-name conversion lives in support/identifiers.ts — one memoized
|
|
191
|
+
// implementation shared with the query builders, whose lookups must produce
|
|
192
|
+
// exactly the property names hydration creates here.
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Format a Date for storage, respecting the active dialect.
|
|
196
|
+
* MySQL DATETIME columns reject ISO 8601 ('T'/'Z') — they require 'YYYY-MM-DD HH:MM:SS'.
|
|
197
|
+
* SQLite and PostgreSQL both accept ISO 8601 as-is.
|
|
198
|
+
*/
|
|
199
|
+
function _serializeDate(date: Date): string {
|
|
200
|
+
const dialect = _writeDialect.getStore() ?? _dialect;
|
|
201
|
+
if (dialect === "mysql") {
|
|
202
|
+
return date.toISOString().replace("T", " ").slice(0, 19);
|
|
203
|
+
}
|
|
204
|
+
return date.toISOString();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function serializeVal(v: unknown): unknown {
|
|
208
|
+
if (v instanceof Carbon) return _serializeDate(v.toDate());
|
|
209
|
+
if (v instanceof Date) return _serializeDate(v);
|
|
210
|
+
return v;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type StringCast =
|
|
214
|
+
| "datetime"
|
|
215
|
+
| "array"
|
|
216
|
+
| "json"
|
|
217
|
+
| "date"
|
|
218
|
+
| "boolean"
|
|
219
|
+
| "integer"
|
|
220
|
+
| "float"
|
|
221
|
+
| "enum"
|
|
222
|
+
| "immutable_datetime"
|
|
223
|
+
| `decimal:${number}`;
|
|
224
|
+
type CastOption = ColumnOptions["cast"];
|
|
225
|
+
|
|
226
|
+
function getCasts(ctor: Function): Record<string, CastOption> {
|
|
227
|
+
const merged: Record<string, CastOption> = {};
|
|
228
|
+
const chain: Function[] = [];
|
|
229
|
+
let current: Function | null = ctor;
|
|
230
|
+
while (current && current !== Function.prototype) {
|
|
231
|
+
chain.push(current);
|
|
232
|
+
current = Object.getPrototypeOf(current) as Function | null;
|
|
233
|
+
}
|
|
234
|
+
chain.reverse();
|
|
235
|
+
for (const entry of chain) {
|
|
236
|
+
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
237
|
+
if (casts) Object.assign(merged, casts);
|
|
238
|
+
}
|
|
239
|
+
return merged;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function applyCastGet(value: unknown, cast: StringCast): unknown {
|
|
243
|
+
if (value === null || value === undefined) return value;
|
|
244
|
+
const cstr = cast as unknown as string;
|
|
245
|
+
if (cstr.startsWith("decimal:")) {
|
|
246
|
+
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
247
|
+
return Number(value).toFixed(n);
|
|
248
|
+
}
|
|
249
|
+
if (cstr === "immutable_datetime") {
|
|
250
|
+
return value instanceof Carbon ? value : new Carbon(value as string | number | Date);
|
|
251
|
+
}
|
|
252
|
+
switch (cast) {
|
|
253
|
+
case "datetime":
|
|
254
|
+
if (value instanceof Carbon) return value;
|
|
255
|
+
return new Carbon(value as string | number | Date);
|
|
256
|
+
case "array":
|
|
257
|
+
case "json":
|
|
258
|
+
if (typeof value === "string") {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(value);
|
|
261
|
+
} catch {
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
case "date":
|
|
267
|
+
if (value instanceof Date) return value;
|
|
268
|
+
if (value instanceof Carbon) return value.toDate();
|
|
269
|
+
if (typeof value === "string" || typeof value === "number") return new Date(value);
|
|
270
|
+
return value;
|
|
271
|
+
case "boolean":
|
|
272
|
+
if (typeof value === "boolean") return value;
|
|
273
|
+
if (typeof value === "number") return value !== 0;
|
|
274
|
+
if (typeof value === "string") return value === "1" || value.toLowerCase() === "true";
|
|
275
|
+
return Boolean(value);
|
|
276
|
+
case "integer":
|
|
277
|
+
return parseInt(String(value), 10);
|
|
278
|
+
case "float":
|
|
279
|
+
return parseFloat(String(value));
|
|
280
|
+
case "enum":
|
|
281
|
+
return value;
|
|
282
|
+
default:
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function tryParseJson(s: string): unknown {
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(s);
|
|
290
|
+
} catch {
|
|
291
|
+
return s;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
296
|
+
if (value === null || value === undefined) return value;
|
|
297
|
+
const cstr = cast as unknown as string;
|
|
298
|
+
if (cstr.startsWith("decimal:")) {
|
|
299
|
+
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
300
|
+
return Number(value).toFixed(n);
|
|
301
|
+
}
|
|
302
|
+
if (cstr === "immutable_datetime") {
|
|
303
|
+
if (value instanceof Carbon) return _serializeDate(value.toDate());
|
|
304
|
+
if (value instanceof Date) return _serializeDate(value);
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
switch (cast) {
|
|
308
|
+
case "datetime":
|
|
309
|
+
if (value instanceof Carbon) return _serializeDate(value.toDate());
|
|
310
|
+
if (value instanceof Date) return _serializeDate(value);
|
|
311
|
+
return value;
|
|
312
|
+
case "array":
|
|
313
|
+
case "json":
|
|
314
|
+
if (typeof value !== "string") return JSON.stringify(value);
|
|
315
|
+
return value;
|
|
316
|
+
case "date":
|
|
317
|
+
if (value instanceof Carbon) return _serializeDate(value.toDate());
|
|
318
|
+
if (value instanceof Date) return _serializeDate(value);
|
|
319
|
+
return value;
|
|
320
|
+
case "boolean":
|
|
321
|
+
return value ? 1 : 0;
|
|
322
|
+
case "integer":
|
|
323
|
+
return parseInt(String(value), 10);
|
|
324
|
+
case "float":
|
|
325
|
+
return parseFloat(String(value));
|
|
326
|
+
case "enum":
|
|
327
|
+
return value;
|
|
328
|
+
default:
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function shouldProxyCast(
|
|
334
|
+
model: typeof BaseModel,
|
|
335
|
+
cast: CastOption | undefined,
|
|
336
|
+
colType: ColumnOptions["type"] | undefined,
|
|
337
|
+
): boolean {
|
|
338
|
+
if (!model.reactiveCasts) return false;
|
|
339
|
+
return cast === "json" || cast === "array" || colType === "json";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Serialize one model property value for a database write.
|
|
344
|
+
*
|
|
345
|
+
* Applies, in priority order: a custom cast object's `set()`, a string
|
|
346
|
+
* shorthand cast ('boolean', 'json', …), then @column type coercions
|
|
347
|
+
* (boolean → 0/1, json → JSON string), and finally dialect-aware date
|
|
348
|
+
* formatting via serializeVal(). Must run inside a `_writeDialect.run()`
|
|
349
|
+
* scope so dates serialize for the right engine.
|
|
350
|
+
*
|
|
351
|
+
* Shared by bulkInsert(), upsert(), and both save() branches.
|
|
352
|
+
*/
|
|
353
|
+
function _serializeForWrite(
|
|
354
|
+
key: string,
|
|
355
|
+
val: unknown,
|
|
356
|
+
casts: Record<string, CastOption>,
|
|
357
|
+
colReg: Map<string, ColumnOptions> | null,
|
|
358
|
+
): unknown {
|
|
359
|
+
const colMeta = colReg?.get(key);
|
|
360
|
+
const castOpt = casts[key] ?? colMeta?.cast;
|
|
361
|
+
const colType = colMeta?.type;
|
|
362
|
+
let serializedVal: unknown;
|
|
363
|
+
if (castOpt && typeof castOpt === "object" && castOpt.set) {
|
|
364
|
+
serializedVal = castOpt.set(val);
|
|
365
|
+
} else if (typeof castOpt === "string") {
|
|
366
|
+
serializedVal = applyCastSet(val, castOpt);
|
|
367
|
+
} else if (colType === "boolean" && val !== null && val !== undefined) {
|
|
368
|
+
serializedVal = val ? 1 : 0;
|
|
369
|
+
} else if (colType === "json" && val !== null && typeof val !== "string") {
|
|
370
|
+
serializedVal = JSON.stringify(val);
|
|
371
|
+
} else {
|
|
372
|
+
serializedVal = val;
|
|
373
|
+
}
|
|
374
|
+
return serializeVal(serializedVal);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
type Seg = string | { val: unknown };
|
|
378
|
+
|
|
379
|
+
// Both delegate to QueryBuilder._runSegments so model writes share the same
|
|
380
|
+
// interned-template cache AND emit the same QueryExecuted telemetry events
|
|
381
|
+
// as builder queries (previously model writes were invisible to the monitor).
|
|
382
|
+
|
|
383
|
+
async function runSegs(conn: SQLInstance, segs: Seg[]): Promise<void> {
|
|
384
|
+
await _runSegments(conn, segs);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function runQuery<T = Record<string, unknown>>(conn: SQLInstance, segs: Seg[]): Promise<T[]> {
|
|
388
|
+
return _runSegments<T>(conn, segs);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const SYSTEM_KEYS = new Set(["id", "createdAt", "updatedAt", "deletedAt"]);
|
|
392
|
+
|
|
393
|
+
type ModelCtor<T extends BaseModel> = typeof BaseModel & { new (): T };
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Return camelCase relation names registered anywhere in a constructor's
|
|
397
|
+
* prototype chain. Walks the chain (like getCasts / _allColumnKeys) so a subclass
|
|
398
|
+
* inherits relations declared on ancestor classes — e.g. mixins applied in layers
|
|
399
|
+
* (`Roles(Permissions(Base))`), where each relation lives on a different
|
|
400
|
+
* class in the chain.
|
|
401
|
+
*/
|
|
402
|
+
function relNames(ctor: Function): Set<string> {
|
|
403
|
+
return new Set(relationsFor(ctor).keys());
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Enumerate own enumerable data properties, skipping:
|
|
408
|
+
* - _private fields
|
|
409
|
+
* - system columns (id, timestamps, softDeletes)
|
|
410
|
+
* - any relation property (getter-guard OR plain undefined from field initialiser)
|
|
411
|
+
*/
|
|
412
|
+
function* ownDataEntries(
|
|
413
|
+
target: object,
|
|
414
|
+
skipKeys: Set<string>,
|
|
415
|
+
rels: Set<string>,
|
|
416
|
+
allowedKeys?: Set<string> | null,
|
|
417
|
+
): Generator<[string, unknown]> {
|
|
418
|
+
for (const key of Object.keys(target)) {
|
|
419
|
+
if (key.startsWith("_")) continue;
|
|
420
|
+
if (skipKeys.has(key)) continue;
|
|
421
|
+
if (rels.has(key)) continue;
|
|
422
|
+
if (allowedKeys && !allowedKeys.has(key)) continue;
|
|
423
|
+
const desc = Object.getOwnPropertyDescriptor(target, key);
|
|
424
|
+
if (desc && typeof desc.get === "function") {
|
|
425
|
+
// Skip lazy-load relation guards, but INCLUDE reactive column accessors
|
|
426
|
+
// (json/array) — they carry a `_zerotal_<key>` backing data property.
|
|
427
|
+
if (!Object.prototype.hasOwnProperty.call(target, `_zerotal_${key}`)) continue;
|
|
428
|
+
}
|
|
429
|
+
yield [key, (target as Record<string, unknown>)[key]];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Collect all @column-registered property names across the full prototype chain
|
|
435
|
+
* of a model class. Returns null when no column registry entries are found at all
|
|
436
|
+
* (rare edge case: a model with zero @column decorators) so callers can fall back
|
|
437
|
+
* to the old unrestricted behaviour.
|
|
438
|
+
*/
|
|
439
|
+
function _allColumnKeys(cls: Function): Set<string> | null {
|
|
440
|
+
const cols = columnsFor(cls);
|
|
441
|
+
return cols ? new Set(cols.keys()) : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* A reusable, applicable query constraint produced by {@link BaseModel.scope}.
|
|
446
|
+
* Its `apply` method mutates a {@link QueryBuilder} to add the scope's clauses.
|
|
447
|
+
*/
|
|
448
|
+
export interface ScopeApplicator {
|
|
449
|
+
apply(query: QueryBuilder): void;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Union of all data-column property names on a model class.
|
|
454
|
+
*
|
|
455
|
+
* Excludes:
|
|
456
|
+
* - Methods / functions
|
|
457
|
+
* - Internal underscore-prefixed properties (`_exists`, `_original`, `_zerotal_*`)
|
|
458
|
+
*
|
|
459
|
+
* Use this to give `fillable`, `hidden`, and `hashable` compile-time safety
|
|
460
|
+
* — TypeScript will catch typos and non-existent column references.
|
|
461
|
+
*
|
|
462
|
+
* @example
|
|
463
|
+
* \@table("users").withTimestamps()
|
|
464
|
+
* export class User extends BaseModel {
|
|
465
|
+
* static fillable: Columns<User>[] = ["name", "email", "role"];
|
|
466
|
+
* static hidden: Columns<User>[] = ["password"];
|
|
467
|
+
* static hashable: Columns<User>[] = ["password"];
|
|
468
|
+
*
|
|
469
|
+
* @column() name!: string;
|
|
470
|
+
* @column() email!: string;
|
|
471
|
+
* @column() password!: string;
|
|
472
|
+
* @column() role?: string;
|
|
473
|
+
* }
|
|
474
|
+
*/
|
|
475
|
+
export type Columns<T> = {
|
|
476
|
+
[K in keyof T & string]: K extends `_${string}`
|
|
477
|
+
? never
|
|
478
|
+
: T[K] extends (...args: any[]) => any
|
|
479
|
+
? never
|
|
480
|
+
: K;
|
|
481
|
+
}[keyof T & string];
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Base class for every Zerotal Active Record model, backed by `Bun.sql`.
|
|
485
|
+
*
|
|
486
|
+
* Subclass it, declare columns with `@column`, and you get querying,
|
|
487
|
+
* persistence, dirty tracking, relationships, serialization, timestamps,
|
|
488
|
+
* lifecycle hooks, and (opt-in) soft deletes — Active Record-style, but
|
|
489
|
+
* fully typed against your model's own properties.
|
|
490
|
+
*
|
|
491
|
+
* @remarks
|
|
492
|
+
* An instance is a row: its enumerable data properties are the attributes.
|
|
493
|
+
* The model snapshots them on load, so {@link isDirty}, {@link $dirty}, and
|
|
494
|
+
* {@link save} write only changed columns (an UPDATE touches dirty columns
|
|
495
|
+
* only; a new instance INSERTs).
|
|
496
|
+
*
|
|
497
|
+
* **Mass assignment is guarded by default.** A model that declares neither
|
|
498
|
+
* {@link fillable} (allowlist) nor {@link guarded} (denylist) rejects every
|
|
499
|
+
* attribute passed to {@link fill} / {@link create} with a
|
|
500
|
+
* {@link MassAssignmentError}, so a stray key from a request body can never
|
|
501
|
+
* reach the database. Use {@link forceFill} / {@link forceCreate} for trusted,
|
|
502
|
+
* framework-internal writes only.
|
|
503
|
+
*
|
|
504
|
+
* **Timestamps** (`created_at` / `updated_at`) are maintained automatically
|
|
505
|
+
* when {@link timestamps} is `true` (the default). The **primary key** is
|
|
506
|
+
* `id` unless {@link primaryKey} is overridden. **Soft deletes** are opt-in
|
|
507
|
+
* via the `SoftDeletes` mixin (which sets {@link softDeletes}); once enabled,
|
|
508
|
+
* queries scope `WHERE deleted_at IS NULL` and {@link delete} sets
|
|
509
|
+
* `deleted_at` instead of removing the row.
|
|
510
|
+
*
|
|
511
|
+
* `@column({ cast })` / {@link casts} coerce values on read and write
|
|
512
|
+
* (booleans, JSON/array, dates via `Carbon`, decimals, enums). {@link hashable}
|
|
513
|
+
* fields are bcrypt-hashed transparently on save. {@link hidden} / {@link visible}
|
|
514
|
+
* / {@link appends} shape {@link toJSON} output.
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* Defining a model with `@column`:
|
|
518
|
+
* ```ts
|
|
519
|
+
* @table("users").withTimestamps()
|
|
520
|
+
* export class User extends BaseModel {
|
|
521
|
+
* static fillable: Columns<User>[] = ["name", "email", "password"];
|
|
522
|
+
* static hidden: Columns<User>[] = ["password"];
|
|
523
|
+
* static hashable = ["password"];
|
|
524
|
+
*
|
|
525
|
+
* @column() name!: string;
|
|
526
|
+
* @column() email!: string;
|
|
527
|
+
* @column() password!: string;
|
|
528
|
+
* @column({ cast: "boolean" }) active?: boolean;
|
|
529
|
+
* }
|
|
530
|
+
* ```
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* Querying, creating, and saving:
|
|
534
|
+
* ```ts
|
|
535
|
+
* // Create (mass-assignment respects `fillable`)
|
|
536
|
+
* const user = await User.create({ name: "Ada", email: "ada@example.com", password: "s3cret" });
|
|
537
|
+
*
|
|
538
|
+
* // Query
|
|
539
|
+
* const admins = await User.where("role", "admin").orderBy("name").get();
|
|
540
|
+
* const found = await User.findOrFail(user.id); // throws ModelNotFoundError if missing
|
|
541
|
+
*
|
|
542
|
+
* // Mutate + persist (only dirty columns are written)
|
|
543
|
+
* found.name = "Ada Lovelace";
|
|
544
|
+
* await found.save();
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
export class BaseModel {
|
|
548
|
+
/**
|
|
549
|
+
* Phantom nominal brand, used ONLY at the type level to detect relation
|
|
550
|
+
* properties (see `ColumnKeys` in payload.ts). Detecting relations by this one
|
|
551
|
+
* marker — rather than structurally via `extends BaseModel` — avoids forcing TS
|
|
552
|
+
* to re-resolve a related model's `fill(data: UpdatePayload<this>)` signature,
|
|
553
|
+
* which is itself defined in terms of `ColumnKeys`. That structural feedback loop
|
|
554
|
+
* is what makes mutually-referential models (A.b: B, B.a: A) trip TS2615
|
|
555
|
+
* ("circularly references itself in mapped type").
|
|
556
|
+
*
|
|
557
|
+
* `declare` => purely type-level, no runtime field is emitted and instances
|
|
558
|
+
* never carry it. The leading underscore keeps it out of column/serialization
|
|
559
|
+
* key sets automatically.
|
|
560
|
+
*/
|
|
561
|
+
declare readonly __isZerotalModel: true;
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Database table this model maps to. Usually set for you by the `@table("…")`
|
|
565
|
+
* decorator; assign directly to override.
|
|
566
|
+
*
|
|
567
|
+
* @category Attributes & mass assignment
|
|
568
|
+
*/
|
|
569
|
+
static table: string;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Primary-key column name. Defaults to `id`.
|
|
573
|
+
*
|
|
574
|
+
* @category Attributes & mass assignment
|
|
575
|
+
*/
|
|
576
|
+
static primaryKey = "id";
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* When `true` (default), `created_at` / `updated_at` are set automatically on
|
|
580
|
+
* insert and `updated_at` is bumped on every update. Toggle off, or wrap a
|
|
581
|
+
* write in {@link withoutTimestamps}, to suppress this.
|
|
582
|
+
*
|
|
583
|
+
* @category Timestamps
|
|
584
|
+
*/
|
|
585
|
+
static timestamps = true;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Whether this model uses soft deletes. Flipped to `true` by the `SoftDeletes`
|
|
589
|
+
* mixin; when set, {@link delete} sets `deleted_at` and queries scope
|
|
590
|
+
* `WHERE deleted_at IS NULL`.
|
|
591
|
+
*
|
|
592
|
+
* @category Soft deletes
|
|
593
|
+
*/
|
|
594
|
+
static softDeletes = false;
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Per-attribute cast map applied on read/write — string shorthands
|
|
598
|
+
* (`"boolean"`, `"json"`, `"array"`, `"date"`, `"datetime"`, `"integer"`,
|
|
599
|
+
* `"float"`, `"decimal:2"`, `"enum"`, `"immutable_datetime"`) or a custom
|
|
600
|
+
* cast object with `get`/`set`. Merged across the prototype chain.
|
|
601
|
+
*
|
|
602
|
+
* @category Attributes & mass assignment
|
|
603
|
+
*/
|
|
604
|
+
static casts?: Record<string, CastOption>;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Wrap `json`/`array` cast columns in a reactive proxy so mutating them in place
|
|
608
|
+
* (`user.meta.count = 99`) marks the column dirty. Defaults to `true`.
|
|
609
|
+
*
|
|
610
|
+
* Off, the failure is silent and looks like success: `_applyRow` stores the same object
|
|
611
|
+
* reference in the instance and in `_original`, and `$dirty()` compares with `!==`, so
|
|
612
|
+
* `user.meta.count = 99; await user.save()` issues no UPDATE and reports no error. The
|
|
613
|
+
* proxy is allocated only for columns actually cast to `json`/`array`.
|
|
614
|
+
*
|
|
615
|
+
* Set `false` for a model where that cost is measurable and every write to a JSON column
|
|
616
|
+
* replaces the whole value (`user.meta = { ...user.meta, count: 99 }`), which dirty
|
|
617
|
+
* tracking sees either way.
|
|
618
|
+
*
|
|
619
|
+
* @category Attributes & mass assignment
|
|
620
|
+
*/
|
|
621
|
+
static reactiveCasts = true;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Whether this model participates in implicit route-model binding (default: true).
|
|
625
|
+
* When on, a route param matching the model name auto-resolves to a loaded instance
|
|
626
|
+
* (e.g. `:user` -> `User.findOrFail(value)`). Set to `false` to opt out.
|
|
627
|
+
*
|
|
628
|
+
* @category Route binding
|
|
629
|
+
*/
|
|
630
|
+
static implicitBinding?: boolean;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Override which route param this model claims for implicit binding. By default a model
|
|
634
|
+
* named `User` binds the `:user` param; set this to claim a different key.
|
|
635
|
+
*
|
|
636
|
+
* @example
|
|
637
|
+
* static implicitBindingKey = "author"; // any :author param resolves via this model
|
|
638
|
+
*
|
|
639
|
+
* @category Route binding
|
|
640
|
+
*/
|
|
641
|
+
static implicitBindingKey?: string;
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Maps lifecycle events to event classes that are dispatched on the app
|
|
645
|
+
* event bus when they fire (via `$dispatchesEvents`). Keys: `creating`, `created`,
|
|
646
|
+
* `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `retrieved`. Each event
|
|
647
|
+
* class is constructed with the model instance and emitted (no-op if no bus is bound).
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* static dispatchesEvents = { created: OrderPlaced, deleted: OrderCancelled };
|
|
651
|
+
*
|
|
652
|
+
* @category Lifecycle & hooks
|
|
653
|
+
*/
|
|
654
|
+
static dispatchesEvents?: Record<string, new (model: unknown) => object>;
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Allowlist of camelCase field names accepted by create() / fill().
|
|
658
|
+
* When set, any key not in this list is rejected with `MassAssignmentError`.
|
|
659
|
+
* Cannot be used together with `guarded`.
|
|
660
|
+
*
|
|
661
|
+
* @category Attributes & mass assignment
|
|
662
|
+
*/
|
|
663
|
+
static fillable?: string[];
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Denylist of camelCase field names blocked from create() / fill().
|
|
667
|
+
* When set, listed keys are rejected; all other keys are accepted.
|
|
668
|
+
* Cannot be used together with `fillable`.
|
|
669
|
+
*
|
|
670
|
+
* @category Attributes & mass assignment
|
|
671
|
+
*/
|
|
672
|
+
static guarded?: string[];
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Disable mass-assignment protection for this model — every attribute passed
|
|
676
|
+
* to `fill()` / `create()` is accepted.
|
|
677
|
+
*
|
|
678
|
+
* Models **guard by default**: when neither `fillable` nor `guarded` is
|
|
679
|
+
* declared, `fill()` rejects every attribute (throwing `MassAssignmentError`)
|
|
680
|
+
* so an unexpected key from a request body can never reach the database. Set
|
|
681
|
+
* this to `true` only for models whose writes never come from user input.
|
|
682
|
+
*
|
|
683
|
+
* @category Attributes & mass assignment
|
|
684
|
+
*/
|
|
685
|
+
static unguarded = false;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Process-wide mass-assignment override. When true, models that declare
|
|
689
|
+
* **neither** `fillable` **nor** `guarded` accept every attribute (an explicit
|
|
690
|
+
* `fillable`/`guarded` list is always honoured regardless).
|
|
691
|
+
*
|
|
692
|
+
* Toggle it around trusted bulk work — seeders, migrations, test setup — with
|
|
693
|
+
* {@link unguard} / {@link reguard}; the default (`false`) keeps request input
|
|
694
|
+
* guarded. This is a global flag, so re-guard promptly in a `finally`.
|
|
695
|
+
*
|
|
696
|
+
* @internal
|
|
697
|
+
*/
|
|
698
|
+
private static _unguardedGlobally = false;
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Turn off mass-assignment guarding process-wide (trusted contexts only).
|
|
702
|
+
*
|
|
703
|
+
* @category Attributes & mass assignment
|
|
704
|
+
*/
|
|
705
|
+
static unguard(): void {
|
|
706
|
+
BaseModel._unguardedGlobally = true;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Restore mass-assignment guarding process-wide.
|
|
711
|
+
*
|
|
712
|
+
* @category Attributes & mass assignment
|
|
713
|
+
*/
|
|
714
|
+
static reguard(): void {
|
|
715
|
+
BaseModel._unguardedGlobally = false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Run `callback` with mass-assignment guarding disabled process-wide,
|
|
720
|
+
* restoring the previous setting afterwards (even on throw).
|
|
721
|
+
*
|
|
722
|
+
* @category Attributes & mass assignment
|
|
723
|
+
*/
|
|
724
|
+
static async withoutGuard<T>(callback: () => T | Promise<T>): Promise<T> {
|
|
725
|
+
BaseModel.unguard();
|
|
726
|
+
try {
|
|
727
|
+
return await callback();
|
|
728
|
+
} finally {
|
|
729
|
+
BaseModel.reguard();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Fields to exclude from `toJSON()` and therefore from any
|
|
735
|
+
* `JSON.stringify()` output — API responses, cache serialisation, etc.
|
|
736
|
+
*
|
|
737
|
+
* List camelCase property names. Nested models serialise independently
|
|
738
|
+
* via their own `toJSON()`, so a parent's `hidden` list does not
|
|
739
|
+
* propagate to relations.
|
|
740
|
+
*
|
|
741
|
+
* @example
|
|
742
|
+
* static hidden = ['password', 'rememberToken'];
|
|
743
|
+
*
|
|
744
|
+
* @category Serialization
|
|
745
|
+
*/
|
|
746
|
+
static hidden: string[] = [];
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Allow-list for serialization. When set (non-empty), `toJSON()` includes
|
|
750
|
+
* ONLY these keys (plus `appends`). Takes precedence over `hidden`.
|
|
751
|
+
*
|
|
752
|
+
* @category Serialization
|
|
753
|
+
*/
|
|
754
|
+
static visible: string[] = [];
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Computed accessor names to include in `toJSON()` output. Each name should
|
|
758
|
+
* resolve to a getter (or plain property) on the instance.
|
|
759
|
+
*
|
|
760
|
+
* @example
|
|
761
|
+
* class User extends BaseModel {
|
|
762
|
+
* static appends = ['fullName'];
|
|
763
|
+
* get fullName() { return `${this.first} ${this.last}`; }
|
|
764
|
+
* }
|
|
765
|
+
*
|
|
766
|
+
* @category Serialization
|
|
767
|
+
*/
|
|
768
|
+
static appends: string[] = [];
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Optional named connection (registered via {@link registerConnection}). When
|
|
772
|
+
* set, queries for this model resolve to that connection instead of the default.
|
|
773
|
+
*
|
|
774
|
+
* @category Querying
|
|
775
|
+
*/
|
|
776
|
+
static connection?: string;
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Fields that are automatically hashed with `Bun.password.hash()` (bcrypt)
|
|
780
|
+
* before every INSERT and whenever the field changes on UPDATE.
|
|
781
|
+
*
|
|
782
|
+
* The hash is applied transparently in `save()` — the plaintext value is
|
|
783
|
+
* never written to the database. Use `Bun.password.verify()` to check
|
|
784
|
+
* a plaintext candidate against the stored hash.
|
|
785
|
+
*
|
|
786
|
+
* @example
|
|
787
|
+
* static hashable = ['password'];
|
|
788
|
+
*
|
|
789
|
+
* // Verify later:
|
|
790
|
+
* const ok = await Bun.password.verify(candidate, user.password);
|
|
791
|
+
*
|
|
792
|
+
* @category Persistence
|
|
793
|
+
*/
|
|
794
|
+
static hashable?: string[];
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Register an observer class for this model.
|
|
798
|
+
* The observer's lifecycle methods (creating, created, updating, …) are
|
|
799
|
+
* wired into the HookRegistry automatically.
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* User.observe(UserObserver); // call once at boot in a ServiceProvider
|
|
803
|
+
*
|
|
804
|
+
* @category Lifecycle & hooks
|
|
805
|
+
*/
|
|
806
|
+
static observe<T extends BaseModel>(
|
|
807
|
+
this: ModelCtor<T>,
|
|
808
|
+
ObserverClass: new () => ModelObserver<T>,
|
|
809
|
+
): void {
|
|
810
|
+
registerObserver<T>(this, ObserverClass);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Primary-key value. Populated after {@link save} inserts a new row, or when
|
|
815
|
+
* the instance is hydrated from the database.
|
|
816
|
+
*
|
|
817
|
+
* @category Attributes & mass assignment
|
|
818
|
+
*/
|
|
819
|
+
id!: number;
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Creation timestamp, set on insert when {@link timestamps} is enabled.
|
|
823
|
+
*
|
|
824
|
+
* @category Timestamps
|
|
825
|
+
*/
|
|
826
|
+
createdAt?: Date;
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Last-update timestamp, bumped on every save when {@link timestamps} is enabled.
|
|
830
|
+
*
|
|
831
|
+
* @category Timestamps
|
|
832
|
+
*/
|
|
833
|
+
updatedAt?: Date;
|
|
834
|
+
|
|
835
|
+
/** @internal Snapshot of attribute values at load/last-save, for dirty tracking. */
|
|
836
|
+
private _original: Record<string, unknown> = {};
|
|
837
|
+
/** @internal Keys force-marked dirty via {@link markDirty}. */
|
|
838
|
+
private _forcedDirty = new Set<string>();
|
|
839
|
+
/**
|
|
840
|
+
* True when this instance was loaded from (or last written to) the database.
|
|
841
|
+
*
|
|
842
|
+
* @internal
|
|
843
|
+
*/
|
|
844
|
+
private _exists = false;
|
|
845
|
+
|
|
846
|
+
// ── Global query scopes ──────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Register a named global scope applied to every query for this model.
|
|
850
|
+
* The callback receives the query builder and constrains it (e.g. a tenant
|
|
851
|
+
* filter). Remove it later with {@link removeGlobalScope}.
|
|
852
|
+
*
|
|
853
|
+
* @category Querying
|
|
854
|
+
*/
|
|
855
|
+
static addGlobalScope<T extends BaseModel>(
|
|
856
|
+
this: ModelCtor<T>,
|
|
857
|
+
name: string,
|
|
858
|
+
callback: GlobalScopeCallback,
|
|
859
|
+
): void {
|
|
860
|
+
const reg = _globalScopeRegistry();
|
|
861
|
+
let scopes = reg.get(this);
|
|
862
|
+
if (!scopes) {
|
|
863
|
+
scopes = new Map();
|
|
864
|
+
reg.set(this, scopes);
|
|
865
|
+
}
|
|
866
|
+
scopes.set(name, callback);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Remove a previously registered global scope by name.
|
|
871
|
+
*
|
|
872
|
+
* @category Querying
|
|
873
|
+
*/
|
|
874
|
+
static removeGlobalScope<T extends BaseModel>(this: ModelCtor<T>, name: string): void {
|
|
875
|
+
_globalScopeRegistry().get(this)?.delete(name);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Register a named connection that models can select via `static connection`.
|
|
880
|
+
* Stored on the current OrmContext (execution-scoped).
|
|
881
|
+
*
|
|
882
|
+
* @category Querying
|
|
883
|
+
*/
|
|
884
|
+
static registerConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
|
|
885
|
+
registerModelConnection(name, conn, dialect);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Run a callback with automatic timestamp updates disabled for this model.
|
|
890
|
+
* Restores the previous setting afterwards (even on throw).
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* await User.withoutTimestamps(() => user.save());
|
|
894
|
+
*
|
|
895
|
+
* @category Timestamps
|
|
896
|
+
*/
|
|
897
|
+
static async withoutTimestamps<R>(callback: () => Promise<R> | R): Promise<R> {
|
|
898
|
+
const prev = this.timestamps;
|
|
899
|
+
this.timestamps = false;
|
|
900
|
+
try {
|
|
901
|
+
return await callback();
|
|
902
|
+
} finally {
|
|
903
|
+
this.timestamps = prev;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* When true, `prune()` permanently deletes rows (forceDelete) rather than
|
|
909
|
+
* soft-deleting them.
|
|
910
|
+
*
|
|
911
|
+
* @category Persistence
|
|
912
|
+
*/
|
|
913
|
+
static massPrune = false;
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Override to return the query selecting records eligible for pruning.
|
|
917
|
+
* Implement this to make a model "prunable" (used by `prune()` and a
|
|
918
|
+
* scheduled `model:prune` task).
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* static prunable() { return this.query().where('created_at', '<', cutoff); }
|
|
922
|
+
*
|
|
923
|
+
* @category Persistence
|
|
924
|
+
*/
|
|
925
|
+
static prunable?<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T>;
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Delete prunable records in chunks. Returns the number of records pruned.
|
|
929
|
+
* Honours `massPrune` (permanent delete) vs. soft delete.
|
|
930
|
+
*
|
|
931
|
+
* @throws {Error} when the model does not define a static `prunable()` method.
|
|
932
|
+
* @category Persistence
|
|
933
|
+
*/
|
|
934
|
+
static async prune<T extends BaseModel>(this: ModelCtor<T>, chunkSize = 1000): Promise<number> {
|
|
935
|
+
if (typeof this.prunable !== "function") {
|
|
936
|
+
throw new Error(`[Zerotal ORM] ${this.name}.prune() requires a static prunable() method.`);
|
|
937
|
+
}
|
|
938
|
+
let total = 0;
|
|
939
|
+
for (;;) {
|
|
940
|
+
const query = (this.prunable as () => ModelQueryBuilder<T>).call(this);
|
|
941
|
+
const rows = await query.limit(chunkSize).get<T>();
|
|
942
|
+
if (rows.length === 0) break;
|
|
943
|
+
for (const row of rows) {
|
|
944
|
+
// massPrune means permanent removal. For soft-delete models that's forceDelete()
|
|
945
|
+
// (from the SoftDeletes mixin); for hard-delete models delete() is already permanent.
|
|
946
|
+
if (this.massPrune && this.softDeletes) {
|
|
947
|
+
await (row as unknown as { forceDelete(): Promise<void> }).forceDelete();
|
|
948
|
+
} else {
|
|
949
|
+
await row.delete();
|
|
950
|
+
}
|
|
951
|
+
total++;
|
|
952
|
+
}
|
|
953
|
+
if (rows.length < chunkSize) break;
|
|
954
|
+
}
|
|
955
|
+
return total;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ── Static query entry points ────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* New model query builder with the soft-delete scope
|
|
962
|
+
* (`deleted_at IS NULL`) applied when the model uses soft deletes.
|
|
963
|
+
* Single source of the scope for every static entry point.
|
|
964
|
+
*
|
|
965
|
+
* @internal
|
|
966
|
+
*/
|
|
967
|
+
private static _newScopedQuery<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
|
|
968
|
+
const qb = new ModelQueryBuilder<T>(this.table, _resolveConn(this), this);
|
|
969
|
+
if (this.softDeletes) qb.whereNull("deleted_at");
|
|
970
|
+
// Everything the caller adds from here on is "theirs", and must be grouped as a unit so an
|
|
971
|
+
// orWhere() cannot split the soft-delete predicate (or a global scope) off its chain.
|
|
972
|
+
qb._markUserWhereStart();
|
|
973
|
+
return qb;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Start a new query builder for this model — the entry point for building
|
|
978
|
+
* `where`/`orderBy`/`with`/etc. chains. Applies the soft-delete scope
|
|
979
|
+
* (`deleted_at IS NULL`) when the model uses soft deletes.
|
|
980
|
+
*
|
|
981
|
+
* @example
|
|
982
|
+
* const posts = await Post.query().where("published", true).orderBy("createdAt", "desc").get();
|
|
983
|
+
*
|
|
984
|
+
* @category Querying
|
|
985
|
+
*/
|
|
986
|
+
static query<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
|
|
987
|
+
return (this as ModelCtor<T>)._newScopedQuery();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* @internal A raw, unscoped model query — no soft-delete (`deleted_at IS NULL`)
|
|
992
|
+
* filter applied. Used by the relation loader to build base related queries
|
|
993
|
+
* (which add their own scoping). The public, soft-delete-aware `withTrashed()` /
|
|
994
|
+
* `onlyTrashed()` live on the `SoftDeletes` mixin.
|
|
995
|
+
*/
|
|
996
|
+
static _unscopedQuery<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
|
|
997
|
+
return new ModelQueryBuilder<T>(this.table, _resolveConn(this), this);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ── Static query shortcuts ─────────────────────────────────────────────────
|
|
1001
|
+
// Fluent static entry points so callers can write Post.where(...) or
|
|
1002
|
+
// User.count() without spelling out .query() first. Each delegates to query(),
|
|
1003
|
+
// so the soft-delete scope and connection resolution are applied consistently.
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Start a query constrained by a `WHERE` clause. Pass `(column, value)` for
|
|
1007
|
+
* equality or `(column, operator, value)` for any other comparison.
|
|
1008
|
+
*
|
|
1009
|
+
* @example
|
|
1010
|
+
* const recent = await Post.where("views", ">=", 100).get();
|
|
1011
|
+
*
|
|
1012
|
+
* @category Querying
|
|
1013
|
+
*/
|
|
1014
|
+
static where<T extends BaseModel>(
|
|
1015
|
+
this: ModelCtor<T>,
|
|
1016
|
+
column: string,
|
|
1017
|
+
value: unknown,
|
|
1018
|
+
): ModelQueryBuilder<T>;
|
|
1019
|
+
static where<T extends BaseModel>(
|
|
1020
|
+
this: ModelCtor<T>,
|
|
1021
|
+
column: string,
|
|
1022
|
+
operator: WhereOperator,
|
|
1023
|
+
value: unknown,
|
|
1024
|
+
): ModelQueryBuilder<T>;
|
|
1025
|
+
static where<T extends BaseModel>(
|
|
1026
|
+
this: ModelCtor<T>,
|
|
1027
|
+
column: string,
|
|
1028
|
+
operatorOrValue: unknown,
|
|
1029
|
+
value?: unknown,
|
|
1030
|
+
): ModelQueryBuilder<T> {
|
|
1031
|
+
const qb = (this as ModelCtor<T>).query();
|
|
1032
|
+
return value === undefined
|
|
1033
|
+
? qb.where(column, operatorOrValue)
|
|
1034
|
+
: qb.where(column, operatorOrValue as WhereOperator, value);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Start a query constrained by `WHERE column IN (values)`.
|
|
1039
|
+
*
|
|
1040
|
+
* @category Querying
|
|
1041
|
+
*/
|
|
1042
|
+
static whereIn<T extends BaseModel>(
|
|
1043
|
+
this: ModelCtor<T>,
|
|
1044
|
+
column: string,
|
|
1045
|
+
values: unknown[],
|
|
1046
|
+
): ModelQueryBuilder<T> {
|
|
1047
|
+
return (this as ModelCtor<T>).query().whereIn(column, values);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Start a query ordered by `column` (default direction `"asc"`).
|
|
1052
|
+
*
|
|
1053
|
+
* @category Querying
|
|
1054
|
+
*/
|
|
1055
|
+
static orderBy<T extends BaseModel>(
|
|
1056
|
+
this: ModelCtor<T>,
|
|
1057
|
+
column: string,
|
|
1058
|
+
direction: OrderDirection = "asc",
|
|
1059
|
+
): ModelQueryBuilder<T> {
|
|
1060
|
+
return (this as ModelCtor<T>).query().orderBy(column, direction);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Start a query ordered newest-first by `column` (default `"created_at"`).
|
|
1065
|
+
*
|
|
1066
|
+
* @category Querying
|
|
1067
|
+
*/
|
|
1068
|
+
static latest<T extends BaseModel>(
|
|
1069
|
+
this: ModelCtor<T>,
|
|
1070
|
+
column = "created_at",
|
|
1071
|
+
): ModelQueryBuilder<T> {
|
|
1072
|
+
return (this as ModelCtor<T>).query().latest(column);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Start a query ordered oldest-first by `column` (default `"created_at"`).
|
|
1077
|
+
*
|
|
1078
|
+
* @category Querying
|
|
1079
|
+
*/
|
|
1080
|
+
static oldest<T extends BaseModel>(
|
|
1081
|
+
this: ModelCtor<T>,
|
|
1082
|
+
column = "created_at",
|
|
1083
|
+
): ModelQueryBuilder<T> {
|
|
1084
|
+
return (this as ModelCtor<T>).query().oldest(column);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Fetch the first row, or `null` when none match.
|
|
1089
|
+
*
|
|
1090
|
+
* @category Querying
|
|
1091
|
+
*/
|
|
1092
|
+
static first<T extends BaseModel>(this: ModelCtor<T>): Promise<T | null> {
|
|
1093
|
+
return (this as ModelCtor<T>).query().first<T>();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Fetch the first row, or throw when none match.
|
|
1098
|
+
*
|
|
1099
|
+
* @throws {ModelNotFoundError} when no row matches.
|
|
1100
|
+
* @category Querying
|
|
1101
|
+
*/
|
|
1102
|
+
static firstOrFail<T extends BaseModel>(this: ModelCtor<T>): Promise<T> {
|
|
1103
|
+
return (this as ModelCtor<T>).query().firstOrFail() as Promise<T>;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Count all rows (subject to any global/soft-delete scopes).
|
|
1108
|
+
*
|
|
1109
|
+
* @category Querying
|
|
1110
|
+
*/
|
|
1111
|
+
static count<T extends BaseModel>(this: ModelCtor<T>): Promise<number> {
|
|
1112
|
+
return (this as ModelCtor<T>).query().count();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Find a single row by primary key, or `null` when not found.
|
|
1117
|
+
*
|
|
1118
|
+
* @category Querying
|
|
1119
|
+
*/
|
|
1120
|
+
static async find<T extends BaseModel>(
|
|
1121
|
+
this: ModelCtor<T>,
|
|
1122
|
+
id: number | string,
|
|
1123
|
+
): Promise<T | null> {
|
|
1124
|
+
return (this as ModelCtor<T>)._newScopedQuery().where(this.primaryKey, id).first<T>();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Find a single row by primary key, or throw when not found.
|
|
1129
|
+
*
|
|
1130
|
+
* @throws {ModelNotFoundError} when no row has the given primary key.
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* const user = await User.findOrFail(ctx.integer("id"));
|
|
1134
|
+
*
|
|
1135
|
+
* @category Querying
|
|
1136
|
+
*/
|
|
1137
|
+
static async findOrFail<T extends BaseModel>(
|
|
1138
|
+
this: ModelCtor<T>,
|
|
1139
|
+
id: number | string,
|
|
1140
|
+
): Promise<T> {
|
|
1141
|
+
const inst = await (this as ModelCtor<T>).find(id);
|
|
1142
|
+
if (inst === null) throw new ModelNotFoundError(this.name, id);
|
|
1143
|
+
return inst;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Find the first row where `column` equals `value` (the column name is
|
|
1148
|
+
* converted to snake_case), or `null` when none match.
|
|
1149
|
+
*
|
|
1150
|
+
* @category Querying
|
|
1151
|
+
*/
|
|
1152
|
+
static async findBy<T extends BaseModel>(
|
|
1153
|
+
this: ModelCtor<T>,
|
|
1154
|
+
column: string,
|
|
1155
|
+
value: unknown,
|
|
1156
|
+
): Promise<T | null> {
|
|
1157
|
+
return (this as ModelCtor<T>)._newScopedQuery().where(toSnake(column), value).first<T>();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Fetch every row for this model (subject to global/soft-delete scopes).
|
|
1162
|
+
*
|
|
1163
|
+
* @category Querying
|
|
1164
|
+
*/
|
|
1165
|
+
static async all<T extends BaseModel>(this: ModelCtor<T>): Promise<T[]> {
|
|
1166
|
+
return (this as ModelCtor<T>)._newScopedQuery().get<T>();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Fetch one page of rows (subject to global/soft-delete scopes).
|
|
1171
|
+
*
|
|
1172
|
+
* The page comes from the request in flight — the `?page=` query string, or whatever a
|
|
1173
|
+
* server-driven view registered instead — so a controller or a Flow page reads
|
|
1174
|
+
* `Post.paginate(10)` and gets the page the user is actually on. Pass `page` to override.
|
|
1175
|
+
*
|
|
1176
|
+
* @param perPage - Rows per page. Defaults to 15.
|
|
1177
|
+
* @param page - 1-based page. Omit to use the request's current page.
|
|
1178
|
+
* @param pageName - Which paginator to read, so one page can drive several. Defaults to `"page"`.
|
|
1179
|
+
*
|
|
1180
|
+
* @example
|
|
1181
|
+
* ```ts
|
|
1182
|
+
* const posts = await Post.paginate(10); // ?page= (or the view's page)
|
|
1183
|
+
* const invoices = await Invoice.paginate(10, undefined, "invoices"); // a second paginator
|
|
1184
|
+
* ```
|
|
1185
|
+
*
|
|
1186
|
+
* @category Querying
|
|
1187
|
+
*/
|
|
1188
|
+
static async paginate<T extends BaseModel>(
|
|
1189
|
+
this: ModelCtor<T>,
|
|
1190
|
+
perPage = 15,
|
|
1191
|
+
page?: number,
|
|
1192
|
+
pageName = "page",
|
|
1193
|
+
): Promise<PaginateResult<T>> {
|
|
1194
|
+
return (this as ModelCtor<T>)._newScopedQuery().paginate<T>(perPage, page, pageName);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
|
|
1199
|
+
* instance and {@link save} it, returning the persisted model.
|
|
1200
|
+
*
|
|
1201
|
+
* @throws {MassAssignmentError} when `data` contains a non-fillable key.
|
|
1202
|
+
*
|
|
1203
|
+
* @example
|
|
1204
|
+
* const user = await User.create({ name: "Ada", email: "ada@example.com" });
|
|
1205
|
+
*
|
|
1206
|
+
* @category Persistence
|
|
1207
|
+
*/
|
|
1208
|
+
static async create<T extends BaseModel>(this: ModelCtor<T>, data: InsertPayload<T>): Promise<T> {
|
|
1209
|
+
const inst = new this();
|
|
1210
|
+
inst.fill(data as UpdatePayload<T>);
|
|
1211
|
+
return inst.save() as Promise<T>;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Mass-assign fields, respecting `fillable` / `guarded` protection.
|
|
1216
|
+
* Call this instead of `Object.assign` when data comes from user input.
|
|
1217
|
+
*
|
|
1218
|
+
* **Guarded by default:** a model that declares neither `fillable` nor
|
|
1219
|
+
* `guarded` (and is not `unguarded`) rejects every attribute with a
|
|
1220
|
+
* `MassAssignmentError`, so a stray key from a request body can never reach
|
|
1221
|
+
* the database. Declare `fillable` to allow specific columns, or use
|
|
1222
|
+
* {@link forceFill} for trusted, framework-internal writes.
|
|
1223
|
+
*
|
|
1224
|
+
* Accepts `UpdatePayload<this>` — a partial of the model's writable,
|
|
1225
|
+
* non-relation, non-auto-managed columns. Passing `id`, `createdAt`,
|
|
1226
|
+
* relations, or methods is a compile-time error.
|
|
1227
|
+
*
|
|
1228
|
+
* @example
|
|
1229
|
+
* post.fill(ctx.body<UpdatePayload<Post>>());
|
|
1230
|
+
*
|
|
1231
|
+
* @throws {MassAssignmentError} when `data` contains a key not permitted by
|
|
1232
|
+
* this model's `fillable` / `guarded` configuration.
|
|
1233
|
+
* @category Attributes & mass assignment
|
|
1234
|
+
*/
|
|
1235
|
+
fill(data: UpdatePayload<this>): this {
|
|
1236
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1237
|
+
// Reactive (json/array) columns are registered at decoration time; install their
|
|
1238
|
+
// per-instance accessors now so assignments below go through the reactive setter.
|
|
1239
|
+
installReactiveAccessors(this);
|
|
1240
|
+
|
|
1241
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
1242
|
+
if (!ModelClass._isFillable(key)) {
|
|
1243
|
+
throw new MassAssignmentError(ModelClass.name, key);
|
|
1244
|
+
}
|
|
1245
|
+
(this as unknown as Record<string, unknown>)[key] = value;
|
|
1246
|
+
}
|
|
1247
|
+
return this;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* True when `key` may be mass-assigned given this model's `fillable` /
|
|
1252
|
+
* `guarded` / `unguarded` configuration. Precedence: `unguarded` (all) →
|
|
1253
|
+
* `fillable` (allowlist) → `guarded` (denylist) → guarded-by-default (none).
|
|
1254
|
+
*
|
|
1255
|
+
* @internal
|
|
1256
|
+
*/
|
|
1257
|
+
static _isFillable(key: string): boolean {
|
|
1258
|
+
// An explicit allow/deny list is always honoured, even under global unguard.
|
|
1259
|
+
if (this.fillable !== undefined) return this.fillable.includes(key);
|
|
1260
|
+
if (this.guarded !== undefined) return !this.guarded.includes(key);
|
|
1261
|
+
if (this.unguarded || BaseModel._unguardedGlobally) return true;
|
|
1262
|
+
// Neither list declared and not unguarded → guard everything.
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Mass-assign fields **bypassing** `fillable` / `guarded` protection.
|
|
1268
|
+
* Use only for trusted data you construct yourself (framework-internal
|
|
1269
|
+
* writes, factories, seeders) — never for request input.
|
|
1270
|
+
*
|
|
1271
|
+
* @example
|
|
1272
|
+
* // Trusted, non-user data:
|
|
1273
|
+
* role.forceFill({ name, guard });
|
|
1274
|
+
*
|
|
1275
|
+
* @category Attributes & mass assignment
|
|
1276
|
+
*/
|
|
1277
|
+
forceFill(data: Record<string, unknown>): this {
|
|
1278
|
+
installReactiveAccessors(this);
|
|
1279
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1280
|
+
(this as unknown as Record<string, unknown>)[key] = value;
|
|
1281
|
+
}
|
|
1282
|
+
return this;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Like {@link create}, but bypasses mass-assignment protection. Use only for
|
|
1287
|
+
* trusted data (framework-internal writes, seeders), never for request input.
|
|
1288
|
+
*
|
|
1289
|
+
* @category Persistence
|
|
1290
|
+
*/
|
|
1291
|
+
static async forceCreate<T extends BaseModel>(
|
|
1292
|
+
this: ModelCtor<T>,
|
|
1293
|
+
data: Record<string, unknown>,
|
|
1294
|
+
): Promise<T> {
|
|
1295
|
+
const inst = new this();
|
|
1296
|
+
inst.forceFill(data);
|
|
1297
|
+
return inst.save() as Promise<T>;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Return the first row matching `search`, or {@link create} one from
|
|
1302
|
+
* `search` merged with `create`.
|
|
1303
|
+
*
|
|
1304
|
+
* @throws {MassAssignmentError} when a created key is not fillable.
|
|
1305
|
+
* @category Persistence
|
|
1306
|
+
*/
|
|
1307
|
+
static async firstOrCreate<T extends BaseModel>(
|
|
1308
|
+
this: ModelCtor<T>,
|
|
1309
|
+
search: UpdatePayload<T>,
|
|
1310
|
+
create?: UpdatePayload<T>,
|
|
1311
|
+
): Promise<T> {
|
|
1312
|
+
const qb = (this as ModelCtor<T>)._newScopedQuery();
|
|
1313
|
+
for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
|
|
1314
|
+
qb.where(toSnake(k), v);
|
|
1315
|
+
}
|
|
1316
|
+
const existing = await qb.first<T>();
|
|
1317
|
+
if (existing) return existing;
|
|
1318
|
+
return (this as ModelCtor<T>).create<T>({
|
|
1319
|
+
...search,
|
|
1320
|
+
...(create ?? {}),
|
|
1321
|
+
} as InsertPayload<T>);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Update the first row matching `search`, or create it. Returns the model.
|
|
1326
|
+
*
|
|
1327
|
+
* @example
|
|
1328
|
+
* await User.updateOrCreate({ email }, { name, lastSeenAt: new Date() });
|
|
1329
|
+
*
|
|
1330
|
+
* @throws {MassAssignmentError} when an updated/created key is not fillable.
|
|
1331
|
+
* @category Persistence
|
|
1332
|
+
*/
|
|
1333
|
+
static async updateOrCreate<T extends BaseModel>(
|
|
1334
|
+
this: ModelCtor<T>,
|
|
1335
|
+
search: UpdatePayload<T>,
|
|
1336
|
+
values?: UpdatePayload<T>,
|
|
1337
|
+
): Promise<T> {
|
|
1338
|
+
const qb = (this as ModelCtor<T>)._newScopedQuery();
|
|
1339
|
+
for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
|
|
1340
|
+
qb.where(toSnake(k), v);
|
|
1341
|
+
}
|
|
1342
|
+
const existing = await qb.first<T>();
|
|
1343
|
+
if (existing) {
|
|
1344
|
+
existing.fill((values ?? {}) as UpdatePayload<T>);
|
|
1345
|
+
return existing.save() as Promise<T>;
|
|
1346
|
+
}
|
|
1347
|
+
return (this as ModelCtor<T>).create<T>({
|
|
1348
|
+
...search,
|
|
1349
|
+
...(values ?? {}),
|
|
1350
|
+
} as InsertPayload<T>);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Return the first row matching `search`, or a new **unsaved** instance
|
|
1355
|
+
* filled with `search` + `values`.
|
|
1356
|
+
*
|
|
1357
|
+
* @throws {MassAssignmentError} when a filled key is not fillable.
|
|
1358
|
+
* @category Persistence
|
|
1359
|
+
*/
|
|
1360
|
+
static async firstOrNew<T extends BaseModel>(
|
|
1361
|
+
this: ModelCtor<T>,
|
|
1362
|
+
search: UpdatePayload<T>,
|
|
1363
|
+
values?: UpdatePayload<T>,
|
|
1364
|
+
): Promise<T> {
|
|
1365
|
+
const qb = (this as ModelCtor<T>)._newScopedQuery();
|
|
1366
|
+
for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
|
|
1367
|
+
qb.where(toSnake(k), v);
|
|
1368
|
+
}
|
|
1369
|
+
const existing = await qb.first<T>();
|
|
1370
|
+
if (existing) return existing;
|
|
1371
|
+
const inst = new this();
|
|
1372
|
+
inst.fill({ ...search, ...(values ?? {}) } as UpdatePayload<T>);
|
|
1373
|
+
return inst;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Find by primary key, or return a new **unsaved** instance if not found.
|
|
1378
|
+
*
|
|
1379
|
+
* @category Persistence
|
|
1380
|
+
*/
|
|
1381
|
+
static async findOrNew<T extends BaseModel>(this: ModelCtor<T>, id: number | string): Promise<T> {
|
|
1382
|
+
const found = await (this as ModelCtor<T>).find(id);
|
|
1383
|
+
if (found) return found;
|
|
1384
|
+
return new this();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Create multiple rows one at a time, returning the saved model instances.
|
|
1389
|
+
* Each row goes through the full `save()` path — casts, hashing, timestamps,
|
|
1390
|
+
* and observer/hook events all fire per row.
|
|
1391
|
+
*
|
|
1392
|
+
* For large bulk loads where per-row events are not needed, prefer
|
|
1393
|
+
* {@link bulkInsert} which issues a single multi-row INSERT.
|
|
1394
|
+
*
|
|
1395
|
+
* @throws {MassAssignmentError} when any record contains a non-fillable key.
|
|
1396
|
+
* @category Persistence
|
|
1397
|
+
*/
|
|
1398
|
+
static async createMany<T extends BaseModel>(
|
|
1399
|
+
this: ModelCtor<T>,
|
|
1400
|
+
records: InsertPayload<T>[],
|
|
1401
|
+
): Promise<T[]> {
|
|
1402
|
+
const out: T[] = [];
|
|
1403
|
+
for (const r of records) out.push(await (this as ModelCtor<T>).create<T>(r));
|
|
1404
|
+
return out;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Insert many rows in a single multi-row `INSERT`, returning the number of
|
|
1409
|
+
* rows written. Applies casts and timestamps but bypasses per-row `save()`
|
|
1410
|
+
* lifecycle hooks — the fast path for bulk loads. Use {@link createMany} when
|
|
1411
|
+
* you need hooks/observers per row.
|
|
1412
|
+
*
|
|
1413
|
+
* @returns the number of rows inserted (`0` for an empty input).
|
|
1414
|
+
* @category Persistence
|
|
1415
|
+
*/
|
|
1416
|
+
static async bulkInsert<T extends BaseModel>(
|
|
1417
|
+
this: ModelCtor<T>,
|
|
1418
|
+
records: InsertPayload<T>[],
|
|
1419
|
+
): Promise<number> {
|
|
1420
|
+
if (records.length === 0) return 0;
|
|
1421
|
+
|
|
1422
|
+
const ModelClass = this as unknown as typeof BaseModel;
|
|
1423
|
+
const conn = _resolveConn(ModelClass);
|
|
1424
|
+
const dialect = dialectFor(conn as unknown as object);
|
|
1425
|
+
const casts = getCasts(ModelClass as unknown as Function);
|
|
1426
|
+
const colReg = columnsFor(ModelClass as unknown as Function);
|
|
1427
|
+
const useTs = ModelClass.timestamps;
|
|
1428
|
+
|
|
1429
|
+
const rows: Record<string, unknown>[] = _writeDialect.run(dialect, () => {
|
|
1430
|
+
const now = _serializeDate(new Date());
|
|
1431
|
+
return records.map((rec) => {
|
|
1432
|
+
const row: Record<string, unknown> = {};
|
|
1433
|
+
for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
|
|
1434
|
+
if (key.startsWith("_")) continue;
|
|
1435
|
+
row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1436
|
+
}
|
|
1437
|
+
if (useTs) {
|
|
1438
|
+
row["created_at"] = now;
|
|
1439
|
+
row["updated_at"] = now;
|
|
1440
|
+
}
|
|
1441
|
+
return row;
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
const colSet = new Set<string>();
|
|
1446
|
+
for (const r of rows) for (const k of Object.keys(r)) colSet.add(k);
|
|
1447
|
+
const cols = [...colSet];
|
|
1448
|
+
for (const c of cols) _assertIdentifier(c, "bulkInsert()");
|
|
1449
|
+
|
|
1450
|
+
const segs: Seg[] = [`INSERT INTO ${ModelClass.table} (${cols.join(", ")}) VALUES `];
|
|
1451
|
+
rows.forEach((r, ri) => {
|
|
1452
|
+
if (ri > 0) segs.push(", ");
|
|
1453
|
+
segs.push("(");
|
|
1454
|
+
cols.forEach((c, ci) => {
|
|
1455
|
+
if (ci > 0) segs.push(", ");
|
|
1456
|
+
segs.push({ val: c in r ? r[c] : null });
|
|
1457
|
+
});
|
|
1458
|
+
segs.push(")");
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
await runSegs(conn, segs);
|
|
1462
|
+
return rows.length;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Persist multiple already-built instances (each via {@link save}).
|
|
1467
|
+
*
|
|
1468
|
+
* @category Persistence
|
|
1469
|
+
*/
|
|
1470
|
+
static async saveMany<T extends BaseModel>(this: ModelCtor<T>, models: T[]): Promise<T[]> {
|
|
1471
|
+
for (const m of models) await m.save();
|
|
1472
|
+
return models;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Fetch many rows by an array of primary keys.
|
|
1477
|
+
*
|
|
1478
|
+
* @category Querying
|
|
1479
|
+
*/
|
|
1480
|
+
static async findMany<T extends BaseModel>(
|
|
1481
|
+
this: ModelCtor<T>,
|
|
1482
|
+
ids: (number | string)[],
|
|
1483
|
+
): Promise<T[]> {
|
|
1484
|
+
if (ids.length === 0) return [];
|
|
1485
|
+
return (this as ModelCtor<T>)._newScopedQuery().whereIn(this.primaryKey, ids).get<T>();
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* INSERT a row; update specified columns when the unique constraint fires.
|
|
1490
|
+
*
|
|
1491
|
+
* - PostgreSQL / SQLite: `ON CONFLICT (conflictKeys) DO UPDATE SET …`
|
|
1492
|
+
* - MySQL: `ON DUPLICATE KEY UPDATE … = VALUES(…)`
|
|
1493
|
+
*
|
|
1494
|
+
* @param data Row data (camelCase keys are converted to snake_case)
|
|
1495
|
+
* @param conflictKeys Columns that define the conflict constraint (ignored on MySQL)
|
|
1496
|
+
* @param updateCols Columns to overwrite on conflict (defaults to all non-conflict cols)
|
|
1497
|
+
*
|
|
1498
|
+
* @example
|
|
1499
|
+
* await User.upsert({ email: 'a@b.com', name: 'Alice' }, ['email'], ['name']);
|
|
1500
|
+
*
|
|
1501
|
+
* @category Persistence
|
|
1502
|
+
*/
|
|
1503
|
+
static async upsert<T extends BaseModel>(
|
|
1504
|
+
this: ModelCtor<T>,
|
|
1505
|
+
data: InsertPayload<T>,
|
|
1506
|
+
conflictKeys: (keyof T & string)[],
|
|
1507
|
+
updateCols?: (keyof T & string)[],
|
|
1508
|
+
): Promise<void> {
|
|
1509
|
+
const conn = _resolveConn(this);
|
|
1510
|
+
const dialect = dialectFor(conn as unknown as object);
|
|
1511
|
+
const casts = getCasts(this as unknown as Function);
|
|
1512
|
+
const colReg = columnsFor(this as unknown as Function);
|
|
1513
|
+
|
|
1514
|
+
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1515
|
+
const r: Record<string, unknown> = {};
|
|
1516
|
+
for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
|
|
1517
|
+
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1518
|
+
}
|
|
1519
|
+
return r;
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
const cols = Object.keys(row);
|
|
1523
|
+
const vals = Object.values(row);
|
|
1524
|
+
if (cols.length === 0) return;
|
|
1525
|
+
|
|
1526
|
+
const conflictSnake = conflictKeys.map(toSnake);
|
|
1527
|
+
const targetCols = updateCols
|
|
1528
|
+
? updateCols.map(toSnake)
|
|
1529
|
+
: cols.filter((c) => !conflictSnake.includes(c));
|
|
1530
|
+
|
|
1531
|
+
if (targetCols.length === 0) return;
|
|
1532
|
+
|
|
1533
|
+
for (const c of cols) _assertIdentifier(c, "upsert()");
|
|
1534
|
+
for (const c of conflictSnake) _assertIdentifier(c, "upsert()");
|
|
1535
|
+
for (const c of targetCols) _assertIdentifier(c, "upsert()");
|
|
1536
|
+
|
|
1537
|
+
const segs: Seg[] = [`INSERT INTO ${this.table} (${cols.join(", ")}) VALUES (`];
|
|
1538
|
+
vals.forEach((v, i) => {
|
|
1539
|
+
if (i > 0) segs.push(", ");
|
|
1540
|
+
segs.push({ val: v });
|
|
1541
|
+
});
|
|
1542
|
+
segs.push(")");
|
|
1543
|
+
|
|
1544
|
+
if (dialect === "mysql") {
|
|
1545
|
+
segs.push(" ON DUPLICATE KEY UPDATE ");
|
|
1546
|
+
targetCols.forEach((col, i) => {
|
|
1547
|
+
if (i > 0) segs.push(", ");
|
|
1548
|
+
segs.push(`${col} = VALUES(${col})`);
|
|
1549
|
+
});
|
|
1550
|
+
} else {
|
|
1551
|
+
// PostgreSQL + SQLite
|
|
1552
|
+
segs.push(` ON CONFLICT (${conflictSnake.join(", ")}) DO UPDATE SET `);
|
|
1553
|
+
targetCols.forEach((col, i) => {
|
|
1554
|
+
if (i > 0) segs.push(", ");
|
|
1555
|
+
segs.push(`${col} = EXCLUDED.${col}`);
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
await runSegs(conn, segs);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Define a named query scope with typed arguments.
|
|
1564
|
+
* Assign to a static property; apply via .withScopes(s => s.active()).
|
|
1565
|
+
*
|
|
1566
|
+
* @example
|
|
1567
|
+
* static active = BaseModel.scope((q) => q.where('active', 1));
|
|
1568
|
+
* static byScore = BaseModel.scope((q, min: number) => q.where('score', '>=', min));
|
|
1569
|
+
*
|
|
1570
|
+
* @category Querying
|
|
1571
|
+
*/
|
|
1572
|
+
static scope<Args extends unknown[]>(
|
|
1573
|
+
fn: (query: QueryBuilder, ...args: Args) => void,
|
|
1574
|
+
): (...args: Args) => ScopeApplicator {
|
|
1575
|
+
return (...args: Args) => ({ apply: (q: QueryBuilder) => fn(q, ...args) });
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// ── Instance methods ─────────────────────────────────────────────────────
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Persist this instance: `INSERT` when new, or `UPDATE` of only the dirty
|
|
1582
|
+
* columns when it already exists. Applies casts, hashes {@link hashable}
|
|
1583
|
+
* fields, maintains timestamps, and runs the save/create/update lifecycle
|
|
1584
|
+
* hooks. On insert the new primary key (and full row) are read back onto the
|
|
1585
|
+
* instance. Returns `this`.
|
|
1586
|
+
*
|
|
1587
|
+
* @example
|
|
1588
|
+
* const user = new User();
|
|
1589
|
+
* user.fill({ name: "Ada", email: "ada@example.com" });
|
|
1590
|
+
* await user.save();
|
|
1591
|
+
*
|
|
1592
|
+
* @category Persistence
|
|
1593
|
+
*/
|
|
1594
|
+
async save(): Promise<this> {
|
|
1595
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1596
|
+
const conn = _resolveConn(ModelClass);
|
|
1597
|
+
const dialect = dialectFor(conn as unknown as object);
|
|
1598
|
+
const rels = relNames(ModelClass as unknown as Function);
|
|
1599
|
+
const casts = getCasts(ModelClass as unknown as Function);
|
|
1600
|
+
|
|
1601
|
+
await HookRegistry.run(ModelClass, "beforeSave", this);
|
|
1602
|
+
|
|
1603
|
+
// ── Auto-hash hashable fields ─────────────────────────────────────────
|
|
1604
|
+
// On INSERT: hash every hashable field that holds a non-empty string.
|
|
1605
|
+
// On UPDATE: only hash hashable fields whose value changed since the last
|
|
1606
|
+
// save (avoids re-hashing an already-stored bcrypt hash).
|
|
1607
|
+
const hashable = ModelClass.hashable;
|
|
1608
|
+
if (hashable && hashable.length > 0) {
|
|
1609
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1610
|
+
if (!this._exists) {
|
|
1611
|
+
for (const key of hashable) {
|
|
1612
|
+
const val = self[key];
|
|
1613
|
+
if (typeof val === "string" && val.length > 0) {
|
|
1614
|
+
self[key] = await Bun.password.hash(val);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
const orig = this._original as Record<string, unknown>;
|
|
1619
|
+
for (const key of hashable) {
|
|
1620
|
+
const current = self[key];
|
|
1621
|
+
if (typeof current === "string" && current.length > 0 && current !== orig[key]) {
|
|
1622
|
+
self[key] = await Bun.password.hash(current);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const colReg = columnsFor(ModelClass as unknown as Function);
|
|
1629
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as Function);
|
|
1630
|
+
|
|
1631
|
+
if (!this._exists) {
|
|
1632
|
+
// ── INSERT ──
|
|
1633
|
+
await HookRegistry.run(ModelClass, "beforeCreate", this);
|
|
1634
|
+
|
|
1635
|
+
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1636
|
+
const r: Record<string, unknown> = {};
|
|
1637
|
+
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
1638
|
+
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1639
|
+
}
|
|
1640
|
+
if (ModelClass.timestamps) {
|
|
1641
|
+
const now = _serializeDate(new Date());
|
|
1642
|
+
r["created_at"] = now;
|
|
1643
|
+
r["updated_at"] = now;
|
|
1644
|
+
}
|
|
1645
|
+
return r;
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
const cols = Object.keys(row);
|
|
1649
|
+
const vals = Object.values(row);
|
|
1650
|
+
for (const c of cols) _assertIdentifier(c, "save()");
|
|
1651
|
+
const segs: Seg[] = [`INSERT INTO ${ModelClass.table} (${cols.join(", ")}) VALUES (`];
|
|
1652
|
+
vals.forEach((v, i) => {
|
|
1653
|
+
if (i > 0) segs.push(", ");
|
|
1654
|
+
segs.push({ val: v });
|
|
1655
|
+
});
|
|
1656
|
+
segs.push(")");
|
|
1657
|
+
|
|
1658
|
+
let newId: number;
|
|
1659
|
+
if (dialect === "postgres") {
|
|
1660
|
+
segs.push(` RETURNING ${ModelClass.primaryKey}`);
|
|
1661
|
+
const [returning] = await runQuery<Record<string, number>>(conn, segs);
|
|
1662
|
+
newId = returning![ModelClass.primaryKey as string] as number;
|
|
1663
|
+
} else if (dialect === "mysql") {
|
|
1664
|
+
// LAST_INSERT_ID() is scoped per connection, so the INSERT and the
|
|
1665
|
+
// SELECT must be pinned to the SAME connection — under concurrency a
|
|
1666
|
+
// pool may otherwise hand the SELECT to a different connection and
|
|
1667
|
+
// return another insert's id. Inside a transaction the connection is
|
|
1668
|
+
// already pinned; otherwise reserve a dedicated connection from the
|
|
1669
|
+
// pool (falling back to a short transaction when reserve() is
|
|
1670
|
+
// unavailable).
|
|
1671
|
+
// _serializeDate() guarantees MySQL DATETIME format ('YYYY-MM-DD HH:MM:SS').
|
|
1672
|
+
const insertAndReadId = async (c: SQLInstance): Promise<number> => {
|
|
1673
|
+
await runSegs(c, segs);
|
|
1674
|
+
const [lastRow] = await runQuery<{ id: number }>(c, ["SELECT LAST_INSERT_ID() as id"]);
|
|
1675
|
+
return lastRow!.id;
|
|
1676
|
+
};
|
|
1677
|
+
const inTx =
|
|
1678
|
+
TransactionContext.getStore() !== undefined ||
|
|
1679
|
+
RequestContext.tryGet()?._transaction !== undefined;
|
|
1680
|
+
const reservable = conn as unknown as {
|
|
1681
|
+
reserve?: () => Promise<SQLInstance & { release(): void }>;
|
|
1682
|
+
};
|
|
1683
|
+
if (inTx) {
|
|
1684
|
+
newId = await insertAndReadId(conn);
|
|
1685
|
+
} else if (typeof reservable.reserve === "function") {
|
|
1686
|
+
const pinned = await reservable.reserve();
|
|
1687
|
+
try {
|
|
1688
|
+
newId = await insertAndReadId(pinned);
|
|
1689
|
+
} finally {
|
|
1690
|
+
pinned.release();
|
|
1691
|
+
}
|
|
1692
|
+
} else {
|
|
1693
|
+
newId = await conn.begin((tx) => insertAndReadId(tx));
|
|
1694
|
+
}
|
|
1695
|
+
} else {
|
|
1696
|
+
await runSegs(conn, segs);
|
|
1697
|
+
const [lastRow] = await runQuery<{ id: number }>(conn, [
|
|
1698
|
+
"SELECT last_insert_rowid() as id",
|
|
1699
|
+
]);
|
|
1700
|
+
newId = lastRow!.id;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const rows = await runQuery<Record<string, unknown>>(conn, [
|
|
1704
|
+
`SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
|
|
1705
|
+
{ val: newId },
|
|
1706
|
+
]);
|
|
1707
|
+
if (rows[0]) _applyRow(this, rows[0]);
|
|
1708
|
+
|
|
1709
|
+
await HookRegistry.run(ModelClass, "afterCreate", this);
|
|
1710
|
+
} else {
|
|
1711
|
+
// ── UPDATE — only dirty columns ──
|
|
1712
|
+
await HookRegistry.run(ModelClass, "beforeUpdate", this);
|
|
1713
|
+
|
|
1714
|
+
const dirty = this.$dirty();
|
|
1715
|
+
if (ModelClass.timestamps) dirty["updatedAt"] = new Date();
|
|
1716
|
+
|
|
1717
|
+
if (Object.keys(dirty).length > 0) {
|
|
1718
|
+
const entries = _writeDialect.run(dialect, () =>
|
|
1719
|
+
Object.entries(dirty).map(
|
|
1720
|
+
([k, v]) => [toSnake(k), _serializeForWrite(k, v, casts, colReg)] as [string, unknown],
|
|
1721
|
+
),
|
|
1722
|
+
);
|
|
1723
|
+
const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
|
|
1724
|
+
entries.forEach(([col, val], i) => {
|
|
1725
|
+
_assertIdentifier(col, "save()");
|
|
1726
|
+
if (i > 0) segs.push(", ");
|
|
1727
|
+
segs.push(`${col} = `);
|
|
1728
|
+
segs.push({ val });
|
|
1729
|
+
});
|
|
1730
|
+
segs.push(` WHERE ${ModelClass.primaryKey} = `);
|
|
1731
|
+
segs.push({ val: this.id });
|
|
1732
|
+
await runSegs(conn, segs);
|
|
1733
|
+
|
|
1734
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1735
|
+
for (const [k, v] of Object.entries(dirty)) self[k] = v;
|
|
1736
|
+
Object.assign(this._original, dirty);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
await HookRegistry.run(ModelClass, "afterUpdate", this);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
this._forcedDirty.clear();
|
|
1743
|
+
await HookRegistry.run(ModelClass, "afterSave", this);
|
|
1744
|
+
return this;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Delete this record. For soft-delete models this sets `deleted_at` (the row
|
|
1749
|
+
* stays in the table but is hidden from default queries); otherwise it issues
|
|
1750
|
+
* a hard `DELETE`. Runs the before/after delete lifecycle hooks.
|
|
1751
|
+
*
|
|
1752
|
+
* @category Persistence
|
|
1753
|
+
*/
|
|
1754
|
+
async delete(): Promise<void> {
|
|
1755
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1756
|
+
const conn = _resolveConn(ModelClass);
|
|
1757
|
+
const dialect = dialectFor(conn as unknown as object);
|
|
1758
|
+
|
|
1759
|
+
await HookRegistry.run(ModelClass, "beforeDelete", this);
|
|
1760
|
+
|
|
1761
|
+
if (ModelClass.softDeletes) {
|
|
1762
|
+
const now = new Date();
|
|
1763
|
+
await runSegs(conn, [
|
|
1764
|
+
`UPDATE ${ModelClass.table} SET deleted_at = `,
|
|
1765
|
+
{ val: _writeDialect.run(dialect, () => _serializeDate(now)) },
|
|
1766
|
+
` WHERE ${ModelClass.primaryKey} = `,
|
|
1767
|
+
{ val: this.id },
|
|
1768
|
+
]);
|
|
1769
|
+
// `deletedAt` lives on the SoftDeletes mixin; this branch only runs for models
|
|
1770
|
+
// that compose it (softDeletes === true).
|
|
1771
|
+
(this as { deletedAt?: Date | null }).deletedAt = now;
|
|
1772
|
+
} else {
|
|
1773
|
+
await runSegs(conn, [
|
|
1774
|
+
`DELETE FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
|
|
1775
|
+
{ val: this.id },
|
|
1776
|
+
]);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
await HookRegistry.run(ModelClass, "afterDelete", this);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Eager-load the given relations onto this already-fetched model instance.
|
|
1784
|
+
* If a relation is already loaded, it is reloaded.
|
|
1785
|
+
*
|
|
1786
|
+
* @example
|
|
1787
|
+
* const post = await Post.find(1);
|
|
1788
|
+
* await post.load(['comments', 'tags']);
|
|
1789
|
+
* // post.comments is now populated
|
|
1790
|
+
*
|
|
1791
|
+
* @category Relationships
|
|
1792
|
+
*/
|
|
1793
|
+
async load(relations: string[]): Promise<this> {
|
|
1794
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1795
|
+
const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
|
|
1796
|
+
for (const rel of relations) qb.with(rel as never);
|
|
1797
|
+
// _eagerLoadRelations is private on ModelQueryBuilder; use the public approach
|
|
1798
|
+
// by fetching from the QB's internal method via a single-item array
|
|
1799
|
+
await (
|
|
1800
|
+
qb as unknown as {
|
|
1801
|
+
_eagerLoadRelations(instances: BaseModel[]): Promise<void>;
|
|
1802
|
+
}
|
|
1803
|
+
)._eagerLoadRelations([this]);
|
|
1804
|
+
return this;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Like load(), but skips relations that are already loaded on this instance.
|
|
1809
|
+
*
|
|
1810
|
+
* @example
|
|
1811
|
+
* await post.loadMissing(['comments']); // no-op if comments already loaded
|
|
1812
|
+
*
|
|
1813
|
+
* @category Relationships
|
|
1814
|
+
*/
|
|
1815
|
+
async loadMissing(relations: string[]): Promise<this> {
|
|
1816
|
+
// A loaded relation is a plain data property (no getter).
|
|
1817
|
+
// An unloaded relation has a lazy-load getter that throws RelationNotLoadedError.
|
|
1818
|
+
const missing = relations.filter((rel) => {
|
|
1819
|
+
const desc = Object.getOwnPropertyDescriptor(this, rel);
|
|
1820
|
+
// If descriptor has a getter, the relation is not yet loaded
|
|
1821
|
+
return !desc || typeof desc.get === "function";
|
|
1822
|
+
});
|
|
1823
|
+
if (missing.length > 0) await this.load(missing);
|
|
1824
|
+
return this;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* Re-read this record from the database and return it as a **new** instance,
|
|
1829
|
+
* leaving the current one untouched. Use {@link refresh} to mutate in place.
|
|
1830
|
+
*
|
|
1831
|
+
* @throws {ModelNotFoundError} when the row no longer exists.
|
|
1832
|
+
* @category Persistence
|
|
1833
|
+
*/
|
|
1834
|
+
async fresh(): Promise<this> {
|
|
1835
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1836
|
+
const conn = _resolveConn(ModelClass);
|
|
1837
|
+
const rows = await runQuery<Record<string, unknown>>(conn, [
|
|
1838
|
+
`SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
|
|
1839
|
+
{ val: this.id },
|
|
1840
|
+
]);
|
|
1841
|
+
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
|
|
1842
|
+
return ModelClass.fromRow(rows[0]) as this;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Reload this instance's attributes from the database, mutating it in place.
|
|
1847
|
+
* Unlike `fresh()` (which returns a new instance) this updates `this`.
|
|
1848
|
+
*
|
|
1849
|
+
* @throws {ModelNotFoundError} when the row no longer exists.
|
|
1850
|
+
* @category Persistence
|
|
1851
|
+
*/
|
|
1852
|
+
async refresh(): Promise<this> {
|
|
1853
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1854
|
+
const conn = _resolveConn(ModelClass);
|
|
1855
|
+
const rows = await runQuery<Record<string, unknown>>(conn, [
|
|
1856
|
+
`SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
|
|
1857
|
+
{ val: this.id },
|
|
1858
|
+
]);
|
|
1859
|
+
if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
|
|
1860
|
+
_applyRow(this, rows[0]);
|
|
1861
|
+
return this;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Copy this model into a new **unsaved** instance. The primary key and
|
|
1866
|
+
* timestamps are not copied; pass `except` to omit additional columns.
|
|
1867
|
+
*
|
|
1868
|
+
* @category Persistence
|
|
1869
|
+
*/
|
|
1870
|
+
replicate(except?: string[]): this {
|
|
1871
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1872
|
+
const rels = relNames(ModelClass as unknown as Function);
|
|
1873
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as Function);
|
|
1874
|
+
const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
|
|
1875
|
+
const inst = new (this.constructor as new () => this)();
|
|
1876
|
+
for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
|
|
1877
|
+
(inst as unknown as Record<string, unknown>)[k] = v;
|
|
1878
|
+
}
|
|
1879
|
+
return inst;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Bump `updated_at` to now and persist. No-op when timestamps are disabled.
|
|
1884
|
+
*
|
|
1885
|
+
* @category Timestamps
|
|
1886
|
+
*/
|
|
1887
|
+
async touch(): Promise<this> {
|
|
1888
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1889
|
+
if (!ModelClass.timestamps) return this;
|
|
1890
|
+
this.updatedAt = new Date();
|
|
1891
|
+
this.markDirty("updatedAt" as keyof this);
|
|
1892
|
+
return this.save();
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* True when `other` is the same model class with the same primary key.
|
|
1897
|
+
*
|
|
1898
|
+
* @category Comparison
|
|
1899
|
+
*/
|
|
1900
|
+
is(other: BaseModel | null | undefined): boolean {
|
|
1901
|
+
if (!other) return false;
|
|
1902
|
+
if (other.constructor !== this.constructor) return false;
|
|
1903
|
+
const pk = toCamel((this.constructor as typeof BaseModel).primaryKey);
|
|
1904
|
+
return (
|
|
1905
|
+
(other as unknown as Record<string, unknown>)[pk] ===
|
|
1906
|
+
(this as unknown as Record<string, unknown>)[pk]
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* Inverse of {@link is}.
|
|
1912
|
+
*
|
|
1913
|
+
* @category Comparison
|
|
1914
|
+
*/
|
|
1915
|
+
isNot(other: BaseModel | null | undefined): boolean {
|
|
1916
|
+
return !this.is(other);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Atomically increment a column in the DB and on this instance.
|
|
1921
|
+
*
|
|
1922
|
+
* @category Persistence
|
|
1923
|
+
*/
|
|
1924
|
+
async increment(column: keyof this & string, amount = 1): Promise<this> {
|
|
1925
|
+
return this._stepColumn(column, amount);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/**
|
|
1929
|
+
* Atomically decrement a column in the DB and on this instance.
|
|
1930
|
+
*
|
|
1931
|
+
* @category Persistence
|
|
1932
|
+
*/
|
|
1933
|
+
async decrement(column: keyof this & string, amount = 1): Promise<this> {
|
|
1934
|
+
return this._stepColumn(column, -amount);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/** @internal Shared implementation of {@link increment} / {@link decrement}. */
|
|
1938
|
+
private async _stepColumn(column: string, delta: number): Promise<this> {
|
|
1939
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1940
|
+
const conn = _resolveConn(ModelClass);
|
|
1941
|
+
const col = toSnake(column);
|
|
1942
|
+
_assertIdentifier(col, "increment()/decrement()");
|
|
1943
|
+
const op = delta >= 0 ? "+" : "-";
|
|
1944
|
+
await runSegs(conn, [
|
|
1945
|
+
`UPDATE ${ModelClass.table} SET ${col} = ${col} ${op} `,
|
|
1946
|
+
{ val: Math.abs(delta) },
|
|
1947
|
+
` WHERE ${ModelClass.primaryKey} = `,
|
|
1948
|
+
{ val: this.id },
|
|
1949
|
+
]);
|
|
1950
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1951
|
+
const current = Number(self[column] ?? 0);
|
|
1952
|
+
self[column] = current + delta;
|
|
1953
|
+
(this._original as Record<string, unknown>)[column] = self[column];
|
|
1954
|
+
return this;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* Load relation COUNT(s) onto this instance (sets `<rel>Count`).
|
|
1959
|
+
*
|
|
1960
|
+
* @category Relationships
|
|
1961
|
+
*/
|
|
1962
|
+
async loadCount(relations: string | string[]): Promise<this> {
|
|
1963
|
+
const rels = Array.isArray(relations) ? relations : [relations];
|
|
1964
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
1965
|
+
const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
|
|
1966
|
+
qb.where(ModelClass.primaryKey, this.id);
|
|
1967
|
+
for (const r of rels) qb.withCount(r);
|
|
1968
|
+
const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
|
|
1969
|
+
const src = fresh as unknown as Record<string, unknown> | null;
|
|
1970
|
+
const self = this as unknown as Record<string, unknown>;
|
|
1971
|
+
for (const r of rels) {
|
|
1972
|
+
const key = `${toCamel(r)}Count`;
|
|
1973
|
+
self[key] = src?.[key] ?? 0;
|
|
1974
|
+
}
|
|
1975
|
+
return this;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* Load relation COUNT(s) for an array of already-fetched models in a SINGLE
|
|
1980
|
+
* query (no N+1), setting `<rel>Count` on each — prefer this over calling the
|
|
1981
|
+
* instance `loadCount()` in a loop.
|
|
1982
|
+
*
|
|
1983
|
+
* @example
|
|
1984
|
+
* const posts = await Post.all();
|
|
1985
|
+
* await Post.loadCount(posts, "comments");
|
|
1986
|
+
* posts[0]!.commentsCount;
|
|
1987
|
+
*
|
|
1988
|
+
* @category Relationships
|
|
1989
|
+
*/
|
|
1990
|
+
static async loadCount<T extends BaseModel>(
|
|
1991
|
+
this: typeof BaseModel,
|
|
1992
|
+
models: T[],
|
|
1993
|
+
relations: string | string[],
|
|
1994
|
+
): Promise<T[]> {
|
|
1995
|
+
if (models.length === 0) return models;
|
|
1996
|
+
const rels = Array.isArray(relations) ? relations : [relations];
|
|
1997
|
+
const idOf = (m: T): unknown => (m as unknown as { id: unknown }).id;
|
|
1998
|
+
|
|
1999
|
+
const qb = new ModelQueryBuilder(this.table, _resolveConn(this), this);
|
|
2000
|
+
qb.whereIn(this.primaryKey, models.map(idOf));
|
|
2001
|
+
for (const r of rels) qb.withCount(r);
|
|
2002
|
+
const fresh = await qb.get<BaseModel>();
|
|
2003
|
+
|
|
2004
|
+
const byId = new Map<unknown, Record<string, unknown>>();
|
|
2005
|
+
for (const f of fresh) byId.set((f as unknown as { id: unknown }).id, f as never);
|
|
2006
|
+
|
|
2007
|
+
for (const m of models) {
|
|
2008
|
+
const src = byId.get(idOf(m));
|
|
2009
|
+
const rec = m as unknown as Record<string, unknown>;
|
|
2010
|
+
for (const r of rels) {
|
|
2011
|
+
const key = countAttribute(r);
|
|
2012
|
+
rec[key] = src?.[key] ?? 0;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
return models;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** @internal Shared implementation of {@link loadSum} / {@link loadAvg} / {@link loadMin} / {@link loadMax}. */
|
|
2019
|
+
private async _loadAgg(
|
|
2020
|
+
fn: "Sum" | "Avg" | "Min" | "Max",
|
|
2021
|
+
relation: string,
|
|
2022
|
+
column: string,
|
|
2023
|
+
): Promise<this> {
|
|
2024
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
2025
|
+
const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
|
|
2026
|
+
qb.where(ModelClass.primaryKey, this.id);
|
|
2027
|
+
(qb as unknown as Record<string, (r: string, c: string) => void>)[`with${fn}`]?.(
|
|
2028
|
+
relation,
|
|
2029
|
+
column,
|
|
2030
|
+
);
|
|
2031
|
+
const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
|
|
2032
|
+
// Attribute name matches the eager path, e.g. commentsSumVotes.
|
|
2033
|
+
const key = aggregateAttribute(relation, fn, column);
|
|
2034
|
+
(this as unknown as Record<string, unknown>)[key] =
|
|
2035
|
+
(fresh as unknown as Record<string, unknown> | null)?.[key] ?? 0;
|
|
2036
|
+
return this;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Load `SUM(column)` over a relation onto this instance (sets `<rel>Sum<Column>`).
|
|
2041
|
+
*
|
|
2042
|
+
* @category Relationships
|
|
2043
|
+
*/
|
|
2044
|
+
loadSum(relation: string, column: string): Promise<this> {
|
|
2045
|
+
return this._loadAgg("Sum", relation, column);
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Load `AVG(column)` over a relation onto this instance (sets `<rel>Avg<Column>`).
|
|
2049
|
+
*
|
|
2050
|
+
* @category Relationships
|
|
2051
|
+
*/
|
|
2052
|
+
loadAvg(relation: string, column: string): Promise<this> {
|
|
2053
|
+
return this._loadAgg("Avg", relation, column);
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Load `MIN(column)` over a relation onto this instance (sets `<rel>Min<Column>`).
|
|
2057
|
+
*
|
|
2058
|
+
* @category Relationships
|
|
2059
|
+
*/
|
|
2060
|
+
loadMin(relation: string, column: string): Promise<this> {
|
|
2061
|
+
return this._loadAgg("Min", relation, column);
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Load `MAX(column)` over a relation onto this instance (sets `<rel>Max<Column>`).
|
|
2065
|
+
*
|
|
2066
|
+
* @category Relationships
|
|
2067
|
+
*/
|
|
2068
|
+
loadMax(relation: string, column: string): Promise<this> {
|
|
2069
|
+
return this._loadAgg("Max", relation, column);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Hide additional keys from `toJSON()` for this instance only.
|
|
2074
|
+
*
|
|
2075
|
+
* @category Serialization
|
|
2076
|
+
*/
|
|
2077
|
+
makeHidden(...keys: string[]): this {
|
|
2078
|
+
const self = this as unknown as { _instanceHidden?: Set<string> };
|
|
2079
|
+
self._instanceHidden = new Set([...(self._instanceHidden ?? []), ...keys]);
|
|
2080
|
+
return this;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* Reveal keys that are hidden by the class `hidden` list, for this instance only.
|
|
2085
|
+
*
|
|
2086
|
+
* @category Serialization
|
|
2087
|
+
*/
|
|
2088
|
+
makeVisible(...keys: string[]): this {
|
|
2089
|
+
const self = this as unknown as { _instanceVisible?: Set<string> };
|
|
2090
|
+
self._instanceVisible = new Set([...(self._instanceVisible ?? []), ...keys]);
|
|
2091
|
+
return this;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Add computed accessor name(s) to this instance's `toJSON()` output.
|
|
2096
|
+
*
|
|
2097
|
+
* @category Serialization
|
|
2098
|
+
*/
|
|
2099
|
+
append(...keys: string[]): this {
|
|
2100
|
+
const self = this as unknown as { _instanceAppends?: string[] };
|
|
2101
|
+
self._instanceAppends = [...(self._instanceAppends ?? []), ...keys];
|
|
2102
|
+
return this;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Set this model's belongsTo foreign key to `model` and cache the relation.
|
|
2107
|
+
* Does not persist — call `save()` afterwards.
|
|
2108
|
+
*
|
|
2109
|
+
* @example
|
|
2110
|
+
* comment.associate('post', post);
|
|
2111
|
+
* await comment.save();
|
|
2112
|
+
*
|
|
2113
|
+
* @throws {Error} when `relation` is not a `belongsTo` relation on this model.
|
|
2114
|
+
* @category Relationships
|
|
2115
|
+
*/
|
|
2116
|
+
associate(relation: string, model: BaseModel): this {
|
|
2117
|
+
const meta = relationsFor(this.constructor).get(relation);
|
|
2118
|
+
if (!meta || meta.type !== "belongsTo") {
|
|
2119
|
+
throw new Error(
|
|
2120
|
+
`associate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
const fkProp = toCamel(meta.foreignKey);
|
|
2124
|
+
const ownerKeyProp = toCamel(meta.localKey);
|
|
2125
|
+
(this as unknown as Record<string, unknown>)[fkProp] = (
|
|
2126
|
+
model as unknown as Record<string, unknown>
|
|
2127
|
+
)[ownerKeyProp];
|
|
2128
|
+
Object.defineProperty(this, relation, {
|
|
2129
|
+
value: model,
|
|
2130
|
+
enumerable: true,
|
|
2131
|
+
configurable: true,
|
|
2132
|
+
writable: true,
|
|
2133
|
+
});
|
|
2134
|
+
return this;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Clear this model's belongsTo foreign key and cached relation.
|
|
2139
|
+
*
|
|
2140
|
+
* @throws {Error} when `relation` is not a `belongsTo` relation on this model.
|
|
2141
|
+
* @category Relationships
|
|
2142
|
+
*/
|
|
2143
|
+
dissociate(relation: string): this {
|
|
2144
|
+
const meta = relationsFor(this.constructor).get(relation);
|
|
2145
|
+
if (!meta || meta.type !== "belongsTo") {
|
|
2146
|
+
throw new Error(
|
|
2147
|
+
`dissociate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
const fkProp = toCamel(meta.foreignKey);
|
|
2151
|
+
(this as unknown as Record<string, unknown>)[fkProp] = null;
|
|
2152
|
+
Object.defineProperty(this, relation, {
|
|
2153
|
+
value: null,
|
|
2154
|
+
enumerable: true,
|
|
2155
|
+
configurable: true,
|
|
2156
|
+
writable: true,
|
|
2157
|
+
});
|
|
2158
|
+
return this;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* True when the model (or a specific `column`) has unsaved changes since it
|
|
2163
|
+
* was loaded or last saved.
|
|
2164
|
+
*
|
|
2165
|
+
* @category Attributes & mass assignment
|
|
2166
|
+
*/
|
|
2167
|
+
isDirty(column?: string): boolean {
|
|
2168
|
+
if (column) {
|
|
2169
|
+
const current = (this as unknown as Record<string, unknown>)[column];
|
|
2170
|
+
const original = this._original[column];
|
|
2171
|
+
return current !== original || this._forcedDirty.has(column);
|
|
2172
|
+
}
|
|
2173
|
+
return Object.keys(this.$dirty()).length > 0;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* Return a map of changed columns to their current values — the set that a
|
|
2178
|
+
* subsequent {@link save} would write.
|
|
2179
|
+
*
|
|
2180
|
+
* @category Attributes & mass assignment
|
|
2181
|
+
*/
|
|
2182
|
+
$dirty(): Record<string, unknown> {
|
|
2183
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
2184
|
+
const rels = relNames(ModelClass as unknown as Function);
|
|
2185
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as Function);
|
|
2186
|
+
const out: Record<string, unknown> = {};
|
|
2187
|
+
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
2188
|
+
if (val !== this._original[key] || this._forcedDirty.has(key)) {
|
|
2189
|
+
out[key] = val;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return out;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* Force a property to be treated as dirty so it is included in the next
|
|
2197
|
+
* {@link save}, even if its value is reference-equal to the loaded snapshot
|
|
2198
|
+
* (e.g. an in-place mutation of a JSON column).
|
|
2199
|
+
*
|
|
2200
|
+
* @category Attributes & mass assignment
|
|
2201
|
+
*/
|
|
2202
|
+
markDirty(property: keyof this): this {
|
|
2203
|
+
this._forcedDirty.add(String(property));
|
|
2204
|
+
return this;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Called automatically by JSON.stringify — returns a plain object containing
|
|
2209
|
+
* only user-facing data:
|
|
2210
|
+
*
|
|
2211
|
+
* • All column values and loaded relation instances (enumerable own props)
|
|
2212
|
+
* • Excludes internal ORM state: _original, _exists, _forcedDirty, _zerotal_*
|
|
2213
|
+
* • Excludes any remaining getter guards (unloaded relation lazy-load traps)
|
|
2214
|
+
*
|
|
2215
|
+
* Nested model instances (e.g. post.author) also have toJSON(), so
|
|
2216
|
+
* JSON.stringify recurses correctly through the full object graph.
|
|
2217
|
+
*
|
|
2218
|
+
* @category Serialization
|
|
2219
|
+
*/
|
|
2220
|
+
toJSON(): Record<string, unknown> {
|
|
2221
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
2222
|
+
const self = this as unknown as {
|
|
2223
|
+
_instanceHidden?: Set<string>;
|
|
2224
|
+
_instanceVisible?: Set<string>;
|
|
2225
|
+
_instanceAppends?: string[];
|
|
2226
|
+
};
|
|
2227
|
+
|
|
2228
|
+
const instVisible = self._instanceVisible ?? new Set<string>();
|
|
2229
|
+
// Effective hidden = class hidden + per-instance hidden − per-instance visible.
|
|
2230
|
+
const hidden = new Set<string>([...(ModelClass.hidden ?? []), ...(self._instanceHidden ?? [])]);
|
|
2231
|
+
for (const k of instVisible) hidden.delete(k);
|
|
2232
|
+
|
|
2233
|
+
// The allow-list is a *class-level* opt-in. `makeVisible()` un-hides a key; it does not
|
|
2234
|
+
// create an allow-list where the class had none — otherwise, with `static visible` empty
|
|
2235
|
+
// (the normal case), one `makeVisible("password")` would reduce the whole payload to
|
|
2236
|
+
// exactly that field. The per-instance set only extends an allow-list the class already
|
|
2237
|
+
// declared.
|
|
2238
|
+
const classVisible = ModelClass.visible ?? [];
|
|
2239
|
+
const visible =
|
|
2240
|
+
classVisible.length > 0 ? new Set<string>([...classVisible, ...instVisible]) : null;
|
|
2241
|
+
|
|
2242
|
+
const include = (key: string): boolean => {
|
|
2243
|
+
if (hidden.has(key)) return false;
|
|
2244
|
+
if (visible && !visible.has(key)) return false;
|
|
2245
|
+
return true;
|
|
2246
|
+
};
|
|
2247
|
+
|
|
2248
|
+
const out: Record<string, unknown> = {};
|
|
2249
|
+
for (const key of Object.keys(this)) {
|
|
2250
|
+
if (key.startsWith("_")) continue;
|
|
2251
|
+
if (!include(key)) continue;
|
|
2252
|
+
const desc = Object.getOwnPropertyDescriptor(this, key);
|
|
2253
|
+
if (desc && typeof desc.get === "function") {
|
|
2254
|
+
// Skip lazy-load relation guards — reading one throws RelationNotLoadedError — but
|
|
2255
|
+
// INCLUDE reactive column accessors. `installReactiveAccessors` defines a getter for
|
|
2256
|
+
// every `cast: "json" | "array"` column, so the blanket skip silently dropped every
|
|
2257
|
+
// such column from toJSON(): persistence still worked, but the field vanished from API
|
|
2258
|
+
// responses, cache writes and queue payloads with no error. A `_zerotal_<key>` backing
|
|
2259
|
+
// data property is what distinguishes the two. `ownDataEntries` (see above) already
|
|
2260
|
+
// draws exactly this distinction; toJSON did not.
|
|
2261
|
+
if (!Object.prototype.hasOwnProperty.call(this, `_zerotal_${key}`)) continue;
|
|
2262
|
+
}
|
|
2263
|
+
out[key] = (this as Record<string, unknown>)[key];
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// Computed accessors declared via `static appends` / instance `append()`.
|
|
2267
|
+
const appends = [...(ModelClass.appends ?? []), ...(self._instanceAppends ?? [])];
|
|
2268
|
+
for (const name of appends) {
|
|
2269
|
+
if (!include(name)) continue;
|
|
2270
|
+
out[name] = (this as unknown as Record<string, unknown>)[name];
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
return out;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Build a model instance from a raw database row, applying casts and marking
|
|
2280
|
+
* it as DB-resident. Primarily used internally by the query builder.
|
|
2281
|
+
*
|
|
2282
|
+
* @internal
|
|
2283
|
+
*/
|
|
2284
|
+
static fromRow(row: Record<string, unknown>): BaseModel {
|
|
2285
|
+
const inst = new this();
|
|
2286
|
+
_applyRow(inst, row);
|
|
2287
|
+
return inst;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// ── Module-private helpers ───────────────────────────────────────────────────
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* Returns a ManyToMany<never> pivot proxy for an *unloaded* manyToMany relation.
|
|
2295
|
+
*
|
|
2296
|
+
* The pivot methods (attach / detach / sync / toggle) work immediately using
|
|
2297
|
+
* the parent model's primary key — no need to eager-load the relation first.
|
|
2298
|
+
* Any array access (length, iteration, indexing) throws RelationNotLoadedError,
|
|
2299
|
+
* preserving the same contract as other unloaded relations.
|
|
2300
|
+
*/
|
|
2301
|
+
function _createLazyPivotProxy(
|
|
2302
|
+
relName: string,
|
|
2303
|
+
modelName: string,
|
|
2304
|
+
pivotTable: string,
|
|
2305
|
+
pivotForeignKey: string,
|
|
2306
|
+
pivotRelatedKey: string,
|
|
2307
|
+
parentId: unknown,
|
|
2308
|
+
): ManyToMany<never> {
|
|
2309
|
+
return new Proxy([] as unknown as ManyToMany<never>, {
|
|
2310
|
+
get(_: ManyToMany<never>, prop: string | symbol): unknown {
|
|
2311
|
+
switch (prop) {
|
|
2312
|
+
case "attach":
|
|
2313
|
+
return async (id: number | number[]): Promise<void> => {
|
|
2314
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
2315
|
+
const conn = _resolveConn();
|
|
2316
|
+
for (const relId of ids) {
|
|
2317
|
+
await new QueryBuilder(pivotTable, conn).insert({
|
|
2318
|
+
[pivotForeignKey]: parentId,
|
|
2319
|
+
[pivotRelatedKey]: relId,
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
case "detach":
|
|
2324
|
+
return async (id?: number | number[]): Promise<void> => {
|
|
2325
|
+
const conn = _resolveConn();
|
|
2326
|
+
const qb = new QueryBuilder(pivotTable, conn).where(pivotForeignKey, parentId);
|
|
2327
|
+
if (id !== undefined) {
|
|
2328
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
2329
|
+
qb.whereIn(pivotRelatedKey, ids as unknown[]);
|
|
2330
|
+
}
|
|
2331
|
+
await qb.delete();
|
|
2332
|
+
};
|
|
2333
|
+
case "sync":
|
|
2334
|
+
return async (ids: number[]): Promise<void> => {
|
|
2335
|
+
const conn = _resolveConn();
|
|
2336
|
+
await new QueryBuilder(pivotTable, conn).where(pivotForeignKey, parentId).delete();
|
|
2337
|
+
for (const relId of ids) {
|
|
2338
|
+
await new QueryBuilder(pivotTable, conn).insert({
|
|
2339
|
+
[pivotForeignKey]: parentId,
|
|
2340
|
+
[pivotRelatedKey]: relId,
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2344
|
+
case "toggle":
|
|
2345
|
+
return async (id: number | number[]): Promise<void> => {
|
|
2346
|
+
const conn = _resolveConn();
|
|
2347
|
+
const ids = Array.isArray(id) ? id : [id];
|
|
2348
|
+
for (const relId of ids) {
|
|
2349
|
+
const existing = await new QueryBuilder(pivotTable, conn)
|
|
2350
|
+
.where(pivotForeignKey, parentId)
|
|
2351
|
+
.where(pivotRelatedKey, relId)
|
|
2352
|
+
.first<Record<string, unknown>>();
|
|
2353
|
+
if (existing) {
|
|
2354
|
+
await new QueryBuilder(pivotTable, conn)
|
|
2355
|
+
.where(pivotForeignKey, parentId)
|
|
2356
|
+
.where(pivotRelatedKey, relId)
|
|
2357
|
+
.delete();
|
|
2358
|
+
} else {
|
|
2359
|
+
await new QueryBuilder(pivotTable, conn).insert({
|
|
2360
|
+
[pivotForeignKey]: parentId,
|
|
2361
|
+
[pivotRelatedKey]: relId,
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
};
|
|
2366
|
+
default:
|
|
2367
|
+
throw new RelationNotLoadedError(relName, modelName);
|
|
2368
|
+
}
|
|
2369
|
+
},
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
2374
|
+
const self = inst as unknown as Record<string, unknown>;
|
|
2375
|
+
const orig: Record<string, unknown> = {};
|
|
2376
|
+
const ctor = inst.constructor;
|
|
2377
|
+
const ModelClass = ctor as typeof BaseModel;
|
|
2378
|
+
const colReg = columnsFor(ctor);
|
|
2379
|
+
installReactiveAccessors(inst); // json/array reactiveCasts accessors (registered at decoration)
|
|
2380
|
+
const casts = getCasts(ctor);
|
|
2381
|
+
|
|
2382
|
+
for (const [snakeKey, rawVal] of Object.entries(row)) {
|
|
2383
|
+
const camelKey = toCamel(snakeKey);
|
|
2384
|
+
// Prefer the property name as registered in the column registry. Models that
|
|
2385
|
+
// define snake_case properties (e.g. `is_active`) register under the snake_case
|
|
2386
|
+
// key, so we fall back to snakeKey when camelKey has no entry.
|
|
2387
|
+
const colMeta = colReg?.get(camelKey) ?? colReg?.get(snakeKey);
|
|
2388
|
+
const propKey = colReg?.has(camelKey) ? camelKey : colReg?.has(snakeKey) ? snakeKey : camelKey;
|
|
2389
|
+
const cast = casts[propKey] ?? casts[camelKey] ?? colMeta?.cast;
|
|
2390
|
+
const colType = colMeta?.type;
|
|
2391
|
+
const castObj =
|
|
2392
|
+
cast && typeof cast === "object" && typeof (cast as { get?: unknown }).get === "function"
|
|
2393
|
+
? (cast as { get(v: unknown): unknown })
|
|
2394
|
+
: undefined;
|
|
2395
|
+
|
|
2396
|
+
let finalVal: unknown;
|
|
2397
|
+
if (castObj) {
|
|
2398
|
+
// User-defined cast (object or Cast class) — method call preserves `this`.
|
|
2399
|
+
finalVal = castObj.get(rawVal);
|
|
2400
|
+
} else if (typeof cast === "string") {
|
|
2401
|
+
// Explicit shorthand cast ('boolean', 'json', 'date', etc.)
|
|
2402
|
+
finalVal = applyCastGet(rawVal, cast);
|
|
2403
|
+
} else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
|
|
2404
|
+
// Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
|
|
2405
|
+
finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
|
|
2406
|
+
} else if (colType === "datetime" && rawVal !== null && rawVal !== undefined) {
|
|
2407
|
+
finalVal = new Carbon(rawVal as string | number);
|
|
2408
|
+
} else if (colType === "json" && rawVal !== null && rawVal !== undefined) {
|
|
2409
|
+
// Auto-cast based on @column({ type: 'json' }) — SQLite stores JSON strings
|
|
2410
|
+
finalVal = typeof rawVal === "string" ? tryParseJson(rawVal) : rawVal;
|
|
2411
|
+
} else if (camelKey === "createdAt" || camelKey === "updatedAt") {
|
|
2412
|
+
finalVal = rawVal != null ? new Carbon(rawVal as string | number) : undefined;
|
|
2413
|
+
} else if (camelKey === "deletedAt") {
|
|
2414
|
+
finalVal = rawVal != null ? new Carbon(rawVal as string | number) : null;
|
|
2415
|
+
} else {
|
|
2416
|
+
finalVal = rawVal;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
if (shouldProxyCast(ModelClass, cast, colType)) {
|
|
2420
|
+
finalVal = makeReactive(inst, propKey, finalVal);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const desc = Object.getOwnPropertyDescriptor(inst, propKey);
|
|
2424
|
+
if (desc && typeof desc.set === "function") {
|
|
2425
|
+
const privateKey = `_zerotal_${propKey}`;
|
|
2426
|
+
(self as Record<string, unknown>)[privateKey] = finalVal;
|
|
2427
|
+
} else {
|
|
2428
|
+
self[propKey] = finalVal;
|
|
2429
|
+
}
|
|
2430
|
+
orig[propKey] = finalVal;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Mark as DB-resident so save() chooses UPDATE over INSERT.
|
|
2434
|
+
(inst as unknown as { _exists: boolean })._exists = true;
|
|
2435
|
+
|
|
2436
|
+
// Install lazy-load guard getters for every declared relation.
|
|
2437
|
+
// For manyToMany relations a pivot proxy is returned instead of throwing so
|
|
2438
|
+
// that attach/detach/sync/toggle can be called without eager-loading.
|
|
2439
|
+
// The setter in every guard rewrites the property to a plain data property
|
|
2440
|
+
// so _attach() can overwrite without triggering the getter recursively.
|
|
2441
|
+
// Walk the prototype chain so relations declared on ancestor classes (e.g.
|
|
2442
|
+
// layered mixins) are guarded on the concrete subclass too; child wins.
|
|
2443
|
+
const relations = relationsFor(ctor);
|
|
2444
|
+
if (relations.size) {
|
|
2445
|
+
for (const [relName, relMeta] of relations.entries()) {
|
|
2446
|
+
if (relMeta.type === "manyToMany") {
|
|
2447
|
+
const { pivotTable, pivotForeignKey, pivotRelatedKey, localKey } = relMeta;
|
|
2448
|
+
const localKeyProp = toCamel(localKey);
|
|
2449
|
+
Object.defineProperty(inst, relName, {
|
|
2450
|
+
get(this: BaseModel) {
|
|
2451
|
+
const parentId = (this as unknown as Record<string, unknown>)[localKeyProp];
|
|
2452
|
+
return _createLazyPivotProxy(
|
|
2453
|
+
relName,
|
|
2454
|
+
ctor.name,
|
|
2455
|
+
pivotTable!,
|
|
2456
|
+
pivotForeignKey!,
|
|
2457
|
+
pivotRelatedKey!,
|
|
2458
|
+
parentId,
|
|
2459
|
+
);
|
|
2460
|
+
},
|
|
2461
|
+
set(this: BaseModel, v: unknown) {
|
|
2462
|
+
Object.defineProperty(this, relName, {
|
|
2463
|
+
value: v,
|
|
2464
|
+
enumerable: true,
|
|
2465
|
+
configurable: true,
|
|
2466
|
+
writable: true,
|
|
2467
|
+
});
|
|
2468
|
+
},
|
|
2469
|
+
configurable: true,
|
|
2470
|
+
enumerable: false,
|
|
2471
|
+
});
|
|
2472
|
+
} else {
|
|
2473
|
+
Object.defineProperty(inst, relName, {
|
|
2474
|
+
get(this: BaseModel) {
|
|
2475
|
+
throw new RelationNotLoadedError(relName, ctor.name);
|
|
2476
|
+
},
|
|
2477
|
+
set(this: BaseModel, v: unknown) {
|
|
2478
|
+
Object.defineProperty(this, relName, {
|
|
2479
|
+
value: v,
|
|
2480
|
+
enumerable: true,
|
|
2481
|
+
configurable: true,
|
|
2482
|
+
writable: true,
|
|
2483
|
+
});
|
|
2484
|
+
},
|
|
2485
|
+
configurable: true,
|
|
2486
|
+
// false so JSON.stringify / cache drivers skip unloaded relations.
|
|
2487
|
+
// Once loaded the set() handler above redefines the property with
|
|
2488
|
+
// enumerable:true so it IS included in subsequent serialisation.
|
|
2489
|
+
enumerable: false,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
(inst as unknown as { _original: Record<string, unknown> })._original = orig;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// Backward-compat alias (index.ts exports `Model`)
|
|
2499
|
+
export { BaseModel as Model };
|