dclient create-table, closes #32

This commit is contained in:
Simon Willison 2026-02-26 21:03:08 -08:00
commit 021c595de4
4 changed files with 615 additions and 0 deletions

View file

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

View file

@ -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 <authentication>` with the instance.
@ -181,3 +219,34 @@ Options:
```
<!-- [[[end]]] -->
## dclient create-table --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["create-table", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->

203
tests/test_create_table.py Normal file
View file

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

View file

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