Merge remote-tracking branch 'origin/main' into claude/json-api-docs-1-0-review-a3e83u

# Conflicts:
#	datasette/__init__.py
#	tests/test_api_write.py
This commit is contained in:
Claude 2026-07-06 22:25:27 +00:00
commit 194ee95ae2
No known key found for this signature in database
37 changed files with 4888 additions and 179 deletions

View file

@ -435,7 +435,7 @@ async def test_row_foreign_key_tables(ds_client):
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details"
)
assert response.status_code == 200
data = response.json()
@ -452,6 +452,45 @@ async def test_row_extras(ds_client):
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
id_detail = data["column_details"]["id"]
assert id_detail["type"].lower() == "integer"
assert id_detail == {
"type": id_detail["type"],
"sqlite_type": "INTEGER",
"notnull": False,
"default": None,
"is_pk": True,
"pk_position": 1,
"hidden": 0,
}
content_detail = data["column_details"]["content"]
assert content_detail["type"].lower() == "text"
assert content_detail == {
"type": content_detail["type"],
"sqlite_type": "TEXT",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
async def test_column_details_extra_row_for_null_blob(ds_client):
response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details")
assert response.status_code == 200
data_detail = response.json()["column_details"]["data"]
assert data_detail["type"].lower() == "blob"
assert data_detail == {
"type": data_detail["type"],
"sqlite_type": "BLOB",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
@ -627,6 +666,7 @@ async def test_settings_json(ds_client):
"facet_time_limit_ms": 200,
"max_returned_rows": 100,
"max_insert_rows": 100,
"max_post_body_bytes": 2 * 1024 * 1024,
"sql_time_limit_ms": 200,
"allow_download": True,
"allow_signed_tokens": True,

View file

@ -3,9 +3,31 @@ from datasette.events import RenameTableEvent
from datasette.utils import error_body, escape_sqlite, sqlite3
from .utils import last_event
import pytest
import re
import time
def schema_variants(schema):
# sqlite-utils < 4 quotes identifiers [like_this] and uses FLOAT;
# sqlite-utils >= 4 quotes them "like_this" and uses REAL. Given a
# schema fragment in the old format, return both variants so tests
# can pass against either version.
converted = re.sub(r"\[([^\]]+)\]", r'"\1"', schema).replace("FLOAT", "REAL")
return (schema, converted)
def assert_schema_contains(fragment, schema):
assert any(
variant in schema for variant in schema_variants(fragment)
), "Expected schema to contain {!r}, got {!r}".format(fragment, schema)
def assert_schema_not_contains(fragment, schema):
assert not any(
variant in schema for variant in schema_variants(fragment)
), "Expected schema not to contain {!r}, got {!r}".format(fragment, schema)
@pytest.fixture
def ds_write(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
@ -50,6 +72,133 @@ def _insert_and_fetch_created(conn, table, insert_sql):
).fetchone()
BASE64_WRITE_API_VALUE = {"$base64": True, "encoded": "AAEC/f7/"}
BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
@pytest.mark.asyncio
async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "binary_create",
"row": {
"id": 1,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
"double_raw": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
},
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
assert_schema_contains("[data] BLOB", response.json()["schema"])
assert_schema_contains("[literal] TEXT", response.json()["schema"])
rows = (await ds_write.get_database("data").execute("""
select
typeof(data) as data_type,
hex(data) as data_hex,
typeof(literal) as literal_type,
literal,
typeof(double_raw) as double_raw_type,
double_raw
from binary_create
""")).dicts()
assert rows == [
{
"data_type": "blob",
"data_hex": "000102FDFEFF",
"literal_type": "text",
"literal": BASE64_WRITE_API_LITERAL,
"double_raw_type": "text",
"double_raw": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
}
]
@pytest.mark.asyncio
async def test_base64_write_api_insert_upsert_update_decode_blobs(ds_write):
token = write_token(ds_write)
db = ds_write.get_database("data")
await db.execute_write(
"create table binary_api (id integer primary key, data blob, literal text)"
)
insert_response = await ds_write.client.post(
"/data/binary_api/-/insert",
json={
"row": {
"id": 1,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
}
},
headers=_headers(token),
)
assert insert_response.status_code == 201
assert insert_response.json()["rows"][0]["data"] == BASE64_WRITE_API_VALUE
upsert_response = await ds_write.client.post(
"/data/binary_api/-/upsert",
json={
"rows": [
{
"id": 2,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
}
]
},
headers=_headers(token),
)
assert upsert_response.status_code == 200
assert upsert_response.json() == {"ok": True}
update_response = await ds_write.client.post(
"/data/binary_api/1/-/update",
json={
"update": {
"data": {"$base64": True, "encoded": "/wAB"},
"literal": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
},
"return": True,
},
headers=_headers(token),
)
assert update_response.status_code == 200
assert update_response.json()["row"]["data"] == {"$base64": True, "encoded": "/wAB"}
rows = (await db.execute("""
select
id,
typeof(data) as data_type,
hex(data) as data_hex,
typeof(literal) as literal_type,
literal
from binary_api
order by id
""")).dicts()
assert rows == [
{
"id": 1,
"data_type": "blob",
"data_hex": "FF0001",
"literal_type": "text",
"literal": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
},
{
"id": 2,
"data_type": "blob",
"data_hex": "000102FDFEFF",
"literal_type": "text",
"literal": BASE64_WRITE_API_LITERAL,
},
]
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json(ds_write):
response = await ds_write.client.get("/-/api", actor={"id": "root"})
@ -180,6 +329,35 @@ async def test_insert_rows(ds_write, return_rows):
assert response.json()["rows"] == actual_rows
@pytest.mark.asyncio
async def test_insert_rows_post_body_too_large(tmp_path_factory):
db_path = str(tmp_path_factory.mktemp("dbs") / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette([db_path], settings={"max_post_body_bytes": 100})
ds.root_enabled = True
token = write_token(ds)
response = await ds.client.post(
"/data/docs/-/insert",
json={"rows": [{"title": "x" * 200}]},
headers=_headers(token),
)
assert response.status_code == 413
assert response.json() == {
"ok": False,
"errors": ["Request body exceeded maximum size of 100 bytes"],
}
# A small body should still work
response2 = await ds.client.post(
"/data/docs/-/insert",
json={"row": {"title": "hi"}},
headers=_headers(token),
)
assert response2.status_code == 201
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,input,special_case,expected_status,expected_errors",
@ -1047,7 +1225,9 @@ async def test_alter_table_foreign_key_operations(ds_write):
assert response.status_code == 200, response.text
data = response.json()
assert data["operations_applied"] == 2
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
assert_schema_contains(
"[owner_id] INTEGER REFERENCES [owners]([id])", data["schema"]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1058,7 +1238,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
assert_schema_not_contains("[owner_id] INTEGER REFERENCES", data["schema"])
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1082,7 +1262,9 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"]
assert_schema_contains(
"[owner_id] INTEGER REFERENCES [categories]([id])", data["schema"]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1091,7 +1273,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
assert_schema_not_contains("[owner_id] INTEGER REFERENCES", data["schema"])
@pytest.mark.asyncio
@ -2019,6 +2201,9 @@ async def test_create_table(
if expected_response.get("ok") is False:
# Error expectations list their messages; derive the canonical envelope
expected_response = error_body(expected_response["errors"], expected_status)
if isinstance(expected_response, dict) and "schema" in expected_response:
assert data.get("schema") in schema_variants(expected_response["schema"])
expected_response = dict(expected_response, schema=data.get("schema"))
assert data == expected_response
# Should have tracked the expected events
events = ds_write._tracked_events
@ -2061,7 +2246,9 @@ async def test_create_table_with_foreign_key(ds_write):
)
assert response.status_code == 201
data = response.json()
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
assert_schema_contains(
"[owner_id] INTEGER REFERENCES [owners]([id])", data["schema"]
)
@pytest.mark.asyncio

View file

@ -1,5 +1,6 @@
import pytest
import sqlite_utils
from datasette.utils import escape_sqlite
# ensure refresh_schemas() gets called before interacting with internal_db
@ -76,19 +77,71 @@ async def test_internal_foreign_key_references(ds_client):
internal_db = await ensure_internal(ds_client)
def inner(conn):
db = sqlite_utils.Database(conn)
table_names = db.table_names()
for table in db.tables:
for fk in table.foreign_keys:
other_table = fk.other_table
other_column = fk.other_column
message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
table.name, fk.column, other_table, other_column
table_names = [
row[0]
for row in conn.execute(
"select name from sqlite_master where type = 'table'"
).fetchall()
]
def columns_for_table(table_name):
return {
row[1]
for row in conn.execute(
"PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall()
}
def primary_keys_for_table(table_name):
return [
name
for _, name in sorted(
(row[5], row[1])
for row in conn.execute(
"PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall()
if row[5]
)
]
columns_by_table = {
table_name: columns_for_table(table_name) for table_name in table_names
}
for table_name in table_names:
foreign_key_rows = conn.execute(
"PRAGMA foreign_key_list({})".format(escape_sqlite(table_name))
).fetchall()
foreign_keys_by_id = {}
for foreign_key in foreign_key_rows:
foreign_keys_by_id.setdefault(foreign_key[0], []).append(foreign_key)
for foreign_key_rows in foreign_keys_by_id.values():
foreign_key_rows.sort(key=lambda row: row[1])
other_table = foreign_key_rows[0][2]
other_columns = [row[4] for row in foreign_key_rows]
message = 'Column "{}.{}" references other table "{}" which does not exist'.format(
table_name, foreign_key_rows[0][3], other_table
)
assert other_table in table_names, message + " (bad table)"
assert other_column in db[other_table].columns_dict, (
message + " (bad column)"
if all(other_column is None for other_column in other_columns):
other_columns = primary_keys_for_table(other_table)
length_message = 'Foreign key from "{}" to "{}" has {} columns but references {} columns'.format(
table_name,
other_table,
len(foreign_key_rows),
len(other_columns),
)
assert len(other_columns) == len(foreign_key_rows), length_message
for foreign_key, other_column in zip(foreign_key_rows, other_columns):
column = foreign_key[3]
message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
table_name, column, other_table, other_column
)
assert other_column in columns_by_table[other_table], (
message + " (bad column)"
)
await internal_db.execute_fn(inner)

View file

@ -1,8 +1,38 @@
from datasette.utils.asgi import Request
from datasette.utils.asgi import PayloadTooLarge, Request
import json
import pytest
def _post_scope(headers=None):
return {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": headers or [[b"content-type", b"application/json"]],
}
def _receive_chunks(chunks):
messages = [
{
"type": "http.request",
"body": chunk,
"more_body": i < len(chunks) - 1,
}
for i, chunk in enumerate(chunks)
]
messages.reverse()
async def receive():
return messages.pop()
return receive
@pytest.mark.asyncio
async def test_request_post_vars():
scope = {
@ -106,6 +136,70 @@ async def test_request_json_invalid():
await request.json()
@pytest.mark.asyncio
async def test_request_post_body_multiple_chunks():
request = Request(_post_scope(), _receive_chunks([b"hello ", b"world"]))
assert await request.post_body() == b"hello world"
@pytest.mark.asyncio
async def test_request_post_body_content_length_too_large():
# Should reject based on content-length without reading the body
async def receive():
raise AssertionError("receive() should not be called")
scope = _post_scope(
headers=[
[b"content-type", b"application/json"],
[b"content-length", b"101"],
]
)
request = Request(scope, receive)
with pytest.raises(PayloadTooLarge):
await request.post_body(max_bytes=100)
@pytest.mark.asyncio
async def test_request_post_body_streaming_too_large():
# No content-length header - limit enforced as chunks arrive
chunks = [b"a" * 60, b"b" * 60, b"c" * 60]
request = Request(_post_scope(), _receive_chunks(chunks))
with pytest.raises(PayloadTooLarge):
await request.post_body(max_bytes=100)
@pytest.mark.asyncio
async def test_request_post_body_limit_from_constructor():
request = Request(
_post_scope(), _receive_chunks([b"too much data"]), max_post_body_bytes=5
)
with pytest.raises(PayloadTooLarge):
await request.post_body()
@pytest.mark.asyncio
async def test_request_post_body_limit_disabled():
body = b"a" * (3 * 1024 * 1024)
request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=0)
assert await request.post_body() == body
@pytest.mark.asyncio
async def test_request_post_body_default_limit():
# Bodies over 2MB are rejected by default
request = Request(_post_scope(), _receive_chunks([b"a" * (2 * 1024 * 1024 + 1)]))
with pytest.raises(PayloadTooLarge):
await request.post_body()
@pytest.mark.asyncio
async def test_request_json_too_large():
body = json.dumps({"rows": ["x" * 100]}).encode("utf-8")
request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=50)
with pytest.raises(PayloadTooLarge):
await request.json()
def test_request_args():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert "1" == request.args.get("multi")

View file

@ -1,3 +1,4 @@
import base64
import json
import socket
import subprocess
@ -10,6 +11,10 @@ import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
PNG_1X1_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
def find_free_port():
with socket.socket() as sock:
@ -17,7 +22,7 @@ def find_free_port():
return sock.getsockname()[1]
def wait_for_server(process, url, timeout=10):
def wait_for_server(process, url, timeout=30):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
@ -36,7 +41,20 @@ def wait_for_server(process, url, timeout=10):
except httpx.HTTPError as ex:
last_error = repr(ex)
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
if process.poll() is None:
process.terminate()
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
stdout, stderr = process.communicate()
else:
stdout, stderr = process.communicate()
raise AssertionError(
f"Timed out waiting for {url}: {last_error}\n"
f"stdout:\n{stdout}\n"
f"stderr:\n{stderr}"
)
@pytest.fixture
@ -102,6 +120,22 @@ def write_playwright_database(db_path):
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
create table binary_files (
id integer primary key,
name text not null,
data blob
);
create table bulk_defaults (
id integer primary key,
title text not null,
status text not null default 'todo',
score integer default 5
);
create table upsert_items (
id text primary key,
title text,
metadata text
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
@ -110,7 +144,22 @@ def write_playwright_database(db_path):
'Initial notes',
5
);
insert into upsert_items (id, title, metadata) values
('existing', 'Existing title', '{"old": true}');
""")
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Raw bytes", b"\x00\x01\x02\x03"),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("PNG image", PNG_1X1_BYTES),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Null bytes", None),
)
conn.commit()
finally:
conn.close()
@ -123,6 +172,7 @@ def write_playwright_config(config_path):
"data": {
"permissions": {
"create-table": True,
"insert-row": True,
"set-column-type": True,
},
"tables": {
@ -145,6 +195,25 @@ def write_playwright_config(config_path):
"alter-table": True,
},
},
"binary_files": {
"label_column": "name",
"permissions": {
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
"bulk_defaults": {
"permissions": {
"insert-row": True,
},
},
"upsert_items": {
"permissions": {
"insert-row": True,
"update-row": True,
},
},
},
},
},
@ -278,6 +347,58 @@ def project_row(datasette_server, pk):
return rows[0]
def binary_file_blob(datasette_server, pk):
response = httpx.get(
f"{datasette_server}data/binary_files/{pk}.blob",
params={"_blob_column": "data"},
)
response.raise_for_status()
return response.content
def wait_for_binary_control_file(binary_control, size, name=None):
binary_control.locator(
".row-edit-binary-size", has_text=f"Binary: {size} bytes"
).wait_for()
if name:
binary_control.locator(".row-edit-binary-name", has_text=name).wait_for()
def bulk_default_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_row(datasette_server, pk):
rows = upsert_item_rows(datasette_server, id=pk)
assert len(rows) == 1
return rows[0]
def open_bulk_insert_dialog(page, url):
page.goto(url)
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
return dialog
def open_jump_menu(page):
page.keyboard.press("/")
page.locator("navigation-search .search-input").wait_for()
@ -381,6 +502,165 @@ def test_create_table_flow(page, datasette_server):
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_from_data_flow(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
from_data = dialog.locator(".table-create-from-data")
assert from_data.inner_text() == "Create table from data"
from_data.click()
assert dialog.locator(".table-create-columns").is_hidden()
assert dialog.locator(".table-create-data").is_visible()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
assert (
dialog.locator(".table-create-data-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
assert (
dialog.locator(".table-create-data-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== ("table-create-data-note table-create-input table-create-data-textarea")
)
assert dialog.locator(".table-create-manual").inner_text() == (
"Create table manually"
)
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "table-create-data-textarea"
)
textarea = dialog.locator(".table-create-data-textarea")
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "short_id,name\nx,Ada"
assert dialog.locator(".table-create-table-name").input_value() == (
"repo_export_2026"
)
dialog.locator(".table-create-table-name").fill("playwright_from_data")
textarea.fill(
json.dumps(
{
"metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
"people": [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
],
}
)
)
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-data-textarea").is_hidden()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
assert dialog.locator(".table-create-cancel").inner_text() == "Back"
assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
"Previewing 2 rows."
)
assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "short_id" in preview_text
assert "Ada" in preview_text
assert "2.5" in preview_text
preview_cell_style = dialog.locator(
".table-create-data-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
dialog.locator(".table-create-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".table-create-data-textarea").is_visible()
assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_from_data")
response = httpx.get(
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
)
response.raise_for_status()
data = response.json()
assert data["rows"] == [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
]
@pytest.mark.playwright
def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
page, datasette_server
):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
dialog.locator(".table-create-from-data").click()
dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "A" in preview_text
assert "1" in preview_text
assert "B" in preview_text
assert "null" in preview_text
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_numeric_blanks")
response = httpx.get(
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
)
response.raise_for_status()
assert response.json()["rows"] == [
{"name": "A", "score": 1},
{"name": "B", "score": None},
]
schema_response = httpx.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select type from pragma_table_info('playwright_numeric_blanks') "
"where name = 'score'"
)
},
)
schema_response.raise_for_status()
assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
page.goto(f"{datasette_server}data")
@ -801,11 +1081,128 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins(
@pytest.mark.playwright
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
page.add_init_script("""
(() => {
let clipboardText = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({
writeText: async (text) => {
clipboardText = String(text);
},
readText: async () => clipboardText,
}),
});
})();
""")
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
bulk_insert = dialog.locator(".row-edit-bulk-insert")
bulk_insert.wait_for()
assert bulk_insert.inner_text() == "Insert multiple rows"
current_url = page.url
bulk_insert.click()
assert page.url == current_url
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-fields").is_hidden()
assert dialog.locator(".row-edit-bulk").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
assert (
dialog.locator(".row-edit-bulk-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
assert (
dialog.locator(".row-edit-bulk-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== (
"row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
"row-edit-bulk-actions"
)
)
copy_template = dialog.locator(".row-edit-copy-template")
assert copy_template.inner_text() == "Copy spreadsheet template"
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "row-edit-bulk-textarea"
)
assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
"Copy template"
)
assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
"You can paste the template into Google Sheets or Excel."
)
assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
"Paste into Google Sheets or Excel"
)
copy_template.click()
page.wait_for_function(
"""() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
)
assert page.evaluate("navigator.clipboard.readText()") == (
"title\tmetadata\tlogo\tnotes\tscore"
)
textarea = dialog.locator(".row-edit-bulk-textarea")
textarea.fill("title\tmetadata\nFrom TSV\t{}")
assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
dialog.locator(".row-edit-save").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 1 row."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "id" in preview_text
assert "title" in preview_text
assert "metadata" in preview_text
assert "From CSV" in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
assert "null" not in preview_text
assert "undefined" not in preview_text
preview_cell_style = dialog.locator(
".row-edit-bulk-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
dialog.locator(".row-edit-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_visible()
assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
"\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
)
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
single_insert = dialog.locator(".row-edit-single-insert")
assert single_insert.inner_text() == "Insert single row"
single_insert.click()
assert dialog.locator(".row-edit-bulk").is_hidden()
assert dialog.locator(".row-edit-fields").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
dialog.locator('textarea[name="metadata"]').fill(
'{"ok": false, "source": "playwright"}'
@ -835,6 +1232,206 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
assert data["score"] == 5
@pytest.mark.playwright
def test_bulk_insert_preview_inserts_rows(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill(
json.dumps(
{
"metadata": [{"title": "Ignored", "metadata": "{}"}],
"projects": [
{"title": "Bulk one", "metadata": "{}"},
{"title": "Bulk two", "metadata": "{}"},
],
}
)
)
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows inserted."
).wait_for()
assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
dialog.locator(".row-edit-cancel").click()
page.wait_for_load_state("domcontentloaded")
assert page.url == f"{datasette_server}data/projects"
assert project_rows(datasette_server, title="Bulk one")
assert project_rows(datasette_server, title="Bulk two")
@pytest.mark.playwright
def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
assert conflict_field.is_hidden()
textarea.fill("title\nNo primary key")
assert conflict_field.is_hidden()
textarea.fill(
"id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
)
assert conflict_field.is_visible()
assert (
dialog.locator(".row-edit-bulk-conflict-label").inner_text()
== "If the row exists already"
)
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
"Update existing and insert new",
]
)
conflict_select.select_option("upsert")
assert conflict_select.input_value() == "upsert"
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Updated by upsert" in preview_text
assert "Inserted by upsert" in preview_text
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows upserted."
).wait_for()
assert upsert_item_row(datasette_server, "existing")["title"] == (
"Updated by upsert"
)
assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
@pytest.mark.playwright
def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
textarea.fill("title\nOnly title")
assert conflict_field.is_hidden()
textarea.fill("id,title\n1,Only title")
assert conflict_field.is_visible()
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
]
)
assert conflict_select.locator('option[value="upsert"]').evaluate(
"node => node.hidden && node.disabled"
)
@pytest.mark.playwright
def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
textarea = dialog.locator(".row-edit-bulk-textarea")
error = dialog.locator(".row-edit-error")
save = dialog.locator(".row-edit-save")
textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
error.wait_for()
assert error.inner_text() == "JSON row 1 has unknown column id2."
assert save.is_disabled()
assert textarea.evaluate("node => document.activeElement === node")
textarea.fill("id2,title\n1,Unknown column")
assert error.inner_text() == "Unknown column id2 in header row."
assert save.is_disabled()
textarea.fill("[")
assert error.is_hidden()
assert not save.is_disabled()
textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
assert error.is_hidden()
assert not save.is_disabled()
assert dialog.locator(".row-edit-bulk-conflict").is_visible()
assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
@pytest.mark.playwright
def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
page.goto(f"{datasette_server}data/bulk_defaults")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Only title" in preview_text
assert "undefined" not in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="1 row inserted."
).wait_for()
rows = bulk_default_rows(datasette_server, title="Only title")
assert rows == [
{
"id": 1,
"title": "Only title",
"status": "todo",
"score": 5,
}
]
@pytest.mark.playwright
def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 3 rows."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "one" in preview_text
assert "two" in preview_text
assert "three" in preview_text
assert "null" not in preview_text
assert "undefined" not in preview_text
@pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
@ -842,6 +1439,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
assert dialog.locator(".row-edit-bulk-insert").is_hidden()
assert dialog.locator(".row-edit-single-insert").is_hidden()
assert dialog.locator(".row-edit-bulk").is_hidden()
title = dialog.locator('input[name="title"]')
title.wait_for()
title.fill("Build Datasette, edited")
@ -877,6 +1477,120 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
assert data["notes"] == "Edited from Playwright"
@pytest.mark.playwright
def test_edit_row_binary_control_shows_size_and_image_preview(page, datasette_server):
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
raw_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
raw_control.wait_for()
assert (
raw_control.locator(".row-edit-binary-size").inner_text() == "Binary: 4 bytes"
)
assert raw_control.locator(".row-edit-binary-preview img").count() == 0
assert dialog.locator('textarea[name="data"]').count() == 0
assert dialog.locator('input[type="hidden"][name="data"]').count() == 1
dialog.locator(".row-edit-cancel").click()
page.locator('tr[data-row="2"] button[data-row-action="edit"]').click()
image_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
image_control.wait_for()
assert (
image_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(PNG_1X1_BYTES)} bytes"
)
image = image_control.locator(".row-edit-binary-preview img")
image.wait_for()
assert image.get_attribute("src").startswith("blob:")
@pytest.mark.playwright
def test_edit_row_binary_control_replaces_blob_from_file(page, datasette_server):
replacement = b"Replacement \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "replacement.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
wait_for_binary_control_file(binary_control, len(replacement), "replacement.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 1").wait_for()
assert binary_file_blob(datasette_server, 1) == replacement
@pytest.mark.playwright
def test_edit_row_binary_control_handles_null_blob(page, datasette_server):
replacement = b"From NULL"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="3"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
assert (
binary_control.locator(".row-edit-binary-size").inner_text() == "No binary data"
)
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "from-null.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
wait_for_binary_control_file(binary_control, len(replacement), "from-null.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 3").wait_for()
assert binary_file_blob(datasette_server, 3) == replacement
@pytest.mark.playwright
def test_insert_row_binary_control_accepts_pasted_file(page, datasette_server):
pasted = b"Pasted \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator('input[name="name"]').fill("Pasted bytes")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.evaluate(
"""(node, bytes) => {
const transfer = new DataTransfer();
transfer.items.add(
new File([new Uint8Array(bytes)], "pasted.bin", {
type: "application/octet-stream"
})
);
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: transfer });
node.dispatchEvent(event);
}""",
list(pasted),
)
wait_for_binary_control_file(binary_control, len(pasted), "pasted.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Inserted row 4").wait_for()
assert binary_file_blob(datasette_server, 4) == pasted
@pytest.mark.playwright
def test_delete_row_flow_removes_row(page, datasette_server):
page.goto(f"{datasette_server}data/projects")

View file

@ -3221,6 +3221,74 @@ async def test_execute_write_create_table_uses_create_table_permission():
assert not await db.table_exists("should_not_exist")
@pytest.mark.asyncio
async def test_execute_write_create_view_uses_create_view_permission():
ds = Datasette(
memory=True,
default_deny=True,
config={
"permissions": {
"insert-row": {"id": "row-writer"},
"update-row": {"id": "row-writer"},
},
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["creator", "row-writer"]},
"execute-write-sql": {"id": ["creator", "row-writer"]},
"create-view": {"id": "creator"},
}
}
},
},
)
db = ds.add_memory_database("execute_write_create_view", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
analysis_response = await ds.client.get(
"/data/-/execute-write/analyze",
actor={"id": "creator"},
params={"sql": "create view dog_names as select id, name from dogs"},
)
allowed_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "creator"},
json={"sql": "create view dog_names as select id, name from dogs"},
)
row_permission_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "row-writer"},
json={"sql": "create view should_not_exist as select id from dogs"},
)
assert analysis_response.status_code == 200
analysis_data = analysis_response.json()
assert analysis_data["ok"] is True
assert analysis_data["execute_disabled"] is False
assert analysis_data["analysis_rows"] == [
{
"operation": "create",
"database": "data",
"table": "dog_names",
"required_permission": "create-view",
"source": None,
"allowed": True,
}
]
assert allowed_response.status_code == 200
assert allowed_response.json()["ok"] is True
assert allowed_response.json()["message"] == "Query executed"
assert await db.view_exists("dog_names")
assert row_permission_response.status_code == 403
assert row_permission_response.json()["errors"] == [
"Permission denied: need create-view on data"
]
assert not await db.view_exists("should_not_exist")
@pytest.mark.parametrize(
(
"database_name",
@ -3271,8 +3339,20 @@ async def test_execute_write_create_table_uses_create_table_permission():
(),
"drop-table",
),
(
"execute_write_drop_view",
"dropper",
"drop view dogs_view",
"drop view cats_view",
"Permission denied: need drop-view on data/cats_view",
(
"create view dogs_view as select * from dogs",
"create view cats_view as select * from cats",
),
"drop-view",
),
),
ids=("alter-table", "create-index", "drop-index", "drop-table"),
ids=("alter-table", "create-index", "drop-index", "drop-table", "drop-view"),
)
@pytest.mark.asyncio
async def test_execute_write_schema_operations_use_schema_permissions(
@ -3307,7 +3387,12 @@ async def test_execute_write_schema_operations_use_schema_permissions(
"drop-table": {"id": "dropper"},
"view-table": {"id": "alterer"},
}
}
},
"dogs_view": {
"permissions": {
"drop-view": {"id": "dropper"},
}
},
},
}
},
@ -3360,6 +3445,9 @@ async def test_execute_write_schema_operations_use_schema_permissions(
elif expected_state == "drop-table":
assert not await db.table_exists("dogs")
assert await db.table_exists("cats")
elif expected_state == "drop-view":
assert not await db.view_exists("dogs_view")
assert await db.view_exists("cats_view")
@pytest.mark.asyncio

View file

@ -1338,6 +1338,109 @@ async def test_binary_data_in_json(ds_client, path, expected_json, expected_text
assert response.text == expected_text
@pytest.mark.asyncio
async def test_column_details_extra_table(ds_client):
response = await ds_client.get(
"/fixtures/binary_data.json?_size=0&_extra=column_details"
)
assert response.status_code == 200
data_detail = response.json()["column_details"]["data"]
assert data_detail["type"].lower() == "blob"
assert data_detail == {
"type": data_detail["type"],
"sqlite_type": "BLOB",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
response = await ds_client.get(
"/fixtures/simple_primary_key.json?_size=0&_extra=column_details"
)
assert response.status_code == 200
column_details = response.json()["column_details"]
id_detail = column_details["id"]
assert id_detail["type"].lower() == "integer"
assert id_detail == {
"type": id_detail["type"],
"sqlite_type": "INTEGER",
"notnull": False,
"default": None,
"is_pk": True,
"pk_position": 1,
"hidden": 0,
}
content_detail = column_details["content"]
assert content_detail["type"].lower() == "text"
assert content_detail == {
"type": content_detail["type"],
"sqlite_type": "TEXT",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
response = await ds_client.get(
"/fixtures/compound_three_primary_keys.json?_size=0&_extra=column_details"
)
assert response.status_code == 200
column_details = response.json()["column_details"]
assert column_details["pk1"]["is_pk"] is True
assert column_details["pk1"]["pk_position"] == 1
assert column_details["pk2"]["is_pk"] is True
assert column_details["pk2"]["pk_position"] == 2
assert column_details["pk3"]["is_pk"] is True
assert column_details["pk3"]["pk_position"] == 3
assert column_details["content"]["is_pk"] is False
assert column_details["content"]["pk_position"] == 0
def test_column_details_extra_defaults_and_notnull():
with make_app_client(extra_databases={"defaults.db": """
CREATE TABLE defaults (
i INTEGER NOT NULL DEFAULT 42,
s TEXT DEFAULT 'hello',
dt TEXT DEFAULT (datetime('now'))
);
"""}) as client:
response = client.get("/defaults/defaults.json?_size=0&_extra=column_details")
assert response.status == 200
column_details = response.json["column_details"]
assert column_details["i"]["notnull"] is True
assert column_details["i"]["default"] == "42"
assert column_details["s"]["notnull"] is False
assert column_details["s"]["default"] == "'hello'"
assert column_details["dt"]["default"] == "datetime('now')"
@pytest.mark.skipif(
sqlite_version() < (3, 31, 0),
reason="generated columns were added in SQLite 3.31.0",
)
def test_column_details_extra_generated_columns():
with make_app_client(extra_databases={"generated.db": """
CREATE TABLE generated_columns (
body TEXT,
body_length_virtual INTEGER
GENERATED ALWAYS AS (length(body)) VIRTUAL,
body_length_stored INTEGER
GENERATED ALWAYS AS (length(body)) STORED
);
"""}) as client:
response = client.get(
"/generated/generated_columns.json?_size=0&_extra=column_details"
)
assert response.status == 200
column_details = response.json["column_details"]
assert column_details["body"]["hidden"] == 0
assert column_details["body_length_virtual"]["hidden"] == 2
assert column_details["body_length_stored"]["hidden"] == 3
@pytest.mark.asyncio
@pytest.mark.parametrize(
"qs",

View file

@ -1027,6 +1027,7 @@ async def test_database_create_table_action_button_and_data():
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
"canInsertRows": False,
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
@ -1050,6 +1051,40 @@ async def test_database_create_table_action_button_and_data():
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_insert_row_permission():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
"insert-row": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_insert_permission"),
name="data",
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data", actor={"id": "root"})
assert response.status_code == 200
create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
"createTable"
]
assert create_table_data["canInsertRows"] is True
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
@ -1316,6 +1351,7 @@ async def test_table_insert_action_button_and_data():
assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items"
assert insert_data["primaryKeys"] == ["id"]
assert insert_data["maxInsertRows"] == 100
assert [column["name"] for column in insert_data["columns"]] == [
"name",
"score",

View file

@ -132,7 +132,11 @@ def test_path_from_row_pks(row, pks, expected_path):
"""
{"CategoryID": 1, "Description": "Soft drinks", "Picture": {"$base64": true, "encoded": "FRwCx60F/g=="}}
""".strip(),
)
),
(
{"message": b"hello"},
'{"message": {"$base64": true, "encoded": "aGVsbG8="}}',
),
],
)
def test_custom_json_encoder(obj, expected):