dbctl 0.7.4__tar.gz → 0.7.8__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.8}/CHANGELOG.md +80 -0
  2. {dbctl-0.7.4 → dbctl-0.7.8}/PKG-INFO +24 -6
  3. {dbctl-0.7.4 → dbctl-0.7.8}/README.md +22 -4
  4. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/cli.py +287 -2
  5. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/multi.py +30 -5
  6. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/runtime.py +61 -11
  7. dbctl-0.7.8/docs/execute.md +149 -0
  8. {dbctl-0.7.4 → dbctl-0.7.8}/pyproject.toml +1 -1
  9. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_copy_features.py +26 -0
  10. dbctl-0.7.8/tests/test_execute.py +454 -0
  11. {dbctl-0.7.4 → dbctl-0.7.8}/uv.lock +1 -1
  12. {dbctl-0.7.4 → dbctl-0.7.8}/.dbctl/connections.yaml +0 -0
  13. {dbctl-0.7.4 → dbctl-0.7.8}/.dbctl/operations.yaml +0 -0
  14. {dbctl-0.7.4 → dbctl-0.7.8}/.github/workflows/ci.yml +0 -0
  15. {dbctl-0.7.4 → dbctl-0.7.8}/.github-local/ci.yml +0 -0
  16. {dbctl-0.7.4 → dbctl-0.7.8}/.gitignore +0 -0
  17. {dbctl-0.7.4 → dbctl-0.7.8}/Makefile +0 -0
  18. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/__init__.py +0 -0
  19. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/__main__.py +0 -0
  20. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/audit.py +0 -0
  21. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/config.py +0 -0
  22. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/connections.py +0 -0
  23. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/db.py +0 -0
  24. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/execute.py +0 -0
  25. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/init.py +0 -0
  26. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/operations.py +0 -0
  27. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/refs.py +0 -0
  28. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/reports.py +0 -0
  29. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/__init__.py +0 -0
  30. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/azure.py +0 -0
  31. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/base.py +0 -0
  32. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/direct.py +0 -0
  33. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/gcp.py +0 -0
  34. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/k8s.py +0 -0
  35. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/ssh.py +0 -0
  36. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/tunnels/ssm.py +0 -0
  37. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/__init__.py +0 -0
  38. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/app.py +0 -0
  39. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/connection_tree.py +0 -0
  40. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/editor_tab.py +0 -0
  41. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/grouping.py +0 -0
  42. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/operation_tab.py +0 -0
  43. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/registry.py +0 -0
  44. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/results.py +0 -0
  45. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/schema.py +0 -0
  46. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/screens.py +0 -0
  47. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/session.py +0 -0
  48. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/splitter.py +0 -0
  49. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/sql_templates.py +0 -0
  50. {dbctl-0.7.4 → dbctl-0.7.8}/dbctl/ui/tabs.py +0 -0
  51. {dbctl-0.7.4 → dbctl-0.7.8}/docker-compose.yml +0 -0
  52. {dbctl-0.7.4 → dbctl-0.7.8}/docs/ACTION_OUTPUT.md +0 -0
  53. {dbctl-0.7.4 → dbctl-0.7.8}/docs/DESIGN.md +0 -0
  54. {dbctl-0.7.4 → dbctl-0.7.8}/docs/SESSION_STATE.md +0 -0
  55. {dbctl-0.7.4 → dbctl-0.7.8}/docs/connections.md +0 -0
  56. {dbctl-0.7.4 → dbctl-0.7.8}/docs/logo.png +0 -0
  57. {dbctl-0.7.4 → dbctl-0.7.8}/docs/logo_small.png +0 -0
  58. {dbctl-0.7.4 → dbctl-0.7.8}/docs/operations.md +0 -0
  59. {dbctl-0.7.4 → dbctl-0.7.8}/docs/tui.md +0 -0
  60. {dbctl-0.7.4 → dbctl-0.7.8}/docs/tutorial.md +0 -0
  61. {dbctl-0.7.4 → dbctl-0.7.8}/seed/mssql.sql +0 -0
  62. {dbctl-0.7.4 → dbctl-0.7.8}/seed/mysql.sql +0 -0
  63. {dbctl-0.7.4 → dbctl-0.7.8}/seed/postgres.sql +0 -0
  64. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_azure_tunnel.py +0 -0
  65. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_bastion_tags.py +0 -0
  66. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_connections_loader.py +0 -0
  67. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_gcp_tunnel.py +0 -0
  68. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_k8s_tunnel.py +0 -0
  69. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_refs.py +0 -0
  70. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_regressions.py +0 -0
  71. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_smoke.py +0 -0
  72. {dbctl-0.7.4 → dbctl-0.7.8}/tests/test_sso_cache.py +0 -0
  73. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/conftest.py +0 -0
  74. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_app.py +0 -0
  75. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_connection_tree.py +0 -0
  76. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_connection_tree_grouping.py +0 -0
  77. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_editor_tab.py +0 -0
  78. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_grouping.py +0 -0
  79. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_operation_launcher.py +0 -0
  80. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_operation_tab.py +0 -0
  81. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_resize.py +0 -0
  82. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_schema.py +0 -0
  83. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_screens.py +0 -0
  84. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_session.py +0 -0
  85. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_sql_templates.py +0 -0
  86. {dbctl-0.7.4 → dbctl-0.7.8}/tests/ui/test_status_bar_and_loading.py +0 -0
  87. {dbctl-0.7.4 → dbctl-0.7.8}/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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dbctl
3
- Version: 0.7.4
3
+ Version: 0.7.8
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."""
@@ -20,6 +20,7 @@ Execution paths here:
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import re
23
24
  import time
24
25
  from collections.abc import Callable, Iterator
25
26
  from contextlib import contextmanager
@@ -367,13 +368,34 @@ def _truncate_table(c, table: str, engine) -> None:
367
368
  c.execute(text(f"DELETE FROM {table_q}"))
368
369
 
369
370
 
371
+ def _safe_param_names(columns: list[str]) -> dict[str, str]:
372
+ """Map each column name to a SQLAlchemy-safe bind-parameter name.
373
+
374
+ ``text()`` parses a ``:name`` bind parameter up to the first character
375
+ outside ``[A-Za-z0-9_]`` — a column containing a space (or any other
376
+ such character) silently truncates the parameter name at that point
377
+ (e.g. ``:Last price`` is parsed as parameter ``Last`` followed by the
378
+ literal SQL text ``price``), which then fails at execute time because
379
+ the row dict has no ``"Last"`` key. Suffixing with the column's index
380
+ keeps names unique even if two columns sanitize to the same string.
381
+ """
382
+ return {c: f"p{i}_{re.sub(r'[^A-Za-z0-9_]', '_', c)}" for i, c in enumerate(columns)}
383
+
384
+
385
+ def _remap_rows(rows: list[dict], columns: list[str], param_names: dict[str, str]) -> list[dict]:
386
+ """Rekey each row dict from real column names to their safe bind-parameter
387
+ names (see ``_safe_param_names``) before handing it to ``conn.execute``."""
388
+ return [{param_names[c]: row[c] for c in columns} for row in rows]
389
+
390
+
370
391
  def _build_insert_sql(table: str, columns: list[str], spec: CopySpec, engine) -> str:
371
392
  """Build the dialect-aware INSERT statement for one batch (excluding the
372
393
  MSSQL ``skip`` path, which has no single-statement shape — see
373
394
  ``_insert_batch_mssql_skip``)."""
374
395
  preparer = engine.dialect.identifier_preparer
396
+ param_names = _safe_param_names(columns)
375
397
  cols = ", ".join(preparer.quote(c) for c in columns)
376
- placeholders = ", ".join(f":{c}" for c in columns)
398
+ placeholders = ", ".join(f":{param_names[c]}" for c in columns)
377
399
  dialect = engine.dialect.name
378
400
  base_sql = f"INSERT INTO {_quote_ident(table, engine)} ({cols}) VALUES ({placeholders})"
379
401
 
@@ -401,7 +423,8 @@ def _execute_insert(conn, table: str, columns: list[str], rows: list[dict], spec
401
423
  """Execute the INSERT for one batch on an already-open connection; the
402
424
  caller controls commit/rollback. Not used for the MSSQL ``skip`` path."""
403
425
  sql = _build_insert_sql(table, columns, spec, engine)
404
- r = conn.execute(text(sql), rows)
426
+ safe_rows = _remap_rows(rows, columns, _safe_param_names(columns))
427
+ r = conn.execute(text(sql), safe_rows)
405
428
  # `rowcount` for `executemany` is the number of rows actually written.
406
429
  # For `INSERT IGNORE` / `ON CONFLICT DO NOTHING` it excludes the
407
430
  # duplicates the driver skipped; for plain `INSERT` it equals
@@ -503,18 +526,20 @@ def _insert_batch_mssql_skip(
503
526
  """MSSQL has no native ON CONFLICT; emulate skip by inserting rows one at a
504
527
  time guarded by NOT EXISTS on the columns. Slow but correct."""
505
528
  preparer = trg.engine.dialect.identifier_preparer
529
+ param_names = _safe_param_names(columns)
506
530
  cols = ", ".join(preparer.quote(c) for c in columns)
507
- vals = ", ".join(f":{c}" for c in columns)
531
+ vals = ", ".join(f":{param_names[c]}" for c in columns)
508
532
  table_q = _quote_ident(table, trg.engine)
509
- not_exists_cols = " AND ".join(f"s.{c}=t.{c}" for c in columns)
533
+ not_exists_cols = " AND ".join(f"t.{preparer.quote(c)} = :{param_names[c]}" for c in columns)
510
534
  sql = (
511
535
  f"INSERT INTO {table_q} ({cols}) "
512
536
  f"SELECT {vals} WHERE NOT EXISTS "
513
537
  f"(SELECT 1 FROM {table_q} t WHERE {not_exists_cols})"
514
538
  )
515
539
  inserted = 0
540
+ safe_rows = _remap_rows(rows, columns, param_names)
516
541
  with trg.engine.begin() as c:
517
- for row in rows:
542
+ for row in safe_rows:
518
543
  r = c.execute(text(sql), row)
519
544
  inserted += r.rowcount or 0
520
545
  return inserted