From 4f4c2d2bea19e3f29274caf0034a43e2e82f6876 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Wed, 1 Jul 2026 14:51:08 +0000 Subject: [PATCH] feat(sqlalchemy): validate UUID input fields UUID columns rendered as TextInput accepted any string on the client. Emit a canonical-form regex validator so malformed UUIDs are caught in the create/edit forms before submission. Completes the 'validators' TODO on the sa.Uuid mapping (part of #666). The frontend already deserializes regexp validator state, so no admin-js change is needed. --- aiohttp_admin/backends/sqlalchemy.py | 8 +++++++- tests/test_backends_sqlalchemy.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/aiohttp_admin/backends/sqlalchemy.py b/aiohttp_admin/backends/sqlalchemy.py index 26df46ea..58f49ec0 100644 --- a/aiohttp_admin/backends/sqlalchemy.py +++ b/aiohttp_admin/backends/sqlalchemy.py @@ -32,6 +32,10 @@ logger = logging.getLogger(__name__) +# Canonical hyphenated UUID form (8-4-4-4-12 hex), matching str(uuid.UUID(...)). +_UUID_REGEX = ("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + _FieldTypesValues = tuple[str, str, MPT[str, object], MPT[str, object]] FIELD_TYPES: MPT[type[sa.types.TypeEngine[Any]], _FieldTypesValues] = MPT({ sa.Boolean: ("BooleanField", "BooleanInput", MPT({}), MPT({})), @@ -43,7 +47,7 @@ sa.Numeric: ("NumberField", "NumberInput", MPT({}), MPT({})), sa.String: ("TextField", "TextInput", MPT({}), MPT({})), sa.Time: ("TimeField", "TimeInput", MPT({}), MPT({})), - sa.Uuid: ("TextField", "TextInput", MPT({}), MPT({})), # TODO: validators + sa.Uuid: ("TextField", "TextInput", MPT({}), MPT({})), # TODO: Set fields for below types. # sa.sql.sqltypes._AbstractInterval: (), # sa.types._Binary: (), @@ -427,6 +431,8 @@ def _get_validators(self, table: sa.Table, c: sa.Column[object]) -> list[Functio max_length = getattr(c.type, "length", None) if max_length: validators.append(func("maxLength", (max_length,))) + if isinstance(c.type, sa.Uuid): + validators.append(func("regex", (regex(_UUID_REGEX),))) for constr in table.constraints: if not isinstance(constr, sa.CheckConstraint): diff --git a/tests/test_backends_sqlalchemy.py b/tests/test_backends_sqlalchemy.py index 0f529308..41f0cc9e 100644 --- a/tests/test_backends_sqlalchemy.py +++ b/tests/test_backends_sqlalchemy.py @@ -59,6 +59,21 @@ class TestModel(base): # type: ignore[misc,valid-type] } +def test_uuid_validator(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: + import uuid as uuid_mod + + class TestModel(base): # type: ignore[misc,valid-type] + __tablename__ = "dummy" + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[uuid_mod.UUID] = mapped_column(nullable=True) + + r = SAResource(mock_engine, TestModel) + uuid_pattern = ("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + assert r.inputs["token"]["props"]["validate"] == [ + func("regex", (regex(uuid_pattern),))] + + def test_table(mock_engine: AsyncEngine) -> None: dummy_table = sa.Table("dummy", sa.MetaData(), sa.Column("id", sa.Integer, primary_key=True),