From 021c595de49072fa1762c4f7cc7af597f73935f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:03:08 -0800 Subject: [PATCH] dclient create-table, closes #32 --- dclient/cli.py | 76 ++++++++++ docs/inserting.md | 69 +++++++++ tests/test_create_table.py | 203 ++++++++++++++++++++++++++ tests/test_output_formats.py | 267 +++++++++++++++++++++++++++++++++++ 4 files changed, 615 insertions(+) create mode 100644 tests/test_create_table.py create mode 100644 tests/test_output_formats.py diff --git a/dclient/cli.py b/dclient/cli.py index 36e2380..b99e577 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -719,6 +719,82 @@ def upsert( ) +@cli.command(name="create-table") +@click.argument("database") +@click.argument("table_name") +@click.option( + "--column", + "-c", + "column_defs", + multiple=True, + nargs=2, + help="Column definition: name type (e.g. --column id integer --column name text)", +) +@click.option( + "pks", + "--pk", + multiple=True, + help="Column(s) to use as primary key", +) +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("--token", help="API token") +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Verbose output: show HTTP request and response", +) +def create_table(database, table_name, column_defs, pks, instance, token, verbose): + """ + Create a new empty table with an explicit schema + + Example usage: + + \b + dclient create-table mydb dogs \\ + --column id integer --column name text --pk id + """ + config_dir = get_config_dir() + url = _resolve_instance(instance, config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) + + if not column_defs: + raise click.ClickException("Provide at least one --column definition") + + columns = [{"name": name, "type": typ} for name, typ in column_defs] + data = {"table": table_name, "columns": columns} + if pks: + if len(pks) == 1: + data["pk"] = pks[0] + else: + data["pks"] = list(pks) + + api_url = url.rstrip("/") + "/" + database + "/-/create" + if verbose: + click.echo("POST {}".format(api_url), err=True) + click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True) + response = httpx.post( + api_url, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + json=data, + timeout=30.0, + ) + if verbose: + click.echo(str(response), err=True) + if str(response.status_code)[0] != "2": + if "/json" in response.headers.get("content-type", ""): + resp_data = response.json() + if "errors" in resp_data: + raise click.ClickException("\n".join(resp_data["errors"])) + response.raise_for_status() + click.echo(json.dumps(response.json(), indent=2)) + + @cli.command() @click.argument("table_name", required=False, default=None) @click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") diff --git a/docs/inserting.md b/docs/inserting.md index 4ac7d4c..ede3d90 100644 --- a/docs/inserting.md +++ b/docs/inserting.md @@ -1,5 +1,43 @@ # Inserting data +## Creating tables + +The `dclient create-table` command creates a new empty table with an explicit schema. Define columns with `--column name type` and optionally set primary keys with `--pk`: + +```bash +dclient create-table mydb dogs \ + --column id integer \ + --column name text \ + --column age integer \ + --pk id \ + -i myapp +``` + +This hits the Datasette [create API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) with a `columns` array. The response includes the generated schema: + +```json +{ + "ok": true, + "database": "mydb", + "table": "dogs", + "schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)" +} +``` + +Compound primary keys are supported by passing `--pk` multiple times: + +```bash +dclient create-table mydb events \ + --column user_id integer \ + --column event_id integer \ + --column data text \ + --pk user_id --pk event_id +``` + +If you want to create a table and populate it with data in one step, use `dclient insert --create` instead. + +## Inserting rows + The `dclient insert` command can be used to insert data from a local file directly into a Datasette instance, via the [Write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) introduced in the Datasette 1.0 alphas. First you'll need to {ref}`authenticate ` with the instance. @@ -181,3 +219,34 @@ Options: ``` + +## dclient create-table --help + +``` +Usage: dclient create-table [OPTIONS] DATABASE TABLE_NAME + + Create a new empty table with an explicit schema + + Example usage: + + dclient create-table mydb dogs \ + --column id integer --column name text --pk id + +Options: + --column TEXT... Column definition: name type (e.g. --column id integer + --column name text) + --pk TEXT Column(s) to use as primary key + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + -v, --verbose Verbose output: show HTTP request and response + --help Show this message and exit. + +``` + diff --git a/tests/test_create_table.py b/tests/test_create_table.py new file mode 100644 index 0000000..d3787de --- /dev/null +++ b/tests/test_create_table.py @@ -0,0 +1,203 @@ +"""Tests for the create-table command.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + + +def test_create_table_basic(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "ok": True, + "database": "mydb", + "table": "dogs", + "table_url": "http://example.com/mydb/dogs", + "table_api_url": "http://example.com/mydb/dogs.json", + "schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)", + }, + status_code=201, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "dogs", + "--column", + "id", + "integer", + "--column", + "name", + "text", + "--pk", + "id", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["ok"] is True + assert data["table"] == "dogs" + + # Verify request + request = httpx_mock.get_request() + assert request.url.path == "/mydb/-/create" + assert request.headers["authorization"] == "Bearer tok" + body = json.loads(request.read()) + assert body["table"] == "dogs" + assert body["columns"] == [ + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ] + assert body["pk"] == "id" + + +def test_create_table_compound_pk(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "events", + "--column", + "user_id", + "integer", + "-c", + "event_id", + "integer", + "--column", + "data", + "text", + "--pk", + "user_id", + "--pk", + "event_id", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + body = json.loads(httpx_mock.get_request().read()) + assert body["pks"] == ["user_id", "event_id"] + assert "pk" not in body + + +def test_create_table_no_pk(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "logs", + "--column", + "message", + "text", + "--column", + "level", + "integer", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + body = json.loads(httpx_mock.get_request().read()) + assert "pk" not in body + assert "pks" not in body + + +def test_create_table_no_columns_error(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "empty", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 1 + assert "at least one --column" in result.output + + +def test_create_table_api_error(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"ok": False, "errors": ["Table already exists: dogs"]}, + status_code=400, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "dogs", + "--column", + "id", + "integer", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 1 + assert "Table already exists" in result.output + + +def test_create_table_uses_default_instance(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "t1", + "--column", + "id", + "integer", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/mydb/-/create" diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py new file mode 100644 index 0000000..b34a1e6 --- /dev/null +++ b/tests/test_output_formats.py @@ -0,0 +1,267 @@ +"""Tests for multiple output formats on the query and default_query commands.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + +QUERY_RESPONSE = { + "ok": True, + "database": "fixtures", + "query_name": None, + "rows": [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Pancakes", "age": 3}, + ], + "truncated": False, + "columns": ["id", "name", "age"], + "query": {"sql": "select * from dogs", "params": {}}, + "error": None, + "private": False, + "allow_execute_sql": True, +} + + +def _mock_and_invoke(httpx_mock, extra_args=None): + httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) + runner = CliRunner() + args = ["query", "fixtures", "select * from dogs", "-i", "https://example.com"] + if extra_args: + args.extend(extra_args) + return runner.invoke(cli, args) + + +# -- query --csv -- + + +def test_query_csv(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--csv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name,age" + assert lines[1] == "1,Cleo,5" + assert lines[2] == "2,Pancakes,3" + + +# -- query --tsv -- + + +def test_query_tsv(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--tsv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id\tname\tage" + assert lines[1] == "1\tCleo\t5" + assert lines[2] == "2\tPancakes\t3" + + +# -- query --nl -- + + +def test_query_nl(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--nl"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5} + assert json.loads(lines[1]) == {"id": 2, "name": "Pancakes", "age": 3} + + +# -- query --table -- + + +def test_query_table(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--table"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + # Should have a header row, a separator row, and 2 data rows + assert len(lines) == 4 + # Header should contain column names + assert "id" in lines[0] + assert "name" in lines[0] + assert "age" in lines[0] + # Data rows should contain values + assert "Cleo" in lines[2] + assert "Pancakes" in lines[3] + + +# -- query -t shortcut for --table -- + + +def test_query_table_shortcut(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["-t"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert len(lines) == 4 + assert "Cleo" in lines[2] + + +# -- default JSON (no flag) stays the same -- + + +def test_query_default_json(httpx_mock): + result = _mock_and_invoke(httpx_mock) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Pancakes", "age": 3}, + ] + + +# -- default_query also supports output formats -- + + +def _mock_default_query(httpx_mock, mocker, tmpdir, extra_args=None): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) + runner = CliRunner() + args = ["select * from dogs"] + if extra_args: + args.extend(extra_args) + return runner.invoke(cli, args) + + +def test_default_query_csv(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--csv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name,age" + assert lines[1] == "1,Cleo,5" + + +def test_default_query_table(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--table"]) + assert result.exit_code == 0 + assert "Cleo" in result.output + assert "Pancakes" in result.output + lines = result.output.strip().split("\n") + assert len(lines) == 4 + + +def test_default_query_nl(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--nl"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5} + + +# -- edge cases -- + + +def test_query_csv_with_commas_in_values(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"name": "Smith, John", "note": 'He said "hi"'}], + "columns": ["name", "note"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--csv", + ], + ) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "name,note" + # CSV should properly quote fields with commas/quotes + assert '"Smith, John"' in lines[1] + + +def test_query_table_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--table", + ], + ) + assert result.exit_code == 0 + + +def test_query_csv_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--csv", + ], + ) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name" + assert len(lines) == 1 # header only, no data rows + + +def test_query_nl_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--nl", + ], + ) + assert result.exit_code == 0 + assert result.output.strip() == ""