Skip to content

Commit a93b0cc

Browse files
Adding output formats, exploration commands, charts and an interactive shell.
Signed-off-by: Carlos Andrés Planchón Prestes <carlosandresplanchonprestes@gmail.com>
1 parent 7a76b52 commit a93b0cc

15 files changed

Lines changed: 3166 additions & 204 deletions

‎README.md‎

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# sqlitexplorer
22

3-
A small command-line explorer for SQLite databases. It lists tables, prints
4-
schemas, dumps rows and runs ad-hoc queries, rendering the results as terminal
5-
tables with [outfancy](https://github.com/carlosplanchon/outfancy).
3+
A command-line explorer for SQLite databases. It lists tables, prints schemas,
4+
dumps rows, computes statistics, searches values, runs ad-hoc queries, draws
5+
charts, exports and imports data, and offers an interactive shell. Tables are
6+
rendered with [outfancy](https://github.com/carlosplanchon/outfancy) and charts
7+
with [plotille](https://github.com/tammoippen/plotille).
68

79
## Installation
810

@@ -15,31 +17,132 @@ uv tool install .
1517
pip install .
1618
```
1719

18-
## Usage
20+
## Quick tour
1921

2022
```sh
21-
sqlitexplorer tables app.db # tables and views with their row counts
22-
sqlitexplorer schema app.db # every CREATE statement in the database
23-
sqlitexplorer schema app.db users # one table with its indexes and triggers
24-
sqlitexplorer describe app.db users # columns: type, NOT NULL, default, primary key
25-
sqlitexplorer show app.db users # all the rows of a table or view
26-
sqlitexplorer show app.db users -n 20 --offset 40
27-
sqlitexplorer query app.db "SELECT name, age FROM users WHERE age > 30"
23+
sqlitexplorer tables app.db # tables and views with their row counts
24+
sqlitexplorer schema app.db [users] # CREATE statements, in creation order
25+
sqlitexplorer describe app.db users # columns: type, NOT NULL, default, primary key
26+
sqlitexplorer indexes app.db [users] # indexes and the columns they cover
27+
sqlitexplorer foreign-keys app.db [posts] # foreign keys
28+
sqlitexplorer info app.db [--check] # size, pragmas, object counts, integrity check
29+
sqlitexplorer show app.db users # rows of a table or view
30+
sqlitexplorer stats app.db users # nulls, distinct, min, max, top values per column
31+
sqlitexplorer search app.db "marie" # find a text in every column of every table
32+
sqlitexplorer query app.db "SELECT ..." # run SQL
33+
sqlitexplorer chart app.db "SELECT day, total FROM sales ORDER BY day"
34+
sqlitexplorer dump app.db # the whole database as SQL
35+
sqlitexplorer export app.db users -o users.csv # rows to a file
36+
sqlitexplorer import app.db people people.csv # a file into a table
37+
sqlitexplorer diff app.db backup.db # compare two schemas
38+
sqlitexplorer shell app.db # interactive shell
39+
```
40+
41+
Every command has `--help`. Databases are opened read-only and are never
42+
created; commands that change data need `--write`, except `import`, which
43+
always writes.
44+
45+
## Exploring
46+
47+
`show` accepts filters so most questions need no SQL:
48+
49+
```sh
50+
sqlitexplorer show app.db users -c name,age --where "age > 18" --order-by age --desc -n 20 --offset 40
51+
```
52+
53+
`--columns` and `--order-by` are validated against the table; `--where` is a
54+
raw SQL condition.
55+
56+
`stats` reports, for each column, the declared type, how many NULLs, how many
57+
distinct values, the minimum, the maximum and the most frequent values
58+
(`--top N`). `search` looks for a case-insensitive substring in every non-BLOB
59+
column of every table (`--table` restricts it, `--limit` stops early) and
60+
prints the table, column, rowid and value of each match.
61+
62+
## Output options
63+
64+
Every command that prints rows takes the same options:
65+
66+
| Option | Effect |
67+
|---|---|
68+
| `--format`, `-f` | `table` (default), `csv`, `tsv`, `json` or `markdown` |
69+
| `--null TEXT` | text shown for NULL values (default `NULL`) |
70+
| `--truncate N` | cut values longer than N characters |
71+
| `--page N`, `--page-size M` | print only one page of the rows; the footer goes to stderr |
72+
| `--pager` | send the output to `$PAGER` (default `less -R`) when on a terminal |
73+
| `--color` / `--no-color` | force or disable ANSI colors; by default only on a terminal, never when `NO_COLOR` is set |
74+
| `--width N` | width to fit tables into (default: the terminal width) |
75+
76+
`NULL` and `BLOB` values are printed as SQL literals (`NULL`, `X'0102'`). In
77+
the JSON format NULL becomes `null`, BLOBs are base64 strings and values are
78+
never truncated.
79+
80+
## Queries
81+
82+
```sh
83+
sqlitexplorer query app.db "SELECT name, age FROM users WHERE age > :min" -p min=30
84+
sqlitexplorer query app.db --file report.sql # several statements, one transaction
2885
echo "SELECT COUNT(*) FROM users" | sqlitexplorer query app.db -
86+
sqlitexplorer query app.db "SELECT ..." --attach old=backup.db # then use old.users
87+
sqlitexplorer query app.db "SELECT ..." --explain # EXPLAIN QUERY PLAN as a tree
88+
sqlitexplorer query app.db "SELECT ..." --time # rows and elapsed time on stderr
89+
sqlitexplorer query app.db "SELECT COUNT(*) FROM jobs" --watch 2 # re-run every 2 s, Ctrl-C stops
2990
sqlitexplorer query app.db "DELETE FROM users WHERE age IS NULL" --write
3091
```
3192

32-
Databases are opened read-only and are never created. Pass `--write` to
33-
`query` to run statements that modify data; the change is committed when the
34-
statement succeeds and rolled back otherwise. `NULL` and `BLOB` values are
35-
printed as SQL literals (`NULL`, `X'0102'`).
93+
Several statements can be given at once (separated by `;`); they run in one
94+
transaction that is committed at the end and rolled back on the first error.
95+
`--param` values that look like numbers are bound as numbers. Attached
96+
databases follow the read-only rule of the main one.
97+
98+
## Charts
99+
100+
```sh
101+
sqlitexplorer chart app.db "SELECT day, sales, returns FROM daily ORDER BY day"
102+
sqlitexplorer chart app.db "SELECT age FROM users" --kind hist --bins 20
103+
```
104+
105+
The first column is the X axis (numbers or ISO dates), every other column is a
106+
series named after the column; rows with NULLs are skipped and counted on
107+
stderr. `--kind` selects `line` (default), `scatter` or `hist` (first column
108+
only, `--bins`). `--height`, `--width`, `--x-label`, `--y-label` and `--color`
109+
adjust the drawing.
110+
111+
## Export, import, dump and diff
112+
113+
```sh
114+
sqlitexplorer dump app.db -o backup.sql # replayable SQL, like .dump
115+
sqlitexplorer export app.db users -f json -o users.json
116+
sqlitexplorer export app.db --all -o exported/ -f csv # one file per table and view
117+
sqlitexplorer import app.db people people.csv # csv, tsv or json (list of objects)
118+
sqlitexplorer diff app.db backup.db # exit status 1 when the schemas differ
119+
```
120+
121+
`import` creates the table when it does not exist, inferring INTEGER, REAL or
122+
TEXT for each column; empty CSV cells become NULL. The format comes from the
123+
file extension unless `--format` is given.
124+
125+
## Shell
126+
127+
```
128+
$ sqlitexplorer shell app.db
129+
sqlitexplorer> SELECT name
130+
...> FROM users;
131+
sqlitexplorer> .tables
132+
sqlitexplorer> .format json
133+
sqlitexplorer> .quit
134+
```
36135

37-
Tables adapt to the terminal width. Use `--width N` to render for another width
38-
(for example when redirecting the output to a file) and `--color` /
39-
`--no-color` to override the automatic color detection (`NO_COLOR` is
40-
honoured).
136+
Statements end with `;` and may span several lines. Dot-commands: `.tables`,
137+
`.schema [NAME]`, `.describe TABLE`, `.indexes [TABLE]`, `.stats TABLE`,
138+
`.format FORMAT`, `.null TEXT`, `.truncate N|off`, `.help` and `.quit`. Tab
139+
completes SQL keywords, table and column names; the history is kept in
140+
`$XDG_STATE_HOME/sqlitexplorer/history` (`~/.local/state` by default). With
141+
`--write` every statement is committed as soon as it succeeds. SQL can also be
142+
piped into the shell.
41143

42-
Run `sqlitexplorer --help` or `sqlitexplorer <command> --help` for every option.
144+
Completion of the command line itself (commands, options and table names) is
145+
installed with `sqlitexplorer --install-completion`.
43146

44147
## Development
45148

‎pyproject.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "sqlitexplorer"
77
version = "0.2.0"
8-
description = "Explore SQLite databases from the terminal: tables, schema, rows and ad-hoc queries rendered with outfancy."
8+
description = "Explore SQLite databases from the terminal: tables, schema, stats, search, queries, charts, export/import and an interactive shell."
99
readme = "README.md"
1010
license = "MIT"
1111
license-files = ["LICENSE"]
1212
requires-python = ">=3.10"
1313
authors = [
1414
{ name = "Carlos A. Planchón", email = "carlosandresplanchonprestes@gmail.com" },
1515
]
16-
keywords = ["sqlite", "sqlite3", "database", "cli", "terminal", "explorer"]
16+
keywords = ["sqlite", "sqlite3", "database", "cli", "terminal", "explorer", "chart", "shell", "repl"]
1717
classifiers = [
1818
"Environment :: Console",
1919
"Intended Audience :: Developers",
@@ -28,6 +28,7 @@ classifiers = [
2828
]
2929
dependencies = [
3030
"outfancy>=0.11",
31+
"plotille>=6",
3132
"typer>=0.12",
3233
]
3334

‎sqlitexplorer/charts.py‎

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Charts drawn with plotille from a :class:`ResultSet`.
2+
3+
The first column of the result is the X axis (numbers or ISO dates) and every
4+
other column is a numeric series. Histograms use the first column only.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
from collections.abc import Iterator, Sequence
11+
from contextlib import contextmanager
12+
from dataclasses import dataclass
13+
from datetime import datetime
14+
from enum import Enum
15+
16+
import plotille
17+
18+
from sqlitexplorer.core import ExplorerError, ResultSet
19+
20+
__all__ = [
21+
"ChartKind",
22+
"Series",
23+
"histogram_values",
24+
"render_chart",
25+
"render_histogram",
26+
"series_from_result",
27+
]
28+
29+
PALETTE = ("red", "green", "yellow", "blue", "magenta", "cyan")
30+
# plotille reserves this many characters for the Y axis label.
31+
AXIS_LABEL_WIDTH = 8
32+
# Characters taken by the Y axis (ticks, label and separator) next to the canvas.
33+
AXIS_WIDTH = 12
34+
35+
36+
class ChartKind(str, Enum):
37+
LINE = "line"
38+
SCATTER = "scatter"
39+
HIST = "hist"
40+
41+
42+
@dataclass(frozen=True)
43+
class Series:
44+
label: str
45+
x: list[float | datetime]
46+
y: list[float]
47+
48+
49+
def _number(value: object) -> float | None:
50+
if isinstance(value, bool | int | float):
51+
return float(value)
52+
if isinstance(value, str):
53+
try:
54+
return float(value)
55+
except ValueError:
56+
return None
57+
return None
58+
59+
60+
def _x_value(value: object) -> float | datetime | None:
61+
number = _number(value)
62+
if number is not None:
63+
return number
64+
if isinstance(value, str):
65+
try:
66+
return datetime.fromisoformat(value)
67+
except ValueError:
68+
return None
69+
return None
70+
71+
72+
def series_from_result(result: ResultSet) -> tuple[list[Series], int]:
73+
"""Split *result* into one series per numeric column after the first.
74+
75+
Rows with a NULL in the X column or in any series are skipped; the second
76+
item of the returned tuple counts them.
77+
"""
78+
if len(result.columns) < 2:
79+
raise ExplorerError("need an X column and at least one numeric column")
80+
x_name, y_names = result.columns[0], result.columns[1:]
81+
xs: list[float | datetime] = []
82+
ys: list[list[float]] = [[] for _ in y_names]
83+
x_type: type | None = None
84+
skipped = 0
85+
for row in result.rows:
86+
if any(value is None for value in row):
87+
skipped += 1
88+
continue
89+
x = _x_value(row[0])
90+
if x is None:
91+
raise ExplorerError(f"column {x_name} is neither numeric nor an ISO date: {row[0]!r}")
92+
if x_type is None:
93+
x_type = type(x)
94+
elif not isinstance(x, x_type):
95+
raise ExplorerError(f"column {x_name} mixes numbers and dates")
96+
numbers = []
97+
for name, value in zip(y_names, row[1:], strict=True):
98+
number = _number(value)
99+
if number is None:
100+
raise ExplorerError(f"column {name} is not numeric: {value!r}")
101+
numbers.append(number)
102+
xs.append(x)
103+
for bucket, number in zip(ys, numbers, strict=True):
104+
bucket.append(number)
105+
if not xs:
106+
raise ExplorerError("no rows to plot")
107+
return [
108+
Series(label=name, x=xs, y=bucket) for name, bucket in zip(y_names, ys, strict=True)
109+
], skipped
110+
111+
112+
def histogram_values(result: ResultSet) -> tuple[list[float], int]:
113+
"""Numeric values of the first column of *result*, and how many NULLs were skipped."""
114+
if not result.columns:
115+
raise ExplorerError("no rows to plot")
116+
name = result.columns[0]
117+
values: list[float] = []
118+
skipped = 0
119+
for row in result.rows:
120+
if row[0] is None:
121+
skipped += 1
122+
continue
123+
number = _number(row[0])
124+
if number is None:
125+
raise ExplorerError(f"column {name} is not numeric: {row[0]!r}")
126+
values.append(number)
127+
if not values:
128+
raise ExplorerError("no rows to plot")
129+
return values, skipped
130+
131+
132+
@contextmanager
133+
def _color_environment(enabled: bool) -> Iterator[None]:
134+
"""plotille checks FORCE_COLOR, NO_COLOR and isatty itself; make it follow *enabled*."""
135+
saved = {name: os.environ.get(name) for name in ("FORCE_COLOR", "NO_COLOR")}
136+
if enabled:
137+
os.environ["FORCE_COLOR"] = "1"
138+
os.environ.pop("NO_COLOR", None)
139+
else:
140+
os.environ["NO_COLOR"] = "1"
141+
try:
142+
yield
143+
finally:
144+
for name, value in saved.items():
145+
if value is None:
146+
os.environ.pop(name, None)
147+
else:
148+
os.environ[name] = value
149+
150+
151+
def _canvas_width(width: int) -> int:
152+
return max(10, width - AXIS_WIDTH)
153+
154+
155+
def render_chart(
156+
series: Sequence[Series],
157+
*,
158+
kind: ChartKind,
159+
width: int,
160+
height: int,
161+
color: bool,
162+
x_label: str,
163+
y_label: str,
164+
) -> str:
165+
"""Draw *series* as a line chart or scatter plot, with a legend when there are several."""
166+
figure = plotille.Figure()
167+
figure.width = _canvas_width(width)
168+
figure.height = max(3, height)
169+
figure.with_colors = color
170+
figure.color_mode = "names"
171+
figure.x_label = x_label
172+
figure.y_label = y_label[:AXIS_LABEL_WIDTH]
173+
for index, item in enumerate(series):
174+
line_color = PALETTE[index % len(PALETTE)] if color else None
175+
if kind is ChartKind.SCATTER:
176+
figure.scatter(item.x, item.y, lc=line_color, label=item.label)
177+
else:
178+
figure.plot(item.x, item.y, lc=line_color, label=item.label)
179+
with _color_environment(color):
180+
return figure.show(legend=len(series) > 1)
181+
182+
183+
def render_histogram(
184+
values: Sequence[float],
185+
*,
186+
bins: int,
187+
width: int,
188+
height: int,
189+
color: bool,
190+
x_label: str,
191+
y_label: str,
192+
) -> str:
193+
"""Draw the distribution of *values*."""
194+
with _color_environment(color):
195+
return plotille.histogram(
196+
list(values),
197+
bins=bins,
198+
width=_canvas_width(width),
199+
height=max(3, height),
200+
X_label=x_label,
201+
Y_label=y_label[:AXIS_LABEL_WIDTH],
202+
lc=PALETTE[0] if color else None,
203+
)

0 commit comments

Comments
 (0)