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.
- {dbctl-0.7.4 → dbctl-0.7.7}/CHANGELOG.md +80 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/PKG-INFO +23 -5
- {dbctl-0.7.4 → dbctl-0.7.7}/README.md +22 -4
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/cli.py +287 -2
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/runtime.py +61 -11
- dbctl-0.7.7/docs/execute.md +149 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/pyproject.toml +1 -1
- dbctl-0.7.7/tests/test_execute.py +454 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/uv.lock +1 -1
- {dbctl-0.7.4 → dbctl-0.7.7}/.dbctl/connections.yaml +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/.dbctl/operations.yaml +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/.github/workflows/ci.yml +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/.github-local/ci.yml +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/.gitignore +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/Makefile +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/__init__.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/__main__.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/audit.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/config.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/connections.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/db.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/execute.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/init.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/multi.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/operations.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/refs.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/reports.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/azure.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/base.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/gcp.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/__init__.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/app.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/connection_tree.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/editor_tab.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/grouping.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/operation_tab.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/registry.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/results.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/schema.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/screens.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/session.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/splitter.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/sql_templates.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/dbctl/ui/tabs.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docker-compose.yml +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/DESIGN.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/connections.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/logo.png +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/logo_small.png +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/operations.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/tui.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/docs/tutorial.md +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/seed/mssql.sql +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/seed/mysql.sql +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/seed/postgres.sql +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_azure_tunnel.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_connections_loader.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_copy_features.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_gcp_tunnel.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_refs.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_regressions.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_smoke.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/test_sso_cache.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/conftest.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_app.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_connection_tree.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_connection_tree_grouping.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_editor_tab.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_grouping.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_operation_launcher.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_operation_tab.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_resize.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_schema.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_screens.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_session.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_sql_templates.py +0 -0
- {dbctl-0.7.4 → dbctl-0.7.7}/tests/ui/test_status_bar_and_loading.py +0 -0
- {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.
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 = [
|
|
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:
|
|
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
|
|
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
|
-
|
|
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]")
|