@fadhilp/stateql 0.11.1 → 0.13.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/README.md +110 -0
- package/dist/src/adapters.d.ts +2 -0
- package/dist/src/adapters.js +95 -2
- package/dist/src/sql.d.ts +6 -1
- package/dist/src/sql.js +1086 -95
- package/dist/src/sqlite-process.js +16 -0
- package/dist/src/stateql.js +40 -5
- package/package.json +2 -1
package/dist/src/sql.js
CHANGED
|
@@ -14,68 +14,27 @@ const SUPPORTED_STATEMENTS = new Set([
|
|
|
14
14
|
"truncate",
|
|
15
15
|
]);
|
|
16
16
|
export function analyzeSql(sql, driver) {
|
|
17
|
-
if (driver === "mongodb") {
|
|
18
|
-
throw new StateQLError("INVALID_SQL",
|
|
17
|
+
if (driver === "mongodb" || driver === "redis") {
|
|
18
|
+
throw new StateQLError("INVALID_SQL", `SQL is not supported for ${driver} connections.`);
|
|
19
19
|
}
|
|
20
20
|
const trimmed = sql.trim();
|
|
21
21
|
if (!trimmed)
|
|
22
22
|
throw new StateQLError("INVALID_SQL", "SQL is empty.");
|
|
23
23
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
: "Sqlite";
|
|
29
|
-
const parserSql = driver === "postgres"
|
|
30
|
-
? postgresParserSql(trimmed)
|
|
31
|
-
: trimmed;
|
|
32
|
-
const parsed = parser.astify(parserSql, { database });
|
|
33
|
-
if (Array.isArray(parsed)) {
|
|
34
|
-
if (parsed.length !== 1) {
|
|
35
|
-
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
36
|
-
}
|
|
24
|
+
if (driver === "postgres") {
|
|
25
|
+
const postgresCommand = analyzePostgresCommand(trimmed);
|
|
26
|
+
if (postgresCommand)
|
|
27
|
+
return postgresCommand;
|
|
37
28
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
|
|
48
|
-
}
|
|
49
|
-
const normalized = parserSql === trimmed
|
|
50
|
-
? parser
|
|
51
|
-
.sqlify(ast, { database })
|
|
52
|
-
.replace(/;\s*$/, "")
|
|
53
|
-
.replace(/\s+/g, " ")
|
|
54
|
-
.trim()
|
|
55
|
-
// Keep the exact ordering modifiers in cache and idempotency fingerprints.
|
|
56
|
-
// The parser copy is analysis-only; adapters execute the original SQL.
|
|
57
|
-
: trimmed.replace(/;\s*$/, "");
|
|
58
|
-
const details = ast;
|
|
59
|
-
const read = statementType === "select";
|
|
60
|
-
const mutation = statementType === "update" ||
|
|
61
|
-
statementType === "delete" ||
|
|
62
|
-
statementType === "truncate";
|
|
63
|
-
const destructive = statementType === "drop" ||
|
|
64
|
-
statementType === "alter" ||
|
|
65
|
-
statementType === "delete" ||
|
|
66
|
-
statementType === "replace" ||
|
|
67
|
-
statementType === "truncate" ||
|
|
68
|
-
(driver === "sqlite" &&
|
|
69
|
-
/^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
|
|
70
|
-
return {
|
|
71
|
-
ast,
|
|
72
|
-
normalized,
|
|
73
|
-
statementType,
|
|
74
|
-
read,
|
|
75
|
-
unboundedMutation: statementType === "truncate" || (mutation && !details.where),
|
|
76
|
-
destructive,
|
|
77
|
-
ordered: read && Boolean(details.orderby),
|
|
78
|
-
};
|
|
29
|
+
else if (driver === "sqlite") {
|
|
30
|
+
const sqliteCommand = analyzeSqliteCommand(trimmed);
|
|
31
|
+
if (sqliteCommand)
|
|
32
|
+
return sqliteCommand;
|
|
33
|
+
}
|
|
34
|
+
else if (driver === "mysql") {
|
|
35
|
+
return analyzeMySqlSql(trimmed);
|
|
36
|
+
}
|
|
37
|
+
return analyzeParsedSql(trimmed, driver);
|
|
79
38
|
}
|
|
80
39
|
catch (error) {
|
|
81
40
|
if (error instanceof StateQLError)
|
|
@@ -84,6 +43,1028 @@ export function analyzeSql(sql, driver) {
|
|
|
84
43
|
throw new StateQLError("INVALID_SQL", message);
|
|
85
44
|
}
|
|
86
45
|
}
|
|
46
|
+
function parseSqlStatement(sql, driver) {
|
|
47
|
+
const database = driver === "postgres"
|
|
48
|
+
? "Postgresql"
|
|
49
|
+
: driver === "mysql"
|
|
50
|
+
? "MySQL"
|
|
51
|
+
: "Sqlite";
|
|
52
|
+
const parserSql = driver === "postgres" ? postgresParserSql(sql) : sql;
|
|
53
|
+
const parsed = parser.astify(parserSql, { database });
|
|
54
|
+
if (Array.isArray(parsed) && parsed.length !== 1) {
|
|
55
|
+
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
56
|
+
}
|
|
57
|
+
const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
|
|
58
|
+
if (!ast)
|
|
59
|
+
throw new StateQLError("INVALID_SQL", "SQL is empty.");
|
|
60
|
+
const normalized = parserSql === sql
|
|
61
|
+
? parser
|
|
62
|
+
.sqlify(ast, { database })
|
|
63
|
+
.replace(/;\s*$/, "")
|
|
64
|
+
.replace(/\s+/g, " ")
|
|
65
|
+
.trim()
|
|
66
|
+
// Keep the exact ordering modifiers in cache and idempotency fingerprints.
|
|
67
|
+
// The parser copy is analysis-only; adapters execute the original SQL.
|
|
68
|
+
: sql.replace(/;\s*$/, "");
|
|
69
|
+
return { ast, normalized };
|
|
70
|
+
}
|
|
71
|
+
function analyzeParsedSql(sql, driver) {
|
|
72
|
+
return analyzeParsedStatement(parseSqlStatement(sql, driver), driver);
|
|
73
|
+
}
|
|
74
|
+
function analyzeParsedStatement({ ast, normalized }, driver) {
|
|
75
|
+
const rawType = String(ast.type);
|
|
76
|
+
if (!SUPPORTED_STATEMENTS.has(rawType)) {
|
|
77
|
+
throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
|
|
78
|
+
}
|
|
79
|
+
const rawStatementType = rawType;
|
|
80
|
+
const insert = rawStatementType === "insert"
|
|
81
|
+
? analyzeInsert(ast, driver)
|
|
82
|
+
: undefined;
|
|
83
|
+
const statementType = insert?.statementType ?? rawStatementType;
|
|
84
|
+
if (statementType === "select" && containsUnexpectedWrite(ast)) {
|
|
85
|
+
throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
|
|
86
|
+
}
|
|
87
|
+
const details = ast;
|
|
88
|
+
const read = statementType === "select";
|
|
89
|
+
const mutation = statementType === "update" ||
|
|
90
|
+
statementType === "delete" ||
|
|
91
|
+
statementType === "truncate";
|
|
92
|
+
const destructive = statementType === "drop" ||
|
|
93
|
+
statementType === "alter" ||
|
|
94
|
+
statementType === "delete" ||
|
|
95
|
+
statementType === "replace" ||
|
|
96
|
+
statementType === "truncate" ||
|
|
97
|
+
(driver === "sqlite" &&
|
|
98
|
+
/^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
|
|
99
|
+
return {
|
|
100
|
+
ast,
|
|
101
|
+
normalized,
|
|
102
|
+
statementType,
|
|
103
|
+
read,
|
|
104
|
+
unboundedMutation: insert?.unboundedMutation ??
|
|
105
|
+
(statementType === "truncate" || (mutation && !details.where)),
|
|
106
|
+
destructive,
|
|
107
|
+
ordered: read && Boolean(details.orderby),
|
|
108
|
+
wrapForLimit: read,
|
|
109
|
+
cacheable: true,
|
|
110
|
+
requiresAutocommit: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function analyzeInsert(ast, driver) {
|
|
114
|
+
const details = ast;
|
|
115
|
+
const conflict = record(details.conflict);
|
|
116
|
+
const duplicate = record(details.on_duplicate_update);
|
|
117
|
+
const allowedWrites = new Set([ast]);
|
|
118
|
+
let upsert = false;
|
|
119
|
+
let updatesOnConflict = false;
|
|
120
|
+
if (conflict) {
|
|
121
|
+
if (driver !== "postgres")
|
|
122
|
+
invalidUpsertSyntax(driver);
|
|
123
|
+
const action = record(conflict.action);
|
|
124
|
+
const expression = record(action?.expr);
|
|
125
|
+
if (conflict.type !== "conflict" ||
|
|
126
|
+
conflict.keyword !== "on" ||
|
|
127
|
+
action?.keyword !== "do" ||
|
|
128
|
+
!expression) {
|
|
129
|
+
invalidUpsertSyntax(driver);
|
|
130
|
+
}
|
|
131
|
+
upsert = true;
|
|
132
|
+
if (expression.type === "update") {
|
|
133
|
+
if (!Array.isArray(expression.set) || expression.set.length === 0) {
|
|
134
|
+
invalidUpsertSyntax(driver);
|
|
135
|
+
}
|
|
136
|
+
allowedWrites.add(expression);
|
|
137
|
+
updatesOnConflict = true;
|
|
138
|
+
}
|
|
139
|
+
else if (expression.type !== "origin" ||
|
|
140
|
+
String(expression.value).toLowerCase() !== "nothing") {
|
|
141
|
+
invalidUpsertSyntax(driver);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (duplicate) {
|
|
145
|
+
if (driver !== "mysql" ||
|
|
146
|
+
duplicate.keyword !== "on duplicate key update" ||
|
|
147
|
+
!Array.isArray(duplicate.set) ||
|
|
148
|
+
duplicate.set.length === 0) {
|
|
149
|
+
invalidUpsertSyntax(driver);
|
|
150
|
+
}
|
|
151
|
+
upsert = true;
|
|
152
|
+
updatesOnConflict = true;
|
|
153
|
+
}
|
|
154
|
+
if (containsUnexpectedWrite(ast, allowedWrites)) {
|
|
155
|
+
throw new StateQLError("INVALID_SQL", "INSERT statements cannot contain additional write statements or SELECT INTO.");
|
|
156
|
+
}
|
|
157
|
+
const source = record(details.values);
|
|
158
|
+
const assignmentSource = driver === "mysql" && !source &&
|
|
159
|
+
Array.isArray(details.set) && details.set.length > 0;
|
|
160
|
+
if (upsert && !assignmentSource &&
|
|
161
|
+
(!source || !["select", "values"].includes(String(source.type)))) {
|
|
162
|
+
invalidUpsertSyntax(driver);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
statementType: upsert ? "upsert" : "insert",
|
|
166
|
+
unboundedMutation: updatesOnConflict && source?.type === "select",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function invalidUpsertSyntax(driver) {
|
|
170
|
+
throw new StateQLError("INVALID_SQL", `Unsupported or invalid ${driver} upsert syntax.`);
|
|
171
|
+
}
|
|
172
|
+
function record(value) {
|
|
173
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
174
|
+
? value
|
|
175
|
+
: undefined;
|
|
176
|
+
}
|
|
177
|
+
const EXPLAIN_INNER_STATEMENTS = new Set([
|
|
178
|
+
"select",
|
|
179
|
+
"insert",
|
|
180
|
+
"update",
|
|
181
|
+
"delete",
|
|
182
|
+
"upsert",
|
|
183
|
+
]);
|
|
184
|
+
const EXPLAIN_BOOLEAN_OPTIONS = new Set([
|
|
185
|
+
"ANALYZE",
|
|
186
|
+
"VERBOSE",
|
|
187
|
+
"COSTS",
|
|
188
|
+
"SETTINGS",
|
|
189
|
+
"GENERIC_PLAN",
|
|
190
|
+
"BUFFERS",
|
|
191
|
+
"WAL",
|
|
192
|
+
"TIMING",
|
|
193
|
+
"SUMMARY",
|
|
194
|
+
"MEMORY",
|
|
195
|
+
]);
|
|
196
|
+
const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
|
|
197
|
+
const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
|
|
198
|
+
const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF", "1", "0"]);
|
|
199
|
+
const POSTGRES_SHOW_SETTINGS = new Set([
|
|
200
|
+
"default_transaction_isolation",
|
|
201
|
+
"server_version",
|
|
202
|
+
"server_version_num",
|
|
203
|
+
"transaction_isolation",
|
|
204
|
+
"transaction_read_only",
|
|
205
|
+
]);
|
|
206
|
+
function analyzePostgresCommand(sql) {
|
|
207
|
+
const scanner = new SqlPrefixScanner(sql, "postgres");
|
|
208
|
+
const command = scanner.readWord();
|
|
209
|
+
if (!command)
|
|
210
|
+
return undefined;
|
|
211
|
+
switch (command.value) {
|
|
212
|
+
case "EXPLAIN":
|
|
213
|
+
return analyzePostgresExplain(sql, scanner);
|
|
214
|
+
case "SHOW":
|
|
215
|
+
return analyzePostgresShow(sql);
|
|
216
|
+
case "VALUES":
|
|
217
|
+
return analyzePostgresValues(sql);
|
|
218
|
+
case "VACUUM":
|
|
219
|
+
case "ANALYZE":
|
|
220
|
+
case "REINDEX":
|
|
221
|
+
case "CLUSTER":
|
|
222
|
+
return analyzePostgresMaintenance(sql, command.value);
|
|
223
|
+
default:
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function analyzePostgresShow(sql) {
|
|
228
|
+
const parsed = parseSqlStatement(sql, "postgres");
|
|
229
|
+
const details = parsed.ast;
|
|
230
|
+
const variable = details.var;
|
|
231
|
+
if (details.type !== "show" ||
|
|
232
|
+
details.keyword !== "var" ||
|
|
233
|
+
variable?.type !== "var" ||
|
|
234
|
+
typeof variable.name !== "string" ||
|
|
235
|
+
(Array.isArray(variable.members) && variable.members.length > 0) ||
|
|
236
|
+
!POSTGRES_SHOW_SETTINGS.has(variable.name.toLowerCase())) {
|
|
237
|
+
invalidPostgresSyntax("SHOW");
|
|
238
|
+
}
|
|
239
|
+
return readDiagnostic(parsed, "show");
|
|
240
|
+
}
|
|
241
|
+
function analyzePostgresValues(sql) {
|
|
242
|
+
const statement = postgresStatementBody(sql);
|
|
243
|
+
const wrapped = analyzeParsedSql(`SELECT * FROM (${statement}) AS _stateql_values`, "postgres");
|
|
244
|
+
return {
|
|
245
|
+
...wrapped,
|
|
246
|
+
normalized: statement,
|
|
247
|
+
statementType: "values",
|
|
248
|
+
ordered: false,
|
|
249
|
+
cacheable: false,
|
|
250
|
+
limitSql: statement,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const MYSQL_SHOW_KEYWORDS = new Set(["columns", "index", "indexes", "tables"]);
|
|
254
|
+
function analyzeMySqlSql(sql) {
|
|
255
|
+
const scanner = new MySqlPrefixScanner(sql);
|
|
256
|
+
scanner.validateComments();
|
|
257
|
+
const command = scanner.readWord();
|
|
258
|
+
if (command?.value === "ANALYZE" ||
|
|
259
|
+
command?.value === "OPTIMIZE" ||
|
|
260
|
+
command?.value === "CHECK") {
|
|
261
|
+
return analyzeMySqlMaintenance(sql, scanner, command.value);
|
|
262
|
+
}
|
|
263
|
+
const parsed = parseSqlStatement(sql, "mysql");
|
|
264
|
+
const details = parsed.ast;
|
|
265
|
+
if (details.type === "explain") {
|
|
266
|
+
const inner = details.expr;
|
|
267
|
+
if (!inner || String(inner.type) !== "select" || containsUnexpectedWrite(inner)) {
|
|
268
|
+
throw new StateQLError("INVALID_SQL", "MySQL EXPLAIN accepts read-only SELECT statements only.");
|
|
269
|
+
}
|
|
270
|
+
return readDiagnostic(parsed, "explain");
|
|
271
|
+
}
|
|
272
|
+
if (details.type === "show") {
|
|
273
|
+
const keyword = typeof details.keyword === "string"
|
|
274
|
+
? details.keyword.toLowerCase()
|
|
275
|
+
: "";
|
|
276
|
+
const allowedKeys = keyword === "tables"
|
|
277
|
+
? new Set(["type", "keyword"])
|
|
278
|
+
: new Set(["type", "keyword", "from"]);
|
|
279
|
+
const hasUnexpectedShape = Object.entries(details).some(([key, value]) => value !== undefined && value !== null && !allowedKeys.has(key));
|
|
280
|
+
const from = details.from;
|
|
281
|
+
if (!MYSQL_SHOW_KEYWORDS.has(keyword) ||
|
|
282
|
+
hasUnexpectedShape ||
|
|
283
|
+
(keyword !== "tables" && (!Array.isArray(from) || from.length !== 1))) {
|
|
284
|
+
throw new StateQLError("INVALID_SQL", `Unsupported MySQL SHOW form "${keyword || "unknown"}".`);
|
|
285
|
+
}
|
|
286
|
+
return readDiagnostic(parsed, "show");
|
|
287
|
+
}
|
|
288
|
+
return analyzeParsedStatement(parsed, "mysql");
|
|
289
|
+
}
|
|
290
|
+
function analyzeMySqlMaintenance(sql, scanner, command) {
|
|
291
|
+
if (scanner.readWord()?.value !== "TABLE")
|
|
292
|
+
invalidMySqlSyntax(command);
|
|
293
|
+
if (!scanner.readQualifiedIdentifier())
|
|
294
|
+
invalidMySqlSyntax(command);
|
|
295
|
+
if (scanner.consume(";")) {
|
|
296
|
+
if (scanner.triviaEnd() !== sql.length)
|
|
297
|
+
invalidMySqlSyntax(command);
|
|
298
|
+
}
|
|
299
|
+
else if (scanner.triviaEnd() !== sql.length) {
|
|
300
|
+
invalidMySqlSyntax(command);
|
|
301
|
+
}
|
|
302
|
+
const read = command === "CHECK";
|
|
303
|
+
return {
|
|
304
|
+
ast: { type: command.toLowerCase() },
|
|
305
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
306
|
+
statementType: command.toLowerCase(),
|
|
307
|
+
read,
|
|
308
|
+
unboundedMutation: false,
|
|
309
|
+
destructive: !read,
|
|
310
|
+
ordered: false,
|
|
311
|
+
wrapForLimit: false,
|
|
312
|
+
cacheable: false,
|
|
313
|
+
requiresAutocommit: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function invalidMySqlSyntax(command) {
|
|
317
|
+
throw new StateQLError("INVALID_SQL", `Unsupported or invalid MySQL ${command} TABLE syntax.`);
|
|
318
|
+
}
|
|
319
|
+
function analyzeSqliteCommand(sql) {
|
|
320
|
+
const scanner = new SqlPrefixScanner(sql, "sqlite");
|
|
321
|
+
const command = scanner.readWord();
|
|
322
|
+
switch (command?.value) {
|
|
323
|
+
case "EXPLAIN":
|
|
324
|
+
return analyzeSqliteExplain(sql, scanner);
|
|
325
|
+
case "VACUUM":
|
|
326
|
+
case "ANALYZE":
|
|
327
|
+
case "REINDEX":
|
|
328
|
+
return analyzeSqliteMaintenance(sql, scanner, command.value);
|
|
329
|
+
default:
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function analyzeSqliteExplain(sql, scanner) {
|
|
334
|
+
if (scanner.readWord()?.value !== "QUERY" || scanner.readWord()?.value !== "PLAN") {
|
|
335
|
+
throw new StateQLError("INVALID_SQL", "Only SQLite EXPLAIN QUERY PLAN is supported.");
|
|
336
|
+
}
|
|
337
|
+
const innerSql = sql.slice(scanner.triviaEnd());
|
|
338
|
+
if (!innerSql) {
|
|
339
|
+
throw new StateQLError("INVALID_SQL", "Invalid SQLite EXPLAIN QUERY PLAN syntax.");
|
|
340
|
+
}
|
|
341
|
+
const inner = analyzeParsedSql(innerSql, "sqlite");
|
|
342
|
+
if (inner.statementType !== "select") {
|
|
343
|
+
throw new StateQLError("INVALID_SQL", "SQLite EXPLAIN QUERY PLAN accepts read-only SELECT statements only.");
|
|
344
|
+
}
|
|
345
|
+
return readDiagnostic({ ast: inner.ast, normalized: sql.replace(/;\s*$/, "") }, "explain");
|
|
346
|
+
}
|
|
347
|
+
function analyzeSqliteMaintenance(sql, scanner, command) {
|
|
348
|
+
if (command !== "VACUUM" && scanner.peek() !== ";" && scanner.peek() !== undefined) {
|
|
349
|
+
if (!readSqliteIdentifier(sql, scanner))
|
|
350
|
+
invalidSqliteSyntax(command);
|
|
351
|
+
}
|
|
352
|
+
if (scanner.consume(";")) {
|
|
353
|
+
if (scanner.triviaEnd() !== sql.length)
|
|
354
|
+
invalidSqliteSyntax(command);
|
|
355
|
+
}
|
|
356
|
+
else if (scanner.triviaEnd() !== sql.length) {
|
|
357
|
+
invalidSqliteSyntax(command);
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
ast: { type: command.toLowerCase() },
|
|
361
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
362
|
+
statementType: command.toLowerCase(),
|
|
363
|
+
read: false,
|
|
364
|
+
unboundedMutation: false,
|
|
365
|
+
destructive: true,
|
|
366
|
+
ordered: false,
|
|
367
|
+
wrapForLimit: false,
|
|
368
|
+
cacheable: false,
|
|
369
|
+
requiresAutocommit: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function readSqliteIdentifier(sql, scanner) {
|
|
373
|
+
scanner.triviaEnd();
|
|
374
|
+
if (sql[scanner.position] === '"') {
|
|
375
|
+
const end = postgresQuotedIdentifierEnd(sql, scanner.position);
|
|
376
|
+
if (end === undefined)
|
|
377
|
+
return false;
|
|
378
|
+
scanner.position = end;
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return Boolean(scanner.readWord(false));
|
|
382
|
+
}
|
|
383
|
+
function invalidSqliteSyntax(command) {
|
|
384
|
+
throw new StateQLError("INVALID_SQL", `Unsupported or invalid SQLite ${command} syntax.`);
|
|
385
|
+
}
|
|
386
|
+
function readDiagnostic({ ast, normalized }, statementType) {
|
|
387
|
+
return {
|
|
388
|
+
ast,
|
|
389
|
+
normalized,
|
|
390
|
+
statementType,
|
|
391
|
+
read: true,
|
|
392
|
+
unboundedMutation: false,
|
|
393
|
+
destructive: false,
|
|
394
|
+
ordered: false,
|
|
395
|
+
wrapForLimit: false,
|
|
396
|
+
cacheable: false,
|
|
397
|
+
requiresAutocommit: false,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function analyzePostgresExplain(sql, scanner) {
|
|
401
|
+
let analyze = false;
|
|
402
|
+
const seen = new Set();
|
|
403
|
+
if (scanner.consume("(")) {
|
|
404
|
+
while (true) {
|
|
405
|
+
const option = scanner.readWord();
|
|
406
|
+
if (!option || seen.has(option.value))
|
|
407
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
408
|
+
seen.add(option.value);
|
|
409
|
+
const next = scanner.peek();
|
|
410
|
+
let value;
|
|
411
|
+
if (next !== "," && next !== ")") {
|
|
412
|
+
value = scanner.readWord()?.value;
|
|
413
|
+
if (!value)
|
|
414
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
415
|
+
}
|
|
416
|
+
validateExplainOption(option.value, value);
|
|
417
|
+
if (option.value === "ANALYZE") {
|
|
418
|
+
analyze = value === undefined || value === "TRUE" || value === "ON";
|
|
419
|
+
}
|
|
420
|
+
if (scanner.consume(")"))
|
|
421
|
+
break;
|
|
422
|
+
if (!scanner.consume(","))
|
|
423
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
while (true) {
|
|
428
|
+
const option = scanner.peekWord();
|
|
429
|
+
if (option !== "ANALYZE" && option !== "VERBOSE")
|
|
430
|
+
break;
|
|
431
|
+
scanner.readWord();
|
|
432
|
+
if (seen.has(option))
|
|
433
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
434
|
+
seen.add(option);
|
|
435
|
+
if (option === "ANALYZE")
|
|
436
|
+
analyze = true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const innerSql = sql.slice(scanner.triviaEnd());
|
|
440
|
+
if (!innerSql)
|
|
441
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
442
|
+
const inner = analyzeParsedSql(innerSql, "postgres");
|
|
443
|
+
if (!EXPLAIN_INNER_STATEMENTS.has(inner.statementType)) {
|
|
444
|
+
throw new StateQLError("INVALID_SQL", `EXPLAIN does not support ${inner.statementType.toUpperCase()} statements.`);
|
|
445
|
+
}
|
|
446
|
+
if (analyze && inner.statementType !== "select") {
|
|
447
|
+
throw new StateQLError("INVALID_SQL", "EXPLAIN ANALYZE accepts read-only SELECT statements only.");
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
ast: inner.ast,
|
|
451
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
452
|
+
statementType: "explain",
|
|
453
|
+
read: true,
|
|
454
|
+
unboundedMutation: false,
|
|
455
|
+
destructive: false,
|
|
456
|
+
ordered: false,
|
|
457
|
+
wrapForLimit: false,
|
|
458
|
+
cacheable: false,
|
|
459
|
+
requiresAutocommit: false,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function validateExplainOption(option, value) {
|
|
463
|
+
if (EXPLAIN_BOOLEAN_OPTIONS.has(option)) {
|
|
464
|
+
if (value !== undefined && !BOOLEAN_VALUES.has(value))
|
|
465
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (option === "FORMAT" && value && EXPLAIN_FORMATS.has(value))
|
|
469
|
+
return;
|
|
470
|
+
if (option === "SERIALIZE" && value && EXPLAIN_SERIALIZE.has(value))
|
|
471
|
+
return;
|
|
472
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
473
|
+
}
|
|
474
|
+
const VACUUM_OPTIONS = new Map([
|
|
475
|
+
["FULL", "boolean"],
|
|
476
|
+
["FREEZE", "boolean"],
|
|
477
|
+
["VERBOSE", "boolean"],
|
|
478
|
+
["ANALYZE", "boolean"],
|
|
479
|
+
["DISABLE_PAGE_SKIPPING", "boolean"],
|
|
480
|
+
["SKIP_LOCKED", "boolean"],
|
|
481
|
+
["INDEX_CLEANUP", new Set(["AUTO", "ON", "OFF"])],
|
|
482
|
+
["PROCESS_MAIN", "boolean"],
|
|
483
|
+
["PROCESS_TOAST", "boolean"],
|
|
484
|
+
["TRUNCATE", "boolean"],
|
|
485
|
+
["PARALLEL", "number"],
|
|
486
|
+
["SKIP_DATABASE_STATS", "boolean"],
|
|
487
|
+
["ONLY_DATABASE_STATS", "boolean"],
|
|
488
|
+
["BUFFER_USAGE_LIMIT", "size"],
|
|
489
|
+
]);
|
|
490
|
+
const ANALYZE_OPTIONS = new Map([
|
|
491
|
+
["VERBOSE", "boolean"],
|
|
492
|
+
["SKIP_LOCKED", "boolean"],
|
|
493
|
+
["BUFFER_USAGE_LIMIT", "size"],
|
|
494
|
+
]);
|
|
495
|
+
const REINDEX_OPTIONS = new Map([
|
|
496
|
+
["CONCURRENTLY", "boolean"],
|
|
497
|
+
["VERBOSE", "boolean"],
|
|
498
|
+
["TABLESPACE", "identifier"],
|
|
499
|
+
]);
|
|
500
|
+
const CLUSTER_OPTIONS = new Map([
|
|
501
|
+
["VERBOSE", "boolean"],
|
|
502
|
+
]);
|
|
503
|
+
function analyzePostgresMaintenance(sql, command) {
|
|
504
|
+
const parser = new UtilityParser(tokenizePostgresMaintenance(sql), command);
|
|
505
|
+
parser.expectWord(command);
|
|
506
|
+
switch (command) {
|
|
507
|
+
case "VACUUM":
|
|
508
|
+
parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
|
|
509
|
+
parser.optionalTargets(true);
|
|
510
|
+
break;
|
|
511
|
+
case "ANALYZE":
|
|
512
|
+
parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
|
|
513
|
+
parser.optionalTargets(true);
|
|
514
|
+
break;
|
|
515
|
+
case "REINDEX": {
|
|
516
|
+
const options = parser.options(REINDEX_OPTIONS);
|
|
517
|
+
const target = parser.expectOneOf([
|
|
518
|
+
"INDEX",
|
|
519
|
+
"TABLE",
|
|
520
|
+
"SCHEMA",
|
|
521
|
+
"DATABASE",
|
|
522
|
+
"SYSTEM",
|
|
523
|
+
]);
|
|
524
|
+
const postTargetConcurrent = parser.consumeWord("CONCURRENTLY");
|
|
525
|
+
if (postTargetConcurrent && options.has("CONCURRENTLY")) {
|
|
526
|
+
invalidPostgresSyntax(command);
|
|
527
|
+
}
|
|
528
|
+
if (target === "INDEX" || target === "TABLE") {
|
|
529
|
+
parser.qualifiedIdentifier();
|
|
530
|
+
}
|
|
531
|
+
else if (target === "SCHEMA") {
|
|
532
|
+
parser.identifier();
|
|
533
|
+
}
|
|
534
|
+
else if (!parser.done()) {
|
|
535
|
+
parser.identifier();
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case "CLUSTER":
|
|
540
|
+
parser.options(CLUSTER_OPTIONS, ["VERBOSE"]);
|
|
541
|
+
if (!parser.done()) {
|
|
542
|
+
parser.qualifiedIdentifier();
|
|
543
|
+
if (parser.consumeWord("USING"))
|
|
544
|
+
parser.identifier();
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
parser.expectDone();
|
|
549
|
+
return {
|
|
550
|
+
ast: { type: command.toLowerCase() },
|
|
551
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
552
|
+
statementType: command.toLowerCase(),
|
|
553
|
+
read: false,
|
|
554
|
+
unboundedMutation: false,
|
|
555
|
+
destructive: true,
|
|
556
|
+
ordered: false,
|
|
557
|
+
wrapForLimit: false,
|
|
558
|
+
cacheable: false,
|
|
559
|
+
requiresAutocommit: true,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function tokenizePostgresMaintenance(sql) {
|
|
563
|
+
const scanner = new SqlPrefixScanner(sql, "postgres");
|
|
564
|
+
const tokens = [];
|
|
565
|
+
while (scanner.triviaEnd() < sql.length) {
|
|
566
|
+
const character = sql[scanner.position];
|
|
567
|
+
if (character === ";") {
|
|
568
|
+
scanner.position += 1;
|
|
569
|
+
if (scanner.triviaEnd() !== sql.length) {
|
|
570
|
+
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
const word = scanner.readWord(false);
|
|
575
|
+
if (word) {
|
|
576
|
+
tokens.push({ kind: "word", value: word.value });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (character === '"') {
|
|
580
|
+
const end = postgresQuotedIdentifierEnd(sql, scanner.position);
|
|
581
|
+
if (end === undefined)
|
|
582
|
+
invalidPostgresSyntax("maintenance");
|
|
583
|
+
tokens.push({ kind: "identifier", value: sql.slice(scanner.position, end) });
|
|
584
|
+
scanner.position = end;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (character === "'") {
|
|
588
|
+
const end = postgresQuotedStringScanEnd(sql, scanner.position);
|
|
589
|
+
if (end <= scanner.position + 1 || sql[end - 1] !== "'") {
|
|
590
|
+
invalidPostgresSyntax("maintenance");
|
|
591
|
+
}
|
|
592
|
+
tokens.push({ kind: "string", value: sql.slice(scanner.position, end) });
|
|
593
|
+
scanner.position = end;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (/[0-9]/u.test(character)) {
|
|
597
|
+
const start = scanner.position;
|
|
598
|
+
scanner.position += 1;
|
|
599
|
+
while (/[0-9]/u.test(sql[scanner.position] ?? ""))
|
|
600
|
+
scanner.position += 1;
|
|
601
|
+
tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (["(", ")", ",", ".", "*"].includes(character)) {
|
|
605
|
+
tokens.push({ kind: "punctuation", value: character });
|
|
606
|
+
scanner.position += 1;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
invalidPostgresSyntax("maintenance");
|
|
610
|
+
}
|
|
611
|
+
return tokens;
|
|
612
|
+
}
|
|
613
|
+
class UtilityParser {
|
|
614
|
+
tokens;
|
|
615
|
+
command;
|
|
616
|
+
index = 0;
|
|
617
|
+
constructor(tokens, command) {
|
|
618
|
+
this.tokens = tokens;
|
|
619
|
+
this.command = command;
|
|
620
|
+
}
|
|
621
|
+
done() {
|
|
622
|
+
return this.index >= this.tokens.length;
|
|
623
|
+
}
|
|
624
|
+
expectDone() {
|
|
625
|
+
if (!this.done())
|
|
626
|
+
invalidPostgresSyntax(this.command);
|
|
627
|
+
}
|
|
628
|
+
expectWord(word) {
|
|
629
|
+
if (!this.consumeWord(word))
|
|
630
|
+
invalidPostgresSyntax(this.command);
|
|
631
|
+
}
|
|
632
|
+
consumeWord(word) {
|
|
633
|
+
const token = this.tokens[this.index];
|
|
634
|
+
if (token?.kind !== "word" || token.value !== word)
|
|
635
|
+
return false;
|
|
636
|
+
this.index += 1;
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
expectOneOf(words) {
|
|
640
|
+
const token = this.tokens[this.index];
|
|
641
|
+
if (token?.kind !== "word" || !words.includes(token.value)) {
|
|
642
|
+
invalidPostgresSyntax(this.command);
|
|
643
|
+
}
|
|
644
|
+
this.index += 1;
|
|
645
|
+
return token.value;
|
|
646
|
+
}
|
|
647
|
+
options(options, legacy = []) {
|
|
648
|
+
if (this.consumePunctuation("(")) {
|
|
649
|
+
const seen = new Set();
|
|
650
|
+
while (true) {
|
|
651
|
+
const option = this.tokens[this.index];
|
|
652
|
+
if (option?.kind !== "word" || seen.has(option.value)) {
|
|
653
|
+
invalidPostgresSyntax(this.command);
|
|
654
|
+
}
|
|
655
|
+
const kind = options.get(option.value);
|
|
656
|
+
if (!kind)
|
|
657
|
+
invalidPostgresSyntax(this.command);
|
|
658
|
+
seen.add(option.value);
|
|
659
|
+
this.index += 1;
|
|
660
|
+
const next = this.tokens[this.index];
|
|
661
|
+
if (next?.value !== "," && next?.value !== ")") {
|
|
662
|
+
this.optionValue(kind);
|
|
663
|
+
}
|
|
664
|
+
else if (kind !== "boolean") {
|
|
665
|
+
invalidPostgresSyntax(this.command);
|
|
666
|
+
}
|
|
667
|
+
if (this.consumePunctuation(")"))
|
|
668
|
+
return seen;
|
|
669
|
+
if (!this.consumePunctuation(","))
|
|
670
|
+
invalidPostgresSyntax(this.command);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const seen = new Set();
|
|
674
|
+
while (true) {
|
|
675
|
+
const option = this.tokens[this.index];
|
|
676
|
+
if (option?.kind !== "word" || !legacy.includes(option.value))
|
|
677
|
+
return seen;
|
|
678
|
+
if (seen.has(option.value))
|
|
679
|
+
invalidPostgresSyntax(this.command);
|
|
680
|
+
seen.add(option.value);
|
|
681
|
+
this.index += 1;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
optionalTargets(allowOnlyAndStar = false) {
|
|
685
|
+
if (this.done())
|
|
686
|
+
return;
|
|
687
|
+
while (true) {
|
|
688
|
+
if (allowOnlyAndStar)
|
|
689
|
+
this.consumeWord("ONLY");
|
|
690
|
+
this.qualifiedIdentifier();
|
|
691
|
+
if (allowOnlyAndStar)
|
|
692
|
+
this.consumePunctuation("*");
|
|
693
|
+
if (this.consumePunctuation("(")) {
|
|
694
|
+
this.identifier();
|
|
695
|
+
while (this.consumePunctuation(","))
|
|
696
|
+
this.identifier();
|
|
697
|
+
if (!this.consumePunctuation(")"))
|
|
698
|
+
invalidPostgresSyntax(this.command);
|
|
699
|
+
}
|
|
700
|
+
if (!this.consumePunctuation(","))
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
qualifiedIdentifier() {
|
|
705
|
+
this.identifier();
|
|
706
|
+
while (this.consumePunctuation("."))
|
|
707
|
+
this.identifier();
|
|
708
|
+
}
|
|
709
|
+
identifier() {
|
|
710
|
+
const token = this.tokens[this.index];
|
|
711
|
+
if (token?.kind !== "word" && token?.kind !== "identifier") {
|
|
712
|
+
invalidPostgresSyntax(this.command);
|
|
713
|
+
}
|
|
714
|
+
this.index += 1;
|
|
715
|
+
}
|
|
716
|
+
optionValue(kind) {
|
|
717
|
+
const token = this.tokens[this.index];
|
|
718
|
+
if (!token)
|
|
719
|
+
invalidPostgresSyntax(this.command);
|
|
720
|
+
if (kind === "boolean") {
|
|
721
|
+
if ((token.kind !== "word" && token.kind !== "number") ||
|
|
722
|
+
!BOOLEAN_VALUES.has(token.value)) {
|
|
723
|
+
invalidPostgresSyntax(this.command);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else if (kind === "number") {
|
|
727
|
+
if (token.kind !== "number")
|
|
728
|
+
invalidPostgresSyntax(this.command);
|
|
729
|
+
}
|
|
730
|
+
else if (kind === "identifier") {
|
|
731
|
+
if (token.kind !== "word" && token.kind !== "identifier") {
|
|
732
|
+
invalidPostgresSyntax(this.command);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else if (kind === "size") {
|
|
736
|
+
if (!validMaintenanceSize(token))
|
|
737
|
+
invalidPostgresSyntax(this.command);
|
|
738
|
+
}
|
|
739
|
+
else if (token.kind !== "word" || !kind.has(token.value)) {
|
|
740
|
+
invalidPostgresSyntax(this.command);
|
|
741
|
+
}
|
|
742
|
+
this.index += 1;
|
|
743
|
+
}
|
|
744
|
+
consumePunctuation(value) {
|
|
745
|
+
const token = this.tokens[this.index];
|
|
746
|
+
if (token?.kind !== "punctuation" || token.value !== value)
|
|
747
|
+
return false;
|
|
748
|
+
this.index += 1;
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function validMaintenanceSize(token) {
|
|
753
|
+
let amountText;
|
|
754
|
+
let unit;
|
|
755
|
+
if (token.kind === "number") {
|
|
756
|
+
amountText = token.value;
|
|
757
|
+
unit = "KB";
|
|
758
|
+
}
|
|
759
|
+
else if (token.kind === "string") {
|
|
760
|
+
const match = token.value.match(/^'([0-9]+)(?:\s*(B|KB|MB|GB|TB))?'$/iu);
|
|
761
|
+
if (!match)
|
|
762
|
+
return false;
|
|
763
|
+
amountText = match[1];
|
|
764
|
+
unit = (match[2]?.toUpperCase() ?? "KB");
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
const factors = {
|
|
770
|
+
B: 1n,
|
|
771
|
+
KB: 1024n,
|
|
772
|
+
MB: 1024n ** 2n,
|
|
773
|
+
GB: 1024n ** 3n,
|
|
774
|
+
TB: 1024n ** 4n,
|
|
775
|
+
};
|
|
776
|
+
const bytes = BigInt(amountText) * factors[unit];
|
|
777
|
+
return bytes === 0n ||
|
|
778
|
+
(bytes >= 128n * factors.KB && bytes <= 16n * factors.GB);
|
|
779
|
+
}
|
|
780
|
+
class MySqlPrefixScanner {
|
|
781
|
+
sql;
|
|
782
|
+
position = 0;
|
|
783
|
+
constructor(sql) {
|
|
784
|
+
this.sql = sql;
|
|
785
|
+
}
|
|
786
|
+
validateComments() {
|
|
787
|
+
if (!this.sql.includes("/*!"))
|
|
788
|
+
return;
|
|
789
|
+
// sql_mode is unknown: cover string escapes, ANSI_QUOTES, and
|
|
790
|
+
// NO_BACKSLASH_ESCAPES. Reject mode-dependent executable comments.
|
|
791
|
+
for (const [singleEscapes, doubleEscapes] of [
|
|
792
|
+
[true, true], [true, false], [false, false],
|
|
793
|
+
]) {
|
|
794
|
+
this.position = 0;
|
|
795
|
+
while (this.triviaEnd() < this.sql.length) {
|
|
796
|
+
const quote = this.sql[this.position];
|
|
797
|
+
if (quote === "'" || quote === '"' || quote === "`") {
|
|
798
|
+
const escaped = quote === "'" ? singleEscapes : quote === '"' && doubleEscapes;
|
|
799
|
+
if (!this.readQuoted(quote, escaped)) {
|
|
800
|
+
throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL value.");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
this.position += 1;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
this.position = 0;
|
|
809
|
+
}
|
|
810
|
+
triviaEnd() {
|
|
811
|
+
while (this.position < this.sql.length) {
|
|
812
|
+
if (/\s/u.test(this.sql[this.position])) {
|
|
813
|
+
this.position += 1;
|
|
814
|
+
}
|
|
815
|
+
else if (this.sql[this.position] === "#") {
|
|
816
|
+
this.position = lineCommentEnd(this.sql, this.position + 1);
|
|
817
|
+
}
|
|
818
|
+
else if (this.sql.startsWith("--", this.position) &&
|
|
819
|
+
(this.sql[this.position + 2] === undefined || /[\x00-\x20]/u.test(this.sql[this.position + 2]))) {
|
|
820
|
+
this.position = lineCommentEnd(this.sql, this.position + 2);
|
|
821
|
+
}
|
|
822
|
+
else if (this.sql.startsWith("/*", this.position)) {
|
|
823
|
+
if (this.sql[this.position + 2] === "!") {
|
|
824
|
+
throw new StateQLError("INVALID_SQL", "MySQL executable comments are not supported.");
|
|
825
|
+
}
|
|
826
|
+
const end = nonNestedBlockCommentEnd(this.sql, this.position + 2);
|
|
827
|
+
if (end === undefined) {
|
|
828
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
829
|
+
}
|
|
830
|
+
this.position = end;
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return this.position;
|
|
837
|
+
}
|
|
838
|
+
readWord(skipTrivia = true) {
|
|
839
|
+
if (skipTrivia)
|
|
840
|
+
this.triviaEnd();
|
|
841
|
+
const start = this.position;
|
|
842
|
+
if (!identifierStart(this.sql[start]))
|
|
843
|
+
return undefined;
|
|
844
|
+
this.position += 1;
|
|
845
|
+
while (identifierPart(this.sql[this.position]))
|
|
846
|
+
this.position += 1;
|
|
847
|
+
return { value: this.sql.slice(start, this.position).toUpperCase() };
|
|
848
|
+
}
|
|
849
|
+
readQualifiedIdentifier() {
|
|
850
|
+
if (!this.readIdentifier())
|
|
851
|
+
return false;
|
|
852
|
+
if (!this.consume("."))
|
|
853
|
+
return true;
|
|
854
|
+
return this.readIdentifier();
|
|
855
|
+
}
|
|
856
|
+
consume(value) {
|
|
857
|
+
this.triviaEnd();
|
|
858
|
+
if (!this.sql.startsWith(value, this.position))
|
|
859
|
+
return false;
|
|
860
|
+
this.position += value.length;
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
readIdentifier() {
|
|
864
|
+
this.triviaEnd();
|
|
865
|
+
if (this.sql[this.position] !== "`")
|
|
866
|
+
return Boolean(this.readWord(false));
|
|
867
|
+
return this.readQuoted("`", false);
|
|
868
|
+
}
|
|
869
|
+
readQuoted(quote, backslashEscapes) {
|
|
870
|
+
let index = this.position + 1;
|
|
871
|
+
while (index < this.sql.length) {
|
|
872
|
+
if (backslashEscapes && this.sql[index] === "\\") {
|
|
873
|
+
index += 2;
|
|
874
|
+
}
|
|
875
|
+
else if (this.sql[index] !== quote) {
|
|
876
|
+
index += 1;
|
|
877
|
+
}
|
|
878
|
+
else if (this.sql[index + 1] === quote) {
|
|
879
|
+
index += 2;
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
this.position = index + 1;
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
class SqlPrefixScanner {
|
|
890
|
+
sql;
|
|
891
|
+
dialect;
|
|
892
|
+
position = 0;
|
|
893
|
+
constructor(sql, dialect) {
|
|
894
|
+
this.sql = sql;
|
|
895
|
+
this.dialect = dialect;
|
|
896
|
+
}
|
|
897
|
+
triviaEnd() {
|
|
898
|
+
while (this.position < this.sql.length) {
|
|
899
|
+
if (/\s/u.test(this.sql[this.position])) {
|
|
900
|
+
this.position += 1;
|
|
901
|
+
}
|
|
902
|
+
else if (this.sql.startsWith("--", this.position)) {
|
|
903
|
+
this.position = lineCommentEnd(this.sql, this.position + 2);
|
|
904
|
+
}
|
|
905
|
+
else if (this.sql.startsWith("/*", this.position)) {
|
|
906
|
+
const end = this.dialect === "postgres"
|
|
907
|
+
? postgresBlockCommentEnd(this.sql, this.position + 2)
|
|
908
|
+
: nonNestedBlockCommentEnd(this.sql, this.position + 2);
|
|
909
|
+
if (end === undefined) {
|
|
910
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
911
|
+
}
|
|
912
|
+
this.position = end;
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return this.position;
|
|
919
|
+
}
|
|
920
|
+
readWord(skipTrivia = true) {
|
|
921
|
+
if (skipTrivia)
|
|
922
|
+
this.triviaEnd();
|
|
923
|
+
const start = this.position;
|
|
924
|
+
if (!identifierStart(this.sql[start]))
|
|
925
|
+
return undefined;
|
|
926
|
+
this.position += 1;
|
|
927
|
+
while (identifierPart(this.sql[this.position]))
|
|
928
|
+
this.position += 1;
|
|
929
|
+
return {
|
|
930
|
+
value: this.sql.slice(start, this.position).toUpperCase(),
|
|
931
|
+
start,
|
|
932
|
+
end: this.position,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
peekWord() {
|
|
936
|
+
const position = this.position;
|
|
937
|
+
const value = this.readWord()?.value;
|
|
938
|
+
this.position = position;
|
|
939
|
+
return value;
|
|
940
|
+
}
|
|
941
|
+
peek() {
|
|
942
|
+
this.triviaEnd();
|
|
943
|
+
return this.sql[this.position];
|
|
944
|
+
}
|
|
945
|
+
consume(value) {
|
|
946
|
+
this.triviaEnd();
|
|
947
|
+
if (!this.sql.startsWith(value, this.position))
|
|
948
|
+
return false;
|
|
949
|
+
this.position += value.length;
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
function postgresStatementBody(sql) {
|
|
954
|
+
let index = 0;
|
|
955
|
+
let lastTokenStart = 0;
|
|
956
|
+
let lastTokenEnd = 0;
|
|
957
|
+
while (index < sql.length) {
|
|
958
|
+
if (/\s/u.test(sql[index])) {
|
|
959
|
+
index += 1;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (sql.startsWith("--", index)) {
|
|
963
|
+
index = lineCommentEnd(sql, index + 2);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (sql.startsWith("/*", index)) {
|
|
967
|
+
const end = postgresBlockCommentEnd(sql, index + 2);
|
|
968
|
+
if (end === undefined) {
|
|
969
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
970
|
+
}
|
|
971
|
+
index = end;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const start = index;
|
|
975
|
+
const character = sql[index];
|
|
976
|
+
if (character === "'") {
|
|
977
|
+
index = postgresQuotedStringScanEnd(sql, index);
|
|
978
|
+
}
|
|
979
|
+
else if (character === '"') {
|
|
980
|
+
const end = postgresQuotedIdentifierEnd(sql, index);
|
|
981
|
+
if (end === undefined) {
|
|
982
|
+
throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
|
|
983
|
+
}
|
|
984
|
+
index = end;
|
|
985
|
+
}
|
|
986
|
+
else if (character === "$") {
|
|
987
|
+
const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0];
|
|
988
|
+
if (delimiter) {
|
|
989
|
+
const end = sql.indexOf(delimiter, index + delimiter.length);
|
|
990
|
+
if (end < 0) {
|
|
991
|
+
throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
|
|
992
|
+
}
|
|
993
|
+
index = end + delimiter.length;
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
index += 1;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
index += 1;
|
|
1001
|
+
}
|
|
1002
|
+
lastTokenStart = start;
|
|
1003
|
+
lastTokenEnd = index;
|
|
1004
|
+
}
|
|
1005
|
+
const end = sql[lastTokenStart] === ";" ? lastTokenStart : lastTokenEnd;
|
|
1006
|
+
return sql.slice(0, end).trim();
|
|
1007
|
+
}
|
|
1008
|
+
function invalidPostgresSyntax(command) {
|
|
1009
|
+
throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
|
|
1010
|
+
}
|
|
1011
|
+
function postgresQuotedIdentifierEnd(sql, start) {
|
|
1012
|
+
let index = start + 1;
|
|
1013
|
+
while (index < sql.length) {
|
|
1014
|
+
if (sql[index] !== '"') {
|
|
1015
|
+
index += 1;
|
|
1016
|
+
}
|
|
1017
|
+
else if (sql[index + 1] === '"') {
|
|
1018
|
+
index += 2;
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
return index + 1;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return undefined;
|
|
1025
|
+
}
|
|
1026
|
+
function postgresQuotedStringScanEnd(sql, start) {
|
|
1027
|
+
let index = start + 1;
|
|
1028
|
+
while (index < sql.length) {
|
|
1029
|
+
if (sql[index] === "\\") {
|
|
1030
|
+
index += 2;
|
|
1031
|
+
}
|
|
1032
|
+
else if (sql[index] !== "'") {
|
|
1033
|
+
index += 1;
|
|
1034
|
+
}
|
|
1035
|
+
else if (sql[index + 1] === "'") {
|
|
1036
|
+
index += 2;
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
return index + 1;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return index;
|
|
1043
|
+
}
|
|
1044
|
+
function nonNestedBlockCommentEnd(sql, start) {
|
|
1045
|
+
const end = sql.indexOf("*/", start);
|
|
1046
|
+
return end < 0 ? undefined : end + 2;
|
|
1047
|
+
}
|
|
1048
|
+
function postgresBlockCommentEnd(sql, start) {
|
|
1049
|
+
let depth = 1;
|
|
1050
|
+
let index = start;
|
|
1051
|
+
while (index < sql.length) {
|
|
1052
|
+
if (sql.startsWith("/*", index)) {
|
|
1053
|
+
depth += 1;
|
|
1054
|
+
index += 2;
|
|
1055
|
+
}
|
|
1056
|
+
else if (sql.startsWith("*/", index)) {
|
|
1057
|
+
depth -= 1;
|
|
1058
|
+
index += 2;
|
|
1059
|
+
if (depth === 0)
|
|
1060
|
+
return index;
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
index += 1;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return undefined;
|
|
1067
|
+
}
|
|
87
1068
|
function postgresParserSql(sql) {
|
|
88
1069
|
const output = sql.split("");
|
|
89
1070
|
const orderDepths = new Set();
|
|
@@ -97,13 +1078,26 @@ function postgresParserSql(sql) {
|
|
|
97
1078
|
continue;
|
|
98
1079
|
}
|
|
99
1080
|
if (sql.startsWith("/*", index)) {
|
|
100
|
-
|
|
1081
|
+
const end = postgresBlockCommentEnd(sql, index + 2);
|
|
1082
|
+
if (end === undefined) {
|
|
1083
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
1084
|
+
}
|
|
1085
|
+
index = end;
|
|
101
1086
|
continue;
|
|
102
1087
|
}
|
|
103
1088
|
const character = sql[index];
|
|
104
|
-
if (character === "'"
|
|
1089
|
+
if (character === "'") {
|
|
105
1090
|
previousWord = undefined;
|
|
106
|
-
index =
|
|
1091
|
+
index = postgresQuotedStringScanEnd(sql, index);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (character === '"') {
|
|
1095
|
+
previousWord = undefined;
|
|
1096
|
+
const end = postgresQuotedIdentifierEnd(sql, index);
|
|
1097
|
+
if (end === undefined) {
|
|
1098
|
+
throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
|
|
1099
|
+
}
|
|
1100
|
+
index = end;
|
|
107
1101
|
continue;
|
|
108
1102
|
}
|
|
109
1103
|
if (character === "$") {
|
|
@@ -111,7 +1105,10 @@ function postgresParserSql(sql) {
|
|
|
111
1105
|
if (delimiter) {
|
|
112
1106
|
previousWord = undefined;
|
|
113
1107
|
const end = sql.indexOf(delimiter, index + delimiter.length);
|
|
114
|
-
|
|
1108
|
+
if (end < 0) {
|
|
1109
|
+
throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
|
|
1110
|
+
}
|
|
1111
|
+
index = end + delimiter.length;
|
|
115
1112
|
continue;
|
|
116
1113
|
}
|
|
117
1114
|
}
|
|
@@ -205,48 +1202,42 @@ function blockCommentEnd(sql, start) {
|
|
|
205
1202
|
}
|
|
206
1203
|
return index;
|
|
207
1204
|
}
|
|
208
|
-
function quotedEnd(sql, start, quote) {
|
|
209
|
-
let index = start;
|
|
210
|
-
while (index < sql.length) {
|
|
211
|
-
if (sql[index] === "\\" && quote === "'") {
|
|
212
|
-
index += 2;
|
|
213
|
-
}
|
|
214
|
-
else if (sql[index] !== quote) {
|
|
215
|
-
index += 1;
|
|
216
|
-
}
|
|
217
|
-
else if (sql[index + 1] === quote) {
|
|
218
|
-
index += 2;
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
return index + 1;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return index;
|
|
225
|
-
}
|
|
226
1205
|
function identifierStart(value) {
|
|
227
1206
|
return value !== undefined && /[A-Za-z_\u0080-\uFFFF]/u.test(value);
|
|
228
1207
|
}
|
|
229
1208
|
function identifierPart(value) {
|
|
230
1209
|
return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
|
|
231
1210
|
}
|
|
232
|
-
function
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
1211
|
+
function containsUnexpectedWrite(ast, allowedWrites = new Set()) {
|
|
1212
|
+
const visited = new Set();
|
|
1213
|
+
const writeTypes = new Set([
|
|
1214
|
+
"insert",
|
|
1215
|
+
"replace",
|
|
1216
|
+
"update",
|
|
1217
|
+
"delete",
|
|
1218
|
+
"create",
|
|
1219
|
+
"alter",
|
|
1220
|
+
"drop",
|
|
1221
|
+
"truncate",
|
|
1222
|
+
]);
|
|
1223
|
+
const visit = (value) => {
|
|
1224
|
+
if (!value || typeof value !== "object")
|
|
242
1225
|
return false;
|
|
243
|
-
|
|
244
|
-
if (!statement || typeof statement !== "object")
|
|
1226
|
+
if (visited.has(value))
|
|
245
1227
|
return false;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1228
|
+
visited.add(value);
|
|
1229
|
+
if (Array.isArray(value))
|
|
1230
|
+
return value.some(visit);
|
|
1231
|
+
const details = value;
|
|
1232
|
+
const type = typeof details.type === "string" ? details.type : undefined;
|
|
1233
|
+
if (type && writeTypes.has(type) && !allowedWrites.has(value))
|
|
249
1234
|
return true;
|
|
250
|
-
|
|
251
|
-
|
|
1235
|
+
if (type === "select") {
|
|
1236
|
+
const into = details.into;
|
|
1237
|
+
if (into?.type === "into" || into?.expr)
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
return Object.values(details).some(visit);
|
|
1241
|
+
};
|
|
1242
|
+
return visit(ast);
|
|
252
1243
|
}
|