@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/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", "SQL is not supported for MongoDB connections.");
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
- const database = driver === "postgres"
25
- ? "Postgresql"
26
- : driver === "mysql"
27
- ? "MySQL"
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
- const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
39
- if (!ast)
40
- throw new StateQLError("INVALID_SQL", "SQL is empty.");
41
- const rawType = String(ast.type);
42
- if (!SUPPORTED_STATEMENTS.has(rawType)) {
43
- throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
44
- }
45
- const statementType = rawType;
46
- if (statementType === "select" && selectContainsWrite(ast)) {
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
- index = blockCommentEnd(sql, index + 2);
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 === "'" || character === '"') {
1089
+ if (character === "'") {
105
1090
  previousWord = undefined;
106
- index = quotedEnd(sql, index + 1, character);
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
- index = end < 0 ? sql.length : end + delimiter.length;
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 selectContainsWrite(ast) {
233
- const details = ast;
234
- const into = details.into;
235
- if (into?.type === "into" || into?.expr)
236
- return true;
237
- const withStatements = details.with;
238
- if (!Array.isArray(withStatements))
239
- return false;
240
- return withStatements.some((entry) => {
241
- if (!entry || typeof entry !== "object")
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
- const statement = entry.stmt;
244
- if (!statement || typeof statement !== "object")
1226
+ if (visited.has(value))
245
1227
  return false;
246
- const wrapper = statement;
247
- const child = (wrapper.ast ?? statement);
248
- if (child.type !== "select")
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
- return selectContainsWrite(child);
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
  }