Skip to content

Commit c1f3fcc

Browse files
authored
Merge pull request #115 from Maxteabag/warning-operationsf
Add query alert mode
2 parents 0779d12 + 47073f3 commit c1f3fcc

File tree

10 files changed

+334
-34
lines changed

10 files changed

+334
-34
lines changed

config/settings.template.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"process_worker_warm_on_idle": true,
88
"process_worker_auto_shutdown_s": 0,
99
"ui_stall_watchdog_ms": 0,
10+
"query_alert_mode": 0,
1011
"allow_plaintext_credentials": false,
1112
"mock": {
1213
"enabled": true,

sqlit/domains/query/app/alerts.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Query alert classification and alert mode helpers."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from enum import IntEnum
7+
8+
from sqlit.domains.query.app.multi_statement import split_statements
9+
10+
11+
class AlertMode(IntEnum):
12+
"""Alert mode thresholds (user-configured)."""
13+
14+
OFF = 0
15+
DELETE = 1
16+
WRITE = 2
17+
18+
19+
class AlertSeverity(IntEnum):
20+
"""Severity classification for a query."""
21+
22+
NONE = 0
23+
WRITE = 1
24+
DELETE = 2
25+
26+
27+
_DELETE_KEYWORDS = ("DELETE",)
28+
_WRITE_KEYWORDS = (
29+
"CREATE",
30+
"ALTER",
31+
"DROP",
32+
"TRUNCATE",
33+
"RENAME",
34+
"INSERT",
35+
"UPDATE",
36+
"MERGE",
37+
"REPLACE",
38+
"UPSERT",
39+
"DELETE",
40+
)
41+
42+
_DELETE_RE = re.compile(r"\b(?:%s)\b" % "|".join(_DELETE_KEYWORDS), re.IGNORECASE)
43+
_WRITE_RE = re.compile(r"\b(?:%s)\b" % "|".join(_WRITE_KEYWORDS), re.IGNORECASE)
44+
45+
_SINGLE_QUOTE_RE = re.compile(r"'[^']*'")
46+
_DOUBLE_QUOTE_RE = re.compile(r'"[^"]*"')
47+
_BACKTICK_RE = re.compile(r"`[^`]*`")
48+
_BRACKET_RE = re.compile(r"\[[^\]]*]")
49+
_LINE_COMMENT_RE = re.compile(r"--[^\n]*")
50+
_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
51+
52+
53+
def parse_alert_mode(value: str | int | None) -> AlertMode | None:
54+
"""Parse a user-provided alert mode value."""
55+
if value is None:
56+
return None
57+
if isinstance(value, int):
58+
return _coerce_alert_mode(value)
59+
raw = str(value).strip().lower()
60+
if not raw:
61+
return None
62+
if raw in {"0", "off", "none", "disable", "disabled"}:
63+
return AlertMode.OFF
64+
if raw in {"1", "delete", "destructive", "danger"}:
65+
return AlertMode.DELETE
66+
if raw in {"2", "write", "writes", "edit", "update"}:
67+
return AlertMode.WRITE
68+
return None
69+
70+
71+
def format_alert_mode(mode: AlertMode) -> str:
72+
"""Human-readable alert mode."""
73+
if mode == AlertMode.DELETE:
74+
return "delete"
75+
if mode == AlertMode.WRITE:
76+
return "write"
77+
return "off"
78+
79+
80+
def should_confirm(mode: AlertMode, severity: AlertSeverity) -> bool:
81+
"""Return True if the given severity should prompt confirmation."""
82+
if mode == AlertMode.DELETE:
83+
return severity == AlertSeverity.DELETE
84+
if mode == AlertMode.WRITE:
85+
return severity in {AlertSeverity.WRITE, AlertSeverity.DELETE}
86+
return False
87+
88+
89+
def classify_query_alert(sql: str) -> AlertSeverity:
90+
"""Classify a SQL query for alerting."""
91+
if not sql:
92+
return AlertSeverity.NONE
93+
highest = AlertSeverity.NONE
94+
for statement in split_statements(sql):
95+
severity = _classify_statement(statement)
96+
if severity == AlertSeverity.DELETE:
97+
return severity
98+
if severity.value > highest.value:
99+
highest = severity
100+
return highest
101+
102+
103+
def _classify_statement(statement: str) -> AlertSeverity:
104+
cleaned = _strip_comments_and_literals(statement)
105+
if not cleaned:
106+
return AlertSeverity.NONE
107+
if _DELETE_RE.search(cleaned):
108+
return AlertSeverity.DELETE
109+
if _WRITE_RE.search(cleaned):
110+
return AlertSeverity.WRITE
111+
return AlertSeverity.NONE
112+
113+
114+
def _strip_comments_and_literals(sql: str) -> str:
115+
"""Remove comments and quoted literals/identifiers for keyword scanning."""
116+
cleaned = _LINE_COMMENT_RE.sub("", sql)
117+
cleaned = _BLOCK_COMMENT_RE.sub("", cleaned)
118+
cleaned = _SINGLE_QUOTE_RE.sub("''", cleaned)
119+
cleaned = _DOUBLE_QUOTE_RE.sub('""', cleaned)
120+
cleaned = _BACKTICK_RE.sub("``", cleaned)
121+
cleaned = _BRACKET_RE.sub("[]", cleaned)
122+
return cleaned
123+
124+
125+
def _coerce_alert_mode(value: int) -> AlertMode | None:
126+
try:
127+
mode = AlertMode(int(value))
128+
except (TypeError, ValueError):
129+
return None
130+
if mode in {AlertMode.OFF, AlertMode.DELETE, AlertMode.WRITE}:
131+
return mode
132+
return None

sqlit/domains/query/ui/mixins/query_execution.py

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING, Any, Callable
66

77
from sqlit.domains.explorer.ui.tree import db_switching as tree_db_switching
88
from sqlit.domains.process_worker.ui.mixins.process_worker_lifecycle import (
@@ -58,16 +58,19 @@ def action_execute_query_atomic(self: QueryMixinHost) -> None:
5858
self.notify("No query to execute", severity="warning")
5959
return
6060

61-
if hasattr(self, "_query_worker") and self._query_worker is not None:
62-
self._query_worker.cancel()
61+
def _proceed() -> None:
62+
if hasattr(self, "_query_worker") and self._query_worker is not None:
63+
self._query_worker.cancel()
6364

64-
self._start_query_spinner()
65+
self._start_query_spinner()
6566

66-
self._query_worker = self.run_worker(
67-
self._run_query_atomic_async(query),
68-
name="query_execution_atomic",
69-
exclusive=True,
70-
)
67+
self._query_worker = self.run_worker(
68+
self._run_query_atomic_async(query),
69+
name="query_execution_atomic",
70+
exclusive=True,
71+
)
72+
73+
self._maybe_confirm_query(query, _proceed)
7174

7275
def action_execute_single_statement(self: QueryMixinHost) -> None:
7376
"""Execute only the SQL statement at the current cursor position."""
@@ -96,16 +99,19 @@ def action_execute_single_statement(self: QueryMixinHost) -> None:
9699
self.notify("No statement found at cursor", severity="warning")
97100
return
98101

99-
if hasattr(self, "_query_worker") and self._query_worker is not None:
100-
self._query_worker.cancel()
102+
def _proceed() -> None:
103+
if hasattr(self, "_query_worker") and self._query_worker is not None:
104+
self._query_worker.cancel()
101105

102-
self._start_query_spinner()
106+
self._start_query_spinner()
103107

104-
self._query_worker = self.run_worker(
105-
self._run_query_async(statement, keep_insert_mode=False),
106-
name="query_execution_single",
107-
exclusive=True,
108-
)
108+
self._query_worker = self.run_worker(
109+
self._run_query_async(statement, keep_insert_mode=False),
110+
name="query_execution_single",
111+
exclusive=True,
112+
)
113+
114+
self._maybe_confirm_query(statement, _proceed)
109115

110116
def _execute_query_common(self: QueryMixinHost, keep_insert_mode: bool) -> None:
111117
"""Common query execution logic."""
@@ -119,15 +125,71 @@ def _execute_query_common(self: QueryMixinHost, keep_insert_mode: bool) -> None:
119125
self.notify("No query to execute", severity="warning")
120126
return
121127

122-
if hasattr(self, "_query_worker") and self._query_worker is not None:
123-
self._query_worker.cancel()
128+
def _proceed() -> None:
129+
if hasattr(self, "_query_worker") and self._query_worker is not None:
130+
self._query_worker.cancel()
131+
132+
self._start_query_spinner()
133+
134+
self._query_worker = self.run_worker(
135+
self._run_query_async(query, keep_insert_mode),
136+
name="query_execution",
137+
exclusive=True,
138+
)
139+
140+
self._maybe_confirm_query(query, _proceed)
141+
142+
def _maybe_confirm_query(self: QueryMixinHost, query: str, proceed: Callable[[], None]) -> None:
143+
"""Confirm query execution based on alert mode, then call proceed."""
144+
from sqlit.domains.query.app.alerts import (
145+
AlertMode,
146+
AlertSeverity,
147+
classify_query_alert,
148+
format_alert_mode,
149+
should_confirm,
150+
)
151+
from sqlit.shared.ui.screens.confirm import ConfirmScreen
124152

125-
self._start_query_spinner()
153+
raw_mode = getattr(self.services.runtime, "query_alert_mode", 0) or 0
154+
try:
155+
mode = AlertMode(int(raw_mode))
156+
except ValueError:
157+
mode = AlertMode.OFF
158+
159+
if mode == AlertMode.OFF:
160+
proceed()
161+
return
162+
163+
severity = classify_query_alert(query)
164+
if severity == AlertSeverity.NONE or not should_confirm(mode, severity):
165+
proceed()
166+
return
167+
168+
title = "Confirm query"
169+
if severity == AlertSeverity.DELETE:
170+
title = "Confirm DELETE query"
171+
elif severity == AlertSeverity.WRITE:
172+
title = "Confirm write query"
173+
174+
description = None
175+
snippet = query.strip().splitlines()[0] if query.strip() else ""
176+
if snippet:
177+
if len(snippet) > 120:
178+
snippet = snippet[:117] + "..."
179+
description = snippet
180+
181+
def _on_result(confirmed: bool | None) -> None:
182+
if confirmed:
183+
proceed()
184+
return
185+
self.notify(
186+
f"Query cancelled (alert mode: {format_alert_mode(mode)})",
187+
severity="warning",
188+
)
126189

127-
self._query_worker = self.run_worker(
128-
self._run_query_async(query, keep_insert_mode),
129-
name="query_execution",
130-
exclusive=True,
190+
self.push_screen(
191+
ConfirmScreen(title, description, yes_label="Run", no_label="Cancel"),
192+
_on_result,
131193
)
132194

133195
def _start_query_spinner(self: QueryMixinHost) -> None:

sqlit/domains/shell/app/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from .router import dispatch_command, register_command_handler
6+
from . import alert as _alert
67
from . import credentials as _credentials
78
from . import debug as _debug
89
from . import watchdog as _watchdog
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Query alert mode command handlers."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from sqlit.domains.query.app.alerts import AlertMode, format_alert_mode, parse_alert_mode
8+
9+
from .router import register_command_handler
10+
11+
12+
def _handle_alert_command(app: Any, cmd: str, args: list[str]) -> bool:
13+
if cmd not in {"alert", "alerts"}:
14+
return False
15+
16+
value = args[0].lower() if args else ""
17+
if not value:
18+
_show_alert_status(app)
19+
return True
20+
21+
mode = parse_alert_mode(value)
22+
if mode is None:
23+
app.notify("Usage: :alert off|delete|write", severity="warning")
24+
return True
25+
26+
_set_alert_mode(app, mode)
27+
return True
28+
29+
30+
def _show_alert_status(app: Any) -> None:
31+
mode = _get_alert_mode(app)
32+
label = format_alert_mode(mode)
33+
app.notify(f"Query alerts: {label}")
34+
35+
36+
def _set_alert_mode(app: Any, mode: AlertMode) -> None:
37+
app.services.runtime.query_alert_mode = int(mode)
38+
try:
39+
app.services.settings_store.set("query_alert_mode", int(mode))
40+
except Exception:
41+
pass
42+
app.notify(f"Query alerts set to {format_alert_mode(mode)}")
43+
44+
45+
def _get_alert_mode(app: Any) -> AlertMode:
46+
raw = getattr(app.services.runtime, "query_alert_mode", 0) or 0
47+
return AlertMode(int(raw))
48+
49+
50+
register_command_handler(_handle_alert_command)

sqlit/domains/shell/app/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,7 @@ def _show_command_list(self) -> None:
748748
"Use 'plaintext' to store passwords in ~/.sqlit/ (protected folder), 'keyring' to use system keyring.",
749749
),
750750
("Appearance", ":theme", "Open theme selection", ""),
751+
("Query", ":alert off|delete|write", "Confirm before risky queries", "Modes: off, delete, write"),
751752
("Query", ":run, :r", "Execute query", ""),
752753
("Query", ":run!, :r!", "Execute query (stay in INSERT)", ""),
753754
(

sqlit/domains/shell/app/startup_flow.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,12 @@ def run_on_mount(app: AppProtocol) -> None:
5656
)
5757
except (TypeError, ValueError):
5858
app.services.runtime.ui_stall_watchdog_ms = 0.0
59-
if "show_line_numbers" in settings:
60-
try:
61-
app.query_input.show_line_numbers = bool(settings.get("show_line_numbers"))
62-
except Exception:
63-
pass
64-
if "relative_line_numbers" in settings:
65-
try:
66-
app.query_input.relative_line_numbers = bool(settings.get("relative_line_numbers"))
67-
except Exception:
68-
pass
59+
if "query_alert_mode" in settings:
60+
from sqlit.domains.query.app.alerts import parse_alert_mode
61+
62+
mode = parse_alert_mode(settings.get("query_alert_mode"))
63+
if mode is not None:
64+
app.services.runtime.query_alert_mode = int(mode)
6965
app._startup_stamp("settings_applied")
7066

7167
apply_mock_settings(app, settings)

sqlit/domains/shell/state/machine.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,5 +310,16 @@ def binding(key: str, desc: str, indent: int = 4) -> str:
310310
lines.append(section("COMMAND MODE"))
311311
lines.append(binding(":", "Enter command mode"))
312312
lines.append(binding(":commands", "Show command list"))
313+
lines.append("")
314+
315+
# ═══════════════════════════════════════════════════════════════════
316+
# SETTINGS
317+
# ═══════════════════════════════════════════════════════════════════
318+
lines.append(section("SETTINGS"))
319+
lines.append(binding(":alert off|delete|write", "Confirm risky queries"))
320+
lines.append(binding(":set number, :set nu", "Show line numbers"))
321+
lines.append(binding(":set nonumber, :set nonu", "Hide line numbers"))
322+
lines.append(binding(":set relativenumber, :set rnu", "Show relative line numbers"))
323+
lines.append(binding(":set norelativenumber, :set nornu", "Show absolute line numbers"))
313324

314325
return "\n".join(lines)

sqlit/shared/app/runtime.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class RuntimeConfig:
4343
process_worker_warm_on_idle: bool = True
4444
process_worker_auto_shutdown_s: float = 0.0
4545
ui_stall_watchdog_ms: float = 0.0
46+
query_alert_mode: int = 0
4647
mock: MockConfig = field(default_factory=MockConfig)
4748

4849
@classmethod

0 commit comments

Comments
 (0)