@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/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 analyzeParsedSql(sql, driver) {
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: statementType === "truncate" || (mutation && !details.where),
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 PostgresPrefixScanner(sql);
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
- if (target !== "SYSTEM")
254
- parser.consumeWord("CONCURRENTLY");
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 PostgresPrefixScanner(sql);
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" || !BOOLEAN_VALUES.has(token.value)) {
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
- class PostgresPrefixScanner {
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 = postgresBlockCommentEnd(this.sql, this.position + 2);
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 selectContainsWrite(ast) {
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;