dbqm 2.3.2__tar.gz → 2.4.1__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.
- {dbqm-2.3.2 → dbqm-2.4.1}/PKG-INFO +1 -1
- {dbqm-2.3.2 → dbqm-2.4.1}/README.md +3 -1
- dbqm-2.4.1/dbqm/_version.py +1 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/__init__.py +4 -4
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/query.py +23 -3
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/deps.py +3 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/group_engine.py +141 -2
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/html_report.py +83 -11
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_exec.py +36 -63
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/PKG-INFO +1 -1
- {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli.py +109 -0
- dbqm-2.3.2/dbqm/_version.py +0 -1
- {dbqm-2.3.2 → dbqm-2.4.1}/PYPI.md +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/__main__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/config_bundle.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/connection.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/inspect.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/schema.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/envelope.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/errors.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/params.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/render.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/audit.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/config_portability.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/connection_builder.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/crypto.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/db_manager.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_extractor.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_mysql.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_pg.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/exporter.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/history.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/object_browser.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/oracle_client_installer.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/package_editor.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/paths.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/query_engine.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/read_only.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/table_browser.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/template_engine.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/design/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/design/tokens.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/main.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/connection.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/group.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/query.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/settings.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/template.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/app.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/column_maps.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/confirm.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/error.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/export_dir_setup.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/export_picker.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/help.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/oracle_client_dir.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/param_input.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/text_input.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/adhoc.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/browser.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/config_port.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/connections.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/exec_routine.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_manage.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_run.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/history.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/oracle_clients.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/package_editor.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/query_exec.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/query_manage.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/settings.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/template_manage.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/tools.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/theme.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/utils.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/__init__.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/action_bar.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/dialog.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/empty_state.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/group_result.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/hierarchical_list.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/panel.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/progress.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/query_list.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/result_table.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/skeleton.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/sql_viewer.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/status_bar.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/templates_sidebar.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/verdict.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/SOURCES.txt +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/dependency_links.txt +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/entry_points.txt +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/requires.txt +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/top_level.txt +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/pyproject.toml +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/setup.cfg +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli_markup.py +0 -0
- {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli_tema.py +0 -0
|
@@ -225,7 +225,9 @@ offer `table|json|csv|raw`; every other command — `test`, `list`, `ddl`,
|
|
|
225
225
|
`connection` group — offers `table|json`. `raw` prints plain values with no
|
|
226
226
|
headers/decoration, handy for piping the body of a view, package, or
|
|
227
227
|
procedure to another tool.
|
|
228
|
-
`--export csv|json|txt` writes the result to a file regardless of `-f`.
|
|
228
|
+
`--export csv|json|txt|html` writes the result to a file regardless of `-f`.
|
|
229
|
+
`html` writes a standalone report meant to be read in a browser, and is not
|
|
230
|
+
available together with `--flat`.
|
|
229
231
|
|
|
230
232
|
`-f json` wraps every command in one envelope: a success prints
|
|
231
233
|
`{"ok": true, "command": "...", "data": {...}}` to stdout; a failure prints
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.4.1"
|
|
@@ -33,7 +33,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
33
33
|
help="Parametro (pode repetir)")
|
|
34
34
|
p_run.add_argument("-f", "--format", choices=["table", "json", "csv", "raw"], default="table",
|
|
35
35
|
help="Formato de saida (padrao: table). 'raw' imprime valores sem decoracao.")
|
|
36
|
-
p_run.add_argument("-e", "--export", choices=["csv", "json", "txt"],
|
|
36
|
+
p_run.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
|
|
37
37
|
help="Exportar resultado para arquivo")
|
|
38
38
|
|
|
39
39
|
# --- run-group ---
|
|
@@ -43,8 +43,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
43
43
|
help="Parametro compartilhado (pode repetir)")
|
|
44
44
|
p_grp.add_argument("-f", "--format", choices=["table", "json"], default="table",
|
|
45
45
|
help="Formato de saida")
|
|
46
|
-
p_grp.add_argument("-e", "--export", choices=["csv", "json", "txt"],
|
|
47
|
-
help="Exportar resultado para arquivo")
|
|
46
|
+
p_grp.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
|
|
47
|
+
help="Exportar resultado para arquivo. 'html' nao pode ser usado com --flat.")
|
|
48
48
|
p_grp.add_argument("--flat", action="store_true",
|
|
49
49
|
help="Usar formato flat (um bloco por coluna)")
|
|
50
50
|
|
|
@@ -69,7 +69,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
69
69
|
help="Parametro (pode repetir)")
|
|
70
70
|
p_sql.add_argument("-f", "--format", choices=["table", "json", "csv", "raw"], default="table",
|
|
71
71
|
help="Formato de saida. 'raw' imprime valores sem decoracao.")
|
|
72
|
-
p_sql.add_argument("-e", "--export", choices=["csv", "json", "txt"],
|
|
72
|
+
p_sql.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
|
|
73
73
|
help="Exportar resultado para arquivo")
|
|
74
74
|
p_sql.add_argument("--commit", action="store_true",
|
|
75
75
|
help="Auto-commit para DML (INSERT/UPDATE/DELETE)")
|
|
@@ -133,6 +133,8 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
|
133
133
|
path = deps.export_query_json(result, table_name, param_values)
|
|
134
134
|
elif fmt == "txt":
|
|
135
135
|
path = deps.export_query_txt(result, table_name, param_values)
|
|
136
|
+
elif fmt == "html":
|
|
137
|
+
path = deps.export_query_html(result, table_name, param_values)
|
|
136
138
|
else:
|
|
137
139
|
_fail_or_print(args, "run", "usage", f"Formato de export invalido: {fmt}")
|
|
138
140
|
if args.format == "json":
|
|
@@ -162,6 +164,14 @@ def cmd_run_group(args: argparse.Namespace) -> None:
|
|
|
162
164
|
on stdout. The exit code alone is what lets a shell branch on the verdict
|
|
163
165
|
without parsing anything.
|
|
164
166
|
"""
|
|
167
|
+
# An argument combination that can never be valid is refused before any
|
|
168
|
+
# work happens -- before the group is even resolved, let alone any query
|
|
169
|
+
# run or any history record written.
|
|
170
|
+
if args.export == "html" and args.flat:
|
|
171
|
+
_fail_or_print(args, "run-group", "usage",
|
|
172
|
+
"--flat nao tem versao HTML. Use --export html sem --flat, "
|
|
173
|
+
"ou --flat com csv, json ou txt.")
|
|
174
|
+
|
|
165
175
|
group = deps.find_group(args.group)
|
|
166
176
|
if not group:
|
|
167
177
|
_fail_or_print(args, "run-group", "not_found", f"Grupo '{args.group}' nao encontrado.")
|
|
@@ -219,15 +229,21 @@ def cmd_run_group(args: argparse.Namespace) -> None:
|
|
|
219
229
|
path = deps.export_group_flat_csv(group_result, param_values)
|
|
220
230
|
elif fmt == "json":
|
|
221
231
|
path = deps.export_group_flat_json(group_result, param_values)
|
|
222
|
-
|
|
232
|
+
elif fmt == "txt":
|
|
223
233
|
path = deps.export_group_flat_txt(group_result, param_values)
|
|
234
|
+
else:
|
|
235
|
+
_fail_or_print(args, "run-group", "usage", f"Formato de export invalido: {fmt}")
|
|
224
236
|
else:
|
|
225
237
|
if fmt == "csv":
|
|
226
238
|
path = deps.export_group_csv(group_result, param_values)
|
|
227
239
|
elif fmt == "json":
|
|
228
240
|
path = deps.export_group_json(group_result, param_values)
|
|
229
|
-
|
|
241
|
+
elif fmt == "txt":
|
|
230
242
|
path = deps.export_group_txt(group_result, param_values)
|
|
243
|
+
elif fmt == "html":
|
|
244
|
+
path = deps.export_group_html(group_result, param_values)
|
|
245
|
+
else:
|
|
246
|
+
_fail_or_print(args, "run-group", "usage", f"Formato de export invalido: {fmt}")
|
|
231
247
|
if args.format == "json":
|
|
232
248
|
ok("run-group", {"exported": str(path), "format": fmt})
|
|
233
249
|
else:
|
|
@@ -412,8 +428,12 @@ def cmd_sql(args: argparse.Namespace) -> None:
|
|
|
412
428
|
path = deps.export_query_csv(qr, "adhoc", param_values)
|
|
413
429
|
elif fmt == "json":
|
|
414
430
|
path = deps.export_query_json(qr, "adhoc", param_values)
|
|
415
|
-
|
|
431
|
+
elif fmt == "txt":
|
|
416
432
|
path = deps.export_query_txt(qr, "adhoc", param_values)
|
|
433
|
+
elif fmt == "html":
|
|
434
|
+
path = deps.export_query_html(qr, "adhoc", param_values)
|
|
435
|
+
else:
|
|
436
|
+
_fail_or_print(args, "sql", "usage", f"Formato de export invalido: {fmt}")
|
|
417
437
|
if args.format == "json":
|
|
418
438
|
ok("sql", {"exported": str(path), "format": fmt})
|
|
419
439
|
return
|
|
@@ -39,6 +39,7 @@ from dbqm.core.exporter import (
|
|
|
39
39
|
export_query_txt,
|
|
40
40
|
)
|
|
41
41
|
from dbqm.core.group_engine import build_group_result
|
|
42
|
+
from dbqm.core.html_report import export_group_html, export_query_html
|
|
42
43
|
from dbqm.core.history import (
|
|
43
44
|
clear_history,
|
|
44
45
|
load_history,
|
|
@@ -83,9 +84,11 @@ __all__ = [
|
|
|
83
84
|
"export_group_flat_csv",
|
|
84
85
|
"export_group_flat_json",
|
|
85
86
|
"export_group_flat_txt",
|
|
87
|
+
"export_group_html",
|
|
86
88
|
"export_group_json",
|
|
87
89
|
"export_group_txt",
|
|
88
90
|
"export_query_csv",
|
|
91
|
+
"export_query_html",
|
|
89
92
|
"export_query_json",
|
|
90
93
|
"export_query_txt",
|
|
91
94
|
"extract_ddl",
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, Callable, Protocol
|
|
6
6
|
|
|
7
|
-
from dbqm.core.query_engine import QueryResult
|
|
7
|
+
from dbqm.core.query_engine import AdhocResult, QueryResult
|
|
8
|
+
from dbqm.models.connection import Connection
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@dataclass
|
|
@@ -68,6 +69,144 @@ class GroupResult:
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
class NoComparableColumns(Exception):
|
|
73
|
+
"""The results have no column in common, so there is nothing to compare."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ResultLike(Protocol):
|
|
77
|
+
"""What the comparison actually reads. `run_comparison` has always been
|
|
78
|
+
annotated `QueryResult` while the Multi-Exec screen passed it
|
|
79
|
+
`AdhocResult`; naming the real requirement retires that mismatch for new
|
|
80
|
+
code without touching the old signature."""
|
|
81
|
+
columns: list[str]
|
|
82
|
+
rows: list[list[Any]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def derive_comparison_columns(
|
|
86
|
+
results: dict[str, ResultLike],
|
|
87
|
+
) -> tuple[str, list[str]]:
|
|
88
|
+
"""Derive the join key and compare columns from the columns common to
|
|
89
|
+
every result.
|
|
90
|
+
|
|
91
|
+
The first result's column order wins -- not sorted, not any other
|
|
92
|
+
result's order. The join key is the first common column, the compare
|
|
93
|
+
columns are the rest. Raises `NoComparableColumns` when no column is
|
|
94
|
+
common to every result, or when there is no result to derive from at all.
|
|
95
|
+
"""
|
|
96
|
+
if not results:
|
|
97
|
+
raise NoComparableColumns(
|
|
98
|
+
"Consultas nao retornaram colunas comparaveis."
|
|
99
|
+
)
|
|
100
|
+
first = next(iter(results))
|
|
101
|
+
base_cols = list(results[first].columns)
|
|
102
|
+
common = [
|
|
103
|
+
c for c in base_cols
|
|
104
|
+
if all(c in r.columns for r in results.values())
|
|
105
|
+
]
|
|
106
|
+
if not common:
|
|
107
|
+
raise NoComparableColumns(
|
|
108
|
+
"Consultas nao retornaram colunas comparaveis."
|
|
109
|
+
)
|
|
110
|
+
return common[0], common[1:]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_adhoc_group_result(
|
|
114
|
+
results: dict[str, ResultLike],
|
|
115
|
+
join_key: str = "",
|
|
116
|
+
compare_columns: list[str] | None = None,
|
|
117
|
+
) -> GroupResult:
|
|
118
|
+
"""Build a `GroupResult` for an ad-hoc, group-name-less comparison.
|
|
119
|
+
|
|
120
|
+
When `join_key` is not given, both it and `compare_columns` are derived
|
|
121
|
+
from the columns common to every result (see `derive_comparison_columns`).
|
|
122
|
+
"""
|
|
123
|
+
if not join_key:
|
|
124
|
+
join_key, compare_columns = derive_comparison_columns(results)
|
|
125
|
+
elif compare_columns is None:
|
|
126
|
+
compare_columns = []
|
|
127
|
+
|
|
128
|
+
comparisons = run_comparison(results, join_key, compare_columns)
|
|
129
|
+
all_match = all(
|
|
130
|
+
c.diff_count == 0 and c.absent_count == 0 for c in comparisons
|
|
131
|
+
)
|
|
132
|
+
return GroupResult(
|
|
133
|
+
group_name="(ad-hoc)",
|
|
134
|
+
query_results=results,
|
|
135
|
+
comparisons=comparisons,
|
|
136
|
+
all_match=all_match,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def execute_across(
|
|
141
|
+
sql: str,
|
|
142
|
+
conns: list[tuple[str, Connection | None]],
|
|
143
|
+
param_values: dict[str, str],
|
|
144
|
+
on_progress: Callable[[str], None] | None = None,
|
|
145
|
+
on_result: Callable[[str, AdhocResult], None] | None = None,
|
|
146
|
+
on_missing: Callable[[str], None] | None = None,
|
|
147
|
+
) -> dict[str, AdhocResult]:
|
|
148
|
+
"""Run the same SQL on each resolved connection, in order.
|
|
149
|
+
|
|
150
|
+
`conns` is a list of `(name, connection)` pairs, already resolved by the
|
|
151
|
+
caller -- core does not look connections up itself. `dbqm/cli/deps.py`
|
|
152
|
+
exists precisely so the CLI's connection lookup can be rebound in tests;
|
|
153
|
+
a lookup done here would route around that seam. A pair whose connection
|
|
154
|
+
is `None` fires `on_missing(name)`, in sequence with the pairs around it,
|
|
155
|
+
and contributes **no entry** to the returned dict -- it was never run,
|
|
156
|
+
so there is nothing to report. A pair that *did* run, successfully or
|
|
157
|
+
not, always gets an entry; Task 3 tells the two apart by entry
|
|
158
|
+
membership (`not_found`) versus `AdhocResult.error_kind` (`connection_failed`
|
|
159
|
+
vs `sql_error`).
|
|
160
|
+
|
|
161
|
+
Decides nothing about what a failure means -- the TUI carries on so a
|
|
162
|
+
dead connection does not discard the comparison on screen, the CLI stops
|
|
163
|
+
because a comparison over a subset answers a different question. A core
|
|
164
|
+
that picked one policy would force the other to work around it.
|
|
165
|
+
|
|
166
|
+
A raised exception becomes an unsuccessful AdhocResult with
|
|
167
|
+
`error_kind="connection"`, so one unreachable host cannot end the loop.
|
|
168
|
+
|
|
169
|
+
`on_progress`, `on_result` and `on_missing` each fire once per pair, in
|
|
170
|
+
the same sequential order as `conns` -- a caller that wants to react
|
|
171
|
+
per-connection (notify, log, stop) does not have to wait for every other
|
|
172
|
+
connection to finish first.
|
|
173
|
+
"""
|
|
174
|
+
from dbqm.core.query_engine import execute_adhoc
|
|
175
|
+
|
|
176
|
+
results: dict[str, AdhocResult] = {}
|
|
177
|
+
for name, conn in conns:
|
|
178
|
+
if conn is None:
|
|
179
|
+
if on_missing is not None:
|
|
180
|
+
on_missing(name)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if on_progress is not None:
|
|
184
|
+
on_progress(name)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
res = execute_adhoc(sql, conn, param_values)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
res = AdhocResult(
|
|
190
|
+
sql_type="",
|
|
191
|
+
connection_name=name,
|
|
192
|
+
sql=sql,
|
|
193
|
+
db_type=conn.db_type,
|
|
194
|
+
success=False,
|
|
195
|
+
error=str(e),
|
|
196
|
+
error_kind="connection",
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
# DML without auto_commit returns (AdhocResult, db_connection).
|
|
200
|
+
if isinstance(res, tuple):
|
|
201
|
+
res = res[0]
|
|
202
|
+
|
|
203
|
+
results[name] = res
|
|
204
|
+
if on_result is not None:
|
|
205
|
+
on_result(name, res)
|
|
206
|
+
|
|
207
|
+
return results
|
|
208
|
+
|
|
209
|
+
|
|
71
210
|
def run_comparison(
|
|
72
211
|
results: dict[str, QueryResult],
|
|
73
212
|
join_key: str,
|
|
@@ -6,6 +6,7 @@ from html import escape as h
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
from dbqm.core.group_engine import GroupResult
|
|
9
|
+
from dbqm.core.query_engine import QueryResult
|
|
9
10
|
from dbqm.core.exporter import _build_filepath
|
|
10
11
|
from dbqm.design.tokens import LIGHT_TOKENS, DARK_TOKENS
|
|
11
12
|
|
|
@@ -29,6 +30,25 @@ def _light_theme_block(tokens: dict[str, str]) -> str:
|
|
|
29
30
|
return f"@media (prefers-color-scheme: light) {{\n :root {{\n{linhas}\n }}\n}}"
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
#: Layout rules shared by every report shell -- reset, body, header, the
|
|
34
|
+
#: params table and the data table. Renderer-specific rules (status
|
|
35
|
+
#: colouring, filter bar, search box, …) stay in their own `_build_*`
|
|
36
|
+
#: function; only what both renderers actually share lives here, so a change
|
|
37
|
+
#: to shared padding cannot drift between the two documents.
|
|
38
|
+
_BASE_STYLE_RULES = """\
|
|
39
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
40
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--ds-background); color: var(--ds-text); padding: 24px; }
|
|
41
|
+
.header { background: var(--ds-panel); border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
42
|
+
.header h1 { color: var(--ds-identity); font-size: 1.4em; }
|
|
43
|
+
.header .meta { color: var(--ds-text-muted); font-size: 0.85em; margin-top: 8px; }
|
|
44
|
+
.params { margin: 12px 0; border-collapse: collapse; }
|
|
45
|
+
.params td { padding: 4px 16px 4px 0; color: var(--ds-text-muted); font-size: 0.9em; }
|
|
46
|
+
table.data { width: 100%; border-collapse: collapse; margin-bottom: 8px; font-size: 0.9em; }
|
|
47
|
+
table.data th { background: var(--ds-panel); color: var(--ds-identity); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--ds-border); }
|
|
48
|
+
table.data td { padding: 6px 12px; border-bottom: 1px solid var(--ds-border); }
|
|
49
|
+
table.data tr:hover { background: var(--ds-surface-raised); }"""
|
|
50
|
+
|
|
51
|
+
|
|
32
52
|
def export_group_html(group_result: GroupResult, params: dict[str, str] | None = None) -> str:
|
|
33
53
|
"""Export group comparison as a standalone HTML file. Returns file path."""
|
|
34
54
|
query_names = list(group_result.query_results.keys())
|
|
@@ -39,6 +59,18 @@ def export_group_html(group_result: GroupResult, params: dict[str, str] | None =
|
|
|
39
59
|
return str(filepath)
|
|
40
60
|
|
|
41
61
|
|
|
62
|
+
def export_query_html(
|
|
63
|
+
result: QueryResult,
|
|
64
|
+
table: str = "",
|
|
65
|
+
params: dict[str, str] | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Export one result set as a standalone HTML file. Returns file path."""
|
|
68
|
+
label = table or result.query_name
|
|
69
|
+
filepath = _build_filepath("consultas", label, result.connection_name, params, "html")
|
|
70
|
+
filepath.write_text(_build_query_html(result, label, params), encoding="utf-8")
|
|
71
|
+
return str(filepath)
|
|
72
|
+
|
|
73
|
+
|
|
42
74
|
def _status_class(status: str) -> str:
|
|
43
75
|
return {"OK": "ok", "OK*": "ok", "DIFF": "diff", "ABSENT": "absent"}.get(status, "")
|
|
44
76
|
|
|
@@ -106,26 +138,16 @@ def _build_html(group_result: GroupResult, query_names: list[str], params: dict[
|
|
|
106
138
|
despercebido. Por isso: nenhuma cor composta em CSS pode carregar
|
|
107
139
|
texto; texto so usa var(--token) puro, e estado em superficie e
|
|
108
140
|
borda/marcador, nunca preenchimento. */
|
|
109
|
-
|
|
110
|
-
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--ds-background); color: var(--ds-text); padding: 24px; }}
|
|
111
|
-
.header {{ background: var(--ds-panel); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
|
|
112
|
-
.header h1 {{ color: var(--ds-identity); font-size: 1.4em; }}
|
|
113
|
-
.header .meta {{ color: var(--ds-text-muted); font-size: 0.85em; margin-top: 8px; }}
|
|
141
|
+
{_BASE_STYLE_RULES}
|
|
114
142
|
.badge {{ display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; font-size: 0.85em; }}
|
|
115
143
|
/* Estado em superficie e borda/marcador, nunca preenchimento: um preenchimento
|
|
116
144
|
misturado a partir do proprio texto sempre compromete um dos dois. */
|
|
117
145
|
.badge.ok {{ background: var(--ds-panel); color: var(--ds-verdict-match); border: 1px solid var(--ds-verdict-match); }}
|
|
118
146
|
.badge.diff {{ background: var(--ds-panel); color: var(--ds-verdict-diff); border: 1px solid var(--ds-verdict-diff); }}
|
|
119
|
-
.params {{ margin: 12px 0; border-collapse: collapse; }}
|
|
120
|
-
.params td {{ padding: 4px 16px 4px 0; color: var(--ds-text-muted); font-size: 0.9em; }}
|
|
121
147
|
h3 {{ color: var(--ds-identity); margin: 24px 0 8px; }}
|
|
122
148
|
.filter-bar {{ margin-bottom: 8px; }}
|
|
123
149
|
.filter-btn {{ background: var(--ds-panel); color: var(--ds-text-muted); border: 1px solid var(--ds-border); padding: 4px 12px; border-radius: 4px; cursor: pointer; margin-right: 4px; font-size: 0.8em; }}
|
|
124
150
|
.filter-btn.active {{ background: var(--ds-surface-raised); color: var(--ds-identity); border-color: var(--ds-identity); }}
|
|
125
|
-
table.data {{ width: 100%; border-collapse: collapse; margin-bottom: 8px; font-size: 0.9em; }}
|
|
126
|
-
table.data th {{ background: var(--ds-panel); color: var(--ds-identity); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--ds-border); }}
|
|
127
|
-
table.data td {{ padding: 6px 12px; border-bottom: 1px solid var(--ds-border); }}
|
|
128
|
-
table.data tr:hover {{ background: var(--ds-surface-raised); }}
|
|
129
151
|
.key {{ color: var(--ds-text-strong); font-weight: 600; }}
|
|
130
152
|
.ok {{ color: var(--ds-verdict-match); font-weight: 600; }}
|
|
131
153
|
.diff {{ color: var(--ds-verdict-diff); font-weight: 600; }}
|
|
@@ -182,3 +204,53 @@ function filterSearch(term) {{
|
|
|
182
204
|
</script>
|
|
183
205
|
</body>
|
|
184
206
|
</html>"""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _build_query_html(result: QueryResult, label: str, params: dict[str, str] | None) -> str:
|
|
210
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
211
|
+
|
|
212
|
+
params_html = ""
|
|
213
|
+
if params:
|
|
214
|
+
rows = "".join(
|
|
215
|
+
f"<tr><td><strong>{h(str(k))}</strong></td><td>{h(str(v))}</td></tr>"
|
|
216
|
+
for k, v in params.items()
|
|
217
|
+
)
|
|
218
|
+
params_html = f'<div class="meta">Parametros:</div><table class="params">{rows}</table>'
|
|
219
|
+
|
|
220
|
+
header_cols = "".join(f"<th>{h(str(col))}</th>" for col in result.columns)
|
|
221
|
+
rows_html = ""
|
|
222
|
+
for row in result.rows:
|
|
223
|
+
cells = "".join(f"<td>{'' if v is None else h(str(v))}</td>" for v in row)
|
|
224
|
+
rows_html += f"<tr>{cells}</tr>"
|
|
225
|
+
|
|
226
|
+
return f"""<!DOCTYPE html>
|
|
227
|
+
<html lang="pt-BR">
|
|
228
|
+
<head>
|
|
229
|
+
<meta charset="UTF-8">
|
|
230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
231
|
+
<title>Relatorio - {h(label)}</title>
|
|
232
|
+
<style>
|
|
233
|
+
{css_variables(DARK_TOKENS)}
|
|
234
|
+
{_light_theme_block(LIGHT_TOKENS)}
|
|
235
|
+
{_BASE_STYLE_RULES}
|
|
236
|
+
</style>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div class="header">
|
|
240
|
+
<h1>DB Query Manager - Relatorio de Consulta</h1>
|
|
241
|
+
<div class="meta">
|
|
242
|
+
Consulta: <strong>{h(label)}</strong> |
|
|
243
|
+
Conexao: <strong>{h(result.connection_name)}</strong> |
|
|
244
|
+
Data: {now} |
|
|
245
|
+
Linhas: {result.row_count} |
|
|
246
|
+
Tempo: {result.elapsed:.3f}s
|
|
247
|
+
</div>
|
|
248
|
+
{params_html}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<table class="data">
|
|
252
|
+
<thead><tr>{header_cols}</tr></thead>
|
|
253
|
+
<tbody>{rows_html}</tbody>
|
|
254
|
+
</table>
|
|
255
|
+
</body>
|
|
256
|
+
</html>"""
|
|
@@ -256,51 +256,44 @@ class GroupExecScreen(Vertical):
|
|
|
256
256
|
def _run(self, sql: str, conn_names: list[str]) -> None:
|
|
257
257
|
"""Run the same SQL on each connection and compare the results."""
|
|
258
258
|
from dbqm.models.connection import find_connection
|
|
259
|
-
from dbqm.core.
|
|
260
|
-
|
|
259
|
+
from dbqm.core.group_engine import (
|
|
260
|
+
NoComparableColumns,
|
|
261
|
+
build_adhoc_group_result,
|
|
262
|
+
execute_across,
|
|
263
|
+
)
|
|
264
|
+
from dbqm.core.query_engine import AdhocResult
|
|
261
265
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
self.app.call_from_thread(
|
|
268
|
-
self.notify,
|
|
269
|
-
f"Conexao '{cname}' nao encontrada.",
|
|
270
|
-
severity="warning",
|
|
271
|
-
)
|
|
272
|
-
continue
|
|
266
|
+
def on_progress(cname: str) -> None:
|
|
267
|
+
self.app.call_from_thread(
|
|
268
|
+
self._update_progress,
|
|
269
|
+
f"Executando em [bold]{escape_markup(cname)}[/]...",
|
|
270
|
+
)
|
|
273
271
|
|
|
272
|
+
def on_result(cname: str, res: AdhocResult) -> None:
|
|
273
|
+
if not res.success:
|
|
274
274
|
self.app.call_from_thread(
|
|
275
|
-
self.
|
|
276
|
-
f"
|
|
275
|
+
self.notify,
|
|
276
|
+
f"Erro em '{cname}': {res.error}",
|
|
277
|
+
severity="error",
|
|
278
|
+
timeout=8,
|
|
277
279
|
)
|
|
278
280
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
self.app.call_from_thread(
|
|
296
|
-
self.notify,
|
|
297
|
-
f"Erro em '{cname}': {res.error}",
|
|
298
|
-
severity="error",
|
|
299
|
-
timeout=8,
|
|
300
|
-
)
|
|
301
|
-
continue
|
|
302
|
-
|
|
303
|
-
results[cname] = res
|
|
281
|
+
def on_missing(cname: str) -> None:
|
|
282
|
+
self.app.call_from_thread(
|
|
283
|
+
self.notify,
|
|
284
|
+
f"Conexao '{cname}' nao encontrada.",
|
|
285
|
+
severity="warning",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
conns = [(cname, find_connection(cname)) for cname in conn_names]
|
|
290
|
+
all_results = execute_across(
|
|
291
|
+
sql, conns, {},
|
|
292
|
+
on_progress=on_progress, on_result=on_result, on_missing=on_missing,
|
|
293
|
+
)
|
|
294
|
+
results = {
|
|
295
|
+
cname: res for cname, res in all_results.items() if res.success
|
|
296
|
+
}
|
|
304
297
|
|
|
305
298
|
if len(results) < 1:
|
|
306
299
|
self.app.call_from_thread(
|
|
@@ -308,34 +301,14 @@ class GroupExecScreen(Vertical):
|
|
|
308
301
|
)
|
|
309
302
|
return
|
|
310
303
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
base_cols = list(results[first].columns)
|
|
315
|
-
common = [
|
|
316
|
-
c for c in base_cols
|
|
317
|
-
if all(c in r.columns for r in results.values())
|
|
318
|
-
]
|
|
319
|
-
if not common:
|
|
304
|
+
try:
|
|
305
|
+
group_result = build_adhoc_group_result(results)
|
|
306
|
+
except NoComparableColumns:
|
|
320
307
|
self.app.call_from_thread(
|
|
321
308
|
self._on_error, "Consultas nao retornaram colunas comparaveis."
|
|
322
309
|
)
|
|
323
310
|
return
|
|
324
311
|
|
|
325
|
-
join_key = common[0]
|
|
326
|
-
compare_columns = common[1:]
|
|
327
|
-
|
|
328
|
-
comparisons = run_comparison(results, join_key, compare_columns)
|
|
329
|
-
all_match = all(
|
|
330
|
-
c.diff_count == 0 and c.absent_count == 0 for c in comparisons
|
|
331
|
-
)
|
|
332
|
-
group_result = GroupResult(
|
|
333
|
-
group_name="(ad-hoc)",
|
|
334
|
-
query_results=results,
|
|
335
|
-
comparisons=comparisons,
|
|
336
|
-
all_match=all_match,
|
|
337
|
-
)
|
|
338
|
-
|
|
339
312
|
self.app.call_from_thread(self._show_result, group_result)
|
|
340
313
|
except Exception as e:
|
|
341
314
|
self.app.call_from_thread(self._on_error, f"Erro inesperado: {e}")
|
|
@@ -261,6 +261,20 @@ class TestCmdRun:
|
|
|
261
261
|
run_cli(["run", "test_query", "-e", "csv"])
|
|
262
262
|
mock_exp.assert_called_once()
|
|
263
263
|
|
|
264
|
+
def test_run_export_html(self, tmp_config_dir):
|
|
265
|
+
query = _make_query()
|
|
266
|
+
conn = _make_connection()
|
|
267
|
+
result = _make_query_result()
|
|
268
|
+
|
|
269
|
+
with patch("dbqm.cli.deps.find_query", return_value=query), \
|
|
270
|
+
patch("dbqm.cli.deps.find_connection", return_value=conn), \
|
|
271
|
+
patch("dbqm.cli.deps.execute_query", return_value=result), \
|
|
272
|
+
patch("dbqm.cli.deps.record_query_execution"), \
|
|
273
|
+
patch("dbqm.cli.deps.log_execution"), \
|
|
274
|
+
patch("dbqm.cli.deps.export_query_html", return_value="/tmp/out.html") as mock_exp:
|
|
275
|
+
run_cli(["run", "test_query", "-e", "html"])
|
|
276
|
+
mock_exp.assert_called_once()
|
|
277
|
+
|
|
264
278
|
def test_run_format_json(self, capsys):
|
|
265
279
|
"""`data` is `QueryResult.to_dict()` verbatim: `rows` are parallel
|
|
266
280
|
arrays (a list per row, indexed by `columns`), not a dict per row —
|
|
@@ -428,6 +442,83 @@ class TestCmdRunGroup:
|
|
|
428
442
|
run_cli(["run-group", "test_group", "-e", "csv"])
|
|
429
443
|
mock_exp.assert_called_once()
|
|
430
444
|
|
|
445
|
+
def test_group_export_html(self):
|
|
446
|
+
group = _make_group()
|
|
447
|
+
q1 = _make_query("q1", "c1")
|
|
448
|
+
q2 = _make_query("q2", "c2")
|
|
449
|
+
c1 = _make_connection("c1")
|
|
450
|
+
c2 = _make_connection("c2")
|
|
451
|
+
gr = _make_group_result()
|
|
452
|
+
|
|
453
|
+
def find_query_side(name):
|
|
454
|
+
return {"q1": q1, "q2": q2}.get(name)
|
|
455
|
+
|
|
456
|
+
def find_conn_side(name):
|
|
457
|
+
return {"c1": c1, "c2": c2}.get(name)
|
|
458
|
+
|
|
459
|
+
with patch("dbqm.cli.deps.find_group", return_value=group), \
|
|
460
|
+
patch("dbqm.cli.deps.find_query", side_effect=find_query_side), \
|
|
461
|
+
patch("dbqm.cli.deps.find_connection", side_effect=find_conn_side), \
|
|
462
|
+
patch("dbqm.cli.deps.execute_query", return_value=_make_query_result()), \
|
|
463
|
+
patch("dbqm.cli.deps.build_group_result", return_value=gr), \
|
|
464
|
+
patch("dbqm.cli.deps.record_group_execution"), \
|
|
465
|
+
patch("dbqm.cli.deps.export_group_html", return_value="/tmp/g.html") as mock_exp:
|
|
466
|
+
run_cli(["run-group", "test_group", "-e", "html"])
|
|
467
|
+
mock_exp.assert_called_once()
|
|
468
|
+
|
|
469
|
+
def test_group_flat_html_is_refused_table_format(self, capsys):
|
|
470
|
+
"""The trap: `--flat` is a CSV-shaped denormalisation with no HTML
|
|
471
|
+
equivalent. Silently ignoring the flag would hand back a different
|
|
472
|
+
shape (the non-flat report) with no signal that `--flat` was
|
|
473
|
+
dropped. Checked under `table` too, not just `json`: a refusal that
|
|
474
|
+
only fires under one renderer is the bug class 2.3.0 existed to
|
|
475
|
+
fix.
|
|
476
|
+
|
|
477
|
+
The refusal fires before the group is even resolved (a bad argument
|
|
478
|
+
combination costs no work), so `find_group` is mocked but must never
|
|
479
|
+
be called -- that is also what proves this test is pinned to *this*
|
|
480
|
+
refusal and not to the flat arm's generic bare-`else` fallback,
|
|
481
|
+
which would also exit 2 with no export call but only after
|
|
482
|
+
resolving the group and running every query in it.
|
|
483
|
+
"""
|
|
484
|
+
with patch("dbqm.cli.deps.find_group") as mock_find_group, \
|
|
485
|
+
patch("dbqm.cli.deps.export_group_flat_csv") as mock_flat_csv, \
|
|
486
|
+
patch("dbqm.cli.deps.export_group_flat_json") as mock_flat_json, \
|
|
487
|
+
patch("dbqm.cli.deps.export_group_flat_txt") as mock_flat_txt:
|
|
488
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
489
|
+
run_cli(["run-group", "test_group", "--flat", "-e", "html"])
|
|
490
|
+
assert excinfo.value.code == 2
|
|
491
|
+
saida = capsys.readouterr().out
|
|
492
|
+
assert "--flat" in saida, "must name the refused flag, not just exit 2"
|
|
493
|
+
assert "Exportado:" not in saida, "no export must have happened"
|
|
494
|
+
mock_find_group.assert_not_called()
|
|
495
|
+
mock_flat_csv.assert_not_called()
|
|
496
|
+
mock_flat_json.assert_not_called()
|
|
497
|
+
mock_flat_txt.assert_not_called()
|
|
498
|
+
|
|
499
|
+
def test_group_flat_html_is_refused_json_format(self, capsys):
|
|
500
|
+
"""Same refusal, `-f json` side: the contract is that stdout stays
|
|
501
|
+
empty and the failure carries the usage token on stderr. Also fires
|
|
502
|
+
before the group is resolved -- see the table-format sibling above
|
|
503
|
+
for why `find_group` must stay uncalled."""
|
|
504
|
+
with patch("dbqm.cli.deps.find_group") as mock_find_group, \
|
|
505
|
+
patch("dbqm.cli.deps.export_group_flat_csv") as mock_flat_csv, \
|
|
506
|
+
patch("dbqm.cli.deps.export_group_flat_json") as mock_flat_json, \
|
|
507
|
+
patch("dbqm.cli.deps.export_group_flat_txt") as mock_flat_txt:
|
|
508
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
509
|
+
run_cli(["run-group", "test_group", "--flat", "-e", "html", "-f", "json"])
|
|
510
|
+
assert excinfo.value.code == 2
|
|
511
|
+
saida = capsys.readouterr()
|
|
512
|
+
assert saida.out == ""
|
|
513
|
+
corpo = json.loads(saida.err)
|
|
514
|
+
assert corpo["error"]["code"] == "usage"
|
|
515
|
+
assert "--flat" in corpo["error"]["message"]
|
|
516
|
+
assert "html" in corpo["error"]["message"]
|
|
517
|
+
mock_find_group.assert_not_called()
|
|
518
|
+
mock_flat_csv.assert_not_called()
|
|
519
|
+
mock_flat_json.assert_not_called()
|
|
520
|
+
mock_flat_txt.assert_not_called()
|
|
521
|
+
|
|
431
522
|
def test_group_export_json_format_emits_envelope(self, capsys):
|
|
432
523
|
"""CRITICAL fix: `-e` used to print bare prose and return under
|
|
433
524
|
`-f json`."""
|
|
@@ -683,6 +774,24 @@ class TestCmdSql:
|
|
|
683
774
|
run_cli(["sql", "SELECT 1", "test_conn", "-e", "csv"])
|
|
684
775
|
mock_exp.assert_called_once()
|
|
685
776
|
|
|
777
|
+
def test_sql_export_html_does_not_fall_through_to_txt(self):
|
|
778
|
+
"""The trap: `sql`'s export branch used to end in a bare `else` that
|
|
779
|
+
wrote TXT, so a new format would have written the wrong file and
|
|
780
|
+
reported success."""
|
|
781
|
+
conn = _make_connection()
|
|
782
|
+
from dbqm.core.query_engine import AdhocResult
|
|
783
|
+
adhoc = AdhocResult(
|
|
784
|
+
sql_type="SELECT", connection_name="test_conn",
|
|
785
|
+
columns=["x"], rows=[[1]], row_count=1, elapsed=0.01,
|
|
786
|
+
)
|
|
787
|
+
with patch("dbqm.cli.deps.find_connection", return_value=conn), \
|
|
788
|
+
patch("dbqm.cli.deps.execute_adhoc", return_value=adhoc), \
|
|
789
|
+
patch("dbqm.cli.deps.export_query_html", return_value="/tmp/o.html") as mock_html, \
|
|
790
|
+
patch("dbqm.cli.deps.export_query_txt") as mock_txt:
|
|
791
|
+
run_cli(["sql", "SELECT 1", "test_conn", "-e", "html"])
|
|
792
|
+
mock_html.assert_called_once()
|
|
793
|
+
mock_txt.assert_not_called()
|
|
794
|
+
|
|
686
795
|
def test_sql_export_json_format_emits_envelope(self, capsys):
|
|
687
796
|
"""CRITICAL fix: `-e` used to print bare prose and return under
|
|
688
797
|
`-f json`."""
|
dbqm-2.3.2/dbqm/_version.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.3.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|