@fadhilp/stateql 0.12.0 → 0.13.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/README.md +53 -749
- package/dist/src/adapters.d.ts +1 -0
- package/dist/src/adapters.js +60 -0
- package/dist/src/sql.d.ts +3 -1
- package/dist/src/sql.js +520 -26
- package/dist/src/sqlite-process.js +16 -0
- package/dist/src/stateql.js +14 -5
- package/docs/databases.md +225 -0
- package/docs/library.md +268 -0
- package/docs/usage.md +352 -0
- package/package.json +2 -1
package/dist/src/sql.js
CHANGED
|
@@ -26,6 +26,14 @@ export function analyzeSql(sql, driver) {
|
|
|
26
26
|
if (postgresCommand)
|
|
27
27
|
return postgresCommand;
|
|
28
28
|
}
|
|
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
|
+
}
|
|
29
37
|
return analyzeParsedSql(trimmed, driver);
|
|
30
38
|
}
|
|
31
39
|
catch (error) {
|
|
@@ -35,7 +43,7 @@ export function analyzeSql(sql, driver) {
|
|
|
35
43
|
throw new StateQLError("INVALID_SQL", message);
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
|
-
function
|
|
46
|
+
function parseSqlStatement(sql, driver) {
|
|
39
47
|
const database = driver === "postgres"
|
|
40
48
|
? "Postgresql"
|
|
41
49
|
: driver === "mysql"
|
|
@@ -49,14 +57,6 @@ function analyzeParsedSql(sql, driver) {
|
|
|
49
57
|
const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
|
|
50
58
|
if (!ast)
|
|
51
59
|
throw new StateQLError("INVALID_SQL", "SQL is empty.");
|
|
52
|
-
const rawType = String(ast.type);
|
|
53
|
-
if (!SUPPORTED_STATEMENTS.has(rawType)) {
|
|
54
|
-
throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
|
|
55
|
-
}
|
|
56
|
-
const statementType = rawType;
|
|
57
|
-
if (statementType === "select" && selectContainsWrite(ast)) {
|
|
58
|
-
throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
|
|
59
|
-
}
|
|
60
60
|
const normalized = parserSql === sql
|
|
61
61
|
? parser
|
|
62
62
|
.sqlify(ast, { database })
|
|
@@ -66,6 +66,24 @@ function analyzeParsedSql(sql, driver) {
|
|
|
66
66
|
// Keep the exact ordering modifiers in cache and idempotency fingerprints.
|
|
67
67
|
// The parser copy is analysis-only; adapters execute the original SQL.
|
|
68
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
|
+
}
|
|
69
87
|
const details = ast;
|
|
70
88
|
const read = statementType === "select";
|
|
71
89
|
const mutation = statementType === "update" ||
|
|
@@ -83,7 +101,8 @@ function analyzeParsedSql(sql, driver) {
|
|
|
83
101
|
normalized,
|
|
84
102
|
statementType,
|
|
85
103
|
read,
|
|
86
|
-
unboundedMutation:
|
|
104
|
+
unboundedMutation: insert?.unboundedMutation ??
|
|
105
|
+
(statementType === "truncate" || (mutation && !details.where)),
|
|
87
106
|
destructive,
|
|
88
107
|
ordered: read && Boolean(details.orderby),
|
|
89
108
|
wrapForLimit: read,
|
|
@@ -91,11 +110,76 @@ function analyzeParsedSql(sql, driver) {
|
|
|
91
110
|
requiresAutocommit: false,
|
|
92
111
|
};
|
|
93
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
|
+
}
|
|
94
177
|
const EXPLAIN_INNER_STATEMENTS = new Set([
|
|
95
178
|
"select",
|
|
96
179
|
"insert",
|
|
97
180
|
"update",
|
|
98
181
|
"delete",
|
|
182
|
+
"upsert",
|
|
99
183
|
]);
|
|
100
184
|
const EXPLAIN_BOOLEAN_OPTIONS = new Set([
|
|
101
185
|
"ANALYZE",
|
|
@@ -111,15 +195,26 @@ const EXPLAIN_BOOLEAN_OPTIONS = new Set([
|
|
|
111
195
|
]);
|
|
112
196
|
const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
|
|
113
197
|
const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
|
|
114
|
-
const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF"]);
|
|
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
|
+
]);
|
|
115
206
|
function analyzePostgresCommand(sql) {
|
|
116
|
-
const scanner = new
|
|
207
|
+
const scanner = new SqlPrefixScanner(sql, "postgres");
|
|
117
208
|
const command = scanner.readWord();
|
|
118
209
|
if (!command)
|
|
119
210
|
return undefined;
|
|
120
211
|
switch (command.value) {
|
|
121
212
|
case "EXPLAIN":
|
|
122
213
|
return analyzePostgresExplain(sql, scanner);
|
|
214
|
+
case "SHOW":
|
|
215
|
+
return analyzePostgresShow(sql);
|
|
216
|
+
case "VALUES":
|
|
217
|
+
return analyzePostgresValues(sql);
|
|
123
218
|
case "VACUUM":
|
|
124
219
|
case "ANALYZE":
|
|
125
220
|
case "REINDEX":
|
|
@@ -129,6 +224,179 @@ function analyzePostgresCommand(sql) {
|
|
|
129
224
|
return undefined;
|
|
130
225
|
}
|
|
131
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
|
+
}
|
|
132
400
|
function analyzePostgresExplain(sql, scanner) {
|
|
133
401
|
let analyze = false;
|
|
134
402
|
const seen = new Set();
|
|
@@ -217,12 +485,15 @@ const VACUUM_OPTIONS = new Map([
|
|
|
217
485
|
["PARALLEL", "number"],
|
|
218
486
|
["SKIP_DATABASE_STATS", "boolean"],
|
|
219
487
|
["ONLY_DATABASE_STATS", "boolean"],
|
|
488
|
+
["BUFFER_USAGE_LIMIT", "size"],
|
|
220
489
|
]);
|
|
221
490
|
const ANALYZE_OPTIONS = new Map([
|
|
222
491
|
["VERBOSE", "boolean"],
|
|
223
492
|
["SKIP_LOCKED", "boolean"],
|
|
493
|
+
["BUFFER_USAGE_LIMIT", "size"],
|
|
224
494
|
]);
|
|
225
495
|
const REINDEX_OPTIONS = new Map([
|
|
496
|
+
["CONCURRENTLY", "boolean"],
|
|
226
497
|
["VERBOSE", "boolean"],
|
|
227
498
|
["TABLESPACE", "identifier"],
|
|
228
499
|
]);
|
|
@@ -235,14 +506,14 @@ function analyzePostgresMaintenance(sql, command) {
|
|
|
235
506
|
switch (command) {
|
|
236
507
|
case "VACUUM":
|
|
237
508
|
parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
|
|
238
|
-
parser.optionalTargets();
|
|
509
|
+
parser.optionalTargets(true);
|
|
239
510
|
break;
|
|
240
511
|
case "ANALYZE":
|
|
241
512
|
parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
|
|
242
|
-
parser.optionalTargets();
|
|
513
|
+
parser.optionalTargets(true);
|
|
243
514
|
break;
|
|
244
515
|
case "REINDEX": {
|
|
245
|
-
parser.options(REINDEX_OPTIONS);
|
|
516
|
+
const options = parser.options(REINDEX_OPTIONS);
|
|
246
517
|
const target = parser.expectOneOf([
|
|
247
518
|
"INDEX",
|
|
248
519
|
"TABLE",
|
|
@@ -250,12 +521,17 @@ function analyzePostgresMaintenance(sql, command) {
|
|
|
250
521
|
"DATABASE",
|
|
251
522
|
"SYSTEM",
|
|
252
523
|
]);
|
|
253
|
-
|
|
254
|
-
|
|
524
|
+
const postTargetConcurrent = parser.consumeWord("CONCURRENTLY");
|
|
525
|
+
if (postTargetConcurrent && options.has("CONCURRENTLY")) {
|
|
526
|
+
invalidPostgresSyntax(command);
|
|
527
|
+
}
|
|
255
528
|
if (target === "INDEX" || target === "TABLE") {
|
|
256
529
|
parser.qualifiedIdentifier();
|
|
257
530
|
}
|
|
258
|
-
else {
|
|
531
|
+
else if (target === "SCHEMA") {
|
|
532
|
+
parser.identifier();
|
|
533
|
+
}
|
|
534
|
+
else if (!parser.done()) {
|
|
259
535
|
parser.identifier();
|
|
260
536
|
}
|
|
261
537
|
break;
|
|
@@ -284,7 +560,7 @@ function analyzePostgresMaintenance(sql, command) {
|
|
|
284
560
|
};
|
|
285
561
|
}
|
|
286
562
|
function tokenizePostgresMaintenance(sql) {
|
|
287
|
-
const scanner = new
|
|
563
|
+
const scanner = new SqlPrefixScanner(sql, "postgres");
|
|
288
564
|
const tokens = [];
|
|
289
565
|
while (scanner.triviaEnd() < sql.length) {
|
|
290
566
|
const character = sql[scanner.position];
|
|
@@ -308,6 +584,15 @@ function tokenizePostgresMaintenance(sql) {
|
|
|
308
584
|
scanner.position = end;
|
|
309
585
|
continue;
|
|
310
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
|
+
}
|
|
311
596
|
if (/[0-9]/u.test(character)) {
|
|
312
597
|
const start = scanner.position;
|
|
313
598
|
scanner.position += 1;
|
|
@@ -316,7 +601,7 @@ function tokenizePostgresMaintenance(sql) {
|
|
|
316
601
|
tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
|
|
317
602
|
continue;
|
|
318
603
|
}
|
|
319
|
-
if (["(", ")", ",", "."].includes(character)) {
|
|
604
|
+
if (["(", ")", ",", ".", "*"].includes(character)) {
|
|
320
605
|
tokens.push({ kind: "punctuation", value: character });
|
|
321
606
|
scanner.position += 1;
|
|
322
607
|
continue;
|
|
@@ -396,11 +681,15 @@ class UtilityParser {
|
|
|
396
681
|
this.index += 1;
|
|
397
682
|
}
|
|
398
683
|
}
|
|
399
|
-
optionalTargets() {
|
|
684
|
+
optionalTargets(allowOnlyAndStar = false) {
|
|
400
685
|
if (this.done())
|
|
401
686
|
return;
|
|
402
687
|
while (true) {
|
|
688
|
+
if (allowOnlyAndStar)
|
|
689
|
+
this.consumeWord("ONLY");
|
|
403
690
|
this.qualifiedIdentifier();
|
|
691
|
+
if (allowOnlyAndStar)
|
|
692
|
+
this.consumePunctuation("*");
|
|
404
693
|
if (this.consumePunctuation("(")) {
|
|
405
694
|
this.identifier();
|
|
406
695
|
while (this.consumePunctuation(","))
|
|
@@ -429,7 +718,8 @@ class UtilityParser {
|
|
|
429
718
|
if (!token)
|
|
430
719
|
invalidPostgresSyntax(this.command);
|
|
431
720
|
if (kind === "boolean") {
|
|
432
|
-
if (token.kind !== "word"
|
|
721
|
+
if ((token.kind !== "word" && token.kind !== "number") ||
|
|
722
|
+
!BOOLEAN_VALUES.has(token.value)) {
|
|
433
723
|
invalidPostgresSyntax(this.command);
|
|
434
724
|
}
|
|
435
725
|
}
|
|
@@ -442,6 +732,10 @@ class UtilityParser {
|
|
|
442
732
|
invalidPostgresSyntax(this.command);
|
|
443
733
|
}
|
|
444
734
|
}
|
|
735
|
+
else if (kind === "size") {
|
|
736
|
+
if (!validMaintenanceSize(token))
|
|
737
|
+
invalidPostgresSyntax(this.command);
|
|
738
|
+
}
|
|
445
739
|
else if (token.kind !== "word" || !kind.has(token.value)) {
|
|
446
740
|
invalidPostgresSyntax(this.command);
|
|
447
741
|
}
|
|
@@ -455,12 +749,151 @@ class UtilityParser {
|
|
|
455
749
|
return true;
|
|
456
750
|
}
|
|
457
751
|
}
|
|
458
|
-
|
|
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 {
|
|
459
781
|
sql;
|
|
460
782
|
position = 0;
|
|
461
783
|
constructor(sql) {
|
|
462
784
|
this.sql = sql;
|
|
463
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
|
+
}
|
|
464
897
|
triviaEnd() {
|
|
465
898
|
while (this.position < this.sql.length) {
|
|
466
899
|
if (/\s/u.test(this.sql[this.position])) {
|
|
@@ -470,7 +903,9 @@ class PostgresPrefixScanner {
|
|
|
470
903
|
this.position = lineCommentEnd(this.sql, this.position + 2);
|
|
471
904
|
}
|
|
472
905
|
else if (this.sql.startsWith("/*", this.position)) {
|
|
473
|
-
const end =
|
|
906
|
+
const end = this.dialect === "postgres"
|
|
907
|
+
? postgresBlockCommentEnd(this.sql, this.position + 2)
|
|
908
|
+
: nonNestedBlockCommentEnd(this.sql, this.position + 2);
|
|
474
909
|
if (end === undefined) {
|
|
475
910
|
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
476
911
|
}
|
|
@@ -515,6 +950,61 @@ class PostgresPrefixScanner {
|
|
|
515
950
|
return true;
|
|
516
951
|
}
|
|
517
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
|
+
}
|
|
518
1008
|
function invalidPostgresSyntax(command) {
|
|
519
1009
|
throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
|
|
520
1010
|
}
|
|
@@ -551,6 +1041,10 @@ function postgresQuotedStringScanEnd(sql, start) {
|
|
|
551
1041
|
}
|
|
552
1042
|
return index;
|
|
553
1043
|
}
|
|
1044
|
+
function nonNestedBlockCommentEnd(sql, start) {
|
|
1045
|
+
const end = sql.indexOf("*/", start);
|
|
1046
|
+
return end < 0 ? undefined : end + 2;
|
|
1047
|
+
}
|
|
554
1048
|
function postgresBlockCommentEnd(sql, start) {
|
|
555
1049
|
let depth = 1;
|
|
556
1050
|
let index = start;
|
|
@@ -714,7 +1208,7 @@ function identifierStart(value) {
|
|
|
714
1208
|
function identifierPart(value) {
|
|
715
1209
|
return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
|
|
716
1210
|
}
|
|
717
|
-
function
|
|
1211
|
+
function containsUnexpectedWrite(ast, allowedWrites = new Set()) {
|
|
718
1212
|
const visited = new Set();
|
|
719
1213
|
const writeTypes = new Set([
|
|
720
1214
|
"insert",
|
|
@@ -736,7 +1230,7 @@ function selectContainsWrite(ast) {
|
|
|
736
1230
|
return value.some(visit);
|
|
737
1231
|
const details = value;
|
|
738
1232
|
const type = typeof details.type === "string" ? details.type : undefined;
|
|
739
|
-
if (type && writeTypes.has(type))
|
|
1233
|
+
if (type && writeTypes.has(type) && !allowedWrites.has(value))
|
|
740
1234
|
return true;
|
|
741
1235
|
if (type === "select") {
|
|
742
1236
|
const into = details.into;
|