From 296754bacbb92c5cca19e4eb5f3fb06195a57374 Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Wed, 1 Jul 2026 15:28:44 +0000 Subject: [PATCH] feat(sqlalchemy): derive NumberInput bounds and step from Numeric precision/scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sa.Numeric(precision, scale) column fully defines the value range the database will accept and the smallest representable increment, but until now rendered as a bare NumberInput with no client-side bounds or step. Emit minValue/maxValue validators from precision/scale (the DB range) and a step input prop from scale. Explicit CheckConstraint bounds still win — type-derived bounds only fill in the side a constraint left open. Float is excluded: its precision counts binary digits, not decimal. --- aiohttp_admin/backends/sqlalchemy.py | 19 +++++++++++++ tests/test_backends_sqlalchemy.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/aiohttp_admin/backends/sqlalchemy.py b/aiohttp_admin/backends/sqlalchemy.py index 26df46ea..936df3ce 100644 --- a/aiohttp_admin/backends/sqlalchemy.py +++ b/aiohttp_admin/backends/sqlalchemy.py @@ -4,6 +4,7 @@ import operator import sys from collections.abc import Callable, Coroutine, Iterator, Sequence +from decimal import Decimal from types import MappingProxyType as MPT from typing import Any, Literal, Optional, TypeVar, Union, cast @@ -234,6 +235,9 @@ def __init__(self, db: AsyncEngine, model_or_table: _ModelOrTable): show = c is not table.autoincrement_column inp_props["validate"] = self._get_validators(table, c) if inp == "NumberInput": + if (isinstance(c.type, sa.Numeric) + and not isinstance(c.type, sa.Float) and c.type.scale): + inp_props["step"] = float(Decimal(10) ** -c.type.scale) for v in inp_props["validate"]: if v["name"] == "minValue": inp_props["min"] = v["args"][0] @@ -474,4 +478,19 @@ def _get_validators(self, table: sa.Table, c: sa.Column[object]) -> list[Functio continue validators.append(func("regex", (regex(clauses[1].value),))) + # Derive value bounds from Numeric precision/scale (the range the DB will + # accept). Float precision counts binary digits, not decimal, so skip it. + # Explicit CheckConstraint bounds take precedence over the type-wide range. + if isinstance(c.type, sa.Numeric) and not isinstance(c.type, sa.Float): + precision = c.type.precision + if precision is not None: + scale = c.type.scale or 0 + bound = Decimal(10) ** (precision - scale) - Decimal(10) ** (-scale) + value = int(bound) if scale == 0 else float(bound) + names = {v["name"] for v in validators} + if "maxValue" not in names: + validators.append(func("maxValue", (value,))) + if "minValue" not in names: + validators.append(func("minValue", (-value,))) + return validators diff --git a/tests/test_backends_sqlalchemy.py b/tests/test_backends_sqlalchemy.py index 0f529308..5a271606 100644 --- a/tests/test_backends_sqlalchemy.py +++ b/tests/test_backends_sqlalchemy.py @@ -1,6 +1,7 @@ import json from collections.abc import Awaitable, Callable from datetime import date, datetime +from decimal import Decimal from typing import Optional, Union import pytest @@ -326,6 +327,47 @@ class TestCC(base): # type: ignore[misc,valid-type] assert f["with_or"]["props"]["validate"] == [required] +def test_numeric_precision_bounds(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: + class TestNum(base): # type: ignore[misc,valid-type] + __tablename__ = "test" + pk: Mapped[int] = mapped_column(primary_key=True) + money: Mapped[Decimal] = mapped_column(sa.Numeric(5, 2)) + whole: Mapped[Decimal] = mapped_column(sa.Numeric(4)) + flt: Mapped[float] = mapped_column(sa.Float()) + unbounded: Mapped[Decimal] = mapped_column(sa.Numeric()) + constrained: Mapped[Decimal] = mapped_column(sa.Numeric(6, 2)) + + __table_args__ = (sa.CheckConstraint(constrained <= 10),) + + r = SAResource(mock_engine, TestNum) + f = r.inputs + required = func("required", ()) + + # scale=2 -> step 0.01, bounds ±999.99 as validators and HTML min/max props. + assert f["money"]["props"]["step"] == 0.01 + assert f["money"]["props"]["validate"] == [ + required, func("maxValue", (999.99,)), func("minValue", (-999.99,))] + assert f["money"]["props"]["max"] == 999.99 + assert f["money"]["props"]["min"] == -999.99 + + # scale defaults to 0 -> integer bounds, no step. + assert "step" not in f["whole"]["props"] + assert f["whole"]["props"]["validate"] == [ + required, func("maxValue", (9999,)), func("minValue", (-9999,))] + + # Float precision counts binary digits, not decimal -> no bounds or step. + assert "step" not in f["flt"]["props"] + assert f["flt"]["props"]["validate"] == [required] + + # Numeric() without precision -> nothing to derive. + assert f["unbounded"]["props"]["validate"] == [required] + + # An explicit CheckConstraint bound wins; only the missing side is filled in. + assert f["constrained"]["props"]["step"] == 0.01 + assert f["constrained"]["props"]["validate"] == [ + required, func("maxValue", (10,)), func("minValue", (-9999.99,))] + + async def test_nonid_pk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test"