mirror of
https://github.com/simonw/datasette.git
synced 2026-09-28 12:54:10 +02:00
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:
commit
194ee95ae2
37 changed files with 4888 additions and 179 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue