From 73225ccad0581a4f505adf2f4590372246a7d9be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:40:37 +0000 Subject: [PATCH 01/14] Add column types system for semantic column annotations Implements the column types feature that lets Datasette and plugins annotate columns with semantic types beyond SQLite storage types (e.g. markdown, email, url, json, file, point). This enables type-appropriate rendering, validation, form widgets, and API behavior. Key changes: - New `column_types` internal DB table for storing assignments - `ColumnType` dataclass in datasette/column_types.py with render_cell, validate, and transform_value methods - `register_column_types` plugin hook for registering types - Built-in url, email, and json column types - Datasette API methods: get/set/remove_column_type(s), get_column_type_class - Config loading from datasette.json `column_types` table config key - `column_types` extra on the table JSON endpoint - Column type info in display_columns extra - Column type render_cell gets priority in rendering pipeline - column_type/column_type_config args added to render_cell hookspec - Write-path validation on insert and update https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3 --- datasette/app.py | 103 +++++++++ datasette/column_types.py | 42 ++++ datasette/default_column_types.py | 82 +++++++ datasette/hookspecs.py | 10 +- datasette/plugins.py | 1 + datasette/utils/internal_db.py | 9 + datasette/views/database.py | 2 + datasette/views/row.py | 50 ++-- datasette/views/table.py | 160 +++++++++---- tests/test_column_types.py | 369 ++++++++++++++++++++++++++++++ tests/test_plugins.py | 11 + 11 files changed, 781 insertions(+), 58 deletions(-) create mode 100644 datasette/column_types.py create mode 100644 datasette/default_column_types.py create mode 100644 tests/test_column_types.py diff --git a/datasette/app.py b/datasette/app.py index f0349895..1a20dbd0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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,25 @@ class Datasette: action_abbrs[action.abbr] = action self.actions[action.name] = action + # Register column types + self._column_types = {} + for hook in pm.hook.register_column_types(datasette=self): + if hook: + for ct in hook: + if ct.name in self._column_types: + raise StartupError( + f"Duplicate column type name: {ct.name}" + ) + self._column_types[ct.name] = ct + 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 +959,95 @@ 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 + ) -> tuple: + """ + Return (column_type_name, config_dict) for a specific column, + or (None, 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, None + ct, config = rows[0] + return (ct, json.loads(config) if config else None) + + async def get_column_types( + self, database: str, resource: str + ) -> dict: + """ + Return {column_name: (column_type_name, config_dict_or_None)} + 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], + ) + return { + row[0]: (row[1], json.loads(row[2]) if row[2] else None) + for row in rows.rows + } + + 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_column_type_class(self, column_type_name: str): + """ + Return the registered ColumnType instance for a given name, + or None if no plugin has registered that name. + """ + return self._column_types.get(column_type_name) + def get_internal_database(self): return self._internal_database diff --git a/datasette/column_types.py b/datasette/column_types.py new file mode 100644 index 00000000..240bcc8f --- /dev/null +++ b/datasette/column_types.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class ColumnType: + name: str + """ + Unique identifier string. Lowercase, no spaces. + Examples: "markdown", "file", "email", "url", "point", "image". + """ + + description: str + """ + Human-readable label for admin UI dropdowns. + Examples: "Markdown text", "File reference", "Email address". + """ + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + """ + Return an HTML string to render this cell value, or None to + fall through to the default render_cell plugin hook chain. + + ``config`` is the parsed JSON config dict for this specific + column assignment, or None. + """ + return None + + async def validate(self, value, config, 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, config, datasette): + """ + Transform a value before it appears in JSON API output. + Return the transformed value. Default: return unchanged. + """ + return value diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py new file mode 100644 index 00000000..24e761ba --- /dev/null +++ b/datasette/default_column_types.py @@ -0,0 +1,82 @@ +import json +import re + +import markupsafe + +from datasette import hookimpl +from datasette.column_types import ColumnType + + +class UrlColumnType(ColumnType): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + if not value or not isinstance(value, str): + return None + escaped = markupsafe.escape(value.strip()) + return markupsafe.Markup(f'{escaped}') + + async def validate(self, value, config, 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): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + if not value or not isinstance(value, str): + return None + escaped = markupsafe.escape(value.strip()) + return markupsafe.Markup(f'{escaped}') + + async def validate(self, value, config, 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): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + 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"
{escaped}
") + except (json.JSONDecodeError, TypeError): + return None + + async def validate(self, value, config, 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(name="url", description="URL"), + EmailColumnType(name="email", description="Email address"), + JsonColumnType(name="json", description="JSON data"), + ] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 64901900..ec779659 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -55,7 +55,10 @@ 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, column_type_config +): """Customize rendering of HTML table cell values""" @@ -74,6 +77,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""" diff --git a/datasette/plugins.py b/datasette/plugins.py index 992137bd..b01b386c 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -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", diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index e4ebddde..cc5d7398 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -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, + resource_name TEXT, + column_name TEXT, + column_type TEXT NOT NULL, + config TEXT, + PRIMARY KEY (database_name, resource_name, column_name) + ); """)) diff --git a/datasette/views/database.py b/datasette/views/database.py index 93ad8eda..29533215 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1205,6 +1205,8 @@ async def display_rows(datasette, database, request, rows, columns): database=database, datasette=datasette, request=request, + column_type=None, + column_type_config=None, ): candidate = await await_me_maybe(candidate) if candidate is not None: diff --git a/datasette/views/row.py b/datasette/views/row.py index 7cc46368..0702368d 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -179,26 +179,38 @@ 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_info = ct_map.get(column) + ct_name = ct_info[0] if ct_info else None + ct_config = ct_info[1] if ct_info else None 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) - if candidate is not None: - plugin_display_value = candidate - break + # Try column type render_cell first + if ct_name: + ct_class = self.ds.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, column=column, table=table, + database=database, datasette=self.ds, + request=request, config=ct_config, + ) + 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, pks=resolved.pks, + database=database, datasette=self.ds, + request=request, column_type=ct_name, + column_type_config=ct_config, + ): + 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 +364,14 @@ 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", diff --git a/datasette/views/table.py b/datasette/views/table.py index 2ee86743..3c9b6656 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -134,6 +134,25 @@ 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_name, ct_config) in ct_map.items(): + if col_name not in row: + continue + ct_class = datasette.get_column_type_class(ct_name) + if ct_class is None: + continue + error = await ct_class.validate(row[col_name], ct_config, datasette) + if error: + errors.append(f"{col_name}: {error}") + return errors + + async def display_columns_and_rows( datasette, database_name, @@ -163,6 +182,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 +201,22 @@ 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]), + } + ct_info = column_types_map.get(r[0]) + if ct_info: + col_dict["column_type"] = ct_info[0] + col_dict["column_type_config"] = ct_info[1] + else: + col_dict["column_type"] = None + col_dict["column_type_config"] = None + columns.append(col_dict) column_to_foreign_key_table = { fk["column"]: fk["other_table"] @@ -227,23 +255,42 @@ 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) - if candidate is not None: - plugin_display_value = candidate - break + ct_name = column_dict.get("column_type") + ct_config = column_dict.get("column_type_config") + if ct_name: + ct_class = datasette.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, + column=column, + table=table_name, + database=database_name, + datasette=datasette, + request=request, + config=ct_config, + ) + 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_name, + column_type_config=ct_config, + ): + 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 +531,11 @@ 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 +1552,39 @@ 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_info = ct_map.get(column) + ct_name = ct_info[0] if ct_info else None + ct_config = ct_info[1] if ct_info else None 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) - if candidate is not None: - plugin_display_value = candidate - break + # Try column type render_cell first + if ct_name: + ct_class = datasette.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, column=column, table=table_name, + database=database_name, datasette=datasette, + request=request, config=ct_config, + ) + 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_name, + column_type_config=ct_config, + ): + 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 +1597,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_name, ct_config) 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 +1817,7 @@ async def table_view_data( extra_debug, extra_request, extra_query, + extra_column_types, extra_metadata, extra_extras, extra_database, diff --git a/tests/test_column_types.py b/tests/test_column_types.py new file mode 100644 index 00000000..3cbadf5e --- /dev/null +++ b/tests/test_column_types.py @@ -0,0 +1,369 @@ +from datasette.app import Datasette +from datasette.column_types import ColumnType +from datasette.utils import sqlite3 +import json +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") + assert "body" in ct_map + assert ct_map["body"] == ("markdown", None) + assert ct_map["author_email"] == ("email", None) + assert ct_map["website"] == ("url", None) + assert ct_map["metadata"] == ("json", None) + + +@pytest.mark.asyncio +async def test_config_with_type_and_config(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 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, config = await ds.get_column_type("data", "geo", "location") + assert ct == "point" + assert config == {"srid": 4326} + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + +# --- Datasette API methods --- + + +@pytest.mark.asyncio +async def test_get_column_type(ds_ct): + await ds_ct.invoke_startup() + ct, config = await ds_ct.get_column_type("data", "posts", "author_email") + assert ct == "email" + assert config is None + + +@pytest.mark.asyncio +async def test_get_column_type_missing(ds_ct): + await ds_ct.invoke_startup() + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct is None + assert config 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", "markdown") + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct == "markdown" + assert config is None + + await ds_ct.remove_column_type("data", "posts", "title") + ct, config = 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", "file", {"accept": "image/*"}) + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct == "file" + assert config == {"accept": "image/*"} + + +# --- Plugin registration --- + + +@pytest.mark.asyncio +async def test_builtin_column_types_registered(ds_ct): + await ds_ct.invoke_startup() + assert ds_ct.get_column_type_class("url") is not None + assert ds_ct.get_column_type_class("email") is not None + assert ds_ct.get_column_type_class("json") is not None + assert ds_ct.get_column_type_class("nonexistent") is None + + +@pytest.mark.asyncio +async def test_column_type_class_attributes(ds_ct): + await ds_ct.invoke_startup() + url_type = ds_ct.get_column_type_class("url") + assert url_type.name == "url" + assert url_type.description == "URL" + email_type = ds_ct.get_column_type_class("email") + assert email_type.name == "email" + assert email_type.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"]["body"] == {"type": "markdown", "config": None} + assert data["column_types"]["author_email"] == {"type": "email", "config": None} + assert data["column_types"]["website"] == {"type": "url", "config": None} + # 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 "
" 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():
+    ct = ColumnType(name="test", description="Test type")
+    assert await ct.render_cell(
+        "val", "col", "tbl", "db", None, None, None
+    ) is None
+    assert await ct.validate("val", None, None) is None
+    assert await ct.transform_value("val", None, 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"]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index f2a47ab4..b3014275 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -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 ds.get_column_type_class("url") is not None
+    assert ds.get_column_type_class("email") is not None
+    assert ds.get_column_type_class("json") is not None
+    assert ds.get_column_type_class("nonexistent") is None

From e8472bc0cde0b2186587c7739e0722e459eb270f Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 02:48:55 +0000
Subject: [PATCH 02/14] Add missing tests and transform_value integration

- Add transform_value integration in table JSON endpoint rows
- Add tests for: duplicate type name error, row endpoint rendering,
  transform_value in JSON output, column type priority over plugins,
  row detail HTML rendering, table HTML rendering, upsert validation,
  unknown type warning logging, config overwrite on restart, and
  no-config edge case
- Total: 34 column type tests, all passing

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py   |  15 +-
 tests/test_column_types.py | 348 +++++++++++++++++++++++++++++++++++++
 2 files changed, 362 insertions(+), 1 deletion(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 3c9b6656..20d78164 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -1851,7 +1851,20 @@ 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_name, ct_config) in ct_map.items():
+            if col_name in row_dict:
+                ct_class = datasette.get_column_type_class(ct_name)
+                if ct_class:
+                    row_dict[col_name] = await ct_class.transform_value(
+                        row_dict[col_name], ct_config, datasette
+                    )
+        transformed_rows.append(row_dict)
+    data["rows"] = transformed_rows
 
     if context_for_html_hack:
         data.update(extra_context_from_filters)
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 3cbadf5e..efb8fbc7 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -1,7 +1,15 @@
+import logging
+
+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 json
+import markupsafe
 import pytest
 import time
 
@@ -367,3 +375,343 @@ async def test_render_cell_extra_with_column_types(ds_ct):
     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):
+        async def render_cell(self, value, column, table, database, datasette, request, config):
+            return None
+
+    class _Plugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [DuplicateUrlType(name="url", description="Duplicate URL")]
+
+    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):
+        async def transform_value(self, value, config, datasette):
+            if isinstance(value, str):
+                return value.upper()
+            return value
+
+    class _Plugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [UpperColumnType(name="upper", description="Uppercase")]
+
+    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):
+        async def render_cell(self, value, column, table, database, datasette, request, config):
+            if value is not None:
+                return markupsafe.Markup(f"COLUMN_TYPE:{markupsafe.escape(value)}")
+            return None
+
+    class _ColumnTypePlugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [PriorityColumnType(name="priority_test", description="Priority test")]
+
+    class _RenderCellPlugin:
+        @hookimpl
+        def render_cell(self, row, value, column, table, pks, database, datasette, request,
+                        column_type, column_type_config):
+            if column == "name":
+                return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
+
+    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 == "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 == "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 == "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()

From de4269629bde2916d5fd0e3fedc6e4ceed9bfee5 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:55:51 +0000
Subject: [PATCH 03/14] Remove duplicate import logging line

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 tests/test_column_types.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index efb8fbc7..0c8a969d 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -1,7 +1,5 @@
 import logging
 
-import logging
-
 from datasette.app import Datasette
 from datasette.column_types import ColumnType
 from datasette.hookspecs import hookimpl

From 5db4f6953d19e0d89864852f02796ed554881427 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:56:20 +0000
Subject: [PATCH 04/14] Fix linting: remove unused import, apply black
 formatting

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py   | 27 ++++++++++-----
 tests/test_column_types.py | 68 +++++++++++++++++---------------------
 2 files changed, 49 insertions(+), 46 deletions(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 20d78164..2b393087 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -532,7 +532,9 @@ class TableInsertView(BaseView):
             return _error(errors, 400)
 
         # Validate column types
-        ct_errors = await _validate_column_types(self.ds, database_name, table_name, rows)
+        ct_errors = await _validate_column_types(
+            self.ds, database_name, table_name, rows
+        )
         if ct_errors:
             return _error(ct_errors, 400)
 
@@ -1567,18 +1569,27 @@ async def table_view_data(
                     ct_class = datasette.get_column_type_class(ct_name)
                     if ct_class:
                         candidate = await ct_class.render_cell(
-                            value=value, column=column, table=table_name,
-                            database=database_name, datasette=datasette,
-                            request=request, config=ct_config,
+                            value=value,
+                            column=column,
+                            table=table_name,
+                            database=database_name,
+                            datasette=datasette,
+                            request=request,
+                            config=ct_config,
                         )
                         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_name,
+                        row=row,
+                        value=value,
+                        column=column,
+                        table=table_name,
+                        pks=pks_for_display,
+                        database=database_name,
+                        datasette=datasette,
+                        request=request,
+                        column_type=ct_name,
                         column_type_config=ct_config,
                     ):
                         candidate = await await_me_maybe(candidate)
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 0c8a969d..7e16e6c2 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -6,7 +6,6 @@ from datasette.hookspecs import hookimpl
 from datasette.plugins import pm
 from datasette.utils import sqlite3
 from datasette.utils import StartupError
-import json
 import markupsafe
 import pytest
 import time
@@ -354,9 +353,7 @@ async def test_validation_allows_empty_string(ds_ct):
 @pytest.mark.asyncio
 async def test_column_type_base_defaults():
     ct = ColumnType(name="test", description="Test type")
-    assert await ct.render_cell(
-        "val", "col", "tbl", "db", None, None, None
-    ) is None
+    assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
     assert await ct.validate("val", None, None) is None
     assert await ct.transform_value("val", None, None) == "val"
 
@@ -381,7 +378,9 @@ async def test_render_cell_extra_with_column_types(ds_ct):
 @pytest.mark.asyncio
 async def test_duplicate_column_type_name_raises_error():
     class DuplicateUrlType(ColumnType):
-        async def render_cell(self, value, column, table, database, datasette, request, config):
+        async def render_cell(
+            self, value, column, table, database, datasette, request, config
+        ):
             return None
 
     class _Plugin:
@@ -445,13 +444,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
             [db_path],
             config={
                 "databases": {
-                    "data": {
-                        "tables": {
-                            "t": {
-                                "column_types": {"name": "upper"}
-                            }
-                        }
-                    }
+                    "data": {"tables": {"t": {"column_types": {"name": "upper"}}}}
                 }
             },
         )
@@ -476,20 +469,37 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     """Column type render_cell should take priority over render_cell plugin hook."""
 
     class PriorityColumnType(ColumnType):
-        async def render_cell(self, value, column, table, database, datasette, request, config):
+        async def render_cell(
+            self, value, column, table, database, datasette, request, config
+        ):
             if value is not None:
-                return markupsafe.Markup(f"COLUMN_TYPE:{markupsafe.escape(value)}")
+                return markupsafe.Markup(
+                    f"COLUMN_TYPE:{markupsafe.escape(value)}"
+                )
             return None
 
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [PriorityColumnType(name="priority_test", description="Priority test")]
+            return [
+                PriorityColumnType(name="priority_test", description="Priority test")
+            ]
 
     class _RenderCellPlugin:
         @hookimpl
-        def render_cell(self, row, value, column, table, pks, database, datasette, request,
-                        column_type, column_type_config):
+        def render_cell(
+            self,
+            row,
+            value,
+            column,
+            table,
+            pks,
+            database,
+            datasette,
+            request,
+            column_type,
+            column_type_config,
+        ):
             if column == "name":
                 return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
 
@@ -510,11 +520,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
             config={
                 "databases": {
                     "data": {
-                        "tables": {
-                            "t": {
-                                "column_types": {"name": "priority_test"}
-                            }
-                        }
+                        "tables": {"t": {"column_types": {"name": "priority_test"}}}
                     }
                 }
             },
@@ -613,13 +619,7 @@ async def test_unknown_type_warning_logged(tmp_path_factory, caplog):
         [db_path],
         config={
             "databases": {
-                "data": {
-                    "tables": {
-                        "t": {
-                            "column_types": {"col": "nonexistent_type"}
-                        }
-                    }
-                }
+                "data": {"tables": {"t": {"column_types": {"col": "nonexistent_type"}}}}
             }
         },
     )
@@ -648,15 +648,7 @@ async def test_config_overwrites_on_restart(tmp_path_factory):
     ds = Datasette(
         [db_path],
         config={
-            "databases": {
-                "data": {
-                    "tables": {
-                        "t": {
-                            "column_types": {"col": "email"}
-                        }
-                    }
-                }
-            }
+            "databases": {"data": {"tables": {"t": {"column_types": {"col": "email"}}}}}
         },
     )
     await ds.invoke_startup()

From ad6a020e6d2b71607fd5b5facff834773b077711 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:58:18 +0000
Subject: [PATCH 05/14] Add NOT NULL constraints to column_types primary key
 columns

SQLite allows NULLs in primary key columns by default, so mark
database_name, resource_name, and column_name as NOT NULL explicitly.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/utils/internal_db.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py
index cc5d7398..df149928 100644
--- a/datasette/utils/internal_db.py
+++ b/datasette/utils/internal_db.py
@@ -105,9 +105,9 @@ async def initialize_metadata_tables(db):
         );
 
         CREATE TABLE IF NOT EXISTS column_types (
-            database_name TEXT,
-            resource_name TEXT,
-            column_name TEXT,
+            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)

From 32e4a319133182e5f5dfd7a787e901ebfab4077f Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:03:52 +0000
Subject: [PATCH 06/14] Document register_column_types hook and updated
 render_cell signature

- Add register_column_types(datasette) hook documentation with example
- Update render_cell signature to include column_type and
  column_type_config parameters
- Fixes test_plugin_hooks_are_documented

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/plugin_hooks.rst | 99 ++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 97 insertions(+), 2 deletions(-)

diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index b9701f7c..b25403b3 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -474,8 +474,8 @@ Examples: `datasette-publish-fly ` assigned to this column, or ``None`` if no column type is assigned.
+
+``column_type_config`` - dict or None
+    The configuration dict for the assigned column type, or ``None``.
+
+If a column has a :ref:`column type ` 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 +997,93 @@ 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 ` 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):
+        async def render_cell(
+            self, value, column, table, database,
+            datasette, request, config
+        ):
+            if value:
+                return markupsafe.Markup(
+                    ''
+                    "{color}"
+                ).format(color=markupsafe.escape(value))
+            return None
+
+        async def validate(self, value, config, datasette):
+            if value and not value.startswith("#"):
+                return "Color must start with #"
+            return None
+
+        async def transform_value(
+            self, value, config, datasette
+        ):
+            # Normalize to uppercase
+            if isinstance(value, str):
+                return value.upper()
+            return value
+
+
+    @hookimpl
+    def register_column_types(datasette):
+        return [
+            ColorColumnType(
+                name="color",
+                description="CSS color value",
+            )
+        ]
+
+Each ``ColumnType`` instance has the following 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, config)``
+    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, config, 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, config, datasette)``
+    Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.
+
+The ``config`` argument passed to these methods is the parsed JSON config dict for the specific column assignment, or ``None`` if no config was provided.
+
+Column types are assigned to columns via the ``column_types`` key in :ref:`table configuration `:
+
+.. 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)

From 77bbfb5f7edb60c2916eb148d1b63b111275e215 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:08:58 +0000
Subject: [PATCH 07/14] Document column type internal methods in internals.rst

Add documentation for get_column_type, get_column_types,
set_column_type, remove_column_type, and get_column_type_class
methods on the Datasette instance.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/internals.rst | 107 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 107 insertions(+)

diff --git a/docs/internals.rst b/docs/internals.rst
index 7d607bfe..f1064b8b 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -903,6 +903,113 @@ 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 `.
 
+.. _datasette_column_types:
+
+Column types
+------------
+
+Column types are stored in the ``column_types`` table in the :ref:`internal database `. 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 ``(column_type_name, config)`` tuple for the specified column. ``column_type_name`` is a string like ``"email"`` or ``"url"``, and ``config`` is a dict or ``None``. If no column type is assigned, returns ``(None, None)``.
+
+.. code-block:: python
+
+    ct_name, config = await datasette.get_column_type(
+        "mydb", "mytable", "email_col"
+    )
+
+.. _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 ``(column_type_name, config)`` tuples for all columns that have assigned types on the given resource.
+
+.. code-block:: python
+
+    ct_map = await datasette.get_column_types(
+        "mydb", "mytable"
+    )
+    # {"email_col": ("email", None), "site": ("url", None)}
+
+.. _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_get_column_type_class:
+
+.get_column_type_class(column_type_name)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``column_type_name`` - string
+    The name of the column type, e.g. ``"email"``.
+
+Returns the registered ``ColumnType`` instance for the given name, or ``None`` if no plugin has registered a column type with that name. This is a synchronous method.
+
+.. code-block:: python
+
+    ct = datasette.get_column_type_class("email")
+    if ct:
+        print(ct.description)  # "Email address"
+
 .. _datasette_add_database:
 
 .add_database(db, name=None, route=None)

From 9fe10cd1aadd0a22cef901d27dc6e95ddb66b1d7 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:50:58 +0000
Subject: [PATCH 08/14] Apply black and blacken-docs formatting

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/app.py       | 34 ++++++++++++++++++++--------------
 datasette/hookspecs.py | 12 ++++++++++--
 datasette/views/row.py | 24 +++++++++++++++++-------
 docs/internals.rst     | 11 ++++++-----
 docs/plugin_hooks.rst  | 10 ++++++++--
 5 files changed, 61 insertions(+), 30 deletions(-)

diff --git a/datasette/app.py b/datasette/app.py
index 1a20dbd0..3793cd55 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -699,9 +699,7 @@ class Datasette:
             if hook:
                 for ct in hook:
                     if ct.name in self._column_types:
-                        raise StartupError(
-                            f"Duplicate column type name: {ct.name}"
-                        )
+                        raise StartupError(f"Duplicate column type name: {ct.name}")
                     self._column_types[ct.name] = ct
 
         for hook in pm.hook.prepare_jinja2_environment(
@@ -977,15 +975,16 @@ class Datasette:
                         logging.warning(
                             "column_types config references unknown type %r "
                             "for %s.%s.%s",
-                            col_type, db_name, table_name, col_name,
+                            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
-    ) -> tuple:
+    async def get_column_type(self, database: str, resource: str, column: str) -> tuple:
         """
         Return (column_type_name, config_dict) for a specific column,
         or (None, None) if no column type is assigned.
@@ -1001,9 +1000,7 @@ class Datasette:
         ct, config = rows[0]
         return (ct, json.loads(config) if config else None)
 
-    async def get_column_types(
-        self, database: str, resource: str
-    ) -> dict:
+    async def get_column_types(self, database: str, resource: str) -> dict:
         """
         Return {column_name: (column_type_name, config_dict_or_None)}
         for all columns with assigned types on the given resource.
@@ -1019,16 +1016,25 @@ class Datasette:
         }
 
     async def set_column_type(
-        self, database: str, resource: str, column: str,
-        column_type: str, config: dict = None
+        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],
+            [
+                database,
+                resource,
+                column,
+                column_type,
+                json.dumps(config) if config else None,
+            ],
         )
 
     async def remove_column_type(
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index ec779659..86cd529e 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -56,8 +56,16 @@ def publish_subcommand(publish):
 
 @hookspec
 def render_cell(
-    row, value, column, table, pks, database, datasette, request,
-    column_type, column_type_config
+    row,
+    value,
+    column,
+    table,
+    pks,
+    database,
+    datasette,
+    request,
+    column_type,
+    column_type_config,
 ):
     """Customize rendering of HTML table cell values"""
 
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 0702368d..d1713d4d 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -193,18 +193,27 @@ class RowView(DataView):
                         ct_class = self.ds.get_column_type_class(ct_name)
                         if ct_class:
                             candidate = await ct_class.render_cell(
-                                value=value, column=column, table=table,
-                                database=database, datasette=self.ds,
-                                request=request, config=ct_config,
+                                value=value,
+                                column=column,
+                                table=table,
+                                database=database,
+                                datasette=self.ds,
+                                request=request,
+                                config=ct_config,
                             )
                             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, pks=resolved.pks,
-                            database=database, datasette=self.ds,
-                            request=request, column_type=ct_name,
+                            row=row,
+                            value=value,
+                            column=column,
+                            table=table,
+                            pks=resolved.pks,
+                            database=database,
+                            datasette=self.ds,
+                            request=request,
+                            column_type=ct_name,
                             column_type_config=ct_config,
                         ):
                             candidate = await await_me_maybe(candidate)
@@ -366,6 +375,7 @@ class RowUpdateView(BaseView):
 
         # 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]
         )
diff --git a/docs/internals.rst b/docs/internals.rst
index f1064b8b..e9c6454e 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -944,9 +944,7 @@ Returns a dictionary mapping column names to ``(column_type_name, config)`` tupl
 
 .. code-block:: python
 
-    ct_map = await datasette.get_column_types(
-        "mydb", "mytable"
-    )
+    ct_map = await datasette.get_column_types("mydb", "mytable")
     # {"email_col": ("email", None), "site": ("url", None)}
 
 .. _datasette_set_column_type:
@@ -970,8 +968,11 @@ Assigns a column type to a column. Overwrites any existing assignment for that c
 .. code-block:: python
 
     await datasette.set_column_type(
-        "mydb", "mytable", "location", "point",
-        config={"srid": 4326}
+        "mydb",
+        "mytable",
+        "location",
+        "point",
+        config={"srid": 4326},
     )
 
 .. _datasette_remove_column_type:
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index b25403b3..052c17b0 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -1013,8 +1013,14 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     class ColorColumnType(ColumnType):
         async def render_cell(
-            self, value, column, table, database,
-            datasette, request, config
+            self,
+            value,
+            column,
+            table,
+            database,
+            datasette,
+            request,
+            config,
         ):
             if value:
                 return markupsafe.Markup(

From 72c8c715186b95c5ccca5a51d1328fb006fc6698 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:54:36 +0000
Subject: [PATCH 09/14] Move column_type defaults into dict literal

Set column_type and column_type_config to None in the initial
col_dict instead of using an else branch.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 2b393087..30beb81f 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -208,14 +208,13 @@ async def display_columns_and_rows(
             "type": type_,
             "notnull": notnull,
             "description": column_descriptions.get(r[0]),
+            "column_type": None,
+            "column_type_config": None,
         }
         ct_info = column_types_map.get(r[0])
         if ct_info:
             col_dict["column_type"] = ct_info[0]
             col_dict["column_type_config"] = ct_info[1]
-        else:
-            col_dict["column_type"] = None
-            col_dict["column_type_config"] = None
         columns.append(col_dict)
 
     column_to_foreign_key_table = {

From 8af98c24c26b86e27c4f422f42c507f4104ede3b Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:00:57 +0000
Subject: [PATCH 10/14] Move name and description to class attributes on
 ColumnType

Instead of passing name= and description= as constructor arguments,
define them as class attributes on each subclass. This better reflects
that they are intrinsic to the type, not configurable per-instance.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/column_types.py         | 20 +++++++++-----------
 datasette/default_column_types.py | 12 +++++++++---
 docs/plugin_hooks.rst             | 12 +++++-------
 tests/test_column_types.py        | 23 +++++++++++++++++------
 4 files changed, 40 insertions(+), 27 deletions(-)

diff --git a/datasette/column_types.py b/datasette/column_types.py
index 240bcc8f..e5b54845 100644
--- a/datasette/column_types.py
+++ b/datasette/column_types.py
@@ -1,19 +1,17 @@
-from dataclasses import dataclass
-
-
-@dataclass(frozen=True, kw_only=True)
 class ColumnType:
-    name: str
     """
-    Unique identifier string. Lowercase, no spaces.
-    Examples: "markdown", "file", "email", "url", "point", "image".
+    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".
     """
 
+    name: str
     description: str
-    """
-    Human-readable label for admin UI dropdowns.
-    Examples: "Markdown text", "File reference", "Email address".
-    """
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py
index 24e761ba..87d9713d 100644
--- a/datasette/default_column_types.py
+++ b/datasette/default_column_types.py
@@ -8,6 +8,8 @@ from datasette.column_types import ColumnType
 
 
 class UrlColumnType(ColumnType):
+    name = "url"
+    description = "URL"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -28,6 +30,8 @@ class UrlColumnType(ColumnType):
 
 
 class EmailColumnType(ColumnType):
+    name = "email"
+    description = "Email address"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -48,6 +52,8 @@ class EmailColumnType(ColumnType):
 
 
 class JsonColumnType(ColumnType):
+    name = "json"
+    description = "JSON data"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -76,7 +82,7 @@ class JsonColumnType(ColumnType):
 @hookimpl
 def register_column_types(datasette):
     return [
-        UrlColumnType(name="url", description="URL"),
-        EmailColumnType(name="email", description="Email address"),
-        JsonColumnType(name="json", description="JSON data"),
+        UrlColumnType(),
+        EmailColumnType(),
+        JsonColumnType(),
     ]
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 052c17b0..bab70edf 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -1012,6 +1012,9 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
 
     class ColorColumnType(ColumnType):
+        name = "color"
+        description = "CSS color value"
+
         async def render_cell(
             self,
             value,
@@ -1045,14 +1048,9 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     @hookimpl
     def register_column_types(datasette):
-        return [
-            ColorColumnType(
-                name="color",
-                description="CSS color value",
-            )
-        ]
+        return [ColorColumnType()]
 
-Each ``ColumnType`` instance has the following attributes:
+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.
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 7e16e6c2..8315d603 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -352,7 +352,11 @@ async def test_validation_allows_empty_string(ds_ct):
 
 @pytest.mark.asyncio
 async def test_column_type_base_defaults():
-    ct = ColumnType(name="test", description="Test type")
+    class TestType(ColumnType):
+        name = "test"
+        description = "Test type"
+
+    ct = TestType()
     assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
     assert await ct.validate("val", None, None) is None
     assert await ct.transform_value("val", None, None) == "val"
@@ -378,6 +382,9 @@ async def test_render_cell_extra_with_column_types(ds_ct):
 @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, config
         ):
@@ -386,7 +393,7 @@ async def test_duplicate_column_type_name_raises_error():
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [DuplicateUrlType(name="url", description="Duplicate URL")]
+            return [DuplicateUrlType()]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_duplicate_ct")
@@ -420,6 +427,9 @@ 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, config, datasette):
             if isinstance(value, str):
                 return value.upper()
@@ -428,7 +438,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [UpperColumnType(name="upper", description="Uppercase")]
+            return [UpperColumnType()]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_transform_ct")
@@ -469,6 +479,9 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     """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, config
         ):
@@ -481,9 +494,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [
-                PriorityColumnType(name="priority_test", description="Priority test")
-            ]
+            return [PriorityColumnType()]
 
     class _RenderCellPlugin:
         @hookimpl

From dd9b83301cf2cd7665c987aa170ef0e1849005e7 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:18:14 +0000
Subject: [PATCH 11/14] Refactor ColumnType: register classes, return instances
 with config

- register_column_types() now returns classes instead of instances
- ColumnType.__init__ takes optional config=, baking it into the instance
- get_column_type() returns a ColumnType instance (or None) instead of a
  (name, config) tuple
- get_column_types() returns {col: ColumnType instance} instead of tuples
- Remove get_column_type_class() - no longer needed
- render_cell/validate/transform_value methods no longer take config arg;
  use self.config instead
- render_cell hook takes column_type (ColumnType or None) instead of
  column_type + column_type_config

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/app.py                  |  45 ++++----
 datasette/column_types.py         |  20 ++--
 datasette/default_column_types.py |  24 ++--
 datasette/hookspecs.py            |   1 -
 datasette/views/database.py       |   1 -
 datasette/views/row.py            |  32 +++---
 datasette/views/table.py          |  94 +++++++---------
 docs/internals.rst                |  36 +++---
 docs/plugin_hooks.rst             |  30 ++---
 tests/test_column_types.py        | 179 +++++++++++++++++-------------
 tests/test_plugins.py             |   8 +-
 11 files changed, 227 insertions(+), 243 deletions(-)

diff --git a/datasette/app.py b/datasette/app.py
index 3793cd55..3790b340 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -693,14 +693,14 @@ class Datasette:
                         action_abbrs[action.abbr] = action
                     self.actions[action.name] = action
 
-        # Register column types
+        # Register column types (classes, not instances)
         self._column_types = {}
         for hook in pm.hook.register_column_types(datasette=self):
             if hook:
-                for ct in hook:
-                    if ct.name in self._column_types:
-                        raise StartupError(f"Duplicate column type name: {ct.name}")
-                    self._column_types[ct.name] = ct
+                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
@@ -984,10 +984,10 @@ class Datasette:
                         db_name, table_name, col_name, col_type, config
                     )
 
-    async def get_column_type(self, database: str, resource: str, column: str) -> tuple:
+    async def get_column_type(self, database: str, resource: str, column: str):
         """
-        Return (column_type_name, config_dict) for a specific column,
-        or (None, None) if no column type is assigned.
+        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 "
@@ -996,13 +996,16 @@ class Datasette:
         )
         rows = row.rows
         if not rows:
-            return None, None
-        ct, config = rows[0]
-        return (ct, json.loads(config) if config else None)
+            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: (column_type_name, config_dict_or_None)}
+        Return {column_name: ColumnType instance (with config)}
         for all columns with assigned types on the given resource.
         """
         rows = await self.get_internal_database().execute(
@@ -1010,10 +1013,13 @@ class Datasette:
             "WHERE database_name = ? AND resource_name = ?",
             [database, resource],
         )
-        return {
-            row[0]: (row[1], json.loads(row[2]) if row[2] else None)
-            for row in rows.rows
-        }
+        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,
@@ -1047,13 +1053,6 @@ class Datasette:
             [database, resource, column],
         )
 
-    def get_column_type_class(self, column_type_name: str):
-        """
-        Return the registered ColumnType instance for a given name,
-        or None if no plugin has registered that name.
-        """
-        return self._column_types.get(column_type_name)
-
     def get_internal_database(self):
         return self._internal_database
 
diff --git a/datasette/column_types.py b/datasette/column_types.py
index e5b54845..c4114294 100644
--- a/datasette/column_types.py
+++ b/datasette/column_types.py
@@ -8,31 +8,35 @@ class ColumnType:
       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
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    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.
-
-        ``config`` is the parsed JSON config dict for this specific
-        column assignment, or None.
         """
         return None
 
-    async def validate(self, value, config, datasette):
+    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, config, datasette):
+    async def transform_value(self, value, datasette):
         """
         Transform a value before it appears in JSON API output.
         Return the transformed value. Default: return unchanged.
diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py
index 87d9713d..b4ebfcc5 100644
--- a/datasette/default_column_types.py
+++ b/datasette/default_column_types.py
@@ -11,15 +11,13 @@ class UrlColumnType(ColumnType):
     name = "url"
     description = "URL"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    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'{escaped}')
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if not isinstance(value, str):
@@ -33,15 +31,13 @@ class EmailColumnType(ColumnType):
     name = "email"
     description = "Email address"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    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'{escaped}')
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if not isinstance(value, str):
@@ -55,9 +51,7 @@ class JsonColumnType(ColumnType):
     name = "json"
     description = "JSON data"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    async def render_cell(self, value, column, table, database, datasette, request):
         if value is None:
             return None
         try:
@@ -68,7 +62,7 @@ class JsonColumnType(ColumnType):
         except (json.JSONDecodeError, TypeError):
             return None
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if isinstance(value, str):
@@ -81,8 +75,4 @@ class JsonColumnType(ColumnType):
 
 @hookimpl
 def register_column_types(datasette):
-    return [
-        UrlColumnType(),
-        EmailColumnType(),
-        JsonColumnType(),
-    ]
+    return [UrlColumnType, EmailColumnType, JsonColumnType]
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 86cd529e..f7bb6ab6 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -65,7 +65,6 @@ def render_cell(
     datasette,
     request,
     column_type,
-    column_type_config,
 ):
     """Customize rendering of HTML table cell values"""
 
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 29533215..916cdbc1 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -1206,7 +1206,6 @@ async def display_rows(datasette, database, request, rows, columns):
                 datasette=datasette,
                 request=request,
                 column_type=None,
-                column_type_config=None,
             ):
                 candidate = await await_me_maybe(candidate)
                 if candidate is not None:
diff --git a/datasette/views/row.py b/datasette/views/row.py
index d1713d4d..4eacfe49 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -184,25 +184,20 @@ class RowView(DataView):
             for row in rows:
                 rendered_row = {}
                 for value, column in zip(row, columns):
-                    ct_info = ct_map.get(column)
-                    ct_name = ct_info[0] if ct_info else None
-                    ct_config = ct_info[1] if ct_info else None
+                    ct = ct_map.get(column)
                     plugin_display_value = None
                     # Try column type render_cell first
-                    if ct_name:
-                        ct_class = self.ds.get_column_type_class(ct_name)
-                        if ct_class:
-                            candidate = await ct_class.render_cell(
-                                value=value,
-                                column=column,
-                                table=table,
-                                database=database,
-                                datasette=self.ds,
-                                request=request,
-                                config=ct_config,
-                            )
-                            if candidate is not None:
-                                plugin_display_value = candidate
+                    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
                     if plugin_display_value is None:
                         for candidate in pm.hook.render_cell(
                             row=row,
@@ -213,8 +208,7 @@ class RowView(DataView):
                             database=database,
                             datasette=self.ds,
                             request=request,
-                            column_type=ct_name,
-                            column_type_config=ct_config,
+                            column_type=ct,
                         ):
                             candidate = await await_me_maybe(candidate)
                             if candidate is not None:
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 30beb81f..035abb1b 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -141,13 +141,10 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
         return []
     errors = []
     for row in rows:
-        for col_name, (ct_name, ct_config) in ct_map.items():
+        for col_name, ct in ct_map.items():
             if col_name not in row:
                 continue
-            ct_class = datasette.get_column_type_class(ct_name)
-            if ct_class is None:
-                continue
-            error = await ct_class.validate(row[col_name], ct_config, datasette)
+            error = await ct.validate(row[col_name], datasette)
             if error:
                 errors.append(f"{col_name}: {error}")
     return errors
@@ -211,10 +208,10 @@ async def display_columns_and_rows(
             "column_type": None,
             "column_type_config": None,
         }
-        ct_info = column_types_map.get(r[0])
-        if ct_info:
-            col_dict["column_type"] = ct_info[0]
-            col_dict["column_type_config"] = ct_info[1]
+        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 = {
@@ -257,22 +254,18 @@ async def display_columns_and_rows(
             # First try column type render_cell, then plugins
             # pylint: disable=no-member
             plugin_display_value = None
-            ct_name = column_dict.get("column_type")
-            ct_config = column_dict.get("column_type_config")
-            if ct_name:
-                ct_class = datasette.get_column_type_class(ct_name)
-                if ct_class:
-                    candidate = await ct_class.render_cell(
-                        value=value,
-                        column=column,
-                        table=table_name,
-                        database=database_name,
-                        datasette=datasette,
-                        request=request,
-                        config=ct_config,
-                    )
-                    if candidate is not None:
-                        plugin_display_value = 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
             if plugin_display_value is None:
                 for candidate in pm.hook.render_cell(
                     row=row,
@@ -283,8 +276,7 @@ async def display_columns_and_rows(
                     database=database_name,
                     datasette=datasette,
                     request=request,
-                    column_type=ct_name,
-                    column_type_config=ct_config,
+                    column_type=ct,
                 ):
                     candidate = await await_me_maybe(candidate)
                     if candidate is not None:
@@ -1559,25 +1551,20 @@ async def table_view_data(
         for row in rows:
             rendered_row = {}
             for value, column in zip(row, col_names):
-                ct_info = ct_map.get(column)
-                ct_name = ct_info[0] if ct_info else None
-                ct_config = ct_info[1] if ct_info else None
+                ct = ct_map.get(column)
                 plugin_display_value = None
                 # Try column type render_cell first
-                if ct_name:
-                    ct_class = datasette.get_column_type_class(ct_name)
-                    if ct_class:
-                        candidate = await ct_class.render_cell(
-                            value=value,
-                            column=column,
-                            table=table_name,
-                            database=database_name,
-                            datasette=datasette,
-                            request=request,
-                            config=ct_config,
-                        )
-                        if candidate is not None:
-                            plugin_display_value = candidate
+                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,
@@ -1588,8 +1575,7 @@ async def table_view_data(
                         database=database_name,
                         datasette=datasette,
                         request=request,
-                        column_type=ct_name,
-                        column_type_config=ct_config,
+                        column_type=ct,
                     ):
                         candidate = await await_me_maybe(candidate)
                         if candidate is not None:
@@ -1612,10 +1598,10 @@ async def table_view_data(
         ct_map = await datasette.get_column_types(database_name, table_name)
         return {
             col_name: {
-                "type": ct_name,
-                "config": ct_config,
+                "type": ct.name,
+                "config": ct.config,
             }
-            for col_name, (ct_name, ct_config) in ct_map.items()
+            for col_name, ct in ct_map.items()
         }
 
     async def extra_metadata():
@@ -1866,13 +1852,11 @@ async def table_view_data(
     transformed_rows = []
     for r in raw_sqlite_rows:
         row_dict = dict(r)
-        for col_name, (ct_name, ct_config) in ct_map.items():
+        for col_name, ct in ct_map.items():
             if col_name in row_dict:
-                ct_class = datasette.get_column_type_class(ct_name)
-                if ct_class:
-                    row_dict[col_name] = await ct_class.transform_value(
-                        row_dict[col_name], ct_config, datasette
-                    )
+                row_dict[col_name] = await ct.transform_value(
+                    row_dict[col_name], datasette
+                )
         transformed_rows.append(row_dict)
     data["rows"] = transformed_rows
 
diff --git a/docs/internals.rst b/docs/internals.rst
index e9c6454e..5adb4cac 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -922,13 +922,16 @@ await .get_column_type(database, resource, column)
 ``column`` - string
     The name of the column.
 
-Returns a ``(column_type_name, config)`` tuple for the specified column. ``column_type_name`` is a string like ``"email"`` or ``"url"``, and ``config`` is a dict or ``None``. If no column type is assigned, returns ``(None, None)``.
+Returns a :ref:`ColumnType ` instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
 
 .. code-block:: python
 
-    ct_name, config = await datasette.get_column_type(
+    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:
 
@@ -940,12 +943,13 @@ await .get_column_types(database, resource)
 ``resource`` - string
     The name of the table or view.
 
-Returns a dictionary mapping column names to ``(column_type_name, config)`` tuples for all columns that have assigned types on the given resource.
+Returns a dictionary mapping column names to :ref:`ColumnType ` 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")
-    # {"email_col": ("email", None), "site": ("url", None)}
+    for col_name, ct in ct_map.items():
+        print(col_name, ct.name, ct.config)
 
 .. _datasette_set_column_type:
 
@@ -995,22 +999,6 @@ Removes the column type assignment for the specified column.
         "mydb", "mytable", "location"
     )
 
-.. _datasette_get_column_type_class:
-
-.get_column_type_class(column_type_name)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-``column_type_name`` - string
-    The name of the column type, e.g. ``"email"``.
-
-Returns the registered ``ColumnType`` instance for the given name, or ``None`` if no plugin has registered a column type with that name. This is a synchronous method.
-
-.. code-block:: python
-
-    ct = datasette.get_column_type_class("email")
-    if ct:
-        print(ct.description)  # "Email address"
-
 .. _datasette_add_database:
 
 .add_database(db, name=None, route=None)
@@ -2049,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]]]
 
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index bab70edf..916f3449 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -474,8 +474,8 @@ Examples: `datasette-publish-fly ` assigned to this column, or ``None`` if no column type is assigned.
-
-``column_type_config`` - dict or None
-    The configuration dict for the assigned column type, or ``None``.
+``column_type`` - :ref:`ColumnType ` instance or None
+    The :ref:`ColumnType ` 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 ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
 
@@ -1002,7 +999,7 @@ The permission system then uses this query along with rules from plugins to dete
 register_column_types(datasette)
 --------------------------------
 
-Return a list of :ref:`ColumnType ` instances to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+Return a list of :ref:`ColumnType ` **classes** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
 
 .. code-block:: python
 
@@ -1023,7 +1020,6 @@ Return a list of :ref:`ColumnType ` instances to register custom c
             database,
             datasette,
             request,
-            config,
         ):
             if value:
                 return markupsafe.Markup(
@@ -1032,14 +1028,12 @@ Return a list of :ref:`ColumnType ` instances to register custom c
                 ).format(color=markupsafe.escape(value))
             return None
 
-        async def validate(self, value, config, datasette):
+        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, config, datasette
-        ):
+        async def transform_value(self, value, datasette):
             # Normalize to uppercase
             if isinstance(value, str):
                 return value.upper()
@@ -1048,7 +1042,7 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     @hookimpl
     def register_column_types(datasette):
-        return [ColorColumnType()]
+        return [ColorColumnType]
 
 Each ``ColumnType`` subclass must define the following class attributes:
 
@@ -1060,16 +1054,16 @@ Each ``ColumnType`` subclass must define the following class attributes:
 
 And the following methods, all optional:
 
-``render_cell(self, value, column, table, database, datasette, request, config)``
+``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, config, datasette)``
+``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, config, datasette)``
+``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.
 
-The ``config`` argument passed to these methods is the parsed JSON config dict for the specific column assignment, or ``None`` if no config was provided.
+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 ` or :ref:`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 `:
 
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 8315d603..0100a079 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -84,47 +84,62 @@ async def test_column_types_table_created(ds_ct):
 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")
-    assert "body" in ct_map
-    assert ct_map["body"] == ("markdown", None)
-    assert ct_map["author_email"] == ("email", None)
-    assert ct_map["website"] == ("url", None)
-    assert ct_map["metadata"] == ("json", None)
+    # "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):
-    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},
+    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, config = await ds.get_column_type("data", "geo", "location")
-    assert ct == "point"
-    assert config == {"srid": 4326}
-    db.close()
-    for database in ds.databases.values():
-        if not database.is_memory:
-            database.close()
+            },
+        )
+        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 ---
@@ -133,39 +148,39 @@ async def test_config_with_type_and_config(tmp_path_factory):
 @pytest.mark.asyncio
 async def test_get_column_type(ds_ct):
     await ds_ct.invoke_startup()
-    ct, config = await ds_ct.get_column_type("data", "posts", "author_email")
-    assert ct == "email"
-    assert config is None
+    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, config = await ds_ct.get_column_type("data", "posts", "title")
+    ct = await ds_ct.get_column_type("data", "posts", "title")
     assert ct is None
-    assert config 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", "markdown")
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
-    assert ct == "markdown"
-    assert config is None
+    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, config = await ds_ct.get_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", "file", {"accept": "image/*"})
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
-    assert ct == "file"
-    assert config == {"accept": "image/*"}
+    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 ---
@@ -173,22 +188,23 @@ async def test_set_column_type_with_config(ds_ct):
 
 @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 ds_ct.get_column_type_class("url") is not None
-    assert ds_ct.get_column_type_class("email") is not None
-    assert ds_ct.get_column_type_class("json") is not None
-    assert ds_ct.get_column_type_class("nonexistent") is None
+    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_type = ds_ct.get_column_type_class("url")
-    assert url_type.name == "url"
-    assert url_type.description == "URL"
-    email_type = ds_ct.get_column_type_class("email")
-    assert email_type.name == "email"
-    assert email_type.description == "Email address"
+    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 ---
@@ -201,9 +217,11 @@ async def test_column_types_extra(ds_ct):
     assert response.status_code == 200
     data = response.json()
     assert "column_types" in data
-    assert data["column_types"]["body"] == {"type": "markdown", "config": None}
     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"]
 
@@ -357,9 +375,21 @@ async def test_column_type_base_defaults():
         description = "Test type"
 
     ct = TestType()
-    assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
-    assert await ct.validate("val", None, None) is None
-    assert await ct.transform_value("val", None, None) == "val"
+    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"
+
+
+@pytest.mark.asyncio
+async def test_column_type_with_config():
+    class TestType(ColumnType):
+        name = "test"
+        description = "Test type"
+
+    ct = TestType(config={"key": "value"})
+    assert ct.config == {"key": "value"}
+    assert ct.name == "test"
 
 
 # --- render_cell extra with column types ---
@@ -385,15 +415,13 @@ async def test_duplicate_column_type_name_raises_error():
         name = "url"
         description = "Duplicate URL"
 
-        async def render_cell(
-            self, value, column, table, database, datasette, request, config
-        ):
+        async def render_cell(self, value, column, table, database, datasette, request):
             return None
 
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [DuplicateUrlType()]
+            return [DuplicateUrlType]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_duplicate_ct")
@@ -430,7 +458,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
         name = "upper"
         description = "Uppercase"
 
-        async def transform_value(self, value, config, datasette):
+        async def transform_value(self, value, datasette):
             if isinstance(value, str):
                 return value.upper()
             return value
@@ -438,7 +466,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [UpperColumnType()]
+            return [UpperColumnType]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_transform_ct")
@@ -482,9 +510,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
         name = "priority_test"
         description = "Priority test"
 
-        async def render_cell(
-            self, value, column, table, database, datasette, request, config
-        ):
+        async def render_cell(self, value, column, table, database, datasette, request):
             if value is not None:
                 return markupsafe.Markup(
                     f"COLUMN_TYPE:{markupsafe.escape(value)}"
@@ -494,7 +520,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [PriorityColumnType()]
+            return [PriorityColumnType]
 
     class _RenderCellPlugin:
         @hookimpl
@@ -509,7 +535,6 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
             datasette,
             request,
             column_type,
-            column_type_config,
         ):
             if column == "name":
                 return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
@@ -663,18 +688,18 @@ async def test_config_overwrites_on_restart(tmp_path_factory):
         },
     )
     await ds.invoke_startup()
-    ct, _ = await ds.get_column_type("data", "t", "col")
-    assert ct == "email"
+    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 == "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 == "email"  # Config wins
+    ct = await ds.get_column_type("data", "t", "col")
+    assert ct.name == "email"  # Config wins
 
     db.close()
     for database in ds.databases.values():
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index b3014275..47d727f2 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1955,7 +1955,7 @@ async def test_hook_register_column_types():
     ds = Datasette()
     await ds.invoke_startup()
     # Built-in column types should be registered
-    assert ds.get_column_type_class("url") is not None
-    assert ds.get_column_type_class("email") is not None
-    assert ds.get_column_type_class("json") is not None
-    assert ds.get_column_type_class("nonexistent") is None
+    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

From da0ea4382ce6bd822431506ca93ccbaba5fd566a Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:18:42 +0000
Subject: [PATCH 12/14] Update cog-generated plugin list to include
 default_column_types

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/plugins.rst | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/docs/plugins.rst b/docs/plugins.rst
index 60bdc111..03cbedeb 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -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,

From c73a1c907a69783a2eeb99a7f41037979694256b Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:22:53 +0000
Subject: [PATCH 13/14] Use 'subclass' instead of 'class' in ColumnType docs

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/internals.rst    | 4 ++--
 docs/plugin_hooks.rst | 6 +++---
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/docs/internals.rst b/docs/internals.rst
index 5adb4cac..6bd3d41d 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -922,7 +922,7 @@ await .get_column_type(database, resource, column)
 ``column`` - string
     The name of the column.
 
-Returns a :ref:`ColumnType ` instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
+Returns a :ref:`ColumnType ` subclass instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
 
 .. code-block:: python
 
@@ -943,7 +943,7 @@ await .get_column_types(database, resource)
 ``resource`` - string
     The name of the table or view.
 
-Returns a dictionary mapping column names to :ref:`ColumnType ` instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
+Returns a dictionary mapping column names to :ref:`ColumnType ` subclass instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
 
 .. code-block:: python
 
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 916f3449..69710bb6 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -503,8 +503,8 @@ 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 ` instance or None
-    The :ref:`ColumnType ` 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.
+``column_type`` - :ref:`ColumnType ` subclass instance or None
+    The :ref:`ColumnType ` 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 ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
 
@@ -999,7 +999,7 @@ The permission system then uses this query along with rules from plugins to dete
 register_column_types(datasette)
 --------------------------------
 
-Return a list of :ref:`ColumnType ` **classes** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+Return a list of :ref:`ColumnType ` **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 b7578a48849effa6b5d15f17330ef7b29fc5cad9 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:24:09 +0000
Subject: [PATCH 14/14] Remove pointless test_column_type_with_config test

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 tests/test_column_types.py | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 0100a079..2929c1f3 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -381,17 +381,6 @@ async def test_column_type_base_defaults():
     assert await ct.transform_value("val", None) == "val"
 
 
-@pytest.mark.asyncio
-async def test_column_type_with_config():
-    class TestType(ColumnType):
-        name = "test"
-        description = "Test type"
-
-    ct = TestType(config={"key": "value"})
-    assert ct.config == {"key": "value"}
-    assert ct.name == "test"
-
-
 # --- render_cell extra with column types ---