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.
Files changed (106) hide show
  1. {dbqm-2.3.2 → dbqm-2.4.1}/PKG-INFO +1 -1
  2. {dbqm-2.3.2 → dbqm-2.4.1}/README.md +3 -1
  3. dbqm-2.4.1/dbqm/_version.py +1 -0
  4. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/__init__.py +4 -4
  5. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/query.py +23 -3
  6. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/deps.py +3 -0
  7. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/group_engine.py +141 -2
  8. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/html_report.py +83 -11
  9. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_exec.py +36 -63
  10. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/PKG-INFO +1 -1
  11. {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli.py +109 -0
  12. dbqm-2.3.2/dbqm/_version.py +0 -1
  13. {dbqm-2.3.2 → dbqm-2.4.1}/PYPI.md +0 -0
  14. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/__init__.py +0 -0
  15. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/__main__.py +0 -0
  16. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/__init__.py +0 -0
  17. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/config_bundle.py +0 -0
  18. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/connection.py +0 -0
  19. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/inspect.py +0 -0
  20. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/commands/schema.py +0 -0
  21. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/envelope.py +0 -0
  22. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/errors.py +0 -0
  23. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/params.py +0 -0
  24. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/cli/render.py +0 -0
  25. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/__init__.py +0 -0
  26. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/audit.py +0 -0
  27. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/config_portability.py +0 -0
  28. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/connection_builder.py +0 -0
  29. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/crypto.py +0 -0
  30. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/db_manager.py +0 -0
  31. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_extractor.py +0 -0
  32. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_mysql.py +0 -0
  33. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/ddl_pg.py +0 -0
  34. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/exporter.py +0 -0
  35. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/history.py +0 -0
  36. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/object_browser.py +0 -0
  37. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/oracle_client_installer.py +0 -0
  38. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/package_editor.py +0 -0
  39. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/paths.py +0 -0
  40. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/query_engine.py +0 -0
  41. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/read_only.py +0 -0
  42. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/table_browser.py +0 -0
  43. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/core/template_engine.py +0 -0
  44. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/design/__init__.py +0 -0
  45. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/design/tokens.py +0 -0
  46. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/main.py +0 -0
  47. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/__init__.py +0 -0
  48. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/connection.py +0 -0
  49. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/group.py +0 -0
  50. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/query.py +0 -0
  51. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/settings.py +0 -0
  52. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/models/template.py +0 -0
  53. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/__init__.py +0 -0
  54. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/app.py +0 -0
  55. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/__init__.py +0 -0
  56. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/column_maps.py +0 -0
  57. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/confirm.py +0 -0
  58. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/error.py +0 -0
  59. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/export_dir_setup.py +0 -0
  60. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/export_picker.py +0 -0
  61. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/help.py +0 -0
  62. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/oracle_client_dir.py +0 -0
  63. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/param_input.py +0 -0
  64. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/modals/text_input.py +0 -0
  65. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/__init__.py +0 -0
  66. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/adhoc.py +0 -0
  67. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/browser.py +0 -0
  68. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/config_port.py +0 -0
  69. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/connections.py +0 -0
  70. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/exec_routine.py +0 -0
  71. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_manage.py +0 -0
  72. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/group_run.py +0 -0
  73. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/history.py +0 -0
  74. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/oracle_clients.py +0 -0
  75. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/package_editor.py +0 -0
  76. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/query_exec.py +0 -0
  77. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/query_manage.py +0 -0
  78. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/settings.py +0 -0
  79. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/template_manage.py +0 -0
  80. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/screens/tools.py +0 -0
  81. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/theme.py +0 -0
  82. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/utils.py +0 -0
  83. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/__init__.py +0 -0
  84. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/action_bar.py +0 -0
  85. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/dialog.py +0 -0
  86. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/empty_state.py +0 -0
  87. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/group_result.py +0 -0
  88. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/hierarchical_list.py +0 -0
  89. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/panel.py +0 -0
  90. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/progress.py +0 -0
  91. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/query_list.py +0 -0
  92. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/result_table.py +0 -0
  93. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/skeleton.py +0 -0
  94. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/sql_viewer.py +0 -0
  95. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/status_bar.py +0 -0
  96. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/templates_sidebar.py +0 -0
  97. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm/ui/widgets/verdict.py +0 -0
  98. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/SOURCES.txt +0 -0
  99. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/dependency_links.txt +0 -0
  100. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/entry_points.txt +0 -0
  101. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/requires.txt +0 -0
  102. {dbqm-2.3.2 → dbqm-2.4.1}/dbqm.egg-info/top_level.txt +0 -0
  103. {dbqm-2.3.2 → dbqm-2.4.1}/pyproject.toml +0 -0
  104. {dbqm-2.3.2 → dbqm-2.4.1}/setup.cfg +0 -0
  105. {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli_markup.py +0 -0
  106. {dbqm-2.3.2 → dbqm-2.4.1}/tests/test_cli_tema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbqm
3
- Version: 2.3.2
3
+ Version: 2.4.1
4
4
  Summary: Multi-database interactive query tool with fullscreen TUI
5
5
  Author: Silvio Chagas
6
6
  License-Expression: MIT
@@ -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
- else:
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
- else:
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
- else:
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
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
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.query_engine import execute_adhoc
260
- from dbqm.core.group_engine import run_comparison, GroupResult
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
- try:
263
- results = {}
264
- for cname in conn_names:
265
- conn = find_connection(cname)
266
- if conn is None:
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._update_progress,
276
- f"Executando em [bold]{escape_markup(cname)}[/]...",
275
+ self.notify,
276
+ f"Erro em '{cname}': {res.error}",
277
+ severity="error",
278
+ timeout=8,
277
279
  )
278
280
 
279
- try:
280
- res = execute_adhoc(sql, conn, {})
281
- except Exception as e:
282
- self.app.call_from_thread(
283
- self.notify,
284
- f"Erro em '{cname}': {e}",
285
- severity="error",
286
- timeout=8,
287
- )
288
- continue
289
-
290
- # DML without auto_commit returns (AdhocResult, db_connection).
291
- if isinstance(res, tuple):
292
- res = res[0]
293
-
294
- if not res.success:
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
- # Auto-derive the join key + compare columns from the columns that
312
- # are common to every result (defensive intersection).
313
- first = next(iter(results))
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}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbqm
3
- Version: 2.3.2
3
+ Version: 2.4.1
4
4
  Summary: Multi-database interactive query tool with fullscreen TUI
5
5
  Author: Silvio Chagas
6
6
  License-Expression: MIT
@@ -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`."""
@@ -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