provsql-studio 1.0.0__py3-none-any.whl
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.
- provsql_studio/__init__.py +2 -0
- provsql_studio/__main__.py +4 -0
- provsql_studio/app.py +667 -0
- provsql_studio/circuit.py +346 -0
- provsql_studio/cli.py +232 -0
- provsql_studio/db.py +1657 -0
- provsql_studio/static/app.css +2008 -0
- provsql_studio/static/app.js +2068 -0
- provsql_studio/static/circuit.js +1741 -0
- provsql_studio/static/colors_and_type.css +174 -0
- provsql_studio/static/fonts/EBGaramond-400-Italic-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-Italic-greek.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-Italic-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-Italic-latin.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-greek.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-400-latin.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-Italic-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-Italic-greek.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-Italic-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-Italic-latin.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-greek.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/EBGaramond-600-latin.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-400-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-400-greek.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-400-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-400-latin.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-400-symbols2.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-500-greek-ext.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-500-greek.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-500-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-500-latin.woff2 +0 -0
- provsql_studio/static/fonts/FiraCode-500-symbols2.woff2 +0 -0
- provsql_studio/static/fonts/Jost-400-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/Jost-400-latin.woff2 +0 -0
- provsql_studio/static/fonts/Jost-500-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/Jost-500-latin.woff2 +0 -0
- provsql_studio/static/fonts/Jost-600-latin-ext.woff2 +0 -0
- provsql_studio/static/fonts/Jost-600-latin.woff2 +0 -0
- provsql_studio/static/fonts/OFL-EBGaramond.txt +94 -0
- provsql_studio/static/fonts/OFL-FiraCode.txt +93 -0
- provsql_studio/static/fonts/OFL-Jost.txt +83 -0
- provsql_studio/static/fonts-face.css +198 -0
- provsql_studio/static/img/favicon.ico +0 -0
- provsql_studio/static/img/logo.png +0 -0
- provsql_studio/static/index.html +303 -0
- provsql_studio-1.0.0.dist-info/METADATA +143 -0
- provsql_studio-1.0.0.dist-info/RECORD +55 -0
- provsql_studio-1.0.0.dist-info/WHEEL +5 -0
- provsql_studio-1.0.0.dist-info/entry_points.txt +2 -0
- provsql_studio-1.0.0.dist-info/licenses/LICENSE +21 -0
- provsql_studio-1.0.0.dist-info/top_level.txt +1 -0
provsql_studio/app.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Flask app factory + routes for ProvSQL Studio.
|
|
2
|
+
|
|
3
|
+
Routes:
|
|
4
|
+
GET /, /where, /circuit – serve the shared shell with a body class.
|
|
5
|
+
GET /api/conn – current_user / current_database / host.
|
|
6
|
+
POST /api/conn – swap the active database (rebuilds the pool).
|
|
7
|
+
GET /api/databases – list databases the current user can connect to.
|
|
8
|
+
GET /api/relations – list provenance-tagged relations + content.
|
|
9
|
+
GET /api/schema – list all SELECT-able tables/views with their columns.
|
|
10
|
+
POST /api/exec – run a SQL batch; only the last statement's result is shown.
|
|
11
|
+
POST /api/cancel/<id> – pg_cancel_backend the batch in flight under that request id.
|
|
12
|
+
GET /api/circuit/<uuid> – BFS subgraph + dot-layout for a circuit root.
|
|
13
|
+
POST /api/circuit/<uuid>/expand – fetch a sub-DAG rooted at a frontier node.
|
|
14
|
+
GET /api/leaf/<uuid> – resolve an input gate back to its source row.
|
|
15
|
+
GET /api/config – read the four whitelisted GUCs.
|
|
16
|
+
POST /api/config – write one whitelisted GUC.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import uuid as uuid_mod
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import psycopg
|
|
26
|
+
import psycopg.conninfo
|
|
27
|
+
import sqlparse
|
|
28
|
+
from flask import Flask, jsonify, redirect, request, send_from_directory
|
|
29
|
+
|
|
30
|
+
from . import circuit as circuit_mod
|
|
31
|
+
from . import db
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
35
|
+
|
|
36
|
+
# Statements that are wrappable for where-mode (i.e. their last statement is
|
|
37
|
+
# a SELECT we can plug into `SELECT *, provenance(), where_provenance(...) FROM (<last>) t`).
|
|
38
|
+
_WRAPPABLE_RE = re.compile(r"^\s*(WITH|SELECT)\b", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app(
|
|
42
|
+
*,
|
|
43
|
+
dsn: str | None = None,
|
|
44
|
+
statement_timeout: str = "30s",
|
|
45
|
+
max_circuit_depth: int = 8,
|
|
46
|
+
max_circuit_nodes: int = 500,
|
|
47
|
+
max_sidebar_rows: int = 100,
|
|
48
|
+
max_result_rows: int = 1000,
|
|
49
|
+
search_path: str = "",
|
|
50
|
+
tool_search_path: str = "",
|
|
51
|
+
db_is_auto: bool = False,
|
|
52
|
+
) -> Flask:
|
|
53
|
+
app = Flask(__name__, static_folder=None) # we serve /static/ ourselves
|
|
54
|
+
app.config.update(
|
|
55
|
+
STATEMENT_TIMEOUT=statement_timeout,
|
|
56
|
+
MAX_CIRCUIT_DEPTH=max_circuit_depth,
|
|
57
|
+
MAX_CIRCUIT_NODES=max_circuit_nodes,
|
|
58
|
+
MAX_SIDEBAR_ROWS=max_sidebar_rows,
|
|
59
|
+
MAX_RESULT_ROWS=max_result_rows,
|
|
60
|
+
SEARCH_PATH=search_path,
|
|
61
|
+
TOOL_SEARCH_PATH=tool_search_path,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
app.config["DSN"] = dsn or ""
|
|
65
|
+
# True when the CLI couldn't infer a DB from --dsn or PG* env vars
|
|
66
|
+
# and fell back to the postgres maintenance DB. The /api/conn route
|
|
67
|
+
# surfaces this so the UI can prompt the user to pick a real DB
|
|
68
|
+
# via the top-nav switcher. Cleared once the user switches.
|
|
69
|
+
app.config["DB_IS_AUTO"] = bool(db_is_auto)
|
|
70
|
+
# Runtime overrides for the panel-managed GUCs (provsql.active and
|
|
71
|
+
# provsql.verbose_level). Applied as SET LOCAL on every batch so changes
|
|
72
|
+
# survive across pool checkouts. Toggle-managed GUCs go in their own
|
|
73
|
+
# request fields, not here.
|
|
74
|
+
#
|
|
75
|
+
# Loaded from ~/.config/provsql-studio/config.json so a Studio restart
|
|
76
|
+
# doesn't drop the user's chosen kill-switch / verbosity settings.
|
|
77
|
+
app.config["RUNTIME_GUCS"] = dict(db.load_persisted_gucs())
|
|
78
|
+
# Studio-level option overrides (Config-panel managed): persisted-on-disk
|
|
79
|
+
# values for max_circuit_depth and statement_timeout. They override the
|
|
80
|
+
# CLI defaults set above so the panel-set values survive restarts.
|
|
81
|
+
persisted_opts = db.load_persisted_options()
|
|
82
|
+
if "max_circuit_depth" in persisted_opts:
|
|
83
|
+
app.config["MAX_CIRCUIT_DEPTH"] = persisted_opts["max_circuit_depth"]
|
|
84
|
+
if "max_circuit_nodes" in persisted_opts:
|
|
85
|
+
app.config["MAX_CIRCUIT_NODES"] = persisted_opts["max_circuit_nodes"]
|
|
86
|
+
if "max_sidebar_rows" in persisted_opts:
|
|
87
|
+
app.config["MAX_SIDEBAR_ROWS"] = persisted_opts["max_sidebar_rows"]
|
|
88
|
+
if "max_result_rows" in persisted_opts:
|
|
89
|
+
app.config["MAX_RESULT_ROWS"] = persisted_opts["max_result_rows"]
|
|
90
|
+
if "statement_timeout_seconds" in persisted_opts:
|
|
91
|
+
app.config["STATEMENT_TIMEOUT"] = f"{persisted_opts['statement_timeout_seconds']}s"
|
|
92
|
+
if "search_path" in persisted_opts:
|
|
93
|
+
app.config["SEARCH_PATH"] = persisted_opts["search_path"]
|
|
94
|
+
if "tool_search_path" in persisted_opts:
|
|
95
|
+
app.config["TOOL_SEARCH_PATH"] = persisted_opts["tool_search_path"]
|
|
96
|
+
app.extensions["provsql_pool"] = db.make_pool(dsn)
|
|
97
|
+
# Registry of in-flight POST /api/exec batches, keyed by the
|
|
98
|
+
# client-generated request id. Lets POST /api/cancel/<id> resolve a
|
|
99
|
+
# request id to a backend pid and fire pg_cancel_backend on a
|
|
100
|
+
# separate connection while the original /api/exec is still
|
|
101
|
+
# blocked. Threaded Flask (cli.py) is required for that to work.
|
|
102
|
+
app.extensions["provsql_inflight"] = {
|
|
103
|
+
"lock": threading.Lock(),
|
|
104
|
+
"by_id": {}, # request_id -> pg_backend_pid
|
|
105
|
+
}
|
|
106
|
+
layout_cache = circuit_mod.LayoutCache()
|
|
107
|
+
|
|
108
|
+
# Routes read the live pool through this getter so swapping the pool
|
|
109
|
+
# (when the user switches database) is picked up without re-binding
|
|
110
|
+
# closures.
|
|
111
|
+
def get_pool():
|
|
112
|
+
return app.extensions["provsql_pool"]
|
|
113
|
+
|
|
114
|
+
# ──────── static + shell routes ────────
|
|
115
|
+
|
|
116
|
+
@app.get("/")
|
|
117
|
+
def root():
|
|
118
|
+
return redirect("/circuit", code=302)
|
|
119
|
+
|
|
120
|
+
@app.get("/where")
|
|
121
|
+
def where_shell():
|
|
122
|
+
return _serve_shell("where")
|
|
123
|
+
|
|
124
|
+
@app.get("/circuit")
|
|
125
|
+
def circuit_shell():
|
|
126
|
+
return _serve_shell("circuit")
|
|
127
|
+
|
|
128
|
+
@app.get("/static/<path:filename>")
|
|
129
|
+
def static_file(filename: str):
|
|
130
|
+
return send_from_directory(_STATIC_DIR, filename)
|
|
131
|
+
|
|
132
|
+
def _serve_shell(mode: str):
|
|
133
|
+
html = (_STATIC_DIR / "index.html").read_text()
|
|
134
|
+
# Replace the body class so app.js sees the correct mode and adjust
|
|
135
|
+
# asset URLs so /static/ resolves under Flask's mount.
|
|
136
|
+
html = html.replace('<body class="mode-where">', f'<body class="mode-{mode}">')
|
|
137
|
+
for asset in (
|
|
138
|
+
"fonts-face.css",
|
|
139
|
+
"colors_and_type.css",
|
|
140
|
+
"app.css",
|
|
141
|
+
"app.js",
|
|
142
|
+
"img/favicon.ico",
|
|
143
|
+
"img/logo.png",
|
|
144
|
+
):
|
|
145
|
+
html = html.replace(f'href="{asset}"', f'href="/static/{asset}"')
|
|
146
|
+
html = html.replace(f'src="{asset}"', f'src="/static/{asset}"')
|
|
147
|
+
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
|
148
|
+
|
|
149
|
+
# ──────── API routes ────────
|
|
150
|
+
|
|
151
|
+
@app.get("/api/conn")
|
|
152
|
+
def api_conn():
|
|
153
|
+
# Catch the OperationalError that the pool raises when PG is down
|
|
154
|
+
# (server stopped, network blip, auth revoked) so /api/conn
|
|
155
|
+
# responds with a structured 503 + human-readable reason instead
|
|
156
|
+
# of a bare Flask 500. The connectivity-poll on the front-end
|
|
157
|
+
# surfaces this string in the dot's tooltip.
|
|
158
|
+
try:
|
|
159
|
+
info = db.conn_info(get_pool())
|
|
160
|
+
except psycopg.OperationalError as e:
|
|
161
|
+
return jsonify({
|
|
162
|
+
"error": "database unreachable",
|
|
163
|
+
"reason": str(e).strip() or "cannot connect to PostgreSQL",
|
|
164
|
+
}), 503
|
|
165
|
+
# Display the path that user queries effectively see: the Studio
|
|
166
|
+
# override (Config panel) when set, else the session value, with
|
|
167
|
+
# provsql always pinned at the end. The front-end renders this
|
|
168
|
+
# as `<path> [lock]` to indicate provsql is enforced.
|
|
169
|
+
info["search_path"] = db.compose_search_path(
|
|
170
|
+
app.config.get("SEARCH_PATH", ""),
|
|
171
|
+
info["search_path"],
|
|
172
|
+
)
|
|
173
|
+
info["db_is_auto"] = app.config.get("DB_IS_AUTO", False)
|
|
174
|
+
# Send back a password-stripped DSN so the connection editor
|
|
175
|
+
# can prefill its input without leaking secrets to the page.
|
|
176
|
+
# The user re-types the password if they need to switch host
|
|
177
|
+
# or role.
|
|
178
|
+
try:
|
|
179
|
+
params = psycopg.conninfo.conninfo_to_dict(app.config.get("DSN", ""))
|
|
180
|
+
params.pop("password", None)
|
|
181
|
+
info["dsn"] = psycopg.conninfo.make_conninfo(**params)
|
|
182
|
+
except Exception:
|
|
183
|
+
info["dsn"] = ""
|
|
184
|
+
return jsonify(info)
|
|
185
|
+
|
|
186
|
+
@app.post("/api/conn")
|
|
187
|
+
def api_conn_switch():
|
|
188
|
+
payload = request.get_json(silent=True) or {}
|
|
189
|
+
new_dsn = payload.get("dsn")
|
|
190
|
+
target = payload.get("database")
|
|
191
|
+
dsn_no_db = False
|
|
192
|
+
if new_dsn and isinstance(new_dsn, str) and new_dsn.strip():
|
|
193
|
+
# Free-form DSN path: the user pasted a full conninfo string
|
|
194
|
+
# (host, port, user, password, options, ...). We open a fresh
|
|
195
|
+
# pool and probe it with SELECT 1 before swapping; if anything
|
|
196
|
+
# is wrong (auth, host unreachable, bad syntax) the old pool
|
|
197
|
+
# stays in service and the error reaches the front-end.
|
|
198
|
+
new_dsn = new_dsn.strip()
|
|
199
|
+
# If the user didn't specify dbname, default to the postgres
|
|
200
|
+
# maintenance DB and re-raise the auto-fallback banner so
|
|
201
|
+
# they can pick a real database from the switcher. Mirrors
|
|
202
|
+
# the CLI launch behaviour.
|
|
203
|
+
try:
|
|
204
|
+
params = psycopg.conninfo.conninfo_to_dict(new_dsn)
|
|
205
|
+
except Exception:
|
|
206
|
+
params = None
|
|
207
|
+
if params is not None and "dbname" not in params:
|
|
208
|
+
params["dbname"] = "postgres"
|
|
209
|
+
new_dsn = psycopg.conninfo.make_conninfo(**params)
|
|
210
|
+
dsn_no_db = True
|
|
211
|
+
try:
|
|
212
|
+
probe_pool = db.make_pool(new_dsn)
|
|
213
|
+
with probe_pool.connection() as c, c.cursor() as cur:
|
|
214
|
+
cur.execute("SELECT 1")
|
|
215
|
+
cur.fetchone()
|
|
216
|
+
except Exception as e:
|
|
217
|
+
try:
|
|
218
|
+
probe_pool.close()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
return jsonify({
|
|
222
|
+
"error": "cannot connect with the supplied DSN",
|
|
223
|
+
"reason": str(e).strip() or repr(e),
|
|
224
|
+
}), 400
|
|
225
|
+
new_pool = probe_pool
|
|
226
|
+
elif target and isinstance(target, str):
|
|
227
|
+
# Convenience path: swap dbname only, preserving the rest of
|
|
228
|
+
# the connection parameters. Used by the top-nav switcher.
|
|
229
|
+
accessible = db.list_databases(get_pool())
|
|
230
|
+
if target not in accessible:
|
|
231
|
+
return jsonify({"error": f"database {target!r} not accessible"}), 403
|
|
232
|
+
params = psycopg.conninfo.conninfo_to_dict(app.config["DSN"])
|
|
233
|
+
params["dbname"] = target
|
|
234
|
+
new_dsn = psycopg.conninfo.make_conninfo(**params)
|
|
235
|
+
new_pool = db.make_pool(new_dsn)
|
|
236
|
+
else:
|
|
237
|
+
return jsonify({"error": "missing 'dsn' or 'database'"}), 400
|
|
238
|
+
|
|
239
|
+
old_pool = app.extensions["provsql_pool"]
|
|
240
|
+
app.extensions["provsql_pool"] = new_pool
|
|
241
|
+
app.config["DSN"] = new_dsn
|
|
242
|
+
# The "no DB picked" hint reappears whenever we land on the
|
|
243
|
+
# postgres maintenance DB by default (here when the user
|
|
244
|
+
# supplied a DSN without a dbname). For an explicit dbname or a
|
|
245
|
+
# plain database-switch, drop it.
|
|
246
|
+
app.config["DB_IS_AUTO"] = dsn_no_db
|
|
247
|
+
layout_cache._store.clear()
|
|
248
|
+
try:
|
|
249
|
+
old_pool.close()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
return jsonify(db.conn_info(new_pool))
|
|
253
|
+
|
|
254
|
+
@app.get("/api/databases")
|
|
255
|
+
def api_databases():
|
|
256
|
+
return jsonify(db.list_databases(get_pool()))
|
|
257
|
+
|
|
258
|
+
@app.get("/api/relations")
|
|
259
|
+
def api_relations():
|
|
260
|
+
return jsonify(db.list_relations(
|
|
261
|
+
get_pool(),
|
|
262
|
+
max_rows=int(app.config["MAX_SIDEBAR_ROWS"]),
|
|
263
|
+
))
|
|
264
|
+
|
|
265
|
+
@app.get("/api/schema")
|
|
266
|
+
def api_schema():
|
|
267
|
+
return jsonify(db.list_schema(get_pool()))
|
|
268
|
+
|
|
269
|
+
@app.post("/api/exec")
|
|
270
|
+
def api_exec():
|
|
271
|
+
payload = request.get_json(silent=True) or {}
|
|
272
|
+
sql_text = payload.get("sql", "")
|
|
273
|
+
mode = payload.get("mode", "where")
|
|
274
|
+
request_id = str(payload.get("request_id") or "").strip()
|
|
275
|
+
|
|
276
|
+
statements = _split_statements(sql_text)
|
|
277
|
+
if not statements:
|
|
278
|
+
return jsonify({"blocks": []})
|
|
279
|
+
|
|
280
|
+
last = statements[-1]
|
|
281
|
+
wrap_last = mode == "where" and bool(_WRAPPABLE_RE.match(last))
|
|
282
|
+
|
|
283
|
+
# Toggles. In where mode `where_provenance` is forced on because the
|
|
284
|
+
# wrap calls `provsql.where_provenance(provsql.provenance())` and
|
|
285
|
+
# would otherwise return zero matches. In circuit mode both are
|
|
286
|
+
# user-controlled; defaults match the previous fixed behaviour.
|
|
287
|
+
where_prov = bool(payload.get("where_provenance", mode == "where"))
|
|
288
|
+
if mode == "where":
|
|
289
|
+
where_prov = True
|
|
290
|
+
update_prov = bool(payload.get("update_provenance", False))
|
|
291
|
+
|
|
292
|
+
inflight = app.extensions["provsql_inflight"]
|
|
293
|
+
registered = False
|
|
294
|
+
|
|
295
|
+
def register_pid(pid: int) -> None:
|
|
296
|
+
nonlocal registered
|
|
297
|
+
if not request_id:
|
|
298
|
+
return
|
|
299
|
+
with inflight["lock"]:
|
|
300
|
+
inflight["by_id"][request_id] = pid
|
|
301
|
+
registered = True
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
intermediate, final, meta = db.exec_batch(
|
|
305
|
+
get_pool(),
|
|
306
|
+
statements,
|
|
307
|
+
statement_timeout=app.config["STATEMENT_TIMEOUT"],
|
|
308
|
+
where_provenance=where_prov,
|
|
309
|
+
update_provenance=update_prov,
|
|
310
|
+
wrap_last=wrap_last,
|
|
311
|
+
extra_gucs=app.config["RUNTIME_GUCS"],
|
|
312
|
+
on_pid=register_pid,
|
|
313
|
+
search_path=app.config.get("SEARCH_PATH", ""),
|
|
314
|
+
tool_search_path=app.config.get("TOOL_SEARCH_PATH", ""),
|
|
315
|
+
max_result_rows=int(app.config["MAX_RESULT_ROWS"]),
|
|
316
|
+
)
|
|
317
|
+
finally:
|
|
318
|
+
if registered:
|
|
319
|
+
with inflight["lock"]:
|
|
320
|
+
inflight["by_id"].pop(request_id, None)
|
|
321
|
+
|
|
322
|
+
blocks = [r.to_dict() for r in intermediate]
|
|
323
|
+
if final is not None:
|
|
324
|
+
blocks.append(final.to_dict())
|
|
325
|
+
return jsonify({
|
|
326
|
+
"blocks": blocks,
|
|
327
|
+
"wrapped": meta["wrapped"],
|
|
328
|
+
"notices": meta.get("notices", []),
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
@app.post("/api/cancel/<request_id>")
|
|
332
|
+
def api_cancel(request_id: str):
|
|
333
|
+
# Fires pg_cancel_backend(pid) on a *fresh* connection (not from
|
|
334
|
+
# the pool) so we never wait for a slot that may itself be held
|
|
335
|
+
# by the very query we're trying to cancel. The cancel arrives
|
|
336
|
+
# at the running backend as a SIGINT, which the patched
|
|
337
|
+
# provsql_sigint_handler turns into the standard
|
|
338
|
+
# InterruptPending / QueryCancelPending pair, and PG ereports
|
|
339
|
+
# 57014 that exec_batch then surfaces as a normal error block.
|
|
340
|
+
inflight = app.extensions["provsql_inflight"]
|
|
341
|
+
with inflight["lock"]:
|
|
342
|
+
pid = inflight["by_id"].get(request_id)
|
|
343
|
+
if pid is None:
|
|
344
|
+
return jsonify({
|
|
345
|
+
"ok": False,
|
|
346
|
+
"reason": "no in-flight query for this id",
|
|
347
|
+
}), 404
|
|
348
|
+
try:
|
|
349
|
+
with psycopg.connect(app.config["DSN"]) as conn:
|
|
350
|
+
with conn.cursor() as cur:
|
|
351
|
+
cur.execute("SELECT pg_cancel_backend(%s)", (pid,))
|
|
352
|
+
ok = bool(cur.fetchone()[0])
|
|
353
|
+
return jsonify({"ok": ok})
|
|
354
|
+
except psycopg.Error as e:
|
|
355
|
+
return jsonify({
|
|
356
|
+
"ok": False,
|
|
357
|
+
"reason": str(e).strip(),
|
|
358
|
+
}), 500
|
|
359
|
+
|
|
360
|
+
@app.get("/api/circuit/<token>")
|
|
361
|
+
def api_circuit(token: str):
|
|
362
|
+
try:
|
|
363
|
+
root_uuid = _coerce_to_uuid(token)
|
|
364
|
+
except ValueError:
|
|
365
|
+
return jsonify({"error": "not a valid UUID or agg_token"}), 400
|
|
366
|
+
depth = _clamp_depth(request.args.get("depth"), app.config["MAX_CIRCUIT_DEPTH"])
|
|
367
|
+
return _layout_response(root_uuid, depth)
|
|
368
|
+
|
|
369
|
+
@app.post("/api/circuit/<token>/expand")
|
|
370
|
+
def api_circuit_expand(token: str):
|
|
371
|
+
# Path token is the original root (kept for client-side correlation),
|
|
372
|
+
# body carries the frontier we actually re-root the next BFS at.
|
|
373
|
+
del token # unused on the server; the frontier in the body is canonical
|
|
374
|
+
payload = request.get_json(silent=True) or {}
|
|
375
|
+
try:
|
|
376
|
+
frontier = _coerce_to_uuid(payload.get("frontier_node_uuid", ""))
|
|
377
|
+
except ValueError:
|
|
378
|
+
return jsonify({"error": "frontier_node_uuid is not a valid UUID"}), 400
|
|
379
|
+
depth = _clamp_depth(payload.get("additional_depth"), app.config["MAX_CIRCUIT_DEPTH"])
|
|
380
|
+
return _layout_response(frontier, depth)
|
|
381
|
+
|
|
382
|
+
def _layout_response(root: str, depth: int):
|
|
383
|
+
import psycopg
|
|
384
|
+
cached = layout_cache.get(root, depth)
|
|
385
|
+
if cached is not None:
|
|
386
|
+
return jsonify(cached)
|
|
387
|
+
try:
|
|
388
|
+
data = circuit_mod.get_circuit(
|
|
389
|
+
get_pool(), root=root, depth=depth, max_nodes=app.config["MAX_CIRCUIT_NODES"]
|
|
390
|
+
)
|
|
391
|
+
except circuit_mod.CircuitTooLarge as e:
|
|
392
|
+
return jsonify({
|
|
393
|
+
"error": "circuit too large",
|
|
394
|
+
"node_count": e.node_count,
|
|
395
|
+
"cap": e.cap,
|
|
396
|
+
"depth": e.depth,
|
|
397
|
+
"depth_1_size": e.depth_1_size,
|
|
398
|
+
"hint": "reduce depth or click into a specific node",
|
|
399
|
+
}), 413
|
|
400
|
+
except psycopg.errors.UndefinedFunction:
|
|
401
|
+
# The current database carries an older provsql that predates
|
|
402
|
+
# circuit_subgraph / resolve_input. Tell the user instead of
|
|
403
|
+
# leaking the raw "function ... does not exist" stack trace.
|
|
404
|
+
return jsonify({
|
|
405
|
+
"error": "circuit introspection unavailable on this database",
|
|
406
|
+
"hint": (
|
|
407
|
+
"The connected database has an older provsql installation "
|
|
408
|
+
"without provsql.circuit_subgraph. Upgrade the extension "
|
|
409
|
+
"(ALTER EXTENSION provsql UPDATE) or switch to a database "
|
|
410
|
+
"that has the current version."
|
|
411
|
+
),
|
|
412
|
+
}), 501
|
|
413
|
+
layout_cache.put(root, depth, data)
|
|
414
|
+
return jsonify(data)
|
|
415
|
+
|
|
416
|
+
@app.get("/api/leaf/<token>")
|
|
417
|
+
def api_leaf(token: str):
|
|
418
|
+
import psycopg
|
|
419
|
+
try:
|
|
420
|
+
uuid_str = _coerce_to_uuid(token)
|
|
421
|
+
except ValueError:
|
|
422
|
+
return jsonify({"error": "not a valid UUID"}), 400
|
|
423
|
+
try:
|
|
424
|
+
rows = circuit_mod.resolve_input(get_pool(), uuid_str)
|
|
425
|
+
except psycopg.errors.UndefinedFunction:
|
|
426
|
+
return jsonify({
|
|
427
|
+
"error": "leaf resolution unavailable on this database",
|
|
428
|
+
"hint": (
|
|
429
|
+
"The connected database has an older provsql installation "
|
|
430
|
+
"without provsql.resolve_input. Upgrade the extension or "
|
|
431
|
+
"switch to a database that has the current version."
|
|
432
|
+
),
|
|
433
|
+
}), 501
|
|
434
|
+
if not rows:
|
|
435
|
+
return jsonify({"error": "no row maps to this input gate"}), 404
|
|
436
|
+
# Best-effort probability: when set_prob has been called on the
|
|
437
|
+
# gate, surface the value alongside the resolved row so the
|
|
438
|
+
# inspector can show the per-row probability without a second
|
|
439
|
+
# round-trip. None when unset / inapplicable.
|
|
440
|
+
body = {"matches": rows}
|
|
441
|
+
probability = circuit_mod.get_prob(get_pool(), uuid_str)
|
|
442
|
+
if probability is not None:
|
|
443
|
+
body["probability"] = probability
|
|
444
|
+
# Single-relation case is the norm; if multiple tables share the UUID,
|
|
445
|
+
# return the list and let the front-end pick.
|
|
446
|
+
return jsonify(body)
|
|
447
|
+
|
|
448
|
+
@app.post("/api/set_prob")
|
|
449
|
+
def api_set_prob():
|
|
450
|
+
"""Write a probability for an input/update gate via
|
|
451
|
+
provsql.set_prob. Backs the inspector's click-to-edit affordance:
|
|
452
|
+
the user opens an input gate, clicks the displayed probability,
|
|
453
|
+
types a new value, hits Enter, and we fire this endpoint."""
|
|
454
|
+
import psycopg
|
|
455
|
+
payload = request.get_json(silent=True) or {}
|
|
456
|
+
try:
|
|
457
|
+
uuid_str = _coerce_to_uuid(payload.get("uuid", ""))
|
|
458
|
+
except ValueError:
|
|
459
|
+
return jsonify({"error": "not a valid UUID"}), 400
|
|
460
|
+
raw = payload.get("probability", None)
|
|
461
|
+
try:
|
|
462
|
+
p = float(raw)
|
|
463
|
+
except (TypeError, ValueError):
|
|
464
|
+
return jsonify({"error": "probability must be a number"}), 400
|
|
465
|
+
if not (0.0 <= p <= 1.0):
|
|
466
|
+
return jsonify({"error": "probability must be between 0 and 1"}), 400
|
|
467
|
+
try:
|
|
468
|
+
with get_pool().connection() as conn, conn.cursor() as cur:
|
|
469
|
+
cur.execute(
|
|
470
|
+
"SELECT provsql.set_prob(%s::uuid, %s::double precision)",
|
|
471
|
+
(uuid_str, p),
|
|
472
|
+
)
|
|
473
|
+
except psycopg.Error as e:
|
|
474
|
+
diag = getattr(e, "diag", None)
|
|
475
|
+
return jsonify({
|
|
476
|
+
"error": "set_prob failed",
|
|
477
|
+
"detail": (diag.message_primary if diag else str(e)) or str(e),
|
|
478
|
+
"sqlstate": diag.sqlstate if diag else None,
|
|
479
|
+
}), 400
|
|
480
|
+
return jsonify({"ok": True, "probability": p})
|
|
481
|
+
|
|
482
|
+
@app.get("/api/provenance_mappings")
|
|
483
|
+
def api_provenance_mappings():
|
|
484
|
+
# Used by the circuit-mode semiring evaluation strip to populate
|
|
485
|
+
# the mapping select; refreshed each time the panel opens, so
|
|
486
|
+
# newly-created mappings show up without a page reload.
|
|
487
|
+
return jsonify(db.list_provenance_mappings(get_pool()))
|
|
488
|
+
|
|
489
|
+
@app.get("/api/custom_semirings")
|
|
490
|
+
def api_custom_semirings():
|
|
491
|
+
# Discovered SQL/PL wrappers around `provenance_evaluate`.
|
|
492
|
+
# Populates the eval strip's "Custom Semirings" optgroup; refreshed
|
|
493
|
+
# whenever the panel opens.
|
|
494
|
+
return jsonify(db.list_custom_semirings(get_pool()))
|
|
495
|
+
|
|
496
|
+
@app.post("/api/evaluate")
|
|
497
|
+
def api_evaluate():
|
|
498
|
+
import psycopg
|
|
499
|
+
payload = request.get_json(silent=True) or {}
|
|
500
|
+
try:
|
|
501
|
+
token = _coerce_to_uuid(payload.get("token", ""))
|
|
502
|
+
except ValueError:
|
|
503
|
+
return jsonify({"error": "token is not a valid UUID"}), 400
|
|
504
|
+
semiring = (payload.get("semiring") or "").strip().lower()
|
|
505
|
+
mapping = payload.get("mapping") or None
|
|
506
|
+
method = payload.get("method") or None
|
|
507
|
+
arguments = payload.get("arguments") or None
|
|
508
|
+
function = payload.get("function") or None
|
|
509
|
+
try:
|
|
510
|
+
data = db.evaluate_circuit(
|
|
511
|
+
get_pool(),
|
|
512
|
+
token=token,
|
|
513
|
+
semiring=semiring,
|
|
514
|
+
mapping=mapping,
|
|
515
|
+
method=method,
|
|
516
|
+
arguments=arguments,
|
|
517
|
+
function=function,
|
|
518
|
+
statement_timeout=app.config["STATEMENT_TIMEOUT"],
|
|
519
|
+
search_path=app.config.get("SEARCH_PATH", ""),
|
|
520
|
+
tool_search_path=app.config.get("TOOL_SEARCH_PATH", ""),
|
|
521
|
+
)
|
|
522
|
+
except ValueError as e:
|
|
523
|
+
return jsonify({"error": str(e)}), 400
|
|
524
|
+
except psycopg.errors.UndefinedFunction as e:
|
|
525
|
+
# Older provsql or missing helper : surface the underlying
|
|
526
|
+
# diagnostic so the user can see which function is missing.
|
|
527
|
+
return jsonify({
|
|
528
|
+
"error": "evaluation function unavailable on this database",
|
|
529
|
+
"detail": str(e).splitlines()[0],
|
|
530
|
+
}), 501
|
|
531
|
+
except psycopg.Error as e:
|
|
532
|
+
diag = getattr(e, "diag", None)
|
|
533
|
+
return jsonify({
|
|
534
|
+
"error": "evaluation failed",
|
|
535
|
+
"sqlstate": diag.sqlstate if diag else None,
|
|
536
|
+
"detail": str(e).strip(),
|
|
537
|
+
}), 500
|
|
538
|
+
return jsonify(data)
|
|
539
|
+
|
|
540
|
+
_OPTION_KEYS = {
|
|
541
|
+
"max_circuit_depth",
|
|
542
|
+
"max_circuit_nodes",
|
|
543
|
+
"max_sidebar_rows",
|
|
544
|
+
"max_result_rows",
|
|
545
|
+
"statement_timeout_seconds",
|
|
546
|
+
"search_path",
|
|
547
|
+
"tool_search_path",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
def _current_options() -> dict:
|
|
551
|
+
# Surface the live values of the Studio-level options so the
|
|
552
|
+
# Config panel can display them after a restart, including those
|
|
553
|
+
# picked up from CLI flags rather than the on-disk config file.
|
|
554
|
+
timeout = str(app.config["STATEMENT_TIMEOUT"]).strip().lower()
|
|
555
|
+
# Best-effort parse of the timeout string; the CLI accepts any
|
|
556
|
+
# PG-parseable interval ("30s", "500ms", "1min"), but the panel
|
|
557
|
+
# stores it as plain seconds.
|
|
558
|
+
seconds: int | None = None
|
|
559
|
+
if timeout.endswith("ms"):
|
|
560
|
+
try:
|
|
561
|
+
seconds = max(1, int(timeout[:-2]) // 1000)
|
|
562
|
+
except ValueError:
|
|
563
|
+
pass
|
|
564
|
+
elif timeout.endswith("s") and not timeout.endswith("ms"):
|
|
565
|
+
try:
|
|
566
|
+
seconds = int(timeout[:-1])
|
|
567
|
+
except ValueError:
|
|
568
|
+
pass
|
|
569
|
+
elif timeout.endswith("min"):
|
|
570
|
+
try:
|
|
571
|
+
seconds = int(timeout[:-3]) * 60
|
|
572
|
+
except ValueError:
|
|
573
|
+
pass
|
|
574
|
+
return {
|
|
575
|
+
"max_circuit_depth": int(app.config["MAX_CIRCUIT_DEPTH"]),
|
|
576
|
+
"max_circuit_nodes": int(app.config["MAX_CIRCUIT_NODES"]),
|
|
577
|
+
"max_sidebar_rows": int(app.config["MAX_SIDEBAR_ROWS"]),
|
|
578
|
+
"max_result_rows": int(app.config["MAX_RESULT_ROWS"]),
|
|
579
|
+
"statement_timeout_seconds": seconds if seconds is not None else 30,
|
|
580
|
+
"search_path": app.config.get("SEARCH_PATH", "") or "",
|
|
581
|
+
"tool_search_path": app.config.get("TOOL_SEARCH_PATH", "") or "",
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@app.get("/api/config")
|
|
585
|
+
def api_config_get():
|
|
586
|
+
# Returns the *effective* values of the panel GUCs after our runtime
|
|
587
|
+
# overrides are applied, plus the bare overrides we hold in app
|
|
588
|
+
# state (so the front-end can show "modified" markers if it wants).
|
|
589
|
+
effective = db.show_panel_gucs(get_pool(), app.config["RUNTIME_GUCS"])
|
|
590
|
+
return jsonify({
|
|
591
|
+
"effective": effective,
|
|
592
|
+
"overrides": dict(app.config["RUNTIME_GUCS"]),
|
|
593
|
+
"options": _current_options(),
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
@app.post("/api/config")
|
|
597
|
+
def api_config_set():
|
|
598
|
+
payload = request.get_json(silent=True) or {}
|
|
599
|
+
name = payload.get("key", "")
|
|
600
|
+
value = payload.get("value", "")
|
|
601
|
+
# Studio-level options (not GUCs) are validated and stored in app
|
|
602
|
+
# config, then persisted alongside the GUC overrides.
|
|
603
|
+
if name in _OPTION_KEYS:
|
|
604
|
+
try:
|
|
605
|
+
key, canonical = db.validate_panel_option(name, value)
|
|
606
|
+
except ValueError as e:
|
|
607
|
+
return jsonify({"error": str(e)}), 400
|
|
608
|
+
if key == "max_circuit_depth":
|
|
609
|
+
app.config["MAX_CIRCUIT_DEPTH"] = canonical
|
|
610
|
+
elif key == "max_circuit_nodes":
|
|
611
|
+
app.config["MAX_CIRCUIT_NODES"] = canonical
|
|
612
|
+
# Drop any cached layouts: the cap change may unblock
|
|
613
|
+
# circuits that were 413'd at the old cap, and stale
|
|
614
|
+
# cache entries would still reflect the old result.
|
|
615
|
+
layout_cache.clear()
|
|
616
|
+
elif key == "max_sidebar_rows":
|
|
617
|
+
app.config["MAX_SIDEBAR_ROWS"] = canonical
|
|
618
|
+
elif key == "max_result_rows":
|
|
619
|
+
app.config["MAX_RESULT_ROWS"] = canonical
|
|
620
|
+
elif key == "statement_timeout_seconds":
|
|
621
|
+
app.config["STATEMENT_TIMEOUT"] = f"{canonical}s"
|
|
622
|
+
elif key == "search_path":
|
|
623
|
+
app.config["SEARCH_PATH"] = canonical
|
|
624
|
+
elif key == "tool_search_path":
|
|
625
|
+
app.config["TOOL_SEARCH_PATH"] = canonical
|
|
626
|
+
db.save_persisted_options(_current_options())
|
|
627
|
+
return jsonify({"ok": True, "key": key, "value": canonical})
|
|
628
|
+
# Otherwise treat as a GUC override.
|
|
629
|
+
try:
|
|
630
|
+
canonical = db.validate_panel_guc(name, value)
|
|
631
|
+
except ValueError as e:
|
|
632
|
+
return jsonify({"error": str(e)}), 400
|
|
633
|
+
app.config["RUNTIME_GUCS"][name] = canonical
|
|
634
|
+
# Best-effort persist so a Studio restart keeps the user's choice.
|
|
635
|
+
db.save_persisted_gucs(app.config["RUNTIME_GUCS"])
|
|
636
|
+
return jsonify({"ok": True, "key": name, "value": canonical})
|
|
637
|
+
|
|
638
|
+
return app
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _coerce_to_uuid(token: str) -> str:
|
|
642
|
+
"""Accept a UUID string (any case, with or without hyphens) and return its
|
|
643
|
+
canonical 36-char form. Raises ValueError otherwise. Front-end agg_token
|
|
644
|
+
cells should send the underlying UUID, not the formatted '<value> (*)'
|
|
645
|
+
text; agg_token's text representation does not carry the UUID."""
|
|
646
|
+
if not token:
|
|
647
|
+
raise ValueError("empty token")
|
|
648
|
+
return str(uuid_mod.UUID(token))
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _clamp_depth(raw, default_max: int) -> int:
|
|
652
|
+
try:
|
|
653
|
+
d = int(raw) if raw is not None and str(raw) != "" else default_max
|
|
654
|
+
except (TypeError, ValueError):
|
|
655
|
+
d = default_max
|
|
656
|
+
return max(1, min(d, default_max))
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _split_statements(sql_text: str) -> list[str]:
|
|
660
|
+
"""Split a SQL batch into individual statements. sqlparse handles
|
|
661
|
+
dollar-quoting, comments, and string literals correctly."""
|
|
662
|
+
out: list[str] = []
|
|
663
|
+
for raw in sqlparse.split(sql_text):
|
|
664
|
+
stripped = raw.strip().rstrip(";").strip()
|
|
665
|
+
if stripped:
|
|
666
|
+
out.append(stripped)
|
|
667
|
+
return out
|