@adminiumjs/adapter-sqlite 0.1.0-rc.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/LICENSE +661 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/file.d.ts +21 -0
- package/dist/file.d.ts.map +1 -0
- package/dist/file.js +48 -0
- package/dist/file.js.map +1 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +278 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect.d.ts +74 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +641 -0
- package/dist/introspect.js.map +1 -0
- package/dist/query-engine.d.ts +32 -0
- package/dist/query-engine.d.ts.map +1 -0
- package/dist/query-engine.js +82 -0
- package/dist/query-engine.js.map +1 -0
- package/dist/serialization.d.ts +35 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +56 -0
- package/dist/serialization.js.map +1 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +124 -0
- package/dist/stats.js.map +1 -0
- package/dist/type-map.d.ts +47 -0
- package/dist/type-map.d.ts.map +1 -0
- package/dist/type-map.js +141 -0
- package/dist/type-map.js.map +1 -0
- package/package.json +30 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite introspection — 05-introspection-engine.md §4.3.
|
|
3
|
+
*
|
|
4
|
+
* Reads pragma table-valued functions joined against `sqlite_master`, so the
|
|
5
|
+
* statement set stays FIXED regardless of table count (the per-table pragma
|
|
6
|
+
* loop happens inside SQLite, not over the executor). This module is
|
|
7
|
+
* executor-agnostic — `introspectSqlite` takes a `CatalogExecutor` (any
|
|
8
|
+
* `sql → rows` function) so the assembly logic is testable without the
|
|
9
|
+
* `better-sqlite3` driver; `src/index.ts` wires it to the database handle.
|
|
10
|
+
* Mirrors the postgres reference adapter file for file.
|
|
11
|
+
*
|
|
12
|
+
* THE "SCHEMA ONLY" INVARIANT (05 §10): every statement here references
|
|
13
|
+
* `sqlite_master` / `pragma_*` / `sqlite_stat1` exclusively — with the one
|
|
14
|
+
* documented exception of §4.3: exact `COUNT(*)` per table on files
|
|
15
|
+
* < 100 MB, which touches no column values.
|
|
16
|
+
*/
|
|
17
|
+
import { AdapterError, ENGINE_CAPABILITY_MATRIX, } from '@adminium/engine/adapter';
|
|
18
|
+
import { quoteIdentifier } from './serialization.js';
|
|
19
|
+
import { classifyDefault, mapSqliteType } from './type-map.js';
|
|
20
|
+
/**
|
|
21
|
+
* Static dialect capabilities — 05 §2.1/§4.3 (probe refines per-connection).
|
|
22
|
+
* Canonical values live in the engine's capability matrix (M9-T04) so the
|
|
23
|
+
* wizard's degradation copy and the adapter never disagree.
|
|
24
|
+
*/
|
|
25
|
+
export const SQLITE_CAPABILITIES = {
|
|
26
|
+
...ENGINE_CAPABILITY_MATRIX.sqlite,
|
|
27
|
+
};
|
|
28
|
+
/** §4.3: exact per-table `COUNT(*)` only when the file is under 100 MB. */
|
|
29
|
+
export const EXACT_COUNT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
30
|
+
/** Migration/meta tables hidden behind "show system tables" — 05 §8.2. */
|
|
31
|
+
const SYSTEM_TABLE_PATTERN = /^(adminium_|knex_migrations)|^(_prisma_migrations|schema_migrations|ar_internal_metadata|django_migrations|sqlite_sequence)$/;
|
|
32
|
+
/** Enum values are capped at 256 with a warning — 05 §10. */
|
|
33
|
+
const ENUM_VALUE_CAP = 256;
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Catalog SQL (fixed set)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** SQLite ≥ 3.37 — guarded with a sqlite_master fallback (05 §4.3). */
|
|
38
|
+
export const TABLE_LIST_SQL = `
|
|
39
|
+
SELECT name, type, wr, strict
|
|
40
|
+
FROM pragma_table_list
|
|
41
|
+
WHERE schema = 'main' AND name NOT LIKE 'sqlite_%'
|
|
42
|
+
ORDER BY name`;
|
|
43
|
+
/** DDL source — CHECK extraction, AUTOINCREMENT, WITHOUT ROWID fallback. */
|
|
44
|
+
export const MASTER_SQL = `
|
|
45
|
+
SELECT name, type, sql
|
|
46
|
+
FROM sqlite_master
|
|
47
|
+
WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
|
|
48
|
+
ORDER BY name`;
|
|
49
|
+
/** table_xinfo includes hidden/generated columns (05 §4.3). */
|
|
50
|
+
export const COLUMNS_SQL = `
|
|
51
|
+
SELECT m.name AS table_name,
|
|
52
|
+
ti.cid AS ordinal,
|
|
53
|
+
ti.name AS column_name,
|
|
54
|
+
ti.type AS declared_type,
|
|
55
|
+
ti."notnull" AS not_null,
|
|
56
|
+
ti.dflt_value AS default_text,
|
|
57
|
+
ti.pk AS pk_ordinal,
|
|
58
|
+
ti.hidden AS hidden
|
|
59
|
+
FROM sqlite_master m
|
|
60
|
+
JOIN pragma_table_xinfo(m.name) ti
|
|
61
|
+
WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite_%'
|
|
62
|
+
ORDER BY m.name, ti.cid`;
|
|
63
|
+
export const FOREIGN_KEYS_SQL = `
|
|
64
|
+
SELECT m.name AS table_name,
|
|
65
|
+
fk.id AS fk_id,
|
|
66
|
+
fk.seq AS seq,
|
|
67
|
+
fk."table" AS ref_table,
|
|
68
|
+
fk."from" AS from_column,
|
|
69
|
+
fk."to" AS to_column,
|
|
70
|
+
fk.on_update AS on_update,
|
|
71
|
+
fk.on_delete AS on_delete
|
|
72
|
+
FROM sqlite_master m
|
|
73
|
+
JOIN pragma_foreign_key_list(m.name) fk
|
|
74
|
+
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
|
|
75
|
+
ORDER BY m.name, fk.id, fk.seq`;
|
|
76
|
+
export const INDEX_LIST_SQL = `
|
|
77
|
+
SELECT m.name AS table_name,
|
|
78
|
+
il.name AS index_name,
|
|
79
|
+
il."unique" AS is_unique,
|
|
80
|
+
il.origin AS origin,
|
|
81
|
+
il.partial AS partial
|
|
82
|
+
FROM sqlite_master m
|
|
83
|
+
JOIN pragma_index_list(m.name) il
|
|
84
|
+
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
|
|
85
|
+
ORDER BY m.name, il.name`;
|
|
86
|
+
export const INDEX_COLUMNS_SQL = `
|
|
87
|
+
SELECT m.name AS table_name,
|
|
88
|
+
il.name AS index_name,
|
|
89
|
+
ii.seqno AS seqno,
|
|
90
|
+
ii.name AS column_name
|
|
91
|
+
FROM sqlite_master m
|
|
92
|
+
JOIN pragma_index_list(m.name) il
|
|
93
|
+
JOIN pragma_index_info(il.name) ii
|
|
94
|
+
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
|
|
95
|
+
ORDER BY m.name, il.name, ii.seqno`;
|
|
96
|
+
/** Guarded: only present after ANALYZE has run (05 §4.3). */
|
|
97
|
+
export const STAT1_SQL = `
|
|
98
|
+
SELECT tbl AS table_name, idx AS index_name, stat AS stat
|
|
99
|
+
FROM sqlite_stat1`;
|
|
100
|
+
function quoteLiteral(value) {
|
|
101
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One UNION ALL statement counting every included table — the §4.3 small-file
|
|
105
|
+
* exception. COUNT(*) touches no column values; `rowCountExact: true`.
|
|
106
|
+
*/
|
|
107
|
+
export function exactCountsSql(tableNames) {
|
|
108
|
+
return tableNames
|
|
109
|
+
.map((name) => `SELECT ${quoteLiteral(name)} AS table_name, count(*) AS n FROM ${quoteIdentifier(name)}`)
|
|
110
|
+
.join('\nUNION ALL\n');
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Row coercion
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
function num(value) {
|
|
116
|
+
if (value === null || value === undefined)
|
|
117
|
+
return null;
|
|
118
|
+
const n = Number(value);
|
|
119
|
+
return Number.isFinite(n) ? n : null;
|
|
120
|
+
}
|
|
121
|
+
function str(value) {
|
|
122
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// DDL scanning: CHECK constraints, AUTOINCREMENT, WITHOUT ROWID / STRICT.
|
|
126
|
+
// CHECK expressions and enum synthesis reuse the same `col IN (...)` parsing
|
|
127
|
+
// approach as the postgres adapter — duplicated by design (adapters never
|
|
128
|
+
// import each other, 05 §4.3 note).
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/** Skip a quoted region starting at `i`; returns the index AFTER it. */
|
|
131
|
+
function skipQuoted(ddl, i) {
|
|
132
|
+
const open = ddl[i];
|
|
133
|
+
const close = open === '[' ? ']' : open;
|
|
134
|
+
let j = i + 1;
|
|
135
|
+
while (j < ddl.length) {
|
|
136
|
+
if (ddl[j] === close) {
|
|
137
|
+
// '' / "" / `` escape by doubling ([ ] does not double)
|
|
138
|
+
if (close !== ']' && ddl[j + 1] === close) {
|
|
139
|
+
j += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
return j + 1;
|
|
143
|
+
}
|
|
144
|
+
j += 1;
|
|
145
|
+
}
|
|
146
|
+
return j;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Scan a CREATE TABLE statement for CHECK constraints (column-level and
|
|
150
|
+
* table-level), respecting strings/quoted identifiers. Returns the balanced
|
|
151
|
+
* inner expression of each `CHECK (…)` plus the `CONSTRAINT <name>` when
|
|
152
|
+
* present.
|
|
153
|
+
*/
|
|
154
|
+
export function scanCheckConstraints(ddl) {
|
|
155
|
+
const results = [];
|
|
156
|
+
const tokens = []; // trailing significant identifiers/keywords
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < ddl.length) {
|
|
159
|
+
const ch = ddl[i] ?? '';
|
|
160
|
+
if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
|
|
161
|
+
const end = skipQuoted(ddl, i);
|
|
162
|
+
tokens.push(ddl.slice(i + 1, end - 1));
|
|
163
|
+
i = end;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === '-' && ddl[i + 1] === '-') {
|
|
167
|
+
const nl = ddl.indexOf('\n', i);
|
|
168
|
+
i = nl === -1 ? ddl.length : nl + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
172
|
+
let j = i;
|
|
173
|
+
while (j < ddl.length && /[\w$]/.test(ddl[j] ?? ''))
|
|
174
|
+
j += 1;
|
|
175
|
+
const word = ddl.slice(i, j);
|
|
176
|
+
if (word.toUpperCase() === 'CHECK') {
|
|
177
|
+
// optional CONSTRAINT <name> immediately before
|
|
178
|
+
const prev = tokens[tokens.length - 2];
|
|
179
|
+
const name = prev !== undefined && prev.toUpperCase() === 'CONSTRAINT'
|
|
180
|
+
? (tokens[tokens.length - 1] ?? null)
|
|
181
|
+
: null;
|
|
182
|
+
// find the balanced ( … ) that follows
|
|
183
|
+
let k = j;
|
|
184
|
+
while (k < ddl.length && ddl[k] !== '(')
|
|
185
|
+
k += 1;
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let end = k;
|
|
188
|
+
while (end < ddl.length) {
|
|
189
|
+
const c = ddl[end] ?? '';
|
|
190
|
+
if (c === "'" || c === '"' || c === '`' || c === '[') {
|
|
191
|
+
end = skipQuoted(ddl, end);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (c === '(')
|
|
195
|
+
depth += 1;
|
|
196
|
+
else if (c === ')') {
|
|
197
|
+
depth -= 1;
|
|
198
|
+
if (depth === 0)
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
end += 1;
|
|
202
|
+
}
|
|
203
|
+
if (k < ddl.length && end > k) {
|
|
204
|
+
results.push({ name, expression: ddl.slice(k + 1, end).trim() });
|
|
205
|
+
}
|
|
206
|
+
i = end + 1;
|
|
207
|
+
tokens.push(word);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
tokens.push(word);
|
|
211
|
+
i = j;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
i += 1;
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
const CHECK_IN_PATTERN = /["'`[]?([a-zA-Z_][\w$]*)["'`\]]?\s+in\s*\(([^)]+)\)/i;
|
|
219
|
+
/** Parse a `col IN ('a','b')` check shape into a synthesized enum (05 §4.3). */
|
|
220
|
+
export function parseCheckEnum(expression) {
|
|
221
|
+
const match = CHECK_IN_PATTERN.exec(expression);
|
|
222
|
+
if (match?.[1] === undefined || match[2] === undefined)
|
|
223
|
+
return null;
|
|
224
|
+
const values = [];
|
|
225
|
+
for (const literal of match[2].matchAll(/'((?:[^']|'')*)'/g)) {
|
|
226
|
+
values.push((literal[1] ?? '').replaceAll("''", "'"));
|
|
227
|
+
}
|
|
228
|
+
return values.length > 0 ? { column: match[1], values } : null;
|
|
229
|
+
}
|
|
230
|
+
/** Tail of the DDL after the closing paren — WITHOUT ROWID / STRICT options. */
|
|
231
|
+
function tableOptions(ddl) {
|
|
232
|
+
if (ddl === null)
|
|
233
|
+
return { withoutRowid: false, strict: false };
|
|
234
|
+
const tail = ddl.slice(ddl.lastIndexOf(')') + 1);
|
|
235
|
+
return {
|
|
236
|
+
withoutRowid: /without\s+rowid/i.test(tail),
|
|
237
|
+
strict: /\bstrict\b/i.test(tail),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Assembly
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
const FK_ACTION_MAP = {
|
|
244
|
+
CASCADE: 'cascade',
|
|
245
|
+
RESTRICT: 'restrict',
|
|
246
|
+
'SET NULL': 'set-null',
|
|
247
|
+
'SET DEFAULT': 'set-default',
|
|
248
|
+
'NO ACTION': 'no-action',
|
|
249
|
+
};
|
|
250
|
+
function compareBy(key) {
|
|
251
|
+
return (a, b) => {
|
|
252
|
+
const ka = key(a);
|
|
253
|
+
const kb = key(b);
|
|
254
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Run the fixed catalog query set through `exec` and assemble the
|
|
259
|
+
* `DatabaseModel`. Schema only — never touches user rows (except the §4.3
|
|
260
|
+
* small-file COUNT exception, which reads no column values).
|
|
261
|
+
*/
|
|
262
|
+
export async function introspectSqlite(exec, ctx, opts = {}) {
|
|
263
|
+
const startedAt = Date.now();
|
|
264
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
265
|
+
const collectRowEstimates = opts.collectRowEstimates ?? true;
|
|
266
|
+
const warnings = [];
|
|
267
|
+
const schema = 'main';
|
|
268
|
+
const guarded = async (sql) => {
|
|
269
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
270
|
+
throw new AdapterError('TIMEOUT', 'introspection exceeded the total time budget', {
|
|
271
|
+
detail: `timeoutMs=${timeoutMs}`,
|
|
272
|
+
hint: 'raise IntrospectOptions.timeoutMs or narrow the table selection',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return exec(sql);
|
|
276
|
+
};
|
|
277
|
+
// 1. DDL master list (always) ------------------------------------------------
|
|
278
|
+
const masterRows = await guarded(MASTER_SQL);
|
|
279
|
+
const ddlByTable = new Map(masterRows.map((row) => [str(row['name']) ?? '', str(row['sql'])]));
|
|
280
|
+
// 2. table_list (SQLite ≥ 3.37; fallback: derive wr/strict from the DDL) ----
|
|
281
|
+
const tableFlags = new Map();
|
|
282
|
+
try {
|
|
283
|
+
for (const row of await guarded(TABLE_LIST_SQL)) {
|
|
284
|
+
tableFlags.set(str(row['name']) ?? '', {
|
|
285
|
+
withoutRowid: num(row['wr']) === 1,
|
|
286
|
+
strict: num(row['strict']) === 1,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error instanceof AdapterError && error.code === 'TIMEOUT')
|
|
292
|
+
throw error;
|
|
293
|
+
for (const [name, ddl] of ddlByTable)
|
|
294
|
+
tableFlags.set(name, tableOptions(ddl));
|
|
295
|
+
warnings.push({
|
|
296
|
+
code: 'table-list-unavailable',
|
|
297
|
+
message: 'pragma_table_list is unavailable (SQLite < 3.37) — flags derived from DDL',
|
|
298
|
+
tableId: null,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// 3–5. Columns, foreign keys, indexes (set-based over pragma TVF joins) -----
|
|
302
|
+
const columnRows = await guarded(COLUMNS_SQL);
|
|
303
|
+
const fkRows = await guarded(FOREIGN_KEYS_SQL);
|
|
304
|
+
const indexListRows = await guarded(INDEX_LIST_SQL);
|
|
305
|
+
const indexColumnRows = await guarded(INDEX_COLUMNS_SQL);
|
|
306
|
+
// 6. sqlite_stat1 (guarded: only exists after ANALYZE) ----------------------
|
|
307
|
+
const statByTable = new Map();
|
|
308
|
+
try {
|
|
309
|
+
for (const row of await guarded(STAT1_SQL)) {
|
|
310
|
+
const name = str(row['table_name']) ?? '';
|
|
311
|
+
const stat = str(row['stat']) ?? '';
|
|
312
|
+
const rows = Number.parseInt(stat.split(' ')[0] ?? '', 10);
|
|
313
|
+
if (Number.isFinite(rows)) {
|
|
314
|
+
statByTable.set(name, Math.max(statByTable.get(name) ?? 0, rows));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error instanceof AdapterError && error.code === 'TIMEOUT')
|
|
320
|
+
throw error;
|
|
321
|
+
// No ANALYZE yet — small files fall through to exact counts below.
|
|
322
|
+
}
|
|
323
|
+
// -- tables -----------------------------------------------------------------
|
|
324
|
+
const tables = new Map();
|
|
325
|
+
const autoincrementTables = new Set();
|
|
326
|
+
for (const row of masterRows) {
|
|
327
|
+
const name = str(row['name']) ?? '';
|
|
328
|
+
if (opts.tableFilter !== undefined && !opts.tableFilter({ schema, name }))
|
|
329
|
+
continue;
|
|
330
|
+
const kind = str(row['type']) === 'view' ? 'view' : 'table';
|
|
331
|
+
const ddl = str(row['sql']);
|
|
332
|
+
if (kind === 'table' && ddl !== null && /\bAUTOINCREMENT\b/i.test(ddl)) {
|
|
333
|
+
autoincrementTables.add(name);
|
|
334
|
+
}
|
|
335
|
+
tables.set(name, {
|
|
336
|
+
id: `${schema}.${name}`,
|
|
337
|
+
schema,
|
|
338
|
+
name,
|
|
339
|
+
kind,
|
|
340
|
+
comment: null, // SQLite has no comment syntax (hasComments: false)
|
|
341
|
+
columns: [],
|
|
342
|
+
primaryKey: [],
|
|
343
|
+
uniques: [],
|
|
344
|
+
checks: [],
|
|
345
|
+
indexes: [],
|
|
346
|
+
rowCountEstimate: null, // filled from stat1 / exact counts below
|
|
347
|
+
rowCountExact: false,
|
|
348
|
+
sizeBytes: null, // per-table sizes need the dbstat vtable — out of scope v1
|
|
349
|
+
activity: null,
|
|
350
|
+
rls: null, // Postgres only
|
|
351
|
+
system: SYSTEM_TABLE_PATTERN.test(name),
|
|
352
|
+
semantics: null,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// -- columns ----------------------------------------------------------------
|
|
356
|
+
const pkOrdinals = new Map();
|
|
357
|
+
for (const row of columnRows) {
|
|
358
|
+
const tableName = str(row['table_name']) ?? '';
|
|
359
|
+
const table = tables.get(tableName);
|
|
360
|
+
if (table === undefined)
|
|
361
|
+
continue;
|
|
362
|
+
const hidden = num(row['hidden']) ?? 0;
|
|
363
|
+
if (hidden === 1)
|
|
364
|
+
continue; // truly hidden columns are not part of the model
|
|
365
|
+
const columnName = str(row['column_name']) ?? '';
|
|
366
|
+
const declaredType = str(row['declared_type']) ?? '';
|
|
367
|
+
const strict = tableFlags.get(tableName)?.strict ?? false;
|
|
368
|
+
const mapped = mapSqliteType(declaredType, { strict });
|
|
369
|
+
if (declaredType === '' && table.kind === 'table') {
|
|
370
|
+
warnings.push({
|
|
371
|
+
code: 'untyped-column',
|
|
372
|
+
message: `column "${tableName}.${columnName}" has no declared type — add one for a better mapping`,
|
|
373
|
+
tableId: table.id,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const pkOrdinal = num(row['pk_ordinal']) ?? 0;
|
|
377
|
+
if (pkOrdinal > 0) {
|
|
378
|
+
const list = pkOrdinals.get(tableName) ?? [];
|
|
379
|
+
list.push({ name: columnName, ordinal: pkOrdinal });
|
|
380
|
+
pkOrdinals.set(tableName, list);
|
|
381
|
+
}
|
|
382
|
+
table.columns.push({
|
|
383
|
+
name: columnName,
|
|
384
|
+
// table_xinfo.cid is 0-based; the IR convention is 1-based (pg attnum).
|
|
385
|
+
ordinal: (num(row['ordinal']) ?? 0) + 1,
|
|
386
|
+
dbType: declaredType, // verbatim — '' means "no declared type" (BLOB affinity)
|
|
387
|
+
logicalType: mapped.logicalType,
|
|
388
|
+
nullable: num(row['not_null']) !== 1,
|
|
389
|
+
default: hidden === 2 || hidden === 3 ? null : classifyDefault(str(row['default_text'])),
|
|
390
|
+
isPrimaryKey: false,
|
|
391
|
+
isUnique: false,
|
|
392
|
+
isGenerated: hidden === 2 || hidden === 3, // 2 = virtual, 3 = stored
|
|
393
|
+
enumRef: null,
|
|
394
|
+
maxLength: mapped.maxLength,
|
|
395
|
+
numericPrecision: mapped.numericPrecision,
|
|
396
|
+
numericScale: mapped.numericScale,
|
|
397
|
+
isArray: false,
|
|
398
|
+
comment: null,
|
|
399
|
+
references: null,
|
|
400
|
+
semantics: null,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// -- primary keys + rowid/AUTOINCREMENT defaults (05 §4.3) -------------------
|
|
404
|
+
for (const [tableName, list] of pkOrdinals) {
|
|
405
|
+
const table = tables.get(tableName);
|
|
406
|
+
if (table === undefined)
|
|
407
|
+
continue;
|
|
408
|
+
table.primaryKey = list.sort((a, b) => a.ordinal - b.ordinal).map((c) => c.name);
|
|
409
|
+
for (const column of table.columns) {
|
|
410
|
+
if (table.primaryKey.includes(column.name))
|
|
411
|
+
column.isPrimaryKey = true;
|
|
412
|
+
}
|
|
413
|
+
const withoutRowid = tableFlags.get(tableName)?.withoutRowid ?? false;
|
|
414
|
+
if (!withoutRowid && table.primaryKey.length === 1) {
|
|
415
|
+
const pkColumn = table.columns.find((c) => c.name === table.primaryKey[0]);
|
|
416
|
+
// INTEGER PRIMARY KEY is the rowid alias: auto-assigned on insert.
|
|
417
|
+
// AUTOINCREMENT additionally pins monotonicity via sqlite_sequence.
|
|
418
|
+
if (pkColumn !== undefined &&
|
|
419
|
+
(pkColumn.dbType.trim().toUpperCase() === 'INTEGER' ||
|
|
420
|
+
autoincrementTables.has(tableName)) &&
|
|
421
|
+
pkColumn.logicalType === 'integer' &&
|
|
422
|
+
pkColumn.default === null) {
|
|
423
|
+
pkColumn.default = { kind: 'autoincrement' };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// -- checks + enum synthesis from the DDL ------------------------------------
|
|
428
|
+
const checkEnums = new Map();
|
|
429
|
+
for (const [tableName, ddl] of ddlByTable) {
|
|
430
|
+
const table = tables.get(tableName);
|
|
431
|
+
if (table === undefined || table.kind !== 'table' || ddl === null)
|
|
432
|
+
continue;
|
|
433
|
+
for (const check of scanCheckConstraints(ddl)) {
|
|
434
|
+
table.checks.push({ name: check.name, expression: check.expression });
|
|
435
|
+
const parsed = parseCheckEnum(check.expression);
|
|
436
|
+
if (parsed === null || !table.columns.some((c) => c.name === parsed.column))
|
|
437
|
+
continue;
|
|
438
|
+
const id = `${table.id}.${parsed.column}`;
|
|
439
|
+
const column = table.columns.find((c) => c.name === parsed.column);
|
|
440
|
+
if (column === undefined || column.enumRef !== null)
|
|
441
|
+
continue;
|
|
442
|
+
let values = parsed.values;
|
|
443
|
+
if (values.length > ENUM_VALUE_CAP) {
|
|
444
|
+
warnings.push({
|
|
445
|
+
code: 'enum-capped',
|
|
446
|
+
message: `enum "${id}" has ${values.length} values; capped at ${ENUM_VALUE_CAP}`,
|
|
447
|
+
tableId: table.id,
|
|
448
|
+
});
|
|
449
|
+
values = values.slice(0, ENUM_VALUE_CAP);
|
|
450
|
+
}
|
|
451
|
+
checkEnums.set(id, { id, name: parsed.column, values, source: 'check' });
|
|
452
|
+
column.enumRef = id;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// -- indexes ------------------------------------------------------------------
|
|
456
|
+
const indexColumns = new Map();
|
|
457
|
+
for (const row of indexColumnRows) {
|
|
458
|
+
const indexName = str(row['index_name']) ?? '';
|
|
459
|
+
const columnName = str(row['column_name']); // null for rowid/expression parts
|
|
460
|
+
const list = indexColumns.get(indexName) ?? [];
|
|
461
|
+
if (columnName !== null)
|
|
462
|
+
list.push({ seq: num(row['seqno']) ?? 0, name: columnName });
|
|
463
|
+
indexColumns.set(indexName, list);
|
|
464
|
+
}
|
|
465
|
+
for (const row of indexListRows) {
|
|
466
|
+
const table = tables.get(str(row['table_name']) ?? '');
|
|
467
|
+
if (table === undefined)
|
|
468
|
+
continue;
|
|
469
|
+
const indexName = str(row['index_name']) ?? '';
|
|
470
|
+
const origin = str(row['origin']) ?? 'c';
|
|
471
|
+
const unique = num(row['is_unique']) === 1;
|
|
472
|
+
const partial = num(row['partial']) === 1;
|
|
473
|
+
const columns = (indexColumns.get(indexName) ?? [])
|
|
474
|
+
.sort((a, b) => a.seq - b.seq)
|
|
475
|
+
.map((c) => c.name);
|
|
476
|
+
table.indexes.push({
|
|
477
|
+
name: indexName,
|
|
478
|
+
columns,
|
|
479
|
+
expression: null, // expression indexes surface with empty columns in v1
|
|
480
|
+
unique,
|
|
481
|
+
primary: origin === 'pk',
|
|
482
|
+
method: null, // SQLite has one index structure; no method taxonomy
|
|
483
|
+
partial,
|
|
484
|
+
});
|
|
485
|
+
if (unique && origin === 'u') {
|
|
486
|
+
table.uniques.push({ name: indexName, columns });
|
|
487
|
+
}
|
|
488
|
+
// Covered by a single-column unique constraint/index (05 §2.1) — the PK
|
|
489
|
+
// autoindex counts, mirroring the pg reference adapter. (INTEGER PRIMARY
|
|
490
|
+
// KEY rowid aliases have no autoindex; isPrimaryKey already covers them.)
|
|
491
|
+
if (unique && !partial && columns.length === 1) {
|
|
492
|
+
const column = table.columns.find((c) => c.name === columns[0]);
|
|
493
|
+
if (column !== undefined)
|
|
494
|
+
column.isUnique = true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// -- foreign keys (pragma foreign_key_list) ----------------------------------
|
|
498
|
+
const relations = [];
|
|
499
|
+
const fkAccumulators = new Map();
|
|
500
|
+
for (const row of fkRows) {
|
|
501
|
+
const table = tables.get(str(row['table_name']) ?? '');
|
|
502
|
+
if (table === undefined)
|
|
503
|
+
continue;
|
|
504
|
+
const key = `${table.name}\x00${num(row['fk_id']) ?? 0}`;
|
|
505
|
+
let acc = fkAccumulators.get(key);
|
|
506
|
+
if (acc === undefined) {
|
|
507
|
+
acc = {
|
|
508
|
+
table,
|
|
509
|
+
refTable: str(row['ref_table']) ?? '',
|
|
510
|
+
columns: [],
|
|
511
|
+
onUpdate: FK_ACTION_MAP[str(row['on_update']) ?? ''] ?? null,
|
|
512
|
+
onDelete: FK_ACTION_MAP[str(row['on_delete']) ?? ''] ?? null,
|
|
513
|
+
};
|
|
514
|
+
fkAccumulators.set(key, acc);
|
|
515
|
+
}
|
|
516
|
+
acc.columns.push({
|
|
517
|
+
seq: num(row['seq']) ?? 0,
|
|
518
|
+
from: str(row['from_column']) ?? '',
|
|
519
|
+
to: str(row['to_column']), // null = implicit reference to the target PK
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
for (const acc of fkAccumulators.values()) {
|
|
523
|
+
const target = tables.get(acc.refTable);
|
|
524
|
+
const toTableId = `${schema}.${acc.refTable}`;
|
|
525
|
+
if (target === undefined) {
|
|
526
|
+
warnings.push({
|
|
527
|
+
code: 'fk-target-excluded',
|
|
528
|
+
message: `foreign key on "${acc.table.name}" references "${toTableId}" which is outside the introspected set`,
|
|
529
|
+
tableId: acc.table.id,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const ordered = acc.columns.sort((a, b) => a.seq - b.seq);
|
|
534
|
+
const columns = ordered.map((c) => c.from);
|
|
535
|
+
const refColumns = ordered.map((c, i) => c.to ?? target.primaryKey[i] ?? '');
|
|
536
|
+
if (refColumns.some((c) => c === ''))
|
|
537
|
+
continue; // unresolvable implicit ref
|
|
538
|
+
relations.push({
|
|
539
|
+
id: `fk:${acc.table.id}(${columns.join(',')})->${toTableId}(${refColumns.join(',')})`,
|
|
540
|
+
kind: 'declared-fk',
|
|
541
|
+
cardinality: 'one-to-many', // refined below once uniques are known
|
|
542
|
+
from: { tableId: acc.table.id, columns },
|
|
543
|
+
to: { tableId: toTableId, columns: refColumns },
|
|
544
|
+
through: null,
|
|
545
|
+
onDelete: acc.onDelete,
|
|
546
|
+
onUpdate: acc.onUpdate,
|
|
547
|
+
selfReferential: acc.table.id === toTableId,
|
|
548
|
+
confidence: 1,
|
|
549
|
+
});
|
|
550
|
+
columns.forEach((columnName, position) => {
|
|
551
|
+
const column = acc.table.columns.find((c) => c.name === columnName);
|
|
552
|
+
const refColumn = refColumns[position];
|
|
553
|
+
if (column !== undefined && column.references === null && refColumn !== undefined) {
|
|
554
|
+
column.references = { tableId: toTableId, column: refColumn };
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
// -- one-to-one refinement: FK column set covered by a unique key -----------
|
|
559
|
+
for (const relation of relations) {
|
|
560
|
+
const table = [...tables.values()].find((t) => t.id === relation.from.tableId);
|
|
561
|
+
if (table === undefined)
|
|
562
|
+
continue;
|
|
563
|
+
const key = [...relation.from.columns].sort().join(' ');
|
|
564
|
+
const uniqueSets = [
|
|
565
|
+
table.primaryKey,
|
|
566
|
+
...table.uniques.map((u) => u.columns),
|
|
567
|
+
...table.indexes.filter((i) => i.unique && !i.partial).map((i) => i.columns),
|
|
568
|
+
];
|
|
569
|
+
if (uniqueSets.some((set) => set.length > 0 && [...set].sort().join(' ') === key)) {
|
|
570
|
+
relation.cardinality = 'one-to-one';
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// -- row counts: stat1 estimates, else small-file exact counts (05 §4.3) ----
|
|
574
|
+
if (collectRowEstimates) {
|
|
575
|
+
const uncounted = [];
|
|
576
|
+
for (const table of tables.values()) {
|
|
577
|
+
if (table.kind !== 'table')
|
|
578
|
+
continue;
|
|
579
|
+
const estimate = statByTable.get(table.name);
|
|
580
|
+
if (estimate !== undefined) {
|
|
581
|
+
table.rowCountEstimate = estimate;
|
|
582
|
+
}
|
|
583
|
+
else if (ctx.fileSizeBytes !== null &&
|
|
584
|
+
ctx.fileSizeBytes < EXACT_COUNT_MAX_FILE_BYTES) {
|
|
585
|
+
uncounted.push(table.name);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (uncounted.length > 0) {
|
|
589
|
+
const countRows = await guarded(exactCountsSql(uncounted));
|
|
590
|
+
for (const row of countRows) {
|
|
591
|
+
const table = tables.get(str(row['table_name']) ?? '');
|
|
592
|
+
const count = num(row['n']);
|
|
593
|
+
if (table !== undefined && count !== null) {
|
|
594
|
+
table.rowCountEstimate = count;
|
|
595
|
+
table.rowCountExact = true; // 05 §2.1: true only for SQLite exact counts
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else if (ctx.fileSizeBytes !== null &&
|
|
600
|
+
ctx.fileSizeBytes >= EXACT_COUNT_MAX_FILE_BYTES &&
|
|
601
|
+
[...tables.values()].some((t) => t.kind === 'table' && t.rowCountEstimate === null)) {
|
|
602
|
+
warnings.push({
|
|
603
|
+
code: 'counts-unavailable',
|
|
604
|
+
message: 'file exceeds the exact-count threshold — run ANALYZE for row counts',
|
|
605
|
+
tableId: null,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// -- final assembly ----------------------------------------------------------
|
|
610
|
+
const enums = [...checkEnums.values()].sort(compareBy((def) => def.id));
|
|
611
|
+
const sortedTables = [...tables.values()].sort(compareBy((t) => t.id));
|
|
612
|
+
for (const table of sortedTables) {
|
|
613
|
+
table.columns.sort((a, b) => a.ordinal - b.ordinal);
|
|
614
|
+
table.indexes.sort(compareBy((i) => i.name));
|
|
615
|
+
table.uniques.sort(compareBy((u) => u.name ?? ''));
|
|
616
|
+
table.checks.sort(compareBy((c) => c.name ?? ''));
|
|
617
|
+
}
|
|
618
|
+
relations.sort(compareBy((r) => r.id));
|
|
619
|
+
const columnCount = sortedTables.reduce((total, table) => total + table.columns.length, 0);
|
|
620
|
+
return {
|
|
621
|
+
irVersion: 1,
|
|
622
|
+
dialect: 'sqlite',
|
|
623
|
+
source: { kind: 'live', connectionId: ctx.connectionId },
|
|
624
|
+
name: ctx.databaseName,
|
|
625
|
+
defaultSchema: schema,
|
|
626
|
+
schemas: [schema],
|
|
627
|
+
tables: sortedTables,
|
|
628
|
+
enums,
|
|
629
|
+
relations,
|
|
630
|
+
capabilities: { ...SQLITE_CAPABILITIES },
|
|
631
|
+
introspectedAt: new Date().toISOString(),
|
|
632
|
+
stats: {
|
|
633
|
+
tableCount: sortedTables.length,
|
|
634
|
+
columnCount,
|
|
635
|
+
relationCount: relations.length,
|
|
636
|
+
durationMs: Date.now() - startedAt,
|
|
637
|
+
},
|
|
638
|
+
warnings,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
//# sourceMappingURL=introspect.js.map
|