Column types system

Closes #2664
This commit is contained in:
Simon Willison 2026-03-16 22:29:51 -07:00 • committed by GitHub
commit bc7a19b39d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1367 additions and 55 deletions

View file

@ -354,6 +354,7 @@ class Datasette:
self.immutables = set(immutables or [])
self.databases = collections.OrderedDict()
self.actions = {} # .invoke_startup() will populate this
self._column_types = {} # .invoke_startup() will populate this
try:
self._refresh_schemas_lock = asyncio.Lock()
except RuntimeError as rex:
@ -692,12 +693,23 @@ class Datasette:
action_abbrs[action.abbr] = action
self.actions[action.name] = action
# Register column types (classes, not instances)
self._column_types = {}
for hook in pm.hook.register_column_types(datasette=self):
if hook:
for ct_cls in hook:
if ct_cls.name in self._column_types:
raise StartupError(f"Duplicate column type name: {ct_cls.name}")
self._column_types[ct_cls.name] = ct_cls
for hook in pm.hook.prepare_jinja2_environment(
env=self._jinja_env, datasette=self
):
await await_me_maybe(hook)
# Ensure internal tables and metadata are populated before startup hooks
await self._refresh_schemas()
# Load column_types from config into internal DB
await self._apply_column_types_config()
for hook in pm.hook.startup(datasette=self):
await await_me_maybe(hook)
self._startup_invoked = True
@ -945,6 +957,102 @@ class Datasette:
[database_name, resource_name, column_name, key, value],
)
# Column types API
async def _apply_column_types_config(self):
"""Load column_types from datasette.json config into the internal DB."""
import logging
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
for table_name, table_conf in db_conf.get("tables", {}).items():
for col_name, ct in table_conf.get("column_types", {}).items():
if isinstance(ct, str):
col_type, config = ct, None
else:
col_type = ct["type"]
config = ct.get("config")
if col_type not in self._column_types:
logging.warning(
"column_types config references unknown type %r "
"for %s.%s.%s",
col_type,
db_name,
table_name,
col_name,
)
await self.set_column_type(
db_name, table_name, col_name, col_type, config
)
async def get_column_type(self, database: str, resource: str, column: str):
"""
Return a ColumnType instance (with config baked in) for a specific
column, or None if no column type is assigned.
"""
row = await self.get_internal_database().execute(
"SELECT column_type, config FROM column_types "
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
[database, resource, column],
)
rows = row.rows
if not rows:
return None
ct_name, config = rows[0]
ct_cls = self._column_types.get(ct_name)
if ct_cls is None:
return None
return ct_cls(config=json.loads(config) if config else None)
async def get_column_types(self, database: str, resource: str) -> dict:
"""
Return {column_name: ColumnType instance (with config)}
for all columns with assigned types on the given resource.
"""
rows = await self.get_internal_database().execute(
"SELECT column_name, column_type, config FROM column_types "
"WHERE database_name = ? AND resource_name = ?",
[database, resource],
)
result = {}
for row in rows.rows:
col_name, ct_name, config = row
ct_cls = self._column_types.get(ct_name)
if ct_cls is not None:
result[col_name] = ct_cls(config=json.loads(config) if config else None)
return result
async def set_column_type(
self,
database: str,
resource: str,
column: str,
column_type: str,
config: dict = None,
) -> None:
"""Assign a column type. Overwrites any existing assignment."""
await self.get_internal_database().execute_write(
"""INSERT OR REPLACE INTO column_types
(database_name, resource_name, column_name, column_type, config)
VALUES (?, ?, ?, ?, ?)""",
[
database,
resource,
column,
column_type,
json.dumps(config) if config else None,
],
)
async def remove_column_type(
self, database: str, resource: str, column: str
) -> None:
"""Remove a column type assignment."""
await self.get_internal_database().execute_write(
"DELETE FROM column_types "
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
[database, resource, column],
)
def get_internal_database(self):
return self._internal_database

44
datasette/column_types.py Normal file
View file

@ -0,0 +1,44 @@
class ColumnType:
"""
Base class for column types.
Subclasses must define ``name`` and ``description`` as class attributes:
- ``name``: Unique identifier string. Lowercase, no spaces.
Examples: "markdown", "file", "email", "url", "point", "image".
- ``description``: Human-readable label for admin UI dropdowns.
Examples: "Markdown text", "File reference", "Email address".
Instantiate with an optional ``config`` dict to bind per-column
configuration::
ct = MyColumnType(config={"key": "value"})
ct.config # {"key": "value"}
"""
name: str
description: str
def __init__(self, config=None):
self.config = config
async def render_cell(self, value, column, table, database, datasette, request):
"""
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
"""
return None
async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return None
async def transform_value(self, value, datasette):
"""
Transform a value before it appears in JSON API output.
Return the transformed value. Default: return unchanged.
"""
return value

View file

@ -0,0 +1,78 @@
import json
import re
import markupsafe
from datasette import hookimpl
from datasette.column_types import ColumnType
class UrlColumnType(ColumnType):
name = "url"
description = "URL"
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "URL must be a string"
if not re.match(r"^https?://\S+$", value.strip()):
return "Invalid URL"
return None
class EmailColumnType(ColumnType):
name = "email"
description = "Email address"
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="mailto:{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "Email must be a string"
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()):
return "Invalid email address"
return None
class JsonColumnType(ColumnType):
name = "json"
description = "JSON data"
async def render_cell(self, value, column, table, database, datasette, request):
if value is None:
return None
try:
parsed = json.loads(value) if isinstance(value, str) else value
formatted = json.dumps(parsed, indent=2)
escaped = markupsafe.escape(formatted)
return markupsafe.Markup(f"<pre>{escaped}</pre>")
except (json.JSONDecodeError, TypeError):
return None
async def validate(self, value, datasette):
if value is None or value == "":
return None
if isinstance(value, str):
try:
json.loads(value)
except json.JSONDecodeError:
return "Invalid JSON"
return None
@hookimpl
def register_column_types(datasette):
return [UrlColumnType, EmailColumnType, JsonColumnType]

View file

@ -55,7 +55,17 @@ def publish_subcommand(publish):
@hookspec
def render_cell(row, value, column, table, pks, database, datasette, request):
def render_cell(
row,
value,
column,
table,
pks,
database,
datasette,
request,
column_type,
):
"""Customize rendering of HTML table cell values"""
@ -74,6 +84,11 @@ def register_actions(datasette):
"""Register actions: returns a list of datasette.permission.Action objects"""
@hookspec
def register_column_types(datasette):
"""Return a list of ColumnType instances"""
@hookspec
def register_routes(datasette):
"""Register URL routes: return a list of (regex, view_function) pairs"""

View file

@ -25,6 +25,7 @@ DEFAULT_PLUGINS = (
"datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
"datasette.default_menu_links",

View file

@ -103,6 +103,15 @@ async def initialize_metadata_tables(db):
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
"""))

View file

@ -1205,6 +1205,7 @@ async def display_rows(datasette, database, request, rows, columns):
database=database,
datasette=datasette,
request=request,
column_type=None,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:

View file

@ -179,26 +179,41 @@ class RowView(DataView):
if "render_cell" in extras:
# Call render_cell plugin hook for each cell
ct_map = await self.ds.get_column_types(database, table)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
# Call render_cell plugin hook
ct = ct_map.get(column)
plugin_display_value = None
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
pks=resolved.pks,
database=database,
datasette=self.ds,
request=request,
):
candidate = await await_me_maybe(candidate)
# Try column type render_cell first
if ct:
candidate = await ct.render_cell(
value=value,
column=column,
table=table,
database=database,
datasette=self.ds,
request=request,
)
if candidate is not None:
plugin_display_value = candidate
break
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
pks=resolved.pks,
database=database,
datasette=self.ds,
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:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
@ -352,6 +367,15 @@ class RowUpdateView(BaseView):
update = data["update"]
# Validate column types
from datasette.views.table import _validate_column_types
ct_errors = await _validate_column_types(
self.ds, resolved.db.name, resolved.table, [update]
)
if ct_errors:
return _error(ct_errors, 400)
alter = data.get("alter")
if alter and not await self.ds.allowed(
action="alter-table",

View file

@ -134,6 +134,22 @@ async def _redirect_if_needed(datasette, request, resolved):
)
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
async def display_columns_and_rows(
datasette,
database_name,
@ -163,6 +179,9 @@ async def display_columns_and_rows(
)
)
# 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)
}
@ -179,16 +198,21 @@ async def display_columns_and_rows(
else:
type_ = column_details[r[0]].type
notnull = column_details[r[0]].notnull
columns.append(
{
"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]),
}
)
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"]
@ -227,23 +251,37 @@ async def display_columns_and_rows(
# already shown in the link column.
continue
# First let the plugins have a go
# First try column type render_cell, then plugins
# pylint: disable=no-member
plugin_display_value = 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,
):
candidate = await await_me_maybe(candidate)
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
break
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):
@ -484,6 +522,13 @@ class TableInsertView(BaseView):
if errors:
return _error(errors, 400)
# Validate column types
ct_errors = await _validate_column_types(
self.ds, database_name, table_name, rows
)
if ct_errors:
return _error(ct_errors, 400)
num_rows = len(rows)
# No that we've passed pks to _validate_data it's safe to
@ -1500,27 +1545,42 @@ async def table_view_data(
async def extra_render_cell():
"Rendered HTML for each cell using the render_cell plugin hook"
pks_for_display = pks if pks else (["rowid"] if not is_view else [])
columns = [col[0] for col in results.description]
col_names = [col[0] for col in results.description]
ct_map = await datasette.get_column_types(database_name, table_name)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
# Call render_cell plugin hook
for value, column in zip(row, col_names):
ct = ct_map.get(column)
plugin_display_value = 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,
):
candidate = await await_me_maybe(candidate)
# Try column type render_cell first
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
break
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:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
@ -1533,6 +1593,17 @@ async def table_view_data(
"params": params,
}
async def extra_column_types():
"Column type assignments for this table"
ct_map = await datasette.get_column_types(database_name, table_name)
return {
col_name: {
"type": ct.name,
"config": ct.config,
}
for col_name, ct in ct_map.items()
}
async def extra_metadata():
"Metadata about the table and database"
tablemetadata = await datasette.get_resource_metadata(database_name, table_name)
@ -1742,6 +1813,7 @@ async def table_view_data(
extra_debug,
extra_request,
extra_query,
extra_column_types,
extra_metadata,
extra_extras,
extra_database,
@ -1775,7 +1847,18 @@ async def table_view_data(
}
)
raw_sqlite_rows = rows[:page_size]
data["rows"] = [dict(r) for r in raw_sqlite_rows]
# 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.update(extra_context_from_filters)

View file

@ -903,6 +903,102 @@ Adds a new metadata entry for the specified column.
Any previous column-level metadata entry with the same ``key`` will be overwritten.
Internally upserts the value into the the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.
.. _datasette_column_types:
Column types
------------
Column types are stored in the ``column_types`` table in the :ref:`internal database <internals_internal>`. The following methods provide the API for reading and modifying column type assignments.
.. _datasette_get_column_type:
await .get_column_type(database, resource, column)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``database`` - string
The name of the database.
``resource`` - string
The name of the table or view.
``column`` - string
The name of the column.
Returns a :ref:`ColumnType <column_types>` subclass instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
.. code-block:: python
ct = await datasette.get_column_type(
"mydb", "mytable", "email_col"
)
if ct:
print(ct.name) # "email"
print(ct.config) # None or {...}
.. _datasette_get_column_types:
await .get_column_types(database, resource)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``database`` - string
The name of the database.
``resource`` - string
The name of the table or view.
Returns a dictionary mapping column names to :ref:`ColumnType <column_types>` subclass instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
.. code-block:: python
ct_map = await datasette.get_column_types("mydb", "mytable")
for col_name, ct in ct_map.items():
print(col_name, ct.name, ct.config)
.. _datasette_set_column_type:
await .set_column_type(database, resource, column, column_type, config=None)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``database`` - string
The name of the database.
``resource`` - string
The name of the table or view.
``column`` - string
The name of the column.
``column_type`` - string
The column type name to assign, e.g. ``"email"``.
``config`` - dict, optional
Optional configuration dict for the column type.
Assigns a column type to a column. Overwrites any existing assignment for that column.
.. code-block:: python
await datasette.set_column_type(
"mydb",
"mytable",
"location",
"point",
config={"srid": 4326},
)
.. _datasette_remove_column_type:
await .remove_column_type(database, resource, column)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``database`` - string
The name of the database.
``resource`` - string
The name of the table or view.
``column`` - string
The name of the column.
Removes the column type assignment for the specified column.
.. code-block:: python
await datasette.remove_column_type(
"mydb", "mytable", "location"
)
.. _datasette_add_database:
.add_database(db, name=None, route=None)
@ -1941,6 +2037,14 @@ The internal database schema is as follows:
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
.. [[[end]]]

View file

@ -474,8 +474,8 @@ Examples: `datasette-publish-fly <https://datasette.io/plugins/datasette-publish
.. _plugin_hook_render_cell:
render_cell(row, value, column, table, pks, database, datasette, request)
-------------------------------------------------------------------------
render_cell(row, value, column, table, pks, database, datasette, request, column_type)
--------------------------------------------------------------------------------------
Lets you customize the display of values within table cells in the HTML table view.
@ -503,6 +503,11 @@ Lets you customize the display of values within table cells in the HTML table vi
``request`` - :ref:`internals_request`
The current request object
``column_type`` - :ref:`ColumnType <column_types>` subclass instance or None
The :ref:`ColumnType <column_types>` subclass instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.
If a column has a :ref:`column type <column_types>` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.
If the hook returns a string, that string will be rendered in the table cell.
@ -989,6 +994,94 @@ This tells Datasette "here's how to find all documents in the system - look in t
The permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python.
.. _plugin_register_column_types:
register_column_types(datasette)
--------------------------------
Return a list of :ref:`ColumnType <column_types>` **subclasses** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
.. code-block:: python
from datasette import hookimpl
from datasette.column_types import ColumnType
import markupsafe
class ColorColumnType(ColumnType):
name = "color"
description = "CSS color value"
async def render_cell(
self,
value,
column,
table,
database,
datasette,
request,
):
if value:
return markupsafe.Markup(
'<span style="background-color: {color}">'
"{color}</span>"
).format(color=markupsafe.escape(value))
return None
async def validate(self, value, datasette):
if value and not value.startswith("#"):
return "Color must start with #"
return None
async def transform_value(self, value, datasette):
# Normalize to uppercase
if isinstance(value, str):
return value.upper()
return value
@hookimpl
def register_column_types(datasette):
return [ColorColumnType]
Each ``ColumnType`` subclass must define the following class attributes:
``name`` - string
Unique identifier for the column type, e.g. ``"color"``. Must be unique across all plugins.
``description`` - string
Human-readable label, e.g. ``"CSS color value"``.
And the following methods, all optional:
``render_cell(self, value, column, table, database, datasette, request)``
Return an HTML string to render this cell value, or ``None`` to fall through to the default ``render_cell`` plugin hook chain. When a column type provides rendering, it takes priority over the ``render_cell`` plugin hook.
``validate(self, value, datasette)``
Validate a value before it is written via the insert, update, or upsert API endpoints. Return ``None`` if valid, or a string error message if invalid. Null values and empty strings skip validation.
``transform_value(self, value, datasette)``
Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.
Per-column configuration is available via ``self.config`` in all methods. When a column type is looked up for a specific column (via :ref:`get_column_type <datasette_get_column_type>` or :ref:`get_column_types <datasette_get_column_types>`), the returned instance has ``config`` set to the parsed JSON config dict for that column assignment, or ``None`` if no config was provided.
Column types are assigned to columns via the ``column_types`` key in :ref:`table configuration <metadata_tables>`:
.. code-block:: yaml
databases:
mydb:
tables:
mytable:
column_types:
bg_color: color
highlight:
type: color
config:
format: rgb
Datasette includes three built-in column types: ``url``, ``email``, and ``json``.
.. _plugin_asgi_wrapper:
asgi_wrapper(datasette)

View file

@ -207,6 +207,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"register_actions"
]
},
{
"name": "datasette.default_column_types",
"static": false,
"templates": false,
"version": null,
"hooks": [
"register_column_types"
]
},
{
"name": "datasette.default_magic_parameters",
"static": false,

732
tests/test_column_types.py Normal file
View file

@ -0,0 +1,732 @@
import logging
from datasette.app import Datasette
from datasette.column_types import ColumnType
from datasette.hookspecs import hookimpl
from datasette.plugins import pm
from datasette.utils import sqlite3
from datasette.utils import StartupError
import markupsafe
import pytest
import time
@pytest.fixture
def ds_ct(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute(
"create table posts (id integer primary key, title text, body text, "
"author_email text, website text, metadata text)"
)
db.execute(
"insert into posts values (1, 'Hello', '# World', 'test@example.com', "
"'https://example.com', '{\"key\": \"value\"}')"
)
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"tables": {
"posts": {
"column_types": {
"body": "markdown",
"author_email": "email",
"website": "url",
"metadata": "json",
}
}
}
}
}
},
)
ds.root_enabled = True
yield ds
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
def write_token(ds, actor_id="root", permissions=None):
to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())}
if permissions:
to_sign["_r"] = {"a": permissions}
return "dstok_{}".format(ds.sign(to_sign, namespace="token"))
def _headers(token):
return {
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
}
# --- Internal DB and config loading ---
@pytest.mark.asyncio
async def test_column_types_table_created(ds_ct):
await ds_ct.invoke_startup()
internal = ds_ct.get_internal_database()
result = await internal.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='column_types'"
)
assert len(result.rows) == 1
@pytest.mark.asyncio
async def test_config_loaded_into_internal_db(ds_ct):
await ds_ct.invoke_startup()
ct_map = await ds_ct.get_column_types("data", "posts")
# "markdown" is not a registered type, so it won't appear
assert "body" not in ct_map
assert ct_map["author_email"].name == "email"
assert ct_map["author_email"].config is None
assert ct_map["website"].name == "url"
assert ct_map["metadata"].name == "json"
@pytest.mark.asyncio
async def test_config_with_type_and_config(tmp_path_factory):
class PointColumnType(ColumnType):
name = "point"
description = "Geographic point"
class _Plugin:
@hookimpl
def register_column_types(self, datasette):
return [PointColumnType]
plugin = _Plugin()
pm.register(plugin, name="test_point_ct")
try:
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table geo (id integer primary key, location text)")
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"tables": {
"geo": {
"column_types": {
"location": {
"type": "point",
"config": {"srid": 4326},
}
}
}
}
}
}
},
)
await ds.invoke_startup()
ct = await ds.get_column_type("data", "geo", "location")
assert ct.name == "point"
assert ct.config == {"srid": 4326}
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
finally:
pm.unregister(plugin, name="test_point_ct")
# --- Datasette API methods ---
@pytest.mark.asyncio
async def test_get_column_type(ds_ct):
await ds_ct.invoke_startup()
ct = await ds_ct.get_column_type("data", "posts", "author_email")
assert isinstance(ct, ColumnType)
assert ct.name == "email"
assert ct.config is None
@pytest.mark.asyncio
async def test_get_column_type_missing(ds_ct):
await ds_ct.invoke_startup()
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct is None
@pytest.mark.asyncio
async def test_set_and_remove_column_type(ds_ct):
await ds_ct.invoke_startup()
await ds_ct.set_column_type("data", "posts", "title", "email")
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct.name == "email"
assert ct.config is None
await ds_ct.remove_column_type("data", "posts", "title")
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct is None
@pytest.mark.asyncio
async def test_set_column_type_with_config(ds_ct):
await ds_ct.invoke_startup()
await ds_ct.set_column_type("data", "posts", "title", "url", {"max_length": 200})
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct.name == "url"
assert ct.config == {"max_length": 200}
# --- Plugin registration ---
@pytest.mark.asyncio
async def test_builtin_column_types_registered(ds_ct):
"""register_column_types returns classes; _column_types stores them by name."""
await ds_ct.invoke_startup()
assert "url" in ds_ct._column_types
assert "email" in ds_ct._column_types
assert "json" in ds_ct._column_types
assert "nonexistent" not in ds_ct._column_types
@pytest.mark.asyncio
async def test_column_type_class_attributes(ds_ct):
await ds_ct.invoke_startup()
url_cls = ds_ct._column_types["url"]
assert url_cls.name == "url"
assert url_cls.description == "URL"
email_cls = ds_ct._column_types["email"]
assert email_cls.name == "email"
assert email_cls.description == "Email address"
# --- JSON API ---
@pytest.mark.asyncio
async def test_column_types_extra(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=column_types")
assert response.status_code == 200
data = response.json()
assert "column_types" in data
assert data["column_types"]["author_email"] == {"type": "email", "config": None}
assert data["column_types"]["website"] == {"type": "url", "config": None}
assert data["column_types"]["metadata"] == {"type": "json", "config": None}
# "markdown" is not a registered type, so body should not appear
assert "body" not in data["column_types"]
# title has no column type, should not appear
assert "title" not in data["column_types"]
@pytest.mark.asyncio
async def test_display_columns_include_column_type(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=display_columns")
assert response.status_code == 200
data = response.json()
cols = {c["name"]: c for c in data["display_columns"]}
assert cols["author_email"]["column_type"] == "email"
assert cols["author_email"]["column_type_config"] is None
assert cols["website"]["column_type"] == "url"
assert cols["title"]["column_type"] is None
# --- Rendering ---
@pytest.mark.asyncio
async def test_url_render_cell(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
assert "href" in rendered["website"]
assert "https://example.com" in rendered["website"]
@pytest.mark.asyncio
async def test_email_render_cell(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
assert "mailto:" in rendered["author_email"]
assert "test@example.com" in rendered["author_email"]
@pytest.mark.asyncio
async def test_json_render_cell(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
assert "<pre>" in rendered["metadata"]
# --- Validation ---
@pytest.mark.asyncio
async def test_email_validation_on_insert(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "author_email": "not-an-email"}},
headers=_headers(token),
)
assert response.status_code == 400
assert "author_email" in response.json()["errors"][0]
@pytest.mark.asyncio
async def test_email_validation_passes_valid(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "author_email": "valid@example.com"}},
headers=_headers(token),
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_url_validation_on_insert(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "website": "not-a-url"}},
headers=_headers(token),
)
assert response.status_code == 400
assert "website" in response.json()["errors"][0]
@pytest.mark.asyncio
async def test_json_validation_on_insert(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "metadata": "not-json{"}},
headers=_headers(token),
)
assert response.status_code == 400
assert "metadata" in response.json()["errors"][0]
@pytest.mark.asyncio
async def test_validation_on_update(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/1/-/update",
json={"update": {"author_email": "invalid"}},
headers=_headers(token),
)
assert response.status_code == 400
assert "author_email" in response.json()["errors"][0]
@pytest.mark.asyncio
async def test_validation_allows_null(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "author_email": None}},
headers=_headers(token),
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_validation_allows_empty_string(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/insert",
json={"row": {"title": "Test", "author_email": ""}},
headers=_headers(token),
)
assert response.status_code == 201
# --- ColumnType base class ---
@pytest.mark.asyncio
async def test_column_type_base_defaults():
class TestType(ColumnType):
name = "test"
description = "Test type"
ct = TestType()
assert ct.config is None
assert await ct.render_cell("val", "col", "tbl", "db", None, None) is None
assert await ct.validate("val", None) is None
assert await ct.transform_value("val", None) == "val"
# --- render_cell extra with column types ---
@pytest.mark.asyncio
async def test_render_cell_extra_with_column_types(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
assert "mailto:" in rendered["author_email"]
assert "href" in rendered["website"]
# --- Duplicate column type name ---
@pytest.mark.asyncio
async def test_duplicate_column_type_name_raises_error():
class DuplicateUrlType(ColumnType):
name = "url"
description = "Duplicate URL"
async def render_cell(self, value, column, table, database, datasette, request):
return None
class _Plugin:
@hookimpl
def register_column_types(self, datasette):
return [DuplicateUrlType]
plugin = _Plugin()
pm.register(plugin, name="test_duplicate_ct")
try:
ds = Datasette()
with pytest.raises(StartupError, match="Duplicate column type name: url"):
await ds.invoke_startup()
finally:
pm.unregister(plugin, name="test_duplicate_ct")
# --- Row endpoint ---
@pytest.mark.asyncio
async def test_row_endpoint_render_cell_with_column_types(ds_ct):
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts/1.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
assert "mailto:" in rendered["author_email"]
assert "href" in rendered["website"]
# --- transform_value in JSON output ---
@pytest.mark.asyncio
async def test_transform_value_in_json_output(tmp_path_factory):
"""A column type with transform_value should modify rows in JSON API."""
class UpperColumnType(ColumnType):
name = "upper"
description = "Uppercase"
async def transform_value(self, value, datasette):
if isinstance(value, str):
return value.upper()
return value
class _Plugin:
@hookimpl
def register_column_types(self, datasette):
return [UpperColumnType]
plugin = _Plugin()
pm.register(plugin, name="test_transform_ct")
try:
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table t (id integer primary key, name text)")
db.execute("insert into t values (1, 'hello')")
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {"tables": {"t": {"column_types": {"name": "upper"}}}}
}
},
)
await ds.invoke_startup()
response = await ds.client.get("/data/t.json")
assert response.status_code == 200
data = response.json()
assert data["rows"][0]["name"] == "HELLO"
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
finally:
pm.unregister(plugin, name="test_transform_ct")
# --- Column type priority over plugins ---
@pytest.mark.asyncio
async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factory):
"""Column type render_cell should take priority over render_cell plugin hook."""
class PriorityColumnType(ColumnType):
name = "priority_test"
description = "Priority test"
async def render_cell(self, value, column, table, database, datasette, request):
if value is not None:
return markupsafe.Markup(
f"<b>COLUMN_TYPE:{markupsafe.escape(value)}</b>"
)
return None
class _ColumnTypePlugin:
@hookimpl
def register_column_types(self, datasette):
return [PriorityColumnType]
class _RenderCellPlugin:
@hookimpl
def render_cell(
self,
row,
value,
column,
table,
pks,
database,
datasette,
request,
column_type,
):
if column == "name":
return markupsafe.Markup(f"<i>PLUGIN:{markupsafe.escape(value)}</i>")
ct_plugin = _ColumnTypePlugin()
rc_plugin = _RenderCellPlugin()
pm.register(ct_plugin, name="test_priority_ct")
pm.register(rc_plugin, name="test_priority_render")
try:
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table t (id integer primary key, name text)")
db.execute("insert into t values (1, 'hello')")
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"tables": {"t": {"column_types": {"name": "priority_test"}}}
}
}
},
)
await ds.invoke_startup()
response = await ds.client.get("/data/t.json?_extra=render_cell")
assert response.status_code == 200
data = response.json()
rendered = data["render_cell"][0]
# Column type should win over the plugin
assert "COLUMN_TYPE:" in rendered["name"]
assert "PLUGIN:" not in rendered["name"]
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
finally:
pm.unregister(ct_plugin, name="test_priority_ct")
pm.unregister(rc_plugin, name="test_priority_render")
# --- Row detail page rendering ---
@pytest.mark.asyncio
async def test_row_detail_page_html_rendering(ds_ct):
"""Row detail HTML page should use column type rendering."""
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts/1")
assert response.status_code == 200
html = response.text
# The email column should be rendered with mailto: link
assert "mailto:test@example.com" in html
# The url column should be rendered with href
assert 'href="https://example.com"' in html
# --- HTML table page rendering ---
@pytest.mark.asyncio
async def test_html_table_page_rendering(ds_ct):
"""HTML table page should use column type rendering."""
await ds_ct.invoke_startup()
response = await ds_ct.client.get("/data/posts")
assert response.status_code == 200
html = response.text
assert "mailto:test@example.com" in html
assert 'href="https://example.com"' in html
# --- Validation on upsert ---
@pytest.mark.asyncio
async def test_validation_on_upsert(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/upsert",
json={
"rows": [{"id": 1, "title": "Updated", "author_email": "invalid"}],
},
headers=_headers(token),
)
assert response.status_code == 400
assert "author_email" in response.json()["errors"][0]
@pytest.mark.asyncio
async def test_validation_on_upsert_passes_valid(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct)
response = await ds_ct.client.post(
"/data/posts/-/upsert",
json={
"rows": [{"id": 1, "title": "Updated", "author_email": "valid@test.com"}],
},
headers=_headers(token),
)
assert response.status_code == 200
# --- Unknown type warning logged ---
@pytest.mark.asyncio
async def test_unknown_type_warning_logged(tmp_path_factory, caplog):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table t (id integer primary key, col text)")
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {"tables": {"t": {"column_types": {"col": "nonexistent_type"}}}}
}
},
)
with caplog.at_level(logging.WARNING):
await ds.invoke_startup()
assert "unknown type" in caplog.text.lower()
assert "nonexistent_type" in caplog.text
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
# --- Config overwrites on restart ---
@pytest.mark.asyncio
async def test_config_overwrites_on_restart(tmp_path_factory):
"""Config values should overwrite any existing column types in internal DB on startup."""
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table t (id integer primary key, col text)")
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {"data": {"tables": {"t": {"column_types": {"col": "email"}}}}}
},
)
await ds.invoke_startup()
ct = await ds.get_column_type("data", "t", "col")
assert ct.name == "email"
# Manually change the column type in the internal DB
await ds.set_column_type("data", "t", "col", "url")
ct = await ds.get_column_type("data", "t", "col")
assert ct.name == "url"
# Re-apply config (simulating what happens on restart)
await ds._apply_column_types_config()
ct = await ds.get_column_type("data", "t", "col")
assert ct.name == "email" # Config wins
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
# --- No column_types in config ---
@pytest.mark.asyncio
async def test_no_column_types_in_config(tmp_path_factory):
"""Datasette should work fine without any column_types configuration."""
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table t (id integer primary key, col text)")
db.execute("insert into t values (1, 'hello')")
db.commit()
ds = Datasette([db_path])
await ds.invoke_startup()
# No column types assigned
ct_map = await ds.get_column_types("data", "t")
assert ct_map == {}
# JSON endpoint should work without column_types extra
response = await ds.client.get("/data/t.json")
assert response.status_code == 200
assert response.json()["rows"][0]["col"] == "hello"
# column_types extra should return empty
response = await ds.client.get("/data/t.json?_extra=column_types")
assert response.status_code == 200
assert response.json()["column_types"] == {}
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()

View file

@ -1948,3 +1948,14 @@ def test_metadata_plugin_config_treated_as_config(
assert "plugins" not in actual_metadata
assert actual_metadata == expected_metadata
assert ds.config == expected_config
@pytest.mark.asyncio
async def test_hook_register_column_types():
ds = Datasette()
await ds.invoke_startup()
# Built-in column types should be registered
assert "url" in ds._column_types
assert "email" in ds._column_types
assert "json" in ds._column_types
assert "nonexistent" not in ds._column_types