Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions aiohttp_admin/backends/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
42 changes: 42 additions & 0 deletions tests/test_backends_sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading