From 050f11f31f7bcbb08c0b3fe9873c5d69b9ab54ea Mon Sep 17 00:00:00 2001 From: Parker Gurney Date: Wed, 5 Aug 2026 14:16:09 -0700 Subject: [PATCH] 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). --- docs/cli-reference.rst | 8 +++++++ docs/cli.rst | 29 +++++++++++++++++++++-- sqlite_utils/cli.py | 39 +++++++++++++++++++++++++++++++ tests/test_cli.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 0a27438..39acf10 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -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 ... 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 ... 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. diff --git a/docs/cli.rst b/docs/cli.rst index 650da54..a1f3f55 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -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 `: ``--csv`` and ``--csv`` and ``--table`` and ``--nl``: +It takes all of the same output formatting options as :ref:`sqlite-utils 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: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index f71b725..da04b83 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index a1e072f..452c1fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"