Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ def __init__(
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:
Expand Down Expand Up @@ -692,12 +693,23 @@ async def invoke_startup(self):
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
Expand Down Expand Up @@ -945,6 +957,102 @@ async def set_column_metadata(
[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

Expand Down
44 changes: 44 additions & 0 deletions datasette/column_types.py
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions datasette/default_column_types.py
Original file line number Diff line number Diff line change
@@ -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]
17 changes: 16 additions & 1 deletion datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""


Expand All @@ -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"""
Expand Down
1 change: 1 addition & 0 deletions datasette/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"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",
Expand Down
9 changes: 9 additions & 0 deletions datasette/utils/internal_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
"""))


Expand Down
1 change: 1 addition & 0 deletions datasette/views/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 37 additions & 13 deletions datasette/views/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,26 +179,41 @@ async def template_data():

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)
Expand Down Expand Up @@ -352,6 +367,15 @@ async def post(self, request):

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",
Expand Down
Loading
Loading