@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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Small, dependency-free helpers for looking at SQL text without a parser.
3
+ *
4
+ * Everything here works on a *code mask*: a string the same length as the
5
+ * SQL in which every character inside a string literal, quoted identifier or
6
+ * comment has been replaced by a space. Regexes run on the mask and the
7
+ * indexes they return are valid in the original text, so a rewrite never
8
+ * touches the inside of a literal such as `'$.a[0]'` or `'-- not a comment'`.
9
+ */
10
+
11
+ /**
12
+ * @param {string} sql
13
+ * @returns {string} the same length as `sql`; literals, quoted identifiers
14
+ * and comments blanked to spaces, everything else preserved.
15
+ */
16
+ export function codeMask(sql) {
17
+ const out = new Array(sql.length);
18
+ const len = sql.length;
19
+ let i = 0;
20
+ const blank = (from, to) => {
21
+ for (let k = from; k < to; k++) out[k] = sql[k] === '\n' ? '\n' : ' ';
22
+ };
23
+ while (i < len) {
24
+ const c = sql[i];
25
+ if (c === "'") {
26
+ let j = i + 1;
27
+ while (j < len) {
28
+ if (sql[j] === "'") {
29
+ if (sql[j + 1] === "'") {
30
+ j += 2;
31
+ continue;
32
+ }
33
+ break;
34
+ }
35
+ j++;
36
+ }
37
+ blank(i, Math.min(j + 1, len));
38
+ i = j + 1;
39
+ } else if (c === '"' || c === '`') {
40
+ // Quoted identifier. Kept visible as the quote character itself so a
41
+ // caller can still find the identifier's extent; the inside is blanked.
42
+ const j = sql.indexOf(c, i + 1);
43
+ const end = j === -1 ? len - 1 : j;
44
+ out[i] = c;
45
+ blank(i + 1, end);
46
+ if (end < len) out[end] = c;
47
+ i = end + 1;
48
+ } else if (c === '[' && /[A-Za-z_]/.test(sql[i + 1] ?? '')) {
49
+ // `[name]` identifier (SQLite accepts the MS Access style).
50
+ const j = sql.indexOf(']', i + 1);
51
+ const end = j === -1 ? len - 1 : j;
52
+ out[i] = '[';
53
+ blank(i + 1, end);
54
+ if (end < len) out[end] = ']';
55
+ i = end + 1;
56
+ } else if (c === '-' && sql[i + 1] === '-') {
57
+ const j = sql.indexOf('\n', i);
58
+ const end = j === -1 ? len : j;
59
+ blank(i, end);
60
+ i = end;
61
+ } else if (c === '/' && sql[i + 1] === '*') {
62
+ const j = sql.indexOf('*/', i + 2);
63
+ const end = j === -1 ? len : j + 2;
64
+ blank(i, end);
65
+ i = end;
66
+ } else {
67
+ out[i] = c;
68
+ i++;
69
+ }
70
+ }
71
+ return out.join('');
72
+ }
73
+
74
+ /**
75
+ * Split a script into statements at `;` outside literals and comments.
76
+ * A `CREATE TRIGGER ... BEGIN ... END;` body, which carries its own
77
+ * semicolons, is kept as one statement.
78
+ *
79
+ * @param {string} sql
80
+ * @returns {string[]} trimmed statements, empty ones dropped
81
+ */
82
+ export function splitStatements(sql) {
83
+ const mask = codeMask(sql);
84
+ const out = [];
85
+ let start = 0;
86
+ let i = 0;
87
+ const len = sql.length;
88
+ while (i < len) {
89
+ // Skip leading whitespace so we can look at the first keywords.
90
+ while (i < len && /\s/.test(mask[i])) i++;
91
+ start = i;
92
+ const head = mask.slice(i, i + 64);
93
+ const isTrigger = /^create\s+(temp(orary)?\s+)?trigger\b/i.test(head);
94
+ let depth = 0;
95
+ let seenBegin = false;
96
+ while (i < len) {
97
+ if (isTrigger) {
98
+ const word = /^[A-Za-z_]+/.exec(mask.slice(i, i + 12));
99
+ if (word && (i === 0 || !/[A-Za-z0-9_]/.test(mask[i - 1]))) {
100
+ const w = word[0].toLowerCase();
101
+ if (w === 'begin' || w === 'case') {
102
+ depth++;
103
+ seenBegin = true;
104
+ } else if (w === 'end') depth--;
105
+ i += word[0].length;
106
+ continue;
107
+ }
108
+ }
109
+ if (mask[i] === ';' && (!isTrigger || (seenBegin && depth <= 0))) break;
110
+ i++;
111
+ }
112
+ const stmt = sql.slice(start, i).trim();
113
+ if (stmt) out.push(stmt);
114
+ i++;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Split text at top-level commas (depth 0 in parentheses, outside literals).
121
+ *
122
+ * @param {string} text
123
+ * @returns {string[]} trimmed parts
124
+ */
125
+ export function splitTopLevel(text, separator = ',') {
126
+ const mask = codeMask(text);
127
+ const parts = [];
128
+ let depth = 0;
129
+ let last = 0;
130
+ for (let i = 0; i < text.length; i++) {
131
+ const c = mask[i];
132
+ if (c === '(') depth++;
133
+ else if (c === ')') depth--;
134
+ else if (c === separator && depth === 0) {
135
+ parts.push(text.slice(last, i).trim());
136
+ last = i + 1;
137
+ }
138
+ }
139
+ parts.push(text.slice(last).trim());
140
+ return parts.filter((p) => p.length);
141
+ }
142
+
143
+ /**
144
+ * Index of the `)` matching the `(` at `open`, or -1.
145
+ *
146
+ * @param {string} mask
147
+ * @param {number} open
148
+ */
149
+ export function matchParen(mask, open) {
150
+ let depth = 0;
151
+ for (let i = open; i < mask.length; i++) {
152
+ if (mask[i] === '(') depth++;
153
+ else if (mask[i] === ')') {
154
+ depth--;
155
+ if (depth === 0) return i;
156
+ }
157
+ }
158
+ return -1;
159
+ }
160
+
161
+ /**
162
+ * Find the next call of a function by name, e.g. `datetime(` at depth
163
+ * anywhere, starting at `from`.
164
+ *
165
+ * @param {string} sql
166
+ * @param {string} mask
167
+ * @param {string} name case-insensitive function name
168
+ * @param {number} [from]
169
+ * @returns {{ start: number, open: number, close: number, args: string[], argsText: string } | null}
170
+ */
171
+ export function findCall(sql, mask, name, from = 0) {
172
+ const re = new RegExp(`(?<![A-Za-z0-9_."\`])${name}\\s*\\(`, 'ig');
173
+ re.lastIndex = from;
174
+ const m = re.exec(mask);
175
+ if (!m) return null;
176
+ const open = m.index + m[0].length - 1;
177
+ const close = matchParen(mask, open);
178
+ if (close === -1) return null;
179
+ const argsText = sql.slice(open + 1, close);
180
+ return { start: m.index, open, close, args: splitTopLevel(argsText), argsText };
181
+ }
182
+
183
+ /**
184
+ * Rewrite every call of `name`, innermost calls after outer ones have been
185
+ * handled (the text is re-scanned after each edit). `fn` receives the parsed
186
+ * call and returns replacement text, or null to leave that call alone.
187
+ *
188
+ * @param {string} sql
189
+ * @param {string} name
190
+ * @param {(call: { args: string[], argsText: string }) => string | null} fn
191
+ * @returns {string}
192
+ */
193
+ export function replaceCalls(sql, name, fn) {
194
+ let from = 0;
195
+ for (let guard = 0; guard < 10_000; guard++) {
196
+ const mask = codeMask(sql);
197
+ const call = findCall(sql, mask, name, from);
198
+ if (!call) return sql;
199
+ const out = fn(call);
200
+ if (out == null) {
201
+ from = call.open + 1;
202
+ continue;
203
+ }
204
+ sql = sql.slice(0, call.start) + out + sql.slice(call.close + 1);
205
+ from = call.start;
206
+ }
207
+ return sql;
208
+ }
209
+
210
+ /**
211
+ * Replace a regex on the code mask, keeping literals intact. The regex is
212
+ * applied to the mask; the replacement is spliced into the SQL at the same
213
+ * indexes. Groups in `replacement` (via a function) receive the *original*
214
+ * text of each match, not the masked one.
215
+ *
216
+ * @param {string} sql
217
+ * @param {RegExp} re must carry the `g` flag
218
+ * @param {string | ((match: string, ...groups: string[]) => string)} replacement
219
+ */
220
+ export function replaceCode(sql, re, replacement) {
221
+ const mask = codeMask(sql);
222
+ let out = '';
223
+ let last = 0;
224
+ re.lastIndex = 0;
225
+ for (let m = re.exec(mask); m; m = re.exec(mask)) {
226
+ const original = sql.slice(m.index, m.index + m[0].length);
227
+ out += sql.slice(last, m.index);
228
+ if (typeof replacement === 'function') {
229
+ // Re-run the regex on the original slice so groups carry real text.
230
+ const local = new RegExp(re.source, re.flags.replace('g', ''));
231
+ const lm = local.exec(original);
232
+ out += replacement(original, ...(lm ? lm.slice(1) : []));
233
+ } else {
234
+ out += original.replace(new RegExp(re.source, re.flags.replace('g', '')), replacement);
235
+ }
236
+ last = m.index + m[0].length;
237
+ if (m[0].length === 0) re.lastIndex++;
238
+ }
239
+ return out + sql.slice(last);
240
+ }
241
+
242
+ /**
243
+ * Strip the quotes off an identifier and fold an unquoted one to lower case,
244
+ * the way Postgres will.
245
+ *
246
+ * @param {string} ident
247
+ */
248
+ export function unquote(ident) {
249
+ const t = ident.trim();
250
+ if (/^".*"$/.test(t)) return t.slice(1, -1).replace(/""/g, '"');
251
+ if (/^`.*`$/.test(t) || /^\[.*\]$/.test(t)) return t.slice(1, -1);
252
+ return t.toLowerCase();
253
+ }
254
+
255
+ /**
256
+ * Quote an identifier for Postgres.
257
+ *
258
+ * @param {string} name
259
+ */
260
+ export function quoteIdent(name) {
261
+ return `"${String(name).replace(/"/g, '""')}"`;
262
+ }
263
+
264
+ /**
265
+ * Quote an identifier only when it needs it, keeping schema qualification.
266
+ * @param {string} name possibly `schema.table`, possibly already quoted
267
+ */
268
+ export function identSql(name) {
269
+ return name
270
+ .split('.')
271
+ .map((part) => (/^[a-z_][a-z0-9_]*$/.test(part) ? part : quoteIdent(part)))
272
+ .join('.');
273
+ }
274
+
275
+ /**
276
+ * Is this a plain single-quoted string literal? Returns its value or null.
277
+ * @param {string} text
278
+ */
279
+ export function literalValue(text) {
280
+ const t = text.trim();
281
+ if (!/^'.*'$/s.test(t)) return null;
282
+ return t.slice(1, -1).replace(/''/g, "'");
283
+ }