Create table, alter table - APIs and modal dialog UI

* Add create table UI

Adds a permission-gated database action that opens a create table modal on database pages, backed by the existing create-table JSON API.

The modal starts with an id integer primary key column plus a blank text column, supports SQLite type selection, and shows custom column type controls only when the actor can set column types.

Selected custom column types are applied after table creation with follow-up set-column-type API calls. Includes styling plus HTML and Playwright coverage for the action payload and create-table flow.

* Add alter table JSON API

- Add POST /<database>/<table>/-/alter with Pydantic validation and dry-run support.
- Support add, rename, alter, drop, primary-key and reorder operations, including allow-listed default expressions.
- Document the endpoint and cover schema changes, validation, permissions, events and dry runs.

Refs #2788

* Add alter table modal

- Register a built-in table action and expose alter-table metadata to table pages.
- Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types.
- Add a review/apply confirmation flow with HTML and Playwright coverage.

Refs #2788

* Ran Prettier

* Isolate Unix domain socket test server paths

- Use a per-process socket path for the UDS test fixture.
- Clean up stale socket files before and after the fixture runs.
- Close the HTTP client and wait for the Datasette subprocess to exit.

* Precompute action permissions for table pages

- Extract reusable helpers for database and table action permission preloading.
- Precompute those permissions before building table-page HTML data.
- Document the default table actions plugin.

* Test against pyodide/v314.0.0

Now that we depend on pydantic we need a more recent
pyodide in order to load the emscripten build
of pydantic-core.

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733412763

* Split table create and alter views

- Move create-table and alter-table API views into table_create_alter.py.
- Keep create and alter schema-editing constants and helpers together.
- Rename the create table modal context helper.

* Add foreign keys to create table API

- Add fk_table and optional fk_column support to create-table columns.
- Validate create-table requests with Pydantic while preserving existing errors.
- Document the API and cover inferred primary-key and validation cases.
Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733544452

* Add foreign keys to alter table API

- Add add_foreign_key, drop_foreign_key, and set_foreign_keys operations.
- Validate flat fk_table and fk_column arguments with Pydantic.
- Document the API and cover inferred primary-key and validation cases.

* /db/table/-/foreign-key-suggestions API

Improved version of the implementation datasette-edit-schema

* /<database>/-/foreign-key-targets API endpoint

Returns a list of tables with a single primary key, and for each one
the name of that primary key column and its SQLite type affinity.

This will be used by the create table UI to suggest foreign keys.

* Expose foreign key targets to create table UI

- Add foreignKeyTargetsPath to create table page data
- Filter hidden tables from database-level foreign key target results
- Update JSON API docs and tests for filtered targets

* Add foreign key controls to create table dialog

- Add create table advanced controls for foreign keys and first-column primary keys
- Share schema dialog row helpers between create and alter dialogs
- Move custom type into advanced options and add Add column icons

* More robust test_datasette_https_server.sh test

* Make custom type and foreign key mutually exclusive

In the create table dialog a column can now have either a custom display
type or a foreign key target, but not both - a foreign key column's type
is determined by the referenced primary key, so a custom type doesn't
apply. Setting one clears and disables the other, and the foreign key
select stays disabled on the primary key column and when no targets exist.

Also add "Controls how Datasette displays and edits this column" help
text (with aria-describedby) under the custom type selector in both the
create and alter dialogs, and style the alter dialog help text.

* Drop table button in alter dialog

* Object not chain of ifs

Refs https://github.com/simonw/datasette/pull/2789/changes#r3453964430

* Fix broken Playwright tests

* Keyword arguments for readability

* Ran prettier

* Removed the alter table dry run feature

It works by doing conn.backup(memory_conn) which could use
a lot of memory for a large database.

* sqlite-utils>=3.30,<4.0

So we don't get test failures from reformatted SQL.

* not_null, default and default_exr support for create table API columns

* Alter table API can now rename tables, refs #2788

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4771774289

* Fix for Safari select box heights

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4772241681

* Expose foreign key data to alter table UI

Include current foreign key metadata in the alter table page data and allow the foreign-key-targets endpoint to be read by actors with alter-table permission for a specific table.

Add API and HTML data tests for the new alter-table foreign key support.

* Unify create and alter table modal controls

Share default value controls between the create and alter table dialogs and expose create-table default expressions to the frontend.

Add create-table not-null/default handling and align the shared foreign key picker behavior across both dialogs.

* Add rename table controls to alter table dialog

Add a collapsed rename-table section to the alter table modal and include rename_table operations in the review/apply flow.

Redirect to the renamed table URL after applying changes and cover the review text in Playwright.

* current_unixtime and current_unixtime_ms default_expr options

Plus tweaked how alter table changing those works a bit.

* Draft changelog for create/alter table UI, refs #2787, #2788
This commit is contained in:
Simon Willison 2026-06-22 13:54:36 -07:00 committed by GitHub
commit f0645c6ddf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 8046 additions and 407 deletions

View file

@ -47,9 +47,14 @@ from .views import Context
from .views.database import (
database_download,
DatabaseView,
TableCreateView,
QueryView,
)
from .views.table_create_alter import (
DatabaseForeignKeyTargetsView,
TableAlterView,
TableCreateView,
TableForeignKeySuggestionsView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
@ -2562,6 +2567,10 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
)
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
DatabaseForeignKeyTargetsView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/foreign-key-targets$",
)
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
@ -2626,6 +2635,14 @@ class Datasette:
TableUpsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
)
add_route(
TableAlterView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/alter$",
)
add_route(
TableForeignKeySuggestionsView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/foreign-key-suggestions$",
)
add_route(
TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",

View file

@ -0,0 +1,29 @@
from datasette import hookimpl
from datasette.resources import TableResource
@hookimpl
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}
]
return inner

View file

@ -31,6 +31,7 @@ DEFAULT_PLUGINS = (
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.handle_exception",
"datasette.forbidden",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,10 @@
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}

View file

@ -6,11 +6,8 @@ import itertools
import json
import markupsafe
import os
import re
import sqlite_utils
import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
@ -37,13 +34,14 @@ from datasette.utils import (
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv
from .base import DatasetteError, View, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
table_extra_registry,
)
from .table_create_alter import _create_table_ui_context
from . import Context
@ -117,21 +115,36 @@ class DatabaseView(View):
else len(stored_queries)
)
# Resolve the registered database-level actions for this database in
# one batched query, seeding the request permission cache so allowed()
# calls made inside plugin hooks below are served from the cache.
database_action_permissions = await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
create_table_ui = await _create_table_ui_context(
datasette, request, db, database, database_action_permissions
)
async def database_actions():
# Resolve the registered database-level actions for this
# database in one batched query, seeding the request permission
# cache so that allowed() calls made inside the plugin hooks
# below are served from the cache
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
links = []
if create_table_ui:
links.append(
{
"type": "button",
"label": "Create table",
"description": "Create a new table in this database.",
"attrs": {
"aria-label": "Create table in {}".format(database),
"data-database-action": "create-table",
},
}
)
for hook in pm.hook.database_actions(
datasette=datasette,
database=database,
@ -211,6 +224,9 @@ class DatabaseView(View):
),
metadata=metadata,
database_color=db.color,
database_page_data=(
{"createTable": create_table_ui} if create_table_ui else {}
),
database_actions=database_actions,
show_hidden=request.args.get("_show_hidden"),
editable=True,
@ -263,6 +279,9 @@ class DatabaseContext(Context):
)
metadata: dict = field(metadata={"help": "Metadata for the database"})
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the database page"}
)
database_actions: callable = field(
metadata={
"help": "Callable returning list of action links for the database menu"
@ -1055,260 +1074,6 @@ class MagicParameters(dict):
return super().__getitem__(key)
class TableCreateView(BaseView):
name = "table-create"
_valid_keys = {
"table",
"rows",
"row",
"columns",
"pk",
"pks",
"ignore",
"replace",
"alter",
}
_supported_column_types = {
"text",
"integer",
"float",
"blob",
}
# Any string that does not contain a newline or start with sqlite_
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
db = await self.ds.resolve_database(request)
database_name = db.name
# Must have create-table permission
if not await self.ds.allowed(
action="create-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return _error(["JSON must be an object"])
invalid_keys = set(data.keys()) - self._valid_keys
if invalid_keys:
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
# ignore and replace are mutually exclusive
if data.get("ignore") and data.get("replace"):
return _error(["ignore and replace are mutually exclusive"])
# ignore and replace only allowed with row or rows
if "ignore" in data or "replace" in data:
if not data.get("row") and not data.get("rows"):
return _error(["ignore and replace require row or rows"])
# ignore and replace require pk or pks
if "ignore" in data or "replace" in data:
if not data.get("pk") and not data.get("pks"):
return _error(["ignore and replace require pk or pks"])
ignore = data.get("ignore")
replace = data.get("replace")
if replace:
# Must have update-row permission
if not await self.ds.allowed(
action="update-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need update-row"], 403)
table_name = data.get("table")
if not table_name:
return _error(["Table is required"])
if not self._table_name_re.match(table_name):
return _error(["Invalid table name"])
table_exists = await db.table_exists(data["table"])
columns = data.get("columns")
rows = data.get("rows")
row = data.get("row")
if not columns and not rows and not row:
return _error(["columns, rows or row is required"])
if rows and row:
return _error(["Cannot specify both rows and row"])
if rows or row:
# Must have insert-row permission
if not await self.ds.allowed(
action="insert-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
alter = False
if rows or row:
if not table_exists:
# if table is being created for the first time, alter=True
alter = True
else:
# alter=True only if they request it AND they have permission
if data.get("alter"):
if not await self.ds.allowed(
action="alter-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
alter = True
if columns:
if rows or row:
return _error(["Cannot specify columns with rows or row"])
if not isinstance(columns, list):
return _error(["columns must be a list"])
for column in columns:
if not isinstance(column, dict):
return _error(["columns must be a list of objects"])
if not column.get("name") or not isinstance(column.get("name"), str):
return _error(["Column name is required"])
if not column.get("type"):
column["type"] = "text"
if column["type"] not in self._supported_column_types:
return _error(
["Unsupported column type: {}".format(column["type"])]
)
# No duplicate column names
dupes = {c["name"] for c in columns if columns.count(c) > 1}
if dupes:
return _error(["Duplicate column name: {}".format(", ".join(dupes))])
if row:
rows = [row]
if rows:
if not isinstance(rows, list):
return _error(["rows must be a list"])
for row in rows:
if not isinstance(row, dict):
return _error(["rows must be a list of objects"])
pk = data.get("pk")
pks = data.get("pks")
if pk and pks:
return _error(["Cannot specify both pk and pks"])
if pk:
if not isinstance(pk, str):
return _error(["pk must be a string"])
if pks:
if not isinstance(pks, list):
return _error(["pks must be a list"])
for pk in pks:
if not isinstance(pk, str):
return _error(["pks must be a list of strings"])
# If table exists already, read pks from that instead
if table_exists:
actual_pks = await db.primary_keys(table_name)
# if pk passed and table already exists check it does not change
bad_pks = False
if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]:
bad_pks = True
elif (
len(actual_pks) > 1
and data.get("pks")
and set(data["pks"]) != set(actual_pks)
):
bad_pks = True
if bad_pks:
return _error(["pk cannot be changed for existing table"])
pks = actual_pks
initial_schema = None
if table_exists:
initial_schema = await db.execute_fn(
lambda conn: sqlite_utils.Database(conn)[table_name].schema
)
def create_table(conn):
table = sqlite_utils.Database(conn)[table_name]
if rows:
table.insert_all(
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
)
else:
table.create(
{c["name"]: c["type"] for c in columns},
pk=pks or pk,
)
return table.schema
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e:
return _error([str(e)])
if initial_schema is not None and initial_schema != schema:
await self.ds.track_event(
AlterTableEvent(
request.actor,
database=database_name,
table=table_name,
before_schema=initial_schema,
after_schema=schema,
)
)
table_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name)
)
table_api_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name, format="json")
)
details = {
"ok": True,
"database": db.name,
"table": table_name,
"table_url": table_url,
"table_api_url": table_api_url,
"schema": schema,
}
if rows:
details["row_count"] = len(rows)
if not table_exists:
# Only log creation if we created a table
await self.ds.track_event(
CreateTableEvent(
request.actor, database=db.name, table=table_name, schema=schema
)
)
if rows:
await self.ds.track_event(
InsertRowsEvent(
request.actor,
database=db.name,
table=table_name,
num_rows=len(rows),
ignore=ignore,
replace=replace,
)
)
return Response.json(details, status=201)
async def display_rows(datasette, database, request, rows, columns):
display_rows = []
truncate_cells = datasette.setting("truncate_cells_html")

View file

@ -205,13 +205,14 @@ class RowView(DataView):
],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
self.ds,
request,
db,
database,
table,
not is_table,
None,
datasette=self.ds,
request=request,
db=db,
database_name=database,
table_name=table,
is_view=not is_table,
table_insert_ui=None,
table_alter_ui=None,
),
"row_actions": row_actions,
"top_row": make_slot_function(

View file

@ -48,9 +48,18 @@ from datasette.filters import Filters
import sqlite_utils
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_create_alter import (
ALTER_TABLE_COLUMN_TYPES,
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE,
_custom_column_type_options_for_create_table,
default_expr_for_sql,
default_expression_options,
)
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
precompute_database_action_permissions,
precompute_table_action_permissions,
resolve_table_extras,
table_extra_registry,
)
@ -280,7 +289,14 @@ async def _foreign_key_autocomplete_urls(
async def _table_page_data(
datasette, request, db, database_name, table_name, is_view, table_insert_ui
datasette,
request,
db,
database_name,
table_name,
is_view,
table_insert_ui,
table_alter_ui,
):
data = {
"database": database_name,
@ -289,6 +305,8 @@ async def _table_page_data(
}
if table_insert_ui:
data["insertRow"] = table_insert_ui
if table_alter_ui:
data["alterTable"] = table_alter_ui
if not is_view:
foreign_keys = await _foreign_key_autocomplete_urls(
datasette, request, db, database_name, table_name
@ -351,6 +369,92 @@ async def _table_insert_ui(
}
async def _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
):
if is_view or not db.is_mutable:
return None
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_types_map = await datasette.get_column_types(database_name, table_name)
foreign_keys_by_column = {}
for fk in await db.foreign_keys_for_table(table_name):
other_column = fk["other_column"]
if other_column is None and await db.table_exists(fk["other_table"]):
other_pks = await db.primary_keys(fk["other_table"])
if len(other_pks) == 1:
other_column = other_pks[0]
if other_column is None:
continue
foreign_keys_by_column[fk["column"]] = {
"fk_table": fk["other_table"],
"fk_column": other_column,
}
columns = []
for column in await db.table_column_details(table_name):
if column.hidden:
continue
sqlite_type = SQLiteType.from_declared_type(column.type)
column_type = column_types_map.get(column.name)
default_expr = default_expr_for_sql(column.default_value)
column_data = {
"name": column.name,
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
"sqlite_type": sqlite_type.value,
"notnull": column.notnull,
"default": None if default_expr else column.default_value,
"has_default": column.default_value is not None,
"is_pk": column.name in pks,
"foreign_key": foreign_keys_by_column.get(column.name),
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
if default_expr:
column_data["default_expr"] = default_expr
columns.append(column_data)
data = {
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
"foreignKeyTargetsPath": "{}/-/foreign-key-targets?table={}".format(
datasette.urls.database(database_name),
urllib.parse.quote(table_name, safe=""),
),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
if can_set_column_type:
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
datasette
)
can_drop_table = await datasette.allowed(
action="drop-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
if can_drop_table:
data["dropPath"] = "{}/-/drop".format(
datasette.urls.table(database_name, table_name)
)
return data
async def display_columns_and_rows(
datasette,
database_name,
@ -1119,6 +1223,11 @@ class TableDropView(BaseView):
actor=request.actor, database=database_name, table=table_name
)
)
self.ds.add_message(
request,
"Table {} dropped".format(table_name),
self.ds.WARNING,
)
return Response.json({"ok": True}, status=200)
@ -1642,6 +1751,15 @@ async def table_view_data(
if redirect_response:
return redirect_response
if context_for_html_hack:
await precompute_database_action_permissions(
datasette, request.actor, database_name
)
if not is_view:
await precompute_table_action_permissions(
datasette, request.actor, database_name, table_name
)
# Introspect columns and primary keys for table
pks = await db.primary_keys(table_name)
table_columns = await db.table_columns(table_name)
@ -2068,15 +2186,20 @@ async def table_view_data(
table_insert_ui = await _table_insert_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
table_alter_ui = await _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
data["table_insert_ui"] = table_insert_ui
data["table_alter_ui"] = table_alter_ui
data["table_page_data"] = await _table_page_data(
datasette,
request,
db,
database_name,
table_name,
is_view,
table_insert_ui,
datasette=datasette,
request=request,
db=db,
database_name=database_name,
table_name=table_name,
is_view=is_view,
table_insert_ui=table_insert_ui,
table_alter_ui=table_alter_ui,
)
return data, rows[:page_size], columns, expanded_columns, sql, next_url

File diff suppressed because it is too large Load diff

View file

@ -367,23 +367,14 @@ class ActionsExtra(Extra):
# that allowed() calls made inside the plugin hooks below
# are served from the cache
datasette = context.datasette
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is TableResource
],
resource=TableResource(context.database_name, context.table_name),
actor=context.request.actor,
await precompute_table_action_permissions(
datasette,
context.request.actor,
context.database_name,
context.table_name,
)
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(context.database_name),
actor=context.request.actor,
await precompute_database_action_permissions(
datasette, context.request.actor, context.database_name
)
for hook in method(**kwargs):
extra_links = await await_me_maybe(hook)
@ -394,6 +385,32 @@ class ActionsExtra(Extra):
return actions
async def precompute_table_action_permissions(
datasette, actor, database_name, table_name
):
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is TableResource
],
resource=TableResource(database_name, table_name),
actor=actor,
)
async def precompute_database_action_permissions(datasette, actor, database_name):
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database_name),
actor=actor,
)
class IsViewExtra(Extra):
description = "Whether this resource is a view instead of a table"
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")

View file

@ -4,6 +4,16 @@
Changelog
=========
.. _unreleased:
Unreleased
----------
- New "Create table" interface in the database actions menu, backed by the ``/<database>/-/create`` :ref:`JSON API <TableCreateView>`. It can define columns, primary keys, custom column types, ``NOT NULL`` constraints, literal defaults, expression defaults and single-column foreign keys. (:issue:`2787`)
- New "Alter table" table action and ``/<database>/<table>/-/alter`` :ref:`JSON API <TableAlterView>` for changing existing tables: add, rename, reorder and drop columns; change column types, defaults, ``NOT NULL`` constraints, primary keys and foreign keys; and rename the table. The alter table dialog also includes a "Drop table" button. (:issue:`2788`)
- New ``/<database>/-/foreign-key-targets`` and ``/<database>/<table>/-/foreign-key-suggestions`` JSON APIs for discovering valid single-column foreign key targets and suggested relationships.
- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering.
.. _v1_0_a34:
1.0a34 (2026-06-16)

View file

@ -44,7 +44,7 @@ looks like this:
``"ok"`` is always ``true`` if an error did not occur.
The ``"rows"`` key is a list of objects, each one representing a row.
The ``"rows"`` key is a list of objects, each one representing a row.
The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).
@ -1968,7 +1968,14 @@ To create a table, make a ``POST`` to ``/<database>/-/create``. This requires th
},
{
"name": "title",
"type": "text"
"type": "text",
"not_null": true,
"default": "Untitled"
},
{
"name": "created",
"type": "text",
"default_expr": "current_timestamp"
}
],
"pk": "id"
@ -1981,6 +1988,10 @@ The JSON here describes the table that will be created:
- ``name`` is the name of the column. This is required.
- ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``.
- ``not_null`` can be set to ``true`` to create this column with a ``NOT NULL`` constraint.
- ``default`` can be used to set a literal default value for this column.
- ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. See :ref:`default_expr values <json_api_default_expr_values>`.
- ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``.
* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column.
@ -1993,6 +2004,56 @@ The JSON here describes the table that will be created:
* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission.
* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
.. _json_api_default_expr_values:
``default_expr`` accepts these values:
.. list-table::
:header-rows: 1
* - Value
- Recommended column type
- Example inserted value
* - ``current_timestamp``
- ``text``
- ``2026-05-01 13:34:00``
* - ``current_date``
- ``text``
- ``2026-05-01``
* - ``current_time``
- ``text``
- ``13:34:00``
* - ``current_unixtime``
- ``integer``
- ``1777642440``
* - ``current_unixtime_ms``
- ``integer``
- ``1777642440000``
This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``:
.. code-block:: json
{
"table": "projects",
"columns": [
{
"name": "id",
"type": "integer"
},
{
"name": "owner_id",
"type": "integer",
"fk_table": "owners"
},
{
"name": "title",
"type": "text"
}
],
"pk": "id"
}
If the table is successfully created this will return a ``201`` status code and the following response:
.. code-block:: json
@ -2003,7 +2064,7 @@ If the table is successfully created this will return a ``201`` status code and
"table": "name_of_new_table",
"table_url": "http://127.0.0.1:8001/data/name_of_new_table",
"table_api_url": "http://127.0.0.1:8001/data/name_of_new_table.json",
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT\n)"
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT NOT NULL DEFAULT 'Untitled',\n [created] TEXT DEFAULT CURRENT_TIMESTAMP\n)"
}
.. _TableCreateView_example:
@ -2072,6 +2133,235 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat
Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission.
.. _DatabaseForeignKeyTargetsView:
Database foreign key targets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``/<database>/-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission.
::
GET /<database>/-/foreign-key-targets
The response includes only tables with exactly one primary key column. Hidden tables, tables with compound primary keys and tables with no explicit primary key are omitted.
Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``.
.. code-block:: json
{
"ok": true,
"database": "data",
"targets": [
{
"fk_table": "owners",
"fk_column": "id",
"type": "integer"
},
{
"fk_table": "categories",
"fk_column": "slug",
"type": "text"
}
]
}
.. _TableForeignKeySuggestionsView:
Table foreign key suggestions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``/<database>/<table>/-/foreign-key-suggestions`` endpoint suggests possible single-column foreign key relationships for a table. This requires the :ref:`actions_alter_table` permission.
::
GET /<database>/<table>/-/foreign-key-suggestions
The response includes every type-compatible single-column primary key target for each column in ``options``. Datasette also performs a bounded data check against up to 500 rows in the table: if the sampled non-null values for a column all exist in a target primary key, that target is included in ``suggestions``.
If the bounded check takes too long, the endpoint fails open. It still returns the type-compatible ``options`` for each column, but ``row_check.status`` will be ``"timed_out"`` and there may be no ``suggestions``.
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "projects",
"row_check": {
"attempted": true,
"status": "completed",
"row_limit": 500,
"sampled_rows": 3,
"checked_options": 4
},
"columns": [
{
"column": "owner_id",
"type": "INTEGER",
"affinity": "integer",
"current": null,
"suggestions": [
{
"fk_table": "owners",
"fk_column": "id",
"confidence": "sampled",
"sampled_values": 3,
"reasons": [
"type_match",
"sample_values_exist",
"name_match"
]
}
],
"options": [
{
"fk_table": "owners",
"fk_column": "id",
"type": "INTEGER"
}
]
}
]
}
.. _TableAlterView:
Altering tables
~~~~~~~~~~~~~~~
To alter an existing table, make a ``POST`` to ``/<database>/<table>/-/alter``. This requires the :ref:`actions_alter_table` permission.
::
POST /<database>/<table>/-/alter
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
The request body should include an ``operations`` array. Each operation has the same top-level shape: an ``op`` string and an ``args`` object.
.. code-block:: json
{
"operations": [
{
"op": "add_column",
"args": {
"name": "slug",
"type": "text",
"not_null": true,
"default": ""
}
},
{
"op": "add_column",
"args": {
"name": "created",
"type": "text",
"default_expr": "current_timestamp"
}
},
{
"op": "rename_column",
"args": {
"name": "title",
"to": "headline"
}
},
{
"op": "rename_table",
"args": {
"to": "published_posts"
}
},
{
"op": "alter_column",
"args": {
"name": "score",
"type": "float"
}
},
{
"op": "drop_column",
"args": {
"name": "draft_notes"
}
},
{
"op": "set_primary_key",
"args": {
"columns": ["id"]
}
},
{
"op": "add_foreign_key",
"args": {
"column": "owner_id",
"fk_table": "owners"
}
},
{
"op": "drop_foreign_key",
"args": {
"column": "old_owner_id"
}
},
{
"op": "set_foreign_keys",
"args": {
"foreign_keys": [
{
"column": "owner_id",
"fk_table": "owners",
"fk_column": "id"
}
]
}
},
{
"op": "reorder_columns",
"args": {
"columns": ["id", "headline", "slug", "created", "score"]
}
}
]
}
Supported operations:
* ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required.
* ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``.
* ``rename_table`` renames the table. ``args`` accepts ``to``, the new table name. If combined with other operations, Datasette applies the column, primary key, foreign key and column order changes before renaming the table.
* ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default.
* ``drop_column`` drops a column. ``args`` accepts ``name``.
* ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names.
* ``add_foreign_key`` adds a single-column foreign key constraint. ``args`` accepts ``column``, ``fk_table`` and optional ``fk_column``. If ``fk_column`` is omitted, Datasette will use the single primary key of ``fk_table``.
* ``drop_foreign_key`` removes the foreign key constraint for a column. ``args`` accepts ``column``.
* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints.
* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order.
``default`` is always treated as a literal value. ``default_expr`` accepts the values shown in :ref:`default_expr values <json_api_default_expr_values>` and is rendered as the corresponding SQLite default expression.
For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table.
A successful response returns the new schema and the previous schema. If the request used ``rename_table``, ``table``, ``table_url`` and ``table_api_url`` will use the new table name. Renaming a table through this endpoint triggers the :class:`~datasette.events.RenameTableEvent` event.
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "published_posts",
"table_url": "http://127.0.0.1:8001/data/published_posts",
"table_api_url": "http://127.0.0.1:8001/data/published_posts.json",
"altered": true,
"schema": "CREATE TABLE ...",
"before_schema": "CREATE TABLE ...",
"operations_applied": 11
}
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableSetColumnTypeView:
Setting a column type

View file

@ -280,6 +280,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"query_actions"
]
},
{
"name": "datasette.default_table_actions",
"static": false,
"templates": false,
"version": null,
"hooks": [
"table_actions"
]
},
{
"name": "datasette.events",
"static": false,

View file

@ -35,10 +35,11 @@ dependencies = [
"PyYAML>=5.3",
"mergedeep>=1.1.1",
"itsdangerous>=1.1",
"sqlite-utils>=3.30",
"sqlite-utils>=3.30,<4.0",
"asyncinject>=0.7",
"setuptools",
"pip",
"pydantic>=2",
]
[project.urls]

View file

@ -1,40 +1,37 @@
#!/bin/bash
set -e
# So the script fails if there are any errors
set -euo pipefail
read -r -a PYTHON_CMD <<< "${PYTHON:-python3}"
read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-shot-scraper}"
# Build the wheel
python3 -m build
"${PYTHON_CMD[@]}" -m build
# Find name of wheel, strip off the dist/
wheel=$(basename $(ls dist/*.whl) | head -n 1)
# Find name of most recently built wheel, strip off the dist/
wheel=$(basename "$(ls -t dist/*.whl | head -n 1)")
# Create a blank index page
echo '
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script src="https://cdn.jsdelivr.net/pyodide/v314.0.0/full/pyodide.js"></script>
' > dist/index.html
# Run a server for that dist/ folder
cd dist
python3 -m http.server 8529 &
cd ..
"${PYTHON_CMD[@]}" -m http.server 8529 --directory dist &
server_pid=$!
# Register the kill_server function to be called on script exit
kill_server() {
pkill -f 'http.server 8529'
kill "$server_pid" 2>/dev/null || true
}
trap kill_server EXIT
shot-scraper javascript http://localhost:8529/ "
"${SHOT_SCRAPER_CMD[@]}" javascript http://localhost:8529/ "
async () => {
let pyodide = await loadPyodide();
await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);
await pyodide.loadPackage(['micropip', 'setuptools']);
let output = await pyodide.runPythonAsync(\`
import micropip
await micropip.install('h11==0.12.0')
await micropip.install('httpx==0.23')
# To avoid 'from typing_extensions import deprecated' error:
await micropip.install('typing-extensions>=4.12.2')
await micropip.install('http://localhost:8529/$wheel')
import ssl
import setuptools

View file

@ -260,8 +260,12 @@ def ds_unix_domain_socket_server(tmp_path_factory):
# This used to use tmp_path_factory.mktemp("uds") but that turned out to
# produce paths that were too long to use as UDS on macOS, see
# https://github.com/simonw/datasette/issues/1407 - so I switched to
# using tempfile.gettempdir()
uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock")
# using tempfile.gettempdir() with a per-process filename.
uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock")
try:
os.unlink(uds)
except FileNotFoundError:
pass
ds_proc = subprocess.Popen(
[sys.executable, "-m", "datasette", "--memory", "--uds", uds],
stdout=subprocess.PIPE,
@ -271,12 +275,26 @@ def ds_unix_domain_socket_server(tmp_path_factory):
# Poll until available
transport = httpx.HTTPTransport(uds=uds)
client = httpx.Client(transport=transport)
wait_until_responds("http://localhost/_memory.json", client=client)
# Check it started successfully
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
yield ds_proc, uds
# Shut it down at the end of the pytest session
ds_proc.terminate()
try:
wait_until_responds(
"http://localhost/_memory.json", timeout=30.0, client=client
)
# Check it started successfully
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
yield ds_proc, uds
finally:
client.close()
# Shut it down at the end of the pytest session
ds_proc.terminate()
try:
ds_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
ds_proc.kill()
ds_proc.wait()
try:
os.unlink(uds)
except FileNotFoundError:
pass
# Import fixtures from fixtures.py to make them available

View file

@ -1,4 +1,5 @@
from datasette.app import Datasette
from datasette.events import RenameTableEvent
from datasette.utils import sqlite3
from .utils import last_event
import pytest
@ -794,6 +795,614 @@ async def test_update_row_alter(ds_write):
assert response.json() == {"ok": True}
@pytest.mark.asyncio
async def test_alter_table_operations(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
before_schema = await db.execute_fn(
lambda conn: conn.execute(
"select sql from sqlite_master where type = 'table' and name = 'docs'"
).fetchone()[0]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_column",
"args": {
"name": "slug",
"type": "text",
"not_null": True,
"default": "",
},
},
{
"op": "add_column",
"args": {
"name": "created",
"type": "text",
"default_expr": "current_timestamp",
},
},
{
"op": "add_column",
"args": {
"name": "literal_default",
"type": "text",
"default": "hello)",
},
},
{"op": "rename_column", "args": {"name": "title", "to": "headline"}},
{
"op": "alter_column",
"args": {"name": "age", "type": "text", "default": "0"},
},
{"op": "drop_column", "args": {"name": "score"}},
{
"op": "reorder_columns",
"args": {
"columns": [
"id",
"headline",
"slug",
"created",
"literal_default",
"age",
]
},
},
{"op": "set_primary_key", "args": {"columns": ["id"]}},
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "docs"
assert data["altered"] is True
assert data["operations_applied"] == 8
assert data["before_schema"] == before_schema
assert "headline" in data["schema"]
assert "score" not in data["schema"]
assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"]
assert "DEFAULT 'hello)'" in data["schema"]
columns = (
await db.execute("select * from pragma_table_info('docs') order by cid")
).dicts()
assert [column["name"] for column in columns] == [
"id",
"headline",
"slug",
"created",
"literal_default",
"age",
]
assert columns[0]["pk"] == 1
assert columns[2]["notnull"] == 1
assert columns[2]["dflt_value"] == "''"
assert columns[3]["dflt_value"] == "CURRENT_TIMESTAMP"
assert columns[4]["dflt_value"] == "'hello)'"
assert columns[5]["type"] == "TEXT"
assert columns[5]["dflt_value"] == "'0'"
event = last_event(ds_write)
assert event.name == "alter-table"
assert event.database == "data"
assert event.table == "docs"
assert event.before_schema == before_schema
assert event.after_schema == data["schema"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"default_expr,minimum_value,expected_schema",
(
(
"current_unixtime",
1_600_000_000,
"strftime('%s', 'now')",
),
(
"current_unixtime_ms",
1_600_000_000_000,
"julianday('now')",
),
),
)
async def test_alter_table_integer_default_expr(
ds_write, default_expr, minimum_value, expected_schema
):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_column",
"args": {
"name": "created",
"type": "integer",
"default_expr": default_expr,
},
}
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert expected_schema in data["schema"]
columns = await db.execute("select * from pragma_table_info('docs')")
created_column = [
column for column in columns.dicts() if column["name"] == "created"
][0]
assert created_column["type"] == "INTEGER"
assert expected_schema in created_column["dflt_value"]
row = await db.execute_write_fn(
lambda conn: conn.execute(
"insert into docs (title) values ('with default') "
"returning created, typeof(created)"
).fetchone()
)
assert row[0] > minimum_value
assert row[1] == "integer"
@pytest.mark.asyncio
async def test_alter_table_rename_table(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
before_schema = await db.execute_fn(
lambda conn: conn.execute(
"select sql from sqlite_master where type = 'table' and name = 'docs'"
).fetchone()[0]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{"op": "rename_table", "args": {"to": "documents"}},
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "documents"
assert data["table_url"].endswith("/data/documents")
assert data["table_api_url"].endswith("/data/documents.json")
assert data["altered"] is True
assert data["operations_applied"] == 1
assert data["before_schema"] == before_schema
assert 'CREATE TABLE "documents"' in data["schema"]
tables = (
await db.execute(
"select name from sqlite_master where type = 'table' order by name"
)
).dicts()
table_names = [table["name"] for table in tables]
assert "docs" not in table_names
assert "documents" in table_names
rename_events = [
event
for event in ds_write._tracked_events
if isinstance(event, RenameTableEvent)
]
assert len(rename_events) == 1
assert rename_events[0].database == "data"
assert rename_events[0].old_table == "docs"
assert rename_events[0].new_table == "documents"
@pytest.mark.asyncio
async def test_alter_table_foreign_key_operations(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("create table categories (id integer primary key)")
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{"op": "add_column", "args": {"name": "owner_id", "type": "integer"}},
{
"op": "add_foreign_key",
"args": {"column": "owner_id", "fk_table": "owners"},
},
]
},
headers=_headers(token),
)
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"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [{"op": "drop_foreign_key", "args": {"column": "owner_id"}}]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "set_foreign_keys",
"args": {
"foreign_keys": [
{
"column": "owner_id",
"fk_table": "categories",
"fk_column": "id",
}
]
},
}
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={"operations": [{"op": "set_foreign_keys", "args": {"foreign_keys": []}}]},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
@pytest.mark.asyncio
async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write):
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_foreign_key",
"args": {"column": "age", "fk_column": "id"},
}
]
},
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"],
}
@pytest.mark.asyncio
async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write(
"create table accounts (tenant_id integer, id integer, primary key (tenant_id, id))"
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_foreign_key",
"args": {"column": "age", "fk_table": "accounts"},
}
]
},
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
async def test_foreign_key_suggestions(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("insert into owners (id) values (1), (2), (3)")
await db.execute_write("create table categories (slug text primary key)")
await db.execute_write("insert into categories (slug) values ('one'), ('two')")
await db.execute_write("create table numbers (id integer primary key)")
await db.execute_write("insert into numbers (id) values (10), (20)")
await db.execute_write("create table weights (id real primary key)")
await db.execute_write("insert into weights (id) values (1.5), (2.5)")
await db.execute_write(
"insert into docs (id, title, score, age) values "
"(1, 'one', 1.5, 1), (2, 'two', 999.5, 2), (3, null, null, null)"
)
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "docs"
assert data["row_check"]["attempted"] is True
assert data["row_check"]["status"] == "completed"
assert data["row_check"]["row_limit"] == 500
assert data["row_check"]["sampled_rows"] == 3
columns = {column["column"]: column for column in data["columns"]}
assert columns["age"]["options"] == [
{"fk_table": "numbers", "fk_column": "id", "type": "INTEGER"},
{"fk_table": "owners", "fk_column": "id", "type": "INTEGER"},
]
assert columns["age"]["suggestions"] == [
{
"fk_table": "owners",
"fk_column": "id",
"confidence": "sampled",
"sampled_values": 2,
"reasons": ["type_match", "sample_values_exist"],
}
]
assert columns["title"]["options"] == [
{"fk_table": "categories", "fk_column": "slug", "type": "TEXT"}
]
assert columns["title"]["suggestions"][0]["fk_table"] == "categories"
assert columns["score"]["options"] == [
{"fk_table": "weights", "fk_column": "id", "type": "REAL"}
]
assert columns["score"]["suggestions"] == []
@pytest.mark.asyncio
async def test_foreign_key_suggestions_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
async def raise_timeout(*args, **kwargs):
raise table_create_alter.ForeignKeySuggestionTimedOut
from datasette.views import table_create_alter
monkeypatch.setattr(
table_create_alter,
"_foreign_key_suggestion_samples",
raise_timeout,
)
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["row_check"]["status"] == "timed_out"
columns = {column["column"]: column for column in data["columns"]}
assert columns["age"]["options"] == [
{"fk_table": "owners", "fk_column": "id", "type": "INTEGER"}
]
assert columns["age"]["suggestions"] == []
@pytest.mark.asyncio
async def test_foreign_key_targets(ds_write):
token = write_token(ds_write, permissions=["ct"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("create table categories (slug varchar(30) primary key)")
await db.execute_write("create table blob_things (hash blob primary key)")
await db.execute_write(
"create table numeric_codes (code decimal(10,5) primary key)"
)
await db.execute_write(
'create table floating_point (value "FLOATING POINT" primary key)'
)
await db.execute_write(
"create table compound (a integer, b integer, primary key (a, b))"
)
await db.execute_write("create table no_pk (name text)")
try:
await db.execute_write("create virtual table search_docs using fts5(body)")
except Exception:
pass
response = await ds_write.client.get(
"/data/-/foreign-key-targets",
headers=_headers(token),
)
assert response.status_code == 200, response.text
assert response.json() == {
"ok": True,
"database": "data",
"targets": [
{
"fk_table": "blob_things",
"fk_column": "hash",
"type": "blob",
},
{
"fk_table": "categories",
"fk_column": "slug",
"type": "text",
},
{
"fk_table": "docs",
"fk_column": "id",
"type": "integer",
},
{
"fk_table": "floating_point",
"fk_column": "value",
"type": "integer",
},
{
"fk_table": "numeric_codes",
"fk_column": "code",
"type": "numeric",
},
{
"fk_table": "owners",
"fk_column": "id",
"type": "integer",
},
],
}
assert not any(
target["fk_table"].startswith("search_docs_")
for target in response.json()["targets"]
)
@pytest.mark.asyncio
async def test_foreign_key_targets_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.get(
"/data/-/foreign-key-targets",
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need create-table"],
}
@pytest.mark.asyncio
async def test_foreign_key_targets_allowed_for_alter_table(ds_write):
token = write_token(ds_write, permissions=["at"])
response = await ds_write.client.get(
"/data/-/foreign-key-targets?table=docs",
headers=_headers(token),
)
assert response.status_code == 200, response.text
assert response.json()["ok"] is True
@pytest.mark.asyncio
async def test_alter_table_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.post(
"/data/docs/-/alter",
json={"operations": [{"op": "add_column", "args": {"name": "slug"}}]},
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body,expected_error",
(
(
{
"dry_run": True,
"operations": [
{"op": "add_column", "args": {"name": "slug", "type": "text"}}
],
},
"dry_run: Extra inputs are not permitted",
),
(
{"operations": [{"op": "add_column", "args": {"type": "text"}}]},
"operations.0.add_column.args.name: Field required",
),
(
{
"operations": [
{"op": "add_column", "args": {"name": "x", "type": "bad"}}
]
},
"operations.0.add_column.args.type: Input should be 'text', 'integer', 'float' or 'blob'",
),
(
{
"operations": [
{
"op": "add_column",
"args": {
"name": "x",
"default_expr": "datetime('now')",
},
}
]
},
"operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
),
(
{
"operations": [
{
"op": "add_column",
"args": {
"name": "x",
"default": "x",
"default_expr": "current_timestamp",
},
}
]
},
"operations.0.add_column.args: Value error, default and default_expr cannot both be provided",
),
),
)
async def test_alter_table_validation_errors(ds_write, body, expected_error):
response = await ds_write.client.post(
"/data/docs/-/alter",
json=body,
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json()["ok"] is False
assert response.json()["errors"] == [expected_error]
@pytest.mark.asyncio
async def test_execute_write_form_parameter_called_sql():
ds = Datasette(memory=True, default_deny=True)
@ -1409,6 +2018,249 @@ async def test_create_table(
assert [e.name for e in events] == expected_events
@pytest.mark.asyncio
async def test_create_table_with_foreign_key(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "owners",
"columns": [
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "owner_id",
"type": "integer",
"fk_table": "owners",
},
{"name": "title", "type": "text"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
data = response.json()
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
@pytest.mark.asyncio
async def test_create_table_with_column_constraints(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "constrained",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "title",
"type": "text",
"not_null": True,
"default": "Untitled",
},
{
"name": "created",
"type": "text",
"default_expr": "current_timestamp",
},
{"name": "score", "type": "integer", "default": 0},
{"name": "literal_default", "type": "text", "default": "hello)"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201, response.text
data = response.json()
assert data["ok"] is True
assert "NOT NULL DEFAULT 'Untitled'" in data["schema"]
assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"]
assert "DEFAULT 0" in data["schema"]
assert "DEFAULT 'hello)'" in data["schema"]
db = ds_write.get_database("data")
columns = (
await db.execute("select * from pragma_table_info('constrained') order by cid")
).dicts()
assert [column["name"] for column in columns] == [
"id",
"title",
"created",
"score",
"literal_default",
]
assert columns[0]["pk"] == 1
assert columns[1]["notnull"] == 1
assert columns[1]["dflt_value"] == "'Untitled'"
assert columns[2]["dflt_value"] == "CURRENT_TIMESTAMP"
assert columns[3]["dflt_value"] == "0"
assert columns[4]["dflt_value"] == "'hello)'"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"default_expr,minimum_value,expected_schema",
(
(
"current_unixtime",
1_600_000_000,
"strftime('%s', 'now')",
),
(
"current_unixtime_ms",
1_600_000_000_000,
"julianday('now')",
),
),
)
async def test_create_table_integer_default_expr(
ds_write, default_expr, minimum_value, expected_schema
):
token = write_token(ds_write)
table = "default_{}".format(default_expr)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": table,
"columns": [
{"name": "id", "type": "integer"},
{
"name": "created",
"type": "integer",
"default_expr": default_expr,
},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201, response.text
data = response.json()
assert expected_schema in data["schema"]
db = ds_write.get_database("data")
columns = (await db.execute("select * from pragma_table_info(?)", [table])).dicts()
assert columns[1]["type"] == "INTEGER"
assert expected_schema in columns[1]["dflt_value"]
row = await db.execute_write_fn(
lambda conn: conn.execute(
"insert into [{}] default values returning created, typeof(created)".format(
table
)
).fetchone()
)
assert row[0] > minimum_value
assert row[1] == "integer"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"column,expected_error",
(
(
{"name": "owner_id", "type": "integer", "fk_table": "owners"},
None,
),
(
{"name": "owner_id", "type": "integer", "fk_column": "id"},
"columns.0: fk_column requires fk_table",
),
(
{
"name": "created",
"type": "text",
"default_expr": "datetime('now')",
},
"columns.0.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
),
(
{
"name": "created",
"type": "text",
"default": "x",
"default_expr": "current_timestamp",
},
"columns.0: Value error, default and default_expr cannot both be provided",
),
),
)
async def test_create_table_column_validation(ds_write, column, expected_error):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [column],
},
headers=_headers(token),
)
if expected_error:
assert response.status_code == 400
assert response.json() == {"ok": False, "errors": [expected_error]}
else:
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'owners'"],
}
@pytest.mark.asyncio
async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "accounts",
"columns": [
{"name": "tenant_id", "type": "integer"},
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
],
"pks": ["tenant_id", "id"],
},
headers=_headers(token),
)
assert response.status_code == 201
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "account_id",
"type": "integer",
"fk_table": "accounts",
},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permissions,body,expected_status,expected_errors",

View file

@ -40,22 +40,23 @@ curl -f --cacert client.pem $test_url
curl_exit_code=$?
# Shut down the server
kill $server_pid
waiting=0
# show all pids
# | find just the $server_pid
# | | dont match on the previous grep
# | | | we dont need the output
# | | | |
until ( ! ps ax | grep $server_pid | grep -v grep > /dev/null ); do
if [ $waiting -eq 4 ]; then
echo "$server_pid does still exist, server failed to stop"
cleanup
exit 1
kill $server_pid 2>/dev/null || true
(
sleep 5
if kill -0 $server_pid 2>/dev/null; then
kill -9 $server_pid 2>/dev/null || true
fi
let waiting=waiting+1
sleep 1
done
) &
killer_pid=$!
wait_status=0
wait $server_pid 2>/dev/null || wait_status=$?
kill $killer_pid 2>/dev/null || true
wait $killer_pid 2>/dev/null || true
if [ $wait_status -eq 137 ]; then
echo "$server_pid did not stop after SIGTERM, server failed to stop"
cleanup
exit 1
fi
# Clean up the certificates
cleanup

View file

@ -98,6 +98,10 @@ def write_playwright_database(db_path):
notes text,
score integer default 5
);
create table defaults_demo (
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
@ -117,6 +121,10 @@ def write_playwright_config(config_path):
{
"databases": {
"data": {
"permissions": {
"create-table": True,
"set-column-type": True,
},
"tables": {
"projects": {
"label_column": "title",
@ -126,11 +134,17 @@ def write_playwright_config(config_path):
"notes": "textarea",
},
"permissions": {
"alter-table": True,
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
"defaults_demo": {
"permissions": {
"alter-table": True,
},
},
},
},
},
@ -275,6 +289,480 @@ def test_datasette_homepage_contains_datasette(page, datasette_server):
assert "Datasette" in page.locator("body").inner_text()
@pytest.mark.playwright
def test_create_table_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()
assert dialog.locator(".modal-title").inner_text() == "Create a table in data"
placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0)
assert placeholder_select.input_value() == ""
assert (
placeholder_select.locator("option:checked").inner_text() == "- custom type -"
)
assert "table-create-input-placeholder" in placeholder_select.get_attribute("class")
assert (
dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-create-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-create-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-create-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
dialog.locator('input[name="table"]').fill("playwright_created")
dialog.locator(".table-create-column-name").nth(1).fill("title")
dialog.locator(".table-create-more-options").nth(1).click()
dialog.locator(".table-create-not-null-input").nth(1).check()
title_defaults = dialog.locator(".table-create-default-options").nth(1)
assert title_defaults.locator("summary").inner_text() == "Set a default value"
title_defaults.locator("summary").click()
assert "or default to a specific value" in title_defaults.inner_text()
title_default_expr = title_defaults.locator(".table-create-default-expr")
title_default_input = title_defaults.locator(".table-create-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in title_default_expr.locator("option").nth(1).inner_text()
)
title_default_expr.select_option("current_timestamp")
assert title_default_input.is_enabled()
title_default_input.fill("Untitled")
assert title_default_expr.input_value() == ""
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(2).fill("score")
dialog.locator(".table-create-column-type").nth(2).select_option("integer")
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(3).fill("metadata")
dialog.locator(".table-create-column-type").nth(3).select_option("integer")
dialog.locator(".table-create-more-options").nth(3).click()
dialog.locator(".table-create-custom-column-type").nth(3).select_option("json")
assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text"
assert "table-create-input-placeholder" not in dialog.locator(
".table-create-custom-column-type"
).nth(3).get_attribute("class")
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_created")
assert "playwright_created" in page.locator("h1").inner_text()
response = httpx.get(
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
)
response.raise_for_status()
data = response.json()
assert data["columns"] == [
"id",
"title",
"score",
"metadata",
]
assert data["column_types"] == {
"metadata": {"type": "json", "config": None},
}
schema_response = httpx.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select sql from sqlite_master where type = 'table' "
"and name = 'playwright_created'"
)
},
)
schema_response.raise_for_status()
schema = schema_response.json()["rows"][0]["sql"]
assert "title" in schema
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(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-more-options").nth(1).click()
column_name = dialog.locator(".table-create-column-name").nth(1)
type_select = dialog.locator(".table-create-column-type").nth(1)
foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1)
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_create_table_unix_default_expression_updates_column_type(
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()
row = dialog.locator(".table-create-column-row").nth(1)
row.locator(".table-create-more-options").click()
row.locator(".table-create-default-options summary").click()
type_select = row.locator(".table-create-column-type")
default_expr = row.locator(".table-create-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer milliseconds since the epoch"
in default_expr.locator("option").last.inner_text()
)
default_expr.select_option("current_unixtime_ms")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
column_name = dialog.locator(".table-alter-column-name").last
type_select = dialog.locator(".table-alter-column-type").last
foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_alter_table_unix_default_expression_updates_column_type(
page, datasette_server
):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
row = dialog.locator(".table-alter-column-row").last
row.locator(".table-alter-default-options summary").click()
type_select = row.locator(".table-alter-column-type")
default_expr = row.locator(".table-alter-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer seconds since the epoch"
in default_expr.locator("option").all_inner_texts()
)
default_expr.select_option("current_unixtime")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_existing_default_expression_populates_select(
page, datasette_server
):
page.goto(f"{datasette_server}data/defaults_demo")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
row = dialog.locator(".table-alter-column-row").nth(1)
row.locator(".table-alter-more-options").click()
row.locator(".table-alter-default-options summary").click()
assert row.locator(".table-alter-default-expr").input_value() == (
"current_unixtime_ms"
)
assert row.locator(".table-alter-default").input_value() == ""
@pytest.mark.playwright
def test_alter_table_flow(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
assert dialog.locator(".table-alter-save").is_disabled()
assert (
dialog.locator(".table-alter-column-name").first.get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-alter-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-alter-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-alter-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
type_options = dialog.locator(".table-alter-column-type").first.locator("option")
assert type_options.all_inner_texts() == [
"text",
"integer",
"floating point number",
"blob - binary data",
]
first_more_options = dialog.locator(".table-alter-more-options").first
assert first_more_options.inner_text() == "> Advanced options"
first_more_options.click()
assert first_more_options.inner_text() == "v Hide options"
expanded_options_text = dialog.locator(
".table-alter-column-details"
).first.inner_text()
assert dialog.locator(".table-alter-fields").evaluate(
"node => node.scrollWidth <= node.clientWidth + 1"
)
assert "Not null" in expanded_options_text
assert "This value cannot be left unset" in expanded_options_text
assert "Set a default value" in expanded_options_text
assert "Primary key" in expanded_options_text
assert "This ID uniquely identifies the record" in expanded_options_text
assert "Foreign key" in expanded_options_text
first_defaults = dialog.locator(".table-alter-default-options").first
first_defaults.locator("summary").click()
assert "or default to a specific value" in first_defaults.inner_text()
first_default_expr = first_defaults.locator(".table-alter-default-expr")
first_default_input = first_defaults.locator(".table-alter-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in first_default_expr.locator("option").nth(1).inner_text()
)
first_default_expr.select_option("current_timestamp")
assert first_default_input.is_enabled()
first_default_input.fill("manual")
assert first_default_expr.input_value() == ""
dialog.locator(".table-alter-add-column").click()
assert dialog.locator(".table-alter-save").is_enabled()
dialog.locator(".table-alter-column-name").last.fill("status")
dialog.locator(".table-alter-column-type").last.select_option("text")
dialog.locator(".table-alter-default-options").last.locator("summary").click()
dialog.locator(".table-alter-default").last.fill("planned")
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Add column status as text, with default value planned." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-back").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Apply changes"
dialog.locator(".table-alter-save").click()
columns = []
for _ in range(20):
response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns")
response.raise_for_status()
columns = response.json()["columns"]
if "status" in columns:
break
time.sleep(0.1)
assert "status" in columns
@pytest.mark.playwright
def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
rows = dialog.locator(".table-alter-column-row")
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button")
for i in range(first_row_move_buttons.count()):
assert first_row_move_buttons.nth(i).is_disabled()
assert (
first_row_move_buttons.nth(i).get_attribute("title")
== "Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").is_disabled()
assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == (
"Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == (
"Primary key columns are always listed first"
)
last_row = rows.nth(rows.count() - 1)
assert last_row.locator(".table-alter-column-name").input_value() == "score"
last_row.locator(".table-alter-move-top").click()
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score"
@pytest.mark.playwright
def test_alter_table_review_rename_primary_key_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
assert save.is_disabled()
dialog.locator(".table-alter-column-name").first.fill("id3")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
review_text = review.inner_text()
assert "Rename column id to id3." in review_text
assert "Set primary key to" not in review_text
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"id",
"id3",
]
@pytest.mark.playwright
def test_alter_table_review_rename_table(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
rename_details = dialog.locator(".table-alter-table-options")
assert rename_details.locator("summary").inner_text() == "Rename table"
assert not dialog.locator(".table-alter-table-name").is_visible()
assert save.is_disabled()
rename_details.locator("summary").click()
table_name = dialog.locator(".table-alter-table-name")
assert table_name.input_value() == "projects"
assert table_name.get_attribute("placeholder") == "table name"
table_name.fill("projects_archive")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Rename table to projects_archive." in review.inner_text()
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"projects_archive",
]
@pytest.mark.playwright
def test_alter_table_review_not_null_wording(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-more-options").first.click()
dialog.locator(".table-alter-not-null-input").first.check()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Change column id: not null (require values)." in review.inner_text()
@pytest.mark.playwright
def test_alter_table_review_warns_when_dropping_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
remove_buttons = dialog.locator(".table-alter-remove-column")
remove_buttons.nth(remove_buttons.count() - 1).click()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Warning: data in dropped columns will be permanently lost." in review_text
assert "Drop column score." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-review-damaging").inner_text() == (
"Drop column score."
)
dialog.locator(".table-alter-back").click()
assert dialog.locator(".table-alter-column-list").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Review changes"
@pytest.mark.playwright
def test_alter_table_cancel_skips_discard_prompt(page, datasette_server):
def open_alter_dialog():
page.locator("details.actions-menu-links").evaluate("node => node.open = true")
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
return dialog
page.goto(f"{datasette_server}data/projects")
page.evaluate("""
() => {
window.__discardConfirmMessages = [];
window.confirm = (message) => {
window.__discardConfirmMessages.push(message);
return false;
};
}
""")
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("cancel_me")
dialog.locator(".table-alter-cancel").click()
assert dialog.evaluate("node => node.open") is False
assert page.evaluate("() => window.__discardConfirmMessages") == []
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("escape_me")
page.keyboard.press("Escape")
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
page.evaluate("() => window.__discardConfirmMessages = []")
dialog.evaluate(
"""node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))"""
)
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
@pytest.mark.playwright
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
page.goto(datasette_server)

View file

@ -23,6 +23,52 @@ def table_data_from_soup(soup):
return json.loads(match.group(1))
def database_data_from_soup(soup):
import json
import re
database_script = [
s
for s in soup.find_all("script")
if "_datasetteDatabaseData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteDatabaseData\s*=\s*({.*?});",
database_script.string,
re.DOTALL,
)
return json.loads(match.group(1))
DEFAULT_EXPRESSION_OPTIONS = [
{
"value": "current_timestamp",
"label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00",
"sqliteType": "text",
},
{
"value": "current_date",
"label": "Current date in UTC, e.g. 2026-05-01",
"sqliteType": "text",
},
{
"value": "current_time",
"label": "Current time in UTC, e.g. 13:34:00",
"sqliteType": "text",
},
{
"value": "current_unixtime",
"label": "Current Unix time, integer seconds since the epoch",
"sqliteType": "integer",
},
{
"value": "current_unixtime_ms",
"label": "Current Unix time, integer milliseconds since the epoch",
"sqliteType": "integer",
},
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_definition_sql",
@ -934,6 +980,292 @@ async def test_row_delete_action_data_attributes():
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_action"), 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
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-database-action="create-table"]'
)
assert button is not None
assert button["aria-label"] == "Create table in data"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == "Create a new table in this database."
description.extract()
assert button.text.strip() == "Create table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
assert database_data_from_soup(soup) == {
"createTable": {
"path": "/data/-/create",
"foreignKeyTargetsPath": "/data/-/foreign-key-targets",
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
response_without_permission = await ds.client.get(
"/data", actor={"id": "someone-else"}
)
assert response_without_permission.status_code == 200
soup_without_permission = Soup(response_without_permission.text, "html.parser")
assert (
soup_without_permission.select_one(
'button[data-database-action="create-table"]'
)
is None
)
assert not any(
"_datasetteDatabaseData" in (script.string or "")
for script in soup_without_permission.find_all("script")
)
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
"set-column-type": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_custom_types"),
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["customColumnTypes"] == [
{
"name": "email",
"description": "Email address",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "json",
"description": "JSON data",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "textarea",
"description": "Multiline text",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "url",
"description": "URL",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_alter_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"alter-table": {"id": ["root", "alter-only"]},
"set-column-type": {"id": "root"},
"drop-table": {"id": "root"},
},
"column_types": {"name": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_alter_action"), name="data"
)
await db.execute_write_script("""
create table items (
id integer primary key,
name text not null,
score integer default 5,
created text default current_timestamp,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
""")
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-table-action="alter-table"]'
)
assert button is not None
assert button["aria-label"] == "Alter table items"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == (
"Change columns and primary key for this table."
)
description.extract()
assert button.text.strip() == "Alter table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
alter_data = table_data_from_soup(soup)["alterTable"]
assert alter_data["path"] == "/data/items/-/alter"
assert alter_data["tableName"] == "items"
assert alter_data["primaryKeys"] == ["id"]
assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"]
assert alter_data["foreignKeyTargetsPath"] == (
"/data/-/foreign-key-targets?table=items"
)
assert alter_data["defaultExpressions"] == DEFAULT_EXPRESSION_OPTIONS
assert [option["name"] for option in alter_data["customColumnTypes"]] == [
"email",
"json",
"textarea",
"url",
]
assert alter_data["dropPath"] == "/data/items/-/drop"
assert alter_data["columns"] == [
{
"name": "id",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": True,
"foreign_key": None,
"column_type": None,
},
{
"name": "name",
"type": "text",
"sqlite_type": "TEXT",
"notnull": 1,
"default": None,
"has_default": False,
"is_pk": False,
"foreign_key": None,
"column_type": {"type": "textarea", "config": None},
},
{
"name": "score",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": "5",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
{
"name": "created",
"type": "text",
"sqlite_type": "TEXT",
"notnull": 0,
"default": None,
"default_expr": "current_timestamp",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
{
"name": "created_ms",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"default_expr": "current_unixtime_ms",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
]
response_without_permission = await ds.client.get(
"/data/items", actor={"id": "someone-else"}
)
assert response_without_permission.status_code == 200
soup_without_permission = Soup(response_without_permission.text, "html.parser")
assert (
soup_without_permission.select_one(
'button[data-table-action="alter-table"]'
)
is None
)
assert "alterTable" not in table_data_from_soup(soup_without_permission)
# An actor that can alter but not drop should not get a dropPath
response_alter_only = await ds.client.get(
"/data/items", actor={"id": "alter-only"}
)
assert response_alter_only.status_code == 200
alter_only_data = table_data_from_soup(
Soup(response_alter_only.text, "html.parser")
)["alterTable"]
assert "dropPath" not in alter_only_data
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_action_button_and_data():
ds = Datasette(