Merge remote-tracking branch 'origin/main' into claude/json-api-docs-1-0-review-a3e83u

# Conflicts:
#	datasette/__init__.py
#	tests/test_api_write.py
This commit is contained in:
Claude 2026-07-06 22:25:27 +00:00
commit 194ee95ae2
No known key found for this signature in database
37 changed files with 4888 additions and 179 deletions

View file

@ -326,7 +326,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(

View file

@ -8,7 +8,7 @@ from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, Response
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
@ -17,7 +17,9 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
decode_write_json_row,
InvalidSql,
make_slot_function,
path_from_row_pks,
@ -26,6 +28,7 @@ from datasette.utils import (
to_css_class,
escape_sqlite,
sqlite3,
WriteJsonValueError,
)
from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope
@ -785,6 +788,8 @@ class RowUpdateView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"])
@ -796,6 +801,10 @@ class RowUpdateView(BaseView):
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"]
try:
update = decode_write_json_row(update)
except WriteJsonValueError as e:
return _error([str(e)], 400)
# Validate column types
from datasette.views.table import _validate_column_types
@ -857,4 +866,4 @@ class RowUpdateView(BaseView):
self.ds.INFO,
)
return Response.json(result, status=200)
return Response.json(result, status=200, default=CustomJSONEncoder().default)

View file

@ -22,9 +22,11 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
append_querystring,
compound_keys_after_sql,
decode_write_json_rows,
format_bytes,
make_slot_function,
tilde_encode,
@ -41,9 +43,17 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
InvalidSql,
WriteJsonValueError,
sqlite3,
)
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
from datasette.utils.asgi import (
BadRequest,
Forbidden,
NotFound,
PayloadTooLarge,
Request,
Response,
)
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
@ -206,7 +216,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(
@ -481,8 +491,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:
@ -493,32 +510,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(
@ -1053,11 +1078,18 @@ class TableInsertView(BaseView):
pks = await db.primary_keys(table_name)
rows, errors, extras = await self._validate_data(
request, db, table_name, pks, upsert
)
try:
rows, errors, extras = await self._validate_data(
request, db, table_name, pks, upsert
)
except PayloadTooLarge as e:
return _error([str(e)], 413)
if errors:
return _error(errors, 400)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return _error([str(e)], 400)
# Validate column types
ct_errors = await _validate_column_types(
@ -1192,7 +1224,11 @@ class TableInsertView(BaseView):
)
)
return Response.json(result, status=200 if upsert else 201)
return Response.json(
result,
status=200 if upsert else 201,
default=CustomJSONEncoder().default,
)
class TableUpsertView(TableInsertView):
@ -1232,6 +1268,8 @@ class TableSetColumnTypeView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
@ -1350,6 +1388,8 @@ class TableDropView(BaseView):
confirm = data.get("confirm")
except json.JSONDecodeError:
pass
except PayloadTooLarge as e:
return _error([str(e)], 413)
if not confirm:
return Response.json(

View file

@ -20,11 +20,13 @@ from datasette.column_types import SQLiteType
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
decode_write_json_rows,
escape_sqlite,
get_outbound_foreign_keys,
table_column_details,
WriteJsonValueError,
)
from datasette.utils.asgi import NotFound, Response
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import sqlite_hidden_table_names
from .base import BaseView, _error
@ -267,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",
@ -804,6 +811,8 @@ class TableCreateView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be an object"])
@ -838,6 +847,10 @@ class TableCreateView(BaseView):
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return _error([str(e)], 400)
alter = False
if rows:
@ -1161,6 +1174,8 @@ class TableAlterView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)

View file

@ -1,6 +1,7 @@
import itertools
from dataclasses import dataclass
from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
from datasette.plugins import pm
@ -375,6 +376,55 @@ class PrimaryKeysExtra(Extra):
return context.pks
def column_detail_as_json(column):
return {
"type": column.type,
"sqlite_type": SQLiteType.from_declared_type(column.type).value,
"notnull": bool(column.notnull),
"default": column.default_value,
"is_pk": bool(column.is_pk),
"pk_position": column.is_pk,
"hidden": column.hidden,
}
class ColumnDetailsExtra(Extra):
description = (
"SQLite schema details for columns in this table. The dictionary maps "
"column names to objects describing the schema for each column."
)
docs_note = (
"Each object has ``type`` as the declared type string returned by "
'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the '
"normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, "
"``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` "
'as the raw SQL default expression string, such as ``"42"``, '
"``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is "
"no default; ``is_pk`` as a boolean; ``pk_position`` as the integer "
"primary key position reported by SQLite, or ``0`` for columns that "
"are not part of the primary key; and ``hidden`` as the integer value "
"reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for "
"normal columns, ``1`` for hidden virtual table columns, ``2`` for "
"virtual generated columns and ``3`` for stored generated columns."
)
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/binary_data/1.json?_extra=column_details"
)
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
column_details = await context.datasette._get_resource_column_details(
context.database_name, context.table_name
)
return {
column_name: column_detail_as_json(column)
for column_name, column in column_details.items()
}
class ActionsExtra(Extra):
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes = {ExtraScope.TABLE}
@ -1240,6 +1290,7 @@ TABLE_EXTRA_CLASSES = [
ColumnsExtra,
AllColumnsExtra,
PrimaryKeysExtra,
ColumnDetailsExtra,
DisplayColumnsAndRowsProvider,
DisplayColumnsExtra,
DisplayRowsExtra,