@fadhilp/stateql 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -216,6 +216,20 @@ Use `--params JSON` for a JSON array or named parameters. Use
|
|
|
216
216
|
`--params-file FILE` when JSON is awkward to quote; `--params-file -` reads
|
|
217
217
|
JSON from standard input.
|
|
218
218
|
|
|
219
|
+
### Dialect upserts
|
|
220
|
+
|
|
221
|
+
PostgreSQL `INSERT ... ON CONFLICT DO NOTHING|UPDATE` and MySQL `INSERT ... ON
|
|
222
|
+
DUPLICATE KEY UPDATE` are structurally validated and recorded with statement
|
|
223
|
+
type `upsert`. Finite `VALUES` and MySQL `INSERT ... SET` sources use normal
|
|
224
|
+
write policy. An update-upsert fed by `SELECT` requires `--allow-unbounded`
|
|
225
|
+
because its candidate row count is not statically bounded. Upserts support direct
|
|
226
|
+
`exec`, `plan`/`apply`, and staged transactions; hidden additional writes are rejected.
|
|
227
|
+
|
|
228
|
+
MySQL `INSERT IGNORE` remains a non-overwriting `insert`. SQLite `INSERT OR
|
|
229
|
+
REPLACE` retains destructive-operation approval, while SQLite modern `ON
|
|
230
|
+
CONFLICT ... DO UPDATE` and every `MERGE` form remain blocked until the parser
|
|
231
|
+
can expose their complete mutation structure.
|
|
232
|
+
|
|
219
233
|
### PostgreSQL diagnostics and maintenance
|
|
220
234
|
|
|
221
235
|
Run PostgreSQL plans through `query`:
|
|
@@ -231,6 +245,15 @@ mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
|
|
|
231
245
|
and are never reused from cache. `--cache require` therefore returns
|
|
232
246
|
`CACHE_MISS` without executing the diagnostic.
|
|
233
247
|
|
|
248
|
+
StateQL supports PostgreSQL 14–18. Top-level `VALUES` is a bounded read and
|
|
249
|
+
accepts normal PostgreSQL positional parameters. It is conservatively
|
|
250
|
+
non-cacheable because expressions may be volatile. The following narrow `SHOW`
|
|
251
|
+
allowlist is also available as non-cacheable diagnostics:
|
|
252
|
+
`server_version`, `server_version_num`, `transaction_read_only`,
|
|
253
|
+
`transaction_isolation`, and `default_transaction_isolation`. `SHOW ALL` and
|
|
254
|
+
other settings remain blocked. Syntax accepted by StateQL but introduced by a
|
|
255
|
+
newer PostgreSQL release may be rejected safely by an older server.
|
|
256
|
+
|
|
234
257
|
`VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
|
|
235
258
|
|
|
236
259
|
```bash
|
|
@@ -238,6 +261,13 @@ stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
|
|
|
238
261
|
stql plan "REINDEX TABLE public.jobs" --allow-destructive
|
|
239
262
|
```
|
|
240
263
|
|
|
264
|
+
The PostgreSQL 14–18 grammar includes parenthesized `REINDEX CONCURRENTLY`,
|
|
265
|
+
PostgreSQL 16 `BUFFER_USAGE_LIMIT` for `VACUUM`/`ANALYZE`, optional
|
|
266
|
+
`DATABASE`/`SYSTEM` reindex names, and PostgreSQL 18 `ONLY table *` maintenance
|
|
267
|
+
targets. Memory sizes accept an integer number of kilobytes or a quoted
|
|
268
|
+
`B|kB|MB|GB|TB` value. Older servers may reject newer forms after dispatch, so
|
|
269
|
+
StateQL retains conservative unknown-outcome handling.
|
|
270
|
+
|
|
241
271
|
They require a read-write connection and `--allow-destructive`, reject StateQL
|
|
242
272
|
parameters, and run as individually tracked autocommit operations. They cannot
|
|
243
273
|
be staged in a StateQL transaction. A timeout or cancellation after dispatch is
|
|
@@ -247,6 +277,55 @@ remain unsupported—use `stql transaction` commands instead. See
|
|
|
247
277
|
[`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
|
|
248
278
|
boundary and deferred command categories.
|
|
249
279
|
|
|
280
|
+
### SQLite and MySQL diagnostics and maintenance
|
|
281
|
+
|
|
282
|
+
SQLite supports `EXPLAIN QUERY PLAN` for structurally read-only `SELECT`
|
|
283
|
+
statements. MySQL supports `EXPLAIN SELECT` plus bare `SHOW TABLES`,
|
|
284
|
+
`SHOW COLUMNS FROM table`, and `SHOW INDEX|INDEXES FROM table`. Broader
|
|
285
|
+
`EXPLAIN`, `SHOW`, and write-bearing forms remain blocked.
|
|
286
|
+
|
|
287
|
+
MySQL executable comments (`/*! ... */`) are rejected throughout SQL. Because
|
|
288
|
+
StateQL does not assume a server `sql_mode`, quoting that could expose these
|
|
289
|
+
comments under `ANSI_QUOTES` or `NO_BACKSLASH_ESCAPES` is also rejected.
|
|
290
|
+
|
|
291
|
+
These diagnostics use `query`, work with read-only connections, preserve the
|
|
292
|
+
original statement instead of applying StateQL's limiting SQL wrapper, and are
|
|
293
|
+
never reused from cache. Materialized results still receive StateQL's row and
|
|
294
|
+
byte checks.
|
|
295
|
+
|
|
296
|
+
SQLite also supports bare `VACUUM`, plus `ANALYZE [target]` and
|
|
297
|
+
`REINDEX [target]` with at most one unqualified or double-quoted target:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
stql exec "ANALYZE jobs" --allow-destructive
|
|
301
|
+
stql exec "REINDEX jobs_created_at_idx" --allow-destructive
|
|
302
|
+
stql exec "VACUUM" --allow-destructive
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
These commands require a read-write connection, reject parameters, run as
|
|
306
|
+
individually tracked autocommit operations, and cannot be staged. A timeout,
|
|
307
|
+
cancellation, or error after dispatch is reported as `OUTCOME_UNKNOWN`.
|
|
308
|
+
`VACUUM INTO`, schema-qualified targets, paths, `ATTACH`, and arbitrary `PRAGMA`
|
|
309
|
+
remain blocked.
|
|
310
|
+
|
|
311
|
+
MySQL supports one optionally qualified bare or backtick-quoted target for
|
|
312
|
+
`ANALYZE TABLE`, `OPTIMIZE TABLE`, and `CHECK TABLE`:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
stql exec "ANALYZE TABLE jobs" --allow-destructive
|
|
316
|
+
stql plan "OPTIMIZE TABLE jobs" --allow-destructive
|
|
317
|
+
stql query "CHECK TABLE jobs"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`ANALYZE` and `OPTIMIZE` are durable autocommit writes requiring a read-write
|
|
321
|
+
connection and destructive approval; server-reported error rows become known
|
|
322
|
+
failed operations, while timeout or cancellation after dispatch remains
|
|
323
|
+
`OUTCOME_UNKNOWN`. `CHECK TABLE` is an unwrapped, non-cacheable autocommit read
|
|
324
|
+
that works on read-only connections and retains normal result limits. All three
|
|
325
|
+
reject StateQL parameters, options, multiple targets, and additional
|
|
326
|
+
statements, and none can run during a staged transaction.
|
|
327
|
+
|
|
328
|
+
|
|
250
329
|
### Native MongoDB
|
|
251
330
|
|
|
252
331
|
MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface Adapter {
|
|
|
32
32
|
readonly confidence: StateConfidence;
|
|
33
33
|
ping(): Promise<void>;
|
|
34
34
|
read(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
35
|
+
readAutocommit?(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
35
36
|
write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
|
|
36
37
|
writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
|
|
37
38
|
writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function createAdapter(connection, context, input) {
|
|
|
51
51
|
throw new StateQLError("UNSUPPORTED_DRIVER", "Redis uses the native Redis adapter.");
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
const SQLITE_AUTOCOMMIT_STATEMENTS = new Set(["vacuum", "analyze", "reindex"]);
|
|
54
55
|
class SQLiteAdapter {
|
|
55
56
|
source;
|
|
56
57
|
readOnly;
|
|
@@ -101,7 +102,14 @@ class SQLiteAdapter {
|
|
|
101
102
|
async write(sql, params, expectedRows) {
|
|
102
103
|
return this.call("write", [sql, params, expectedRows], true, false);
|
|
103
104
|
}
|
|
105
|
+
async writeAutocommit(sql, params) {
|
|
106
|
+
return this.call("writeAutocommit", [sql, params], true, false);
|
|
107
|
+
}
|
|
104
108
|
async writeBatch(operations, isolation) {
|
|
109
|
+
const unsupported = operations.find((operation) => SQLITE_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
|
|
110
|
+
if (unsupported) {
|
|
111
|
+
throw new BatchWriteError(`SQLite transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
|
|
112
|
+
}
|
|
105
113
|
return this.call("writeBatch", [operations, isolation], true, true);
|
|
106
114
|
}
|
|
107
115
|
async signature() {
|
|
@@ -590,6 +598,16 @@ class MySqlAdapter {
|
|
|
590
598
|
throw error;
|
|
591
599
|
}
|
|
592
600
|
}
|
|
601
|
+
async readAutocommit(sql, params) {
|
|
602
|
+
const [result, fields] = await this.query(sql, mysqlParams(params), false, false);
|
|
603
|
+
return {
|
|
604
|
+
rows: toJsonSafe(mysqlRows(result)),
|
|
605
|
+
columns: fields.map((field) => ({
|
|
606
|
+
name: field.name,
|
|
607
|
+
type: mysqlFieldType(field),
|
|
608
|
+
})),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
593
611
|
async write(sql, params, expectedRows) {
|
|
594
612
|
if (this.readOnly)
|
|
595
613
|
throw new Error("Connection is read-only.");
|
|
@@ -629,6 +647,35 @@ class MySqlAdapter {
|
|
|
629
647
|
throw new AdapterWriteError(errorText(error), true);
|
|
630
648
|
}
|
|
631
649
|
}
|
|
650
|
+
async writeAutocommit(sql, params) {
|
|
651
|
+
if (this.readOnly)
|
|
652
|
+
throw new AdapterWriteError("Connection is read-only.", false);
|
|
653
|
+
let values;
|
|
654
|
+
try {
|
|
655
|
+
values = mysqlParams(params);
|
|
656
|
+
await this.connect();
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (error instanceof AdapterExecutionError)
|
|
660
|
+
throw error;
|
|
661
|
+
throw new AdapterWriteError(errorText(error), false);
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
const [result] = await this.runQuery(sql, values, true, false);
|
|
665
|
+
const maintenanceError = mysqlMaintenanceError(result);
|
|
666
|
+
if (maintenanceError)
|
|
667
|
+
throw new AdapterWriteError(maintenanceError, false);
|
|
668
|
+
return {
|
|
669
|
+
affectedRows: Array.isArray(result) ? 0 : mysqlAffectedRows(result),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
if (error instanceof AdapterExecutionError || error instanceof AdapterWriteError) {
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
throw new AdapterWriteError(errorText(error), true);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
632
679
|
async writeBatch(operations, isolation) {
|
|
633
680
|
if (this.readOnly)
|
|
634
681
|
throw new Error("Connection is read-only.");
|
|
@@ -901,6 +948,7 @@ const MYSQL_ISOLATION_LEVELS = new Set([
|
|
|
901
948
|
const MYSQL_TRANSACTIONAL_STATEMENTS = new Set([
|
|
902
949
|
"delete",
|
|
903
950
|
"insert",
|
|
951
|
+
"upsert",
|
|
904
952
|
"replace",
|
|
905
953
|
"update",
|
|
906
954
|
]);
|
|
@@ -927,6 +975,18 @@ function mysqlRows(result) {
|
|
|
927
975
|
}
|
|
928
976
|
return result;
|
|
929
977
|
}
|
|
978
|
+
function mysqlMaintenanceError(result) {
|
|
979
|
+
if (!Array.isArray(result))
|
|
980
|
+
return undefined;
|
|
981
|
+
for (const row of result) {
|
|
982
|
+
const typeEntry = Object.entries(row).find(([key]) => key.toLowerCase() === "msg_type");
|
|
983
|
+
if (String(typeEntry?.[1] ?? "").toLowerCase() !== "error")
|
|
984
|
+
continue;
|
|
985
|
+
const textEntry = Object.entries(row).find(([key]) => key.toLowerCase() === "msg_text");
|
|
986
|
+
return String(textEntry?.[1] ?? "MySQL maintenance failed.");
|
|
987
|
+
}
|
|
988
|
+
return undefined;
|
|
989
|
+
}
|
|
930
990
|
function mysqlAffectedRows(result) {
|
|
931
991
|
if (Array.isArray(result) || !("affectedRows" in result)) {
|
|
932
992
|
throw new Error("MySQL statement did not return a write result.");
|
package/dist/src/sql.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AST } from "node-sql-parser";
|
|
2
2
|
import type { Driver, SqlDriver } from "./types.js";
|
|
3
|
-
export type StatementType = "select" | "insert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "explain" | "vacuum" | "analyze" | "reindex" | "cluster";
|
|
3
|
+
export type StatementType = "select" | "insert" | "upsert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "values" | "show" | "explain" | "vacuum" | "analyze" | "reindex" | "optimize" | "check" | "cluster";
|
|
4
4
|
export interface SqlAnalysis {
|
|
5
5
|
ast: AST;
|
|
6
6
|
normalized: string;
|
|
@@ -12,6 +12,8 @@ export interface SqlAnalysis {
|
|
|
12
12
|
wrapForLimit: boolean;
|
|
13
13
|
cacheable: boolean;
|
|
14
14
|
requiresAutocommit: boolean;
|
|
15
|
+
/** Analysis-only SQL with trailing trivia removed when limit wrapping needs it. */
|
|
16
|
+
limitSql?: string;
|
|
15
17
|
}
|
|
16
18
|
export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
|
|
17
19
|
/** @internal Compatibility for existing connection records during Mongo rollout. */
|
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;
|
|
@@ -83,6 +83,22 @@ function execute(request) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
case "writeAutocommit": {
|
|
87
|
+
if (readOnly)
|
|
88
|
+
throw new SQLiteBatchError("Connection is read-only.", false);
|
|
89
|
+
const [sql, params] = request.args;
|
|
90
|
+
if ((Array.isArray(params) && params.length > 0) ||
|
|
91
|
+
(!Array.isArray(params) && Object.keys(params).length > 0)) {
|
|
92
|
+
throw new SQLiteBatchError("SQLite maintenance statements do not accept parameters.", false);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
database.exec(sql);
|
|
96
|
+
return { affectedRows: 0 };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw new SQLiteBatchError(errorText(error), true);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
86
102
|
case "writeBatch": {
|
|
87
103
|
if (readOnly)
|
|
88
104
|
throw new Error("Connection is read-only.");
|
package/dist/src/stateql.js
CHANGED
|
@@ -680,6 +680,9 @@ export class StateQL {
|
|
|
680
680
|
});
|
|
681
681
|
}
|
|
682
682
|
const parameters = options.params ?? [];
|
|
683
|
+
if (analysis.requiresAutocommit && sqlParametersLength(parameters) > 0) {
|
|
684
|
+
throw new StateQLError("INVALID_SQL", "Autocommit diagnostic statements do not accept StateQL parameters.");
|
|
685
|
+
}
|
|
683
686
|
const context = this.executionContext(options);
|
|
684
687
|
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
685
688
|
const adapter = await this.openAdapter(connection, context, adapterSource);
|
|
@@ -716,9 +719,15 @@ export class StateQL {
|
|
|
716
719
|
suggestedAction: "Run with --cache auto or --cache bypass.",
|
|
717
720
|
});
|
|
718
721
|
}
|
|
719
|
-
const
|
|
720
|
-
? boundedReadSql(sql, this.maxResultRows + 1)
|
|
721
|
-
: sql
|
|
722
|
+
const executionSql = analysis.wrapForLimit
|
|
723
|
+
? boundedReadSql(analysis.limitSql ?? sql, this.maxResultRows + 1)
|
|
724
|
+
: sql;
|
|
725
|
+
if (analysis.requiresAutocommit && !adapter.readAutocommit) {
|
|
726
|
+
throw new StateQLError("UNSUPPORTED_DRIVER", `${analysis.statementType.toUpperCase()} requires adapter autocommit reads.`);
|
|
727
|
+
}
|
|
728
|
+
const result = analysis.requiresAutocommit
|
|
729
|
+
? await adapter.readAutocommit(executionSql, parameters)
|
|
730
|
+
: await adapter.read(executionSql, parameters);
|
|
722
731
|
if (result.rows.length > this.maxResultRows) {
|
|
723
732
|
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
|
|
724
733
|
}
|
|
@@ -2211,7 +2220,7 @@ export class StateQL {
|
|
|
2211
2220
|
const parameters = options.params ?? [];
|
|
2212
2221
|
if (analysis.requiresAutocommit &&
|
|
2213
2222
|
(options.expectedRows !== undefined || sqlParametersLength(parameters) > 0)) {
|
|
2214
|
-
throw new StateQLError("INVALID_SQL", "
|
|
2223
|
+
throw new StateQLError("INVALID_SQL", "Autocommit maintenance statements do not accept StateQL parameters or row-count preconditions.");
|
|
2215
2224
|
}
|
|
2216
2225
|
const transactionId = session.active_transaction_id ?? undefined;
|
|
2217
2226
|
if (analysis.requiresAutocommit && transactionId) {
|
|
@@ -2322,7 +2331,7 @@ export class StateQL {
|
|
|
2322
2331
|
}
|
|
2323
2332
|
try {
|
|
2324
2333
|
if (analysis.requiresAutocommit && !adapter.writeAutocommit) {
|
|
2325
|
-
throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires
|
|
2334
|
+
throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires adapter autocommit execution.`, false);
|
|
2326
2335
|
}
|
|
2327
2336
|
const write = analysis.requiresAutocommit
|
|
2328
2337
|
? await adapter.writeAutocommit(sql, parameters)
|