@profullstack/libsql-pg 0.1.0
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 +21 -0
- package/README.md +128 -0
- package/bin/libsql-pg.js +11 -0
- package/index.js +8 -0
- package/package.json +73 -0
- package/src/bind.js +141 -0
- package/src/cli.js +123 -0
- package/src/client.js +449 -0
- package/src/copy.js +614 -0
- package/src/errors.js +111 -0
- package/src/result.js +87 -0
- package/src/rewrite.js +549 -0
- package/src/schema.js +549 -0
- package/src/sqlparse.js +283 -0
package/src/schema.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { rewriteFunctions, rewriteStatement } from './rewrite.js';
|
|
2
|
+
import { codeMask, matchParen, quoteIdent, replaceCode, splitStatements, splitTopLevel, unquote } from './sqlparse.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SQLite DDL to Postgres DDL.
|
|
6
|
+
*
|
|
7
|
+
* The converter is a set of textual rules over a shallow parse of each
|
|
8
|
+
* CREATE TABLE: column definitions and table constraints are split at the
|
|
9
|
+
* top-level commas, types are mapped, the SQLite-only words are dropped and
|
|
10
|
+
* the DEFAULT expressions go through the same function rewriter the client
|
|
11
|
+
* uses at run time. Anything it does not understand is passed through
|
|
12
|
+
* unchanged, or emitted as a commented TODO when Postgres would refuse it
|
|
13
|
+
* (FTS5 virtual tables, triggers).
|
|
14
|
+
*
|
|
15
|
+
* @typedef {{ name: string, columns: Map<string, string> }} TableInfo
|
|
16
|
+
* @typedef {{ tables: Map<string, TableInfo>, usesPgcrypto: boolean, notes: string[] }} ConvertContext
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* promoteTextTimestamps?: boolean,
|
|
19
|
+
* json?: 'text' | 'jsonb',
|
|
20
|
+
* searchColumn?: string,
|
|
21
|
+
* textSearchConfig?: string,
|
|
22
|
+
* }} ConvertOptions
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const CONSTRAINT_START = /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i;
|
|
26
|
+
const COLUMN_CONSTRAINT_WORDS = /\b(constraint|not\s+null|null|primary\s+key|unique|check|default|collate|references|generated|as)\b/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map a SQLite declared type (or none) to a Postgres type.
|
|
30
|
+
*
|
|
31
|
+
* SQLite's type affinity rules are followed loosely: anything with INT in it
|
|
32
|
+
* is an integer, CHAR/CLOB/TEXT are text, BLOB is bytea, REAL/FLOA/DOUB are
|
|
33
|
+
* doubles. Names SQLite ignores but people write (BOOLEAN, DATETIME, DATE,
|
|
34
|
+
* UUID) become the Postgres type they meant.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} declared
|
|
37
|
+
* @param {ConvertOptions} [opts]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function mapType(declared, opts = {}) {
|
|
41
|
+
const t = declared.trim().replace(/\s+/g, ' ').toUpperCase();
|
|
42
|
+
if (!t) return 'text';
|
|
43
|
+
// Postgres types already, so converting converted DDL is a no-op (the
|
|
44
|
+
// client runs run-time DDL through here, and so does a second pass).
|
|
45
|
+
if (/^(BYTEA|TSVECTOR|JSONB|BIGSERIAL|SERIAL|SMALLSERIAL|DOUBLE PRECISION|TIMESTAMPTZ|TIMESTAMP(TZ)? WITH(OUT)? TIME ZONE|CHARACTER VARYING(\(\d+\))?|INET|CIDR|MACADDR|INTERVAL|MONEY|POINT|XML|BIT(\(\d+\))?|VARBIT|BOX|LINE|PATH|POLYGON|CIRCLE|TSQUERY|OID|NAME|REGCLASS)$/.test(t)) {
|
|
46
|
+
return t.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
if (/\[\]$/.test(t)) return t.toLowerCase(); // an array type
|
|
49
|
+
if (/^BOOL/.test(t)) return 'boolean';
|
|
50
|
+
if (/^(DATETIME|TIMESTAMP)/.test(t)) return 'timestamptz';
|
|
51
|
+
if (/^DATE$/.test(t)) return 'date';
|
|
52
|
+
if (/^TIME$/.test(t)) return 'time';
|
|
53
|
+
if (/^UUID$/.test(t)) return 'uuid';
|
|
54
|
+
if (/^JSON/.test(t)) return opts.json === 'jsonb' ? 'jsonb' : 'text';
|
|
55
|
+
if (/INT/.test(t)) return 'bigint';
|
|
56
|
+
if (/CHAR|CLOB|TEXT|STRING/.test(t)) return 'text';
|
|
57
|
+
if (/BLOB|BINARY/.test(t)) return 'bytea';
|
|
58
|
+
if (/^(NUMERIC|DECIMAL)/.test(t)) {
|
|
59
|
+
const m = /\(([^)]*)\)/.exec(t);
|
|
60
|
+
return m ? `numeric(${m[1].trim()})` : 'numeric';
|
|
61
|
+
}
|
|
62
|
+
if (/REAL|FLOA|DOUB/.test(t)) return 'double precision';
|
|
63
|
+
return 'text';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Split a column definition into name, declared type and the constraint tail.
|
|
68
|
+
* @param {string} def
|
|
69
|
+
*/
|
|
70
|
+
export function parseColumnDef(def) {
|
|
71
|
+
const text = def.trim();
|
|
72
|
+
const mask = codeMask(text);
|
|
73
|
+
// Name: quoted or bare.
|
|
74
|
+
let i = 0;
|
|
75
|
+
if (mask[0] === '"' || mask[0] === '`' || mask[0] === '[') {
|
|
76
|
+
const end = mask.indexOf(mask[0] === '[' ? ']' : mask[0], 1);
|
|
77
|
+
i = end === -1 ? text.length : end + 1;
|
|
78
|
+
} else {
|
|
79
|
+
const m = /^[^\s(]+/.exec(text);
|
|
80
|
+
i = m ? m[0].length : text.length;
|
|
81
|
+
}
|
|
82
|
+
const name = unquote(text.slice(0, i));
|
|
83
|
+
const rest = text.slice(i);
|
|
84
|
+
const restMask = codeMask(rest);
|
|
85
|
+
// Type: everything up to the first constraint keyword (a type may carry a
|
|
86
|
+
// parenthesised length and several words, e.g. `UNSIGNED BIG INT`).
|
|
87
|
+
const kw = COLUMN_CONSTRAINT_WORDS.exec(restMask);
|
|
88
|
+
const typeEnd = kw ? kw.index : rest.length;
|
|
89
|
+
const declared = rest.slice(0, typeEnd).trim();
|
|
90
|
+
const constraints = rest.slice(typeEnd).trim();
|
|
91
|
+
return { name, declared, constraints };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite the constraint tail of one column.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} constraints
|
|
98
|
+
* @param {{ type: string, ctx: ConvertContext, opts: ConvertOptions }} info
|
|
99
|
+
* @returns {{ constraints: string, type: string, identity: boolean, notes: string[] }}
|
|
100
|
+
*/
|
|
101
|
+
function convertColumnConstraints(constraints, { type, ctx, opts }) {
|
|
102
|
+
let c = ` ${constraints} `;
|
|
103
|
+
const notes = [];
|
|
104
|
+
let identity = false;
|
|
105
|
+
|
|
106
|
+
// Column-level ON CONFLICT clauses have no Postgres form.
|
|
107
|
+
c = replaceCode(c, /\son\s+conflict\s+(rollback|abort|fail|ignore|replace)\b/gi, (m) => {
|
|
108
|
+
notes.push(`dropped column conflict clause "${m.trim()}"`);
|
|
109
|
+
return ' ';
|
|
110
|
+
});
|
|
111
|
+
c = replaceCode(c, /\sautoincrement\b/gi, ' ');
|
|
112
|
+
c = replaceCode(c, /\sprimary\s+key(\s+(asc|desc))?\b/gi, ' PRIMARY KEY ');
|
|
113
|
+
// COLLATE NOCASE and friends.
|
|
114
|
+
c = replaceCode(c, /\scollate\s+(nocase|binary|rtrim|[A-Za-z_]+)\b/gi, (_m, name) => {
|
|
115
|
+
if (/^(nocase|binary|rtrim)$/i.test(name)) notes.push(`dropped COLLATE ${name.toUpperCase()}`);
|
|
116
|
+
else return ` COLLATE ${name}`;
|
|
117
|
+
return ' ';
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const alreadyIdentity = /\bgenerated\s+(always|by\s+default)\s+as\s+identity\b/i.test(codeMask(c));
|
|
121
|
+
if (/\sPRIMARY KEY\s/.test(c) && type === 'bigint' && !alreadyIdentity) {
|
|
122
|
+
identity = true;
|
|
123
|
+
c = c.replace(/\sPRIMARY KEY\s/, ' ');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// DEFAULT expression.
|
|
127
|
+
c = rewriteDefault(c, type, ctx, notes);
|
|
128
|
+
|
|
129
|
+
// Generated columns must be STORED in Postgres.
|
|
130
|
+
c = replaceCode(c, /\b(generated\s+always\s+)?as\s*\(/gi, 'GENERATED ALWAYS AS (');
|
|
131
|
+
if (/GENERATED ALWAYS AS \(/.test(c)) {
|
|
132
|
+
const mask = codeMask(c);
|
|
133
|
+
const open = mask.indexOf('(', mask.indexOf('GENERATED ALWAYS AS'));
|
|
134
|
+
const close = matchParen(mask, open);
|
|
135
|
+
if (close !== -1) {
|
|
136
|
+
const after = c.slice(close + 1).replace(/^\s*(virtual|stored)\b/i, ' STORED');
|
|
137
|
+
c = `${c.slice(0, close + 1)}${/^\s*STORED/.test(after) ? after : ` STORED${after}`}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A `check (flag in (0, 1))` on a column that became boolean is now wrong.
|
|
142
|
+
if (type === 'boolean') {
|
|
143
|
+
c = replaceCode(c, /\scheck\s*\(\s*[A-Za-z_"][^()]*\s+in\s*\(\s*[01]\s*,\s*[01]\s*\)\s*\)/gi, ' ');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (identity) {
|
|
147
|
+
// Identity columns take no DEFAULT.
|
|
148
|
+
c = stripDefault(c);
|
|
149
|
+
c = ` GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY${c}`;
|
|
150
|
+
}
|
|
151
|
+
return { constraints: c.replace(/\s+/g, ' ').trim(), type, identity, notes };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Remove a DEFAULT clause (a literal or a parenthesised expression). */
|
|
155
|
+
function stripDefault(c) {
|
|
156
|
+
const mask = codeMask(c);
|
|
157
|
+
const m = /(?<!\bby)\sdefault\s+/i.exec(mask);
|
|
158
|
+
if (!m) return c;
|
|
159
|
+
let end = m.index + m[0].length;
|
|
160
|
+
if (mask[end] === '(') end = matchParen(mask, end) + 1;
|
|
161
|
+
else {
|
|
162
|
+
const rest = /^('[^']*(?:''[^']*)*'|[^\s]+)/.exec(c.slice(end));
|
|
163
|
+
// A literal string may contain spaces; the mask blanked it, so measure on the text.
|
|
164
|
+
end += rest ? rest[0].length : 0;
|
|
165
|
+
}
|
|
166
|
+
return `${c.slice(0, m.index)} ${c.slice(end)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewrite the DEFAULT clause of a column's constraint tail in place.
|
|
171
|
+
* @param {string} c
|
|
172
|
+
* @param {string} type mapped Postgres type
|
|
173
|
+
* @param {ConvertContext} ctx
|
|
174
|
+
* @param {string[]} notes
|
|
175
|
+
*/
|
|
176
|
+
function rewriteDefault(c, type, ctx, notes) {
|
|
177
|
+
const mask = codeMask(c);
|
|
178
|
+
const m = /(?<!\bby)\sdefault\s+/i.exec(mask);
|
|
179
|
+
if (!m) return c;
|
|
180
|
+
const start = m.index + m[0].length;
|
|
181
|
+
let end;
|
|
182
|
+
if (mask[start] === '(') end = matchParen(mask, start) + 1;
|
|
183
|
+
else {
|
|
184
|
+
const rest = /^('[^']*(?:''[^']*)*'|[^\s]+)/.exec(c.slice(start));
|
|
185
|
+
end = start + (rest ? rest[0].length : 0);
|
|
186
|
+
}
|
|
187
|
+
let expr = c.slice(start, end).trim();
|
|
188
|
+
const bare = /^\((.*)\)$/s.test(expr) ? expr.slice(1, -1).trim() : expr;
|
|
189
|
+
let out = bare;
|
|
190
|
+
const lower = bare.toLowerCase().replace(/\s+/g, '');
|
|
191
|
+
if (type === 'boolean' && /^[01]$/.test(bare)) out = bare === '1' ? 'true' : 'false';
|
|
192
|
+
else if (lower === 'current_timestamp' || /^datetime\('now'/.test(lower)) out = 'now()';
|
|
193
|
+
else if (/^strftime\('%y-%m-%dt%h:%m:%[fs]z?','now'\)$/.test(lower)) out = 'now()';
|
|
194
|
+
else out = rewriteFunctions(bare);
|
|
195
|
+
if (/gen_random_bytes/.test(out)) ctx.usesPgcrypto = true;
|
|
196
|
+
// to_char(...) as a default on a text column is fine; on timestamptz it is not.
|
|
197
|
+
if (type === 'timestamptz' && /^to_char\(/.test(out)) out = 'now()';
|
|
198
|
+
if (/^[A-Za-z_]/.test(out) && !/^(true|false|null|now\(\)|current_date|current_time|current_timestamp)$/i.test(out) && !/^\(/.test(out)) {
|
|
199
|
+
out = `(${out})`;
|
|
200
|
+
}
|
|
201
|
+
if (out !== bare) notes.push(`default ${expr} -> ${out}`);
|
|
202
|
+
return `${c.slice(0, start)}${out}${c.slice(end)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Is the DEFAULT a "current time" expression (used to promote text columns)? */
|
|
206
|
+
function isNowDefault(constraints) {
|
|
207
|
+
const lower = constraints.toLowerCase().replace(/\s+/g, '');
|
|
208
|
+
return /default\(?(current_timestamp|datetime\('now'|strftime\('%y-%m-%dt%h:%m:%[fs]z?','now'\))/.test(lower);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Convert a table-level constraint.
|
|
213
|
+
* @param {string} def
|
|
214
|
+
* @param {string[]} notes
|
|
215
|
+
*/
|
|
216
|
+
function convertTableConstraint(def, notes) {
|
|
217
|
+
let c = def;
|
|
218
|
+
c = replaceCode(c, /\sautoincrement\b/gi, '');
|
|
219
|
+
c = replaceCode(c, /\s+collate\s+(nocase|binary|rtrim)\b/gi, (_m, name) => {
|
|
220
|
+
notes.push(`dropped COLLATE ${name.toUpperCase()} in a table constraint`);
|
|
221
|
+
return '';
|
|
222
|
+
});
|
|
223
|
+
c = replaceCode(c, /\son\s+conflict\s+(rollback|abort|fail|ignore|replace)\b/gi, (m) => {
|
|
224
|
+
notes.push(`dropped conflict clause "${m.trim()}"`);
|
|
225
|
+
return '';
|
|
226
|
+
});
|
|
227
|
+
c = replaceCode(c, /\b(asc|desc)\b(?=\s*[,)])/gi, '');
|
|
228
|
+
return c.replace(/\s+/g, ' ').trim();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Convert one CREATE TABLE statement.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} sql
|
|
235
|
+
* @param {ConvertContext} ctx
|
|
236
|
+
* @param {ConvertOptions} opts
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function convertCreateTable(sql, ctx, opts) {
|
|
240
|
+
const mask = codeMask(sql);
|
|
241
|
+
const head = /^\s*create\s+(temp(?:orary)?\s+)?table\s+(if\s+not\s+exists\s+)?/i.exec(mask);
|
|
242
|
+
if (!head) return sql;
|
|
243
|
+
let i = head[0].length;
|
|
244
|
+
// Table name.
|
|
245
|
+
let j = i;
|
|
246
|
+
while (j < mask.length) {
|
|
247
|
+
const c = mask[j];
|
|
248
|
+
if (c === '"' || c === '`' || c === '[') {
|
|
249
|
+
const end = mask.indexOf(c === '[' ? ']' : c, j + 1);
|
|
250
|
+
j = end === -1 ? mask.length : end + 1;
|
|
251
|
+
} else if (/[A-Za-z0-9_.$]/.test(c)) j++;
|
|
252
|
+
else break;
|
|
253
|
+
}
|
|
254
|
+
const rawName = sql.slice(i, j);
|
|
255
|
+
const name = rawName.split('.').map(unquote).join('.');
|
|
256
|
+
const nameSql = rawName
|
|
257
|
+
.split('.')
|
|
258
|
+
.map((p) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(p) ? p.toLowerCase() === p ? p : quoteIdent(p) : quoteIdent(unquote(p))))
|
|
259
|
+
.join('.');
|
|
260
|
+
const open = mask.indexOf('(', j);
|
|
261
|
+
if (open === -1 || /^\s*as\b/i.test(mask.slice(j))) {
|
|
262
|
+
// CREATE TABLE ... AS SELECT: pass the select through the DML rewriter.
|
|
263
|
+
return rewriteFunctions(sql);
|
|
264
|
+
}
|
|
265
|
+
const close = matchParen(mask, open);
|
|
266
|
+
if (close === -1) return sql;
|
|
267
|
+
const body = sql.slice(open + 1, close);
|
|
268
|
+
const tail = sql.slice(close + 1);
|
|
269
|
+
const defs = splitTopLevel(body);
|
|
270
|
+
const notes = [];
|
|
271
|
+
const columns = new Map();
|
|
272
|
+
const out = [];
|
|
273
|
+
const parsed = [];
|
|
274
|
+
for (const def of defs) {
|
|
275
|
+
if (CONSTRAINT_START.test(codeMask(def).trim())) {
|
|
276
|
+
parsed.push({ constraint: def });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const col = parseColumnDef(def);
|
|
280
|
+
let type = mapType(col.declared, opts);
|
|
281
|
+
if (type === 'text' && opts.promoteTextTimestamps !== false && isNowDefault(col.constraints)) {
|
|
282
|
+
type = 'timestamptz';
|
|
283
|
+
notes.push(`${col.name}: text column with a current-time default became timestamptz`);
|
|
284
|
+
}
|
|
285
|
+
if (col.declared && mapType(col.declared, opts) === 'text' && !/CHAR|CLOB|TEXT|STRING|JSON/i.test(col.declared)) {
|
|
286
|
+
notes.push(`${col.name}: unknown type "${col.declared}" mapped to text`);
|
|
287
|
+
}
|
|
288
|
+
parsed.push({ column: col, type });
|
|
289
|
+
}
|
|
290
|
+
// A single-column table-level PRIMARY KEY on an integer column is SQLite's
|
|
291
|
+
// rowid alias too, so it becomes an identity column.
|
|
292
|
+
const tablePk = parsed.find((p) => p.constraint && /^\s*(constraint\s+\S+\s+)?primary\s+key\s*\(/i.test(codeMask(p.constraint)));
|
|
293
|
+
let identityFromTablePk = null;
|
|
294
|
+
if (tablePk) {
|
|
295
|
+
const m = /\(([^)]*)\)/.exec(tablePk.constraint);
|
|
296
|
+
const cols = m ? splitTopLevel(m[1]).map((c) => unquote(c.replace(/\s+(asc|desc|autoincrement)\b/gi, ''))) : [];
|
|
297
|
+
if (cols.length === 1) {
|
|
298
|
+
const target = parsed.find((p) => p.column && p.column.name === cols[0]);
|
|
299
|
+
if (target && target.type === 'bigint') identityFromTablePk = target;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const p of parsed) {
|
|
303
|
+
if (p.constraint) {
|
|
304
|
+
if (p === tablePk && identityFromTablePk) continue;
|
|
305
|
+
out.push(convertTableConstraint(p.constraint, notes));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const col = p.column;
|
|
309
|
+
let constraints = col.constraints;
|
|
310
|
+
if (p === identityFromTablePk) constraints = `${constraints} PRIMARY KEY`;
|
|
311
|
+
const conv = convertColumnConstraints(constraints, { type: p.type, ctx, opts });
|
|
312
|
+
for (const n of conv.notes) notes.push(`${col.name}: ${n}`);
|
|
313
|
+
columns.set(col.name, p.type);
|
|
314
|
+
const ident = /^[a-z_][a-z0-9_]*$/.test(col.name) && !RESERVED.has(col.name) ? col.name : quoteIdent(col.name);
|
|
315
|
+
out.push(`${ident} ${p.type}${conv.constraints ? ` ${conv.constraints}` : ''}`.trim());
|
|
316
|
+
}
|
|
317
|
+
ctx.tables.set(name, { name, columns });
|
|
318
|
+
let tailOut = replaceCode(tail, /\s*(without\s+rowid|strict)\b/gi, (m) => {
|
|
319
|
+
notes.push(`dropped ${m.trim().toUpperCase()}`);
|
|
320
|
+
return '';
|
|
321
|
+
});
|
|
322
|
+
tailOut = tailOut.replace(/;\s*$/, '').trim();
|
|
323
|
+
const temp = head[1] ? 'temporary ' : '';
|
|
324
|
+
const ifNot = head[2] ? 'if not exists ' : '';
|
|
325
|
+
const lines = [`create ${temp}table ${ifNot}${nameSql} (`, out.map((d) => ` ${d}`).join(',\n'), `)${tailOut ? ` ${tailOut}` : ''};`];
|
|
326
|
+
const comments = notes.map((n) => `-- ${n}`);
|
|
327
|
+
return [...comments, ...lines].join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Column names that need quoting in Postgres but were fine in SQLite. */
|
|
331
|
+
const RESERVED = new Set([
|
|
332
|
+
'user', 'order', 'group', 'limit', 'offset', 'from', 'to', 'select', 'where', 'table', 'column', 'default',
|
|
333
|
+
'check', 'references', 'primary', 'foreign', 'key', 'index', 'constraint', 'end', 'case', 'when', 'then', 'else',
|
|
334
|
+
'and', 'or', 'not', 'in', 'is', 'null', 'true', 'false', 'all', 'any', 'some', 'as', 'on', 'using', 'join',
|
|
335
|
+
'left', 'right', 'full', 'inner', 'outer', 'cross', 'natural', 'union', 'except', 'intersect', 'having',
|
|
336
|
+
'distinct', 'into', 'values', 'returning', 'with', 'only', 'for', 'do', 'grant', 'session_user', 'current_user',
|
|
337
|
+
'current_date', 'current_time', 'current_timestamp', 'localtime', 'localtimestamp', 'desc', 'asc', 'both',
|
|
338
|
+
'leading', 'trailing', 'window', 'over', 'partition', 'fetch', 'lateral', 'cast', 'collate', 'array', 'analyse',
|
|
339
|
+
'analyze', 'authorization', 'binary', 'concurrently', 'create', 'current_catalog', 'current_role',
|
|
340
|
+
'current_schema', 'deferrable', 'else', 'except', 'freeze', 'ilike', 'initially', 'isnull', 'like', 'notnull',
|
|
341
|
+
'placing', 'similar', 'symmetric', 'tablesample', 'unique', 'variadic', 'verbose',
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* CREATE INDEX: keep it; `col COLLATE NOCASE` becomes `lower(col)`.
|
|
346
|
+
* @param {string} sql
|
|
347
|
+
*/
|
|
348
|
+
export function convertCreateIndex(sql) {
|
|
349
|
+
let out = sql.replace(/;\s*$/, '');
|
|
350
|
+
out = replaceCode(out, /("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+collate\s+nocase\b/gi, (_m, col) => `lower(${col})`);
|
|
351
|
+
out = replaceCode(out, /\s+collate\s+(binary|rtrim)\b/gi, '');
|
|
352
|
+
out = replaceCode(out, /`([^`]*)`/g, (_m, n) => quoteIdent(n));
|
|
353
|
+
return `${rewriteFunctions(out)};`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* CREATE VIRTUAL TABLE ... USING fts5(...): a tsvector column plus a GIN
|
|
358
|
+
* index on the content table when it can be found, else a TODO.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} sql
|
|
361
|
+
* @param {ConvertContext} ctx
|
|
362
|
+
* @param {ConvertOptions} opts
|
|
363
|
+
*/
|
|
364
|
+
export function convertVirtualTable(sql, ctx, opts) {
|
|
365
|
+
const mask = codeMask(sql);
|
|
366
|
+
const m = /^\s*create\s+virtual\s+table\s+(if\s+not\s+exists\s+)?(\S+)\s+using\s+([A-Za-z0-9_]+)\s*\(/i.exec(mask);
|
|
367
|
+
const commented = sql
|
|
368
|
+
.trim()
|
|
369
|
+
.split('\n')
|
|
370
|
+
.map((l) => `-- ${l}`)
|
|
371
|
+
.join('\n');
|
|
372
|
+
if (!m) return `-- TODO: virtual table not understood\n${commented}`;
|
|
373
|
+
const name = unquote(sql.slice(m.index + m[0].indexOf(m[2]), m.index + m[0].indexOf(m[2]) + m[2].length));
|
|
374
|
+
const module = m[3].toLowerCase();
|
|
375
|
+
if (module !== 'fts5' && module !== 'fts4' && module !== 'fts3') {
|
|
376
|
+
return `-- TODO: virtual table "${name}" uses ${module}, which has no Postgres counterpart\n${commented}`;
|
|
377
|
+
}
|
|
378
|
+
const open = m[0].length - 1;
|
|
379
|
+
const close = matchParen(mask, open);
|
|
380
|
+
const args = splitTopLevel(sql.slice(open + 1, close));
|
|
381
|
+
const options = new Map();
|
|
382
|
+
const ftsColumns = [];
|
|
383
|
+
for (const a of args) {
|
|
384
|
+
const kv = /^([A-Za-z_]+)\s*=\s*(.+)$/s.exec(a.trim());
|
|
385
|
+
if (kv) {
|
|
386
|
+
options.set(kv[1].toLowerCase(), kv[2].trim().replace(/^['"]|['"]$/g, ''));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (/\bunindexed\b/i.test(a)) continue;
|
|
390
|
+
ftsColumns.push(unquote(a.trim().split(/\s+/)[0]));
|
|
391
|
+
}
|
|
392
|
+
let contentTable = options.get('content') || null;
|
|
393
|
+
if (contentTable === '') contentTable = null;
|
|
394
|
+
if (!contentTable) {
|
|
395
|
+
const guesses = [name.replace(/_?fts\d?$/i, ''), name.replace(/^fts\d?_/i, ''), name.replace(/_(search|index|idx)$/i, '')];
|
|
396
|
+
contentTable = guesses.find((g) => g && g !== name && ctx.tables.has(g)) ?? null;
|
|
397
|
+
}
|
|
398
|
+
const search = opts.searchColumn ?? 'search';
|
|
399
|
+
const config = opts.textSearchConfig ?? 'english';
|
|
400
|
+
const header = `-- FTS5 table "${name}" (${ftsColumns.join(', ')}) has no Postgres counterpart; it is replaced below.\n${commented}`;
|
|
401
|
+
if (!contentTable) {
|
|
402
|
+
return `${header}\n-- TODO: could not find the content table for "${name}". Add a tsvector column to it:\n` +
|
|
403
|
+
`-- alter table <content_table> add column ${search} tsvector generated always as (to_tsvector('${config}', coalesce(${ftsColumns.map((c) => `${c}, ''`).join(") || ' ' || coalesce(")}))) stored;\n` +
|
|
404
|
+
`-- create index on <content_table> using gin (${search});`;
|
|
405
|
+
}
|
|
406
|
+
const info = ctx.tables.get(contentTable);
|
|
407
|
+
const cols = info ? ftsColumns.filter((c) => info.columns.has(c)) : ftsColumns;
|
|
408
|
+
const missing = info ? ftsColumns.filter((c) => !info.columns.has(c)) : [];
|
|
409
|
+
if (!cols.length) {
|
|
410
|
+
return `${header}\n-- TODO: none of the FTS5 columns exist on "${contentTable}"; add a tsvector column by hand.`;
|
|
411
|
+
}
|
|
412
|
+
const expr = cols
|
|
413
|
+
.map((c) => {
|
|
414
|
+
const type = info?.columns.get(c) ?? 'text';
|
|
415
|
+
const ref = /^[a-z_][a-z0-9_]*$/.test(c) && !RESERVED.has(c) ? c : quoteIdent(c);
|
|
416
|
+
return `coalesce(${type === 'text' ? ref : `${ref}::text`}, '')`;
|
|
417
|
+
})
|
|
418
|
+
.join(" || ' ' || ");
|
|
419
|
+
const tableSql = /^[a-z_][a-z0-9_]*$/.test(contentTable) ? contentTable : quoteIdent(contentTable);
|
|
420
|
+
const lines = [
|
|
421
|
+
header,
|
|
422
|
+
...(missing.length ? [`-- (columns not on ${contentTable}, skipped: ${missing.join(', ')})`] : []),
|
|
423
|
+
`alter table ${tableSql} add column if not exists ${search} tsvector`,
|
|
424
|
+
` generated always as (to_tsvector('${config}', ${expr})) stored;`,
|
|
425
|
+
`create index if not exists ${contentTable.replace(/[^A-Za-z0-9_]/g, '_')}_${search}_idx on ${tableSql} using gin (${search});`,
|
|
426
|
+
`-- query: where ${search} @@ websearch_to_tsquery('${config}', ?) order by ts_rank_cd(${search}, websearch_to_tsquery('${config}', ?)) desc`,
|
|
427
|
+
];
|
|
428
|
+
return lines.join('\n');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Triggers are emitted commented out with a TODO; a Postgres trigger needs a
|
|
433
|
+
* function and different NEW/OLD spelling, and most SQLite triggers only
|
|
434
|
+
* maintained an FTS5 shadow table, which the tsvector column makes redundant.
|
|
435
|
+
* @param {string} sql
|
|
436
|
+
*/
|
|
437
|
+
export function convertTrigger(sql) {
|
|
438
|
+
const mask = codeMask(sql);
|
|
439
|
+
const m = /create\s+(temp(?:orary)?\s+)?trigger\s+(if\s+not\s+exists\s+)?(\S+)/i.exec(mask);
|
|
440
|
+
const name = m ? unquote(sql.slice(m.index + m[0].length - m[3].length, m.index + m[0].length)) : 'unknown';
|
|
441
|
+
const fts = /_fts\b|fts5|fts4/i.test(mask);
|
|
442
|
+
const why = fts
|
|
443
|
+
? 'it maintained an FTS5 table, which the generated tsvector column replaces; probably drop it'
|
|
444
|
+
: 'rewrite as a Postgres trigger function (create function ... returns trigger, then create trigger ... execute function)';
|
|
445
|
+
const body = sql
|
|
446
|
+
.trim()
|
|
447
|
+
.split('\n')
|
|
448
|
+
.map((l) => `-- ${l}`)
|
|
449
|
+
.join('\n');
|
|
450
|
+
return `-- TODO: trigger "${name}": ${why}\n${body}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Convert one DDL/DML statement from a SQLite schema file.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} stmt
|
|
457
|
+
* @param {ConvertContext} ctx
|
|
458
|
+
* @param {ConvertOptions} [opts]
|
|
459
|
+
* @returns {string | null} Postgres SQL, or null to drop the statement
|
|
460
|
+
*/
|
|
461
|
+
export function convertStatement(stmt, ctx, opts = {}) {
|
|
462
|
+
const mask = codeMask(stmt);
|
|
463
|
+
const text = stmt.trim();
|
|
464
|
+
if (!/\S/.test(mask)) return text || null; // comment-only
|
|
465
|
+
if (/^\s*(pragma|begin|commit|end|rollback|vacuum)\b/i.test(mask)) return null;
|
|
466
|
+
if (/\bsqlite_(sequence|stat\d|master)\b/i.test(mask)) return null;
|
|
467
|
+
if (/^\s*create\s+virtual\s+table\b/i.test(mask)) return convertVirtualTable(text, ctx, opts);
|
|
468
|
+
if (/^\s*create\s+(temp(orary)?\s+)?trigger\b/i.test(mask)) return convertTrigger(text);
|
|
469
|
+
if (/^\s*create\s+(temp(orary)?\s+)?table\b/i.test(mask)) return convertCreateTable(text, ctx, opts);
|
|
470
|
+
if (/^\s*create\s+(unique\s+)?index\b/i.test(mask)) return convertCreateIndex(text);
|
|
471
|
+
if (/^\s*create\s+(temp(orary)?\s+)?view\b/i.test(mask)) return `${rewriteFunctions(text.replace(/;\s*$/, ''))};`;
|
|
472
|
+
if (/^\s*alter\s+table\b/i.test(mask)) return convertAlterTable(text, ctx, opts);
|
|
473
|
+
if (/^\s*drop\s+(table|index|view)\b/i.test(mask)) return `${text.replace(/;\s*$/, '')};`;
|
|
474
|
+
if (/^\s*drop\s+trigger\b/i.test(mask)) return `-- ${text}`;
|
|
475
|
+
// Seed data and anything else: the run-time rewriter.
|
|
476
|
+
try {
|
|
477
|
+
const r = rewriteStatement(text, {
|
|
478
|
+
keys: (table) => {
|
|
479
|
+
const info = ctx.tables.get(table);
|
|
480
|
+
return info ? { pk: [], unique: [], columns: [...info.columns.keys()] } : undefined;
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
return r.noop ? (r.sql ? `-- ${r.sql}` : null) : `${r.sql};`;
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return `-- TODO: ${err.message}\n${text
|
|
486
|
+
.split('\n')
|
|
487
|
+
.map((l) => `-- ${l}`)
|
|
488
|
+
.join('\n')}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* ALTER TABLE t ADD COLUMN def / RENAME / DROP COLUMN.
|
|
494
|
+
* @param {string} sql
|
|
495
|
+
* @param {ConvertContext} ctx
|
|
496
|
+
* @param {ConvertOptions} opts
|
|
497
|
+
*/
|
|
498
|
+
function convertAlterTable(sql, ctx, opts) {
|
|
499
|
+
const mask = codeMask(sql);
|
|
500
|
+
const m = /^\s*alter\s+table\s+(if\s+exists\s+)?(\S+)\s+add\s+(column\s+)?(if\s+not\s+exists\s+)?/i.exec(mask);
|
|
501
|
+
if (!m) return `${sql.replace(/;\s*$/, '')};`;
|
|
502
|
+
const table = unquote(sql.slice(m.index + m[0].indexOf(m[2]), m.index + m[0].indexOf(m[2]) + m[2].length));
|
|
503
|
+
const def = sql.slice(m[0].length).replace(/;\s*$/, '');
|
|
504
|
+
const col = parseColumnDef(def);
|
|
505
|
+
let type = mapType(col.declared, opts);
|
|
506
|
+
if (type === 'text' && opts.promoteTextTimestamps !== false && isNowDefault(col.constraints)) type = 'timestamptz';
|
|
507
|
+
const conv = convertColumnConstraints(col.constraints, { type, ctx, opts });
|
|
508
|
+
ctx.tables.get(table)?.columns.set(col.name, type);
|
|
509
|
+
const ident = /^[a-z_][a-z0-9_]*$/.test(col.name) && !RESERVED.has(col.name) ? col.name : quoteIdent(col.name);
|
|
510
|
+
const tableSql = /^[a-z_][a-z0-9_]*$/.test(table) ? table : quoteIdent(table);
|
|
511
|
+
const notes = conv.notes.map((n) => `-- ${col.name}: ${n}`);
|
|
512
|
+
return [...notes, `alter table ${tableSql} add column if not exists ${ident} ${type}${conv.constraints ? ` ${conv.constraints}` : ''};`].join('\n');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Convert a whole SQLite schema script to a Postgres one.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} sql
|
|
519
|
+
* @param {ConvertOptions} [opts]
|
|
520
|
+
* @returns {string}
|
|
521
|
+
*/
|
|
522
|
+
export function convertSchema(sql, opts = {}) {
|
|
523
|
+
/** @type {ConvertContext} */
|
|
524
|
+
const ctx = { tables: new Map(), usesPgcrypto: false, notes: [] };
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const stmt of splitStatements(sql)) {
|
|
527
|
+
const converted = convertStatement(stmt, ctx, opts);
|
|
528
|
+
if (converted !== null) out.push(converted);
|
|
529
|
+
}
|
|
530
|
+
const header = [
|
|
531
|
+
'-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.',
|
|
532
|
+
'-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,',
|
|
533
|
+
'-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.',
|
|
534
|
+
].join('\n');
|
|
535
|
+
const parts = [header];
|
|
536
|
+
const hasPgcrypto = out.some((o) => /create\s+extension\s+if\s+not\s+exists\s+pgcrypto/i.test(o));
|
|
537
|
+
if (ctx.usesPgcrypto && !hasPgcrypto) parts.push('create extension if not exists pgcrypto;');
|
|
538
|
+
return `${[...parts, ...out].join('\n\n').replace(/\n{3,}/g, '\n\n')}\n`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Convert a single DDL statement issued at run time through the client.
|
|
543
|
+
* @param {string} sql
|
|
544
|
+
*/
|
|
545
|
+
export function convertDdl(sql) {
|
|
546
|
+
const ctx = { tables: new Map(), usesPgcrypto: false, notes: [] };
|
|
547
|
+
const r = convertStatement(sql, ctx);
|
|
548
|
+
return r ?? '';
|
|
549
|
+
}
|