@zerotal/orm 1.3.0 → 1.5.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 +154 -0
- package/package.json +3 -3
- package/src/casts/encrypted.ts +168 -0
- package/src/commands/DbSeedCommand.ts +15 -43
- package/src/commands/MigrateCommand.ts +46 -7
- package/src/commands/MigrateFreshCommand.ts +45 -2
- package/src/commands/MigrateRefreshCommand.ts +28 -0
- package/src/commands/_runSeeders.ts +71 -0
- package/src/commands/index.ts +1 -0
- package/src/conventions.ts +2 -1
- package/src/db/NPlusOneDetector.ts +93 -15
- package/src/db/QueryBuilder.ts +34 -3
- package/src/diagnostics/missingRelation.ts +187 -0
- package/src/diagnostics/runMigrationsEndpoint.ts +124 -0
- package/src/index.ts +8 -0
- package/src/model/BaseModel.ts +89 -30
- package/src/model/ModelQueryBuilder.ts +28 -10
- package/src/model/Observer.ts +2 -1
- package/src/model/OrmContext.ts +4 -3
- package/src/model/State.ts +4 -3
- package/src/model/decorators/_metadata.ts +19 -18
- package/src/model/decorators/_registerRelation.ts +2 -1
- package/src/model/decorators/column.ts +20 -3
- package/src/model/decorators/table.ts +3 -2
- package/src/model/hooks/HookRegistry.ts +9 -8
- package/src/model/relations/RelationRegistry.ts +3 -1
- package/src/observability.ts +2 -2
- package/src/provider/DatabaseProvider.ts +32 -3
- package/src/schema/Blueprint.ts +15 -2
- package/src/schema/ColumnDefinition.ts +35 -1
- package/src/schema/ModelInspector.ts +33 -5
- package/src/schema/Schema.ts +62 -2
- package/src/support/classRef.ts +23 -0
- package/src/support/identifiers.ts +5 -4
package/src/model/BaseModel.ts
CHANGED
|
@@ -36,9 +36,17 @@ import { installReactiveAccessors, type ColumnOptions } from "./decorators/colum
|
|
|
36
36
|
import { _compose } from "./mixins.ts";
|
|
37
37
|
import type { Compose } from "./mixins.ts";
|
|
38
38
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
39
|
+
import {
|
|
40
|
+
collectEncryptable,
|
|
41
|
+
decryptColumn,
|
|
42
|
+
encryptColumn,
|
|
43
|
+
isEncryptedCast,
|
|
44
|
+
type EncryptedCastName,
|
|
45
|
+
} from "../casts/encrypted.ts";
|
|
39
46
|
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
40
47
|
import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
|
|
41
48
|
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
49
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
42
50
|
|
|
43
51
|
let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
|
|
44
52
|
|
|
@@ -222,18 +230,26 @@ type StringCast =
|
|
|
222
230
|
| "float"
|
|
223
231
|
| "enum"
|
|
224
232
|
| "immutable_datetime"
|
|
233
|
+
| EncryptedCastName
|
|
225
234
|
| `decimal:${number}`;
|
|
226
235
|
type CastOption = ColumnOptions["cast"];
|
|
227
236
|
|
|
228
|
-
function getCasts(ctor:
|
|
237
|
+
function getCasts(ctor: ClassRef): Record<string, CastOption> {
|
|
229
238
|
const merged: Record<string, CastOption> = {};
|
|
230
|
-
const chain:
|
|
231
|
-
let current:
|
|
239
|
+
const chain: ClassRef[] = [];
|
|
240
|
+
let current: ClassRef | null = ctor;
|
|
232
241
|
while (current && current !== Function.prototype) {
|
|
233
242
|
chain.push(current);
|
|
234
|
-
current = Object.getPrototypeOf(current) as
|
|
243
|
+
current = Object.getPrototypeOf(current) as ClassRef | null;
|
|
235
244
|
}
|
|
236
245
|
chain.reverse();
|
|
246
|
+
// `static encryptable` first, so an explicit cast on the same column still wins —
|
|
247
|
+
// spelling one out is the more specific statement of intent.
|
|
248
|
+
const colReg = columnsFor(ctor);
|
|
249
|
+
Object.assign(
|
|
250
|
+
merged,
|
|
251
|
+
collectEncryptable(chain, (key) => colReg?.get(key)?.type),
|
|
252
|
+
);
|
|
237
253
|
for (const entry of chain) {
|
|
238
254
|
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
239
255
|
if (casts) Object.assign(merged, casts);
|
|
@@ -241,8 +257,9 @@ function getCasts(ctor: Function): Record<string, CastOption> {
|
|
|
241
257
|
return merged;
|
|
242
258
|
}
|
|
243
259
|
|
|
244
|
-
function applyCastGet(value: unknown, cast: StringCast): unknown {
|
|
260
|
+
function applyCastGet(value: unknown, cast: StringCast, label: string): unknown {
|
|
245
261
|
if (value === null || value === undefined) return value;
|
|
262
|
+
if (isEncryptedCast(cast)) return decryptColumn(value, cast, label);
|
|
246
263
|
const cstr = cast as unknown as string;
|
|
247
264
|
if (cstr.startsWith("decimal:")) {
|
|
248
265
|
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
@@ -294,8 +311,9 @@ function tryParseJson(s: string): unknown {
|
|
|
294
311
|
}
|
|
295
312
|
}
|
|
296
313
|
|
|
297
|
-
function applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
314
|
+
function applyCastSet(value: unknown, cast: StringCast, label: string): unknown {
|
|
298
315
|
if (value === null || value === undefined) return value;
|
|
316
|
+
if (isEncryptedCast(cast)) return encryptColumn(value, cast, label);
|
|
299
317
|
const cstr = cast as unknown as string;
|
|
300
318
|
if (cstr.startsWith("decimal:")) {
|
|
301
319
|
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
@@ -362,6 +380,7 @@ function _serializeForWrite(
|
|
|
362
380
|
val: unknown,
|
|
363
381
|
casts: Record<string, CastOption>,
|
|
364
382
|
colReg: Map<string, ColumnOptions> | null,
|
|
383
|
+
model?: string,
|
|
365
384
|
): unknown {
|
|
366
385
|
const colMeta = colReg?.get(key);
|
|
367
386
|
const castOpt = casts[key] ?? colMeta?.cast;
|
|
@@ -370,7 +389,7 @@ function _serializeForWrite(
|
|
|
370
389
|
if (castOpt && typeof castOpt === "object" && castOpt.set) {
|
|
371
390
|
serializedVal = castOpt.set(val);
|
|
372
391
|
} else if (typeof castOpt === "string") {
|
|
373
|
-
serializedVal = applyCastSet(val, castOpt);
|
|
392
|
+
serializedVal = applyCastSet(val, castOpt, model ? `${model}.${key}` : key);
|
|
374
393
|
} else if (colType === "boolean" && val !== null && val !== undefined) {
|
|
375
394
|
serializedVal = val ? 1 : 0;
|
|
376
395
|
} else if (colType === "json" && val !== null) {
|
|
@@ -408,7 +427,7 @@ type ModelCtor<T extends BaseModel> = typeof BaseModel & { new (): T };
|
|
|
408
427
|
* (`Roles(Permissions(Base))`), where each relation lives on a different
|
|
409
428
|
* class in the chain.
|
|
410
429
|
*/
|
|
411
|
-
function relNames(ctor:
|
|
430
|
+
function relNames(ctor: ClassRef): Set<string> {
|
|
412
431
|
return new Set(relationsFor(ctor).keys());
|
|
413
432
|
}
|
|
414
433
|
|
|
@@ -445,7 +464,7 @@ function* ownDataEntries(
|
|
|
445
464
|
* (rare edge case: a model with zero @column decorators) so callers can fall back
|
|
446
465
|
* to the old unrestricted behaviour.
|
|
447
466
|
*/
|
|
448
|
-
function _allColumnKeys(cls:
|
|
467
|
+
function _allColumnKeys(cls: ClassRef): Set<string> | null {
|
|
449
468
|
const cols = columnsFor(cls);
|
|
450
469
|
return cols ? new Set(cols.keys()) : null;
|
|
451
470
|
}
|
|
@@ -833,6 +852,42 @@ export class BaseModel {
|
|
|
833
852
|
*/
|
|
834
853
|
static hashable?: string[];
|
|
835
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Columns encrypted at rest with AES-256-GCM under `APP_KEY`, and decrypted
|
|
857
|
+
* transparently on read. Shorthand for putting `cast: "encrypted"` on each one.
|
|
858
|
+
*
|
|
859
|
+
* Unlike {@link hashable} this is reversible and non-destructive: the model
|
|
860
|
+
* property still holds the value you assigned after a `save()`, because the
|
|
861
|
+
* encryption happens on the way to the database rather than to the instance.
|
|
862
|
+
* `$dirty` therefore compares plaintext, and an unchanged column is not
|
|
863
|
+
* rewritten with a new IV on every save.
|
|
864
|
+
*
|
|
865
|
+
* A `json` column in this list encrypts as `encrypted:json`, so it round-trips
|
|
866
|
+
* as the structure it was rather than as `"[object Object]"`.
|
|
867
|
+
*
|
|
868
|
+
* @example
|
|
869
|
+
* ```ts
|
|
870
|
+
* class Client extends BaseModel {
|
|
871
|
+
* static encryptable = ["idNumber", "passportNumber"];
|
|
872
|
+
*
|
|
873
|
+
* // TEXT, not VARCHAR — a payload outgrows its plaintext.
|
|
874
|
+
* @column({ type: "text", nullable: true }) idNumber?: string;
|
|
875
|
+
* @column({ type: "text", nullable: true }) passportNumber?: string;
|
|
876
|
+
* }
|
|
877
|
+
* ```
|
|
878
|
+
*
|
|
879
|
+
* @remarks
|
|
880
|
+
* Encrypted columns cannot be filtered, grouped or usefully sorted — every
|
|
881
|
+
* write draws a fresh IV, so the ciphertext for a given value never repeats.
|
|
882
|
+
* `where()` on one throws rather than quietly matching nothing. For lookup,
|
|
883
|
+
* keep a hashed blind-index column beside it. Add these to {@link hidden} too
|
|
884
|
+
* if the model is serialized to a client: decryption puts the real value back
|
|
885
|
+
* on the instance, and `toJSON()` will happily include it.
|
|
886
|
+
*
|
|
887
|
+
* @category Persistence
|
|
888
|
+
*/
|
|
889
|
+
static encryptable?: string[];
|
|
890
|
+
|
|
836
891
|
/**
|
|
837
892
|
* Register an observer class for this model.
|
|
838
893
|
* The observer's lifecycle methods (creating, created, updating, …) are
|
|
@@ -1470,8 +1525,8 @@ export class BaseModel {
|
|
|
1470
1525
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
1471
1526
|
const conn = _resolveConn(ModelClass);
|
|
1472
1527
|
const dialect = dialectFor(conn as unknown as object);
|
|
1473
|
-
const casts = getCasts(ModelClass as unknown as
|
|
1474
|
-
const colReg = columnsFor(ModelClass as unknown as
|
|
1528
|
+
const casts = getCasts(ModelClass as unknown as ClassRef);
|
|
1529
|
+
const colReg = columnsFor(ModelClass as unknown as ClassRef);
|
|
1475
1530
|
const useTs = ModelClass.timestamps;
|
|
1476
1531
|
|
|
1477
1532
|
const rows: Record<string, unknown>[] = _writeDialect.run(dialect, () => {
|
|
@@ -1480,7 +1535,7 @@ export class BaseModel {
|
|
|
1480
1535
|
const row: Record<string, unknown> = {};
|
|
1481
1536
|
for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
|
|
1482
1537
|
if (key.startsWith("_")) continue;
|
|
1483
|
-
row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1538
|
+
row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, ModelClass.name);
|
|
1484
1539
|
}
|
|
1485
1540
|
if (useTs) {
|
|
1486
1541
|
row["created_at"] = now;
|
|
@@ -1556,13 +1611,13 @@ export class BaseModel {
|
|
|
1556
1611
|
): Promise<void> {
|
|
1557
1612
|
const conn = _resolveConn(this);
|
|
1558
1613
|
const dialect = dialectFor(conn as unknown as object);
|
|
1559
|
-
const casts = getCasts(this as unknown as
|
|
1560
|
-
const colReg = columnsFor(this as unknown as
|
|
1614
|
+
const casts = getCasts(this as unknown as ClassRef);
|
|
1615
|
+
const colReg = columnsFor(this as unknown as ClassRef);
|
|
1561
1616
|
|
|
1562
1617
|
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1563
1618
|
const r: Record<string, unknown> = {};
|
|
1564
1619
|
for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
|
|
1565
|
-
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1620
|
+
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, this.name);
|
|
1566
1621
|
}
|
|
1567
1622
|
return r;
|
|
1568
1623
|
});
|
|
@@ -1643,8 +1698,8 @@ export class BaseModel {
|
|
|
1643
1698
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1644
1699
|
const conn = _resolveConn(ModelClass);
|
|
1645
1700
|
const dialect = dialectFor(conn as unknown as object);
|
|
1646
|
-
const rels = relNames(ModelClass as unknown as
|
|
1647
|
-
const casts = getCasts(ModelClass as unknown as
|
|
1701
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
1702
|
+
const casts = getCasts(ModelClass as unknown as ClassRef);
|
|
1648
1703
|
|
|
1649
1704
|
await HookRegistry.run(ModelClass, "beforeSave", this);
|
|
1650
1705
|
|
|
@@ -1676,8 +1731,8 @@ export class BaseModel {
|
|
|
1676
1731
|
}
|
|
1677
1732
|
}
|
|
1678
1733
|
|
|
1679
|
-
const colReg = columnsFor(ModelClass as unknown as
|
|
1680
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
1734
|
+
const colReg = columnsFor(ModelClass as unknown as ClassRef);
|
|
1735
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
1681
1736
|
|
|
1682
1737
|
if (!this._exists) {
|
|
1683
1738
|
// ── INSERT ──
|
|
@@ -1703,7 +1758,7 @@ export class BaseModel {
|
|
|
1703
1758
|
// value is readable straight after save() without a reload.
|
|
1704
1759
|
self[key] = effective;
|
|
1705
1760
|
}
|
|
1706
|
-
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
|
|
1761
|
+
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
|
|
1707
1762
|
}
|
|
1708
1763
|
if (ModelClass.timestamps) {
|
|
1709
1764
|
const now = _serializeDate(new Date());
|
|
@@ -1785,7 +1840,11 @@ export class BaseModel {
|
|
|
1785
1840
|
if (Object.keys(dirty).length > 0) {
|
|
1786
1841
|
const entries = _writeDialect.run(dialect, () =>
|
|
1787
1842
|
Object.entries(dirty).map(
|
|
1788
|
-
([k, v]) =>
|
|
1843
|
+
([k, v]) =>
|
|
1844
|
+
[toSnake(k), _serializeForWrite(k, v, casts, colReg, ModelClass.name)] as [
|
|
1845
|
+
string,
|
|
1846
|
+
unknown,
|
|
1847
|
+
],
|
|
1789
1848
|
),
|
|
1790
1849
|
);
|
|
1791
1850
|
const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
|
|
@@ -1937,8 +1996,8 @@ export class BaseModel {
|
|
|
1937
1996
|
*/
|
|
1938
1997
|
replicate(except?: string[]): this {
|
|
1939
1998
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1940
|
-
const rels = relNames(ModelClass as unknown as
|
|
1941
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
1999
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
2000
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
1942
2001
|
const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
|
|
1943
2002
|
const inst = new (this.constructor as new () => this)();
|
|
1944
2003
|
for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
|
|
@@ -2182,7 +2241,7 @@ export class BaseModel {
|
|
|
2182
2241
|
* @category Relationships
|
|
2183
2242
|
*/
|
|
2184
2243
|
associate(relation: string, model: BaseModel): this {
|
|
2185
|
-
const meta = relationsFor(this.constructor).get(relation);
|
|
2244
|
+
const meta = relationsFor(this.constructor as ClassRef).get(relation);
|
|
2186
2245
|
if (!meta || meta.type !== "belongsTo") {
|
|
2187
2246
|
throw new Error(
|
|
2188
2247
|
`associate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
@@ -2209,7 +2268,7 @@ export class BaseModel {
|
|
|
2209
2268
|
* @category Relationships
|
|
2210
2269
|
*/
|
|
2211
2270
|
dissociate(relation: string): this {
|
|
2212
|
-
const meta = relationsFor(this.constructor).get(relation);
|
|
2271
|
+
const meta = relationsFor(this.constructor as ClassRef).get(relation);
|
|
2213
2272
|
if (!meta || meta.type !== "belongsTo") {
|
|
2214
2273
|
throw new Error(
|
|
2215
2274
|
`dissociate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
|
|
@@ -2249,8 +2308,8 @@ export class BaseModel {
|
|
|
2249
2308
|
*/
|
|
2250
2309
|
$dirty(): Record<string, unknown> {
|
|
2251
2310
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
2252
|
-
const rels = relNames(ModelClass as unknown as
|
|
2253
|
-
const colKeys = _allColumnKeys(ModelClass as unknown as
|
|
2311
|
+
const rels = relNames(ModelClass as unknown as ClassRef);
|
|
2312
|
+
const colKeys = _allColumnKeys(ModelClass as unknown as ClassRef);
|
|
2254
2313
|
const out: Record<string, unknown> = {};
|
|
2255
2314
|
for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
|
|
2256
2315
|
if (val !== this._original[key] || this._forcedDirty.has(key)) {
|
|
@@ -2441,8 +2500,8 @@ function _createLazyPivotProxy(
|
|
|
2441
2500
|
function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
2442
2501
|
const self = inst as unknown as Record<string, unknown>;
|
|
2443
2502
|
const orig: Record<string, unknown> = {};
|
|
2444
|
-
const
|
|
2445
|
-
const
|
|
2503
|
+
const ModelClass = inst.constructor as typeof BaseModel;
|
|
2504
|
+
const ctor: ClassRef = ModelClass;
|
|
2446
2505
|
const colReg = columnsFor(ctor);
|
|
2447
2506
|
installReactiveAccessors(inst); // json/array reactiveCasts accessors (registered at decoration)
|
|
2448
2507
|
const casts = getCasts(ctor);
|
|
@@ -2467,7 +2526,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
|
2467
2526
|
finalVal = castObj.get(rawVal);
|
|
2468
2527
|
} else if (typeof cast === "string") {
|
|
2469
2528
|
// Explicit shorthand cast ('boolean', 'json', 'date', etc.)
|
|
2470
|
-
finalVal = applyCastGet(rawVal, cast);
|
|
2529
|
+
finalVal = applyCastGet(rawVal, cast, `${ModelClass.name}.${propKey}`);
|
|
2471
2530
|
} else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
|
|
2472
2531
|
// Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
|
|
2473
2532
|
finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
toSnakeColumn as _toSnakeColumn,
|
|
7
7
|
ctorChain,
|
|
8
8
|
} from "../support/identifiers.ts";
|
|
9
|
+
import { collectEncryptable, encryptedQueryError, isEncryptedCast } from "../casts/encrypted.ts";
|
|
9
10
|
import type {
|
|
10
11
|
PaginateResult,
|
|
11
12
|
SimplePaginateResult,
|
|
@@ -21,12 +22,21 @@ import type { BaseModel } from "./BaseModel.ts";
|
|
|
21
22
|
import { type ColumnOptions } from "./decorators/column.ts";
|
|
22
23
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
23
24
|
import { currentOrmContext } from "./OrmContext.ts";
|
|
25
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
24
26
|
|
|
25
27
|
type StringCast = "datetime" | "array" | "json" | "date" | "boolean" | "integer" | "float";
|
|
26
28
|
type CastOption = ColumnOptions["cast"];
|
|
27
29
|
|
|
28
|
-
function _getCasts(ctor:
|
|
30
|
+
function _getCasts(ctor: ClassRef): Record<string, CastOption> {
|
|
29
31
|
const merged: Record<string, CastOption> = {};
|
|
32
|
+
const colReg = columnsFor(ctor);
|
|
33
|
+
// Mirrors getCasts() in BaseModel: `static encryptable` resolves to casts, and an
|
|
34
|
+
// explicit cast on the same column wins. Without this the guard below cannot see
|
|
35
|
+
// a column declared encrypted through the list form.
|
|
36
|
+
Object.assign(
|
|
37
|
+
merged,
|
|
38
|
+
collectEncryptable(ctorChain(ctor), (key) => colReg?.get(key)?.type),
|
|
39
|
+
);
|
|
30
40
|
for (const entry of ctorChain(ctor)) {
|
|
31
41
|
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
32
42
|
if (casts) Object.assign(merged, casts);
|
|
@@ -265,9 +275,9 @@ function _createPivotCollection<T extends BaseModel>(
|
|
|
265
275
|
// BaseModel as a type only — no cycle.
|
|
266
276
|
|
|
267
277
|
export type GlobalScopeCallback = (qb: ModelQueryBuilder<BaseModel>) => void;
|
|
268
|
-
export function _globalScopeRegistry(): Map<
|
|
278
|
+
export function _globalScopeRegistry(): Map<ClassRef, Map<string, GlobalScopeCallback>> {
|
|
269
279
|
return currentOrmContext().globalScopes as unknown as Map<
|
|
270
|
-
|
|
280
|
+
ClassRef,
|
|
271
281
|
Map<string, GlobalScopeCallback>
|
|
272
282
|
>;
|
|
273
283
|
}
|
|
@@ -1133,15 +1143,14 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1133
1143
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1134
1144
|
withScopes(callback: (scopes: any) => void): this {
|
|
1135
1145
|
const ModelClass = this._ModelClass;
|
|
1136
|
-
const self = this;
|
|
1137
1146
|
const proxy = new Proxy({} as Record<string, (...args: unknown[]) => void>, {
|
|
1138
|
-
get(_target, prop: string | symbol) {
|
|
1147
|
+
get: (_target, prop: string | symbol) => {
|
|
1139
1148
|
return (...args: unknown[]) => {
|
|
1140
1149
|
const fn = (ModelClass as unknown as Record<string | symbol, unknown>)[prop];
|
|
1141
1150
|
if (typeof fn !== "function") return;
|
|
1142
1151
|
const result = fn(...args) as { apply?: (q: unknown) => void } | null | undefined;
|
|
1143
1152
|
if (result != null && typeof result.apply === "function") {
|
|
1144
|
-
result.apply(
|
|
1153
|
+
result.apply(this);
|
|
1145
1154
|
}
|
|
1146
1155
|
};
|
|
1147
1156
|
},
|
|
@@ -1240,11 +1249,20 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1240
1249
|
|
|
1241
1250
|
const rawKey = column.split(".").pop() ?? column;
|
|
1242
1251
|
const camelKey = rawKey.includes("_") ? _toCamel(rawKey) : rawKey;
|
|
1243
|
-
const casts = _getCasts(this._ModelClass as unknown as
|
|
1244
|
-
const colMeta = columnsFor(this._ModelClass as unknown as
|
|
1252
|
+
const casts = _getCasts(this._ModelClass as unknown as ClassRef);
|
|
1253
|
+
const colMeta = columnsFor(this._ModelClass as unknown as ClassRef)?.get(camelKey);
|
|
1245
1254
|
const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
|
|
1246
1255
|
const colType = colMeta?.type;
|
|
1247
1256
|
|
|
1257
|
+
// Before anything binds: an encrypted column cannot be compared. Running the
|
|
1258
|
+
// cast's set() here would encrypt the search term under a fresh IV, producing
|
|
1259
|
+
// ciphertext that cannot equal what is stored — a query that always returns
|
|
1260
|
+
// nothing and never says why. Same failure the created_at note below describes,
|
|
1261
|
+
// and permanent rather than occasional, so it is refused outright.
|
|
1262
|
+
if (isEncryptedCast(castOpt)) {
|
|
1263
|
+
throw encryptedQueryError(`${this._ModelClass.name}.${camelKey}`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1248
1266
|
if (operator === "in" || operator === "not in") {
|
|
1249
1267
|
if (Array.isArray(value)) {
|
|
1250
1268
|
return value.map((v) => this._coerceWhereValue(column, v));
|
|
@@ -1306,9 +1324,9 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1306
1324
|
if (spec.children.length > 0 && related.length > 0) {
|
|
1307
1325
|
if (meta.type === "morphTo") {
|
|
1308
1326
|
// Mixed related classes — group by constructor and recurse per group.
|
|
1309
|
-
const groups = new Map<
|
|
1327
|
+
const groups = new Map<ClassRef, BaseModel[]>();
|
|
1310
1328
|
for (const r of related) {
|
|
1311
|
-
const ctor = r.constructor as
|
|
1329
|
+
const ctor = r.constructor as ClassRef;
|
|
1312
1330
|
if (!groups.has(ctor)) groups.set(ctor, []);
|
|
1313
1331
|
groups.get(ctor)!.push(r);
|
|
1314
1332
|
}
|
package/src/model/Observer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
|
|
2
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Observer interface — implement any subset of lifecycle methods to react to a
|
|
@@ -61,7 +62,7 @@ const _methodToHook: Record<keyof ModelObserver, HookName> = {
|
|
|
61
62
|
* @param ObserverClass - An observer class (zero-arg constructor) implementing any subset of {@link ModelObserver}.
|
|
62
63
|
* @internal
|
|
63
64
|
*/
|
|
64
|
-
export function registerObserver<T>(ModelClass:
|
|
65
|
+
export function registerObserver<T>(ModelClass: ClassRef, ObserverClass: ObserverClass<T>): void {
|
|
65
66
|
const instance = new ObserverClass();
|
|
66
67
|
|
|
67
68
|
for (const [method, hook] of Object.entries(_methodToHook) as [keyof ModelObserver, HookName][]) {
|
package/src/model/OrmContext.ts
CHANGED
|
@@ -25,11 +25,11 @@ export class OrmContext {
|
|
|
25
25
|
/** Connections registered by name, selectable via `static connection`. */
|
|
26
26
|
namedConnections = new Map<string, SQLInstance>();
|
|
27
27
|
/** Per-model `onTransition` callbacks, keyed by target state (see the `State` mixin). */
|
|
28
|
-
transitionCallbacks = new Map<
|
|
28
|
+
transitionCallbacks = new Map<ClassRef, Map<string, unknown[]>>();
|
|
29
29
|
/** Per-model registered global query scopes. */
|
|
30
|
-
globalScopes = new Map<
|
|
30
|
+
globalScopes = new Map<ClassRef, Map<string, unknown>>();
|
|
31
31
|
/** Per-model lifecycle hooks. */
|
|
32
|
-
hooks = new Map<
|
|
32
|
+
hooks = new Map<ClassRef, Map<string, unknown[]>>();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
let _ctx = new OrmContext();
|
|
@@ -58,6 +58,7 @@ export function resetOrmContext(): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
import { registerAppScope } from "@zerotal/core";
|
|
61
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
61
62
|
|
|
62
63
|
let _appScopeRegistered = false;
|
|
63
64
|
if (!_appScopeRegistered) {
|
package/src/model/State.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { StateError } from "../errors/index.ts";
|
|
23
23
|
import { currentOrmContext } from "./OrmContext.ts";
|
|
24
24
|
import type { Constructor } from "./mixins.ts";
|
|
25
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
25
26
|
|
|
26
27
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -87,7 +88,7 @@ export type TransitionCallback<T> = (
|
|
|
87
88
|
// ── Callback registry (execution-scoped on the OrmContext) ────────────────────
|
|
88
89
|
|
|
89
90
|
/** @internal Register or retrieve transition callbacks for a model class. */
|
|
90
|
-
function _getCallbacks(ModelClass:
|
|
91
|
+
function _getCallbacks(ModelClass: ClassRef, state: string): TransitionCallback<unknown>[] {
|
|
91
92
|
const reg = currentOrmContext().transitionCallbacks;
|
|
92
93
|
if (!reg.has(ModelClass)) reg.set(ModelClass, new Map());
|
|
93
94
|
const map = reg.get(ModelClass)!;
|
|
@@ -169,7 +170,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
|
|
|
169
170
|
toState: string,
|
|
170
171
|
callback: TransitionCallback<T>,
|
|
171
172
|
): void {
|
|
172
|
-
_getCallbacks(this as unknown as
|
|
173
|
+
_getCallbacks(this as unknown as ClassRef, toState).push(
|
|
173
174
|
callback as TransitionCallback<unknown>,
|
|
174
175
|
);
|
|
175
176
|
}
|
|
@@ -260,7 +261,7 @@ export function State<TBase extends Constructor>(Base: TBase) {
|
|
|
260
261
|
await (this as unknown as { save(): Promise<unknown> }).save();
|
|
261
262
|
|
|
262
263
|
// Fire registered transition callbacks.
|
|
263
|
-
const map = currentOrmContext().transitionCallbacks.get(this.constructor as
|
|
264
|
+
const map = currentOrmContext().transitionCallbacks.get(this.constructor as ClassRef);
|
|
264
265
|
const meta = { from: currentState, to: newState };
|
|
265
266
|
const toFns = (map?.get(newState) ?? []) as TransitionCallback<unknown>[];
|
|
266
267
|
const anyFns = (map?.get("*") ?? []) as TransitionCallback<unknown>[];
|
|
@@ -18,19 +18,20 @@
|
|
|
18
18
|
import { relationRegistry } from "../relations/RelationRegistry.ts";
|
|
19
19
|
import type { RelationMetadata } from "../relations/RelationRegistry.ts";
|
|
20
20
|
import type { ColumnOptions } from "./column.ts";
|
|
21
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Per-class OWN column definitions. Readers walk the prototype chain to merge inherited.
|
|
24
25
|
* @internal
|
|
25
26
|
*/
|
|
26
|
-
export const columnRegistry = new Map<
|
|
27
|
+
export const columnRegistry = new Map<ClassRef, Map<string, ColumnOptions>>();
|
|
27
28
|
|
|
28
29
|
// ── Definition-time queue ─────────────────────────────────────────────────────
|
|
29
30
|
|
|
30
31
|
interface PendingMember {
|
|
31
32
|
/** Field name (captured correctly in the decorator body). */
|
|
32
33
|
name: string;
|
|
33
|
-
apply: (ctor:
|
|
34
|
+
apply: (ctor: ClassRef) => void;
|
|
34
35
|
}
|
|
35
36
|
let _pending: PendingMember[] = [];
|
|
36
37
|
|
|
@@ -38,7 +39,7 @@ let _pending: PendingMember[] = [];
|
|
|
38
39
|
* Enqueue a member registration from a decorator body (name captured correctly there).
|
|
39
40
|
* @internal
|
|
40
41
|
*/
|
|
41
|
-
export function enqueueMember(name: string, apply: (ctor:
|
|
42
|
+
export function enqueueMember(name: string, apply: (ctor: ClassRef) => void): void {
|
|
42
43
|
_pending.push({ name, apply });
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -48,7 +49,7 @@ export function enqueueMember(name: string, apply: (ctor: Function) => void): vo
|
|
|
48
49
|
* contains exactly that class's members and nothing else.
|
|
49
50
|
* @internal
|
|
50
51
|
*/
|
|
51
|
-
export function drainPendingMembers(ctor:
|
|
52
|
+
export function drainPendingMembers(ctor: ClassRef): void {
|
|
52
53
|
if (_pending.length === 0) return;
|
|
53
54
|
const batch = _pending;
|
|
54
55
|
_pending = [];
|
|
@@ -63,7 +64,7 @@ export function drainPendingMembers(ctor: Function): void {
|
|
|
63
64
|
* `static casts` map (seeded from the parent so a subclass extends rather than mutates it).
|
|
64
65
|
* @internal
|
|
65
66
|
*/
|
|
66
|
-
export function registerColumn(ctor:
|
|
67
|
+
export function registerColumn(ctor: ClassRef, name: string, options: ColumnOptions): void {
|
|
67
68
|
let m = columnRegistry.get(ctor);
|
|
68
69
|
if (!m) {
|
|
69
70
|
m = new Map();
|
|
@@ -89,7 +90,7 @@ export function registerColumn(ctor: Function, name: string, options: ColumnOpti
|
|
|
89
90
|
* Record relation metadata for `ctor`. Invoked from a drained decorator closure.
|
|
90
91
|
* @internal
|
|
91
92
|
*/
|
|
92
|
-
export function registerRelation(ctor:
|
|
93
|
+
export function registerRelation(ctor: ClassRef, name: string, meta: RelationMetadata): void {
|
|
93
94
|
let m = relationRegistry.get(ctor);
|
|
94
95
|
if (!m) {
|
|
95
96
|
m = new Map();
|
|
@@ -100,23 +101,23 @@ export function registerRelation(ctor: Function, name: string, meta: RelationMet
|
|
|
100
101
|
|
|
101
102
|
// ── Convention registration (used by the auto-discovery loader) ───────────────
|
|
102
103
|
|
|
103
|
-
const _registeredModels = new WeakSet<
|
|
104
|
+
const _registeredModels = new WeakSet<ClassRef>();
|
|
104
105
|
/**
|
|
105
106
|
* class name → model class, for observer/policy association by name.
|
|
106
107
|
* @internal
|
|
107
108
|
*/
|
|
108
|
-
export const modelsByName = new Map<string,
|
|
109
|
+
export const modelsByName = new Map<string, ClassRef>();
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
112
|
* Look up a model class by its (unqualified) class name.
|
|
112
113
|
* @internal
|
|
113
114
|
*/
|
|
114
|
-
export function modelByName(name: string):
|
|
115
|
+
export function modelByName(name: string): ClassRef | undefined {
|
|
115
116
|
return modelsByName.get(name);
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/** Index a model class under its name. Called by @table's drain and by registerModel(). */
|
|
119
|
-
function registerModelName(ctor:
|
|
120
|
+
function registerModelName(ctor: ClassRef): void {
|
|
120
121
|
const name = (ctor as { name?: string }).name;
|
|
121
122
|
if (name) modelsByName.set(name, ctor);
|
|
122
123
|
}
|
|
@@ -136,7 +137,7 @@ function registerModelName(ctor: Function): void {
|
|
|
136
137
|
* safe no-op on already-`@table`'d models.
|
|
137
138
|
* @internal
|
|
138
139
|
*/
|
|
139
|
-
export function registerModel(ctor:
|
|
140
|
+
export function registerModel(ctor: ClassRef): void {
|
|
140
141
|
if (_registeredModels.has(ctor)) return;
|
|
141
142
|
_registeredModels.add(ctor);
|
|
142
143
|
|
|
@@ -170,13 +171,13 @@ export function registerModel(ctor: Function): void {
|
|
|
170
171
|
* Merged column definitions (own + inherited) for a class, or null if none.
|
|
171
172
|
* @internal
|
|
172
173
|
*/
|
|
173
|
-
export function columnsFor(ctor:
|
|
174
|
+
export function columnsFor(ctor: ClassRef): Map<string, ColumnOptions> | null {
|
|
174
175
|
const merged = new Map<string, ColumnOptions>();
|
|
175
|
-
let cls:
|
|
176
|
+
let cls: ClassRef | null = ctor;
|
|
176
177
|
while (cls && cls !== Function.prototype) {
|
|
177
178
|
const c = columnRegistry.get(cls);
|
|
178
179
|
if (c) for (const [k, v] of c) if (!merged.has(k)) merged.set(k, v);
|
|
179
|
-
cls = Object.getPrototypeOf(cls) as
|
|
180
|
+
cls = Object.getPrototypeOf(cls) as ClassRef | null;
|
|
180
181
|
}
|
|
181
182
|
return merged.size ? merged : null;
|
|
182
183
|
}
|
|
@@ -185,13 +186,13 @@ export function columnsFor(ctor: Function): Map<string, ColumnOptions> | null {
|
|
|
185
186
|
* Merged relation metadata (imperative mixins + @decorators, own + inherited).
|
|
186
187
|
* @internal
|
|
187
188
|
*/
|
|
188
|
-
export function relationsFor(ctor:
|
|
189
|
+
export function relationsFor(ctor: ClassRef): Map<string, RelationMetadata> {
|
|
189
190
|
const merged = new Map<string, RelationMetadata>();
|
|
190
|
-
let cls:
|
|
191
|
+
let cls: ClassRef | null = ctor;
|
|
191
192
|
while (cls && cls !== Function.prototype) {
|
|
192
193
|
const r = relationRegistry.get(cls);
|
|
193
194
|
if (r) for (const [k, v] of r) if (!merged.has(k)) merged.set(k, v);
|
|
194
|
-
cls = Object.getPrototypeOf(cls) as
|
|
195
|
+
cls = Object.getPrototypeOf(cls) as ClassRef | null;
|
|
195
196
|
}
|
|
196
197
|
return merged;
|
|
197
198
|
}
|
|
@@ -200,7 +201,7 @@ export function relationsFor(ctor: Function): Map<string, RelationMetadata> {
|
|
|
200
201
|
* Names of reactive (json/array cast) columns for a class (own + inherited).
|
|
201
202
|
* @internal
|
|
202
203
|
*/
|
|
203
|
-
export function reactiveColumnsFor(ctor:
|
|
204
|
+
export function reactiveColumnsFor(ctor: ClassRef): string[] {
|
|
204
205
|
const cols = columnsFor(ctor);
|
|
205
206
|
if (!cols) return [];
|
|
206
207
|
const out: string[] = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RelationMetadata } from "../relations/RelationRegistry.ts";
|
|
2
2
|
import { enqueueMember, registerRelation } from "./_metadata.ts";
|
|
3
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The single plumbing for every relation field decorator (standard TC39 decorators).
|
|
@@ -11,7 +12,7 @@ import { enqueueMember, registerRelation } from "./_metadata.ts";
|
|
|
11
12
|
* with that class so morph/through values depending on the class name resolve correctly.
|
|
12
13
|
*/
|
|
13
14
|
export function makeRelationDecorator(
|
|
14
|
-
metaFor: (ctor:
|
|
15
|
+
metaFor: (ctor: ClassRef, field: string) => RelationMetadata,
|
|
15
16
|
) {
|
|
16
17
|
return function (_value: unknown, context: ClassFieldDecoratorContext): void {
|
|
17
18
|
const name = String(context.name);
|