From e0efd08fa3e0b76824a76deac720aea5ce6a3559 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 01:58:34 +0000 Subject: [PATCH] Add rows command with --filter, --where, --sort, --sort-desc options New `rows` command fetches rows from a Datasette table with support for: - `-f`/`--filter column op value` mapping to `?column__op=value` query params - `--where` for raw SQL WHERE clauses (multiple allowed) - `--sort` and `--sort-desc` for sorting (mutually exclusive) - `eq` operator alias maps to Datasette's `exact` filter - All other operators passed through directly to Datasette https://claude.ai/code/session_01W24ZNk4u2qJRZ51rzx4cN7 --- dclient/cli.py | 112 +++++++++++++++++++ tests/test_rows.py | 272 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 tests/test_rows.py diff --git a/dclient/cli.py b/dclient/cli.py index d19a57e..5c86ada 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -181,6 +181,118 @@ def query(url_or_alias, sql, token, verbose): click.echo(json.dumps(response.json()["rows"], indent=2)) +@cli.command() +@click.argument("url_or_alias") +@click.argument("table") +@click.option( + "-f", + "--filter", + "filters", + nargs=3, + multiple=True, + help="Filter: column operator value. Example: -f age gte 5", +) +@click.option( + "--where", + "where_clauses", + multiple=True, + help="SQL where clause. Can be specified multiple times.", +) +@click.option("--sort", "sort_column", default=None, help="Sort by column (ascending)") +@click.option( + "--sort-desc", + "sort_desc_column", + default=None, + help="Sort by column (descending)", +) +@click.option("--token", help="API token") +@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") +def rows(url_or_alias, table, filters, where_clauses, sort_column, sort_desc_column, token, verbose): + """ + Fetch rows from a Datasette table + + Returns a JSON array of row objects + + Example usage: + + \b + dclient rows content creatures + dclient rows content creatures -f age gte 5 + dclient rows content creatures -f species in dog,cat --sort name + dclient rows content creatures --where "id > 5" --sort-desc age + """ + if sort_column and sort_desc_column: + raise click.ClickException("Cannot use both --sort and --sort-desc") + + url = _resolve_url(url_or_alias) + token = _resolve_token(token, url) + + table_url = url.rstrip("/") + "/" + table + ".json" + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + # Build params as list of tuples to support repeated keys (_where) + params = [("_shape", "objects")] + + for col, op, val in filters: + if op == "eq": + op = "exact" + params.append((f"{col}__{op}", val)) + + for clause in where_clauses: + params.append(("_where", clause)) + + if sort_column: + params.append(("_sort", sort_column)) + + if sort_desc_column: + params.append(("_sort_desc", sort_desc_column)) + + if verbose: + click.echo(table_url + "?" + urllib.parse.urlencode(params), err=True) + + response = httpx.get( + table_url, params=params, headers=headers, follow_redirects=True, timeout=30.0 + ) + + if response.status_code != 200: + try: + data = response.json() + except json.JSONDecodeError: + raise click.ClickException( + "{} status code. Response was not valid JSON".format( + response.status_code + ) + ) + bits = [] + if data.get("title"): + bits.append(data["title"]) + if data.get("error"): + bits.append(data["error"]) + raise click.ClickException( + "{} status code. {}".format(response.status_code, ": ".join(bits)) + ) + + try: + data = response.json() + except json.JSONDecodeError: + raise click.ClickException("Response was not valid JSON") + + if not data.get("ok"): + bits = [] + if data.get("title"): + bits.append(data["title"]) + if data.get("error"): + bits.append(data["error"]) + if not bits: + bits = [json.dumps(data)] + raise click.ClickException(": ".join(bits)) + + click.echo(json.dumps(data["rows"], indent=2)) + + @cli.command() @click.argument("url_or_alias") @click.argument("table") diff --git a/tests/test_rows.py b/tests/test_rows.py new file mode 100644 index 0000000..2ecc936 --- /dev/null +++ b/tests/test_rows.py @@ -0,0 +1,272 @@ +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib +import pytest + + +def test_rows_basic(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"id": 1, "name": "Cleo", "age": 4}], + "next": None, + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"]) + assert result.exit_code == 0 + assert json.loads(result.output) == [{"id": 1, "name": "Cleo", "age": 4}] + request = httpx_mock.get_request() + assert str(request.url) == "https://example.com/db/creatures.json?_shape=objects" + + +def test_rows_single_filter(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 2, "name": "Luna", "age": 7}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "-f", "age", "gte", "5"], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert "age__gte=5" in str(request.url) + assert "_shape=objects" in str(request.url) + + +def test_rows_eq_maps_to_exact(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1, "name": "Cleo"}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "-f", "name", "eq", "Cleo"], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert "name__exact=Cleo" in str(request.url) + + +def test_rows_multiple_filters(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": []}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "rows", + "https://example.com/db", + "creatures", + "-f", + "species", + "in", + "dog,cat", + "-f", + "age", + "gt", + "3", + ], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + url_str = str(request.url) + assert "species__in=" in url_str + assert "age__gt=3" in url_str + + +def test_rows_where_clause(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 6}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "--where", "id > 5"], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert "_where=" in str(request.url) + + +def test_rows_multiple_where_clauses(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": []}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "rows", + "https://example.com/db", + "creatures", + "--where", + "id > 5", + "--where", + "age < 10", + ], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + url_str = str(request.url) + assert url_str.count("_where=") == 2 + + +def test_rows_sort(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "--sort", "name"], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert "_sort=name" in str(request.url) + + +def test_rows_sort_desc(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "--sort-desc", "age"], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert "_sort_desc=age" in str(request.url) + + +def test_rows_sort_mutual_exclusion(): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "rows", + "https://example.com/db", + "creatures", + "--sort", + "name", + "--sort-desc", + "age", + ], + ) + assert result.exit_code == 1 + assert "Cannot use both --sort and --sort-desc" in result.output + + +@pytest.mark.parametrize("with_token", (False, True)) +def test_rows_with_token(httpx_mock, with_token): + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1}]}, + status_code=200, + ) + runner = CliRunner() + args = ["rows", "https://example.com/db", "creatures"] + if with_token: + args.extend(["--token", "xyz"]) + result = runner.invoke(cli, args) + assert result.exit_code == 0 + request = httpx_mock.get_request() + if with_token: + assert request.headers["authorization"] == "Bearer xyz" + else: + assert "authorization" not in request.headers + + +def test_rows_http_error(httpx_mock): + httpx_mock.add_response( + json={"title": "Not Found", "error": "Table not found: creatures"}, + status_code=404, + ) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"]) + assert result.exit_code == 1 + assert "404 status code" in result.output + assert "Table not found: creatures" in result.output + + +def test_rows_ok_false_error(httpx_mock): + httpx_mock.add_response( + json={"ok": False, "error": "Something went wrong"}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"]) + assert result.exit_code == 1 + assert "Something went wrong" in result.output + + +def test_rows_with_alias(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + aliases_file = pathlib.Path(tmpdir) / "aliases.json" + aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"})) + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "content", "creatures"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.host == "datasette.io" + assert request.url.path == "/content/creatures.json" + + +def test_rows_verbose(httpx_mock): + httpx_mock.add_response( + json={"ok": True, "rows": []}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "https://example.com/db", "creatures", "-v"], + ) + assert result.exit_code == 0 + assert "creatures.json" in result.output + + +def test_rows_combined(httpx_mock): + """Test filters, where, and sort all together.""" + httpx_mock.add_response( + json={"ok": True, "rows": [{"id": 1, "name": "Cleo", "age": 4}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "rows", + "https://example.com/db", + "creatures", + "-f", + "species", + "eq", + "dog", + "--where", + "age > 2", + "--sort-desc", + "age", + ], + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + url_str = str(request.url) + assert "species__exact=dog" in url_str + assert "_where=" in url_str + assert "_sort_desc=age" in url_str