mirror of
https://github.com/simonw/datasette.git
synced 2026-07-08 08:34:42 +02:00
Merge remote-tracking branch 'origin/main' into edit-blobs
# Conflicts: # docs/changelog.rst # tests/test_playwright.py
This commit is contained in:
commit
7ab8b644a7
15 changed files with 3225 additions and 166 deletions
3
Justfile
3
Justfile
|
|
@ -33,10 +33,11 @@ export DATASETTE_SECRET := "not_a_secret"
|
|||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, cog
|
||||
# Run linters: black, ruff, prettier, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
npm run prettier -- --check
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ def register_actions():
|
|||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="create-view",
|
||||
abbr="cv",
|
||||
description="Create views",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
|
|
@ -111,6 +117,12 @@ def register_actions():
|
|||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-view",
|
||||
abbr="dv",
|
||||
description="Drop views",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
|
|
|
|||
|
|
@ -1641,6 +1641,11 @@ dialog.row-edit-dialog::backdrop {
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-fields[hidden],
|
||||
.row-edit-bulk[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
|
|
@ -1910,6 +1915,207 @@ textarea.row-edit-input {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions .btn {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
color: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-help {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea.row-edit-bulk-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-bulk-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-note label,
|
||||
.row-edit-bulk-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row-edit-copy-template-label-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th,
|
||||
.row-edit-bulk-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th:last-child,
|
||||
.row-edit-bulk-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-null,
|
||||
.row-edit-bulk-preview-auto {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-status {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
datasette-autocomplete {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
|
@ -1988,6 +2194,16 @@ datasette-autocomplete input[type="text"],
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.row-edit-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -2184,6 +2400,120 @@ select.table-create-input {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-columns[hidden],
|
||||
.table-create-data[hidden],
|
||||
.table-create-data-editor[hidden],
|
||||
.table-create-data-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-data,
|
||||
.table-create-data-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-create-data-label {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.table-create-data-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.table-create-data-textarea.table-create-data-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.table-create-data-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-note label,
|
||||
.table-create-data-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-create-data-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-data-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th,
|
||||
.table-create-data-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th:last-child,
|
||||
.table-create-data-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-null {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-create-column-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -2411,6 +2741,16 @@ select.table-create-input {
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.table-create-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.table-create-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -3124,7 +3464,8 @@ select.table-alter-input {
|
|||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-fields {
|
||||
.row-edit-fields,
|
||||
.row-edit-bulk {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
|
@ -3143,6 +3484,15 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
|
|
@ -3174,6 +3524,11 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.table-create-column-headings {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -150,7 +150,6 @@ _SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
|||
"sqlite_rename_test",
|
||||
"substr",
|
||||
}
|
||||
|
||||
_AUTHORIZER_ACTION_NAMES = {
|
||||
getattr(sqlite3, name): name
|
||||
for name in (
|
||||
|
|
@ -391,6 +390,10 @@ def analyze_sql_tables(
|
|||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_RECURSIVE:
|
||||
# Recursive CTE bookkeeping; table reads are reported separately.
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
|
||||
record(
|
||||
"function",
|
||||
|
|
@ -485,17 +488,17 @@ def analyze_sql_tables(
|
|||
and key.operation in {"create", "alter", "drop"}
|
||||
for key in operations
|
||||
)
|
||||
dropped_tables = {
|
||||
dropped_tables_and_views = {
|
||||
(key.database, key.table)
|
||||
for key in operations
|
||||
if key.operation == "drop" and key.target_type == "table"
|
||||
if key.operation == "drop" and key.target_type in {"table", "view"}
|
||||
}
|
||||
|
||||
def key_is_drop_table_delete(key: OperationKey) -> bool:
|
||||
return (
|
||||
key.operation == "delete"
|
||||
and key.target_type == "table"
|
||||
and (key.database, key.table) in dropped_tables
|
||||
and (key.database, key.table) in dropped_tables_and_views
|
||||
)
|
||||
|
||||
has_user_table_access_in_schema_operation = any(
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ class DatabaseContext(Context):
|
|||
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. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
|
||||
}
|
||||
)
|
||||
database_actions: callable = field(
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ class TableContext(Context):
|
|||
)
|
||||
table_insert_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
}
|
||||
)
|
||||
table_alter_ui: dict = field(
|
||||
|
|
@ -483,8 +483,15 @@ async def _table_insert_ui(
|
|||
):
|
||||
return None
|
||||
|
||||
can_update = await datasette.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
bulk_columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
|
|
@ -495,32 +502,40 @@ async def _table_insert_ui(
|
|||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
column_type = column_types_map.get(column.name)
|
||||
column_data = {
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"is_auto_pk": is_auto_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
bulk_columns.append(column_data)
|
||||
if is_auto_pk:
|
||||
continue
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
columns.append(column_data)
|
||||
|
||||
return {
|
||||
data = {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"bulkColumns": bulk_columns,
|
||||
"primaryKeys": pks,
|
||||
"maxInsertRows": datasette.setting("max_insert_rows"),
|
||||
}
|
||||
if can_update:
|
||||
data["upsertPath"] = "{}/-/upsert".format(
|
||||
datasette.urls.table(database_name, table_name)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _table_alter_ui(
|
||||
|
|
|
|||
|
|
@ -269,6 +269,11 @@ async def _create_table_ui_context(
|
|||
"databaseName": database_name,
|
||||
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": default_expression_options(),
|
||||
"canInsertRows": await datasette.allowed(
|
||||
action="insert-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,33 @@ def decision_for_write_sql_operation(
|
|||
),
|
||||
)
|
||||
)
|
||||
if operation.operation == "create" and operation.target_type == "view":
|
||||
if operation.database is None:
|
||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="create-view",
|
||||
resource=DatabaseResource(database=operation.database),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "drop"
|
||||
and operation.target_type == "view"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="drop-view",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "alter"
|
||||
and operation.target_type == "table"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ The one exception is the "root" account, which you can sign into while using Dat
|
|||
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
||||
|
||||
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
|
||||
* Debug permissions (``permissions-debug``, ``debug-menu``)
|
||||
* Any custom permissions defined by plugins
|
||||
|
||||
|
|
@ -1386,6 +1386,16 @@ create-table
|
|||
|
||||
Actor is allowed to create a database table.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_create_view:
|
||||
|
||||
create-view
|
||||
-----------
|
||||
|
||||
Actor is allowed to create a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
|
|
@ -1425,6 +1435,18 @@ Actor is allowed to drop a database table.
|
|||
|
||||
``table`` is the name of the table (string)
|
||||
|
||||
.. _actions_drop_view:
|
||||
|
||||
drop-view
|
||||
---------
|
||||
|
||||
Actor is allowed to drop a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.TableResource(database, table)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``table`` is the name of the view (string)
|
||||
|
||||
.. _actions_execute_sql:
|
||||
|
||||
execute-sql
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
.. _v1_0_a36:
|
||||
.. _unreleased:
|
||||
|
||||
1.0a36 (in development)
|
||||
-----------------------
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
|
||||
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
|
||||
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
|
||||
- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format <binary_json_format>`, even when the bytes could be decoded as UTF-8 text.
|
||||
- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control.
|
||||
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
|
|||
The color assigned to the database
|
||||
|
||||
``database_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the database is editable
|
||||
|
|
@ -389,7 +389,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
|||
SQL definition for this table
|
||||
|
||||
``table_insert_ui`` - ``dict``
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def find_free_port():
|
|||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_server(process, url, timeout=10):
|
||||
def wait_for_server(process, url, timeout=30):
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = None
|
||||
while time.monotonic() < deadline:
|
||||
|
|
@ -41,7 +41,20 @@ def wait_for_server(process, url, timeout=10):
|
|||
except httpx.HTTPError as ex:
|
||||
last_error = repr(ex)
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
stdout, stderr = process.communicate()
|
||||
else:
|
||||
stdout, stderr = process.communicate()
|
||||
raise AssertionError(
|
||||
f"Timed out waiting for {url}: {last_error}\n"
|
||||
f"stdout:\n{stdout}\n"
|
||||
f"stderr:\n{stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -112,6 +125,17 @@ def write_playwright_database(db_path):
|
|||
name text not null,
|
||||
data blob
|
||||
);
|
||||
create table bulk_defaults (
|
||||
id integer primary key,
|
||||
title text not null,
|
||||
status text not null default 'todo',
|
||||
score integer default 5
|
||||
);
|
||||
create table upsert_items (
|
||||
id text primary key,
|
||||
title text,
|
||||
metadata text
|
||||
);
|
||||
insert into projects (title, metadata, logo, notes, score) values
|
||||
(
|
||||
'Build Datasette',
|
||||
|
|
@ -120,6 +144,8 @@ def write_playwright_database(db_path):
|
|||
'Initial notes',
|
||||
5
|
||||
);
|
||||
insert into upsert_items (id, title, metadata) values
|
||||
('existing', 'Existing title', '{"old": true}');
|
||||
""")
|
||||
conn.execute(
|
||||
"insert into binary_files (name, data) values (?, ?)",
|
||||
|
|
@ -146,6 +172,7 @@ def write_playwright_config(config_path):
|
|||
"data": {
|
||||
"permissions": {
|
||||
"create-table": True,
|
||||
"insert-row": True,
|
||||
"set-column-type": True,
|
||||
},
|
||||
"tables": {
|
||||
|
|
@ -176,6 +203,17 @@ def write_playwright_config(config_path):
|
|||
"delete-row": True,
|
||||
},
|
||||
},
|
||||
"bulk_defaults": {
|
||||
"permissions": {
|
||||
"insert-row": True,
|
||||
},
|
||||
},
|
||||
"upsert_items": {
|
||||
"permissions": {
|
||||
"insert-row": True,
|
||||
"update-row": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -318,6 +356,41 @@ def binary_file_blob(datasette_server, pk):
|
|||
return response.content
|
||||
|
||||
|
||||
def bulk_default_rows(datasette_server, **filters):
|
||||
params = {
|
||||
"_shape": "objects",
|
||||
**{key: str(value) for key, value in filters.items()},
|
||||
}
|
||||
response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()["rows"]
|
||||
|
||||
|
||||
def upsert_item_rows(datasette_server, **filters):
|
||||
params = {
|
||||
"_shape": "objects",
|
||||
**{key: str(value) for key, value in filters.items()},
|
||||
}
|
||||
response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()["rows"]
|
||||
|
||||
|
||||
def upsert_item_row(datasette_server, pk):
|
||||
rows = upsert_item_rows(datasette_server, id=pk)
|
||||
assert len(rows) == 1
|
||||
return rows[0]
|
||||
|
||||
|
||||
def open_bulk_insert_dialog(page, url):
|
||||
page.goto(url)
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
return dialog
|
||||
|
||||
|
||||
def open_jump_menu(page):
|
||||
page.keyboard.press("/")
|
||||
page.locator("navigation-search .search-input").wait_for()
|
||||
|
|
@ -421,6 +494,165 @@ def test_create_table_flow(page, datasette_server):
|
|||
assert "NOT NULL DEFAULT 'Untitled'" in schema
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_from_data_flow(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-database-action="create-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-create-dialog")
|
||||
dialog.wait_for()
|
||||
from_data = dialog.locator(".table-create-from-data")
|
||||
assert from_data.inner_text() == "Create table from data"
|
||||
from_data.click()
|
||||
|
||||
assert dialog.locator(".table-create-columns").is_hidden()
|
||||
assert dialog.locator(".table-create-data").is_visible()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
|
||||
assert (
|
||||
dialog.locator(".table-create-data-note").inner_text()
|
||||
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
|
||||
)
|
||||
assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
|
||||
assert (
|
||||
dialog.locator(".table-create-data-editor").evaluate(
|
||||
"""node => Array.from(node.children)
|
||||
.filter((child) => !child.hidden)
|
||||
.map((child) => child.className)
|
||||
.join(" ")"""
|
||||
)
|
||||
== ("table-create-data-note table-create-input table-create-data-textarea")
|
||||
)
|
||||
assert dialog.locator(".table-create-manual").inner_text() == (
|
||||
"Create table manually"
|
||||
)
|
||||
assert (
|
||||
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
|
||||
== "table-create-data-textarea"
|
||||
)
|
||||
|
||||
textarea = dialog.locator(".table-create-data-textarea")
|
||||
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
|
||||
node.addEventListener("input", () => resolve(node.value), { once: true });
|
||||
const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
})""")
|
||||
assert dropped_value == "short_id,name\nx,Ada"
|
||||
assert dialog.locator(".table-create-table-name").input_value() == (
|
||||
"repo_export_2026"
|
||||
)
|
||||
|
||||
dialog.locator(".table-create-table-name").fill("playwright_from_data")
|
||||
textarea.fill(
|
||||
json.dumps(
|
||||
{
|
||||
"metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
|
||||
"people": [
|
||||
{"short_id": "b", "name": "Ada", "score": 1},
|
||||
{"short_id": "c", "name": "Bob", "score": 2.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
dialog.locator(".table-create-save").click()
|
||||
|
||||
assert dialog.locator(".table-create-data-textarea").is_hidden()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
assert dialog.locator(".table-create-cancel").inner_text() == "Back"
|
||||
assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
|
||||
"Previewing 2 rows."
|
||||
)
|
||||
assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
|
||||
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
|
||||
assert "short_id" in preview_text
|
||||
assert "Ada" in preview_text
|
||||
assert "2.5" in preview_text
|
||||
preview_cell_style = dialog.locator(
|
||||
".table-create-data-preview-table td"
|
||||
).first.evaluate(
|
||||
"""node => ({
|
||||
overflowWrap: getComputedStyle(node).overflowWrap,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace
|
||||
})"""
|
||||
)
|
||||
assert preview_cell_style == {
|
||||
"overflowWrap": "anywhere",
|
||||
"whiteSpace": "normal",
|
||||
}
|
||||
|
||||
dialog.locator(".table-create-cancel").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".table-create-data-textarea").is_visible()
|
||||
assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
|
||||
|
||||
dialog.locator(".table-create-save").click()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
dialog.locator(".table-create-save").click()
|
||||
page.wait_for_url("**/data/playwright_from_data")
|
||||
|
||||
response = httpx.get(
|
||||
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
assert data["rows"] == [
|
||||
{"short_id": "b", "name": "Ada", "score": 1},
|
||||
{"short_id": "c", "name": "Bob", "score": 2.5},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
|
||||
page, datasette_server
|
||||
):
|
||||
page.goto(f"{datasette_server}data")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-database-action="create-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-create-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".table-create-from-data").click()
|
||||
dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
|
||||
dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
|
||||
dialog.locator(".table-create-save").click()
|
||||
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
|
||||
assert "A" in preview_text
|
||||
assert "1" in preview_text
|
||||
assert "B" in preview_text
|
||||
assert "null" in preview_text
|
||||
|
||||
dialog.locator(".table-create-save").click()
|
||||
page.wait_for_url("**/data/playwright_numeric_blanks")
|
||||
|
||||
response = httpx.get(
|
||||
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
|
||||
)
|
||||
response.raise_for_status()
|
||||
assert response.json()["rows"] == [
|
||||
{"name": "A", "score": 1},
|
||||
{"name": "B", "score": None},
|
||||
]
|
||||
|
||||
schema_response = httpx.get(
|
||||
f"{datasette_server}data/-/query.json",
|
||||
params={
|
||||
"sql": (
|
||||
"select type from pragma_table_info('playwright_numeric_blanks') "
|
||||
"where name = 'score'"
|
||||
)
|
||||
},
|
||||
)
|
||||
schema_response.raise_for_status()
|
||||
assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data")
|
||||
|
|
@ -841,11 +1073,128 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins(
|
|||
|
||||
@pytest.mark.playwright
|
||||
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
|
||||
page.add_init_script("""
|
||||
(() => {
|
||||
let clipboardText = "";
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
writeText: async (text) => {
|
||||
clipboardText = String(text);
|
||||
},
|
||||
readText: async () => clipboardText,
|
||||
}),
|
||||
});
|
||||
})();
|
||||
""")
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
bulk_insert = dialog.locator(".row-edit-bulk-insert")
|
||||
bulk_insert.wait_for()
|
||||
assert bulk_insert.inner_text() == "Insert multiple rows"
|
||||
current_url = page.url
|
||||
bulk_insert.click()
|
||||
assert page.url == current_url
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-fields").is_hidden()
|
||||
assert dialog.locator(".row-edit-bulk").is_visible()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-note").inner_text()
|
||||
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-editor").evaluate(
|
||||
"""node => Array.from(node.children)
|
||||
.filter((child) => !child.hidden)
|
||||
.map((child) => child.className)
|
||||
.join(" ")"""
|
||||
)
|
||||
== (
|
||||
"row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
|
||||
"row-edit-bulk-actions"
|
||||
)
|
||||
)
|
||||
copy_template = dialog.locator(".row-edit-copy-template")
|
||||
assert copy_template.inner_text() == "Copy spreadsheet template"
|
||||
assert (
|
||||
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
|
||||
== "row-edit-bulk-textarea"
|
||||
)
|
||||
assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
|
||||
"Copy template"
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
|
||||
"You can paste the template into Google Sheets or Excel."
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
|
||||
"Paste into Google Sheets or Excel"
|
||||
)
|
||||
copy_template.click()
|
||||
page.wait_for_function(
|
||||
"""() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
|
||||
)
|
||||
assert page.evaluate("navigator.clipboard.readText()") == (
|
||||
"title\tmetadata\tlogo\tnotes\tscore"
|
||||
)
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
textarea.fill("title\tmetadata\nFrom TSV\t{}")
|
||||
assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
|
||||
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
|
||||
node.addEventListener("input", () => resolve(node.value), { once: true });
|
||||
const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
})""")
|
||||
assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
|
||||
"Previewing 1 row."
|
||||
)
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "id" in preview_text
|
||||
assert "title" in preview_text
|
||||
assert "metadata" in preview_text
|
||||
assert "From CSV" in preview_text
|
||||
assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
|
||||
assert "null" not in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
preview_cell_style = dialog.locator(
|
||||
".row-edit-bulk-preview-table td"
|
||||
).first.evaluate(
|
||||
"""node => ({
|
||||
overflowWrap: getComputedStyle(node).overflowWrap,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace
|
||||
})"""
|
||||
)
|
||||
assert preview_cell_style == {
|
||||
"overflowWrap": "anywhere",
|
||||
"whiteSpace": "normal",
|
||||
}
|
||||
assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
|
||||
dialog.locator(".row-edit-cancel").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-bulk-textarea").is_visible()
|
||||
assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
|
||||
"\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
|
||||
)
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
|
||||
single_insert = dialog.locator(".row-edit-single-insert")
|
||||
assert single_insert.inner_text() == "Insert single row"
|
||||
single_insert.click()
|
||||
assert dialog.locator(".row-edit-bulk").is_hidden()
|
||||
assert dialog.locator(".row-edit-fields").is_visible()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
|
||||
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
|
||||
dialog.locator('textarea[name="metadata"]').fill(
|
||||
'{"ok": false, "source": "playwright"}'
|
||||
|
|
@ -875,6 +1224,206 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
|
|||
assert data["score"] == 5
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_preview_inserts_rows(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill(
|
||||
json.dumps(
|
||||
{
|
||||
"metadata": [{"title": "Ignored", "metadata": "{}"}],
|
||||
"projects": [
|
||||
{"title": "Bulk one", "metadata": "{}"},
|
||||
{"title": "Bulk two", "metadata": "{}"},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
dialog.locator(".row-edit-save").click()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="2 rows inserted."
|
||||
).wait_for()
|
||||
assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
|
||||
dialog.locator(".row-edit-cancel").click()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert page.url == f"{datasette_server}data/projects"
|
||||
|
||||
assert project_rows(datasette_server, title="Bulk one")
|
||||
assert project_rows(datasette_server, title="Bulk two")
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
|
||||
page, datasette_server
|
||||
):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
conflict_field = dialog.locator(".row-edit-bulk-conflict")
|
||||
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
|
||||
|
||||
assert conflict_field.is_hidden()
|
||||
textarea.fill("title\nNo primary key")
|
||||
assert conflict_field.is_hidden()
|
||||
|
||||
textarea.fill(
|
||||
"id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
|
||||
)
|
||||
assert conflict_field.is_visible()
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-conflict-label").inner_text()
|
||||
== "If the row exists already"
|
||||
)
|
||||
assert conflict_select.input_value() == "ignore"
|
||||
assert (
|
||||
conflict_select.evaluate("""node => Array.from(node.options)
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => option.textContent.trim())""")
|
||||
== [
|
||||
"Stop with an error",
|
||||
"Skip existing rows",
|
||||
"Update existing and insert new",
|
||||
]
|
||||
)
|
||||
|
||||
conflict_select.select_option("upsert")
|
||||
assert conflict_select.input_value() == "upsert"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "Updated by upsert" in preview_text
|
||||
assert "Inserted by upsert" in preview_text
|
||||
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="2 rows upserted."
|
||||
).wait_for()
|
||||
|
||||
assert upsert_item_row(datasette_server, "existing")["title"] == (
|
||||
"Updated by upsert"
|
||||
)
|
||||
assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
|
||||
page, datasette_server
|
||||
):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
conflict_field = dialog.locator(".row-edit-bulk-conflict")
|
||||
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
|
||||
|
||||
textarea.fill("title\nOnly title")
|
||||
assert conflict_field.is_hidden()
|
||||
|
||||
textarea.fill("id,title\n1,Only title")
|
||||
assert conflict_field.is_visible()
|
||||
assert conflict_select.input_value() == "ignore"
|
||||
assert (
|
||||
conflict_select.evaluate("""node => Array.from(node.options)
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => option.textContent.trim())""")
|
||||
== [
|
||||
"Stop with an error",
|
||||
"Skip existing rows",
|
||||
]
|
||||
)
|
||||
assert conflict_select.locator('option[value="upsert"]').evaluate(
|
||||
"node => node.hidden && node.disabled"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
error = dialog.locator(".row-edit-error")
|
||||
save = dialog.locator(".row-edit-save")
|
||||
|
||||
textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
|
||||
error.wait_for()
|
||||
assert error.inner_text() == "JSON row 1 has unknown column id2."
|
||||
assert save.is_disabled()
|
||||
assert textarea.evaluate("node => document.activeElement === node")
|
||||
|
||||
textarea.fill("id2,title\n1,Unknown column")
|
||||
assert error.inner_text() == "Unknown column id2 in header row."
|
||||
assert save.is_disabled()
|
||||
|
||||
textarea.fill("[")
|
||||
assert error.is_hidden()
|
||||
assert not save.is_disabled()
|
||||
|
||||
textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
|
||||
assert error.is_hidden()
|
||||
assert not save.is_disabled()
|
||||
assert dialog.locator(".row-edit-bulk-conflict").is_visible()
|
||||
assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/bulk_defaults")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "Only title" in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
|
||||
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="1 row inserted."
|
||||
).wait_for()
|
||||
|
||||
rows = bulk_default_rows(datasette_server, title="Only title")
|
||||
assert rows == [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Only title",
|
||||
"status": "todo",
|
||||
"score": 5,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
|
||||
"Previewing 3 rows."
|
||||
)
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "one" in preview_text
|
||||
assert "two" in preview_text
|
||||
assert "three" in preview_text
|
||||
assert "null" not in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
|
|
@ -882,6 +1431,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
|
|||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
assert dialog.locator(".row-edit-bulk-insert").is_hidden()
|
||||
assert dialog.locator(".row-edit-single-insert").is_hidden()
|
||||
assert dialog.locator(".row-edit-bulk").is_hidden()
|
||||
title = dialog.locator('input[name="title"]')
|
||||
title.wait_for()
|
||||
title.fill("Build Datasette, edited")
|
||||
|
|
|
|||
|
|
@ -1585,6 +1585,66 @@ async def test_create_query_analyze_endpoint_uses_sql_only():
|
|||
assert old_analyze_response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_query_supports_recursive_cte():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_create_recursive_cte", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
sql = """
|
||||
with recursive dog_tree(id, name) as (
|
||||
select id, name from dogs
|
||||
union all
|
||||
select id + 1, name from dog_tree where id < 3
|
||||
)
|
||||
select name from dog_tree
|
||||
""".strip()
|
||||
|
||||
analysis_response = await ds.client.get(
|
||||
"/data/-/queries/analyze",
|
||||
actor={"id": "root"},
|
||||
params={"sql": sql},
|
||||
)
|
||||
form_response = await ds.client.get(
|
||||
"/data/-/queries/store",
|
||||
actor={"id": "root"},
|
||||
params={"sql": sql},
|
||||
)
|
||||
store_response = await ds.client.post(
|
||||
"/data/-/queries/store",
|
||||
actor={"id": "root"},
|
||||
data={
|
||||
"name": "dog-tree",
|
||||
"title": "Dog tree",
|
||||
"sql": sql,
|
||||
"is_private": "1",
|
||||
},
|
||||
)
|
||||
|
||||
assert analysis_response.status_code == 200
|
||||
analysis_data = analysis_response.json()
|
||||
assert analysis_data["ok"] is True
|
||||
assert analysis_data["analysis_error"] is None
|
||||
assert analysis_data["analysis_is_write"] is False
|
||||
assert analysis_data["save_disabled"] is False
|
||||
|
||||
assert form_response.status_code == 200
|
||||
soup = Soup(form_response.text, "html.parser")
|
||||
submit = soup.select_one("[data-query-create-submit]")
|
||||
assert submit is not None
|
||||
assert not submit.has_attr("disabled")
|
||||
assert "This is a read-only query." in form_response.text
|
||||
|
||||
assert store_response.status_code == 302
|
||||
assert store_response.headers["location"] == "/data/dog-tree"
|
||||
query = await ds.get_query("data", "dog-tree")
|
||||
assert query is not None
|
||||
assert query.sql == sql
|
||||
assert query.is_write is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_query_form_error_redisplays_form_with_values():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
|
|
@ -3154,6 +3214,74 @@ async def test_execute_write_create_table_uses_create_table_permission():
|
|||
assert not await db.table_exists("should_not_exist")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_create_view_uses_create_view_permission():
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
default_deny=True,
|
||||
config={
|
||||
"permissions": {
|
||||
"insert-row": {"id": "row-writer"},
|
||||
"update-row": {"id": "row-writer"},
|
||||
},
|
||||
"databases": {
|
||||
"data": {
|
||||
"permissions": {
|
||||
"view-database": {"id": ["creator", "row-writer"]},
|
||||
"execute-write-sql": {"id": ["creator", "row-writer"]},
|
||||
"create-view": {"id": "creator"},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
db = ds.add_memory_database("execute_write_create_view", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
analysis_response = await ds.client.get(
|
||||
"/data/-/execute-write/analyze",
|
||||
actor={"id": "creator"},
|
||||
params={"sql": "create view dog_names as select id, name from dogs"},
|
||||
)
|
||||
allowed_response = await ds.client.post(
|
||||
"/data/-/execute-write",
|
||||
actor={"id": "creator"},
|
||||
json={"sql": "create view dog_names as select id, name from dogs"},
|
||||
)
|
||||
row_permission_response = await ds.client.post(
|
||||
"/data/-/execute-write",
|
||||
actor={"id": "row-writer"},
|
||||
json={"sql": "create view should_not_exist as select id from dogs"},
|
||||
)
|
||||
|
||||
assert analysis_response.status_code == 200
|
||||
analysis_data = analysis_response.json()
|
||||
assert analysis_data["ok"] is True
|
||||
assert analysis_data["execute_disabled"] is False
|
||||
assert analysis_data["analysis_rows"] == [
|
||||
{
|
||||
"operation": "create",
|
||||
"database": "data",
|
||||
"table": "dog_names",
|
||||
"required_permission": "create-view",
|
||||
"source": None,
|
||||
"allowed": True,
|
||||
}
|
||||
]
|
||||
|
||||
assert allowed_response.status_code == 200
|
||||
assert allowed_response.json()["ok"] is True
|
||||
assert allowed_response.json()["message"] == "Query executed"
|
||||
assert await db.view_exists("dog_names")
|
||||
|
||||
assert row_permission_response.status_code == 403
|
||||
assert row_permission_response.json()["errors"] == [
|
||||
"Permission denied: need create-view on data"
|
||||
]
|
||||
assert not await db.view_exists("should_not_exist")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"database_name",
|
||||
|
|
@ -3204,8 +3332,20 @@ async def test_execute_write_create_table_uses_create_table_permission():
|
|||
(),
|
||||
"drop-table",
|
||||
),
|
||||
(
|
||||
"execute_write_drop_view",
|
||||
"dropper",
|
||||
"drop view dogs_view",
|
||||
"drop view cats_view",
|
||||
"Permission denied: need drop-view on data/cats_view",
|
||||
(
|
||||
"create view dogs_view as select * from dogs",
|
||||
"create view cats_view as select * from cats",
|
||||
),
|
||||
"drop-view",
|
||||
),
|
||||
),
|
||||
ids=("alter-table", "create-index", "drop-index", "drop-table"),
|
||||
ids=("alter-table", "create-index", "drop-index", "drop-table", "drop-view"),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_schema_operations_use_schema_permissions(
|
||||
|
|
@ -3240,7 +3380,12 @@ async def test_execute_write_schema_operations_use_schema_permissions(
|
|||
"drop-table": {"id": "dropper"},
|
||||
"view-table": {"id": "alterer"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"dogs_view": {
|
||||
"permissions": {
|
||||
"drop-view": {"id": "dropper"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
@ -3293,6 +3438,9 @@ async def test_execute_write_schema_operations_use_schema_permissions(
|
|||
elif expected_state == "drop-table":
|
||||
assert not await db.table_exists("dogs")
|
||||
assert await db.table_exists("cats")
|
||||
elif expected_state == "drop-view":
|
||||
assert not await db.view_exists("dogs_view")
|
||||
assert await db.view_exists("cats_view")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,7 @@ async def test_database_create_table_action_button_and_data():
|
|||
"databaseName": "data",
|
||||
"columnTypes": ["text", "integer", "float", "blob"],
|
||||
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
|
||||
"canInsertRows": False,
|
||||
},
|
||||
}
|
||||
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
|
||||
|
|
@ -1050,6 +1051,40 @@ async def test_database_create_table_action_button_and_data():
|
|||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_create_table_data_includes_insert_row_permission():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"permissions": {
|
||||
"create-table": {"id": "root"},
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_database_create_table_insert_permission"),
|
||||
name="data",
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/data", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
|
||||
"createTable"
|
||||
]
|
||||
assert create_table_data["canInsertRows"] is True
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_create_table_data_includes_custom_column_types():
|
||||
ds = Datasette(
|
||||
|
|
@ -1316,6 +1351,7 @@ async def test_table_insert_action_button_and_data():
|
|||
assert insert_data["path"] == "/data/items/-/insert"
|
||||
assert insert_data["tableName"] == "items"
|
||||
assert insert_data["primaryKeys"] == ["id"]
|
||||
assert insert_data["maxInsertRows"] == 100
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"name",
|
||||
"score",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue