@c9up/atlas 0.1.13 → 0.1.15
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 +3 -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 +13 -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 +26 -0
- package/dist/schema/raw.d.ts.map +1 -0
- package/dist/schema/raw.js +33 -0
- package/dist/schema/raw.js.map +1 -0
- package/dist/services/db.d.ts +12 -1
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +5 -0
- package/dist/services/db.js.map +1 -1
- 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 +14 -5
- package/src/schema/introspect.ts +133 -0
- package/src/schema/raw.ts +34 -0
- package/src/services/db.ts +17 -1
|
@@ -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,8 @@ import {
|
|
|
12
12
|
compileStatementNative,
|
|
13
13
|
getAtlasDialect,
|
|
14
14
|
} from "../query/native.js";
|
|
15
|
+
import { RawSql } from "../query/QueryBuilder.js";
|
|
16
|
+
import { type DefaultValue, renderDefaultValue } from "./raw.js";
|
|
15
17
|
import {
|
|
16
18
|
type ColumnDefinition,
|
|
17
19
|
type ColumnType,
|
|
@@ -128,7 +130,7 @@ export class TableBuilder {
|
|
|
128
130
|
* the helper dialect-aware.
|
|
129
131
|
*/
|
|
130
132
|
id(): this {
|
|
131
|
-
return this.uuid("id").primary().defaultTo("gen_random_uuid()");
|
|
133
|
+
return this.uuid("id").primary().defaultTo(new RawSql("gen_random_uuid()"));
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
@@ -155,8 +157,8 @@ export class TableBuilder {
|
|
|
155
157
|
* the helper dialect-aware.
|
|
156
158
|
*/
|
|
157
159
|
timestamps(): this {
|
|
158
|
-
this.timestamp("created_at").notNullable().defaultTo("NOW()");
|
|
159
|
-
this.timestamp("updated_at").notNullable().defaultTo("NOW()");
|
|
160
|
+
this.timestamp("created_at").notNullable().defaultTo(new RawSql("NOW()"));
|
|
161
|
+
this.timestamp("updated_at").notNullable().defaultTo(new RawSql("NOW()"));
|
|
160
162
|
return this;
|
|
161
163
|
}
|
|
162
164
|
|
|
@@ -182,8 +184,15 @@ export class TableBuilder {
|
|
|
182
184
|
return this;
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Set a column default. JS literals are quoted/escaped (`'x'`, `123`,
|
|
189
|
+
* `true` — Lucid/Knex semantics); wrap SQL expressions in {@link raw} (or
|
|
190
|
+
* use `Migration.now()`) to emit them verbatim.
|
|
191
|
+
*/
|
|
192
|
+
defaultTo(value: DefaultValue): this {
|
|
193
|
+
if (this.#currentColumn) {
|
|
194
|
+
this.#currentColumn.defaultValue = renderDefaultValue(value);
|
|
195
|
+
}
|
|
187
196
|
return this;
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-value rendering for the schema builder — Lucid/Knex semantics.
|
|
3
|
+
*
|
|
4
|
+
* `defaultTo` quotes JS literals and emits raw SQL only when given a `RawSql`
|
|
5
|
+
* (the single raw-expression type atlas uses everywhere, incl. queries),
|
|
6
|
+
* produced in a migration by `this.now()` / `this.raw(...)`:
|
|
7
|
+
*
|
|
8
|
+
* t.text('status').defaultTo('new') // → DEFAULT 'new' (quoted literal)
|
|
9
|
+
* t.boolean('active').defaultTo(false) // → DEFAULT false
|
|
10
|
+
* t.integer('count').defaultTo(0) // → DEFAULT 0
|
|
11
|
+
* t.uuid('id').defaultTo(this.raw('gen_random_uuid()')) // → DEFAULT gen_random_uuid()
|
|
12
|
+
* t.timestamp('created_at').defaultTo(this.now()) // → DEFAULT NOW()/CURRENT_TIMESTAMP
|
|
13
|
+
*
|
|
14
|
+
* Before this, `defaultTo` wrote its argument verbatim, so a bare
|
|
15
|
+
* `defaultTo('new')` produced the invalid `DEFAULT new` and string defaults had
|
|
16
|
+
* to be hand-quoted (`defaultTo("'new'")`) — a footgun Lucid/Knex avoid.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RawSql } from "../query/QueryBuilder.js";
|
|
20
|
+
|
|
21
|
+
/** Accepted `defaultTo` argument: a JS literal or a raw SQL expression. */
|
|
22
|
+
export type DefaultValue = string | number | boolean | RawSql;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a `defaultTo` argument to the SQL fragment stored as the column
|
|
26
|
+
* default. Literals are quoted/escaped; a {@link RawSql} passes through verbatim.
|
|
27
|
+
*/
|
|
28
|
+
export function renderDefaultValue(value: DefaultValue): string {
|
|
29
|
+
if (value instanceof RawSql) return value.sql;
|
|
30
|
+
if (typeof value === "number") return String(value);
|
|
31
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
32
|
+
// String literal — single-quote and escape embedded quotes (SQL standard).
|
|
33
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
34
|
+
}
|
package/src/services/db.ts
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
|
|
17
|
+
import { RawSql } from "../query/QueryBuilder.js";
|
|
18
|
+
|
|
19
|
+
/** The `db` singleton surface: the bound connection plus the AdonisJS-style `db.raw()` builder. */
|
|
20
|
+
export interface DbService extends AsyncDatabaseConnection {
|
|
21
|
+
/**
|
|
22
|
+
* Build a raw SQL expression — AdonisJS `db.raw()` / `Database.raw()`. Use it
|
|
23
|
+
* for query fragments and for column defaults that are SQL expressions:
|
|
24
|
+
*
|
|
25
|
+
* t.uuid('id').defaultTo(db.raw('gen_random_uuid()'))
|
|
26
|
+
*/
|
|
27
|
+
raw(sql: string, params?: unknown[]): RawSql;
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
let instance: AsyncDatabaseConnection | undefined;
|
|
19
31
|
|
|
@@ -37,8 +49,12 @@ export function getDb(): AsyncDatabaseConnection | undefined {
|
|
|
37
49
|
return instance;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
const db:
|
|
52
|
+
const db: DbService = new Proxy({} as DbService, {
|
|
41
53
|
get(_target, prop) {
|
|
54
|
+
// `raw` is a pure builder (no connection needed) — available pre-boot too.
|
|
55
|
+
if (prop === "raw") {
|
|
56
|
+
return (sql: string, params: unknown[] = []) => new RawSql(sql, params);
|
|
57
|
+
}
|
|
42
58
|
if (!instance) {
|
|
43
59
|
throw new Error(
|
|
44
60
|
"[atlas] db singleton accessed before AtlasProvider.boot() ran. " +
|