Skip to content

Commit a212b14

Browse files
author
symphony-dbcli
committed
Fix #238: Exporting as a dot file (How to ?)
1 parent 6a3e595 commit a212b14

7 files changed

Lines changed: 140 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Unreleased
22

3+
### Features
4+
5+
- Add a `dot` table format for exporting query results as Graphviz DOT.
6+
37
### Bug Fixes
48

59
- Expand `~` in configured log file paths before opening the log.

litecli/liteclirc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ log_level = INFO
3939
# Table format. Possible values:
4040
# ascii, double, github, psql, plain, simple, grid, fancy_grid, pipe, orgtbl,
4141
# rst, mediawiki, html, latex, latex_booktabs, textile, moinmoin, jira,
42-
# vertical, tsv, csv.
42+
# vertical, tsv, csv, dot.
4343
# Recommended: ascii
4444
table_format = ascii
4545

litecli/main.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .key_bindings import cli_bindings
4141
from .lexer import LiteCliLexer
4242
from .packages import special
43+
from .packages.dot_output import format_dot_output
4344
from .packages.filepaths import dir_path_exists
4445
from .packages.prompt_utils import confirm, confirm_destructive_query
4546
from .packages.special.main import NO_QUERY
@@ -60,6 +61,7 @@ def _load_sqlite3() -> Any:
6061
_sqlite3 = _load_sqlite3()
6162
OperationalError = _sqlite3.OperationalError
6263
sqlite_version = _sqlite3.sqlite_version
64+
LOCAL_OUTPUT_FORMATS = ("dot",)
6365

6466
# Query tuples are used for maintaining history
6567
Query = namedtuple("Query", ["query", "successful", "mutating"])
@@ -89,7 +91,11 @@ def __init__(
8991
self.multi_line = c["main"].as_bool("multi_line")
9092
self.key_bindings = c["main"]["key_bindings"]
9193
special.set_favorite_queries(self.config)
92-
self.formatter = TabularOutputFormatter(format_name=c["main"]["table_format"])
94+
self.local_format_name: str | None = None
95+
config_table_format = c["main"]["table_format"]
96+
self.formatter = TabularOutputFormatter(format_name="ascii" if config_table_format in LOCAL_OUTPUT_FORMATS else config_table_format)
97+
if config_table_format in LOCAL_OUTPUT_FORMATS:
98+
self.local_format_name = config_table_format
9399
# self.formatter.litecli = self, ty raises unresolved-attribute, hence use dynamic assignment
94100
setattr(self.formatter, "litecli", self)
95101
self.syntax_style = c["main"]["syntax_style"]
@@ -137,7 +143,7 @@ def __init__(
137143

138144
# Initialize completer.
139145
self.completer = SQLCompleter(
140-
supported_formats=self.formatter.supported_formats,
146+
supported_formats=self.supported_table_formats(),
141147
keyword_casing=keyword_casing,
142148
)
143149
self._completer_lock = threading.Lock()
@@ -188,13 +194,31 @@ def register_special_commands(self) -> None:
188194
case_sensitive=True,
189195
)
190196

197+
def supported_table_formats(self) -> list[str]:
198+
supported_formats = list(self.formatter.supported_formats)
199+
for format_name in LOCAL_OUTPUT_FORMATS:
200+
if format_name not in supported_formats:
201+
supported_formats.append(format_name)
202+
return supported_formats
203+
204+
def current_table_format(self) -> str:
205+
return self.local_format_name or self.formatter.format_name
206+
207+
def set_table_format(self, format_name: str) -> None:
208+
if format_name in LOCAL_OUTPUT_FORMATS:
209+
self.local_format_name = format_name
210+
return
211+
212+
self.formatter.format_name = format_name
213+
self.local_format_name = None
214+
191215
def change_table_format(self, arg: str, **_: Any) -> Generator[tuple[None, None, None, str], None, None]:
192216
try:
193-
self.formatter.format_name = arg
217+
self.set_table_format(arg)
194218
yield (None, None, None, "Changed table format to {}".format(arg))
195219
except ValueError:
196220
msg = "Table format {} not recognized. Allowed formats:".format(arg)
197-
for table_type in self.formatter.supported_formats:
221+
for table_type in self.supported_table_formats():
198222
msg += "\n\t{}".format(table_type)
199223
yield (None, None, None, msg)
200224

@@ -839,7 +863,8 @@ def run_query(self, query: str, new_line: bool = True) -> None:
839863
click.echo(line, nl=new_line)
840864

841865
def format_output(self, title: Any, cur: Any, headers: Any, expanded: bool = False, max_width: int | None = None) -> Iterable[str]:
842-
expanded = expanded or self.formatter.format_name == "vertical"
866+
format_name = self.current_table_format()
867+
expanded = expanded or format_name == "vertical"
843868
output_iter: Iterable[str] = []
844869

845870
output_kwargs = {
@@ -854,6 +879,9 @@ def format_output(self, title: Any, cur: Any, headers: Any, expanded: bool = Fal
854879
output_iter = itertools.chain(output_iter, [title])
855880

856881
if cur:
882+
if format_name == "dot":
883+
return itertools.chain(output_iter, format_dot_output(cur, headers or []))
884+
857885
column_types = None
858886
if hasattr(cur, "description"):
859887
column_types = [str(col) for col in cur.description]
@@ -972,9 +1000,9 @@ def cli(
9721000
if execute:
9731001
try:
9741002
if csv:
975-
litecli.formatter.format_name = "csv"
1003+
litecli.set_table_format("csv")
9761004
elif not table:
977-
litecli.formatter.format_name = "tsv"
1005+
litecli.set_table_format("tsv")
9781006

9791007
litecli.run_query(execute)
9801008
exit(0)
@@ -999,9 +1027,9 @@ def cli(
9991027
new_line = True
10001028

10011029
if csv:
1002-
litecli.formatter.format_name = "csv"
1030+
litecli.set_table_format("csv")
10031031
elif not table:
1004-
litecli.formatter.format_name = "tsv"
1032+
litecli.set_table_format("tsv")
10051033

10061034
litecli.run_query(stdin_text, new_line=new_line)
10071035
exit(0)

litecli/packages/dot_output.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Iterable
4+
5+
6+
def _dot_value(value: Any) -> str:
7+
if value is None:
8+
return "NULL"
9+
return str(value)
10+
11+
12+
def _dot_quote(value: Any) -> str:
13+
text = _dot_value(value)
14+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r") + '"'
15+
16+
17+
def format_dot_output(rows: Iterable[Iterable[Any]], headers: Iterable[str]) -> Iterable[str]:
18+
"""Format one-column results as nodes and multi-column results as edges."""
19+
header_names = list(headers)
20+
21+
yield "digraph result {"
22+
23+
if header_names:
24+
yield " // Columns: {}".format(", ".join(header_names))
25+
26+
for row in rows:
27+
row_values = list(row)
28+
if not row_values:
29+
continue
30+
31+
if len(row_values) == 1:
32+
yield " {};".format(_dot_quote(row_values[0]))
33+
continue
34+
35+
label = ""
36+
if len(row_values) > 2:
37+
label_values = []
38+
for index, value in enumerate(row_values[2:], start=2):
39+
column_name = header_names[index] if index < len(header_names) else "column{}".format(index + 1)
40+
label_values.append("{}={}".format(column_name, _dot_value(value)))
41+
label = " [label={}]".format(_dot_quote(", ".join(label_values)))
42+
43+
yield " {} -> {}{};".format(_dot_quote(row_values[0]), _dot_quote(row_values[1]), label)
44+
45+
yield "}"

tests/liteclirc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ log_level = INFO
3232
# Table format. Possible values:
3333
# ascii, double, github, psql, plain, simple, grid, fancy_grid, pipe, orgtbl,
3434
# rst, mediawiki, html, latex, latex_booktabs, textile, moinmoin, jira,
35-
# vertical, tsv, csv.
35+
# vertical, tsv, csv, dot.
3636
# Recommended: ascii
3737
table_format = ascii
3838

tests/test_dot_output.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from litecli.packages.dot_output import format_dot_output
2+
3+
4+
def test_dot_output_formats_edges():
5+
rows = [("orders", "customers"), ("line_items", "orders")]
6+
headers = ["child", "parent"]
7+
8+
assert list(format_dot_output(rows, headers)) == [
9+
"digraph result {",
10+
" // Columns: child, parent",
11+
' "orders" -> "customers";',
12+
' "line_items" -> "orders";',
13+
"}",
14+
]
15+
16+
17+
def test_dot_output_formats_nodes_and_escapes_values():
18+
rows = [('a"b',), ("line\nbreak",), (None,)]
19+
20+
assert list(format_dot_output(rows, ["name"])) == [
21+
"digraph result {",
22+
" // Columns: name",
23+
' "a\\"b";',
24+
' "line\\nbreak";',
25+
' "NULL";',
26+
"}",
27+
]
28+
29+
30+
def test_dot_output_uses_extra_columns_as_edge_label():
31+
rows = [("a", "b", "foreign key")]
32+
headers = ["source", "target", "relation"]
33+
34+
assert list(format_dot_output(rows, headers)) == [
35+
"digraph result {",
36+
" // Columns: source, target, relation",
37+
' "a" -> "b" [label="relation=foreign key"];',
38+
"}",
39+
]

tests/test_main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ def test_batch_mode_csv(executor):
134134
assert expected in "".join(result.output)
135135

136136

137+
def test_dot_table_format_is_supported():
138+
m = LiteCli(liteclirc=default_config_file)
139+
140+
assert "dot" in m.supported_table_formats()
141+
assert list(m.change_table_format("dot")) == [(None, None, None, "Changed table format to dot")]
142+
assert list(m.format_output(None, [("orders", "customers")], ["source", "target"])) == [
143+
"digraph result {",
144+
" // Columns: source, target",
145+
' "orders" -> "customers";',
146+
"}",
147+
]
148+
149+
137150
def test_help_strings_end_with_periods():
138151
"""Make sure click options have help text that end with a period."""
139152
for param in cli.params:

0 commit comments

Comments
 (0)