@zerotal/orm 1.4.0 → 1.5.1
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 +106 -0
- package/package.json +3 -3
- 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 +3 -0
- package/src/model/BaseModel.ts +23 -22
- package/src/model/ModelQueryBuilder.ts +10 -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 +3 -2
- 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 +3 -2
- package/src/schema/Schema.ts +62 -2
- package/src/support/classRef.ts +23 -0
- package/src/support/identifiers.ts +5 -4
|
@@ -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);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
columnsFor,
|
|
8
8
|
reactiveColumnsFor,
|
|
9
9
|
} from "./_metadata.ts";
|
|
10
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
10
11
|
|
|
11
12
|
// Re-exported here for the public API — populated at class-definition time via @table.
|
|
12
13
|
export { columnRegistry };
|
|
@@ -331,9 +332,9 @@ export function column(
|
|
|
331
332
|
* @internal
|
|
332
333
|
*/
|
|
333
334
|
export function installReactiveAccessors(instance: object): void {
|
|
334
|
-
const reactive = reactiveColumnsFor(instance.constructor as
|
|
335
|
+
const reactive = reactiveColumnsFor(instance.constructor as ClassRef);
|
|
335
336
|
if (!reactive.length) return;
|
|
336
|
-
const cols = columnsFor(instance.constructor as
|
|
337
|
+
const cols = columnsFor(instance.constructor as ClassRef);
|
|
337
338
|
for (const name of reactive) {
|
|
338
339
|
const desc = Object.getOwnPropertyDescriptor(instance, name);
|
|
339
340
|
if (desc && typeof desc.get === "function") continue; // already installed
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { drainPendingMembers } from "./_metadata.ts";
|
|
2
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Options object accepted as the second argument to `@table()`.
|
|
@@ -50,7 +51,7 @@ interface TableConfig {
|
|
|
50
51
|
*/
|
|
51
52
|
export interface TableDecoratorBuilder {
|
|
52
53
|
/** Apply the decorator to a class constructor (called automatically by TS). */
|
|
53
|
-
(target:
|
|
54
|
+
(target: ClassRef, context?: unknown): void;
|
|
54
55
|
|
|
55
56
|
/** Enable automatic `created_at` / `updated_at` management. Default: on. */
|
|
56
57
|
withTimestamps(): TableDecoratorBuilder;
|
|
@@ -92,7 +93,7 @@ export function table(tableName: string, options: TableOptions = {}): TableDecor
|
|
|
92
93
|
primaryKey: options.primaryKey ?? "id",
|
|
93
94
|
};
|
|
94
95
|
|
|
95
|
-
function apply(target:
|
|
96
|
+
function apply(target: ClassRef, _context?: unknown): void {
|
|
96
97
|
// Drain the @column / @relation registrations queued while this class's members were
|
|
97
98
|
// decorated. @table is the definition-time anchor that owns this — see _metadata.ts.
|
|
98
99
|
drainPendingMembers(target);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { currentOrmContext } from "../OrmContext.ts";
|
|
3
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The set of model lifecycle points a hook can attach to. Each fires once per
|
|
@@ -19,8 +20,8 @@ export type HookName =
|
|
|
19
20
|
|
|
20
21
|
type HookFn<T> = (model: T) => Promise<void> | void;
|
|
21
22
|
|
|
22
|
-
function _registry(): Map<
|
|
23
|
-
return currentOrmContext().hooks as unknown as Map<
|
|
23
|
+
function _registry(): Map<ClassRef, Map<HookName, HookFn<unknown>[]>> {
|
|
24
|
+
return currentOrmContext().hooks as unknown as Map<ClassRef, Map<HookName, HookFn<unknown>[]>>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -56,7 +57,7 @@ export class HookRegistry {
|
|
|
56
57
|
* Optional post-run callback, invoked after a hook's functions run (and only when hooks
|
|
57
58
|
* are not suppressed). BaseModel sets this to dispatch model events (`dispatchesEvents`).
|
|
58
59
|
*/
|
|
59
|
-
static onAfterRun: ((ModelClass:
|
|
60
|
+
static onAfterRun: ((ModelClass: ClassRef, hook: HookName, model: unknown) => void) | undefined;
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
63
|
* Append a hook callback for a model class at a given lifecycle point.
|
|
@@ -65,7 +66,7 @@ export class HookRegistry {
|
|
|
65
66
|
* @param hook - Which lifecycle point to fire on.
|
|
66
67
|
* @param fn - Callback receiving the model instance; may be async.
|
|
67
68
|
*/
|
|
68
|
-
static register<T>(ModelClass:
|
|
69
|
+
static register<T>(ModelClass: ClassRef, hook: HookName, fn: HookFn<T>): void {
|
|
69
70
|
const registry = _registry();
|
|
70
71
|
if (!registry.has(ModelClass)) {
|
|
71
72
|
registry.set(ModelClass, new Map());
|
|
@@ -84,15 +85,15 @@ export class HookRegistry {
|
|
|
84
85
|
* @param hook - Which lifecycle point is firing.
|
|
85
86
|
* @param model - The model instance passed to each callback.
|
|
86
87
|
*/
|
|
87
|
-
static async run<T>(ModelClass:
|
|
88
|
+
static async run<T>(ModelClass: ClassRef, hook: HookName, model: T): Promise<void> {
|
|
88
89
|
if (_suppressCtx.getStore()) return;
|
|
89
90
|
|
|
90
91
|
// Walk the prototype chain to collect inherited hooks
|
|
91
|
-
const chain:
|
|
92
|
-
let cur:
|
|
92
|
+
const chain: ClassRef[] = [];
|
|
93
|
+
let cur: ClassRef | null = ModelClass;
|
|
93
94
|
while (cur && cur !== Function.prototype) {
|
|
94
95
|
chain.unshift(cur);
|
|
95
|
-
cur = Object.getPrototypeOf(cur) as
|
|
96
|
+
cur = Object.getPrototypeOf(cur) as ClassRef | null;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
const registry = _registry();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ClassRef } from "../../support/classRef.ts";
|
|
2
|
+
|
|
1
3
|
/** Discriminator for every relation kind the ORM supports. */
|
|
2
4
|
export type RelationType =
|
|
3
5
|
| "hasMany"
|
|
@@ -70,7 +72,7 @@ export type RelationDefinition = RelationMetadata;
|
|
|
70
72
|
* relation (property) name. Populated by the relation decorators at class-definition
|
|
71
73
|
* time and consulted by {@link ModelQueryBuilder} when resolving a relation.
|
|
72
74
|
*/
|
|
73
|
-
export const relationRegistry = new Map<
|
|
75
|
+
export const relationRegistry = new Map<ClassRef, Map<string, RelationMetadata>>();
|
|
74
76
|
|
|
75
77
|
// ── Pivot collection ─────────────────────────────────────────────────────────
|
|
76
78
|
|
package/src/observability.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function installOrmObservability(app: Application): () => void {
|
|
|
100
100
|
if (e.ctx) store.markNPlus(e.ctx);
|
|
101
101
|
store.recordEvent({
|
|
102
102
|
kind: "nplus",
|
|
103
|
-
label: e.fingerprint.
|
|
103
|
+
label: e.fingerprint.replaceAll("\x00", "?"),
|
|
104
104
|
status: "warn",
|
|
105
105
|
route: e.ctx ? _ctxPath(e.ctx) : null,
|
|
106
106
|
data: { count: e.count },
|
|
@@ -161,7 +161,7 @@ export function installOrmObservability(app: Application): () => void {
|
|
|
161
161
|
}),
|
|
162
162
|
FrameworkEvents.on(NPlusOneDetected, (e) => {
|
|
163
163
|
if (!e.ctx) return;
|
|
164
|
-
trace.bufferWarning(e.ctx, { sql: e.fingerprint.
|
|
164
|
+
trace.bufferWarning(e.ctx, { sql: e.fingerprint.replaceAll("\x00", "?"), count: e.count });
|
|
165
165
|
}),
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SQLInstance } from "../db/sql-types.ts";
|
|
2
|
-
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import { ServiceProvider, registerErrorDiagnoser, isProdLike, deployEnv } from "@zerotal/core";
|
|
3
3
|
import type { AppEnvironment } from "@zerotal/core";
|
|
4
4
|
import type { ConfigManager } from "@zerotal/core/config";
|
|
5
5
|
import { SQL } from "bun";
|
|
@@ -17,6 +17,12 @@ import { validateDatabaseConfig } from "../config.ts";
|
|
|
17
17
|
import { autoMigrateConcern } from "../schema/autoMigrate.ts";
|
|
18
18
|
import { registerImplicitBinding } from "../implicitBinding.ts";
|
|
19
19
|
import { installOrmObservability } from "../observability.ts";
|
|
20
|
+
import { diagnoseMissingRelation } from "../diagnostics/missingRelation.ts";
|
|
21
|
+
import {
|
|
22
|
+
registerRunMigrationsEndpoint,
|
|
23
|
+
RUN_MIGRATIONS_PATH,
|
|
24
|
+
_mintDiagnosisToken,
|
|
25
|
+
} from "../diagnostics/runMigrationsEndpoint.ts";
|
|
20
26
|
|
|
21
27
|
// Extend the core container registry so 'db' is a typed binding.
|
|
22
28
|
declare module "@zerotal/core" {
|
|
@@ -65,6 +71,19 @@ export class DatabaseProvider extends ServiceProvider {
|
|
|
65
71
|
// route-compile time, so model registration order doesn't matter.
|
|
66
72
|
registerImplicitBinding();
|
|
67
73
|
|
|
74
|
+
// "no such table: assets" arrives with a stack that is entirely SQL-driver
|
|
75
|
+
// frames, so the overlay can name the failure and nothing else. This turns it
|
|
76
|
+
// into the list of migrations that have not run — and, when none are pending,
|
|
77
|
+
// says so instead of offering a button that would change nothing.
|
|
78
|
+
registerErrorDiagnoser((error) =>
|
|
79
|
+
diagnoseMissingRelation(error, {
|
|
80
|
+
endpoint: RUN_MIGRATIONS_PATH,
|
|
81
|
+
mintToken: _mintDiagnosisToken,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
// Registers nothing outside development. See the file for the three guards.
|
|
85
|
+
registerRunMigrationsEndpoint(() => this.app._allowedOrigins?.() ?? []);
|
|
86
|
+
|
|
68
87
|
setConnectionResolver(() => {
|
|
69
88
|
try {
|
|
70
89
|
return this.app.container.makeSync("db") as SQLInstance;
|
|
@@ -180,8 +199,12 @@ export class DatabaseProvider extends ServiceProvider {
|
|
|
180
199
|
|
|
181
200
|
// N+1 query detection — enabled outside production. Previously activated by
|
|
182
201
|
// the devtools provider; owned here so devtools needs no ORM import.
|
|
183
|
-
|
|
184
|
-
|
|
202
|
+
//
|
|
203
|
+
// `deployEnv()`, not `Bun.env.APP_ENV`: the latter holds the runtime mode by
|
|
204
|
+
// the time a provider boots (`setAppEnv()` overwrote it), so this read was
|
|
205
|
+
// `"web"` and the detector was installed in production too — wrapping every
|
|
206
|
+
// query on a live app to warn about something nobody was there to read.
|
|
207
|
+
if (!isProdLike(deployEnv())) {
|
|
185
208
|
preventNPlusOne({ threshold: 5, mode: "warn" });
|
|
186
209
|
}
|
|
187
210
|
|
|
@@ -199,6 +222,12 @@ export class DatabaseProvider extends ServiceProvider {
|
|
|
199
222
|
runner.registerLazy("migrate:fresh", () =>
|
|
200
223
|
import("../commands/MigrateFreshCommand.ts").then((m) => m.MigrateFreshCommand),
|
|
201
224
|
);
|
|
225
|
+
// Same command, the name it has elsewhere. Nothing otherwise pushes anyone to
|
|
226
|
+
// exercise their `down()` methods, and a rollback nobody has run is a
|
|
227
|
+
// rollback that does not work.
|
|
228
|
+
runner.registerLazy("migrate:refresh", () =>
|
|
229
|
+
import("../commands/MigrateRefreshCommand.ts").then((m) => m.MigrateRefreshCommand),
|
|
230
|
+
);
|
|
202
231
|
runner.registerLazy("migrate:status", () =>
|
|
203
232
|
import("../commands/MigrateStatusCommand.ts").then((m) => m.MigrateStatusCommand),
|
|
204
233
|
);
|
package/src/schema/Blueprint.ts
CHANGED
|
@@ -648,6 +648,19 @@ export class Blueprint {
|
|
|
648
648
|
return this;
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
/**
|
|
652
|
+
* The columns this blueprint will drop.
|
|
653
|
+
*
|
|
654
|
+
* Read by {@link Schema.table} so it can refuse an impossible drop on SQLite
|
|
655
|
+
* *before* running any statement, rather than after the earlier ones have
|
|
656
|
+
* already applied.
|
|
657
|
+
*
|
|
658
|
+
* @internal
|
|
659
|
+
*/
|
|
660
|
+
get _pendingDrops(): readonly string[] {
|
|
661
|
+
return this._drops;
|
|
662
|
+
}
|
|
663
|
+
|
|
651
664
|
/**
|
|
652
665
|
* Rename a column (`ALTER TABLE … RENAME COLUMN from TO to`).
|
|
653
666
|
* @category Table modifiers
|
|
@@ -893,7 +906,7 @@ function _postgresAlterStatements(table: string, col: IAlterCol): string[] {
|
|
|
893
906
|
}
|
|
894
907
|
|
|
895
908
|
// NOT NULL / nullable.
|
|
896
|
-
if (
|
|
909
|
+
if (/\bNOT NULL\b/i.test(afterName)) {
|
|
897
910
|
stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} SET NOT NULL`);
|
|
898
911
|
} else {
|
|
899
912
|
stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} DROP NOT NULL`);
|
|
@@ -901,7 +914,7 @@ function _postgresAlterStatements(table: string, col: IAlterCol): string[] {
|
|
|
901
914
|
|
|
902
915
|
// DEFAULT.
|
|
903
916
|
const defMatch = afterName.match(
|
|
904
|
-
|
|
917
|
+
/\bDEFAULT\s+(\S+(?:\s+\S+)*?)(?:\s+(?:NOT NULL|NULL|UNIQUE|CHECK|GENERATED)\b|$)/i,
|
|
905
918
|
);
|
|
906
919
|
if (defMatch) {
|
|
907
920
|
stmts.push(`ALTER TABLE ${table} ALTER COLUMN ${name} SET DEFAULT ${defMatch[1]}`);
|
|
@@ -362,7 +362,7 @@ export class ColumnBuilder<Locked extends string = never> {
|
|
|
362
362
|
* table.foreignId('author_id').constrained('users', 'uuid'); // → users.uuid
|
|
363
363
|
* table.foreignId('user_id').nullable().constrained().nullOnDelete();
|
|
364
364
|
*/
|
|
365
|
-
export class ForeignIdColumnBuilder extends ColumnBuilder {
|
|
365
|
+
export class ForeignIdColumnBuilder<Locked extends string = never> extends ColumnBuilder<Locked> {
|
|
366
366
|
constructor(
|
|
367
367
|
name: string,
|
|
368
368
|
sqlType: string,
|
|
@@ -371,6 +371,40 @@ export class ForeignIdColumnBuilder extends ColumnBuilder {
|
|
|
371
371
|
super(name, sqlType);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Allow NULL, keeping `.constrained()` reachable.
|
|
376
|
+
*
|
|
377
|
+
* The base `nullable()` returns `ColumnBuilder`, which drops the subclass — so
|
|
378
|
+
* the documented `foreignId('user_id').nullable().constrained()` did not
|
|
379
|
+
* compile, and a nullable foreign key is the commonest kind there is. These
|
|
380
|
+
* two overrides re-declare the return as this builder while keeping the
|
|
381
|
+
* phantom lock, so `.nullable().notNullable()` is still a compile error.
|
|
382
|
+
*
|
|
383
|
+
* @locked `nullability` — shared with `notNullable()`.
|
|
384
|
+
* @category Nullability & defaults
|
|
385
|
+
*/
|
|
386
|
+
override nullable(): "nullability" extends Locked
|
|
387
|
+
? never
|
|
388
|
+
: ForeignIdColumnBuilder<Locked | "nullability"> {
|
|
389
|
+
super.nullable();
|
|
390
|
+
// `as any` per this file's own convention (see the header note): TypeScript
|
|
391
|
+
// cannot reduce a deferred conditional inside a generic body.
|
|
392
|
+
return this as any;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Enforce NOT NULL explicitly, keeping `.constrained()` reachable.
|
|
397
|
+
*
|
|
398
|
+
* @locked `nullability` — shared with `nullable()`.
|
|
399
|
+
* @category Nullability & defaults
|
|
400
|
+
*/
|
|
401
|
+
override notNullable(): "nullability" extends Locked
|
|
402
|
+
? never
|
|
403
|
+
: ForeignIdColumnBuilder<Locked | "nullability"> {
|
|
404
|
+
super.notNullable();
|
|
405
|
+
return this as any;
|
|
406
|
+
}
|
|
407
|
+
|
|
374
408
|
/**
|
|
375
409
|
* Add a `FOREIGN KEY` constraint for this column. The referenced table is
|
|
376
410
|
* inferred from the column name (`user_id` → `users`) unless supplied
|
|
@@ -3,6 +3,7 @@ import type { ColumnOptions } from "../model/decorators/column.ts";
|
|
|
3
3
|
import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
|
|
4
4
|
import { ctorChain } from "../support/identifiers.ts";
|
|
5
5
|
import { isEncryptedCast } from "../casts/encrypted.ts";
|
|
6
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
6
7
|
|
|
7
8
|
// ── Model schema descriptor ───────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -50,7 +51,7 @@ export function columnDbName(name: string): string {
|
|
|
50
51
|
* declared on `User` appear in `AdminUser`'s schema without needing to be
|
|
51
52
|
* re-declared.
|
|
52
53
|
*/
|
|
53
|
-
function collectColumns(ctor:
|
|
54
|
+
function collectColumns(ctor: ClassRef): Map<string, ColumnOptions> | null {
|
|
54
55
|
// columnsFor walks the prototype chain (child overrides parent) and mirrors each
|
|
55
56
|
// class's metadata into columnRegistry on first read.
|
|
56
57
|
return columnsFor(ctor);
|
|
@@ -147,7 +148,7 @@ export const ModelInspector = {
|
|
|
147
148
|
* Returns null if the class has no `static table` or no `@column()` fields
|
|
148
149
|
* anywhere in its prototype chain.
|
|
149
150
|
*/
|
|
150
|
-
fromClass(ctor:
|
|
151
|
+
fromClass(ctor: ClassRef): ModelSchema | null {
|
|
151
152
|
const M = ctor as unknown as Record<string, unknown>;
|
|
152
153
|
const table = M["table"] as string | undefined;
|
|
153
154
|
if (!table) return null;
|
package/src/schema/Schema.ts
CHANGED
|
@@ -28,6 +28,54 @@ async function query<T = Record<string, unknown>>(sql: string, params: unknown[]
|
|
|
28
28
|
return conn<T>(tpl, ...params);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Throw if a column this blueprint drops is named by a foreign key on the table.
|
|
33
|
+
*
|
|
34
|
+
* SQLite has no way to drop an FK constraint through `ALTER TABLE`, so while the
|
|
35
|
+
* constraint names the column the column cannot go — the engine answers
|
|
36
|
+
* `unknown column "x" in foreign key definition`, and it answers *after* every
|
|
37
|
+
* earlier statement in the block has run. The standard way out is SQLite's own
|
|
38
|
+
* 12-step table rebuild; until that exists here, failing before the
|
|
39
|
+
* first statement is the difference between a migration that did nothing and
|
|
40
|
+
* one that has to be unpicked by hand.
|
|
41
|
+
*
|
|
42
|
+
* The message names the constraint and the way out, because "rebuild the table"
|
|
43
|
+
* is not obvious from the engine's own error.
|
|
44
|
+
*/
|
|
45
|
+
async function _assertDroppableOnSqlite(table: string, bp: Blueprint): Promise<void> {
|
|
46
|
+
const drops = bp._pendingDrops;
|
|
47
|
+
if (drops.length === 0) return;
|
|
48
|
+
|
|
49
|
+
// `PRAGMA foreign_key_list` takes no bind parameters, and the table name here
|
|
50
|
+
// comes from the migration's own source rather than from a request.
|
|
51
|
+
const fks = await query<{ id: number; from: string; table: string }>(
|
|
52
|
+
`PRAGMA foreign_key_list(${table})`,
|
|
53
|
+
[],
|
|
54
|
+
);
|
|
55
|
+
if (fks.length === 0) return;
|
|
56
|
+
|
|
57
|
+
const blocked = drops.filter((column) =>
|
|
58
|
+
fks.some((fk) => String(fk.from).toLowerCase() === column.toLowerCase()),
|
|
59
|
+
);
|
|
60
|
+
if (blocked.length === 0) return;
|
|
61
|
+
|
|
62
|
+
const referenced = blocked
|
|
63
|
+
.map((column) => {
|
|
64
|
+
const fk = fks.find((f) => String(f.from).toLowerCase() === column.toLowerCase());
|
|
65
|
+
return `'${column}' (references ${fk?.table ?? "another table"})`;
|
|
66
|
+
})
|
|
67
|
+
.join(", ");
|
|
68
|
+
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[Zerotal ORM] SQLite cannot drop ${referenced} from '${table}' while a foreign key ` +
|
|
71
|
+
`names the column — the constraint has to go first, and SQLite cannot drop one through ` +
|
|
72
|
+
`ALTER TABLE.\n\n` +
|
|
73
|
+
`Rebuild the table instead: create a replacement with the columns you want, copy the ` +
|
|
74
|
+
`rows across, drop the original, and rename. Nothing has been applied — this migration ` +
|
|
75
|
+
`stopped before its first statement, so the schema is exactly as it was.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
31
79
|
// ── Schema facade ─────────────────────────────────────────────────────────────
|
|
32
80
|
|
|
33
81
|
/**
|
|
@@ -96,7 +144,19 @@ export const Schema = {
|
|
|
96
144
|
async table(name: string, callback: (bp: Blueprint) => void): Promise<void> {
|
|
97
145
|
const bp = new Blueprint();
|
|
98
146
|
callback(bp);
|
|
99
|
-
|
|
147
|
+
const dialect = _getDialect();
|
|
148
|
+
|
|
149
|
+
// Refuse before the first statement, not in the middle of the list.
|
|
150
|
+
//
|
|
151
|
+
// SQLite cannot drop a column a foreign key still names, and the error it
|
|
152
|
+
// raises — `unknown column "x" in foreign key definition` — arrives *after*
|
|
153
|
+
// every earlier statement in the same `Schema.table()` block has run. That
|
|
154
|
+
// is the difference between a migration that does nothing and one that has
|
|
155
|
+
// to be unpicked by hand. The check costs a single PRAGMA on the only path
|
|
156
|
+
// that can hit it.
|
|
157
|
+
if (dialect === "sqlite") await _assertDroppableOnSqlite(name, bp);
|
|
158
|
+
|
|
159
|
+
for (const sql of bp.toAlterSQL(name, dialect)) {
|
|
100
160
|
await ddl(sql);
|
|
101
161
|
}
|
|
102
162
|
},
|
|
@@ -104,7 +164,7 @@ export const Schema = {
|
|
|
104
164
|
/**
|
|
105
165
|
* Alias of {@link Schema.table}, for modifying an existing table.
|
|
106
166
|
*
|
|
107
|
-
* `alter` is the name
|
|
167
|
+
* `alter` is the name most schema builders use, so it is the first thing reached for — and
|
|
108
168
|
* because the blueprint callback is loosely typed, `Schema.alter(...)` was not a type
|
|
109
169
|
* error, only a `TypeError` at run time. A migration that fails there has already run
|
|
110
170
|
* whatever statements preceded it, leaving the schema half-changed, which is a worse
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ORM's class-keyed registries — columns, relations, hooks, observers, global
|
|
3
|
+
* scopes, state-machine callbacks — all key on the model class and read back by
|
|
4
|
+
* walking its prototype chain. They share the framework's `ClassRef` rather than
|
|
5
|
+
* spelling the constructor type per registry; this module exists so the ORM's own
|
|
6
|
+
* modules import it from one place.
|
|
7
|
+
*/
|
|
8
|
+
import type { ClassRef as CoreClassRef } from "@zerotal/core";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A model class used as a metadata key — the constructor, not an instance.
|
|
12
|
+
*
|
|
13
|
+
* This is the framework-wide `ClassRef` from `@zerotal/core`, re-exported because
|
|
14
|
+
* every ORM signature that registers or reads per-class metadata takes one:
|
|
15
|
+
* `registerColumn`, `registerRelation`, `columnsFor`, `relationsFor`,
|
|
16
|
+
* `registerObserver` and the hook registry all key on it. Being `abstract` with
|
|
17
|
+
* `never[]` constructor arguments, it accepts abstract bases and mixin-composed
|
|
18
|
+
* classes alike, while rejecting the plain callbacks the old `Function` typing let
|
|
19
|
+
* through.
|
|
20
|
+
*
|
|
21
|
+
* @internal Every ORM signature that takes one is itself `@internal`.
|
|
22
|
+
*/
|
|
23
|
+
export type ClassRef = CoreClassRef;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* helper that reads a property off a hydrated instance must derive the same
|
|
13
13
|
* name hydration produced.
|
|
14
14
|
*/
|
|
15
|
+
import type { ClassRef } from "./classRef.ts";
|
|
15
16
|
|
|
16
17
|
// DB schemas are static: the same names appear on every row. Caching the regex
|
|
17
18
|
// result turns thousands of executions into Map lookups after the first query.
|
|
@@ -51,12 +52,12 @@ export function toSnakeColumn(s: string): string {
|
|
|
51
52
|
* `Function.prototype` (not a falsy `.name`) so anonymous mixin classes are
|
|
52
53
|
* still visited. Shared by cast collection and global-scope merging.
|
|
53
54
|
*/
|
|
54
|
-
export function ctorChain(ctor:
|
|
55
|
-
const chain:
|
|
56
|
-
let current:
|
|
55
|
+
export function ctorChain(ctor: ClassRef): ClassRef[] {
|
|
56
|
+
const chain: ClassRef[] = [];
|
|
57
|
+
let current: ClassRef | null = ctor;
|
|
57
58
|
while (current && current !== Function.prototype) {
|
|
58
59
|
chain.unshift(current);
|
|
59
|
-
current = Object.getPrototypeOf(current) as
|
|
60
|
+
current = Object.getPrototypeOf(current) as ClassRef | null;
|
|
60
61
|
}
|
|
61
62
|
return chain;
|
|
62
63
|
}
|