mirror of
https://github.com/simonw/dclient.git
synced 2026-09-11 02:54:24 +02:00
dclient create-table, closes #32
This commit is contained in:
parent
f2aea1b108
commit
021c595de4
4 changed files with 615 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
203
tests/test_create_table.py
Normal 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"
|
||||
267
tests/test_output_formats.py
Normal file
267
tests/test_output_formats.py
Normal 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() == ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue