@fadhilp/stateql 0.11.1 → 0.12.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 +31 -0
- package/dist/src/adapters.d.ts +1 -0
- package/dist/src/adapters.js +35 -2
- package/dist/src/sql.d.ts +4 -1
- package/dist/src/sql.js +591 -94
- package/dist/src/stateql.js +31 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -216,6 +216,37 @@ 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
|
+
### PostgreSQL diagnostics and maintenance
|
|
220
|
+
|
|
221
|
+
Run PostgreSQL plans through `query`:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
stql query "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM jobs WHERE id = 42"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Plain `EXPLAIN` may plan a structurally validated `SELECT`, `INSERT`, `UPDATE`,
|
|
228
|
+
or `DELETE`. Because `EXPLAIN ANALYZE` executes its inner statement, StateQL
|
|
229
|
+
accepts only a validated read-only `SELECT`; `SELECT INTO`, writing CTEs, and
|
|
230
|
+
mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
|
|
231
|
+
and are never reused from cache. `--cache require` therefore returns
|
|
232
|
+
`CACHE_MISS` without executing the diagnostic.
|
|
233
|
+
|
|
234
|
+
`VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
|
|
238
|
+
stql plan "REINDEX TABLE public.jobs" --allow-destructive
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
They require a read-write connection and `--allow-destructive`, reject StateQL
|
|
242
|
+
parameters, and run as individually tracked autocommit operations. They cannot
|
|
243
|
+
be staged in a StateQL transaction. A timeout or cancellation after dispatch is
|
|
244
|
+
reported as `OUTCOME_UNKNOWN`; inspect database state before replaying it. Raw
|
|
245
|
+
`BEGIN`, `COMMIT`, `ROLLBACK`, savepoint, and other transaction-control SQL
|
|
246
|
+
remain unsupported—use `stql transaction` commands instead. See
|
|
247
|
+
[`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
|
|
248
|
+
boundary and deferred command categories.
|
|
249
|
+
|
|
219
250
|
### Native MongoDB
|
|
220
251
|
|
|
221
252
|
MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface Adapter {
|
|
|
33
33
|
ping(): Promise<void>;
|
|
34
34
|
read(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
35
35
|
write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
|
|
36
|
+
writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
|
|
36
37
|
writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
|
|
37
38
|
signature(): Promise<string>;
|
|
38
39
|
inspect(kind: string, table?: string): Promise<unknown>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -218,6 +218,12 @@ export function normalizePostgresConnectionString(source) {
|
|
|
218
218
|
return source;
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
const POSTGRES_AUTOCOMMIT_STATEMENTS = new Set([
|
|
222
|
+
"vacuum",
|
|
223
|
+
"analyze",
|
|
224
|
+
"reindex",
|
|
225
|
+
"cluster",
|
|
226
|
+
]);
|
|
221
227
|
class PostgresAdapter {
|
|
222
228
|
readOnly;
|
|
223
229
|
context;
|
|
@@ -294,9 +300,36 @@ class PostgresAdapter {
|
|
|
294
300
|
throw new AdapterWriteError(errorText(error), true);
|
|
295
301
|
}
|
|
296
302
|
}
|
|
303
|
+
async writeAutocommit(sql, params) {
|
|
304
|
+
if (this.readOnly)
|
|
305
|
+
throw new Error("Connection is read-only.");
|
|
306
|
+
let values;
|
|
307
|
+
try {
|
|
308
|
+
values = postgresParams(params);
|
|
309
|
+
await this.connect();
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error instanceof AdapterExecutionError)
|
|
313
|
+
throw error;
|
|
314
|
+
throw new AdapterWriteError(errorText(error), false);
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const result = await this.query(sql, values, true, false);
|
|
318
|
+
return { affectedRows: result.rowCount ?? 0 };
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error instanceof AdapterExecutionError)
|
|
322
|
+
throw error;
|
|
323
|
+
throw new AdapterWriteError(errorText(error), true);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
297
326
|
async writeBatch(operations, isolation) {
|
|
298
327
|
if (this.readOnly)
|
|
299
328
|
throw new Error("Connection is read-only.");
|
|
329
|
+
const unsupported = operations.find((operation) => POSTGRES_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
|
|
330
|
+
if (unsupported) {
|
|
331
|
+
throw new BatchWriteError(`PostgreSQL transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
|
|
332
|
+
}
|
|
300
333
|
await this.connect();
|
|
301
334
|
const level = isolation.toUpperCase();
|
|
302
335
|
if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
|
|
@@ -491,8 +524,8 @@ class PostgresAdapter {
|
|
|
491
524
|
async setLocalDeadline() {
|
|
492
525
|
await this.query(`SET LOCAL statement_timeout = ${remainingMilliseconds(this.context)}`, [], false);
|
|
493
526
|
}
|
|
494
|
-
async query(sql, params, outcomeUnknown) {
|
|
495
|
-
throwIfStopped(this.context,
|
|
527
|
+
async query(sql, params, outcomeUnknown, preDispatchOutcomeUnknown = outcomeUnknown) {
|
|
528
|
+
throwIfStopped(this.context, preDispatchOutcomeUnknown);
|
|
496
529
|
try {
|
|
497
530
|
return await withContext(this.client.query(sql, params), this.context, () => this.stop(), outcomeUnknown);
|
|
498
531
|
}
|
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";
|
|
3
|
+
export type StatementType = "select" | "insert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "explain" | "vacuum" | "analyze" | "reindex" | "cluster";
|
|
4
4
|
export interface SqlAnalysis {
|
|
5
5
|
ast: AST;
|
|
6
6
|
normalized: string;
|
|
@@ -9,6 +9,9 @@ export interface SqlAnalysis {
|
|
|
9
9
|
unboundedMutation: boolean;
|
|
10
10
|
destructive: boolean;
|
|
11
11
|
ordered: boolean;
|
|
12
|
+
wrapForLimit: boolean;
|
|
13
|
+
cacheable: boolean;
|
|
14
|
+
requiresAutocommit: boolean;
|
|
12
15
|
}
|
|
13
16
|
export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
|
|
14
17
|
/** @internal Compatibility for existing connection records during Mongo rollout. */
|
package/dist/src/sql.js
CHANGED
|
@@ -14,68 +14,19 @@ const SUPPORTED_STATEMENTS = new Set([
|
|
|
14
14
|
"truncate",
|
|
15
15
|
]);
|
|
16
16
|
export function analyzeSql(sql, driver) {
|
|
17
|
-
if (driver === "mongodb") {
|
|
18
|
-
throw new StateQLError("INVALID_SQL",
|
|
17
|
+
if (driver === "mongodb" || driver === "redis") {
|
|
18
|
+
throw new StateQLError("INVALID_SQL", `SQL is not supported for ${driver} connections.`);
|
|
19
19
|
}
|
|
20
20
|
const trimmed = sql.trim();
|
|
21
21
|
if (!trimmed)
|
|
22
22
|
throw new StateQLError("INVALID_SQL", "SQL is empty.");
|
|
23
23
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
: "Sqlite";
|
|
29
|
-
const parserSql = driver === "postgres"
|
|
30
|
-
? postgresParserSql(trimmed)
|
|
31
|
-
: trimmed;
|
|
32
|
-
const parsed = parser.astify(parserSql, { database });
|
|
33
|
-
if (Array.isArray(parsed)) {
|
|
34
|
-
if (parsed.length !== 1) {
|
|
35
|
-
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
36
|
-
}
|
|
24
|
+
if (driver === "postgres") {
|
|
25
|
+
const postgresCommand = analyzePostgresCommand(trimmed);
|
|
26
|
+
if (postgresCommand)
|
|
27
|
+
return postgresCommand;
|
|
37
28
|
}
|
|
38
|
-
|
|
39
|
-
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
|
+
return analyzeParsedSql(trimmed, driver);
|
|
79
30
|
}
|
|
80
31
|
catch (error) {
|
|
81
32
|
if (error instanceof StateQLError)
|
|
@@ -84,6 +35,542 @@ export function analyzeSql(sql, driver) {
|
|
|
84
35
|
throw new StateQLError("INVALID_SQL", message);
|
|
85
36
|
}
|
|
86
37
|
}
|
|
38
|
+
function analyzeParsedSql(sql, driver) {
|
|
39
|
+
const database = driver === "postgres"
|
|
40
|
+
? "Postgresql"
|
|
41
|
+
: driver === "mysql"
|
|
42
|
+
? "MySQL"
|
|
43
|
+
: "Sqlite";
|
|
44
|
+
const parserSql = driver === "postgres" ? postgresParserSql(sql) : sql;
|
|
45
|
+
const parsed = parser.astify(parserSql, { database });
|
|
46
|
+
if (Array.isArray(parsed) && parsed.length !== 1) {
|
|
47
|
+
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
48
|
+
}
|
|
49
|
+
const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
|
|
50
|
+
if (!ast)
|
|
51
|
+
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
|
+
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
|
+
const details = ast;
|
|
70
|
+
const read = statementType === "select";
|
|
71
|
+
const mutation = statementType === "update" ||
|
|
72
|
+
statementType === "delete" ||
|
|
73
|
+
statementType === "truncate";
|
|
74
|
+
const destructive = statementType === "drop" ||
|
|
75
|
+
statementType === "alter" ||
|
|
76
|
+
statementType === "delete" ||
|
|
77
|
+
statementType === "replace" ||
|
|
78
|
+
statementType === "truncate" ||
|
|
79
|
+
(driver === "sqlite" &&
|
|
80
|
+
/^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
|
|
81
|
+
return {
|
|
82
|
+
ast,
|
|
83
|
+
normalized,
|
|
84
|
+
statementType,
|
|
85
|
+
read,
|
|
86
|
+
unboundedMutation: statementType === "truncate" || (mutation && !details.where),
|
|
87
|
+
destructive,
|
|
88
|
+
ordered: read && Boolean(details.orderby),
|
|
89
|
+
wrapForLimit: read,
|
|
90
|
+
cacheable: true,
|
|
91
|
+
requiresAutocommit: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const EXPLAIN_INNER_STATEMENTS = new Set([
|
|
95
|
+
"select",
|
|
96
|
+
"insert",
|
|
97
|
+
"update",
|
|
98
|
+
"delete",
|
|
99
|
+
]);
|
|
100
|
+
const EXPLAIN_BOOLEAN_OPTIONS = new Set([
|
|
101
|
+
"ANALYZE",
|
|
102
|
+
"VERBOSE",
|
|
103
|
+
"COSTS",
|
|
104
|
+
"SETTINGS",
|
|
105
|
+
"GENERIC_PLAN",
|
|
106
|
+
"BUFFERS",
|
|
107
|
+
"WAL",
|
|
108
|
+
"TIMING",
|
|
109
|
+
"SUMMARY",
|
|
110
|
+
"MEMORY",
|
|
111
|
+
]);
|
|
112
|
+
const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
|
|
113
|
+
const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
|
|
114
|
+
const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF"]);
|
|
115
|
+
function analyzePostgresCommand(sql) {
|
|
116
|
+
const scanner = new PostgresPrefixScanner(sql);
|
|
117
|
+
const command = scanner.readWord();
|
|
118
|
+
if (!command)
|
|
119
|
+
return undefined;
|
|
120
|
+
switch (command.value) {
|
|
121
|
+
case "EXPLAIN":
|
|
122
|
+
return analyzePostgresExplain(sql, scanner);
|
|
123
|
+
case "VACUUM":
|
|
124
|
+
case "ANALYZE":
|
|
125
|
+
case "REINDEX":
|
|
126
|
+
case "CLUSTER":
|
|
127
|
+
return analyzePostgresMaintenance(sql, command.value);
|
|
128
|
+
default:
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function analyzePostgresExplain(sql, scanner) {
|
|
133
|
+
let analyze = false;
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
if (scanner.consume("(")) {
|
|
136
|
+
while (true) {
|
|
137
|
+
const option = scanner.readWord();
|
|
138
|
+
if (!option || seen.has(option.value))
|
|
139
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
140
|
+
seen.add(option.value);
|
|
141
|
+
const next = scanner.peek();
|
|
142
|
+
let value;
|
|
143
|
+
if (next !== "," && next !== ")") {
|
|
144
|
+
value = scanner.readWord()?.value;
|
|
145
|
+
if (!value)
|
|
146
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
147
|
+
}
|
|
148
|
+
validateExplainOption(option.value, value);
|
|
149
|
+
if (option.value === "ANALYZE") {
|
|
150
|
+
analyze = value === undefined || value === "TRUE" || value === "ON";
|
|
151
|
+
}
|
|
152
|
+
if (scanner.consume(")"))
|
|
153
|
+
break;
|
|
154
|
+
if (!scanner.consume(","))
|
|
155
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
while (true) {
|
|
160
|
+
const option = scanner.peekWord();
|
|
161
|
+
if (option !== "ANALYZE" && option !== "VERBOSE")
|
|
162
|
+
break;
|
|
163
|
+
scanner.readWord();
|
|
164
|
+
if (seen.has(option))
|
|
165
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
166
|
+
seen.add(option);
|
|
167
|
+
if (option === "ANALYZE")
|
|
168
|
+
analyze = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const innerSql = sql.slice(scanner.triviaEnd());
|
|
172
|
+
if (!innerSql)
|
|
173
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
174
|
+
const inner = analyzeParsedSql(innerSql, "postgres");
|
|
175
|
+
if (!EXPLAIN_INNER_STATEMENTS.has(inner.statementType)) {
|
|
176
|
+
throw new StateQLError("INVALID_SQL", `EXPLAIN does not support ${inner.statementType.toUpperCase()} statements.`);
|
|
177
|
+
}
|
|
178
|
+
if (analyze && inner.statementType !== "select") {
|
|
179
|
+
throw new StateQLError("INVALID_SQL", "EXPLAIN ANALYZE accepts read-only SELECT statements only.");
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
ast: inner.ast,
|
|
183
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
184
|
+
statementType: "explain",
|
|
185
|
+
read: true,
|
|
186
|
+
unboundedMutation: false,
|
|
187
|
+
destructive: false,
|
|
188
|
+
ordered: false,
|
|
189
|
+
wrapForLimit: false,
|
|
190
|
+
cacheable: false,
|
|
191
|
+
requiresAutocommit: false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function validateExplainOption(option, value) {
|
|
195
|
+
if (EXPLAIN_BOOLEAN_OPTIONS.has(option)) {
|
|
196
|
+
if (value !== undefined && !BOOLEAN_VALUES.has(value))
|
|
197
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (option === "FORMAT" && value && EXPLAIN_FORMATS.has(value))
|
|
201
|
+
return;
|
|
202
|
+
if (option === "SERIALIZE" && value && EXPLAIN_SERIALIZE.has(value))
|
|
203
|
+
return;
|
|
204
|
+
invalidPostgresSyntax("EXPLAIN");
|
|
205
|
+
}
|
|
206
|
+
const VACUUM_OPTIONS = new Map([
|
|
207
|
+
["FULL", "boolean"],
|
|
208
|
+
["FREEZE", "boolean"],
|
|
209
|
+
["VERBOSE", "boolean"],
|
|
210
|
+
["ANALYZE", "boolean"],
|
|
211
|
+
["DISABLE_PAGE_SKIPPING", "boolean"],
|
|
212
|
+
["SKIP_LOCKED", "boolean"],
|
|
213
|
+
["INDEX_CLEANUP", new Set(["AUTO", "ON", "OFF"])],
|
|
214
|
+
["PROCESS_MAIN", "boolean"],
|
|
215
|
+
["PROCESS_TOAST", "boolean"],
|
|
216
|
+
["TRUNCATE", "boolean"],
|
|
217
|
+
["PARALLEL", "number"],
|
|
218
|
+
["SKIP_DATABASE_STATS", "boolean"],
|
|
219
|
+
["ONLY_DATABASE_STATS", "boolean"],
|
|
220
|
+
]);
|
|
221
|
+
const ANALYZE_OPTIONS = new Map([
|
|
222
|
+
["VERBOSE", "boolean"],
|
|
223
|
+
["SKIP_LOCKED", "boolean"],
|
|
224
|
+
]);
|
|
225
|
+
const REINDEX_OPTIONS = new Map([
|
|
226
|
+
["VERBOSE", "boolean"],
|
|
227
|
+
["TABLESPACE", "identifier"],
|
|
228
|
+
]);
|
|
229
|
+
const CLUSTER_OPTIONS = new Map([
|
|
230
|
+
["VERBOSE", "boolean"],
|
|
231
|
+
]);
|
|
232
|
+
function analyzePostgresMaintenance(sql, command) {
|
|
233
|
+
const parser = new UtilityParser(tokenizePostgresMaintenance(sql), command);
|
|
234
|
+
parser.expectWord(command);
|
|
235
|
+
switch (command) {
|
|
236
|
+
case "VACUUM":
|
|
237
|
+
parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
|
|
238
|
+
parser.optionalTargets();
|
|
239
|
+
break;
|
|
240
|
+
case "ANALYZE":
|
|
241
|
+
parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
|
|
242
|
+
parser.optionalTargets();
|
|
243
|
+
break;
|
|
244
|
+
case "REINDEX": {
|
|
245
|
+
parser.options(REINDEX_OPTIONS);
|
|
246
|
+
const target = parser.expectOneOf([
|
|
247
|
+
"INDEX",
|
|
248
|
+
"TABLE",
|
|
249
|
+
"SCHEMA",
|
|
250
|
+
"DATABASE",
|
|
251
|
+
"SYSTEM",
|
|
252
|
+
]);
|
|
253
|
+
if (target !== "SYSTEM")
|
|
254
|
+
parser.consumeWord("CONCURRENTLY");
|
|
255
|
+
if (target === "INDEX" || target === "TABLE") {
|
|
256
|
+
parser.qualifiedIdentifier();
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
parser.identifier();
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "CLUSTER":
|
|
264
|
+
parser.options(CLUSTER_OPTIONS, ["VERBOSE"]);
|
|
265
|
+
if (!parser.done()) {
|
|
266
|
+
parser.qualifiedIdentifier();
|
|
267
|
+
if (parser.consumeWord("USING"))
|
|
268
|
+
parser.identifier();
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
parser.expectDone();
|
|
273
|
+
return {
|
|
274
|
+
ast: { type: command.toLowerCase() },
|
|
275
|
+
normalized: sql.replace(/;\s*$/, ""),
|
|
276
|
+
statementType: command.toLowerCase(),
|
|
277
|
+
read: false,
|
|
278
|
+
unboundedMutation: false,
|
|
279
|
+
destructive: true,
|
|
280
|
+
ordered: false,
|
|
281
|
+
wrapForLimit: false,
|
|
282
|
+
cacheable: false,
|
|
283
|
+
requiresAutocommit: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function tokenizePostgresMaintenance(sql) {
|
|
287
|
+
const scanner = new PostgresPrefixScanner(sql);
|
|
288
|
+
const tokens = [];
|
|
289
|
+
while (scanner.triviaEnd() < sql.length) {
|
|
290
|
+
const character = sql[scanner.position];
|
|
291
|
+
if (character === ";") {
|
|
292
|
+
scanner.position += 1;
|
|
293
|
+
if (scanner.triviaEnd() !== sql.length) {
|
|
294
|
+
throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const word = scanner.readWord(false);
|
|
299
|
+
if (word) {
|
|
300
|
+
tokens.push({ kind: "word", value: word.value });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (character === '"') {
|
|
304
|
+
const end = postgresQuotedIdentifierEnd(sql, scanner.position);
|
|
305
|
+
if (end === undefined)
|
|
306
|
+
invalidPostgresSyntax("maintenance");
|
|
307
|
+
tokens.push({ kind: "identifier", value: sql.slice(scanner.position, end) });
|
|
308
|
+
scanner.position = end;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (/[0-9]/u.test(character)) {
|
|
312
|
+
const start = scanner.position;
|
|
313
|
+
scanner.position += 1;
|
|
314
|
+
while (/[0-9]/u.test(sql[scanner.position] ?? ""))
|
|
315
|
+
scanner.position += 1;
|
|
316
|
+
tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (["(", ")", ",", "."].includes(character)) {
|
|
320
|
+
tokens.push({ kind: "punctuation", value: character });
|
|
321
|
+
scanner.position += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
invalidPostgresSyntax("maintenance");
|
|
325
|
+
}
|
|
326
|
+
return tokens;
|
|
327
|
+
}
|
|
328
|
+
class UtilityParser {
|
|
329
|
+
tokens;
|
|
330
|
+
command;
|
|
331
|
+
index = 0;
|
|
332
|
+
constructor(tokens, command) {
|
|
333
|
+
this.tokens = tokens;
|
|
334
|
+
this.command = command;
|
|
335
|
+
}
|
|
336
|
+
done() {
|
|
337
|
+
return this.index >= this.tokens.length;
|
|
338
|
+
}
|
|
339
|
+
expectDone() {
|
|
340
|
+
if (!this.done())
|
|
341
|
+
invalidPostgresSyntax(this.command);
|
|
342
|
+
}
|
|
343
|
+
expectWord(word) {
|
|
344
|
+
if (!this.consumeWord(word))
|
|
345
|
+
invalidPostgresSyntax(this.command);
|
|
346
|
+
}
|
|
347
|
+
consumeWord(word) {
|
|
348
|
+
const token = this.tokens[this.index];
|
|
349
|
+
if (token?.kind !== "word" || token.value !== word)
|
|
350
|
+
return false;
|
|
351
|
+
this.index += 1;
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
expectOneOf(words) {
|
|
355
|
+
const token = this.tokens[this.index];
|
|
356
|
+
if (token?.kind !== "word" || !words.includes(token.value)) {
|
|
357
|
+
invalidPostgresSyntax(this.command);
|
|
358
|
+
}
|
|
359
|
+
this.index += 1;
|
|
360
|
+
return token.value;
|
|
361
|
+
}
|
|
362
|
+
options(options, legacy = []) {
|
|
363
|
+
if (this.consumePunctuation("(")) {
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
while (true) {
|
|
366
|
+
const option = this.tokens[this.index];
|
|
367
|
+
if (option?.kind !== "word" || seen.has(option.value)) {
|
|
368
|
+
invalidPostgresSyntax(this.command);
|
|
369
|
+
}
|
|
370
|
+
const kind = options.get(option.value);
|
|
371
|
+
if (!kind)
|
|
372
|
+
invalidPostgresSyntax(this.command);
|
|
373
|
+
seen.add(option.value);
|
|
374
|
+
this.index += 1;
|
|
375
|
+
const next = this.tokens[this.index];
|
|
376
|
+
if (next?.value !== "," && next?.value !== ")") {
|
|
377
|
+
this.optionValue(kind);
|
|
378
|
+
}
|
|
379
|
+
else if (kind !== "boolean") {
|
|
380
|
+
invalidPostgresSyntax(this.command);
|
|
381
|
+
}
|
|
382
|
+
if (this.consumePunctuation(")"))
|
|
383
|
+
return seen;
|
|
384
|
+
if (!this.consumePunctuation(","))
|
|
385
|
+
invalidPostgresSyntax(this.command);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const seen = new Set();
|
|
389
|
+
while (true) {
|
|
390
|
+
const option = this.tokens[this.index];
|
|
391
|
+
if (option?.kind !== "word" || !legacy.includes(option.value))
|
|
392
|
+
return seen;
|
|
393
|
+
if (seen.has(option.value))
|
|
394
|
+
invalidPostgresSyntax(this.command);
|
|
395
|
+
seen.add(option.value);
|
|
396
|
+
this.index += 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
optionalTargets() {
|
|
400
|
+
if (this.done())
|
|
401
|
+
return;
|
|
402
|
+
while (true) {
|
|
403
|
+
this.qualifiedIdentifier();
|
|
404
|
+
if (this.consumePunctuation("(")) {
|
|
405
|
+
this.identifier();
|
|
406
|
+
while (this.consumePunctuation(","))
|
|
407
|
+
this.identifier();
|
|
408
|
+
if (!this.consumePunctuation(")"))
|
|
409
|
+
invalidPostgresSyntax(this.command);
|
|
410
|
+
}
|
|
411
|
+
if (!this.consumePunctuation(","))
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
qualifiedIdentifier() {
|
|
416
|
+
this.identifier();
|
|
417
|
+
while (this.consumePunctuation("."))
|
|
418
|
+
this.identifier();
|
|
419
|
+
}
|
|
420
|
+
identifier() {
|
|
421
|
+
const token = this.tokens[this.index];
|
|
422
|
+
if (token?.kind !== "word" && token?.kind !== "identifier") {
|
|
423
|
+
invalidPostgresSyntax(this.command);
|
|
424
|
+
}
|
|
425
|
+
this.index += 1;
|
|
426
|
+
}
|
|
427
|
+
optionValue(kind) {
|
|
428
|
+
const token = this.tokens[this.index];
|
|
429
|
+
if (!token)
|
|
430
|
+
invalidPostgresSyntax(this.command);
|
|
431
|
+
if (kind === "boolean") {
|
|
432
|
+
if (token.kind !== "word" || !BOOLEAN_VALUES.has(token.value)) {
|
|
433
|
+
invalidPostgresSyntax(this.command);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (kind === "number") {
|
|
437
|
+
if (token.kind !== "number")
|
|
438
|
+
invalidPostgresSyntax(this.command);
|
|
439
|
+
}
|
|
440
|
+
else if (kind === "identifier") {
|
|
441
|
+
if (token.kind !== "word" && token.kind !== "identifier") {
|
|
442
|
+
invalidPostgresSyntax(this.command);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
else if (token.kind !== "word" || !kind.has(token.value)) {
|
|
446
|
+
invalidPostgresSyntax(this.command);
|
|
447
|
+
}
|
|
448
|
+
this.index += 1;
|
|
449
|
+
}
|
|
450
|
+
consumePunctuation(value) {
|
|
451
|
+
const token = this.tokens[this.index];
|
|
452
|
+
if (token?.kind !== "punctuation" || token.value !== value)
|
|
453
|
+
return false;
|
|
454
|
+
this.index += 1;
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
class PostgresPrefixScanner {
|
|
459
|
+
sql;
|
|
460
|
+
position = 0;
|
|
461
|
+
constructor(sql) {
|
|
462
|
+
this.sql = sql;
|
|
463
|
+
}
|
|
464
|
+
triviaEnd() {
|
|
465
|
+
while (this.position < this.sql.length) {
|
|
466
|
+
if (/\s/u.test(this.sql[this.position])) {
|
|
467
|
+
this.position += 1;
|
|
468
|
+
}
|
|
469
|
+
else if (this.sql.startsWith("--", this.position)) {
|
|
470
|
+
this.position = lineCommentEnd(this.sql, this.position + 2);
|
|
471
|
+
}
|
|
472
|
+
else if (this.sql.startsWith("/*", this.position)) {
|
|
473
|
+
const end = postgresBlockCommentEnd(this.sql, this.position + 2);
|
|
474
|
+
if (end === undefined) {
|
|
475
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
476
|
+
}
|
|
477
|
+
this.position = end;
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return this.position;
|
|
484
|
+
}
|
|
485
|
+
readWord(skipTrivia = true) {
|
|
486
|
+
if (skipTrivia)
|
|
487
|
+
this.triviaEnd();
|
|
488
|
+
const start = this.position;
|
|
489
|
+
if (!identifierStart(this.sql[start]))
|
|
490
|
+
return undefined;
|
|
491
|
+
this.position += 1;
|
|
492
|
+
while (identifierPart(this.sql[this.position]))
|
|
493
|
+
this.position += 1;
|
|
494
|
+
return {
|
|
495
|
+
value: this.sql.slice(start, this.position).toUpperCase(),
|
|
496
|
+
start,
|
|
497
|
+
end: this.position,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
peekWord() {
|
|
501
|
+
const position = this.position;
|
|
502
|
+
const value = this.readWord()?.value;
|
|
503
|
+
this.position = position;
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
peek() {
|
|
507
|
+
this.triviaEnd();
|
|
508
|
+
return this.sql[this.position];
|
|
509
|
+
}
|
|
510
|
+
consume(value) {
|
|
511
|
+
this.triviaEnd();
|
|
512
|
+
if (!this.sql.startsWith(value, this.position))
|
|
513
|
+
return false;
|
|
514
|
+
this.position += value.length;
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function invalidPostgresSyntax(command) {
|
|
519
|
+
throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
|
|
520
|
+
}
|
|
521
|
+
function postgresQuotedIdentifierEnd(sql, start) {
|
|
522
|
+
let index = start + 1;
|
|
523
|
+
while (index < sql.length) {
|
|
524
|
+
if (sql[index] !== '"') {
|
|
525
|
+
index += 1;
|
|
526
|
+
}
|
|
527
|
+
else if (sql[index + 1] === '"') {
|
|
528
|
+
index += 2;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
return index + 1;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
function postgresQuotedStringScanEnd(sql, start) {
|
|
537
|
+
let index = start + 1;
|
|
538
|
+
while (index < sql.length) {
|
|
539
|
+
if (sql[index] === "\\") {
|
|
540
|
+
index += 2;
|
|
541
|
+
}
|
|
542
|
+
else if (sql[index] !== "'") {
|
|
543
|
+
index += 1;
|
|
544
|
+
}
|
|
545
|
+
else if (sql[index + 1] === "'") {
|
|
546
|
+
index += 2;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
return index + 1;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return index;
|
|
553
|
+
}
|
|
554
|
+
function postgresBlockCommentEnd(sql, start) {
|
|
555
|
+
let depth = 1;
|
|
556
|
+
let index = start;
|
|
557
|
+
while (index < sql.length) {
|
|
558
|
+
if (sql.startsWith("/*", index)) {
|
|
559
|
+
depth += 1;
|
|
560
|
+
index += 2;
|
|
561
|
+
}
|
|
562
|
+
else if (sql.startsWith("*/", index)) {
|
|
563
|
+
depth -= 1;
|
|
564
|
+
index += 2;
|
|
565
|
+
if (depth === 0)
|
|
566
|
+
return index;
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
index += 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
87
574
|
function postgresParserSql(sql) {
|
|
88
575
|
const output = sql.split("");
|
|
89
576
|
const orderDepths = new Set();
|
|
@@ -97,13 +584,26 @@ function postgresParserSql(sql) {
|
|
|
97
584
|
continue;
|
|
98
585
|
}
|
|
99
586
|
if (sql.startsWith("/*", index)) {
|
|
100
|
-
|
|
587
|
+
const end = postgresBlockCommentEnd(sql, index + 2);
|
|
588
|
+
if (end === undefined) {
|
|
589
|
+
throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
|
|
590
|
+
}
|
|
591
|
+
index = end;
|
|
101
592
|
continue;
|
|
102
593
|
}
|
|
103
594
|
const character = sql[index];
|
|
104
|
-
if (character === "'"
|
|
595
|
+
if (character === "'") {
|
|
105
596
|
previousWord = undefined;
|
|
106
|
-
index =
|
|
597
|
+
index = postgresQuotedStringScanEnd(sql, index);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (character === '"') {
|
|
601
|
+
previousWord = undefined;
|
|
602
|
+
const end = postgresQuotedIdentifierEnd(sql, index);
|
|
603
|
+
if (end === undefined) {
|
|
604
|
+
throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
|
|
605
|
+
}
|
|
606
|
+
index = end;
|
|
107
607
|
continue;
|
|
108
608
|
}
|
|
109
609
|
if (character === "$") {
|
|
@@ -111,7 +611,10 @@ function postgresParserSql(sql) {
|
|
|
111
611
|
if (delimiter) {
|
|
112
612
|
previousWord = undefined;
|
|
113
613
|
const end = sql.indexOf(delimiter, index + delimiter.length);
|
|
114
|
-
|
|
614
|
+
if (end < 0) {
|
|
615
|
+
throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
|
|
616
|
+
}
|
|
617
|
+
index = end + delimiter.length;
|
|
115
618
|
continue;
|
|
116
619
|
}
|
|
117
620
|
}
|
|
@@ -205,24 +708,6 @@ function blockCommentEnd(sql, start) {
|
|
|
205
708
|
}
|
|
206
709
|
return index;
|
|
207
710
|
}
|
|
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
711
|
function identifierStart(value) {
|
|
227
712
|
return value !== undefined && /[A-Za-z_\u0080-\uFFFF]/u.test(value);
|
|
228
713
|
}
|
|
@@ -230,23 +715,35 @@ function identifierPart(value) {
|
|
|
230
715
|
return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
|
|
231
716
|
}
|
|
232
717
|
function selectContainsWrite(ast) {
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
718
|
+
const visited = new Set();
|
|
719
|
+
const writeTypes = new Set([
|
|
720
|
+
"insert",
|
|
721
|
+
"replace",
|
|
722
|
+
"update",
|
|
723
|
+
"delete",
|
|
724
|
+
"create",
|
|
725
|
+
"alter",
|
|
726
|
+
"drop",
|
|
727
|
+
"truncate",
|
|
728
|
+
]);
|
|
729
|
+
const visit = (value) => {
|
|
730
|
+
if (!value || typeof value !== "object")
|
|
242
731
|
return false;
|
|
243
|
-
|
|
244
|
-
if (!statement || typeof statement !== "object")
|
|
732
|
+
if (visited.has(value))
|
|
245
733
|
return false;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
734
|
+
visited.add(value);
|
|
735
|
+
if (Array.isArray(value))
|
|
736
|
+
return value.some(visit);
|
|
737
|
+
const details = value;
|
|
738
|
+
const type = typeof details.type === "string" ? details.type : undefined;
|
|
739
|
+
if (type && writeTypes.has(type))
|
|
249
740
|
return true;
|
|
250
|
-
|
|
251
|
-
|
|
741
|
+
if (type === "select") {
|
|
742
|
+
const into = details.into;
|
|
743
|
+
if (into?.type === "into" || into?.expr)
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
return Object.values(details).some(visit);
|
|
747
|
+
};
|
|
748
|
+
return visit(ast);
|
|
252
749
|
}
|
package/dist/src/stateql.js
CHANGED
|
@@ -672,6 +672,13 @@ export class StateQL {
|
|
|
672
672
|
if (!analysis.read) {
|
|
673
673
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
674
674
|
}
|
|
675
|
+
const cacheMode = options.cache ?? "auto";
|
|
676
|
+
if (!analysis.cacheable && cacheMode === "require") {
|
|
677
|
+
throw new StateQLError("CACHE_MISS", "This statement is not cacheable.", {
|
|
678
|
+
retryable: true,
|
|
679
|
+
suggestedAction: "Run with --cache auto or --cache bypass.",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
675
682
|
const parameters = options.params ?? [];
|
|
676
683
|
const context = this.executionContext(options);
|
|
677
684
|
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
@@ -689,8 +696,8 @@ export class StateQL {
|
|
|
689
696
|
stateVersion,
|
|
690
697
|
});
|
|
691
698
|
const cached = this.store.findResult(fingerprint);
|
|
692
|
-
|
|
693
|
-
|
|
699
|
+
if (analysis.cacheable &&
|
|
700
|
+
cacheMode !== "bypass" &&
|
|
694
701
|
cached &&
|
|
695
702
|
cached.row_count <= this.maxResultRows &&
|
|
696
703
|
this.cacheValid(cached, stateVersion, stateSignature)) {
|
|
@@ -709,7 +716,9 @@ export class StateQL {
|
|
|
709
716
|
suggestedAction: "Run with --cache auto or --cache bypass.",
|
|
710
717
|
});
|
|
711
718
|
}
|
|
712
|
-
const result = await adapter.read(
|
|
719
|
+
const result = await adapter.read(analysis.wrapForLimit
|
|
720
|
+
? boundedReadSql(sql, this.maxResultRows + 1)
|
|
721
|
+
: sql, parameters);
|
|
713
722
|
if (result.rows.length > this.maxResultRows) {
|
|
714
723
|
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
|
|
715
724
|
}
|
|
@@ -2200,12 +2209,19 @@ export class StateQL {
|
|
|
2200
2209
|
throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
|
|
2201
2210
|
}
|
|
2202
2211
|
const parameters = options.params ?? [];
|
|
2212
|
+
if (analysis.requiresAutocommit &&
|
|
2213
|
+
(options.expectedRows !== undefined || sqlParametersLength(parameters) > 0)) {
|
|
2214
|
+
throw new StateQLError("INVALID_SQL", "PostgreSQL maintenance statements do not accept StateQL parameters or row-count preconditions.");
|
|
2215
|
+
}
|
|
2216
|
+
const transactionId = session.active_transaction_id ?? undefined;
|
|
2217
|
+
if (analysis.requiresAutocommit && transactionId) {
|
|
2218
|
+
throw new StateQLError("TRANSACTION_FAILED", `${analysis.statementType.toUpperCase()} cannot be staged in a transaction.`, { suggestedAction: "Rollback or commit the staged transaction, then run the maintenance statement separately." });
|
|
2219
|
+
}
|
|
2203
2220
|
const fingerprint = hash({
|
|
2204
2221
|
sql: analysis.normalized,
|
|
2205
2222
|
parameters,
|
|
2206
2223
|
database: databaseIdentity(connection),
|
|
2207
2224
|
});
|
|
2208
|
-
const transactionId = session.active_transaction_id ?? undefined;
|
|
2209
2225
|
if (transactionId) {
|
|
2210
2226
|
const transaction = this.store.getTransaction(transactionId);
|
|
2211
2227
|
if (!transaction ||
|
|
@@ -2305,7 +2321,12 @@ export class StateQL {
|
|
|
2305
2321
|
});
|
|
2306
2322
|
}
|
|
2307
2323
|
try {
|
|
2308
|
-
|
|
2324
|
+
if (analysis.requiresAutocommit && !adapter.writeAutocommit) {
|
|
2325
|
+
throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires PostgreSQL autocommit execution.`, false);
|
|
2326
|
+
}
|
|
2327
|
+
const write = analysis.requiresAutocommit
|
|
2328
|
+
? await adapter.writeAutocommit(sql, parameters)
|
|
2329
|
+
: await adapter.write(sql, parameters, options.expectedRows);
|
|
2309
2330
|
try {
|
|
2310
2331
|
const finalized = planClaim
|
|
2311
2332
|
? this.store.finishPlannedOperation({
|
|
@@ -3083,6 +3104,11 @@ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId)
|
|
|
3083
3104
|
// A stale committing transaction is recovered as unknown after five minutes.
|
|
3084
3105
|
}
|
|
3085
3106
|
}
|
|
3107
|
+
function sqlParametersLength(parameters) {
|
|
3108
|
+
return Array.isArray(parameters)
|
|
3109
|
+
? parameters.length
|
|
3110
|
+
: Object.keys(parameters).length;
|
|
3111
|
+
}
|
|
3086
3112
|
function boundedReadSql(sql, limit) {
|
|
3087
3113
|
const statement = sql.trim().replace(/;\s*$/, "");
|
|
3088
3114
|
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fadhilp/stateql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Stateful, agent-oriented database CLI for safe result reuse",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"dist/src",
|
|
23
23
|
"README.md",
|
|
24
|
+
"SQL_COMMAND_ROADMAP.md",
|
|
24
25
|
"LICENSE"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|