dbctl 0.7.4__tar.gz → 0.7.7__tar.gz

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.
Files changed (87) hide show
  1. {dbctl-0.7.4 → dbctl-0.7.7}/CHANGELOG.md +80 -0
  2. {dbctl-0.7.4 → dbctl-0.7.7}/PKG-INFO +23 -5
  3. {dbctl-0.7.4 → dbctl-0.7.7}/README.md +22 -4
  4. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/cli.py +287 -2
  5. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/runtime.py +61 -11
  6. dbctl-0.7.7/docs/execute.md +149 -0
  7. {dbctl-0.7.4 → dbctl-0.7.7}/pyproject.toml +1 -1
  8. dbctl-0.7.7/tests/test_execute.py +454 -0
  9. {dbctl-0.7.4 → dbctl-0.7.7}/uv.lock +1 -1
  10. {dbctl-0.7.4 → dbctl-0.7.7}/.dbctl/connections.yaml +0 -0
  11. {dbctl-0.7.4 → dbctl-0.7.7}/.dbctl/operations.yaml +0 -0
  12. {dbctl-0.7.4 → dbctl-0.7.7}/.github/workflows/ci.yml +0 -0
  13. {dbctl-0.7.4 → dbctl-0.7.7}/.github-local/ci.yml +0 -0
  14. {dbctl-0.7.4 → dbctl-0.7.7}/.gitignore +0 -0
  15. {dbctl-0.7.4 → dbctl-0.7.7}/Makefile +0 -0
  16. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/__init__.py +0 -0
  17. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/__main__.py +0 -0
  18. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/audit.py +0 -0
  19. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/config.py +0 -0
  20. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/connections.py +0 -0
  21. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/db.py +0 -0
  22. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/execute.py +0 -0
  23. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/init.py +0 -0
  24. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/multi.py +0 -0
  25. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/operations.py +0 -0
  26. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/refs.py +0 -0
  27. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/reports.py +0 -0
  28. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/__init__.py +0 -0
  29. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/azure.py +0 -0
  30. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/base.py +0 -0
  31. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/direct.py +0 -0
  32. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/gcp.py +0 -0
  33. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/k8s.py +0 -0
  34. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/ssh.py +0 -0
  35. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/ssm.py +0 -0
  36. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/__init__.py +0 -0
  37. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/app.py +0 -0
  38. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/connection_tree.py +0 -0
  39. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/editor_tab.py +0 -0
  40. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/grouping.py +0 -0
  41. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/operation_tab.py +0 -0
  42. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/registry.py +0 -0
  43. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/results.py +0 -0
  44. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/schema.py +0 -0
  45. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/screens.py +0 -0
  46. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/session.py +0 -0
  47. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/splitter.py +0 -0
  48. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/sql_templates.py +0 -0
  49. {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/tabs.py +0 -0
  50. {dbctl-0.7.4 → dbctl-0.7.7}/docker-compose.yml +0 -0
  51. {dbctl-0.7.4 → dbctl-0.7.7}/docs/ACTION_OUTPUT.md +0 -0
  52. {dbctl-0.7.4 → dbctl-0.7.7}/docs/DESIGN.md +0 -0
  53. {dbctl-0.7.4 → dbctl-0.7.7}/docs/SESSION_STATE.md +0 -0
  54. {dbctl-0.7.4 → dbctl-0.7.7}/docs/connections.md +0 -0
  55. {dbctl-0.7.4 → dbctl-0.7.7}/docs/logo.png +0 -0
  56. {dbctl-0.7.4 → dbctl-0.7.7}/docs/logo_small.png +0 -0
  57. {dbctl-0.7.4 → dbctl-0.7.7}/docs/operations.md +0 -0
  58. {dbctl-0.7.4 → dbctl-0.7.7}/docs/tui.md +0 -0
  59. {dbctl-0.7.4 → dbctl-0.7.7}/docs/tutorial.md +0 -0
  60. {dbctl-0.7.4 → dbctl-0.7.7}/seed/mssql.sql +0 -0
  61. {dbctl-0.7.4 → dbctl-0.7.7}/seed/mysql.sql +0 -0
  62. {dbctl-0.7.4 → dbctl-0.7.7}/seed/postgres.sql +0 -0
  63. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_azure_tunnel.py +0 -0
  64. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_bastion_tags.py +0 -0
  65. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_connections_loader.py +0 -0
  66. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_copy_features.py +0 -0
  67. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_gcp_tunnel.py +0 -0
  68. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_k8s_tunnel.py +0 -0
  69. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_refs.py +0 -0
  70. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_regressions.py +0 -0
  71. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_smoke.py +0 -0
  72. {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_sso_cache.py +0 -0
  73. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/conftest.py +0 -0
  74. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_app.py +0 -0
  75. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_connection_tree.py +0 -0
  76. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_connection_tree_grouping.py +0 -0
  77. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_editor_tab.py +0 -0
  78. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_grouping.py +0 -0
  79. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_operation_launcher.py +0 -0
  80. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_operation_tab.py +0 -0
  81. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_resize.py +0 -0
  82. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_schema.py +0 -0
  83. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_screens.py +0 -0
  84. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_session.py +0 -0
  85. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_sql_templates.py +0 -0
  86. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_status_bar_and_loading.py +0 -0
  87. {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_tab_resize.py +0 -0
@@ -5,6 +5,86 @@ All notable changes to this project will be documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.7] — 2026-08-10
9
+
10
+ ### Fixed
11
+
12
+ - **`ruff format` failed in CI** on the v0.7.6 commit — three files
13
+ (`dbctl/cli.py`, `dbctl/runtime.py`, `tests/test_execute.py`) had
14
+ manually-typed signatures that fit on one line per `ruff format`'s
15
+ rules but were wrapped in the source. Reformatted to the canonical
16
+ layout (`def opened_engine(ctx: click.Context, canonical: str, conn:
17
+ Connection) -> Iterator[OpenedStub]:` on one line; same for the
18
+ `execute_cmd` callback signature and the inline-URL error `print`
19
+ call). Re-running `uv run ruff format --check dbctl tests` is now
20
+ clean and `make format` is a no-op on top of this commit.
21
+ - **Typo in `docs/execute.md`** — `# dduckdb inline URL` comment →
22
+ `# duckdb inline URL`.
23
+ - **README project layout** now lists `execute.md` under `docs/` so the
24
+ new reference page is discoverable from the layout block.
25
+
26
+ No behaviour change; re-running `dbctl execute --help` produces the
27
+ same output as v0.7.6.
28
+
29
+ ## [0.7.6] — 2026-08-10
30
+
31
+ ### Added
32
+
33
+ - **`dbctl execute`** — run ad-hoc SQL without declaring an operation in
34
+ `operations.yaml`. The new top-level verb takes a connection name *or* a
35
+ full SQLAlchemy URL, the SQL string as a single positional argument, and
36
+ the short option set the existing operation dispatch uses:
37
+
38
+ ```bash
39
+ dbctl execute -c pg -o json "SELECT * FROM users"
40
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
41
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
42
+ dbctl execute -c pg -o yaml "SELECT * FROM users LIMIT 10"
43
+ ```
44
+
45
+ Behaviour:
46
+
47
+ - **`-c` / `--connection`** accepts a registered connection name /
48
+ alias (resolved via `connections.yaml`) **or** a full SQLAlchemy URL
49
+ (detected by the presence of `://`). Inline URLs build a transient
50
+ `direct` Connection so the SSM/SSH password-source plumbing, native-lib
51
+ install hints, and `connect_args` (connect-timeout, Windows SSO) all
52
+ apply uniformly.
53
+ - **`-o` / `--output`** chooses `table` (default) / `json` / `csv` /
54
+ `yaml` for SELECT-shaped results, exactly like declared `fetch` ops.
55
+ - The SQL is auto-classified by its first verb: `SELECT` / `WITH` /
56
+ `SHOW` / `EXPLAIN` / `DESCRIBE` / `PRAGMA` / `VALUES` / `TABLE` runs
57
+ as a query (rendered via `--output`); anything else runs as DML
58
+ (INSERT / UPDATE / DELETE / CREATE / DROP / ALTER / ...) inside
59
+ `engine.begin()`.
60
+ - DML respects the connection's `safety.confirm` (dry-run-by-default
61
+ unless `--apply`), `safety.read_only` (DML blocked with exit 6), and
62
+ prompts before commit unless `--yes` / `-y` is passed — exactly the
63
+ same gate as a declared `mode: execute` operation. Inline URLs
64
+ default to `safety.confirm: true`.
65
+ - DDL that returns `rowcount = -1` (CREATE TABLE, DROP, ...) is rendered
66
+ as `OK in <ms>ms` instead of the misleading `OK -1 row(s) affected`.
67
+ Real INSERT/UPDATE/DELETE row counts are still reported.
68
+ - Every run is appended to `~/.dbctl/history.jsonl` as
69
+ `operation="execute"` with the SQL (truncated to 500 chars) in the
70
+ `params.sql` field, so it shows up under `dbctl history list`. The
71
+ audit `connection` field is the canonical connection name, or the
72
+ literal `<inline>` token for inline URL runs.
73
+ - `--show-sql` prints the resolved SQL before executing; `--` separates
74
+ options from positional SQL that begins with a dash (Click would
75
+ otherwise treat the leading dash as an unknown option).
76
+ - The new verb is registered as a top-level static command alongside
77
+ `connections` / `operations` / `doctor` / `init` / `history` /
78
+ `tunnel` / `ui`, so shell completion picks it up.
79
+
80
+ This closes the long-standing "no ad-hoc query command" gap without
81
+ weakening the declarative operation registry: the declared
82
+ `operations.yaml` model remains the recommended way to make "what can
83
+ be run against this DB" discoverable from a versioned file, while
84
+ `execute` covers the exploratory / break-glass path the TUI already
85
+ serves — both share the same tunnel / safety / audit plumbing so every
86
+ run is still logged and policy-checked.
87
+
8
88
  ## [0.7.4] — 2026-08-10
9
89
 
10
90
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbctl
3
- Version: 0.7.4
3
+ Version: 0.7.7
4
4
  Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
5
5
  Author: dbctl contributors
6
6
  License: MIT
@@ -122,9 +122,9 @@ the same params.
122
122
 
123
123
  ## Why "the operations are declarative"
124
124
 
125
- There is **no ad-hoc query command**. To run SQL through `dbctl` you must
126
- declare an operation in `operations.yaml` first — its name, its parameters
127
- (with types, descriptions, positional vs keyword), its SQL, and its mode
125
+ The recommended path for permanent, repeatable work is to declare an
126
+ operation in `operations.yaml` first — its name, its parameters (with
127
+ types, descriptions, positional vs keyword), its SQL, and its mode
128
128
  (`execute` / `fetch` / `fetch_one` / `script` / `upsert` for single-DB;
129
129
  `diff` / `compare` / `copy` / `sync` / `validate` / `replay` for multi-DB).
130
130
  The CLI then synthesises a Click subcommand per operation, so:
@@ -135,7 +135,18 @@ The CLI then synthesises a Click subcommand per operation, so:
135
135
  - the audit log is queryable by operation name (`dbctl history list`).
136
136
 
137
137
  This keeps "what can be run against this DB" discoverable from a versioned
138
- config file, instead of buried in shell history.
138
+ config file, instead of buried in shell history. For exploratory / break-glass
139
+ SQL — the path the TUI serves — `dbctl execute` (added in v0.7.6) runs
140
+ ad-hoc SQL through the same tunnel / safety / audit plumbing without
141
+ requiring a declared operation:
142
+
143
+ ```bash
144
+ dbctl execute -c pg -o json "SELECT * FROM users"
145
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
146
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
147
+ ```
148
+
149
+ See [`docs/execute.md`](docs/execute.md) for the full reference.
139
150
 
140
151
  ## Install (uv)
141
152
 
@@ -188,6 +199,12 @@ dbctl pg add-user stephen 12 --show-sql # dry-run (prints SQL)
188
199
  dbctl pg add-user stephen 12 --apply --yes # commit (no prompt)
189
200
  dbctl pg increase-credits alice 10 --apply -y # +10% on alice's credits
190
201
 
202
+ # ad-hoc SQL without declaring an operation (since v0.7.6):
203
+ dbctl execute -c pg -o json "SELECT * FROM users"
204
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
205
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
206
+ dbctl execute -c pg -o yaml -- "SELECT * FROM users WHERE name='-alice'" # SQL starting with a dash needs `--`
207
+
191
208
  # multi-DB modes (operation-first, preferred since v0.6):
192
209
  dbctl user-count pg my # multi-DB diff
193
210
  dbctl compare-credits pg my Daily
@@ -414,6 +431,7 @@ dbctl/
414
431
  ├── docs/
415
432
  │ ├── connections.md # connections.yaml reference
416
433
  │ ├── operations.md # operations.yaml reference
434
+ │ ├── execute.md # dbctl execute (ad-hoc SQL) reference
417
435
  │ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
418
436
  │ └── DESIGN.md # architecture and design decisions
419
437
  └── dbctl/
@@ -86,9 +86,9 @@ the same params.
86
86
 
87
87
  ## Why "the operations are declarative"
88
88
 
89
- There is **no ad-hoc query command**. To run SQL through `dbctl` you must
90
- declare an operation in `operations.yaml` first — its name, its parameters
91
- (with types, descriptions, positional vs keyword), its SQL, and its mode
89
+ The recommended path for permanent, repeatable work is to declare an
90
+ operation in `operations.yaml` first — its name, its parameters (with
91
+ types, descriptions, positional vs keyword), its SQL, and its mode
92
92
  (`execute` / `fetch` / `fetch_one` / `script` / `upsert` for single-DB;
93
93
  `diff` / `compare` / `copy` / `sync` / `validate` / `replay` for multi-DB).
94
94
  The CLI then synthesises a Click subcommand per operation, so:
@@ -99,7 +99,18 @@ The CLI then synthesises a Click subcommand per operation, so:
99
99
  - the audit log is queryable by operation name (`dbctl history list`).
100
100
 
101
101
  This keeps "what can be run against this DB" discoverable from a versioned
102
- config file, instead of buried in shell history.
102
+ config file, instead of buried in shell history. For exploratory / break-glass
103
+ SQL — the path the TUI serves — `dbctl execute` (added in v0.7.6) runs
104
+ ad-hoc SQL through the same tunnel / safety / audit plumbing without
105
+ requiring a declared operation:
106
+
107
+ ```bash
108
+ dbctl execute -c pg -o json "SELECT * FROM users"
109
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
110
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
111
+ ```
112
+
113
+ See [`docs/execute.md`](docs/execute.md) for the full reference.
103
114
 
104
115
  ## Install (uv)
105
116
 
@@ -152,6 +163,12 @@ dbctl pg add-user stephen 12 --show-sql # dry-run (prints SQL)
152
163
  dbctl pg add-user stephen 12 --apply --yes # commit (no prompt)
153
164
  dbctl pg increase-credits alice 10 --apply -y # +10% on alice's credits
154
165
 
166
+ # ad-hoc SQL without declaring an operation (since v0.7.6):
167
+ dbctl execute -c pg -o json "SELECT * FROM users"
168
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
169
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
170
+ dbctl execute -c pg -o yaml -- "SELECT * FROM users WHERE name='-alice'" # SQL starting with a dash needs `--`
171
+
155
172
  # multi-DB modes (operation-first, preferred since v0.6):
156
173
  dbctl user-count pg my # multi-DB diff
157
174
  dbctl compare-credits pg my Daily
@@ -378,6 +395,7 @@ dbctl/
378
395
  ├── docs/
379
396
  │ ├── connections.md # connections.yaml reference
380
397
  │ ├── operations.md # operations.yaml reference
398
+ │ ├── execute.md # dbctl execute (ad-hoc SQL) reference
381
399
  │ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
382
400
  │ └── DESIGN.md # architecture and design decisions
383
401
  └── dbctl/
@@ -20,10 +20,12 @@ from typing import Any
20
20
  import click
21
21
  import yaml
22
22
  from rich.table import Table
23
+ from sqlalchemy import text
24
+ from sqlalchemy.engine import Engine
23
25
  from sqlalchemy.exc import SQLAlchemyError
24
26
 
25
27
  from dbctl import __version__
26
- from dbctl.config import Operation, OutputFormat, Param, ParamType
28
+ from dbctl.config import Connection, Operation, OutputFormat, Param, ParamType
27
29
  from dbctl.operations import by_scope
28
30
  from dbctl.operations import resolve as resolve_op
29
31
  from dbctl.runtime import (
@@ -1020,7 +1022,17 @@ def _aliases(conns):
1020
1022
 
1021
1023
  def _root_list(ctx: click.Context) -> list[str]:
1022
1024
  conns, ops = registries(ctx)
1023
- static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel", "ui"]
1025
+ static = [
1026
+ "connections",
1027
+ "operations",
1028
+ "status",
1029
+ "doctor",
1030
+ "init",
1031
+ "history",
1032
+ "tunnel",
1033
+ "ui",
1034
+ "execute",
1035
+ ]
1024
1036
  # multi-op operation-first top-level commands + deprecated verb-first groups
1025
1037
  multi_ops = {n for n, o in ops.items() if o.scope.value == "multi"}
1026
1038
  multi_modes = {o.mode.value for o in ops.values() if o.scope.value == "multi"}
@@ -1040,6 +1052,7 @@ def _root_get(ctx: click.Context, name: str):
1040
1052
  "history": history_cmd,
1041
1053
  "tunnel": tunnel_cmd,
1042
1054
  "ui": ui_cmd,
1055
+ "execute": execute_cmd,
1043
1056
  }
1044
1057
  if name in static:
1045
1058
  return static[name]
@@ -1339,6 +1352,278 @@ def ui_cmd(ctx):
1339
1352
  DbctlApp(profile=ctx.obj.get("profile")).run()
1340
1353
 
1341
1354
 
1355
+ # --------------------------------------------------------------------------- #
1356
+ # ad-hoc SQL: `dbctl execute`
1357
+ # --------------------------------------------------------------------------- #
1358
+ # A read-only SQL verb is one whose first word matches this set. "VALUES"
1359
+ # / "TABLE" are SQL-standard row-set constructors; "WITH" is a CTE head;
1360
+ # the rest are dialect-specific introspection verbs (EXPLAIN / DESCRIBE /
1361
+ # SHOW / PRAGMA / DESC) that return a result set the user wants to render
1362
+ # like a SELECT.
1363
+ _READ_VERBS = {
1364
+ "SELECT",
1365
+ "WITH",
1366
+ "VALUES",
1367
+ "TABLE",
1368
+ "SHOW",
1369
+ "EXPLAIN",
1370
+ "DESC",
1371
+ "DESCRIBE",
1372
+ "PRAGMA",
1373
+ }
1374
+
1375
+
1376
+ def _sql_is_read(sql: str) -> bool:
1377
+ head = sql.strip().lstrip("(").strip().split(None, 1)
1378
+ return bool(head) and head[0].upper() in _READ_VERBS
1379
+
1380
+
1381
+ def _make_inline_connection(url: str) -> Connection:
1382
+ """Build a transient ``Connection`` from a full SQLAlchemy URL.
1383
+
1384
+ Used by ``dbctl execute -c "<sqlalchemy url>"``. The placeholder
1385
+ ``direct`` block is required by the ``Connection`` validator even in
1386
+ URL mode, but it's never *used* — ``build_engine`` honours ``url:``
1387
+ over host/port. Default safety mirrors the bundled dev connections:
1388
+ DML is dry-run-by-default unless ``--apply`` is supplied.
1389
+ """
1390
+ from dbctl.config import Connection
1391
+
1392
+ return Connection.model_validate(
1393
+ {
1394
+ "type": "direct",
1395
+ "direct": {"host": "localhost", "port": 1},
1396
+ "url": url,
1397
+ "healthcheck": {"query": "SELECT 1", "timeout_seconds": 5},
1398
+ "safety": {"confirm": True, "read_only": False},
1399
+ }
1400
+ )
1401
+
1402
+
1403
+ def _do_execute_ad_hoc(
1404
+ *,
1405
+ engine: Engine,
1406
+ sql: str,
1407
+ is_select: bool,
1408
+ output_fmt: str,
1409
+ canonical: str,
1410
+ profile: str | None,
1411
+ actor: str | None,
1412
+ ) -> None:
1413
+ """Run an ad-hoc SQL string against ``engine`` and render the result.
1414
+
1415
+ SELECT-shaped SQL runs in a read-only ``engine.connect()`` and renders
1416
+ via ``render_rows`` (table / json / yaml / csv). DML runs inside
1417
+ ``engine.begin()`` and reports rows-affected with a green OK line. Any
1418
+ SQLAlchemy / runtime error is rendered via ``fmt_db_error`` (one-line
1419
+ friendly message) and exits 1 — matching the declared-op execution
1420
+ path's behaviour. Every run is appended to the audit log as
1421
+ ``operation="execute"`` with the SQL (truncated to 500 chars) in
1422
+ ``params`` so it shows up under ``dbctl history list``.
1423
+ """
1424
+ from dbctl.audit import append
1425
+ from dbctl.reports import render_rows
1426
+
1427
+ started = time.monotonic()
1428
+ mode_label = "fetch" if is_select else "execute"
1429
+ audit_sql = sql[:500]
1430
+ rows_affected: int | None = None
1431
+ try:
1432
+ if is_select:
1433
+ with engine.connect() as c:
1434
+ rows = [dict(r) for r in c.execute(text(sql)).mappings()]
1435
+ render_rows(rows, output_fmt, title=canonical)
1436
+ else:
1437
+ with engine.begin() as c:
1438
+ result = c.execute(text(sql))
1439
+ rows_affected = result.rowcount
1440
+ elapsed = (time.monotonic() - started) * 1000.0
1441
+ # Many drivers report rowcount = -1 (unknown) for DDL and for
1442
+ # statements that don't naturally expose a row count (CREATE
1443
+ # TABLE, DROP TABLE, ALTER, ...). Render a cleaner line instead
1444
+ # of the misleading "OK -1 row(s) affected".
1445
+ if rows_affected is not None and rows_affected >= 0:
1446
+ console.print(f"[green]OK[/green] {rows_affected} row(s) affected in {elapsed:.1f}ms")
1447
+ else:
1448
+ console.print(f"[green]OK[/green] in {elapsed:.1f}ms")
1449
+ except (RuntimeError, SQLAlchemyError) as e:
1450
+ append(
1451
+ profile=profile,
1452
+ connection=canonical,
1453
+ operation="execute",
1454
+ params={"sql": audit_sql},
1455
+ mode=mode_label,
1456
+ status="error",
1457
+ duration_ms=(time.monotonic() - started) * 1000.0,
1458
+ actor=actor,
1459
+ )
1460
+ err_console.print(f"[red]{_fmt_db_error(e)}[/red]")
1461
+ raise SystemExit(1)
1462
+ append(
1463
+ profile=profile,
1464
+ connection=canonical,
1465
+ operation="execute",
1466
+ params={"sql": audit_sql},
1467
+ mode=mode_label,
1468
+ status="ok",
1469
+ rows_affected=rows_affected,
1470
+ duration_ms=(time.monotonic() - started) * 1000.0,
1471
+ actor=actor,
1472
+ )
1473
+
1474
+
1475
+ @click.command("execute", context_settings={"help_option_names": ["-h", "--help"]})
1476
+ @click.option(
1477
+ "-c",
1478
+ "--connection",
1479
+ "connection_str",
1480
+ required=True,
1481
+ metavar="CONN-OR-URL",
1482
+ help=(
1483
+ "Either a connection name / alias from connections.yaml, or a full "
1484
+ "SQLAlchemy URL (e.g. "
1485
+ "'postgresql+psycopg://user:pass@host:5432/db'). "
1486
+ "URLs are detected by the presence of '://'."
1487
+ ),
1488
+ )
1489
+ @click.option(
1490
+ "-o",
1491
+ "--output",
1492
+ "output_fmt",
1493
+ type=click.Choice([m.value for m in OutputFormat]),
1494
+ default="table",
1495
+ show_default=True,
1496
+ help="Output format for SELECT-shaped results (ignored for DML).",
1497
+ )
1498
+ @click.option(
1499
+ "--apply",
1500
+ is_flag=True,
1501
+ help="Commit any DML (default is a dry-run preview when confirm is on).",
1502
+ )
1503
+ @click.option(
1504
+ "-y",
1505
+ "--yes",
1506
+ is_flag=True,
1507
+ help="Skip the confirmation prompt before DML is committed.",
1508
+ )
1509
+ @click.option(
1510
+ "--show-sql",
1511
+ is_flag=True,
1512
+ help="Print the SQL before executing.",
1513
+ )
1514
+ @click.argument("sql")
1515
+ @click.pass_context
1516
+ def execute_cmd(
1517
+ ctx: click.Context, connection_str: str, output_fmt: str, apply: bool, yes: bool, show_sql: bool, sql: str
1518
+ ) -> None:
1519
+ """Run ad-hoc SQL against a configured connection OR a full SQLAlchemy URL.
1520
+
1521
+ The SQL is auto-classified by its first verb: SELECT / WITH / SHOW /
1522
+ EXPLAIN / DESCRIBE / PRAGMA / VALUES / TABLE runs as a query and is
1523
+ rendered via ``--output`` (table / json / yaml / csv). Anything else
1524
+ (INSERT / UPDATE / DELETE / CREATE / DROP / ALTER / ...) runs as DML
1525
+ with the same dry-run-by-default + ``--apply`` safety semantics as a
1526
+ declared ``mode: execute`` operation: on a connection with
1527
+ ``safety.confirm: true`` (the default config), the SQL is previewed
1528
+ and the audit log records a ``dry-run`` status unless ``--apply`` is
1529
+ passed; ``--yes`` skips the final [y/N] prompt.
1530
+
1531
+ \b
1532
+ Examples:
1533
+ dbctl execute -c pg -o json "SELECT * FROM users"
1534
+ dbctl execute -c pg --show-sql --apply "DELETE FROM users WHERE name='bob'"
1535
+ dbctl execute -c "postgresql+psycopg://u:p@host:5432/db" -o csv "SELECT 1"
1536
+ dbctl execute -c sqlite -o yaml "SELECT name FROM sqlite_master WHERE type='table'"
1537
+
1538
+ \b
1539
+ If the SQL itself begins with a dash, separate options from the
1540
+ positional with ``--`` (Click would otherwise treat the leading dash
1541
+ as an unknown option):
1542
+ dbctl execute -c pg -- "-- my SQL comment starting with a dash"
1543
+
1544
+ Every run is appended to ~/.dbctl/history.jsonl as
1545
+ ``operation="execute"`` so it shows up under ``dbctl history list``;
1546
+ the SQL (truncated to 500 chars) is recorded in the audit entry's
1547
+ ``params.sql`` field.
1548
+ """
1549
+ from dbctl.connections import resolve as resolve_conn_name
1550
+
1551
+ sql_clean = sql.strip()
1552
+ if not sql_clean:
1553
+ err_console.print("[red]empty SQL statement[/red]")
1554
+ raise SystemExit(2)
1555
+ is_select = _sql_is_read(sql_clean)
1556
+
1557
+ is_url = "://" in connection_str
1558
+ if is_url:
1559
+ canonical = "<inline>"
1560
+ try:
1561
+ conn = _make_inline_connection(connection_str)
1562
+ except Exception as e: # noqa: BLE001 - validator / make-url errors
1563
+ err_console.print(f"[red]invalid connection URL: {e}[/red]")
1564
+ raise SystemExit(2)
1565
+ else:
1566
+ conns, _ = registries(ctx)
1567
+ try:
1568
+ canonical, conn = resolve_conn_name(connection_str, conns)
1569
+ except KeyError as e:
1570
+ err_console.print(f"[red]{e}[/red]")
1571
+ raise SystemExit(2)
1572
+
1573
+ if show_sql:
1574
+ console.print(f"[cyan]SQL:[/cyan]\n{sql_clean}")
1575
+
1576
+ profile = ctx.obj.get("profile")
1577
+ actor = ctx.obj.get("actor")
1578
+
1579
+ # DML safety gate — mirrors `_execute_single`'s read_only / confirm / dry-run logic.
1580
+ if not is_select:
1581
+ if conn.safety.read_only:
1582
+ err_console.print(f"[red]connection {canonical!r} is read-only; DML is blocked[/red]")
1583
+ raise SystemExit(6)
1584
+ if conn.safety.confirm and not apply:
1585
+ console.print("[yellow]dry-run (use --apply to commit)[/yellow]")
1586
+ from dbctl.audit import append
1587
+
1588
+ append(
1589
+ profile=profile,
1590
+ connection=canonical,
1591
+ operation="execute",
1592
+ params={"sql": sql_clean[:500]},
1593
+ mode="execute",
1594
+ status="dry-run",
1595
+ actor=actor,
1596
+ )
1597
+ raise SystemExit(0)
1598
+ if conn.safety.confirm and not yes:
1599
+ confirm_or_abort(f"Apply SQL to {canonical}?", yes=yes)
1600
+
1601
+ if is_url:
1602
+ from dbctl.runtime import opened_engine
1603
+
1604
+ with opened_engine(ctx, canonical, conn) as stub:
1605
+ _do_execute_ad_hoc(
1606
+ engine=stub.engine,
1607
+ sql=sql_clean,
1608
+ is_select=is_select,
1609
+ output_fmt=output_fmt,
1610
+ canonical=canonical,
1611
+ profile=profile,
1612
+ actor=actor,
1613
+ )
1614
+ else:
1615
+ with opened_conn(ctx, canonical) as (_name, _conn, stub):
1616
+ _do_execute_ad_hoc(
1617
+ engine=stub.engine,
1618
+ sql=sql_clean,
1619
+ is_select=is_select,
1620
+ output_fmt=output_fmt,
1621
+ canonical=canonical,
1622
+ profile=profile,
1623
+ actor=actor,
1624
+ )
1625
+
1626
+
1342
1627
  @main.group("history")
1343
1628
  def history_cmd():
1344
1629
  """Show the audit log."""
@@ -22,6 +22,8 @@ from dbctl.operations import OperationsFileError
22
22
  from dbctl.operations import load as load_operations
23
23
 
24
24
  if TYPE_CHECKING:
25
+ from sqlalchemy.engine import Engine
26
+
25
27
  from dbctl.config import Connection
26
28
 
27
29
  console = Console()
@@ -72,7 +74,7 @@ def confirm_or_abort(prompt: str, *, yes: bool) -> None:
72
74
  class OpenedStub:
73
75
  name: str
74
76
  conn: Connection
75
- engine: object
77
+ engine: Engine
76
78
  tunnel: object
77
79
 
78
80
 
@@ -85,7 +87,7 @@ def opened_conn(ctx: click.Context, name: str) -> Iterator[tuple[str, Connection
85
87
  errors so command callbacks don't need to handle those states.
86
88
  """
87
89
  from dbctl.connections import resolve
88
- from dbctl.db import DBError, build_engine, healthcheck
90
+ from dbctl.db import DBError, build_engine
89
91
  from dbctl.tunnels.base import build_tunnel
90
92
 
91
93
  conns, _ = registries(ctx)
@@ -108,17 +110,65 @@ def opened_conn(ctx: click.Context, name: str) -> Iterator[tuple[str, Connection
108
110
  err_console.print(f"[red]db error:[/red] {e}")
109
111
  sys.exit(4)
110
112
 
111
- if not ctx.obj.get("skip_healthcheck"):
112
- ok, _ms, msg = healthcheck(engine, conn.healthcheck.query, conn.healthcheck.timeout_seconds)
113
- if not ok:
114
- tun.__exit__(None, None, None)
115
- err_console.print(f"[red]healthcheck failed:[/red] {msg}")
116
- sys.exit(5)
117
- if ctx.obj.get("verbose"):
118
- console.print(f"[dim]health {canonical}: ok ({_ms:.1f}ms)[/dim]")
119
-
113
+ _healthcheck(ctx, canonical, conn, engine, tun)
120
114
  stub = OpenedStub(canonical, conn, engine, tun)
121
115
  try:
122
116
  yield canonical, conn, stub
123
117
  finally:
124
118
  tun.__exit__(None, None, None)
119
+
120
+
121
+ @contextmanager
122
+ def opened_engine(ctx: click.Context, canonical: str, conn: Connection) -> Iterator[OpenedStub]:
123
+ """Like ``opened_conn`` but takes a pre-built ``Connection`` (instead of
124
+ resolving a name from the registry). Used by ``dbctl execute`` for
125
+ inline SQLAlchemy URLs — the connection's ``direct`` block (a
126
+ placeholder host:port) is unused since ``url:`` overrides it; the
127
+ tunnel is a no-op ``DirectTunnel`` in that case.
128
+
129
+ Yields ``OpenedStub``. Tears the tunnel down on exit. Exits with the
130
+ same exit-code convention as ``opened_conn`` (3 tunnel, 4 db, 5 health).
131
+ """
132
+ from dbctl.db import DBError, build_engine
133
+ from dbctl.tunnels.base import build_tunnel
134
+
135
+ tun = build_tunnel(conn)
136
+ try:
137
+ tun.__enter__()
138
+ except RuntimeError as e:
139
+ err_console.print(f"[red]tunnel error:[/red] {e}")
140
+ sys.exit(3)
141
+ try:
142
+ engine = build_engine(conn, tun)
143
+ except DBError as e:
144
+ tun.__exit__(None, None, None)
145
+ err_console.print(f"[red]db error:[/red] {e}")
146
+ sys.exit(4)
147
+
148
+ _healthcheck(ctx, canonical, conn, engine, tun)
149
+ stub = OpenedStub(canonical, conn, engine, tun)
150
+ try:
151
+ yield stub
152
+ finally:
153
+ tun.__exit__(None, None, None)
154
+
155
+
156
+ def _healthcheck(
157
+ ctx: click.Context,
158
+ canonical: str,
159
+ conn: Connection,
160
+ engine: Engine,
161
+ tun: object,
162
+ ) -> None:
163
+ """Shared healthcheck prefix for `opened_conn` / `opened_engine`."""
164
+ from dbctl.db import healthcheck
165
+
166
+ if ctx.obj.get("skip_healthcheck"):
167
+ return
168
+ ok, ms, msg = healthcheck(engine, conn.healthcheck.query, conn.healthcheck.timeout_seconds)
169
+ if not ok:
170
+ tun.__exit__(None, None, None) # type: ignore[attr-defined]
171
+ err_console.print(f"[red]healthcheck failed:[/red] {msg}")
172
+ sys.exit(5)
173
+ if ctx.obj.get("verbose"):
174
+ console.print(f"[dim]health {canonical}: ok ({ms:.1f}ms)[/dim]")