@zmdb/sqlite 1.0.0-beta.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 +674 -0
- package/README.md +108 -0
- package/dist/dialect.d.ts +2 -0
- package/dist/dialect.d.ts.map +1 -0
- package/dist/dialect.js +93 -0
- package/dist/dialect.js.map +1 -0
- package/dist/driver.d.ts +21 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +133 -0
- package/dist/driver.js.map +1 -0
- package/dist/embedded.d.ts +3 -0
- package/dist/embedded.d.ts.map +1 -0
- package/dist/embedded.js +2 -0
- package/dist/embedded.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/introspector.d.ts +5 -0
- package/dist/introspector.d.ts.map +1 -0
- package/dist/introspector.js +392 -0
- package/dist/introspector.js.map +1 -0
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +349 -0
- package/dist/migrations.js.map +1 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +2 -0
- package/dist/node.js.map +1 -0
- package/package.json +61 -0
- package/src/dialect.ts +95 -0
- package/src/driver.ts +158 -0
- package/src/embedded.ts +2 -0
- package/src/index.ts +14 -0
- package/src/introspector.ts +513 -0
- package/src/migrations.ts +500 -0
- package/src/node.ts +1 -0
package/src/driver.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type SelectedDriver, type TransactionalDriver } from '@zmdb/orm';
|
|
2
|
+
|
|
3
|
+
import { sqlite } from './dialect.js';
|
|
4
|
+
|
|
5
|
+
// Minimal structural types keep the adapter independent of `@types/node` at
|
|
6
|
+
// build time. Methods are bivariant, so a real `node:sqlite` `DatabaseSync` is
|
|
7
|
+
// assignable to `SqliteDatabase` — pass one straight in.
|
|
8
|
+
export interface SqliteStatement {
|
|
9
|
+
/** Returns rows for a row-returning statement such as SELECT, PRAGMA or RETURNING. */
|
|
10
|
+
all(...params: unknown[]): unknown[];
|
|
11
|
+
/** Describes result columns without executing the statement (`node:sqlite` provides this). */
|
|
12
|
+
columns?(): readonly unknown[];
|
|
13
|
+
/** Executes a non-returning statement. */
|
|
14
|
+
run(...params: unknown[]): unknown;
|
|
15
|
+
/** Steps a row-returning statement without materialising the result. */
|
|
16
|
+
iterate(...params: unknown[]): Iterable<Record<string, unknown>>;
|
|
17
|
+
}
|
|
18
|
+
export interface SqliteDatabase {
|
|
19
|
+
exec(sql: string): unknown;
|
|
20
|
+
prepare(sql: string): SqliteStatement;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SqliteOptions {
|
|
24
|
+
maxCacheSize?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CachedStatement {
|
|
28
|
+
stmt: SqliteStatement;
|
|
29
|
+
isRead: boolean;
|
|
30
|
+
activeIterators: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The app→db crossing for SQLite (plan D3).
|
|
35
|
+
*
|
|
36
|
+
* `node:sqlite` binds `null`, a boolean, a number, a bigint, a string and a `Uint8Array`, and
|
|
37
|
+
* throws "Provided value cannot be bound to SQLite parameter N" for anything else. A `Date`
|
|
38
|
+
* is exactly that anything else, and it is also the app type of every `timestamp` column —
|
|
39
|
+
* so before this, a `timestamp` could not be written through this driver at all: passing a
|
|
40
|
+
* `Date` threw here and passing a string was the wrong type one layer up.
|
|
41
|
+
*
|
|
42
|
+
* ISO-8601 is not an arbitrary choice of encoding. It is what the DDL emitter declares the
|
|
43
|
+
* column as (`TEXT`), what the wire layer carries, and — being fixed-width and
|
|
44
|
+
* zero-padded — the one text form whose lexicographic order is its chronological order, so
|
|
45
|
+
* `WHERE at > ?` and `ORDER BY at` mean what they say. UTC for the same reason: an offset
|
|
46
|
+
* would break that ordering.
|
|
47
|
+
*
|
|
48
|
+
* Applied to every parameter rather than per column, because the driver has no schema and
|
|
49
|
+
* needs none: there is one right answer for a `Date` here regardless of which column it
|
|
50
|
+
* was bound for.
|
|
51
|
+
*/
|
|
52
|
+
function bindable(value: unknown): unknown {
|
|
53
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Wrap a node:sqlite DatabaseSync as a zmdb Driver. Zero external deps. */
|
|
57
|
+
export function sqliteDriver(db: SqliteDatabase, opts?: SqliteOptions): TransactionalDriver<'sqlite'> {
|
|
58
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
59
|
+
const maxCacheSize = opts?.maxCacheSize ?? 1000;
|
|
60
|
+
const cache = new Map<string, CachedStatement>();
|
|
61
|
+
|
|
62
|
+
const statementFor = (text: string): CachedStatement => {
|
|
63
|
+
let entry = maxCacheSize > 0 ? cache.get(text) : undefined;
|
|
64
|
+
if (entry !== undefined && entry.activeIterators === 0) {
|
|
65
|
+
cache.delete(text);
|
|
66
|
+
cache.set(text, entry);
|
|
67
|
+
return entry;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stmt = db.prepare(text);
|
|
71
|
+
const columns = stmt.columns?.();
|
|
72
|
+
entry = {
|
|
73
|
+
stmt,
|
|
74
|
+
isRead:
|
|
75
|
+
columns === undefined ? /^\s*(?:SELECT|PRAGMA)\b/i.test(text) || /RETURNING/i.test(text) : columns.length > 0,
|
|
76
|
+
activeIterators: 0,
|
|
77
|
+
};
|
|
78
|
+
if (maxCacheSize <= 0) return entry;
|
|
79
|
+
|
|
80
|
+
if (cache.size >= maxCacheSize) {
|
|
81
|
+
const evictable = [...cache].find(([, candidate]) => candidate.activeIterators === 0);
|
|
82
|
+
if (evictable !== undefined) cache.delete(evictable[0]);
|
|
83
|
+
}
|
|
84
|
+
if (cache.size < maxCacheSize) cache.set(text, entry);
|
|
85
|
+
return entry;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const driver: TransactionalDriver<'sqlite'> = {
|
|
89
|
+
dialect: sqlite,
|
|
90
|
+
async execute(q, executeOpts) {
|
|
91
|
+
const signal = executeOpts?.signal;
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
const entry = statementFor(q.text);
|
|
94
|
+
const parameters = q.parameters.map(bindable);
|
|
95
|
+
if (entry.isRead) {
|
|
96
|
+
// boundary: rows leave the database untyped. `all()` is declared
|
|
97
|
+
// `unknown[]` (the widest shape every @types/node version agrees on);
|
|
98
|
+
// node:sqlite always yields plain row objects for a row-returning
|
|
99
|
+
// statement, and callers re-type them at the repository's row boundary.
|
|
100
|
+
const rows = entry.stmt.all(...parameters) as Record<string, unknown>[];
|
|
101
|
+
signal?.throwIfAborted();
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
if (parameters.length === 0) db.exec(q.text);
|
|
105
|
+
else entry.stmt.run(...parameters);
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
return [];
|
|
108
|
+
},
|
|
109
|
+
stream(q, executeOpts) {
|
|
110
|
+
const signal = executeOpts?.signal;
|
|
111
|
+
return {
|
|
112
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<Record<string, unknown>, void, unknown> {
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
const entry = statementFor(q.text);
|
|
115
|
+
if (!entry.isRead) throw new Error('sqliteDriver.stream requires a row-returning statement');
|
|
116
|
+
const parameters = q.parameters.map(bindable);
|
|
117
|
+
entry.activeIterators++;
|
|
118
|
+
let completed = false;
|
|
119
|
+
let iterator: Iterator<Record<string, unknown>> | undefined;
|
|
120
|
+
try {
|
|
121
|
+
iterator = entry.stmt.iterate(...parameters)[Symbol.iterator]();
|
|
122
|
+
for (;;) {
|
|
123
|
+
// node:sqlite has no sqlite3_interrupt binding. JavaScript regains
|
|
124
|
+
// control only between native steps, so abort can stop further
|
|
125
|
+
// rows but cannot interrupt one slow step already in SQLite.
|
|
126
|
+
signal?.throwIfAborted();
|
|
127
|
+
const next = iterator.next();
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
if (next.done) {
|
|
130
|
+
completed = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
yield next.value;
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
try {
|
|
137
|
+
if (!completed && iterator?.return !== undefined) iterator.return();
|
|
138
|
+
} finally {
|
|
139
|
+
entry.activeIterators--;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
async transaction<Result>(run: (driver: SelectedDriver<'sqlite'>) => Promise<Result>): Promise<Result> {
|
|
146
|
+
db.exec('BEGIN');
|
|
147
|
+
try {
|
|
148
|
+
const result = await run(driver);
|
|
149
|
+
db.exec('COMMIT');
|
|
150
|
+
return result;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
db.exec('ROLLBACK');
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
return driver;
|
|
158
|
+
}
|
package/src/embedded.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { sqlite } from './dialect.js';
|
|
2
|
+
export { sqliteDriver, type SqliteDatabase, type SqliteOptions, type SqliteStatement } from './driver.js';
|
|
3
|
+
export { sqliteIntrospector } from './introspector.js';
|
|
4
|
+
export { sqliteMigrations } from './migrations.js';
|
|
5
|
+
|
|
6
|
+
import { type DatabaseVertical } from '@zmdb/orm';
|
|
7
|
+
|
|
8
|
+
import { sqlite } from './dialect.js';
|
|
9
|
+
import { sqliteDriver, type SqliteDatabase, type SqliteOptions } from './driver.js';
|
|
10
|
+
|
|
11
|
+
export const sqliteVertical: DatabaseVertical<'sqlite', SqliteDatabase, SqliteOptions> = Object.freeze({
|
|
12
|
+
dialect: sqlite,
|
|
13
|
+
driver: sqliteDriver,
|
|
14
|
+
});
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { ColumnSnapshot, SchemaSnapshot } from '@zmdb/migrations';
|
|
2
|
+
import {
|
|
3
|
+
action,
|
|
4
|
+
CatalogRowError,
|
|
5
|
+
deterministicForeignKeyName,
|
|
6
|
+
flagField,
|
|
7
|
+
integerField,
|
|
8
|
+
nullableTextField,
|
|
9
|
+
query,
|
|
10
|
+
sortByName,
|
|
11
|
+
sortWarnings,
|
|
12
|
+
splitSqlList,
|
|
13
|
+
tableSelected,
|
|
14
|
+
textField,
|
|
15
|
+
type CatalogColumnSnapshot,
|
|
16
|
+
type CatalogForeignKeySnapshot,
|
|
17
|
+
type CatalogIndexColumn,
|
|
18
|
+
type CatalogIndexSnapshot,
|
|
19
|
+
type CatalogSchemaSnapshot,
|
|
20
|
+
type CatalogTableSnapshot,
|
|
21
|
+
type CatalogWarning,
|
|
22
|
+
normalizeDriftSnapshot,
|
|
23
|
+
type IntrospectionDriver,
|
|
24
|
+
type Introspector,
|
|
25
|
+
type IntrospectOptions,
|
|
26
|
+
} from '@zmdb/migrations/introspect/runtime';
|
|
27
|
+
|
|
28
|
+
interface SqliteTable {
|
|
29
|
+
readonly schema: string;
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly withoutRowid: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SqliteColumn {
|
|
35
|
+
readonly ordinal: number;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly catalogType: string;
|
|
38
|
+
readonly notNull: boolean;
|
|
39
|
+
readonly default: string | null;
|
|
40
|
+
readonly primaryKeyOrdinal: number;
|
|
41
|
+
readonly hidden: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SqliteForeignKeyRow {
|
|
45
|
+
readonly id: number;
|
|
46
|
+
readonly sequence: number;
|
|
47
|
+
readonly targetTable: string;
|
|
48
|
+
readonly column: string;
|
|
49
|
+
readonly targetColumn: string | null;
|
|
50
|
+
readonly onUpdate: string;
|
|
51
|
+
readonly onDelete: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SqliteIndexRow {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly unique: boolean;
|
|
57
|
+
readonly origin: string;
|
|
58
|
+
readonly partial: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SqliteIndexColumnRow {
|
|
62
|
+
readonly sequence: number;
|
|
63
|
+
readonly columnId: number;
|
|
64
|
+
readonly name: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function placeholders(count: number): string {
|
|
68
|
+
return Array.from({ length: count }, () => '?').join(', ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sqliteSchemas(options: IntrospectOptions): readonly string[] {
|
|
72
|
+
const schemas = options.schemas ?? ['main'];
|
|
73
|
+
if (schemas.length === 0) return ['main'];
|
|
74
|
+
return [...new Set(schemas)].toSorted();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseTable(row: Readonly<Record<string, unknown>>, index: number): SqliteTable {
|
|
78
|
+
const catalog = 'sqlite pragma_table_list';
|
|
79
|
+
const type = textField(row, 'type', catalog, index);
|
|
80
|
+
if (type !== 'table') throw new CatalogRowError(catalog, index, 'type', '"table"', type);
|
|
81
|
+
return {
|
|
82
|
+
schema: textField(row, 'schema', catalog, index),
|
|
83
|
+
name: textField(row, 'name', catalog, index),
|
|
84
|
+
withoutRowid: flagField(row, 'wr', catalog, index),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseColumn(row: Readonly<Record<string, unknown>>, index: number): SqliteColumn {
|
|
89
|
+
const catalog = 'sqlite pragma_table_xinfo';
|
|
90
|
+
const hidden = integerField(row, 'hidden', catalog, index);
|
|
91
|
+
if (hidden !== 0 && hidden !== 2 && hidden !== 3) {
|
|
92
|
+
throw new CatalogRowError(catalog, index, 'hidden', '0, 2 or 3 for an ordinary table', hidden);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ordinal: integerField(row, 'cid', catalog, index),
|
|
96
|
+
name: textField(row, 'name', catalog, index),
|
|
97
|
+
catalogType: textField(row, 'type', catalog, index),
|
|
98
|
+
notNull: flagField(row, 'notnull', catalog, index),
|
|
99
|
+
default: nullableTextField(row, 'dflt_value', catalog, index),
|
|
100
|
+
primaryKeyOrdinal: integerField(row, 'pk', catalog, index),
|
|
101
|
+
hidden,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseForeignKey(row: Readonly<Record<string, unknown>>, index: number): SqliteForeignKeyRow {
|
|
106
|
+
const catalog = 'sqlite pragma_foreign_key_list';
|
|
107
|
+
return {
|
|
108
|
+
id: integerField(row, 'id', catalog, index),
|
|
109
|
+
sequence: integerField(row, 'seq', catalog, index),
|
|
110
|
+
targetTable: textField(row, 'table', catalog, index),
|
|
111
|
+
column: textField(row, 'from', catalog, index),
|
|
112
|
+
targetColumn: nullableTextField(row, 'to', catalog, index),
|
|
113
|
+
onUpdate: textField(row, 'on_update', catalog, index),
|
|
114
|
+
onDelete: textField(row, 'on_delete', catalog, index),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseIndex(row: Readonly<Record<string, unknown>>, index: number): SqliteIndexRow {
|
|
119
|
+
const catalog = 'sqlite pragma_index_list';
|
|
120
|
+
const origin = textField(row, 'origin', catalog, index);
|
|
121
|
+
if (origin !== 'c' && origin !== 'u' && origin !== 'pk') {
|
|
122
|
+
throw new CatalogRowError(catalog, index, 'origin', '"c", "u" or "pk"', origin);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: textField(row, 'name', catalog, index),
|
|
126
|
+
unique: flagField(row, 'unique', catalog, index),
|
|
127
|
+
origin,
|
|
128
|
+
partial: flagField(row, 'partial', catalog, index),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseIndexColumn(row: Readonly<Record<string, unknown>>, index: number): SqliteIndexColumnRow {
|
|
133
|
+
const catalog = 'sqlite pragma_index_info';
|
|
134
|
+
return {
|
|
135
|
+
sequence: integerField(row, 'seqno', catalog, index),
|
|
136
|
+
columnId: integerField(row, 'cid', catalog, index),
|
|
137
|
+
name: nullableTextField(row, 'name', catalog, index),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sqliteType(
|
|
142
|
+
declared: string,
|
|
143
|
+
serial: boolean,
|
|
144
|
+
): { readonly type: string; readonly length?: number; readonly warning?: string } {
|
|
145
|
+
const normalized = declared.trim().toUpperCase();
|
|
146
|
+
if (serial) return { type: 'serial' };
|
|
147
|
+
if (normalized === 'INTEGER') return { type: 'integer' };
|
|
148
|
+
if (normalized === 'INT') return { type: 'integer' };
|
|
149
|
+
if (normalized === 'TEXT') return { type: 'text' };
|
|
150
|
+
|
|
151
|
+
const varchar = /^VARCHAR\s*\(\s*(\d+)\s*\)$/.exec(normalized);
|
|
152
|
+
if (varchar) {
|
|
153
|
+
const lengthText = varchar[1];
|
|
154
|
+
if (lengthText !== undefined) {
|
|
155
|
+
return {
|
|
156
|
+
type: 'varchar',
|
|
157
|
+
length: Number(lengthText),
|
|
158
|
+
warning: `SQLite does not enforce the declared length ${declared}; the generated declaration will`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
normalized === 'REAL' ||
|
|
164
|
+
normalized === 'NUMERIC' ||
|
|
165
|
+
/^DECIMAL(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?$/.test(normalized)
|
|
166
|
+
) {
|
|
167
|
+
return { type: 'numeric' };
|
|
168
|
+
}
|
|
169
|
+
if (normalized === 'BLOB' || normalized.length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
type: declared,
|
|
172
|
+
warning:
|
|
173
|
+
normalized.length === 0
|
|
174
|
+
? 'SQLite column has no declared type; BLOB affinity cannot be represented by the current declared SQL type vocabulary'
|
|
175
|
+
: `SQLite type ${declared} cannot be represented by the current declared SQL type vocabulary`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// SQLite's declared type is open text. These are SQLite's own affinity rules,
|
|
180
|
+
// not guesses at application semantics; catalogType preserves the lossy source.
|
|
181
|
+
if (normalized.includes('INT')) {
|
|
182
|
+
return { type: 'integer', warning: `SQLite declared type ${declared} was normalized by INTEGER affinity` };
|
|
183
|
+
}
|
|
184
|
+
if (normalized.includes('CHAR') || normalized.includes('CLOB') || normalized.includes('TEXT')) {
|
|
185
|
+
return { type: 'text', warning: `SQLite declared type ${declared} was normalized by TEXT affinity` };
|
|
186
|
+
}
|
|
187
|
+
if (normalized.includes('BLOB')) {
|
|
188
|
+
return {
|
|
189
|
+
type: declared,
|
|
190
|
+
warning: `SQLite declared type ${declared} has BLOB affinity and cannot be represented by the current declared SQL type vocabulary`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (normalized.includes('REAL') || normalized.includes('FLOA') || normalized.includes('DOUB')) {
|
|
194
|
+
return { type: 'numeric', warning: `SQLite declared type ${declared} was normalized by REAL affinity` };
|
|
195
|
+
}
|
|
196
|
+
return { type: 'numeric', warning: `SQLite declared type ${declared} was normalized by NUMERIC affinity` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function snapshotColumns(
|
|
200
|
+
table: SqliteTable,
|
|
201
|
+
rows: readonly SqliteColumn[],
|
|
202
|
+
primaryKeyIndexed: boolean,
|
|
203
|
+
warnings: CatalogWarning[],
|
|
204
|
+
): readonly CatalogColumnSnapshot[] {
|
|
205
|
+
const primaryKey = rows
|
|
206
|
+
.filter(row => row.primaryKeyOrdinal > 0)
|
|
207
|
+
.toSorted((left, right) => left.primaryKeyOrdinal - right.primaryKeyOrdinal);
|
|
208
|
+
const rowidAlias =
|
|
209
|
+
!table.withoutRowid &&
|
|
210
|
+
!primaryKeyIndexed &&
|
|
211
|
+
primaryKey.length === 1 &&
|
|
212
|
+
primaryKey[0]?.catalogType.trim().toUpperCase() === 'INTEGER';
|
|
213
|
+
|
|
214
|
+
return rows
|
|
215
|
+
.map(row => {
|
|
216
|
+
const serial = rowidAlias && row.primaryKeyOrdinal === 1;
|
|
217
|
+
const mapped = sqliteType(row.catalogType, serial);
|
|
218
|
+
if (mapped.warning !== undefined) {
|
|
219
|
+
warnings.push({ table: table.name, column: row.name, reason: mapped.warning });
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
name: row.name,
|
|
223
|
+
type: mapped.type,
|
|
224
|
+
catalogType: row.catalogType,
|
|
225
|
+
nullable: !row.notNull && !serial,
|
|
226
|
+
primaryKey: row.primaryKeyOrdinal > 0,
|
|
227
|
+
...(mapped.length === undefined ? {} : { length: mapped.length }),
|
|
228
|
+
...(row.default === null ? {} : { default: row.default }),
|
|
229
|
+
};
|
|
230
|
+
})
|
|
231
|
+
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function indexSqlSource(schema: string): string | undefined {
|
|
235
|
+
if (schema === 'main') return 'sqlite_schema';
|
|
236
|
+
if (schema === 'temp') return 'sqlite_temp_schema';
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function indexParts(sql: string): { readonly columns: readonly string[]; readonly where?: string } {
|
|
241
|
+
const open = sql.indexOf('(');
|
|
242
|
+
if (open === -1) return { columns: [] };
|
|
243
|
+
let depth = 0;
|
|
244
|
+
let quoteCharacter: "'" | '"' | '`' | undefined;
|
|
245
|
+
let close = -1;
|
|
246
|
+
for (let index = open; index < sql.length; index += 1) {
|
|
247
|
+
const character = sql[index];
|
|
248
|
+
if (quoteCharacter !== undefined) {
|
|
249
|
+
if (character === quoteCharacter) {
|
|
250
|
+
if (sql[index + 1] === quoteCharacter) index += 1;
|
|
251
|
+
else quoteCharacter = undefined;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (character === "'" || character === '"' || character === '`') {
|
|
256
|
+
quoteCharacter = character;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (character === '(') depth += 1;
|
|
260
|
+
else if (character === ')') {
|
|
261
|
+
depth -= 1;
|
|
262
|
+
if (depth === 0) {
|
|
263
|
+
close = index;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (close === -1) return { columns: [] };
|
|
269
|
+
const tail = sql.slice(close + 1).trim();
|
|
270
|
+
const where = /^WHERE\s+(.+)$/is.exec(tail)?.[1]?.trim();
|
|
271
|
+
return {
|
|
272
|
+
columns: splitSqlList(sql.slice(open + 1, close)),
|
|
273
|
+
...(where === undefined ? {} : { where }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function readIndexSql(driver: IntrospectionDriver, schema: string, name: string): Promise<string | undefined> {
|
|
278
|
+
const source = indexSqlSource(schema);
|
|
279
|
+
if (source === undefined) return undefined;
|
|
280
|
+
const rows = await driver.execute(query(`SELECT sql FROM ${source} WHERE type = 'index' AND name = ?`, [name]));
|
|
281
|
+
if (rows.length === 0) return undefined;
|
|
282
|
+
if (rows.length !== 1) {
|
|
283
|
+
throw new TypeError(`sqlite catalog returned ${String(rows.length)} definitions for index "${name}"`);
|
|
284
|
+
}
|
|
285
|
+
return nullableTextField(rows[0] ?? {}, 'sql', `sqlite ${source}`, 0) ?? undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function readIndexes(
|
|
289
|
+
driver: IntrospectionDriver,
|
|
290
|
+
table: SqliteTable,
|
|
291
|
+
warnings: CatalogWarning[],
|
|
292
|
+
): Promise<{
|
|
293
|
+
readonly indexes: readonly CatalogIndexSnapshot[];
|
|
294
|
+
readonly primaryKeyIndexed: boolean;
|
|
295
|
+
}> {
|
|
296
|
+
const rows = await driver.execute(
|
|
297
|
+
query('SELECT seq, name, "unique", origin, partial FROM pragma_index_list(?, ?)', [table.name, table.schema]),
|
|
298
|
+
);
|
|
299
|
+
const indexes: CatalogIndexSnapshot[] = [];
|
|
300
|
+
let primaryKeyIndexed = false;
|
|
301
|
+
for (const [rowIndex, raw] of rows.entries()) {
|
|
302
|
+
const index = parseIndex(raw, rowIndex);
|
|
303
|
+
if (index.origin === 'pk') {
|
|
304
|
+
primaryKeyIndexed = true;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const columnRows = await driver.execute(
|
|
308
|
+
query('SELECT seqno, cid, name FROM pragma_index_info(?, ?) ORDER BY seqno', [index.name, table.schema]),
|
|
309
|
+
);
|
|
310
|
+
const parsedColumns = columnRows.map(parseIndexColumn).toSorted((left, right) => left.sequence - right.sequence);
|
|
311
|
+
const sql = await readIndexSql(driver, table.schema, index.name);
|
|
312
|
+
const parts = sql === undefined ? { columns: [] } : indexParts(sql);
|
|
313
|
+
if (index.partial !== (parts.where !== undefined)) {
|
|
314
|
+
throw new TypeError(
|
|
315
|
+
`sqlite index "${index.name}" reports partial=${String(index.partial)} but its CREATE INDEX text ` +
|
|
316
|
+
`${parts.where === undefined ? 'has no WHERE clause' : 'has a WHERE clause'}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const columns: CatalogIndexColumn[] = parsedColumns.map(column => {
|
|
320
|
+
if (column.name !== null && column.columnId >= 0) return column.name;
|
|
321
|
+
const expression = parts.columns[column.sequence];
|
|
322
|
+
if (expression === undefined) {
|
|
323
|
+
throw new TypeError(
|
|
324
|
+
`sqlite index "${index.name}" contains an expression but its CREATE INDEX text is unavailable`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
warnings.push({
|
|
328
|
+
table: table.name,
|
|
329
|
+
reason: `SQLite index "${index.name}" expression was parsed from sqlite_schema SQL because PRAGMA index_info reports only cid = -2`,
|
|
330
|
+
});
|
|
331
|
+
return { expr: expression };
|
|
332
|
+
});
|
|
333
|
+
indexes.push({
|
|
334
|
+
name: index.name,
|
|
335
|
+
columns,
|
|
336
|
+
unique: index.unique,
|
|
337
|
+
...(parts.where === undefined ? {} : { where: parts.where }),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { indexes: sortByName(indexes), primaryKeyIndexed };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function readForeignKeys(
|
|
344
|
+
driver: IntrospectionDriver,
|
|
345
|
+
table: SqliteTable,
|
|
346
|
+
primaryKeys: ReadonlyMap<string, readonly string[]>,
|
|
347
|
+
): Promise<readonly CatalogForeignKeySnapshot[]> {
|
|
348
|
+
const rows = await driver.execute(
|
|
349
|
+
query(
|
|
350
|
+
'SELECT id, seq, "table", "from", "to", on_update, on_delete ' +
|
|
351
|
+
'FROM pragma_foreign_key_list(?, ?) ORDER BY id, seq',
|
|
352
|
+
[table.name, table.schema],
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
const grouped = new Map<number, SqliteForeignKeyRow[]>();
|
|
356
|
+
for (const [index, row] of rows.entries()) {
|
|
357
|
+
const parsed = parseForeignKey(row, index);
|
|
358
|
+
const values = grouped.get(parsed.id);
|
|
359
|
+
if (values) values.push(parsed);
|
|
360
|
+
else grouped.set(parsed.id, [parsed]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const foreignKeys: CatalogForeignKeySnapshot[] = [];
|
|
364
|
+
for (const values of grouped.values()) {
|
|
365
|
+
values.sort((left, right) => left.sequence - right.sequence);
|
|
366
|
+
const first = values[0];
|
|
367
|
+
if (!first) continue;
|
|
368
|
+
const columns = values.map(value => value.column);
|
|
369
|
+
const inferredTarget = primaryKeys.get(first.targetTable) ?? [];
|
|
370
|
+
const targetColumns = values.map((value, index) => value.targetColumn ?? inferredTarget[index]);
|
|
371
|
+
if (targetColumns.some(column => column === undefined)) {
|
|
372
|
+
throw new TypeError(
|
|
373
|
+
`sqlite foreign key on "${table.name}" omits target columns and "${first.targetTable}" has no matching primary key`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
foreignKeys.push({
|
|
377
|
+
name: deterministicForeignKeyName(table.name, columns),
|
|
378
|
+
columns,
|
|
379
|
+
targetTable: first.targetTable,
|
|
380
|
+
targetColumns: targetColumns.filter(column => column !== undefined),
|
|
381
|
+
onDelete: action(first.onDelete, 'sqlite pragma_foreign_key_list', first.id, 'on_delete'),
|
|
382
|
+
onUpdate: action(first.onUpdate, 'sqlite pragma_foreign_key_list', first.id, 'on_update'),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return sortByName(foreignKeys);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function sqliteSnapshot(
|
|
389
|
+
driver: IntrospectionDriver,
|
|
390
|
+
options: IntrospectOptions = {},
|
|
391
|
+
): Promise<CatalogSchemaSnapshot> {
|
|
392
|
+
const schemas = sqliteSchemas(options);
|
|
393
|
+
const rawTables = await driver.execute(
|
|
394
|
+
query(
|
|
395
|
+
`SELECT schema, name, type, wr FROM pragma_table_list ` +
|
|
396
|
+
`WHERE schema IN (${placeholders(schemas.length)}) AND type = 'table' ORDER BY schema, name`,
|
|
397
|
+
schemas,
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
const tables = rawTables
|
|
401
|
+
.map(parseTable)
|
|
402
|
+
.filter(table => !table.name.startsWith('sqlite_') && tableSelected(table.name, options));
|
|
403
|
+
const duplicate = tables.find(
|
|
404
|
+
(table, index) => tables.findIndex(candidate => candidate.name === table.name) !== index,
|
|
405
|
+
);
|
|
406
|
+
if (duplicate !== undefined) {
|
|
407
|
+
throw new TypeError(
|
|
408
|
+
`sqlite introspection cannot represent table "${duplicate.name}" from more than one schema in a schema-neutral snapshot`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const warnings: CatalogWarning[] = [];
|
|
413
|
+
const columns = new Map<string, readonly SqliteColumn[]>();
|
|
414
|
+
const primaryKeys = new Map<string, readonly string[]>();
|
|
415
|
+
for (const table of tables) {
|
|
416
|
+
const rawColumns = await driver.execute(
|
|
417
|
+
query('SELECT cid, name, type, "notnull", dflt_value, pk, hidden FROM pragma_table_xinfo(?, ?) ORDER BY cid', [
|
|
418
|
+
table.name,
|
|
419
|
+
table.schema,
|
|
420
|
+
]),
|
|
421
|
+
);
|
|
422
|
+
const parsed = rawColumns.map(parseColumn).toSorted((left, right) => left.ordinal - right.ordinal);
|
|
423
|
+
for (const column of parsed) {
|
|
424
|
+
if (column.hidden === 0) continue;
|
|
425
|
+
warnings.push({
|
|
426
|
+
table: table.name,
|
|
427
|
+
column: column.name,
|
|
428
|
+
reason:
|
|
429
|
+
`SQLite ${column.hidden === 3 ? 'stored' : 'virtual'} generated column is omitted because ` +
|
|
430
|
+
'the portable schema snapshot does not carry generated expressions',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const ordinary = parsed.filter(column => column.hidden === 0);
|
|
434
|
+
columns.set(`${table.schema}\0${table.name}`, ordinary);
|
|
435
|
+
primaryKeys.set(
|
|
436
|
+
table.name,
|
|
437
|
+
ordinary
|
|
438
|
+
.filter(column => column.primaryKeyOrdinal > 0)
|
|
439
|
+
.toSorted((left, right) => left.primaryKeyOrdinal - right.primaryKeyOrdinal)
|
|
440
|
+
.map(column => column.name),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const snapshots: CatalogTableSnapshot[] = [];
|
|
445
|
+
const foreignKeyMode = await driver.execute(query('PRAGMA foreign_keys'));
|
|
446
|
+
const mode = foreignKeyMode[0];
|
|
447
|
+
if (mode === undefined) throw new TypeError('sqlite PRAGMA foreign_keys returned no row');
|
|
448
|
+
if (!flagField(mode, 'foreign_keys', 'sqlite PRAGMA foreign_keys', 0)) {
|
|
449
|
+
warnings.push({
|
|
450
|
+
table: '*',
|
|
451
|
+
reason: 'SQLite foreign key enforcement is disabled on this connection (PRAGMA foreign_keys = 0)',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
for (const table of tables) {
|
|
455
|
+
const tableColumns = columns.get(`${table.schema}\0${table.name}`) ?? [];
|
|
456
|
+
const indexResult = await readIndexes(driver, table, warnings);
|
|
457
|
+
snapshots.push({
|
|
458
|
+
name: table.name,
|
|
459
|
+
columns: snapshotColumns(table, tableColumns, indexResult.primaryKeyIndexed, warnings),
|
|
460
|
+
primaryKey: primaryKeys.get(table.name) ?? [],
|
|
461
|
+
foreignKeys: await readForeignKeys(driver, table, primaryKeys),
|
|
462
|
+
indexes: indexResult.indexes,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
version: 1,
|
|
467
|
+
tables: sortByName(snapshots),
|
|
468
|
+
extensions: [],
|
|
469
|
+
warnings: sortWarnings(warnings),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const databaseName = 'sqlite' as const;
|
|
474
|
+
|
|
475
|
+
const NORMALIZED_DECLARED_TYPES = Object.freeze({
|
|
476
|
+
serial: 'serial',
|
|
477
|
+
integer: 'integer',
|
|
478
|
+
bigint: 'integer',
|
|
479
|
+
numeric: 'numeric',
|
|
480
|
+
text: 'text',
|
|
481
|
+
varchar: 'text',
|
|
482
|
+
boolean: 'integer',
|
|
483
|
+
timestamp: 'text',
|
|
484
|
+
json: 'text',
|
|
485
|
+
jsonEnum: 'text',
|
|
486
|
+
} as const);
|
|
487
|
+
|
|
488
|
+
function normalizeDeclaredColumn(column: ColumnSnapshot): ColumnSnapshot {
|
|
489
|
+
if (typeof column.type !== 'string') return column;
|
|
490
|
+
const mapped: unknown = Reflect.get(NORMALIZED_DECLARED_TYPES, column.type);
|
|
491
|
+
if (typeof mapped !== 'string') return column;
|
|
492
|
+
if (column.type !== 'varchar') return { ...column, type: mapped };
|
|
493
|
+
const { length: _length, ...withoutLength } = column;
|
|
494
|
+
return { ...withoutLength, type: mapped };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function normalizeSqliteDriftSnapshot(snapshot: SchemaSnapshot, role: 'live' | 'declared'): SchemaSnapshot {
|
|
498
|
+
const normalized = normalizeDriftSnapshot(snapshot, role);
|
|
499
|
+
return {
|
|
500
|
+
...normalized,
|
|
501
|
+
tables: normalized.tables.map(table => ({
|
|
502
|
+
...table,
|
|
503
|
+
columns: role === 'declared' ? table.columns.map(normalizeDeclaredColumn) : table.columns,
|
|
504
|
+
indexes: table.indexes ?? [],
|
|
505
|
+
})),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const sqliteIntrospector: Introspector<typeof databaseName> = {
|
|
510
|
+
name: databaseName,
|
|
511
|
+
snapshot: sqliteSnapshot,
|
|
512
|
+
normalizeForDrift: normalizeSqliteDriftSnapshot,
|
|
513
|
+
};
|