Add --transpose/-x option to rows, query, memory and search for wide records

Displays each row as a psql-style key/value block (\x extended display)
instead of a wide table, while leaving JSON/CSV/table output untouched (#535).
This commit is contained in:
Parker Gurney 2026-08-05 14:16:09 -07:00
commit 050f11f31f
4 changed files with 126 additions and 2 deletions

View file

@ -136,6 +136,8 @@ See :ref:`cli_query`.
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-x, --transpose Transpose wide rows, one key/value pair per line
(like psql's \x)
-r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query
@ -207,6 +209,8 @@ See :ref:`cli_memory`.
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-x, --transpose Transpose wide rows, one key/value pair per line
(like psql's \x)
-r, --raw Raw output, first column of first row
--raw-lines Raw output, first column of each row
-p, --param <TEXT TEXT>... Named :parameters for SQL query
@ -478,6 +482,8 @@ See :ref:`cli_search`.
--json-cols Detect JSON cols and output them as JSON, not escaped
strings
--ascii Escape non-ASCII characters in JSON output as \uXXXX
-x, --transpose Transpose wide rows, one key/value pair per line (like
psql's \x)
--load-extension TEXT Path to SQLite extension, with optional :entrypoint
-h, --help Show this message and exit.
@ -842,6 +848,8 @@ See :ref:`cli_rows`.
escaped strings
--ascii Escape non-ASCII characters in JSON output as
\uXXXX
-x, --transpose Transpose wide rows, one key/value pair per line
(like psql's \x)
--load-extension TEXT Path to SQLite extension, with optional
:entrypoint
-h, --help Show this message and exit.

View file

@ -337,6 +337,31 @@ Available ``--fmt`` options are:
This list can also be found by running ``sqlite-utils query --help``.
.. _cli_query_transpose:
Transposed output for wide rows
--------------------------------
If your rows have a lot of columns they can be hard to read as a table.
Use the ``--transpose`` option (or the ``-x`` shortcut, matching ``psql``'s ``\x``) to display each row as a block of key/value pairs instead:
.. code-block:: bash
sqlite-utils dogs.db "select * from dogs" --transpose
.. code-block:: output
-[ RECORD 1 ]--
id | 1
age | 4
name | Cleo
-[ RECORD 2 ]--
id | 2
age | 2
name | Pancakes
Combine this with ``--no-headers`` to omit the ``-[ RECORD n ]-`` separator lines.
.. _cli_query_raw:
Returning raw data, such as binary content
@ -480,7 +505,7 @@ Without any extra arguments, this command executes SQL against the in-memory dat
[{"sqlite_version()": "3.35.5"}]
It takes all of the same output formatting options as :ref:`sqlite-utils query <cli_query>`: ``--csv`` and ``--csv`` and ``--table`` and ``--nl``:
It takes all of the same output formatting options as :ref:`sqlite-utils query <cli_query>`: ``--csv`` and ``--table`` and ``--nl`` and ``--transpose``:
.. code-block:: bash
@ -690,7 +715,7 @@ You can return every row in a specified table using the ``rows`` command:
[{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"}]
This command accepts the same output options as ``query`` - so you can pass ``--nl``, ``--csv``, ``--tsv``, ``--no-headers``, ``--table`` and ``--fmt``.
This command accepts the same output options as ``query`` - so you can pass ``--nl``, ``--csv``, ``--tsv``, ``--no-headers``, ``--table``, ``--fmt`` and ``--transpose`` (see :ref:`cli_query_transpose`).
You can use the ``-c`` option to specify a subset of columns to return:

View file

@ -160,6 +160,15 @@ def load_extension_option(fn):
)(fn)
def transpose_option(fn):
return click.option(
"-x",
"--transpose",
is_flag=True,
help="Transpose wide rows, one key/value pair per line (like psql's \\x)",
)(fn)
def functions_option(fn):
return click.option(
"--functions",
@ -1992,6 +2001,7 @@ def drop_view(path, view, ignore, load_extension):
help="Additional databases to attach - specify alias and filepath",
)
@output_options
@transpose_option
@click.option("-r", "--raw", is_flag=True, help="Raw output, first column of first row")
@click.option("--raw-lines", is_flag=True, help="Raw output, first column of each row")
@click.option(
@ -2016,6 +2026,7 @@ def query(
fmt,
json_cols,
ascii_,
transpose,
raw,
raw_lines,
param,
@ -2063,6 +2074,7 @@ def query(
arrays,
json_cols,
ascii_,
transpose,
)
@ -2087,6 +2099,7 @@ def query(
help='Flatten nested JSON objects, so {"foo": {"bar": 1}} becomes {"foo_bar": 1}',
)
@output_options
@transpose_option
@click.option("-r", "--raw", is_flag=True, help="Raw output, first column of first row")
@click.option("--raw-lines", is_flag=True, help="Raw output, first column of each row")
@click.option(
@ -2134,6 +2147,7 @@ def memory(
fmt,
json_cols,
ascii_,
transpose,
raw,
raw_lines,
param,
@ -2274,6 +2288,7 @@ def memory(
arrays,
json_cols,
ascii_,
transpose,
)
@ -2292,6 +2307,7 @@ def _execute_query(
arrays,
json_cols,
ascii_,
transpose=False,
):
with db.conn:
try:
@ -2319,6 +2335,9 @@ def _execute_query(
sys.stdout.buffer.write(data + b"\n")
else:
sys.stdout.write(str(data) + "\n")
elif transpose:
for line in output_transpose(cursor, headers, no_headers):
click.echo(line)
elif fmt or table:
print(
tabulate.tabulate(
@ -2358,6 +2377,7 @@ def _execute_query(
)
@click.option("--quote", is_flag=True, help="Apply FTS quoting rules to search term")
@output_options
@transpose_option
@load_extension_option
@click.pass_context
def search(
@ -2379,6 +2399,7 @@ def search(
fmt,
json_cols,
ascii_,
transpose,
load_extension,
):
"""Execute a full-text search against this table
@ -2424,6 +2445,7 @@ def search(
fmt=fmt,
json_cols=json_cols,
ascii_=ascii_,
transpose=transpose,
param=[("query", q)],
load_extension=load_extension,
)
@ -2464,6 +2486,7 @@ def search(
help="SQL offset to use",
)
@output_options
@transpose_option
@load_extension_option
@click.pass_context
def rows(
@ -2485,6 +2508,7 @@ def rows(
fmt,
json_cols,
ascii_,
transpose,
load_extension,
):
"""Output all rows in the specified table
@ -2520,6 +2544,7 @@ def rows(
param=param,
json_cols=json_cols,
ascii_=ascii_,
transpose=transpose,
load_extension=load_extension,
)
@ -3798,6 +3823,20 @@ def output_rows(iterator, headers, nl, arrays, json_cols, ascii_=False):
yield "[]"
def output_transpose(rows, headers, no_headers):
# psql-style extended display: one "key = value" block per row
headers = [str(h) for h in headers]
str_rows = [["" if v is None else str(v) for v in row] for row in rows]
key_width = max([len(h) for h in headers], default=0)
val_width = max([len(v) for row in str_rows for v in row], default=0)
total_width = key_width + 3 + val_width
for i, row in enumerate(str_rows, start=1):
if not no_headers:
yield "-[ RECORD {} ]-".format(i).ljust(total_width, "-")
for header, value in zip(headers, row):
yield "{} | {}".format(header.ljust(key_width), value)
def maybe_json(value):
if not isinstance(value, str):
return value

View file

@ -760,6 +760,58 @@ def test_query_csv(db_path, format, expected):
assert result.output.strip().replace("\r", "") == expected_rest
def test_query_transpose(db_path):
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[
{"id": 1, "age": 4, "name": "Cleo"},
{"id": 2, "age": 2, "name": "Pancakes"},
]
)
result = CliRunner().invoke(
cli.cli,
[db_path, "select id, name, age from dogs", "--transpose"],
)
assert result.exit_code == 0
assert result.output == (
"-[ RECORD 1 ]--\n"
"id | 1\n"
"name | Cleo\n"
"age | 4\n"
"-[ RECORD 2 ]--\n"
"id | 2\n"
"name | Pancakes\n"
"age | 2\n"
)
# -x is a shorthand for --transpose
result2 = CliRunner().invoke(
cli.cli, [db_path, "select id, name, age from dogs", "-x"]
)
assert result2.output == result.output
# --no-headers drops the "-[ RECORD n ]-" separators
result3 = CliRunner().invoke(
cli.cli,
[db_path, "select id, name, age from dogs", "--transpose", "--no-headers"],
)
assert result3.exit_code == 0
assert (
result3.output
== "id | 1\nname | Cleo\nage | 4\nid | 2\nname | Pancakes\nage | 2\n"
)
def test_rows_transpose(db_path):
db = Database(db_path)
with db.conn:
db["dogs"].insert_all(
[{"id": 1, "name": "Cleo", "age": 4}], column_order=("id", "name", "age")
)
result = CliRunner().invoke(cli.cli, ["rows", db_path, "dogs", "-x"])
assert result.exit_code == 0
assert result.output == "-[ RECORD 1 ]-\nid | 1\nname | Cleo\nage | 4\n"
_all_query = "select id, name, age from dogs"
_one_query = "select id, name, age from dogs where id = 1"