/-/schema and /db/-/schema and /db/table/-/schema pages (plus .json/.md)

* Add schema endpoints for databases, instances, and tables

Closes: #2586

This commit adds new endpoints to view database schemas in multiple formats:

- /-/schema - View schemas for all databases (HTML, JSON, MD)
- /database/-/schema - View schema for a specific database (HTML, JSON, MD)
- /database/table/-/schema - View schema for a specific table (JSON, MD)

Features:
- Supports HTML, JSON, and Markdown output formats
- Respects view-database and view-table permissions
- Uses group_concat(sql, ';' || CHAR(10)) from sqlite_master to retrieve schemas
- Includes comprehensive tests covering all formats and permission checks

The JSON endpoints return:
- Instance level: {"schemas": [{"database": "name", "schema": "sql"}, ...]}
- Database level: {"database": "name", "schema": "sql"}
- Table level: {"database": "name", "table": "name", "schema": "sql"}

Markdown format provides formatted output with headings and SQL code blocks.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-11-07 12:01:23 -08:00 committed by GitHub
commit 8bc9b1ee03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 526 additions and 4 deletions

View file

@ -142,7 +142,7 @@ async def test_database_page(ds_client):
# And a list of tables
for fragment in (
'<h2 id="tables">Tables</h2>',
'<h2 id="tables">Tables',
'<h3><a href="/fixtures/sortable">sortable</a></h3>',
"<p><em>pk, foreign_key_with_label, foreign_key_with_blank_label, ",
):

View file

@ -0,0 +1,248 @@
import asyncio
import pytest
import pytest_asyncio
from datasette.app import Datasette
@pytest_asyncio.fixture(scope="module")
async def schema_ds():
"""Create a Datasette instance with test databases and permission config."""
ds = Datasette(
config={
"databases": {
"schema_private_db": {"allow": {"id": "root"}},
}
}
)
# Create public database with multiple tables
public_db = ds.add_memory_database("schema_public_db")
await public_db.execute_write(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
)
await public_db.execute_write(
"CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT)"
)
await public_db.execute_write(
"CREATE VIEW IF NOT EXISTS recent_posts AS SELECT * FROM posts ORDER BY id DESC"
)
# Create a database with restricted access (requires root permission)
private_db = ds.add_memory_database("schema_private_db")
await private_db.execute_write(
"CREATE TABLE IF NOT EXISTS secret_data (id INTEGER PRIMARY KEY, value TEXT)"
)
# Create an empty database
ds.add_memory_database("schema_empty_db")
return ds
@pytest.mark.asyncio
@pytest.mark.parametrize(
"format_ext,expected_in_content",
[
("json", None),
("md", ["# Schema for", "```sql"]),
("", ["Schema for", "CREATE TABLE"]),
],
)
async def test_database_schema_formats(schema_ds, format_ext, expected_in_content):
"""Test /database/-/schema endpoint in different formats."""
url = "/schema_public_db/-/schema"
if format_ext:
url += f".{format_ext}"
response = await schema_ds.client.get(url)
assert response.status_code == 200
if format_ext == "json":
data = response.json()
assert "database" in data
assert data["database"] == "schema_public_db"
assert "schema" in data
assert "CREATE TABLE users" in data["schema"]
else:
content = response.text
for expected in expected_in_content:
assert expected in content
@pytest.mark.asyncio
@pytest.mark.parametrize(
"format_ext,expected_in_content",
[
("json", None),
("md", ["# Schema for", "```sql"]),
("", ["Schema for all databases"]),
],
)
async def test_instance_schema_formats(schema_ds, format_ext, expected_in_content):
"""Test /-/schema endpoint in different formats."""
url = "/-/schema"
if format_ext:
url += f".{format_ext}"
response = await schema_ds.client.get(url)
assert response.status_code == 200
if format_ext == "json":
data = response.json()
assert "schemas" in data
assert isinstance(data["schemas"], list)
db_names = [item["database"] for item in data["schemas"]]
# Should see schema_public_db and schema_empty_db, but not schema_private_db (anonymous user)
assert "schema_public_db" in db_names
assert "schema_empty_db" in db_names
assert "schema_private_db" not in db_names
# Check schemas are present
for item in data["schemas"]:
if item["database"] == "schema_public_db":
assert "CREATE TABLE users" in item["schema"]
else:
content = response.text
for expected in expected_in_content:
assert expected in content
@pytest.mark.asyncio
@pytest.mark.parametrize(
"format_ext,expected_in_content",
[
("json", None),
("md", ["# Schema for", "```sql"]),
("", ["Schema for users"]),
],
)
async def test_table_schema_formats(schema_ds, format_ext, expected_in_content):
"""Test /database/table/-/schema endpoint in different formats."""
url = "/schema_public_db/users/-/schema"
if format_ext:
url += f".{format_ext}"
response = await schema_ds.client.get(url)
assert response.status_code == 200
if format_ext == "json":
data = response.json()
assert "database" in data
assert data["database"] == "schema_public_db"
assert "table" in data
assert data["table"] == "users"
assert "schema" in data
assert "CREATE TABLE users" in data["schema"]
else:
content = response.text
for expected in expected_in_content:
assert expected in content
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"/schema_private_db/-/schema.json",
"/schema_private_db/secret_data/-/schema.json",
],
)
async def test_schema_permission_enforcement(schema_ds, url):
"""Test that permissions are enforced for schema endpoints."""
# Anonymous user should get 403
response = await schema_ds.client.get(url)
assert response.status_code == 403
# Authenticated user with permission should succeed
response = await schema_ds.client.get(
url,
cookies={"ds_actor": schema_ds.client.actor_cookie({"id": "root"})},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_instance_schema_respects_database_permissions(schema_ds):
"""Test that /-/schema only shows databases the user can view."""
# Anonymous user should only see public databases
response = await schema_ds.client.get("/-/schema.json")
assert response.status_code == 200
data = response.json()
db_names = [item["database"] for item in data["schemas"]]
assert "schema_public_db" in db_names
assert "schema_empty_db" in db_names
assert "schema_private_db" not in db_names
# Authenticated user should see all databases
response = await schema_ds.client.get(
"/-/schema.json",
cookies={"ds_actor": schema_ds.client.actor_cookie({"id": "root"})},
)
assert response.status_code == 200
data = response.json()
db_names = [item["database"] for item in data["schemas"]]
assert "schema_public_db" in db_names
assert "schema_empty_db" in db_names
assert "schema_private_db" in db_names
@pytest.mark.asyncio
async def test_database_schema_with_multiple_tables(schema_ds):
"""Test schema with multiple tables in a database."""
response = await schema_ds.client.get("/schema_public_db/-/schema.json")
assert response.status_code == 200
data = response.json()
schema = data["schema"]
# All objects should be in the schema
assert "CREATE TABLE users" in schema
assert "CREATE TABLE posts" in schema
assert "CREATE VIEW recent_posts" in schema
@pytest.mark.asyncio
async def test_empty_database_schema(schema_ds):
"""Test schema for an empty database."""
response = await schema_ds.client.get("/schema_empty_db/-/schema.json")
assert response.status_code == 200
data = response.json()
assert data["database"] == "schema_empty_db"
assert data["schema"] == ""
@pytest.mark.asyncio
async def test_database_not_exists(schema_ds):
"""Test schema for a non-existent database returns 404."""
# Test JSON format
response = await schema_ds.client.get("/nonexistent_db/-/schema.json")
assert response.status_code == 404
data = response.json()
assert data["ok"] is False
assert "not found" in data["error"].lower()
# Test HTML format (returns text)
response = await schema_ds.client.get("/nonexistent_db/-/schema")
assert response.status_code == 404
assert "not found" in response.text.lower()
# Test Markdown format (returns text)
response = await schema_ds.client.get("/nonexistent_db/-/schema.md")
assert response.status_code == 404
assert "not found" in response.text.lower()
@pytest.mark.asyncio
async def test_table_not_exists(schema_ds):
"""Test schema for a non-existent table returns 404."""
# Test JSON format
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema.json")
assert response.status_code == 404
data = response.json()
assert data["ok"] is False
assert "not found" in data["error"].lower()
# Test HTML format (returns text)
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema")
assert response.status_code == 404
assert "not found" in response.text.lower()
# Test Markdown format (returns text)
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema.md")
assert response.status_code == 404
assert "not found" in response.text.lower()