datasette/tests/test_api_write.py

1595 wiersze
46 KiB
Python

from datasette.app import Datasette
from datasette.utils import sqlite3
from .utils import last_event
import pytest
import time
@pytest.fixture
def ds_write(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db_path_immutable = str(db_directory / "immutable.db")
db1 = sqlite3.connect(str(db_path))
db2 = sqlite3.connect(str(db_path_immutable))
for db in (db1, db2):
db.execute("vacuum")
db.execute(
"create table docs (id integer primary key, title text, score float, age integer)"
)
ds = Datasette([db_path], immutables=[db_path_immutable])
yield ds
db.close()
def write_token(ds, actor_id="root", permissions=None):
to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())}
if permissions:
to_sign["_r"] = {"a": permissions}
return "dstok_{}".format(ds.sign(to_sign, namespace="token"))
def _headers(token):
return {
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
}
@pytest.mark.asyncio
async def test_insert_row(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/docs/-/insert",
json={"row": {"title": "Test", "score": 1.2, "age": 5}},
headers=_headers(token),
)
expected_row = {"id": 1, "title": "Test", "score": 1.2, "age": 5}
assert response.status_code == 201
assert response.json()["ok"] is True
assert response.json()["rows"] == [expected_row]
rows = (await ds_write.get_database("data").execute("select * from docs")).rows
assert dict(rows[0]) == expected_row
# Analytics event
event = last_event(ds_write)
assert event.name == "insert-rows"
assert event.num_rows == 1
assert event.database == "data"
assert event.table == "docs"
assert not event.ignore
assert not event.replace
@pytest.mark.asyncio
async def test_insert_row_alter(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/docs/-/insert",
json={
"row": {"title": "Test", "score": 1.2, "age": 5, "extra": "extra"},
"alter": True,
},
headers=_headers(token),
)
assert response.status_code == 201
assert response.json()["ok"] is True
assert response.json()["rows"][0]["extra"] == "extra"
# Analytics event
event = last_event(ds_write)
assert event.name == "alter-table"
assert "extra" not in event.before_schema
assert "extra" in event.after_schema
@pytest.mark.asyncio
@pytest.mark.parametrize("return_rows", (True, False))
async def test_insert_rows(ds_write, return_rows):
token = write_token(ds_write)
data = {
"rows": [
{"title": "Test {}".format(i), "score": 1.0, "age": 5} for i in range(20)
]
}
if return_rows:
data["return"] = True
response = await ds_write.client.post(
"/data/docs/-/insert",
json=data,
headers=_headers(token),
)
assert response.status_code == 201
# Analytics event
event = last_event(ds_write)
assert event.name == "insert-rows"
assert event.num_rows == 20
assert event.database == "data"
assert event.table == "docs"
assert not event.ignore
assert not event.replace
actual_rows = [
dict(r)
for r in (
await ds_write.get_database("data").execute("select * from docs")
).rows
]
assert len(actual_rows) == 20
assert actual_rows == [
{"id": i + 1, "title": "Test {}".format(i), "score": 1.0, "age": 5}
for i in range(20)
]
assert response.json()["ok"] is True
if return_rows:
assert response.json()["rows"] == actual_rows
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,input,special_case,expected_status,expected_errors",
(
(
"/data2/docs/-/insert",
{},
None,
404,
["Database not found: data2"],
),
(
"/data/docs2/-/insert",
{},
None,
404,
["Table not found: docs2"],
),
(
"/data/docs/-/insert",
{"rows": [{"title": "Test"} for i in range(10)]},
"bad_token",
403,
["Permission denied"],
),
(
"/data/docs/-/insert",
{},
"invalid_json",
400,
[
"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"
],
),
(
"/data/docs/-/insert",
{},
"invalid_content_type",
400,
["Invalid content-type, must be application/json"],
),
(
"/data/docs/-/insert",
[],
None,
400,
["JSON must be a dictionary"],
),
(
"/data/docs/-/insert",
{"row": "blah"},
None,
400,
['"row" must be a dictionary'],
),
(
"/data/docs/-/insert",
{"blah": "blah"},
None,
400,
['JSON must have one or other of "row" or "rows"'],
),
(
"/data/docs/-/insert",
{"rows": "blah"},
None,
400,
['"rows" must be a list'],
),
(
"/data/docs/-/insert",
{"rows": ["blah"]},
None,
400,
['"rows" must be a list of dictionaries'],
),
(
"/data/docs/-/insert",
{"rows": [{"title": "Test"} for i in range(101)]},
None,
400,
["Too many rows, maximum allowed is 100"],
),
(
"/data/docs/-/insert",
{"rows": [{"id": 1, "title": "Test"}, {"id": 2, "title": "Test"}]},
"duplicate_id",
400,
["UNIQUE constraint failed: docs.id"],
),
(
"/data/docs/-/insert",
{"rows": [{"title": "Test"}], "ignore": True, "replace": True},
None,
400,
['Cannot use "ignore" and "replace" at the same time'],
),
(
# Replace is not allowed if you don't have update-row
"/data/docs/-/insert",
{"rows": [{"title": "Test"}], "replace": True},
"insert-but-not-update",
403,
['Permission denied: need update-row to use "replace"'],
),
(
"/data/docs/-/insert",
{"rows": [{"title": "Test"}], "invalid_param": True},
None,
400,
['Invalid parameter: "invalid_param"'],
),
(
"/data/docs/-/insert",
{"rows": [{"title": "Test"}], "one": True, "two": True},
None,
400,
['Invalid parameter: "one", "two"'],
),
(
"/immutable/docs/-/insert",
{"rows": [{"title": "Test"}]},
None,
403,
["Database is immutable"],
),
# Validate columns of each row
(
"/data/docs/-/insert",
{"rows": [{"title": "Test", "bad": 1, "worse": 2} for i in range(2)]},
None,
400,
[
"Row 0 has invalid columns: bad, worse",
"Row 1 has invalid columns: bad, worse",
],
),
## UPSERT ERRORS:
(
"/immutable/docs/-/upsert",
{"rows": [{"title": "Test"}]},
None,
403,
["Database is immutable"],
),
(
"/data/badtable/-/upsert",
{"rows": [{"title": "Test"}]},
None,
404,
["Table not found: badtable"],
),
# missing primary key
(
"/data/docs/-/upsert",
{"rows": [{"title": "Missing PK"}]},
None,
400,
['Row 0 is missing primary key column(s): "id"'],
),
# Upsert does not support ignore or replace
(
"/data/docs/-/upsert",
{"rows": [{"id": 1, "title": "Bad"}], "ignore": True},
None,
400,
["Upsert does not support ignore or replace"],
),
# Upsert permissions
(
"/data/docs/-/upsert",
{"rows": [{"id": 1, "title": "Disallowed"}]},
"insert-but-not-update",
403,
["Permission denied: need both insert-row and update-row"],
),
(
"/data/docs/-/upsert",
{"rows": [{"id": 1, "title": "Disallowed"}]},
"update-but-not-insert",
403,
["Permission denied: need both insert-row and update-row"],
),
# Alter table forbidden without alter permission
(
"/data/docs/-/upsert",
{"rows": [{"id": 1, "title": "One", "extra": "extra"}], "alter": True},
"update-and-insert-but-no-alter",
403,
["Permission denied for alter-table"],
),
),
)
async def test_insert_or_upsert_row_errors(
ds_write, path, input, special_case, expected_status, expected_errors
):
token_permissions = []
if special_case == "insert-but-not-update":
token_permissions = ["ir", "vi"]
if special_case == "update-but-not-insert":
token_permissions = ["ur", "vi"]
if special_case == "update-and-insert-but-no-alter":
token_permissions = ["ur", "ir"]
token = write_token(ds_write, permissions=token_permissions)
if special_case == "duplicate_id":
await ds_write.get_database("data").execute_write(
"insert into docs (id) values (1)"
)
if special_case == "bad_token":
token += "bad"
kwargs = dict(
json=input,
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": (
"text/plain"
if special_case == "invalid_content_type"
else "application/json"
),
},
)
actor_response = (
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
).json()
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
token_permissions
)
if special_case == "invalid_json":
del kwargs["json"]
kwargs["content"] = "{bad json"
before_count = (
await ds_write.get_database("data").execute("select count(*) from docs")
).rows[0][0] == 0
response = await ds_write.client.post(
path,
**kwargs,
)
assert response.status_code == expected_status
assert response.json()["ok"] is False
assert response.json()["errors"] == expected_errors
# Check that no rows were inserted
after_count = (
await ds_write.get_database("data").execute("select count(*) from docs")
).rows[0][0] == 0
assert before_count == after_count
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed", (True, False))
async def test_upsert_permissions_per_table(ds_write, allowed):
# https://github.com/simonw/datasette/issues/2262
token = "dstok_{}".format(
ds_write.sign(
{
"a": "root",
"token": "dstok",
"t": int(time.time()),
"_r": {
"r": {
"data": {
"docs" if allowed else "other": ["ir", "ur"],
}
}
},
},
namespace="token",
)
)
response = await ds_write.client.post(
"/data/docs/-/upsert",
json={"rows": [{"id": 1, "title": "One"}]},
headers={
"Authorization": "Bearer {}".format(token),
},
)
if allowed:
assert response.status_code == 200
assert response.json()["ok"] is True
else:
assert response.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize(
"ignore,replace,expected_rows",
(
(
True,
False,
[
{"id": 1, "title": "Exists", "score": None, "age": None},
],
),
(
False,
True,
[
{"id": 1, "title": "One", "score": None, "age": None},
],
),
),
)
@pytest.mark.parametrize("should_return", (True, False))
async def test_insert_ignore_replace(
ds_write, ignore, replace, expected_rows, should_return
):
await ds_write.get_database("data").execute_write(
"insert into docs (id, title) values (1, 'Exists')"
)
token = write_token(ds_write)
data = {"rows": [{"id": 1, "title": "One"}]}
if ignore:
data["ignore"] = True
if replace:
data["replace"] = True
if should_return:
data["return"] = True
response = await ds_write.client.post(
"/data/docs/-/insert",
json=data,
headers=_headers(token),
)
assert response.status_code == 201
# Analytics event
event = last_event(ds_write)
assert event.name == "insert-rows"
assert event.num_rows == 1
assert event.database == "data"
assert event.table == "docs"
assert event.ignore == ignore
assert event.replace == replace
actual_rows = [
dict(r)
for r in (
await ds_write.get_database("data").execute("select * from docs")
).rows
]
assert actual_rows == expected_rows
assert response.json()["ok"] is True
if should_return:
assert response.json()["rows"] == expected_rows
@pytest.mark.asyncio
@pytest.mark.parametrize(
"initial,input,expected_rows",
(
(
# Simple primary key update
{"rows": [{"id": 1, "title": "One"}], "pk": "id"},
{"rows": [{"id": 1, "title": "Two"}]},
[
{"id": 1, "title": "Two"},
],
),
(
# Multiple rows update one of them
{
"rows": [{"id": 1, "title": "One"}, {"id": 2, "title": "Two"}],
"pk": "id",
},
{"rows": [{"id": 1, "title": "Three"}]},
[
{"id": 1, "title": "Three"},
{"id": 2, "title": "Two"},
],
),
(
# rowid update
{"rows": [{"title": "One"}]},
{"rows": [{"rowid": 1, "title": "Two"}]},
[
{"rowid": 1, "title": "Two"},
],
),
(
# Compound primary key update
{"rows": [{"id": 1, "title": "One", "score": 1}], "pks": ["id", "score"]},
{"rows": [{"id": 1, "title": "Two", "score": 1}]},
[
{"id": 1, "title": "Two", "score": 1},
],
),
(
# Upsert with an alter
{"rows": [{"id": 1, "title": "One"}], "pk": "id"},
{"rows": [{"id": 1, "title": "Two", "extra": "extra"}], "alter": True},
[{"id": 1, "title": "Two", "extra": "extra"}],
),
),
)
@pytest.mark.parametrize("should_return", (False, True))
async def test_upsert(ds_write, initial, input, expected_rows, should_return):
token = write_token(ds_write)
# Insert initial data
initial["table"] = "upsert_test"
create_response = await ds_write.client.post(
"/data/-/create",
json=initial,
headers=_headers(token),
)
assert create_response.status_code == 201
if should_return:
input["return"] = True
response = await ds_write.client.post(
"/data/upsert_test/-/upsert",
json=input,
headers=_headers(token),
)
assert response.status_code == 200
assert response.json()["ok"] is True
# Analytics event
event = last_event(ds_write)
assert event.database == "data"
assert event.table == "upsert_test"
if input.get("alter"):
assert event.name == "alter-table"
assert "extra" in event.after_schema
else:
assert event.name == "upsert-rows"
assert event.num_rows == 1
if should_return:
# We only expect it to return rows corresponding to those we sent
expected_returned_rows = expected_rows[: len(input["rows"])]
assert response.json()["rows"] == expected_returned_rows
# Check the database too
actual_rows = (
await ds_write.client.get("/data/upsert_test.json?_shape=array")
).json()
assert actual_rows == expected_rows
# Drop the upsert_test table
await ds_write.get_database("data").execute_write("drop table upsert_test")
async def _insert_row(ds):
insert_response = await ds.client.post(
"/data/docs/-/insert",
json={"row": {"title": "Row one", "score": 1.2, "age": 5}, "return": True},
headers=_headers(write_token(ds)),
)
assert insert_response.status_code == 201
return insert_response.json()["rows"][0]["id"]
@pytest.mark.asyncio
@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table"))
async def test_delete_row_errors(ds_write, scenario):
if scenario == "no_token":
token = "bad_token"
elif scenario == "no_perm":
token = write_token(ds_write, actor_id="not-root")
else:
token = write_token(ds_write)
pk = await _insert_row(ds_write)
path = "/data/{}/{}/-/delete".format(
"docs" if scenario != "bad_table" else "bad_table", pk
)
response = await ds_write.client.post(
path,
headers=_headers(token),
)
assert response.status_code == 403 if scenario in ("no_token", "bad_token") else 404
assert response.json()["ok"] is False
assert (
response.json()["errors"] == ["Permission denied"]
if scenario == "no_token"
else ["Table not found: bad_table"]
)
assert len((await ds_write.client.get("/data/docs.json?_shape=array")).json()) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"table,row_for_create,pks,delete_path",
(
("rowid_table", {"name": "rowid row"}, None, None),
("pk_table", {"id": 1, "name": "ID table"}, "id", "1"),
(
"compound_pk_table",
{"type": "article", "key": "k"},
["type", "key"],
"article,k",
),
),
)
async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
# First create the table with that example row
create_data = {
"table": table,
"row": row_for_create,
}
if pks:
if isinstance(pks, str):
create_data["pk"] = pks
else:
create_data["pks"] = pks
create_response = await ds_write.client.post(
"/data/-/create",
json=create_data,
headers=_headers(write_token(ds_write)),
)
assert create_response.status_code == 201, create_response.json()
# Should be a single row
assert (
await ds_write.client.get(
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
)
).json() == [1]
# Now delete the row
if delete_path is None:
# Special case for that rowid table
delete_path = (
await ds_write.client.get(
"/data.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(table)
)
).json()[0]
delete_response = await ds_write.client.post(
"/data/{}/{}/-/delete".format(table, delete_path),
headers=_headers(write_token(ds_write)),
)
assert delete_response.status_code == 200
# Analytics event
event = last_event(ds_write)
assert event.name == "delete-row"
assert event.database == "data"
assert event.table == table
assert event.pks == str(delete_path).split(",")
assert (
await ds_write.client.get(
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
)
).json() == [0]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"scenario", ("no_token", "no_perm", "bad_table", "cannot_alter")
)
async def test_update_row_check_permission(ds_write, scenario):
if scenario == "no_token":
token = "bad_token"
elif scenario == "no_perm":
token = write_token(ds_write, actor_id="not-root")
elif scenario == "cannot_alter":
# update-row but no alter-table:
token = write_token(ds_write, permissions=["ur"])
else:
token = write_token(ds_write)
pk = await _insert_row(ds_write)
path = "/data/{}/{}/-/update".format(
"docs" if scenario != "bad_table" else "bad_table", pk
)
json_body = {"update": {"title": "New title"}}
if scenario == "cannot_alter":
json_body["alter"] = True
response = await ds_write.client.post(
path,
json=json_body,
headers=_headers(token),
)
assert response.status_code == 403 if scenario in ("no_token", "bad_token") else 404
assert response.json()["ok"] is False
assert (
response.json()["errors"] == ["Permission denied"]
if scenario == "no_token"
else ["Table not found: bad_table"]
)
@pytest.mark.asyncio
async def test_update_row_invalid_key(ds_write):
token = write_token(ds_write)
pk = await _insert_row(ds_write)
path = "/data/docs/{}/-/update".format(pk)
response = await ds_write.client.post(
path,
json={"update": {"title": "New title"}, "bad_key": 1},
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {"ok": False, "errors": ["Invalid keys: bad_key"]}
@pytest.mark.asyncio
async def test_update_row_alter(ds_write):
token = write_token(ds_write, permissions=["ur", "at"])
pk = await _insert_row(ds_write)
path = "/data/docs/{}/-/update".format(pk)
response = await ds_write.client.post(
path,
json={"update": {"title": "New title", "extra": "extra"}, "alter": True},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json() == {"ok": True}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input,expected_errors",
(
({"title": "New title"}, None),
({"title": None}, None),
({"score": 1.6}, None),
({"age": 10}, None),
({"title": "New title", "score": 1.6}, None),
({"title2": "New title"}, ["no such column: title2"]),
),
)
@pytest.mark.parametrize("use_return", (True, False))
async def test_update_row(ds_write, input, expected_errors, use_return):
token = write_token(ds_write)
pk = await _insert_row(ds_write)
path = "/data/docs/{}/-/update".format(pk)
data = {"update": input}
if use_return:
data["return"] = True
response = await ds_write.client.post(
path,
json=data,
headers=_headers(token),
)
if expected_errors:
assert response.status_code == 400
assert response.json()["ok"] is False
assert response.json()["errors"] == expected_errors
return
assert response.json()["ok"] is True
if not use_return:
assert "row" not in response.json()
else:
returned_row = response.json()["row"]
assert returned_row["id"] == pk
for k, v in input.items():
assert returned_row[k] == v
# Analytics event
event = last_event(ds_write)
assert event.actor == {"id": "root", "token": "dstok"}
assert event.database == "data"
assert event.table == "docs"
assert event.pks == [str(pk)]
# And fetch the row to check it's updated
response = await ds_write.client.get(
"/data/docs/{}.json?_shape=array".format(pk),
)
assert response.status_code == 200
row = response.json()[0]
assert row["id"] == pk
for k, v in input.items():
assert row[k] == v
@pytest.mark.asyncio
@pytest.mark.parametrize(
"scenario", ("no_token", "no_perm", "bad_table", "has_perm", "immutable")
)
async def test_drop_table(ds_write, scenario):
if scenario == "no_token":
token = "bad_token"
elif scenario == "no_perm":
token = write_token(ds_write, actor_id="not-root")
else:
token = write_token(ds_write)
should_work = scenario == "has_perm"
await ds_write.get_database("data").execute_write(
"insert into docs (id, title) values (1, 'Row 1')"
)
path = "/{database}/{table}/-/drop".format(
database="immutable" if scenario == "immutable" else "data",
table="docs" if scenario != "bad_table" else "bad_table",
)
response = await ds_write.client.post(
path,
headers=_headers(token),
)
if not should_work:
assert (
response.status_code == 403
if scenario in ("no_token", "bad_token")
else 404
)
assert response.json()["ok"] is False
expected_error = "Permission denied"
if scenario == "bad_table":
expected_error = "Table not found: bad_table"
elif scenario == "immutable":
expected_error = "Database is immutable"
assert response.json()["errors"] == [expected_error]
assert (await ds_write.client.get("/data/docs")).status_code == 200
else:
# It should show a confirmation page
assert response.status_code == 200
assert response.json() == {
"ok": True,
"database": "data",
"table": "docs",
"row_count": 1,
"message": 'Pass "confirm": true to confirm',
}
assert (await ds_write.client.get("/data/docs")).status_code == 200
# Now send confirm: true
response2 = await ds_write.client.post(
path,
json={"confirm": True},
headers=_headers(token),
)
assert response2.json() == {"ok": True}
# Check event
event = last_event(ds_write)
assert event.name == "drop-table"
assert event.actor == {"id": "root", "token": "dstok"}
assert event.table == "docs"
assert event.database == "data"
# Table should 404
assert (await ds_write.client.get("/data/docs")).status_code == 404
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input,expected_status,expected_response,expected_events",
(
# Permission error with a bad token
(
{"table": "bad", "row": {"id": 1}},
403,
{"ok": False, "errors": ["Permission denied"]},
[],
),
# Successful creation with columns:
(
{
"table": "one",
"columns": [
{
"name": "id",
"type": "integer",
},
{
"name": "title",
"type": "text",
},
{
"name": "score",
"type": "integer",
},
{
"name": "weight",
"type": "float",
},
{
"name": "thumbnail",
"type": "blob",
},
],
"pk": "id",
},
201,
{
"ok": True,
"database": "data",
"table": "one",
"table_url": "http://localhost/data/one",
"table_api_url": "http://localhost/data/one.json",
"schema": (
"CREATE TABLE [one] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] INTEGER,\n"
" [weight] FLOAT,\n"
" [thumbnail] BLOB\n"
")"
),
},
["create-table"],
),
# Successful creation with rows:
(
{
"table": "two",
"rows": [
{
"id": 1,
"title": "Row 1",
"score": 1.5,
},
{
"id": 2,
"title": "Row 2",
"score": 1.5,
},
],
"pk": "id",
},
201,
{
"ok": True,
"database": "data",
"table": "two",
"table_url": "http://localhost/data/two",
"table_api_url": "http://localhost/data/two.json",
"schema": (
"CREATE TABLE [two] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] FLOAT\n"
")"
),
"row_count": 2,
},
["create-table", "insert-rows"],
),
# Successful creation with row:
(
{
"table": "three",
"row": {
"id": 1,
"title": "Row 1",
"score": 1.5,
},
"pk": "id",
},
201,
{
"ok": True,
"database": "data",
"table": "three",
"table_url": "http://localhost/data/three",
"table_api_url": "http://localhost/data/three.json",
"schema": (
"CREATE TABLE [three] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] FLOAT\n"
")"
),
"row_count": 1,
},
["create-table", "insert-rows"],
),
# Create with row and no primary key
(
{
"table": "four",
"row": {
"name": "Row 1",
},
},
201,
{
"ok": True,
"database": "data",
"table": "four",
"table_url": "http://localhost/data/four",
"table_api_url": "http://localhost/data/four.json",
"schema": ("CREATE TABLE [four] (\n" " [name] TEXT\n" ")"),
"row_count": 1,
},
["create-table", "insert-rows"],
),
# Create table with compound primary key
(
{
"table": "five",
"row": {"type": "article", "key": 123, "title": "Article 1"},
"pks": ["type", "key"],
},
201,
{
"ok": True,
"database": "data",
"table": "five",
"table_url": "http://localhost/data/five",
"table_api_url": "http://localhost/data/five.json",
"schema": (
"CREATE TABLE [five] (\n [type] TEXT,\n [key] INTEGER,\n"
" [title] TEXT,\n PRIMARY KEY ([type], [key])\n)"
),
"row_count": 1,
},
["create-table", "insert-rows"],
),
# Error: Table is required
(
{
"row": {"id": 1},
},
400,
{
"ok": False,
"errors": ["Table is required"],
},
[],
),
# Error: Invalid table name
(
{
"table": "sqlite_bad_name",
"row": {"id": 1},
},
400,
{
"ok": False,
"errors": ["Invalid table name"],
},
[],
),
# Error: JSON must be an object
(
[],
400,
{
"ok": False,
"errors": ["JSON must be an object"],
},
[],
),
# Error: Cannot specify columns with rows or row
(
{
"table": "bad",
"columns": [{"name": "id", "type": "integer"}],
"rows": [{"id": 1}],
},
400,
{
"ok": False,
"errors": ["Cannot specify columns with rows or row"],
},
[],
),
# Error: columns, rows or row is required
(
{
"table": "bad",
},
400,
{
"ok": False,
"errors": ["columns, rows or row is required"],
},
[],
),
# Error: columns must be a list
(
{
"table": "bad",
"columns": {"name": "id", "type": "integer"},
},
400,
{
"ok": False,
"errors": ["columns must be a list"],
},
[],
),
# Error: columns must be a list of objects
(
{
"table": "bad",
"columns": ["id"],
},
400,
{
"ok": False,
"errors": ["columns must be a list of objects"],
},
[],
),
# Error: Column name is required
(
{
"table": "bad",
"columns": [{"type": "integer"}],
},
400,
{
"ok": False,
"errors": ["Column name is required"],
},
[],
),
# Error: Unsupported column type
(
{
"table": "bad",
"columns": [{"name": "id", "type": "bad"}],
},
400,
{
"ok": False,
"errors": ["Unsupported column type: bad"],
},
[],
),
# Error: Duplicate column name
(
{
"table": "bad",
"columns": [
{"name": "id", "type": "integer"},
{"name": "id", "type": "integer"},
],
},
400,
{
"ok": False,
"errors": ["Duplicate column name: id"],
},
[],
),
# Error: rows must be a list
(
{
"table": "bad",
"rows": {"id": 1},
},
400,
{
"ok": False,
"errors": ["rows must be a list"],
},
[],
),
# Error: rows must be a list of objects
(
{
"table": "bad",
"rows": ["id"],
},
400,
{
"ok": False,
"errors": ["rows must be a list of objects"],
},
[],
),
# Error: pk must be a string
(
{
"table": "bad",
"row": {"id": 1},
"pk": 1,
},
400,
{
"ok": False,
"errors": ["pk must be a string"],
},
[],
),
# Error: Cannot specify both pk and pks
(
{
"table": "bad",
"row": {"id": 1, "name": "Row 1"},
"pk": "id",
"pks": ["id", "name"],
},
400,
{
"ok": False,
"errors": ["Cannot specify both pk and pks"],
},
[],
),
# Error: pks must be a list
(
{
"table": "bad",
"row": {"id": 1, "name": "Row 1"},
"pks": "id",
},
400,
{
"ok": False,
"errors": ["pks must be a list"],
},
[],
),
# Error: pks must be a list of strings
(
{"table": "bad", "row": {"id": 1, "name": "Row 1"}, "pks": [1, 2]},
400,
{"ok": False, "errors": ["pks must be a list of strings"]},
[],
),
# Error: ignore and replace are mutually exclusive
(
{
"table": "bad",
"row": {"id": 1, "name": "Row 1"},
"pk": "id",
"ignore": True,
"replace": True,
},
400,
{
"ok": False,
"errors": ["ignore and replace are mutually exclusive"],
},
[],
),
# ignore and replace require row or rows
(
{
"table": "bad",
"columns": [{"name": "id", "type": "integer"}],
"ignore": True,
},
400,
{
"ok": False,
"errors": ["ignore and replace require row or rows"],
},
[],
),
# ignore and replace require pk or pks
(
{
"table": "bad",
"row": {"id": 1},
"ignore": True,
},
400,
{
"ok": False,
"errors": ["ignore and replace require pk or pks"],
},
[],
),
(
{
"table": "bad",
"row": {"id": 1},
"replace": True,
},
400,
{
"ok": False,
"errors": ["ignore and replace require pk or pks"],
},
[],
),
),
)
async def test_create_table(
ds_write, input, expected_status, expected_response, expected_events
):
ds_write._tracked_events = []
# Special case for expected status of 403
if expected_status == 403:
token = "bad_token"
else:
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json=input,
headers=_headers(token),
)
assert response.status_code == expected_status
data = response.json()
assert data == expected_response
# Should have tracked the expected events
events = ds_write._tracked_events
assert [e.name for e in events] == expected_events
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permissions,body,expected_status,expected_errors",
(
(["create-table"], {"table": "t", "columns": [{"name": "c"}]}, 201, None),
# Need insert-row too if you use "rows":
(
["create-table"],
{"table": "t", "rows": [{"name": "c"}]},
403,
["Permission denied: need insert-row"],
),
# This should work:
(
["create-table", "insert-row"],
{"table": "t", "rows": [{"name": "c"}]},
201,
None,
),
# If you use replace: true you need update-row too:
(
["create-table", "insert-row"],
{"table": "t", "rows": [{"id": 1}], "pk": "id", "replace": True},
403,
["Permission denied: need update-row"],
),
),
)
async def test_create_table_permissions(
ds_write, permissions, body, expected_status, expected_errors
):
token = ds_write.create_token("root", restrict_all=["view-instance"] + permissions)
response = await ds_write.client.post(
"/data/-/create",
json=body,
headers=_headers(token),
)
assert response.status_code == expected_status
if expected_errors:
data = response.json()
assert data["ok"] is False
assert data["errors"] == expected_errors
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input,expected_rows_after",
(
(
{
"table": "test_insert_replace",
"rows": [
{"id": 1, "name": "Row 1 new"},
{"id": 3, "name": "Row 3 new"},
],
"pk": "id",
"ignore": True,
},
[
{"id": 1, "name": "Row 1"},
{"id": 2, "name": "Row 2"},
{"id": 3, "name": "Row 3 new"},
],
),
(
{
"table": "test_insert_replace",
"rows": [
{"id": 1, "name": "Row 1 new"},
{"id": 3, "name": "Row 3 new"},
],
"pk": "id",
"replace": True,
},
[
{"id": 1, "name": "Row 1 new"},
{"id": 2, "name": "Row 2"},
{"id": 3, "name": "Row 3 new"},
],
),
),
)
async def test_create_table_ignore_replace(ds_write, input, expected_rows_after):
# Create table with two rows
token = write_token(ds_write)
first_response = await ds_write.client.post(
"/data/-/create",
json={
"rows": [{"id": 1, "name": "Row 1"}, {"id": 2, "name": "Row 2"}],
"table": "test_insert_replace",
"pk": "id",
},
headers=_headers(token),
)
assert first_response.status_code == 201
ds_write._tracked_events = []
# Try a second time
second_response = await ds_write.client.post(
"/data/-/create",
json=input,
headers=_headers(token),
)
assert second_response.status_code == 201
# Check that the rows are as expected
rows = await ds_write.client.get("/data/test_insert_replace.json?_shape=array")
assert rows.json() == expected_rows_after
# Check it fired the right events
event_names = [e.name for e in ds_write._tracked_events]
assert event_names == ["insert-rows"]
@pytest.mark.asyncio
async def test_create_table_error_if_pk_changed(ds_write):
token = write_token(ds_write)
first_response = await ds_write.client.post(
"/data/-/create",
json={
"rows": [{"id": 1, "name": "Row 1"}, {"id": 2, "name": "Row 2"}],
"table": "test_insert_replace",
"pk": "id",
},
headers=_headers(token),
)
assert first_response.status_code == 201
# Try a second time with a different pk
second_response = await ds_write.client.post(
"/data/-/create",
json={
"rows": [{"id": 1, "name": "Row 1"}, {"id": 2, "name": "Row 2"}],
"table": "test_insert_replace",
"pk": "name",
"replace": True,
},
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == {
"ok": False,
"errors": ["pk cannot be changed for existing table"],
}
@pytest.mark.asyncio
async def test_create_table_error_rows_twice_with_duplicates(ds_write):
# Error if you don't send ignore: True or replace: True
token = write_token(ds_write)
input = {
"rows": [{"id": 1, "name": "Row 1"}, {"id": 2, "name": "Row 2"}],
"table": "test_create_twice",
"pk": "id",
}
first_response = await ds_write.client.post(
"/data/-/create",
json=input,
headers=_headers(token),
)
assert first_response.status_code == 201
second_response = await ds_write.client.post(
"/data/-/create",
json=input,
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == {
"ok": False,
"errors": ["UNIQUE constraint failed: test_create_twice.id"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/data/-/create",
"/data/docs/-/drop",
"/data/docs/-/insert",
),
)
async def test_method_not_allowed(ds_write, path):
response = await ds_write.client.get(
path,
headers={
"Content-Type": "application/json",
},
)
assert response.status_code == 405
assert response.json() == {
"ok": False,
"error": "Method not allowed",
}
@pytest.mark.asyncio
async def test_create_uses_alter_by_default_for_new_table(ds_write):
ds_write._tracked_events = []
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "new_table",
"rows": [
{
"name": "Row 1",
}
]
* 100
+ [
{"name": "Row 2", "extra": "Extra"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
event_names = [e.name for e in ds_write._tracked_events]
assert event_names == ["create-table", "insert-rows"]
@pytest.mark.asyncio
@pytest.mark.parametrize("has_alter_permission", (True, False))
async def test_create_using_alter_against_existing_table(
ds_write, has_alter_permission
):
token = write_token(
ds_write, permissions=["ir", "ct"] + (["at"] if has_alter_permission else [])
)
# First create the table
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "new_table",
"rows": [
{
"name": "Row 1",
}
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
ds_write._tracked_events = []
# Now try to insert more rows using /-/create with alter=True
response2 = await ds_write.client.post(
"/data/-/create",
json={
"table": "new_table",
"rows": [{"name": "Row 2", "extra": "extra"}],
"pk": "id",
"alter": True,
},
headers=_headers(token),
)
if not has_alter_permission:
assert response2.status_code == 403
assert response2.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
else:
assert response2.status_code == 201
event_names = [e.name for e in ds_write._tracked_events]
assert event_names == ["alter-table", "insert-rows"]
# It should have altered the table
alter_event = ds_write._tracked_events[0]
assert alter_event.name == "alter-table"
assert "extra" not in alter_event.before_schema
assert "extra" in alter_event.after_schema
insert_rows_event = ds_write._tracked_events[1]
assert insert_rows_event.name == "insert-rows"
assert insert_rows_event.num_rows == 1