mirror of
https://github.com/simonw/datasette.git
synced 2026-09-27 04:14:25 +02:00
Three corrections to the emitted data, bundled because changing what is on the wire after operators have built dashboards on it is a breaking change - so they belong in the first release that ships spans at all, not a later one. db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off the span kind rather than off db.system, so the spans rendered as ordinary internal work despite carrying db.system and db.query.text. The three child spans stay INTERNAL on purpose: db.query.execute, db.write.execute and db.write.queue_wait are Datasette's decomposition of one logical query, not three database calls, and queue_wait touches no database at all - marking them CLIENT would make one query look like several to anything counting spans by kind. The instrumentation scope now carries the Datasette version and a schema URL, so a backend can tell which Datasette produced a span. The URL is 1.29.0 rather than the latest semconv release because that is the highest version at which every name emitted here is the current spelling: db.system was renamed to db.system.name in 1.30.0 and this code still emits the older form. Claiming a later schema would be false, and would stop a consumer translating that name forward, since the claim asserts the rename already happened. db.operation.name is the statement's leading keyword matched against a fixed allowlist, not a parse. On a public instance the SQL is attacker-controlled and this attribute is a candidate metric dimension in a later phase, so echoing back an arbitrary first token would let a visitor's typo mint a permanent series. Anything unrecognised gets no attribute rather than a wrong one. execute_write_script() does not set it at all, since semantic conventions say not to extract an operation name from query text that can hold several statements. db.collection.name comes only from a new table= argument on Database.execute(), and is never derived from the SQL: deriving it would be a parse, and on an instance where anyone can create a table the value set has no ceiling. It is passed from every query in the table and row views that targets exactly one user table. Internal-catalog reads and the row view's cross-table foreign key counts are deliberately left without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2560 lines
90 KiB
Python
2560 lines
90 KiB
Python
import asyncio
|
|
import itertools
|
|
import json
|
|
import time
|
|
import urllib
|
|
import urllib.parse
|
|
from dataclasses import dataclass, field
|
|
|
|
import markupsafe
|
|
import sqlite_utils
|
|
|
|
from datasette import tracer
|
|
from datasette.column_types import SQLiteType
|
|
from datasette.database import QueryInterrupted
|
|
from datasette.events import (
|
|
AlterTableEvent,
|
|
DropTableEvent,
|
|
InsertRowsEvent,
|
|
UpsertRowsEvent,
|
|
)
|
|
from datasette.extras import ExtraScope, extra_names_from_request
|
|
from datasette.filters import Filters
|
|
from datasette.plugins import pm
|
|
from datasette.resources import DatabaseResource, TableResource
|
|
from datasette.utils import (
|
|
CustomJSONEncoder,
|
|
CustomRow,
|
|
InvalidSql,
|
|
WriteJsonValueError,
|
|
add_cors_headers,
|
|
append_querystring,
|
|
await_me_maybe,
|
|
call_with_supported_arguments,
|
|
compound_keys_after_sql,
|
|
decode_write_json_rows,
|
|
escape_sqlite,
|
|
filters_should_redirect,
|
|
format_bytes,
|
|
is_url,
|
|
make_slot_function,
|
|
path_from_row_pks,
|
|
path_with_added_args,
|
|
path_with_format,
|
|
path_with_removed_args,
|
|
path_with_replaced_args,
|
|
sqlite3,
|
|
tilde_encode,
|
|
to_css_class,
|
|
truncate_url,
|
|
urlsafe_components,
|
|
value_as_boolean,
|
|
)
|
|
from datasette.utils.asgi import (
|
|
BadRequest,
|
|
Forbidden,
|
|
NotFound,
|
|
PayloadTooLarge,
|
|
Request,
|
|
Response,
|
|
)
|
|
from datasette.utils.sqlite import check_structured_write_table
|
|
|
|
from . import Context, from_extra
|
|
from .base import BaseView, DatasetteError, 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,
|
|
count_is_truncated,
|
|
precompute_database_action_permissions,
|
|
precompute_table_action_permissions,
|
|
resolve_table_extras,
|
|
table_extra_registry,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class TableContext(Context):
|
|
"The page showing the rows in a table or SQL view, e.g. /fixtures/facetable."
|
|
|
|
documented_template = "table.html"
|
|
extras_scope = ExtraScope.TABLE
|
|
|
|
# Fields resolved by registered extras - their documentation comes
|
|
# from the description on each Extra class in table_extras.py
|
|
actions: callable = from_extra()
|
|
all_columns: list = from_extra()
|
|
columns: list = from_extra()
|
|
count: int = from_extra()
|
|
count_sql: str = from_extra()
|
|
custom_table_templates: list = from_extra()
|
|
database: str = from_extra()
|
|
database_color: str = from_extra()
|
|
display_columns: list = from_extra()
|
|
display_rows: list = from_extra()
|
|
expandable_columns: list = from_extra()
|
|
facet_results: dict = from_extra()
|
|
facets_timed_out: list = from_extra()
|
|
filters: Filters = from_extra()
|
|
form_hidden_args: list = from_extra()
|
|
human_description_en: str = from_extra()
|
|
is_view: bool = from_extra()
|
|
metadata: dict = from_extra()
|
|
primary_keys: list = from_extra()
|
|
private: bool = from_extra()
|
|
query: dict = from_extra()
|
|
renderers: dict = from_extra()
|
|
set_column_type_ui: dict = from_extra()
|
|
sorted_facet_results: list = from_extra()
|
|
suggested_facets: list = from_extra()
|
|
table: str = from_extra()
|
|
table_definition: str = from_extra()
|
|
view_definition: str = from_extra()
|
|
|
|
# Fields added by the view code
|
|
ok: bool = field(
|
|
metadata={"help": "True if the data for this page was retrieved without errors"}
|
|
)
|
|
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
|
|
next_url: str = field(
|
|
metadata={
|
|
"help": "Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`."
|
|
}
|
|
)
|
|
count_truncated: bool = field(
|
|
metadata={
|
|
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
|
|
}
|
|
)
|
|
rows: list = field(
|
|
metadata={
|
|
"help": "The rows for this page, as a list of dictionaries mapping column name to raw value."
|
|
}
|
|
)
|
|
filter_columns: list = field(
|
|
metadata={
|
|
"help": "List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered."
|
|
}
|
|
)
|
|
supports_search: bool = field(
|
|
metadata={"help": "True if this table has full-text search configured"}
|
|
)
|
|
extra_wheres_for_ui: list = field(
|
|
metadata={
|
|
"help": "Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment."
|
|
}
|
|
)
|
|
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
|
|
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
|
|
url_csv_hidden_args: list = field(
|
|
metadata={
|
|
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``."
|
|
}
|
|
)
|
|
sort: str = field(metadata={"help": "Column the page is sorted by, or None"})
|
|
sort_desc: str = field(
|
|
metadata={"help": "Column the page is sorted by in descending order, or None"}
|
|
)
|
|
append_querystring: callable = field(
|
|
metadata={
|
|
"help": "Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate."
|
|
}
|
|
)
|
|
path_with_replaced_args: callable = field(
|
|
metadata={
|
|
"help": "Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument."
|
|
}
|
|
)
|
|
fix_path: callable = field(
|
|
metadata={
|
|
"help": "Function that applies the configured ``base_url`` prefix to a path."
|
|
}
|
|
)
|
|
settings: dict = field(
|
|
metadata={
|
|
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
|
}
|
|
)
|
|
alternate_url_json: str = field(
|
|
metadata={"help": "URL for the JSON version of this page"}
|
|
)
|
|
datasette_allow_facet: str = field(
|
|
metadata={
|
|
"help": 'The string "true" or "false" reflecting the allow_facet setting'
|
|
}
|
|
)
|
|
is_sortable: bool = field(
|
|
metadata={"help": "True if any of the displayed columns can be used to sort"}
|
|
)
|
|
allow_execute_sql: bool = field(
|
|
metadata={
|
|
"help": "True if the current actor can execute custom SQL against this database"
|
|
}
|
|
)
|
|
query_ms: float = field(
|
|
metadata={
|
|
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
|
}
|
|
)
|
|
select_templates: list = field(
|
|
metadata={
|
|
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
|
}
|
|
)
|
|
top_table: callable = field(
|
|
metadata={
|
|
"help": "Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML."
|
|
}
|
|
)
|
|
table_page_data: dict = field(
|
|
metadata={
|
|
"help": "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."
|
|
}
|
|
)
|
|
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``, ``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(
|
|
metadata={
|
|
"help": "Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys."
|
|
}
|
|
)
|
|
|
|
|
|
LINK_WITH_LABEL = (
|
|
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
|
)
|
|
LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'
|
|
|
|
|
|
class Row:
|
|
def __init__(
|
|
self,
|
|
cells,
|
|
pk_path=None,
|
|
row_path=None,
|
|
row_label=None,
|
|
):
|
|
self.cells = cells
|
|
self.pk_path = pk_path
|
|
self.row_path = row_path
|
|
self.row_label = row_label
|
|
|
|
def __iter__(self):
|
|
return iter(self.cells)
|
|
|
|
def __getitem__(self, key):
|
|
for cell in self.cells:
|
|
if cell["column"] == key:
|
|
return cell["raw"]
|
|
raise KeyError
|
|
|
|
def display(self, key):
|
|
for cell in self.cells:
|
|
if cell["column"] == key:
|
|
return cell["value"]
|
|
return None
|
|
|
|
def __str__(self):
|
|
d = {
|
|
key: self[key]
|
|
for key in [
|
|
c["column"] for c in self.cells if not c.get("is_special_link_column")
|
|
]
|
|
}
|
|
return json.dumps(d, default=repr, indent=2)
|
|
|
|
|
|
def row_label_from_label_column(row, label_column):
|
|
if not label_column:
|
|
return None
|
|
try:
|
|
value = row[label_column]
|
|
except (KeyError, IndexError):
|
|
return None
|
|
if isinstance(value, dict):
|
|
value = value.get("label")
|
|
if value is None or value == "":
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
async def run_sequential(*args):
|
|
# This used to be swappable for asyncio.gather() to run things in
|
|
# parallel, but this lead to hard-to-debug locking issues with
|
|
# in-memory databases: https://github.com/simonw/datasette/issues/2189
|
|
results = []
|
|
for fn in args:
|
|
results.append(await fn)
|
|
return results
|
|
|
|
|
|
def _exact_filter_key(column):
|
|
if column.startswith("_"):
|
|
return f"{column}__exact"
|
|
return column
|
|
|
|
|
|
def _request_with_query_string(request, query_string):
|
|
scope = dict(request.scope)
|
|
scope["query_string"] = query_string.encode("latin-1")
|
|
return Request(scope, request.receive)
|
|
|
|
|
|
async def _fragment_request_for_row(request, resolved):
|
|
row_path = request.args.get("_row")
|
|
if not row_path:
|
|
return request
|
|
if resolved.is_view:
|
|
raise BadRequest("_row is not supported for views")
|
|
|
|
pks = await resolved.db.primary_keys(resolved.table)
|
|
row_pks = pks or ["rowid"]
|
|
pk_values = urlsafe_components(row_path)
|
|
if len(pk_values) != len(row_pks):
|
|
raise BadRequest("_row does not match the primary key for this table")
|
|
|
|
row_pk_filter_keys = {
|
|
key
|
|
for pk in row_pks
|
|
for key in {
|
|
_exact_filter_key(pk),
|
|
f"{pk}__exact",
|
|
}
|
|
}
|
|
args = [
|
|
(key, value)
|
|
for key, value in urllib.parse.parse_qsl(
|
|
request.query_string, keep_blank_values=True
|
|
)
|
|
if key
|
|
not in {
|
|
"_row",
|
|
"_next",
|
|
"_nocount",
|
|
"_nofacet",
|
|
"_nosuggest",
|
|
}.union(row_pk_filter_keys)
|
|
]
|
|
args.extend(
|
|
[(_exact_filter_key(pk), value) for pk, value in zip(row_pks, pk_values)]
|
|
)
|
|
args.extend(
|
|
[
|
|
("_nocount", "1"),
|
|
("_nofacet", "1"),
|
|
("_nosuggest", "1"),
|
|
]
|
|
)
|
|
return _request_with_query_string(request, urllib.parse.urlencode(args))
|
|
|
|
|
|
def _redirect(datasette, request, path, forward_querystring=True, remove_args=None):
|
|
if request.query_string and "?" not in path and forward_querystring:
|
|
path = f"{path}?{request.query_string}"
|
|
if remove_args:
|
|
path = path_with_removed_args(request, remove_args, path=path)
|
|
r = Response.redirect(path)
|
|
r.headers["Link"] = f"<{path}>; rel=preload"
|
|
if datasette.cors:
|
|
add_cors_headers(r.headers)
|
|
return r
|
|
|
|
|
|
async def _redirect_if_needed(datasette, request, resolved):
|
|
# Handle ?_filter_column
|
|
redirect_params = filters_should_redirect(request.args)
|
|
if redirect_params:
|
|
return _redirect(
|
|
datasette,
|
|
request,
|
|
datasette.urls.path(path_with_added_args(request, redirect_params)),
|
|
forward_querystring=False,
|
|
)
|
|
|
|
# If ?_sort_by_desc=on (from checkbox) redirect to _sort_desc=(_sort)
|
|
if "_sort_by_desc" in request.args:
|
|
return _redirect(
|
|
datasette,
|
|
request,
|
|
datasette.urls.path(
|
|
path_with_added_args(
|
|
request,
|
|
{
|
|
"_sort_desc": request.args.get("_sort"),
|
|
"_sort_by_desc": None,
|
|
"_sort": None,
|
|
},
|
|
)
|
|
),
|
|
forward_querystring=False,
|
|
)
|
|
|
|
|
|
async def _validate_column_types(datasette, database_name, table_name, rows):
|
|
"""Validate row values against assigned column types. Returns list of error strings."""
|
|
ct_map = await datasette.get_column_types(database_name, table_name)
|
|
if not ct_map:
|
|
return []
|
|
errors = []
|
|
for row in rows:
|
|
for col_name, ct in ct_map.items():
|
|
if col_name not in row:
|
|
continue
|
|
error = await ct.validate(row[col_name], datasette)
|
|
if error:
|
|
errors.append(f"{col_name}: {error}")
|
|
return errors
|
|
|
|
|
|
def _column_value_kind_for_insert_form(column_detail):
|
|
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
|
if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL):
|
|
return "number"
|
|
return "string"
|
|
|
|
|
|
def _column_sqlite_type_for_insert_form(column_detail):
|
|
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
|
return sqlite_type.value if sqlite_type is not None else None
|
|
|
|
|
|
async def _foreign_key_autocomplete_urls(
|
|
datasette, request, db, database_name, table_name
|
|
):
|
|
autocomplete_urls = {}
|
|
for fk in await db.foreign_keys_for_table(table_name):
|
|
if not await db.table_exists(fk["other_table"]):
|
|
continue
|
|
other_pks = await db.primary_keys(fk["other_table"])
|
|
other_column = fk["other_column"]
|
|
if other_column is None and len(other_pks) == 1:
|
|
other_column = other_pks[0]
|
|
if len(other_pks) != 1 or other_column != other_pks[0]:
|
|
continue
|
|
visible, _ = await datasette.check_visibility(
|
|
request.actor,
|
|
action="view-table",
|
|
resource=TableResource(database=database_name, table=fk["other_table"]),
|
|
)
|
|
if not visible:
|
|
continue
|
|
autocomplete_urls[fk["column"]] = "{}/-/autocomplete".format(
|
|
datasette.urls.table(database_name, fk["other_table"])
|
|
)
|
|
return autocomplete_urls
|
|
|
|
|
|
async def _table_page_data(
|
|
datasette,
|
|
request,
|
|
db,
|
|
database_name,
|
|
table_name,
|
|
is_view,
|
|
table_insert_ui,
|
|
table_alter_ui,
|
|
):
|
|
data = {
|
|
"database": database_name,
|
|
"table": table_name,
|
|
"tableUrl": datasette.urls.table(database_name, table_name),
|
|
}
|
|
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
|
|
)
|
|
if foreign_keys:
|
|
data["foreignKeys"] = foreign_keys
|
|
return data
|
|
|
|
|
|
async def _table_insert_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="insert-row",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
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:
|
|
continue
|
|
is_pk = column.name in pks
|
|
is_auto_pk = (
|
|
is_pk
|
|
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
|
|
columns.append(column_data)
|
|
|
|
data = {
|
|
"path": f"{datasette.urls.table(database_name, table_name)}/-/insert",
|
|
"tableName": table_name,
|
|
"columns": columns,
|
|
"bulkColumns": bulk_columns,
|
|
"primaryKeys": pks,
|
|
"maxInsertRows": datasette.setting("max_insert_rows"),
|
|
}
|
|
if can_update:
|
|
data["upsertPath"] = (
|
|
f"{datasette.urls.table(database_name, table_name)}/-/upsert"
|
|
)
|
|
return data
|
|
|
|
|
|
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": f"{datasette.urls.table(database_name, table_name)}/-/alter",
|
|
"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"] = f"{datasette.urls.table(database_name, table_name)}/-/drop"
|
|
return data
|
|
|
|
|
|
async def display_columns_and_rows(
|
|
datasette,
|
|
database_name,
|
|
table_name,
|
|
description,
|
|
rows,
|
|
link_column=False,
|
|
truncate_cells=0,
|
|
sortable_columns=None,
|
|
request=None,
|
|
):
|
|
"""Returns columns, rows for specified table - including fancy foreign key treatment"""
|
|
sortable_columns = sortable_columns or set()
|
|
db = datasette.databases[database_name]
|
|
column_descriptions = dict(
|
|
await datasette.get_internal_database().execute(
|
|
"""
|
|
SELECT
|
|
column_name,
|
|
value
|
|
FROM metadata_columns
|
|
WHERE database_name = ?
|
|
AND resource_name = ?
|
|
AND key = 'description'
|
|
""",
|
|
[database_name, table_name],
|
|
)
|
|
)
|
|
|
|
# Look up column types for this table
|
|
column_types_map = await datasette.get_column_types(database_name, table_name)
|
|
|
|
column_details = {
|
|
col.name: col for col in await db.table_column_details(table_name)
|
|
}
|
|
pks = await db.primary_keys(table_name)
|
|
pks_for_display = pks
|
|
if not pks_for_display and not await db.view_exists(table_name):
|
|
pks_for_display = ["rowid"]
|
|
label_column = None
|
|
if link_column:
|
|
label_column = await db.label_column_for_table(table_name)
|
|
row_action_permissions = {}
|
|
if link_column and request is not None and db.is_mutable:
|
|
row_action_permissions = await datasette.allowed_many(
|
|
actions=["update-row", "delete-row"],
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
)
|
|
|
|
columns = []
|
|
for r in description:
|
|
if r[0] == "rowid" and "rowid" not in column_details:
|
|
type_ = "integer"
|
|
notnull = 0
|
|
else:
|
|
type_ = column_details[r[0]].type
|
|
notnull = column_details[r[0]].notnull
|
|
col_dict = {
|
|
"name": r[0],
|
|
"sortable": r[0] in sortable_columns,
|
|
"is_pk": r[0] in pks_for_display,
|
|
"type": type_,
|
|
"notnull": notnull,
|
|
"description": column_descriptions.get(r[0]),
|
|
"column_type": None,
|
|
"column_type_config": None,
|
|
}
|
|
ct = column_types_map.get(r[0])
|
|
if ct:
|
|
col_dict["column_type"] = ct.name
|
|
col_dict["column_type_config"] = ct.config
|
|
columns.append(col_dict)
|
|
|
|
column_to_foreign_key_table = {
|
|
fk["column"]: fk["other_table"]
|
|
for fk in await db.foreign_keys_for_table(table_name)
|
|
}
|
|
|
|
cell_rows = []
|
|
base_url = datasette.setting("base_url")
|
|
for row in rows:
|
|
cells = []
|
|
# Unless we are a view, the first column is a link - either to the rowid
|
|
# or to the simple or compound primary key
|
|
if link_column:
|
|
is_special_link_column = len(pks) != 1
|
|
pk_path = path_from_row_pks(row, pks, not pks, False)
|
|
row_path = path_from_row_pks(row, pks, not pks)
|
|
row_label = row_label_from_label_column(row, label_column)
|
|
row_action_label = pk_path
|
|
if row_label and row_label != pk_path:
|
|
row_action_label = f"{pk_path} {row_label}"
|
|
table_path = datasette.urls.table(database_name, table_name)
|
|
row_link = (
|
|
f'<a href="{table_path}/{row_path}">{markupsafe.escape(pk_path)!s}</a>'
|
|
)
|
|
edit_icon = (
|
|
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
|
'xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
|
|
'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
|
'<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>'
|
|
'<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>'
|
|
"</svg>"
|
|
)
|
|
delete_icon = (
|
|
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
|
'xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
|
|
'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
|
'<path d="M3 6h18"></path>'
|
|
'<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>'
|
|
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>'
|
|
'<path d="M10 11v6"></path>'
|
|
'<path d="M14 11v6"></path>'
|
|
"</svg>"
|
|
)
|
|
row_actions = []
|
|
if row_action_permissions.get("update-row"):
|
|
row_actions.append(
|
|
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
|
f'aria-label="Edit row {markupsafe.escape(row_action_label)}" title="Edit row" '
|
|
'data-row-action="edit">'
|
|
f"{edit_icon}</button>"
|
|
)
|
|
if row_action_permissions.get("delete-row"):
|
|
row_actions.append(
|
|
'<button type="button" class="row-inline-action row-inline-action-delete" '
|
|
f'aria-label="Delete row {markupsafe.escape(row_action_label)}" title="Delete row" '
|
|
'data-row-action="delete">'
|
|
f"{delete_icon}</button>"
|
|
)
|
|
if row_actions:
|
|
row_link = (
|
|
'<span class="row-link-with-actions">{row_link}'
|
|
'<span class="row-inline-actions" aria-label="Row actions">'
|
|
"{row_actions}</span></span>"
|
|
).format(row_link=row_link, row_actions="".join(row_actions))
|
|
cells.append(
|
|
{
|
|
"column": pks[0] if len(pks) == 1 else "Link",
|
|
"value_type": "pk",
|
|
"is_special_link_column": is_special_link_column,
|
|
"raw": pk_path,
|
|
"value": markupsafe.Markup(row_link),
|
|
}
|
|
)
|
|
|
|
for value, column_dict in zip(row, columns):
|
|
column = column_dict["name"]
|
|
if link_column and len(pks) == 1 and column == pks[0]:
|
|
# If there's a simple primary key, don't repeat the value as it's
|
|
# already shown in the link column.
|
|
continue
|
|
|
|
# First try column type render_cell, then plugins
|
|
# pylint: disable=no-member
|
|
plugin_display_value = None
|
|
ct = column_types_map.get(column)
|
|
if ct:
|
|
candidate = await ct.render_cell(
|
|
value=value,
|
|
column=column,
|
|
table=table_name,
|
|
database=database_name,
|
|
datasette=datasette,
|
|
request=request,
|
|
)
|
|
if candidate is not None:
|
|
plugin_display_value = candidate
|
|
if plugin_display_value is None:
|
|
for candidate in pm.hook.render_cell(
|
|
row=row,
|
|
value=value,
|
|
column=column,
|
|
table=table_name,
|
|
pks=pks_for_display,
|
|
database=database_name,
|
|
datasette=datasette,
|
|
request=request,
|
|
column_type=ct,
|
|
):
|
|
candidate = await await_me_maybe(candidate)
|
|
if candidate is not None:
|
|
plugin_display_value = candidate
|
|
break
|
|
if plugin_display_value:
|
|
display_value = plugin_display_value
|
|
elif isinstance(value, bytes):
|
|
formatted = format_bytes(len(value))
|
|
display_value = markupsafe.Markup(
|
|
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
|
datasette.urls.row_blob(
|
|
database_name,
|
|
table_name,
|
|
path_from_row_pks(row, pks, not pks),
|
|
column,
|
|
),
|
|
(f' title="{formatted}"' if "bytes" not in formatted else ""),
|
|
len(value),
|
|
"" if len(value) == 1 else "s",
|
|
)
|
|
)
|
|
elif isinstance(value, dict):
|
|
# It's an expanded foreign key - display link to other row
|
|
label = value["label"]
|
|
value = value["value"]
|
|
# The table we link to depends on the column
|
|
other_table = column_to_foreign_key_table[column]
|
|
link_template = LINK_WITH_LABEL if (label != value) else LINK_WITH_VALUE
|
|
display_value = markupsafe.Markup(
|
|
link_template.format(
|
|
database=tilde_encode(database_name),
|
|
base_url=base_url,
|
|
table=tilde_encode(other_table),
|
|
link_id=tilde_encode(str(value)),
|
|
id=str(markupsafe.escape(value)),
|
|
label=str(markupsafe.escape(label)) or "-",
|
|
)
|
|
)
|
|
elif value in ("", None):
|
|
display_value = markupsafe.Markup(" ")
|
|
elif is_url(str(value).strip()):
|
|
display_value = markupsafe.Markup(
|
|
'<a href="{url}">{truncated_url}</a>'.format(
|
|
url=markupsafe.escape(value.strip()),
|
|
truncated_url=markupsafe.escape(
|
|
truncate_url(value.strip(), truncate_cells)
|
|
),
|
|
)
|
|
)
|
|
else:
|
|
display_value = str(value)
|
|
if truncate_cells and len(display_value) > truncate_cells:
|
|
display_value = display_value[:truncate_cells] + "\u2026"
|
|
|
|
cells.append(
|
|
{
|
|
"column": column,
|
|
"value": display_value,
|
|
"raw": value,
|
|
"value_type": (
|
|
"none" if value is None else str(type(value).__name__)
|
|
),
|
|
}
|
|
)
|
|
if link_column:
|
|
cell_rows.append(
|
|
Row(
|
|
cells,
|
|
pk_path=pk_path,
|
|
row_path=row_path,
|
|
row_label=row_label,
|
|
)
|
|
)
|
|
else:
|
|
cell_rows.append(Row(cells))
|
|
|
|
if link_column:
|
|
# Add the link column header.
|
|
# If it's a simple primary key, we have to remove and re-add that column name at
|
|
# the beginning of the header row.
|
|
first_column = None
|
|
if len(pks) == 1:
|
|
columns = [col for col in columns if col["name"] != pks[0]]
|
|
first_column = {
|
|
"name": pks[0],
|
|
"sortable": len(pks) == 1,
|
|
"is_pk": True,
|
|
"type": column_details[pks[0]].type,
|
|
"notnull": column_details[pks[0]].notnull,
|
|
}
|
|
else:
|
|
first_column = {
|
|
"name": "Link",
|
|
"sortable": False,
|
|
"is_pk": False,
|
|
"type": "",
|
|
"notnull": 0,
|
|
"is_special_link_column": True,
|
|
}
|
|
columns = [first_column] + columns
|
|
return columns, cell_rows
|
|
|
|
|
|
class TableInsertView(BaseView):
|
|
name = "table-insert"
|
|
|
|
def __init__(self, datasette):
|
|
self.ds = datasette
|
|
|
|
async def _validate_data(self, request, db, table_name, pks, upsert):
|
|
errors = []
|
|
|
|
pks_list = []
|
|
if isinstance(pks, str):
|
|
pks_list = [pks]
|
|
else:
|
|
pks_list = list(pks)
|
|
|
|
if not pks_list:
|
|
pks_list = ["rowid"]
|
|
|
|
def _errors(errors):
|
|
return None, errors, {}
|
|
|
|
# The body is parsed as JSON regardless of the Content-Type header
|
|
try:
|
|
data = await request.json()
|
|
except json.JSONDecodeError as e:
|
|
return _errors([f"Invalid JSON: {e}"])
|
|
if not isinstance(data, dict):
|
|
return _errors(["JSON must be a dictionary"])
|
|
keys = data.keys()
|
|
|
|
# keys must contain "row" or "rows"
|
|
if "row" not in keys and "rows" not in keys:
|
|
return _errors(['JSON must have one or other of "row" or "rows"'])
|
|
rows = []
|
|
if "row" in keys:
|
|
if "rows" in keys:
|
|
return _errors(['Cannot use "row" and "rows" at the same time'])
|
|
row = data["row"]
|
|
if not isinstance(row, dict):
|
|
return _errors(['"row" must be a dictionary'])
|
|
rows = [row]
|
|
data["return"] = True
|
|
else:
|
|
rows = data["rows"]
|
|
if not isinstance(rows, list):
|
|
return _errors(['"rows" must be a list'])
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
return _errors(['"rows" must be a list of dictionaries'])
|
|
|
|
# Does this exceed max_insert_rows?
|
|
max_insert_rows = self.ds.setting("max_insert_rows")
|
|
if len(rows) > max_insert_rows:
|
|
return _errors([f"Too many rows, maximum allowed is {max_insert_rows}"])
|
|
|
|
# Validate other parameters
|
|
extras = {
|
|
key: value for key, value in data.items() if key not in ("row", "rows")
|
|
}
|
|
valid_extras = {"return", "ignore", "replace", "alter"}
|
|
invalid_extras = extras.keys() - valid_extras
|
|
if invalid_extras:
|
|
return _errors(
|
|
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_extras)))]
|
|
)
|
|
if extras.get("ignore") and extras.get("replace"):
|
|
return _errors(['Cannot use "ignore" and "replace" at the same time'])
|
|
|
|
columns = set(await db.table_columns(table_name))
|
|
columns.update(pks_list)
|
|
|
|
for i, row in enumerate(rows):
|
|
if upsert:
|
|
# It MUST have the primary key
|
|
missing_pks = [pk for pk in pks_list if pk not in row]
|
|
if missing_pks:
|
|
errors.append(
|
|
'Row {} is missing primary key column(s): "{}"'.format(
|
|
i, '", "'.join(missing_pks)
|
|
)
|
|
)
|
|
null_pks = [pk for pk in pks_list if pk in row and row[pk] is None]
|
|
if null_pks:
|
|
errors.append(
|
|
'Row {} has null primary key column(s): "{}"'.format(
|
|
i, '", "'.join(null_pks)
|
|
)
|
|
)
|
|
invalid_columns = set(row.keys()) - columns
|
|
if invalid_columns and not extras.get("alter"):
|
|
errors.append(
|
|
"Row {} has invalid columns: {}".format(
|
|
i, ", ".join(sorted(invalid_columns))
|
|
)
|
|
)
|
|
if errors:
|
|
return _errors(errors)
|
|
return rows, errors, extras
|
|
|
|
async def post(self, request, upsert=False):
|
|
try:
|
|
resolved = await self.ds.resolve_table(request)
|
|
except NotFound as e:
|
|
return Response.error([e.args[0]], 404)
|
|
db = resolved.db
|
|
database_name = db.name
|
|
table_name = resolved.table
|
|
|
|
# Table must exist (may handle table creation in the future)
|
|
db = self.ds.get_database(database_name)
|
|
if not await db.table_exists(table_name):
|
|
return Response.error([f"Table not found: {table_name}"], 404)
|
|
|
|
if upsert:
|
|
# Must have insert-row AND upsert-row permissions
|
|
if not (
|
|
await self.ds.allowed(
|
|
action="insert-row",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
)
|
|
and await self.ds.allowed(
|
|
action="update-row",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
)
|
|
):
|
|
return Response.error(
|
|
["Permission denied: need both insert-row and update-row"], 403
|
|
)
|
|
else:
|
|
# Must have insert-row permission
|
|
if not await self.ds.allowed(
|
|
action="insert-row",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
return Response.error(["Permission denied"], 403)
|
|
|
|
if not db.is_mutable:
|
|
return Response.error(["Database is immutable"], 403)
|
|
|
|
pks = await db.primary_keys(table_name)
|
|
|
|
try:
|
|
rows, errors, extras = await self._validate_data(
|
|
request, db, table_name, pks, upsert
|
|
)
|
|
except PayloadTooLarge as e:
|
|
return Response.error([str(e)], 413)
|
|
if errors:
|
|
return Response.error(errors, 400)
|
|
try:
|
|
rows = decode_write_json_rows(rows)
|
|
except WriteJsonValueError as e:
|
|
return Response.error([str(e)], 400)
|
|
|
|
# Validate column types
|
|
ct_errors = await _validate_column_types(
|
|
self.ds, database_name, table_name, rows
|
|
)
|
|
if ct_errors:
|
|
return Response.error(ct_errors, 400)
|
|
|
|
num_rows = len(rows)
|
|
|
|
# No that we've passed pks to _validate_data it's safe to
|
|
# fix the rowids case:
|
|
if not pks:
|
|
pks = ["rowid"]
|
|
|
|
ignore = extras.get("ignore")
|
|
replace = extras.get("replace")
|
|
alter = extras.get("alter")
|
|
|
|
if upsert and (ignore or replace):
|
|
return Response.error(["Upsert does not support ignore or replace"], 400)
|
|
|
|
if replace and not await self.ds.allowed(
|
|
action="update-row",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
return Response.error(
|
|
['Permission denied: need update-row to use "replace"'], 403
|
|
)
|
|
|
|
initial_schema = None
|
|
if alter:
|
|
# Must have alter-table permission
|
|
if not await self.ds.allowed(
|
|
action="alter-table",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
return Response.error(["Permission denied for alter-table"], 403)
|
|
# Track initial schema to check if it changed later
|
|
initial_schema = await db.execute_fn(
|
|
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
|
)
|
|
|
|
should_return = bool(extras.get("return", False))
|
|
row_pk_values_for_later = []
|
|
if should_return and upsert:
|
|
row_pk_values_for_later = [tuple(row[pk] for pk in pks) for row in rows]
|
|
|
|
def insert_or_upsert_rows(conn):
|
|
check_structured_write_table(conn, table_name)
|
|
table = sqlite_utils.Database(conn)[table_name]
|
|
kwargs = {}
|
|
if upsert:
|
|
kwargs = {
|
|
"pk": pks[0] if len(pks) == 1 else pks,
|
|
"alter": alter,
|
|
}
|
|
else:
|
|
# Insert
|
|
kwargs = {"ignore": ignore, "replace": replace, "alter": alter}
|
|
if should_return and not upsert:
|
|
rowids = []
|
|
method = table.upsert if upsert else table.insert
|
|
for row in rows:
|
|
rowids.append(method(row, **kwargs).last_rowid)
|
|
return list(
|
|
table.rows_where(
|
|
"rowid in ({})".format(",".join("?" for _ in rowids)),
|
|
rowids,
|
|
)
|
|
)
|
|
else:
|
|
method_all = table.upsert_all if upsert else table.insert_all
|
|
method_all(rows, **kwargs)
|
|
|
|
try:
|
|
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
|
|
except Exception as e: # noqa: BLE001
|
|
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
return Response.error([str(e)])
|
|
result = {"ok": True}
|
|
# Only read back and disclose stored rows if the actor is also
|
|
# allowed to view this table - insert-row/update-row alone must
|
|
# not be usable to read data the actor cannot otherwise see.
|
|
if should_return and not await self.ds.allowed(
|
|
action="view-table",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
should_return = False
|
|
if should_return:
|
|
if upsert:
|
|
# Fetch based on initial input IDs
|
|
where_clause = " OR ".join(
|
|
[
|
|
"({})".format(
|
|
" AND ".join(f"{escape_sqlite(pk)} = ?" for pk in pks)
|
|
)
|
|
]
|
|
* len(row_pk_values_for_later)
|
|
)
|
|
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
|
fetched_rows = await db.execute(
|
|
"select {}* from {} where {}".format(
|
|
"rowid, " if pks == ["rowid"] else "",
|
|
escape_sqlite(table_name),
|
|
where_clause,
|
|
),
|
|
args,
|
|
table=table_name,
|
|
)
|
|
result["rows"] = fetched_rows.dicts()
|
|
else:
|
|
result["rows"] = rows
|
|
# We track the number of rows requested, but do not attempt to show which were actually
|
|
# inserted or upserted v.s. ignored
|
|
if upsert:
|
|
await self.ds.track_event(
|
|
UpsertRowsEvent(
|
|
actor=request.actor,
|
|
database=database_name,
|
|
table=table_name,
|
|
num_rows=num_rows,
|
|
)
|
|
)
|
|
else:
|
|
await self.ds.track_event(
|
|
InsertRowsEvent(
|
|
actor=request.actor,
|
|
database=database_name,
|
|
table=table_name,
|
|
num_rows=num_rows,
|
|
ignore=bool(ignore),
|
|
replace=bool(replace),
|
|
)
|
|
)
|
|
|
|
if initial_schema is not None:
|
|
after_schema = await db.execute_fn(
|
|
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
|
)
|
|
if initial_schema != after_schema:
|
|
await self.ds.track_event(
|
|
AlterTableEvent(
|
|
request.actor,
|
|
database=database_name,
|
|
table=table_name,
|
|
before_schema=initial_schema,
|
|
after_schema=after_schema,
|
|
)
|
|
)
|
|
|
|
return Response.json(
|
|
result,
|
|
status=200 if upsert else 201,
|
|
default=CustomJSONEncoder().default,
|
|
)
|
|
|
|
|
|
class TableUpsertView(TableInsertView):
|
|
name = "table-upsert"
|
|
|
|
async def post(self, request):
|
|
return await super().post(request, upsert=True)
|
|
|
|
|
|
class TableSetColumnTypeView(BaseView):
|
|
name = "table-set-column-type"
|
|
|
|
def __init__(self, datasette):
|
|
self.ds = datasette
|
|
|
|
async def post(self, request):
|
|
try:
|
|
resolved = await self.ds.resolve_table(request)
|
|
except NotFound as e:
|
|
return Response.error([e.args[0]], 404)
|
|
|
|
database_name = resolved.db.name
|
|
table_name = resolved.table
|
|
|
|
if not await self.ds.allowed(
|
|
action="set-column-type",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
return Response.error(["Permission denied"], 403)
|
|
|
|
try:
|
|
data = await request.json()
|
|
except json.JSONDecodeError as e:
|
|
return Response.error([f"Invalid JSON: {e}"], 400)
|
|
except PayloadTooLarge as e:
|
|
return Response.error([str(e)], 413)
|
|
|
|
if not isinstance(data, dict):
|
|
return Response.error(["JSON must be a dictionary"], 400)
|
|
|
|
invalid_keys = set(data.keys()) - {"column", "column_type"}
|
|
if invalid_keys:
|
|
return Response.error(
|
|
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
|
|
400,
|
|
)
|
|
|
|
if "column" not in data:
|
|
return Response.error(['"column" is required'], 400)
|
|
column = data["column"]
|
|
if not isinstance(column, str):
|
|
return Response.error(['"column" must be a string'], 400)
|
|
|
|
if "column_type" not in data:
|
|
return Response.error(['"column_type" is required'], 400)
|
|
|
|
column_details = await self.ds._get_resource_column_details(
|
|
database_name, table_name
|
|
)
|
|
if column not in column_details:
|
|
return Response.error([f"Column not found: {column}"], 400)
|
|
|
|
column_type_data = data["column_type"]
|
|
if column_type_data is None:
|
|
await self.ds.remove_column_type(database_name, table_name, column)
|
|
return Response.json(
|
|
{
|
|
"ok": True,
|
|
"database": database_name,
|
|
"table": table_name,
|
|
"column": column,
|
|
"column_type": None,
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
if not isinstance(column_type_data, dict):
|
|
return Response.error(['"column_type" must be an object or null'], 400)
|
|
|
|
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
|
|
if invalid_column_type_keys:
|
|
return Response.error(
|
|
[
|
|
'Invalid column_type parameter: "{}"'.format(
|
|
'", "'.join(sorted(invalid_column_type_keys))
|
|
)
|
|
],
|
|
400,
|
|
)
|
|
|
|
if "type" not in column_type_data:
|
|
return Response.error(['"column_type.type" is required'], 400)
|
|
column_type = column_type_data["type"]
|
|
if not isinstance(column_type, str):
|
|
return Response.error(['"column_type.type" must be a string'], 400)
|
|
|
|
config = column_type_data.get("config")
|
|
if config is not None and not isinstance(config, dict):
|
|
return Response.error(['"column_type.config" must be a dictionary'], 400)
|
|
|
|
if column_type not in self.ds._column_types:
|
|
return Response.error([f"Unknown column type: {column_type}"], 400)
|
|
|
|
try:
|
|
await self.ds.set_column_type(
|
|
database_name, table_name, column, column_type, config
|
|
)
|
|
except ValueError as e:
|
|
return Response.error([str(e)], 400)
|
|
|
|
return Response.json(
|
|
{
|
|
"ok": True,
|
|
"database": database_name,
|
|
"table": table_name,
|
|
"column": column,
|
|
"column_type": {"type": column_type, "config": config},
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
|
|
class TableDropView(BaseView):
|
|
name = "table-drop"
|
|
|
|
def __init__(self, datasette):
|
|
self.ds = datasette
|
|
|
|
async def post(self, request):
|
|
try:
|
|
resolved = await self.ds.resolve_table(request)
|
|
except NotFound as e:
|
|
return Response.error([e.args[0]], 404)
|
|
db = resolved.db
|
|
database_name = db.name
|
|
table_name = resolved.table
|
|
# Table must exist
|
|
db = self.ds.get_database(database_name)
|
|
if not await db.table_exists(table_name):
|
|
return Response.error([f"Table not found: {table_name}"], 404)
|
|
if not await self.ds.allowed(
|
|
action="drop-table",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
actor=request.actor,
|
|
):
|
|
return Response.error(["Permission denied"], 403)
|
|
if not db.is_mutable:
|
|
return Response.error(["Database is immutable"], 403)
|
|
confirm = False
|
|
try:
|
|
data = await request.json()
|
|
confirm = data.get("confirm")
|
|
except json.JSONDecodeError:
|
|
pass
|
|
except PayloadTooLarge as e:
|
|
return Response.error([str(e)], 413)
|
|
|
|
if not confirm:
|
|
return Response.json(
|
|
{
|
|
"ok": True,
|
|
"database": database_name,
|
|
"table": table_name,
|
|
"row_count": (
|
|
await db.execute(
|
|
f"select count(*) from {escape_sqlite(table_name)}",
|
|
table=table_name,
|
|
)
|
|
).single_value(),
|
|
"message": 'Pass "confirm": true to confirm',
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
# Drop table
|
|
def drop_table(conn):
|
|
table = sqlite_utils.Database(conn)[table_name]
|
|
table.disable_fts()
|
|
table.drop()
|
|
|
|
await db.execute_write_fn(drop_table, request=request)
|
|
await self.ds.track_event(
|
|
DropTableEvent(
|
|
actor=request.actor, database=database_name, table=table_name
|
|
)
|
|
)
|
|
self.ds.add_message(
|
|
request,
|
|
f"Table {table_name} dropped",
|
|
self.ds.WARNING,
|
|
)
|
|
return Response.json({"ok": True}, status=200)
|
|
|
|
|
|
class TableCountView(BaseView):
|
|
name = "table-count"
|
|
|
|
async def post(self, request):
|
|
try:
|
|
return await self.count(request)
|
|
except (NotFound, Forbidden, BadRequest, DatasetteError) as ex:
|
|
return Response.error(str(ex), status=ex.status)
|
|
|
|
async def count(self, request):
|
|
resolved = await self.ds.resolve_table(request)
|
|
visible, _private = await self.ds.check_visibility(
|
|
request.actor,
|
|
action="view-table",
|
|
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
|
)
|
|
if not visible:
|
|
raise Forbidden("You do not have permission to view this table")
|
|
_, where_clauses, params, _, _ = await _table_filters(
|
|
self.ds, request, resolved.db.name, resolved.table
|
|
)
|
|
sql = f"select count(*) from {escape_sqlite(resolved.table)}"
|
|
if where_clauses:
|
|
sql += " where " + " and ".join(where_clauses)
|
|
try:
|
|
results = await resolved.db.execute(sql, params)
|
|
except QueryInterrupted:
|
|
return Response.error("Count query timed out", status=400)
|
|
except (sqlite3.OperationalError, InvalidSql) as ex:
|
|
return Response.error(str(ex), status=400)
|
|
return Response.json(
|
|
{"ok": True, "count": results.single_value()},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
class TableFragmentView(BaseView):
|
|
name = "table-fragment"
|
|
|
|
def __init__(self, datasette):
|
|
self.ds = datasette
|
|
|
|
async def get(self, request):
|
|
resolved = await self.ds.resolve_table(request)
|
|
request = await _fragment_request_for_row(request, resolved)
|
|
view_data = await table_view_data(
|
|
self.ds,
|
|
request,
|
|
resolved,
|
|
extra_extras={"_html"},
|
|
context_for_html_hack=True,
|
|
default_labels=True,
|
|
)
|
|
if isinstance(view_data, Response):
|
|
return view_data
|
|
data, _rows, _columns, _expanded_columns, _sql, _next_url = view_data
|
|
templates = data["custom_table_templates"]
|
|
html = await self.ds.render_template(
|
|
templates,
|
|
dict(
|
|
data,
|
|
append_querystring=append_querystring,
|
|
path_with_replaced_args=path_with_replaced_args,
|
|
fix_path=self.ds.urls.path,
|
|
settings=self.ds.settings_dict(),
|
|
),
|
|
request=request,
|
|
view_name="table",
|
|
)
|
|
return Response.html(html)
|
|
|
|
|
|
def _escape_like(value):
|
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
# Returns the exclusive upper bound for an indexed prefix search:
|
|
# For example, values beginning with "abc" fall below the next prefix boundary.
|
|
# The LIKE clause is still applied separately for exact escaped-LIKE semantics.
|
|
def _prefix_range_end(value):
|
|
if not value:
|
|
return None
|
|
characters = list(value)
|
|
for i in range(len(characters) - 1, -1, -1):
|
|
if ord(characters[i]) < 0x10FFFF:
|
|
return "{}{}".format("".join(characters[:i]), chr(ord(characters[i]) + 1))
|
|
return None
|
|
|
|
|
|
def _autocomplete_like(column):
|
|
return f"{escape_sqlite(column)} like :like escape char(92)"
|
|
|
|
|
|
def _autocomplete_prefix_like(column):
|
|
return f"{escape_sqlite(column)} like :prefix escape char(92)"
|
|
|
|
|
|
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
|
|
clauses = []
|
|
if exact_pk:
|
|
clauses.append(
|
|
f"case when cast({escape_sqlite(pks[0])} as text) = :q then 0 else 1 end"
|
|
)
|
|
if label_column:
|
|
label_like = _autocomplete_like(label_column)
|
|
if label_matches_first:
|
|
clauses.append(f"case when {label_like} then 0 else 1 end")
|
|
clauses.append(
|
|
f"case when {label_like} then length(cast({escape_sqlite(label_column)} as text)) end"
|
|
)
|
|
else:
|
|
clauses.append(f"length(cast({escape_sqlite(pks[0])} as text))")
|
|
clauses.extend(escape_sqlite(pk) for pk in pks)
|
|
return ", ".join(clauses)
|
|
|
|
|
|
def _autocomplete_pk_order_by(pks):
|
|
return ", ".join(escape_sqlite(pk) for pk in pks)
|
|
|
|
|
|
def _autocomplete_initial_order_by(pks):
|
|
order_by = [f"{escape_sqlite(pks[0])} desc"]
|
|
order_by.extend(escape_sqlite(pk) for pk in pks[1:])
|
|
return ", ".join(order_by)
|
|
|
|
|
|
def _autocomplete_response_rows(rows, pks, label_column):
|
|
response_rows = []
|
|
for row in rows:
|
|
item = {"pks": {pk: row[pk] for pk in pks}}
|
|
if label_column:
|
|
item["label"] = row[label_column]
|
|
response_rows.append(item)
|
|
return response_rows
|
|
|
|
|
|
AUTOCOMPLETE_TIME_LIMIT_MS = 500
|
|
|
|
|
|
class TableAutocompleteView(BaseView):
|
|
name = "table-autocomplete"
|
|
|
|
async def get(self, request):
|
|
resolved = await self.ds.resolve_table(request)
|
|
if resolved.is_view:
|
|
raise BadRequest("Autocomplete is only available for tables")
|
|
|
|
db = resolved.db
|
|
database_name = db.name
|
|
table_name = resolved.table
|
|
visible, _ = await self.ds.check_visibility(
|
|
request.actor,
|
|
action="view-table",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
)
|
|
if not visible:
|
|
raise Forbidden("You do not have permission to view this table")
|
|
|
|
pks = await db.primary_keys(table_name)
|
|
if not pks:
|
|
pks = ["rowid"]
|
|
label_column = await db.label_column_for_table(table_name)
|
|
select_columns = list(
|
|
dict.fromkeys(pks + ([label_column] if label_column else []))
|
|
)
|
|
select_sql = ", ".join(escape_sqlite(column) for column in select_columns)
|
|
q = request.args.get("q") or ""
|
|
initial_arg = request.args.get("_initial")
|
|
initial = (
|
|
not q
|
|
and initial_arg is not None
|
|
and initial_arg != ""
|
|
and value_as_boolean(initial_arg)
|
|
)
|
|
if not q and not initial:
|
|
return Response.json({"ok": True, "rows": []})
|
|
params = {
|
|
"q": q,
|
|
"like": f"%{_escape_like(q)}%",
|
|
"prefix": f"{_escape_like(q)}%",
|
|
}
|
|
|
|
like_columns = pks[:]
|
|
if label_column and label_column not in like_columns:
|
|
like_columns.append(label_column)
|
|
where_sql = " or ".join(_autocomplete_like(column) for column in like_columns)
|
|
exact_pk = len(pks) == 1
|
|
order_by = _autocomplete_order_by(pks, label_column, exact_pk)
|
|
|
|
if initial:
|
|
where_sql = "1 = 1"
|
|
order_by = _autocomplete_initial_order_by(pks)
|
|
|
|
sql = f"""
|
|
select {select_sql}
|
|
from {escape_sqlite(table_name)}
|
|
where {where_sql}
|
|
order by {order_by}
|
|
limit 10
|
|
"""
|
|
|
|
try:
|
|
results = await db.execute(
|
|
sql,
|
|
params,
|
|
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
|
|
table=table_name,
|
|
)
|
|
except QueryInterrupted:
|
|
fallback_where = _autocomplete_prefix_like(pks[0])
|
|
prefix_end = _prefix_range_end(q)
|
|
if prefix_end:
|
|
params["prefix_end"] = prefix_end
|
|
first_pk = escape_sqlite(pks[0])
|
|
fallback_where = f"{first_pk} >= :q and {first_pk} < :prefix_end and {fallback_where}"
|
|
fallback_sql = f"""
|
|
select {select_sql}
|
|
from {escape_sqlite(table_name)}
|
|
where {fallback_where}
|
|
order by {_autocomplete_pk_order_by(pks)}
|
|
limit 10
|
|
"""
|
|
try:
|
|
results = await db.execute(
|
|
fallback_sql,
|
|
params,
|
|
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
|
|
table=table_name,
|
|
)
|
|
except QueryInterrupted:
|
|
return Response.json({"ok": True, "rows": []})
|
|
|
|
return Response.json(
|
|
{
|
|
"ok": True,
|
|
"rows": _autocomplete_response_rows(results.rows, pks, label_column),
|
|
}
|
|
)
|
|
|
|
|
|
async def _columns_to_select(table_columns, pks, request):
|
|
columns = list(table_columns)
|
|
if "_col" in request.args:
|
|
columns = list(pks)
|
|
_cols = request.args.getlist("_col")
|
|
bad_columns = [column for column in _cols if column not in table_columns]
|
|
if bad_columns:
|
|
raise DatasetteError(
|
|
"_col={} - invalid columns".format(", ".join(bad_columns)),
|
|
status=400,
|
|
)
|
|
# De-duplicate maintaining order, skipping columns already added (pks):
|
|
columns.extend(c for c in dict.fromkeys(_cols) if c not in columns)
|
|
if "_nocol" in request.args:
|
|
# Return all columns EXCEPT these
|
|
bad_columns = [
|
|
column
|
|
for column in request.args.getlist("_nocol")
|
|
if (column not in table_columns) or (column in pks)
|
|
]
|
|
if bad_columns:
|
|
raise DatasetteError(
|
|
"_nocol={} - invalid columns".format(", ".join(bad_columns)),
|
|
status=400,
|
|
)
|
|
tmp_columns = [
|
|
column for column in columns if column not in request.args.getlist("_nocol")
|
|
]
|
|
columns = tmp_columns
|
|
return columns
|
|
|
|
|
|
async def _sortable_columns_for_table(datasette, database_name, table_name, use_rowid):
|
|
db = datasette.databases[database_name]
|
|
table_metadata = await datasette.table_config(database_name, table_name)
|
|
if "sortable_columns" in table_metadata:
|
|
sortable_columns = set(table_metadata["sortable_columns"])
|
|
else:
|
|
sortable_columns = set(await db.table_columns(table_name))
|
|
if use_rowid:
|
|
sortable_columns.add("rowid")
|
|
return sortable_columns
|
|
|
|
|
|
async def _sort_order(table_metadata, sortable_columns, request, order_by):
|
|
sort = request.args.get("_sort")
|
|
sort_desc = request.args.get("_sort_desc")
|
|
|
|
if not sort and not sort_desc:
|
|
sort = table_metadata.get("sort")
|
|
sort_desc = table_metadata.get("sort_desc")
|
|
|
|
if sort and sort_desc:
|
|
raise DatasetteError(
|
|
"Cannot use _sort and _sort_desc at the same time", status=400
|
|
)
|
|
|
|
if sort:
|
|
if sort not in sortable_columns:
|
|
raise DatasetteError(f"Cannot sort table by {sort}", status=400)
|
|
|
|
order_by = escape_sqlite(sort)
|
|
|
|
if sort_desc:
|
|
if sort_desc not in sortable_columns:
|
|
raise DatasetteError(f"Cannot sort table by {sort_desc}", status=400)
|
|
|
|
order_by = f"{escape_sqlite(sort_desc)} desc"
|
|
|
|
return sort, sort_desc, order_by
|
|
|
|
|
|
async def table_view(datasette, request):
|
|
await datasette.refresh_schemas()
|
|
with tracer.trace_child_tasks():
|
|
response = await table_view_traced(datasette, request)
|
|
|
|
# CORS
|
|
if datasette.cors:
|
|
add_cors_headers(response.headers)
|
|
|
|
# Cache TTL header
|
|
ttl = request.args.get("_ttl", None)
|
|
if ttl is None or not ttl.isdigit():
|
|
ttl = datasette.setting("default_cache_ttl")
|
|
|
|
private = getattr(request, "_datasette_private_response", False)
|
|
|
|
if datasette.cache_headers and response.status == 200:
|
|
if private:
|
|
# This response is only visible to the current actor (denied to
|
|
# anonymous requests), so it must never be stored by a shared
|
|
# cache/CDN - and ?_ttl= must not be able to override that.
|
|
response.headers["Cache-Control"] = "private, no-store"
|
|
response.headers["Vary"] = "Cookie"
|
|
else:
|
|
ttl = int(ttl)
|
|
if ttl == 0:
|
|
ttl_header = "no-cache"
|
|
else:
|
|
ttl_header = f"max-age={ttl}"
|
|
response.headers["Cache-Control"] = ttl_header
|
|
|
|
# Referrer policy
|
|
response.headers["Referrer-Policy"] = "no-referrer"
|
|
|
|
return response
|
|
|
|
|
|
async def table_view_traced(datasette, request):
|
|
from datasette.app import TableNotFound
|
|
|
|
try:
|
|
resolved = await datasette.resolve_table(request)
|
|
except TableNotFound as not_found:
|
|
# Was this actually a stored query?
|
|
stored_query = await datasette.get_query(
|
|
not_found.database_name, not_found.table
|
|
)
|
|
# If this is a stored query, not a table, then dispatch to QueryView instead
|
|
if stored_query:
|
|
return await QueryView()(request, datasette)
|
|
else:
|
|
raise
|
|
|
|
if request.method == "POST":
|
|
return Response.text("Method not allowed", status=405)
|
|
|
|
format_ = request.url_vars.get("format") or "html"
|
|
extra_extras = None
|
|
context_for_html_hack = False
|
|
default_labels = False
|
|
if format_ == "html":
|
|
extra_extras = {"_html"}
|
|
context_for_html_hack = True
|
|
default_labels = True
|
|
|
|
start = time.perf_counter()
|
|
view_data = await table_view_data(
|
|
datasette,
|
|
request,
|
|
resolved,
|
|
extra_extras=extra_extras,
|
|
context_for_html_hack=context_for_html_hack,
|
|
default_labels=default_labels,
|
|
)
|
|
if isinstance(view_data, Response):
|
|
return view_data
|
|
query_ms = (time.perf_counter() - start) * 1000
|
|
data, rows, columns, _expanded_columns, sql, next_url = view_data
|
|
|
|
# Handle formats from plugins
|
|
if format_ == "csv":
|
|
|
|
async def fetch_data(request, _next=None):
|
|
(
|
|
data,
|
|
rows,
|
|
columns,
|
|
expanded_columns,
|
|
_sql,
|
|
_next_url,
|
|
) = await table_view_data(
|
|
datasette,
|
|
request,
|
|
resolved,
|
|
extra_extras=extra_extras,
|
|
context_for_html_hack=context_for_html_hack,
|
|
default_labels=default_labels,
|
|
_next=_next,
|
|
)
|
|
data["rows"] = rows
|
|
data["table"] = resolved.table
|
|
data["columns"] = columns
|
|
data["expanded_columns"] = expanded_columns
|
|
return data, None, None
|
|
|
|
return await stream_csv(datasette, fetch_data, request, resolved.db.name)
|
|
elif format_ in datasette.renderers:
|
|
# Dispatch request to the correct output format renderer
|
|
# (CSV is not handled here due to streaming)
|
|
result = call_with_supported_arguments(
|
|
datasette.renderers[format_][0],
|
|
datasette=datasette,
|
|
columns=columns,
|
|
rows=rows,
|
|
sql=sql,
|
|
query_name=None,
|
|
database=resolved.db.name,
|
|
table=resolved.table,
|
|
request=request,
|
|
view_name="table",
|
|
truncated=False,
|
|
error=None,
|
|
# These will be deprecated in Datasette 1.0:
|
|
args=request.args,
|
|
data=data,
|
|
)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
if result is None:
|
|
raise NotFound("No data")
|
|
if isinstance(result, dict):
|
|
r = Response(
|
|
body=result.get("body"),
|
|
status=result.get("status_code") or 200,
|
|
content_type=result.get("content_type", "text/plain"),
|
|
headers=result.get("headers"),
|
|
)
|
|
elif isinstance(result, Response):
|
|
r = result
|
|
# if status_code is not None:
|
|
# # Over-ride the status code
|
|
# r.status = status_code
|
|
else:
|
|
assert False, f"{result} should be dict or Response"
|
|
elif format_ == "html":
|
|
headers = {}
|
|
templates = [
|
|
f"table-{to_css_class(resolved.db.name)}-{to_css_class(resolved.table)}.html",
|
|
"table.html",
|
|
]
|
|
environment = datasette.get_jinja_environment(request)
|
|
template = environment.select_template(templates)
|
|
alternate_url_json = datasette.absolute_url(
|
|
request,
|
|
datasette.urls.path(
|
|
path_with_format(
|
|
request=request,
|
|
path=request.scope.get("route_path"),
|
|
format="json",
|
|
)
|
|
),
|
|
)
|
|
headers.update(
|
|
{
|
|
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
|
}
|
|
)
|
|
table_context = TableContext(
|
|
actions=data["actions"],
|
|
all_columns=data["all_columns"],
|
|
columns=data["columns"],
|
|
count=data["count"],
|
|
count_sql=data["count_sql"],
|
|
custom_table_templates=data["custom_table_templates"],
|
|
database=data["database"],
|
|
database_color=data["database_color"],
|
|
display_columns=data["display_columns"],
|
|
display_rows=data["display_rows"],
|
|
expandable_columns=data["expandable_columns"],
|
|
facet_results=data["facet_results"],
|
|
facets_timed_out=data["facets_timed_out"],
|
|
filters=data["filters"],
|
|
form_hidden_args=data["form_hidden_args"],
|
|
human_description_en=data["human_description_en"],
|
|
is_view=data["is_view"],
|
|
metadata=data["metadata"],
|
|
next_url=data["next_url"],
|
|
primary_keys=data["primary_keys"],
|
|
private=data["private"],
|
|
query=data["query"],
|
|
renderers=data["renderers"],
|
|
set_column_type_ui=data["set_column_type_ui"],
|
|
sorted_facet_results=data["sorted_facet_results"],
|
|
suggested_facets=data["suggested_facets"],
|
|
table=data["table"],
|
|
table_definition=data["table_definition"],
|
|
view_definition=data["view_definition"],
|
|
ok=data["ok"],
|
|
next=data["next"],
|
|
count_truncated=data["count_truncated"],
|
|
rows=data["rows"],
|
|
filter_columns=data["filter_columns"],
|
|
supports_search=data["supports_search"],
|
|
extra_wheres_for_ui=data["extra_wheres_for_ui"],
|
|
url_csv=data["url_csv"],
|
|
url_csv_path=data["url_csv_path"],
|
|
url_csv_hidden_args=data["url_csv_hidden_args"],
|
|
sort=data["sort"],
|
|
sort_desc=data["sort_desc"],
|
|
append_querystring=append_querystring,
|
|
path_with_replaced_args=path_with_replaced_args,
|
|
fix_path=datasette.urls.path,
|
|
settings=datasette.settings_dict(),
|
|
alternate_url_json=alternate_url_json,
|
|
datasette_allow_facet=(
|
|
"true" if datasette.setting("allow_facet") else "false"
|
|
),
|
|
is_sortable=any(c["sortable"] for c in data["display_columns"]),
|
|
allow_execute_sql=await datasette.allowed(
|
|
action="execute-sql",
|
|
resource=DatabaseResource(database=resolved.db.name),
|
|
actor=request.actor,
|
|
),
|
|
query_ms=query_ms,
|
|
select_templates=[
|
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
|
for template_name in templates
|
|
],
|
|
top_table=make_slot_function(
|
|
"top_table",
|
|
datasette,
|
|
request,
|
|
database=resolved.db.name,
|
|
table=resolved.table,
|
|
),
|
|
table_page_data=data["table_page_data"],
|
|
table_insert_ui=data["table_insert_ui"],
|
|
table_alter_ui=data["table_alter_ui"],
|
|
)
|
|
r = Response.html(
|
|
await datasette.render_template(
|
|
template,
|
|
table_context,
|
|
request=request,
|
|
view_name="table",
|
|
),
|
|
headers=headers,
|
|
)
|
|
else:
|
|
assert False, f"Invalid format: {format_}"
|
|
if next_url:
|
|
r.headers["link"] = f'<{next_url}>; rel="next"'
|
|
return r
|
|
|
|
|
|
async def _table_filters(datasette, request, database_name, table_name):
|
|
# Arguments that start with _ and don't contain a __ are
|
|
# special - things like ?_search= - and should not be
|
|
# treated as filters.
|
|
filter_args = []
|
|
for key in request.args:
|
|
if not (key.startswith("_") and "__" not in key):
|
|
for v in request.args.getlist(key):
|
|
filter_args.append((key, v))
|
|
|
|
# Build where clauses from query string arguments
|
|
filters = Filters(sorted(filter_args))
|
|
where_clauses, params = filters.build_where_clauses(table_name)
|
|
|
|
# Execute filters_from_request plugin hooks - including the default
|
|
# ones that live in datasette/filters.py
|
|
extra_context_from_filters = {}
|
|
extra_human_descriptions = []
|
|
|
|
for hook in pm.hook.filters_from_request(
|
|
request=request,
|
|
table=table_name,
|
|
database=database_name,
|
|
datasette=datasette,
|
|
):
|
|
filter_arguments = await await_me_maybe(hook)
|
|
if filter_arguments:
|
|
where_clauses.extend(filter_arguments.where_clauses)
|
|
params.update(filter_arguments.params)
|
|
extra_human_descriptions.extend(filter_arguments.human_descriptions)
|
|
extra_context_from_filters.update(filter_arguments.extra_context)
|
|
|
|
return (
|
|
filters,
|
|
where_clauses,
|
|
params,
|
|
extra_human_descriptions,
|
|
extra_context_from_filters,
|
|
)
|
|
|
|
|
|
async def table_view_data(
|
|
datasette,
|
|
request,
|
|
resolved,
|
|
extra_extras=None,
|
|
context_for_html_hack=False,
|
|
default_labels=False,
|
|
_next=None,
|
|
):
|
|
extra_extras = extra_extras or set()
|
|
# We have a table or view
|
|
db = resolved.db
|
|
database_name = resolved.db.name
|
|
table_name = resolved.table
|
|
is_view = resolved.is_view
|
|
|
|
# Can this user view it?
|
|
visible, private = await datasette.check_visibility(
|
|
request.actor,
|
|
action="view-table",
|
|
resource=TableResource(database=database_name, table=table_name),
|
|
)
|
|
if not visible:
|
|
raise Forbidden("You do not have permission to view this table")
|
|
# Record whether this response is private (visible to this actor only)
|
|
# so the outer table_view() can set appropriate Cache-Control headers,
|
|
# regardless of which output format ends up being rendered.
|
|
request._datasette_private_response = private
|
|
|
|
# Redirect based on request.args, if necessary
|
|
redirect_response = await _redirect_if_needed(datasette, request, resolved)
|
|
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)
|
|
|
|
# Take ?_col= and ?_nocol= into account
|
|
specified_columns = await _columns_to_select(table_columns, pks, request)
|
|
select_specified_columns = ", ".join(escape_sqlite(t) for t in specified_columns)
|
|
select_all_columns = ", ".join(escape_sqlite(t) for t in table_columns)
|
|
|
|
# rowid tables (no specified primary key) need a different SELECT
|
|
use_rowid = not pks and not is_view
|
|
order_by = ""
|
|
if use_rowid:
|
|
select_specified_columns = f"rowid, {select_specified_columns}"
|
|
select_all_columns = f"rowid, {select_all_columns}"
|
|
order_by = "rowid"
|
|
order_by_pks = "rowid"
|
|
else:
|
|
order_by_pks = ", ".join([escape_sqlite(pk) for pk in pks])
|
|
order_by = order_by_pks
|
|
|
|
if is_view:
|
|
order_by = ""
|
|
|
|
# TODO: This logic should turn into logic about which ?_extras get
|
|
# executed instead:
|
|
nocount = request.args.get("_nocount")
|
|
nofacet = request.args.get("_nofacet")
|
|
nosuggest = request.args.get("_nosuggest")
|
|
if request.args.get("_shape") in ("array", "object"):
|
|
nocount = True
|
|
nofacet = True
|
|
|
|
table_metadata = await datasette.table_config(database_name, table_name)
|
|
|
|
(
|
|
filters,
|
|
where_clauses,
|
|
params,
|
|
extra_human_descriptions,
|
|
extra_context_from_filters,
|
|
) = await _table_filters(datasette, request, database_name, table_name)
|
|
|
|
# Deal with custom sort orders
|
|
sortable_columns = await _sortable_columns_for_table(
|
|
datasette, database_name, table_name, use_rowid
|
|
)
|
|
|
|
sort, sort_desc, order_by = await _sort_order(
|
|
table_metadata, sortable_columns, request, order_by
|
|
)
|
|
|
|
from_sql = "from {table_name} {where}".format(
|
|
table_name=escape_sqlite(table_name),
|
|
where=(
|
|
("where {} ".format(" and ".join(where_clauses))) if where_clauses else ""
|
|
),
|
|
)
|
|
# Copy of params so we can mutate them later:
|
|
from_sql_params = dict(**params)
|
|
|
|
count_sql = f"select count(*) {from_sql}"
|
|
|
|
# Handle pagination driven by ?_next=
|
|
_next = _next or request.args.get("_next")
|
|
|
|
offset = ""
|
|
if _next:
|
|
sort_value = None
|
|
if is_view:
|
|
# _next is an offset
|
|
offset = f" offset {int(_next)}"
|
|
else:
|
|
components = urlsafe_components(_next)
|
|
# If a sort order is applied and there are multiple components,
|
|
# the first of these is the sort value
|
|
if (sort or sort_desc) and (len(components) > 1):
|
|
sort_value = components[0]
|
|
# Special case for if non-urlencoded first token was $null
|
|
if _next.split(",")[0] == "$null":
|
|
sort_value = None
|
|
components = components[1:]
|
|
|
|
# Figure out the SQL for next-based-on-primary-key first
|
|
next_by_pk_clauses = []
|
|
if use_rowid:
|
|
next_by_pk_clauses.append(f"rowid > :p{len(params)}")
|
|
params[f"p{len(params)}"] = components[0]
|
|
else:
|
|
# Apply the tie-breaker based on primary keys
|
|
if len(components) == len(pks):
|
|
param_len = len(params)
|
|
next_by_pk_clauses.append(compound_keys_after_sql(pks, param_len))
|
|
for i, pk_value in enumerate(components):
|
|
params[f"p{param_len + i}"] = pk_value
|
|
|
|
# Now add the sort SQL, which may incorporate next_by_pk_clauses
|
|
if sort or sort_desc:
|
|
if sort_value is None:
|
|
if sort_desc:
|
|
# Just items where column is null ordered by pk
|
|
where_clauses.append(
|
|
"({column} is null and {next_clauses})".format(
|
|
column=escape_sqlite(sort_desc),
|
|
next_clauses=" and ".join(next_by_pk_clauses),
|
|
)
|
|
)
|
|
else:
|
|
where_clauses.append(
|
|
"({column} is not null or ({column} is null and {next_clauses}))".format(
|
|
column=escape_sqlite(sort),
|
|
next_clauses=" and ".join(next_by_pk_clauses),
|
|
)
|
|
)
|
|
else:
|
|
where_clauses.append(
|
|
"({column} {op} :p{p}{extra_desc_only} or ({column} = :p{p} and {next_clauses}))".format(
|
|
column=escape_sqlite(sort or sort_desc),
|
|
op=">" if sort else "<",
|
|
p=len(params),
|
|
extra_desc_only=(
|
|
""
|
|
if sort
|
|
else f" or {escape_sqlite(sort or sort_desc)} is null"
|
|
),
|
|
next_clauses=" and ".join(next_by_pk_clauses),
|
|
)
|
|
)
|
|
params[f"p{len(params)}"] = sort_value
|
|
order_by = f"{order_by}, {order_by_pks}"
|
|
else:
|
|
where_clauses.extend(next_by_pk_clauses)
|
|
|
|
where_clause = ""
|
|
if where_clauses:
|
|
where_clause = f"where {' and '.join(where_clauses)} "
|
|
|
|
if order_by:
|
|
order_by = f"order by {order_by}"
|
|
|
|
extra_args = {}
|
|
# Handle ?_size=500
|
|
# TODO: This was:
|
|
# page_size = _size or request.args.get("_size") or table_metadata.get("size")
|
|
page_size = request.args.get("_size") or table_metadata.get("size")
|
|
if page_size:
|
|
if page_size == "max":
|
|
page_size = datasette.max_returned_rows
|
|
try:
|
|
page_size = int(page_size)
|
|
if page_size < 0:
|
|
raise ValueError
|
|
|
|
except ValueError:
|
|
raise BadRequest("_size must be a positive integer")
|
|
|
|
if page_size > datasette.max_returned_rows:
|
|
raise BadRequest(f"_size must be <= {datasette.max_returned_rows}")
|
|
|
|
extra_args["page_size"] = page_size
|
|
else:
|
|
page_size = datasette.page_size
|
|
|
|
# Facets are calculated against SQL without order by or limit
|
|
sql_no_order_no_limit = (
|
|
f"select {select_all_columns} from {escape_sqlite(table_name)} {where_clause}"
|
|
)
|
|
|
|
# This is the SQL that populates the main table on the page
|
|
sql = f"select {select_specified_columns} from {escape_sqlite(table_name)} {where_clause}{order_by} limit {page_size + 1}{offset}"
|
|
|
|
if request.args.get("_timelimit"):
|
|
extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
|
|
|
|
# Execute the main query!
|
|
try:
|
|
results = await db.execute(
|
|
sql, params, truncate=True, table=table_name, **extra_args
|
|
)
|
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
|
|
|
columns = [r[0] for r in results.description]
|
|
rows = list(results.rows)
|
|
|
|
# Expand labeled columns if requested
|
|
expanded_columns = []
|
|
# List of (fk_dict, label_column-or-None) pairs for that table
|
|
expandable_columns = []
|
|
for fk in await db.foreign_keys_for_table(table_name):
|
|
label_column = await db.label_column_for_table(fk["other_table"])
|
|
expandable_columns.append((fk, label_column))
|
|
|
|
columns_to_expand = None
|
|
try:
|
|
all_labels = value_as_boolean(request.args.get("_labels", ""))
|
|
except ValueError:
|
|
all_labels = default_labels
|
|
# Check for explicit _label=
|
|
if "_label" in request.args:
|
|
columns_to_expand = request.args.getlist("_label")
|
|
if columns_to_expand is None and all_labels:
|
|
# expand all columns with foreign keys
|
|
columns_to_expand = [fk["column"] for fk, _ in expandable_columns]
|
|
|
|
if columns_to_expand:
|
|
expanded_labels = {}
|
|
for fk, _ in expandable_columns:
|
|
column = fk["column"]
|
|
if column not in columns_to_expand:
|
|
continue
|
|
if column not in columns:
|
|
continue
|
|
expanded_columns.append(column)
|
|
# Gather the values
|
|
column_index = columns.index(column)
|
|
values = [row[column_index] for row in rows]
|
|
# Expand them
|
|
expanded_labels.update(
|
|
await datasette.expand_foreign_keys(
|
|
request.actor, database_name, table_name, column, values
|
|
)
|
|
)
|
|
if expanded_labels:
|
|
# Rewrite the rows
|
|
new_rows = []
|
|
for row in rows:
|
|
new_row = CustomRow(columns)
|
|
# CustomRow/sqlite3.Row iterate over values, so .keys() is required
|
|
for column in row.keys(): # noqa: SIM118
|
|
value = row[column]
|
|
if (column, value) in expanded_labels and value is not None:
|
|
new_row[column] = {
|
|
"value": value,
|
|
"label": expanded_labels[(column, value)],
|
|
}
|
|
else:
|
|
new_row[column] = value
|
|
new_rows.append(new_row)
|
|
rows = new_rows
|
|
|
|
# Pagination next link
|
|
next_value, next_url = await _next_value_and_url(
|
|
datasette,
|
|
db,
|
|
request,
|
|
table_name,
|
|
_next,
|
|
rows,
|
|
pks,
|
|
use_rowid,
|
|
sort,
|
|
sort_desc,
|
|
page_size,
|
|
is_view,
|
|
)
|
|
rows = rows[:page_size]
|
|
|
|
# Resolve extras
|
|
extras = extra_names_from_request(request)
|
|
if not extra_extras:
|
|
# Data formats reject unknown extras; the HTML path (which passes
|
|
# extra_extras={"_html"}) resolves internal extras of its own
|
|
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
|
|
if any(k for k in request.args if k == "_facet" or k.startswith("_facet_")):
|
|
extras.add("facet_results")
|
|
if request.args.get("_shape") == "object":
|
|
extras.add("primary_keys")
|
|
if "count" in extras:
|
|
extras.add("count_truncated")
|
|
if extra_extras:
|
|
extras.update(extra_extras)
|
|
|
|
# Faceting
|
|
if not datasette.setting("allow_facet") and any(
|
|
arg.startswith("_facet") for arg in request.args
|
|
):
|
|
raise BadRequest("_facet= is not allowed")
|
|
|
|
for key, values in TABLE_EXTRA_BUNDLES.items():
|
|
if f"_{key}" in extras:
|
|
extras.update(values)
|
|
extras.discard(f"_{key}")
|
|
|
|
table_extra_context = TableExtraContext(
|
|
datasette=datasette,
|
|
request=request,
|
|
resolved=resolved,
|
|
db=db,
|
|
database_name=database_name,
|
|
table_name=table_name,
|
|
is_view=is_view,
|
|
private=private,
|
|
rows=rows,
|
|
columns=columns,
|
|
results_description=results.description,
|
|
table_columns=table_columns,
|
|
pks=pks,
|
|
count_sql=count_sql,
|
|
from_sql=from_sql,
|
|
from_sql_params=from_sql_params,
|
|
nocount=nocount,
|
|
nofacet=nofacet,
|
|
nosuggest=nosuggest,
|
|
next_arg=request.args.get("_next"),
|
|
next_url=next_url,
|
|
sql=sql,
|
|
sql_no_order_no_limit=sql_no_order_no_limit,
|
|
params=params,
|
|
table_metadata=table_metadata,
|
|
filters=filters,
|
|
extra_human_descriptions=extra_human_descriptions,
|
|
sort=sort,
|
|
sort_desc=sort_desc,
|
|
sortable_columns=sortable_columns,
|
|
extras=extras,
|
|
extra_registry=table_extra_registry,
|
|
display_columns_and_rows=display_columns_and_rows,
|
|
run_sequential=run_sequential,
|
|
)
|
|
|
|
data = {
|
|
"ok": True,
|
|
"next": next_value and str(next_value) or None,
|
|
"next_url": next_url,
|
|
}
|
|
data.update(
|
|
await resolve_table_extras(
|
|
extras,
|
|
table_extra_context,
|
|
# The HTML view needs extras that are not JSON serializable
|
|
include_internal=bool(extra_extras),
|
|
)
|
|
)
|
|
raw_sqlite_rows = rows[:page_size]
|
|
# Apply transform_value for columns with assigned types
|
|
ct_map = await datasette.get_column_types(database_name, table_name)
|
|
transformed_rows = []
|
|
for r in raw_sqlite_rows:
|
|
row_dict = dict(r)
|
|
for col_name, ct in ct_map.items():
|
|
if col_name in row_dict:
|
|
row_dict[col_name] = await ct.transform_value(
|
|
row_dict[col_name], datasette
|
|
)
|
|
transformed_rows.append(row_dict)
|
|
data["rows"] = transformed_rows
|
|
|
|
if context_for_html_hack:
|
|
data["count_truncated"] = count_is_truncated(
|
|
datasette, db, database_name, table_name, count_sql, data.get("count")
|
|
)
|
|
data.update(extra_context_from_filters)
|
|
# filter_columns combine the columns we know are available
|
|
# in the table with any additional columns (such as rowid)
|
|
# which are available in the query
|
|
data["filter_columns"] = list(columns) + [
|
|
table_column
|
|
for table_column in table_columns
|
|
if table_column not in columns
|
|
]
|
|
url_labels_extra = {}
|
|
if data.get("expandable_columns"):
|
|
url_labels_extra = {"_labels": "on"}
|
|
url_csv_args = {"_size": "max", **url_labels_extra}
|
|
url_csv = datasette.urls.path(
|
|
path_with_format(
|
|
request=request,
|
|
path=request.scope.get("route_path"),
|
|
format="csv",
|
|
extra_qs=url_csv_args,
|
|
)
|
|
)
|
|
url_csv_path = url_csv.split("?")[0]
|
|
data.update(
|
|
{
|
|
"url_csv": url_csv,
|
|
"url_csv_path": url_csv_path,
|
|
"url_csv_hidden_args": [
|
|
(key, value)
|
|
for key, value in urllib.parse.parse_qsl(request.query_string)
|
|
if key not in ("_labels", "_facet", "_size")
|
|
]
|
|
+ [("_size", "max")],
|
|
}
|
|
)
|
|
# if no sort specified AND table has a single primary key,
|
|
# set sort to that so arrow is displayed
|
|
if not sort and not sort_desc:
|
|
if 1 == len(pks):
|
|
sort = pks[0]
|
|
elif use_rowid:
|
|
sort = "rowid"
|
|
data["sort"] = sort
|
|
data["sort_desc"] = sort_desc
|
|
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=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
|
|
|
|
|
|
async def _next_value_and_url(
|
|
datasette,
|
|
db,
|
|
request,
|
|
table_name,
|
|
_next,
|
|
rows,
|
|
pks,
|
|
use_rowid,
|
|
sort,
|
|
sort_desc,
|
|
page_size,
|
|
is_view,
|
|
):
|
|
next_value = None
|
|
next_url = None
|
|
if 0 < page_size < len(rows):
|
|
if is_view:
|
|
next_value = int(_next or 0) + page_size
|
|
else:
|
|
next_value = path_from_row_pks(rows[-2], pks, use_rowid)
|
|
# If there's a sort or sort_desc, add that value as a prefix
|
|
if (sort or sort_desc) and not is_view:
|
|
try:
|
|
prefix = rows[-2][sort or sort_desc]
|
|
except IndexError:
|
|
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
|
prefix_where_clause = " and ".join(
|
|
f"{escape_sqlite(pk)} = :pk{i}" for i, pk in enumerate(pks)
|
|
)
|
|
prefix_lookup_sql = (
|
|
f"select {escape_sqlite(sort or sort_desc)} "
|
|
f"from {escape_sqlite(table_name)} where {prefix_where_clause}"
|
|
)
|
|
prefix = (
|
|
await db.execute(
|
|
prefix_lookup_sql,
|
|
{**{f"pk{i}": rows[-2][pk] for i, pk in enumerate(pks)}},
|
|
table=table_name,
|
|
)
|
|
).single_value()
|
|
if isinstance(prefix, dict) and "value" in prefix:
|
|
prefix = prefix["value"]
|
|
if prefix is None:
|
|
prefix = "$null"
|
|
else:
|
|
prefix = tilde_encode(str(prefix))
|
|
next_value = f"{prefix},{next_value}"
|
|
added_args = {"_next": next_value}
|
|
if sort:
|
|
added_args["_sort"] = sort
|
|
else:
|
|
added_args["_sort_desc"] = sort_desc
|
|
else:
|
|
added_args = {"_next": next_value}
|
|
next_url = datasette.absolute_url(
|
|
request, datasette.urls.path(path_with_replaced_args(request, added_args))
|
|
)
|
|
return next_value, next_url
|