mirror of
https://github.com/simonw/datasette.git
synced 2026-07-10 01:24:45 +02:00
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:
commit
194ee95ae2
37 changed files with 4888 additions and 179 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue