Compare commits

...

1 commit

Author SHA1 Message Date
Claude
e0efd08fa3
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
2026-02-25 01:58:34 +00:00
2 changed files with 384 additions and 0 deletions

View file

@ -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")

272
tests/test_rows.py Normal file
View file

@ -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