mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
/-/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:
parent
1df4028d78
commit
8bc9b1ee03
7 changed files with 526 additions and 4 deletions
|
|
@ -58,6 +58,9 @@ from .views.special import (
|
|||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
TablesView,
|
||||
InstanceSchemaView,
|
||||
DatabaseSchemaView,
|
||||
TableSchemaView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableInsertView,
|
||||
|
|
@ -1910,6 +1913,10 @@ class Datasette:
|
|||
TablesView.as_view(self),
|
||||
r"/-/tables(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
InstanceSchemaView.as_view(self),
|
||||
r"/-/schema(\.(?P<format>json|md))?$",
|
||||
)
|
||||
add_route(
|
||||
LogoutView.as_view(self),
|
||||
r"/-/logout$",
|
||||
|
|
@ -1951,6 +1958,10 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||
add_route(
|
||||
DatabaseSchemaView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(QueryView, self),
|
||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||
|
|
@ -1975,6 +1986,10 @@ class Datasette:
|
|||
TableDropView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
||||
)
|
||||
add_route(
|
||||
TableSchemaView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||
)
|
||||
add_route(
|
||||
RowDeleteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^/]+?)/(?P<pks>[^/]+?)/-/delete$",
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if tables %}
|
||||
<h2 id="tables">Tables</h2>
|
||||
<h2 id="tables">Tables <a style="font-weight: normal; font-size: 0.75em; padding-left: 0.5em;" href="{{ urls.database(database) }}/-/schema">schema</a></h2>
|
||||
{% endif %}
|
||||
|
||||
{% for table in tables %}
|
||||
|
|
|
|||
41
datasette/templates/schema.html
Normal file
41
datasette/templates/schema.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ schemas[0].database }}.{{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}{% endblock %}
|
||||
|
||||
{% block body_class %}schema{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{% if is_instance %}
|
||||
{{ crumbs.nav(request=request) }}
|
||||
{% elif table_name %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database, table=table_name) }}
|
||||
{% else %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
{% for item in schemas %}
|
||||
{% if is_instance %}
|
||||
<h2>{{ item.database }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if item.schema %}
|
||||
<pre style="background-color: #f5f5f5; padding: 1em; overflow-x: auto; border: 1px solid #ddd; border-radius: 4px;"><code>{{ item.schema }}</code></pre>
|
||||
{% else %}
|
||||
<p><em>No schema available for this database.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if not loop.last %}
|
||||
<hr style="margin: 2em 0;">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not schemas %}
|
||||
<p><em>No databases with viewable schemas found.</em></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -761,8 +761,6 @@ class ApiExplorerView(BaseView):
|
|||
async def example_links(self, request):
|
||||
databases = []
|
||||
for name, db in self.ds.databases.items():
|
||||
if name == "_internal":
|
||||
continue
|
||||
database_visible, _ = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
action="view-database",
|
||||
|
|
@ -981,3 +979,180 @@ class TablesView(BaseView):
|
|||
]
|
||||
|
||||
return Response.json({"matches": matches, "truncated": truncated})
|
||||
|
||||
|
||||
class SchemaBaseView(BaseView):
|
||||
"""Base class for schema views with common response formatting."""
|
||||
|
||||
has_json_alternate = False
|
||||
|
||||
async def get_database_schema(self, database_name):
|
||||
"""Get schema SQL for a database."""
|
||||
db = self.ds.databases[database_name]
|
||||
result = await db.execute(
|
||||
"select group_concat(sql, ';' || CHAR(10)) as schema from sqlite_master where sql is not null"
|
||||
)
|
||||
row = result.first()
|
||||
return row["schema"] if row and row["schema"] else ""
|
||||
|
||||
def format_json_response(self, data):
|
||||
"""Format data as JSON response with CORS headers if needed."""
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(data, headers=headers)
|
||||
|
||||
def format_error_response(self, error_message, format_, status=404):
|
||||
"""Format error response based on requested format."""
|
||||
if format_ == "json":
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(
|
||||
{"ok": False, "error": error_message}, status=status, headers=headers
|
||||
)
|
||||
else:
|
||||
return Response.text(error_message, status=status)
|
||||
|
||||
def format_markdown_response(self, heading, schema):
|
||||
"""Format schema as Markdown response."""
|
||||
md_output = f"# {heading}\n\n```sql\n{schema}\n```\n"
|
||||
return Response.text(
|
||||
md_output, headers={"content-type": "text/markdown; charset=utf-8"}
|
||||
)
|
||||
|
||||
async def format_html_response(
|
||||
self, request, schemas, is_instance=False, table_name=None
|
||||
):
|
||||
"""Format schema as HTML response."""
|
||||
context = {
|
||||
"schemas": schemas,
|
||||
"is_instance": is_instance,
|
||||
}
|
||||
if table_name:
|
||||
context["table_name"] = table_name
|
||||
return await self.render(["schema.html"], request=request, context=context)
|
||||
|
||||
|
||||
class InstanceSchemaView(SchemaBaseView):
|
||||
"""
|
||||
Displays schema for all databases in the instance.
|
||||
Supports HTML, JSON, and Markdown formats.
|
||||
"""
|
||||
|
||||
name = "instance_schema"
|
||||
|
||||
async def get(self, request):
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
||||
# Get all databases the actor can view
|
||||
allowed_databases_page = await self.ds.allowed_resources(
|
||||
"view-database",
|
||||
request.actor,
|
||||
)
|
||||
allowed_databases = [r.parent async for r in allowed_databases_page.all()]
|
||||
|
||||
# Get schema for each database
|
||||
schemas = []
|
||||
for database_name in allowed_databases:
|
||||
schema = await self.get_database_schema(database_name)
|
||||
schemas.append({"database": database_name, "schema": schema})
|
||||
|
||||
if format_ == "json":
|
||||
return self.format_json_response({"schemas": schemas})
|
||||
elif format_ == "md":
|
||||
md_parts = [
|
||||
f"# Schema for {item['database']}\n\n```sql\n{item['schema']}\n```"
|
||||
for item in schemas
|
||||
]
|
||||
return Response.text(
|
||||
"\n\n".join(md_parts),
|
||||
headers={"content-type": "text/markdown; charset=utf-8"},
|
||||
)
|
||||
else:
|
||||
return await self.format_html_response(request, schemas, is_instance=True)
|
||||
|
||||
|
||||
class DatabaseSchemaView(SchemaBaseView):
|
||||
"""
|
||||
Displays schema for a specific database.
|
||||
Supports HTML, JSON, and Markdown formats.
|
||||
"""
|
||||
|
||||
name = "database_schema"
|
||||
|
||||
async def get(self, request):
|
||||
database_name = request.url_vars["database"]
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
||||
# Check if database exists
|
||||
if database_name not in self.ds.databases:
|
||||
return self.format_error_response("Database not found", format_)
|
||||
|
||||
# Check view-database permission
|
||||
await self.ds.ensure_permission(
|
||||
action="view-database",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
schema = await self.get_database_schema(database_name)
|
||||
|
||||
if format_ == "json":
|
||||
return self.format_json_response(
|
||||
{"database": database_name, "schema": schema}
|
||||
)
|
||||
elif format_ == "md":
|
||||
return self.format_markdown_response(f"Schema for {database_name}", schema)
|
||||
else:
|
||||
schemas = [{"database": database_name, "schema": schema}]
|
||||
return await self.format_html_response(request, schemas)
|
||||
|
||||
|
||||
class TableSchemaView(SchemaBaseView):
|
||||
"""
|
||||
Displays schema for a specific table.
|
||||
Supports HTML, JSON, and Markdown formats.
|
||||
"""
|
||||
|
||||
name = "table_schema"
|
||||
|
||||
async def get(self, request):
|
||||
database_name = request.url_vars["database"]
|
||||
table_name = request.url_vars["table"]
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
||||
# Check view-table permission
|
||||
await self.ds.ensure_permission(
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
# Get schema for the table
|
||||
db = self.ds.databases[database_name]
|
||||
result = await db.execute(
|
||||
"select sql from sqlite_master where name = ? and sql is not null",
|
||||
[table_name],
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
# Return 404 if table doesn't exist
|
||||
if not row or not row["sql"]:
|
||||
return self.format_error_response("Table not found", format_)
|
||||
|
||||
schema = row["sql"]
|
||||
|
||||
if format_ == "json":
|
||||
return self.format_json_response(
|
||||
{"database": database_name, "table": table_name, "schema": schema}
|
||||
)
|
||||
elif format_ == "md":
|
||||
return self.format_markdown_response(
|
||||
f"Schema for {database_name}.{table_name}", schema
|
||||
)
|
||||
else:
|
||||
schemas = [{"database": database_name, "schema": schema}]
|
||||
return await self.format_html_response(
|
||||
request, schemas, table_name=table_name
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue