mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 09:14:34 +02:00
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:
commit
f0645c6ddf
21 changed files with 8046 additions and 407 deletions
|
|
@ -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$",
|
||||
|
|
|
|||
29
datasette/default_table_actions.py
Normal file
29
datasette/default_table_actions.py
Normal 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
|
||||
|
|
@ -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
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1353
datasette/views/table_create_alter.py
Normal file
1353
datasette/views/table_create_alter.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# | | don’t match on the previous grep
|
||||
# | | | we don’t 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue