@c9up/atlas 0.1.13 → 0.1.14
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/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +12 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +19 -1
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/console/schemaCheckCommand.d.ts +37 -0
- package/dist/console/schemaCheckCommand.d.ts.map +1 -0
- package/dist/console/schemaCheckCommand.js +49 -0
- package/dist/console/schemaCheckCommand.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/schema/Migration.d.ts +4 -3
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +4 -3
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts +61 -0
- package/dist/schema/SchemaCheck.d.ts.map +1 -0
- package/dist/schema/SchemaCheck.js +204 -0
- package/dist/schema/SchemaCheck.js.map +1 -0
- package/dist/schema/TableBuilder.d.ts +7 -1
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +12 -5
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/introspect.d.ts +34 -0
- package/dist/schema/introspect.d.ts.map +1 -0
- package/dist/schema/introspect.js +83 -0
- package/dist/schema/introspect.js.map +1 -0
- package/dist/schema/raw.d.ts +31 -0
- package/dist/schema/raw.d.ts.map +1 -0
- package/dist/schema/raw.js +42 -0
- package/dist/schema/raw.js.map +1 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/AtlasProvider.ts +29 -1
- package/src/console/schemaCheckCommand.ts +62 -0
- package/src/index.ts +19 -0
- package/src/schema/Migration.ts +8 -5
- package/src/schema/SchemaCheck.ts +273 -0
- package/src/schema/TableBuilder.ts +13 -5
- package/src/schema/introspect.ts +133 -0
- package/src/schema/raw.ts +41 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema verification — reconciles each model's `@Column` metadata against the
|
|
3
|
+
* LIVE database schema (via {@link introspectTable}) and reports drift BEFORE
|
|
4
|
+
* it bites at runtime. This is what pure-JS ORMs (Lucid) structurally cannot
|
|
5
|
+
* do; atlas can because its driver introspects the real database.
|
|
6
|
+
*
|
|
7
|
+
* Four drift categories:
|
|
8
|
+
* - `missing-table` — the model's table does not exist.
|
|
9
|
+
* - `missing-in-db` — a model column maps to a non-existent DB column
|
|
10
|
+
* (typo → `did you mean`).
|
|
11
|
+
* - `type-mismatch` — a declared `@Column({ type })` clashes with the DB
|
|
12
|
+
* column's type (conservative: only clear num↔text).
|
|
13
|
+
* - `missing-in-model` — a NOT NULL DB column with no default that no model
|
|
14
|
+
* property maps to → inserts will fail.
|
|
15
|
+
*
|
|
16
|
+
* The check is dialect-agnostic; only {@link introspectTable} is dialect-aware.
|
|
17
|
+
* Kept free of any `@c9up/ream` import — atlas stays framework-agnostic.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getColumnMetadata, getEntityMetadata } from "../decorators/entity.js";
|
|
21
|
+
import { getNamingStrategy } from "../naming/NamingStrategy.js";
|
|
22
|
+
import type { AtlasDialect } from "../query/native.js";
|
|
23
|
+
import {
|
|
24
|
+
type IntrospectedColumn,
|
|
25
|
+
introspectTable,
|
|
26
|
+
type SchemaIntrospectable,
|
|
27
|
+
} from "./introspect.js";
|
|
28
|
+
|
|
29
|
+
type Constructor = new (...args: unknown[]) => unknown;
|
|
30
|
+
|
|
31
|
+
export type SchemaFindingKind =
|
|
32
|
+
| "missing-table"
|
|
33
|
+
| "missing-in-db"
|
|
34
|
+
| "type-mismatch"
|
|
35
|
+
| "missing-in-model";
|
|
36
|
+
|
|
37
|
+
export interface SchemaFinding {
|
|
38
|
+
entity: string;
|
|
39
|
+
table: string;
|
|
40
|
+
kind: SchemaFindingKind;
|
|
41
|
+
column: string;
|
|
42
|
+
detail: string;
|
|
43
|
+
/** A close DB column name, for typo diagnostics (`did you mean`). */
|
|
44
|
+
suggestion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Type compatibility (conservative) ───────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coarse group of a type string. We only ever flag a mismatch between the two
|
|
51
|
+
* UNAMBIGUOUS groups (`num` vs `text`); everything storage-dependent
|
|
52
|
+
* (date/time, binary, blob — SQLite stores these as TEXT/NUMERIC) is `other`
|
|
53
|
+
* and never flagged, so the check has NO false positives.
|
|
54
|
+
*/
|
|
55
|
+
function typeGroup(raw: string): "num" | "text" | "other" {
|
|
56
|
+
const t = raw.toLowerCase();
|
|
57
|
+
if (/(^|[^a-z])(int|serial|decimal|numeric|real|float|double|bool)/.test(t)) {
|
|
58
|
+
return "num";
|
|
59
|
+
}
|
|
60
|
+
if (/char|text|clob|string|uuid|json/.test(t)) return "text";
|
|
61
|
+
return "other";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compatible unless the model and DB types are in clearly-different groups. */
|
|
65
|
+
export function typesCompatible(modelType: string, dbType: string): boolean {
|
|
66
|
+
const m = typeGroup(modelType);
|
|
67
|
+
const d = typeGroup(dbType);
|
|
68
|
+
if (m === "other" || d === "other") return true;
|
|
69
|
+
return m === d;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── `did you mean` (atlas-local; no @c9up/ream import) ───────────────
|
|
73
|
+
|
|
74
|
+
function levenshtein(a: string, b: string): number {
|
|
75
|
+
const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
76
|
+
for (let i = 1; i <= a.length; i++) {
|
|
77
|
+
let prev = dp[0];
|
|
78
|
+
dp[0] = i;
|
|
79
|
+
for (let j = 1; j <= b.length; j++) {
|
|
80
|
+
const tmp = dp[j];
|
|
81
|
+
dp[j] = Math.min(
|
|
82
|
+
dp[j] + 1,
|
|
83
|
+
dp[j - 1] + 1,
|
|
84
|
+
prev + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
85
|
+
);
|
|
86
|
+
prev = tmp;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return dp[b.length];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Closest candidate within edit distance 2 (typo suggestion), else undefined. */
|
|
93
|
+
export function suggestColumn(
|
|
94
|
+
name: string,
|
|
95
|
+
candidates: string[],
|
|
96
|
+
): string | undefined {
|
|
97
|
+
let best: string | undefined;
|
|
98
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
99
|
+
for (const c of candidates) {
|
|
100
|
+
const d = levenshtein(name, c);
|
|
101
|
+
if (d < bestD) {
|
|
102
|
+
bestD = d;
|
|
103
|
+
best = c;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return bestD <= 2 ? best : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Reconciler ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function reconcile(
|
|
112
|
+
entityName: string,
|
|
113
|
+
table: string,
|
|
114
|
+
cols: ReturnType<typeof getColumnMetadata>,
|
|
115
|
+
columnNameOf: (property: string) => string,
|
|
116
|
+
dbCols: IntrospectedColumn[],
|
|
117
|
+
): SchemaFinding[] {
|
|
118
|
+
const findings: SchemaFinding[] = [];
|
|
119
|
+
const dbByName = new Map(dbCols.map((c) => [c.name, c]));
|
|
120
|
+
const dbNames = dbCols.map((c) => c.name);
|
|
121
|
+
const mappedDbColumns = new Set<string>();
|
|
122
|
+
|
|
123
|
+
for (const col of cols) {
|
|
124
|
+
const dbName = columnNameOf(col.propertyKey);
|
|
125
|
+
mappedDbColumns.add(dbName);
|
|
126
|
+
const dbCol = dbByName.get(dbName);
|
|
127
|
+
if (!dbCol) {
|
|
128
|
+
findings.push({
|
|
129
|
+
entity: entityName,
|
|
130
|
+
table,
|
|
131
|
+
kind: "missing-in-db",
|
|
132
|
+
column: dbName,
|
|
133
|
+
detail: `model property \`${col.propertyKey}\` maps to column \`${dbName}\`, which does not exist`,
|
|
134
|
+
suggestion: suggestColumn(dbName, dbNames),
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (col.type && !typesCompatible(col.type, dbCol.type)) {
|
|
139
|
+
findings.push({
|
|
140
|
+
entity: entityName,
|
|
141
|
+
table,
|
|
142
|
+
kind: "type-mismatch",
|
|
143
|
+
column: dbName,
|
|
144
|
+
detail: `declared \`${col.type}\` but column is \`${dbCol.type}\``,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Reverse drift — a NOT NULL column with no default that no model property
|
|
150
|
+
// writes: every insert omitting it fails. The dangerous, easy-to-miss case
|
|
151
|
+
// (e.g. a migration added a column the model never caught up to). PKs are
|
|
152
|
+
// excluded (DB-generated).
|
|
153
|
+
for (const dbCol of dbCols) {
|
|
154
|
+
if (dbCol.primaryKey) continue;
|
|
155
|
+
if (!dbCol.nullable && !dbCol.hasDefault && !mappedDbColumns.has(dbCol.name)) {
|
|
156
|
+
findings.push({
|
|
157
|
+
entity: entityName,
|
|
158
|
+
table,
|
|
159
|
+
kind: "missing-in-model",
|
|
160
|
+
column: dbCol.name,
|
|
161
|
+
detail: `column \`${dbCol.name}\` is NOT NULL with no default but no model property maps to it — inserts will fail`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return findings;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reconcile every given entity against the live database. Pass the app's model
|
|
172
|
+
* classes (atlas has no global entity registry by design — mirror Lucid, where
|
|
173
|
+
* `ace` is pointed at your models). Returns a flat list of findings (empty when
|
|
174
|
+
* the schema and models agree).
|
|
175
|
+
*/
|
|
176
|
+
export async function checkSchema(
|
|
177
|
+
entities: readonly Constructor[],
|
|
178
|
+
db: SchemaIntrospectable,
|
|
179
|
+
dialect: AtlasDialect,
|
|
180
|
+
): Promise<SchemaFinding[]> {
|
|
181
|
+
const findings: SchemaFinding[] = [];
|
|
182
|
+
for (const entity of entities) {
|
|
183
|
+
const meta = getEntityMetadata(entity);
|
|
184
|
+
if (!meta?.tableName) continue; // not an @Entity — skip silently
|
|
185
|
+
const table = meta.tableName;
|
|
186
|
+
const entityName = entity.name;
|
|
187
|
+
|
|
188
|
+
const dbCols = await introspectTable(db, dialect, table);
|
|
189
|
+
if (dbCols === null) {
|
|
190
|
+
findings.push({
|
|
191
|
+
entity: entityName,
|
|
192
|
+
table,
|
|
193
|
+
kind: "missing-table",
|
|
194
|
+
column: table,
|
|
195
|
+
detail: `table \`${table}\` does not exist in the database — run your migrations`,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const strategy = getNamingStrategy(entity);
|
|
201
|
+
findings.push(
|
|
202
|
+
...reconcile(
|
|
203
|
+
entityName,
|
|
204
|
+
table,
|
|
205
|
+
getColumnMetadata(entity),
|
|
206
|
+
(p) => strategy.columnName(p),
|
|
207
|
+
dbCols,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return findings;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Render findings as a didactic, Adonis-style diff (grouped per table). */
|
|
215
|
+
export function formatSchemaFindings(findings: SchemaFinding[]): string {
|
|
216
|
+
if (findings.length === 0) return "[atlas:check] schema OK — models match the database.";
|
|
217
|
+
const byTable = new Map<string, SchemaFinding[]>();
|
|
218
|
+
for (const f of findings) {
|
|
219
|
+
const key = `${f.table} (${f.entity})`;
|
|
220
|
+
const list = byTable.get(key) ?? [];
|
|
221
|
+
list.push(f);
|
|
222
|
+
byTable.set(key, list);
|
|
223
|
+
}
|
|
224
|
+
const lines: string[] = [
|
|
225
|
+
`[atlas:check] ${findings.length} schema issue(s) found:`,
|
|
226
|
+
];
|
|
227
|
+
for (const [key, list] of byTable) {
|
|
228
|
+
lines.push(`\n ${key}`);
|
|
229
|
+
for (const f of list) {
|
|
230
|
+
const hint = f.suggestion ? ` — did you mean \`${f.suggestion}\`?` : "";
|
|
231
|
+
lines.push(` ✗ ${f.column}: ${f.detail}${hint}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Boot-time guard: run {@link checkSchema} and either throw (fail-fast, for CI /
|
|
239
|
+
* dev startup) or warn (non-blocking). Returns the findings. `mode` defaults to
|
|
240
|
+
* `"throw"` — a schema mismatch is a misconfiguration that should stop the boot
|
|
241
|
+
* before requests serve stale assumptions.
|
|
242
|
+
*/
|
|
243
|
+
export async function verifySchema(
|
|
244
|
+
entities: readonly Constructor[],
|
|
245
|
+
db: SchemaIntrospectable,
|
|
246
|
+
dialect: AtlasDialect,
|
|
247
|
+
opts: { mode?: "throw" | "warn" } = {},
|
|
248
|
+
): Promise<SchemaFinding[]> {
|
|
249
|
+
const findings = await checkSchema(entities, db, dialect);
|
|
250
|
+
if (findings.length > 0) {
|
|
251
|
+
const report = formatSchemaFindings(findings);
|
|
252
|
+
if ((opts.mode ?? "throw") === "throw") {
|
|
253
|
+
throw new Error(report);
|
|
254
|
+
}
|
|
255
|
+
console.warn(report);
|
|
256
|
+
}
|
|
257
|
+
return findings;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* CLI body: run the check, print the report (diff or the OK line), and return
|
|
262
|
+
* the process exit code (`0` = schema matches, `1` = drift). Used by the
|
|
263
|
+
* `atlas:check` console command; safe to call from any script.
|
|
264
|
+
*/
|
|
265
|
+
export async function runSchemaCheck(
|
|
266
|
+
entities: readonly Constructor[],
|
|
267
|
+
db: SchemaIntrospectable,
|
|
268
|
+
dialect: AtlasDialect,
|
|
269
|
+
): Promise<number> {
|
|
270
|
+
const findings = await checkSchema(entities, db, dialect);
|
|
271
|
+
console.log(formatSchemaFindings(findings));
|
|
272
|
+
return findings.length > 0 ? 1 : 0;
|
|
273
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
compileStatementNative,
|
|
13
13
|
getAtlasDialect,
|
|
14
14
|
} from "../query/native.js";
|
|
15
|
+
import { type DefaultValue, raw, renderDefaultValue } from "./raw.js";
|
|
15
16
|
import {
|
|
16
17
|
type ColumnDefinition,
|
|
17
18
|
type ColumnType,
|
|
@@ -128,7 +129,7 @@ export class TableBuilder {
|
|
|
128
129
|
* the helper dialect-aware.
|
|
129
130
|
*/
|
|
130
131
|
id(): this {
|
|
131
|
-
return this.uuid("id").primary().defaultTo("gen_random_uuid()");
|
|
132
|
+
return this.uuid("id").primary().defaultTo(raw("gen_random_uuid()"));
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/**
|
|
@@ -155,8 +156,8 @@ export class TableBuilder {
|
|
|
155
156
|
* the helper dialect-aware.
|
|
156
157
|
*/
|
|
157
158
|
timestamps(): this {
|
|
158
|
-
this.timestamp("created_at").notNullable().defaultTo("NOW()");
|
|
159
|
-
this.timestamp("updated_at").notNullable().defaultTo("NOW()");
|
|
159
|
+
this.timestamp("created_at").notNullable().defaultTo(raw("NOW()"));
|
|
160
|
+
this.timestamp("updated_at").notNullable().defaultTo(raw("NOW()"));
|
|
160
161
|
return this;
|
|
161
162
|
}
|
|
162
163
|
|
|
@@ -182,8 +183,15 @@ export class TableBuilder {
|
|
|
182
183
|
return this;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Set a column default. JS literals are quoted/escaped (`'x'`, `123`,
|
|
188
|
+
* `true` — Lucid/Knex semantics); wrap SQL expressions in {@link raw} (or
|
|
189
|
+
* use `Migration.now()`) to emit them verbatim.
|
|
190
|
+
*/
|
|
191
|
+
defaultTo(value: DefaultValue): this {
|
|
192
|
+
if (this.#currentColumn) {
|
|
193
|
+
this.#currentColumn.defaultValue = renderDefaultValue(value);
|
|
194
|
+
}
|
|
187
195
|
return this;
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live schema introspection — reads the ACTUAL table/column shape from the
|
|
3
|
+
* connected database, per dialect. The reconciler in `SchemaCheck.ts` diffs
|
|
4
|
+
* this against each model's `@Column` metadata.
|
|
5
|
+
*
|
|
6
|
+
* SQLite uses `pragma_table_info`; Postgres and MySQL use `information_schema`.
|
|
7
|
+
* Table names come from `@Entity(...)` (developer-defined, trusted) — they are
|
|
8
|
+
* validated against a strict identifier pattern before interpolation, since
|
|
9
|
+
* `pragma_table_info(...)` cannot bind its argument.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AtlasDialect } from "../query/native.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal connection surface the check needs: a row-returning `query`. Both the
|
|
16
|
+
* real `AsyncDatabaseConnection` and the test `Database` satisfy it, so the
|
|
17
|
+
* checker doesn't depend on the full driver interface.
|
|
18
|
+
*/
|
|
19
|
+
export interface SchemaIntrospectable {
|
|
20
|
+
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One column as it actually exists in the database. */
|
|
24
|
+
export interface IntrospectedColumn {
|
|
25
|
+
name: string;
|
|
26
|
+
/** Raw dialect type string (e.g. `INTEGER`, `character varying`, `varchar`). */
|
|
27
|
+
type: string;
|
|
28
|
+
nullable: boolean;
|
|
29
|
+
hasDefault: boolean;
|
|
30
|
+
primaryKey: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A safe SQL identifier (table name from `@Entity`). */
|
|
34
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
|
|
36
|
+
function assertIdent(name: string): void {
|
|
37
|
+
if (!IDENT.test(name)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`[atlas:check] refusing to introspect unsafe table identifier: ${JSON.stringify(name)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Introspect one table. Returns its columns, or `null` when the table does not
|
|
46
|
+
* exist in the database (a distinct, reportable drift — not an error).
|
|
47
|
+
*/
|
|
48
|
+
export async function introspectTable(
|
|
49
|
+
db: SchemaIntrospectable,
|
|
50
|
+
dialect: AtlasDialect,
|
|
51
|
+
table: string,
|
|
52
|
+
): Promise<IntrospectedColumn[] | null> {
|
|
53
|
+
assertIdent(table);
|
|
54
|
+
switch (dialect) {
|
|
55
|
+
case "sqlite":
|
|
56
|
+
return introspectSqlite(db, table);
|
|
57
|
+
case "postgres":
|
|
58
|
+
return introspectPostgres(db, table);
|
|
59
|
+
case "mysql":
|
|
60
|
+
return introspectMysql(db, table);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function introspectSqlite(
|
|
65
|
+
db: SchemaIntrospectable,
|
|
66
|
+
table: string,
|
|
67
|
+
): Promise<IntrospectedColumn[] | null> {
|
|
68
|
+
const rows = await db.query(
|
|
69
|
+
`SELECT * FROM pragma_table_info('${table}')`,
|
|
70
|
+
);
|
|
71
|
+
if (rows.length === 0) return null; // unknown table → no columns
|
|
72
|
+
return rows.map((r) => ({
|
|
73
|
+
name: String(r.name),
|
|
74
|
+
type: String(r.type ?? ""),
|
|
75
|
+
nullable: Number(r.notnull) === 0,
|
|
76
|
+
hasDefault: r.dflt_value !== null && r.dflt_value !== undefined,
|
|
77
|
+
primaryKey: Number(r.pk) > 0,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function introspectPostgres(
|
|
82
|
+
db: SchemaIntrospectable,
|
|
83
|
+
table: string,
|
|
84
|
+
): Promise<IntrospectedColumn[] | null> {
|
|
85
|
+
const cols = await db.query(
|
|
86
|
+
`SELECT column_name, data_type, is_nullable, column_default
|
|
87
|
+
FROM information_schema.columns
|
|
88
|
+
WHERE table_schema = current_schema() AND table_name = $1
|
|
89
|
+
ORDER BY ordinal_position`,
|
|
90
|
+
[table],
|
|
91
|
+
);
|
|
92
|
+
if (cols.length === 0) return null;
|
|
93
|
+
const pkRows = await db.query(
|
|
94
|
+
`SELECT kcu.column_name
|
|
95
|
+
FROM information_schema.table_constraints tc
|
|
96
|
+
JOIN information_schema.key_column_usage kcu
|
|
97
|
+
ON kcu.constraint_name = tc.constraint_name
|
|
98
|
+
AND kcu.table_schema = tc.table_schema
|
|
99
|
+
WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'`,
|
|
100
|
+
[table],
|
|
101
|
+
);
|
|
102
|
+
const pks = new Set(pkRows.map((r) => String(r.column_name)));
|
|
103
|
+
return cols.map((r) => ({
|
|
104
|
+
name: String(r.column_name),
|
|
105
|
+
type: String(r.data_type ?? ""),
|
|
106
|
+
nullable: String(r.is_nullable).toUpperCase() === "YES",
|
|
107
|
+
hasDefault: r.column_default !== null && r.column_default !== undefined,
|
|
108
|
+
primaryKey: pks.has(String(r.column_name)),
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function introspectMysql(
|
|
113
|
+
db: SchemaIntrospectable,
|
|
114
|
+
table: string,
|
|
115
|
+
): Promise<IntrospectedColumn[] | null> {
|
|
116
|
+
const rows = await db.query(
|
|
117
|
+
`SELECT column_name, data_type, is_nullable, column_default, column_key
|
|
118
|
+
FROM information_schema.columns
|
|
119
|
+
WHERE table_schema = DATABASE() AND table_name = ?
|
|
120
|
+
ORDER BY ordinal_position`,
|
|
121
|
+
[table],
|
|
122
|
+
);
|
|
123
|
+
if (rows.length === 0) return null;
|
|
124
|
+
return rows.map((r) => ({
|
|
125
|
+
name: String(r.column_name ?? r.COLUMN_NAME),
|
|
126
|
+
type: String(r.data_type ?? r.DATA_TYPE ?? ""),
|
|
127
|
+
nullable: String(r.is_nullable ?? r.IS_NULLABLE).toUpperCase() === "YES",
|
|
128
|
+
hasDefault:
|
|
129
|
+
(r.column_default ?? r.COLUMN_DEFAULT) !== null &&
|
|
130
|
+
(r.column_default ?? r.COLUMN_DEFAULT) !== undefined,
|
|
131
|
+
primaryKey: String(r.column_key ?? r.COLUMN_KEY ?? "").toUpperCase() === "PRI",
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-value handling for the schema builder — Lucid/Knex semantics.
|
|
3
|
+
*
|
|
4
|
+
* `defaultTo` quotes JS literals and emits raw SQL only when wrapped in
|
|
5
|
+
* {@link raw} (or produced by `Migration.now()`):
|
|
6
|
+
*
|
|
7
|
+
* t.text('status').defaultTo('new') // → DEFAULT 'new' (quoted literal)
|
|
8
|
+
* t.boolean('active').defaultTo(false) // → DEFAULT false
|
|
9
|
+
* t.integer('count').defaultTo(0) // → DEFAULT 0
|
|
10
|
+
* t.uuid('id').defaultTo(raw('gen_random_uuid()')) // → DEFAULT gen_random_uuid()
|
|
11
|
+
* t.timestamp('created_at').defaultTo(this.now()) // → DEFAULT NOW()/CURRENT_TIMESTAMP
|
|
12
|
+
*
|
|
13
|
+
* Before this, `defaultTo` wrote its argument verbatim, so a bare
|
|
14
|
+
* `defaultTo('new')` produced the invalid `DEFAULT new` and string defaults had
|
|
15
|
+
* to be hand-quoted (`defaultTo("'new'")`) — a footgun Lucid/Knex avoid.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A raw SQL expression for a column default — emitted verbatim, never quoted. */
|
|
19
|
+
export class RawValue {
|
|
20
|
+
constructor(readonly sql: string) {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Wrap a SQL expression so {@link TableBuilder.defaultTo} emits it verbatim. */
|
|
24
|
+
export function raw(sql: string): RawValue {
|
|
25
|
+
return new RawValue(sql);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Accepted `defaultTo` argument: a JS literal or a {@link RawValue}. */
|
|
29
|
+
export type DefaultValue = string | number | boolean | RawValue;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a `defaultTo` argument to the SQL fragment stored as the column
|
|
33
|
+
* default. Literals are quoted/escaped; {@link RawValue} passes through.
|
|
34
|
+
*/
|
|
35
|
+
export function renderDefaultValue(value: DefaultValue): string {
|
|
36
|
+
if (value instanceof RawValue) return value.sql;
|
|
37
|
+
if (typeof value === "number") return String(value);
|
|
38
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
39
|
+
// String literal — single-quote and escape embedded quotes (SQL standard).
|
|
40
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
41
|
+
}
|