Skip to content

Commit 52c8ae4

Browse files
Add validators to inputs (#688)
1 parent cbf9a5d commit 52c8ae4

File tree

8 files changed

+228
-16
lines changed

8 files changed

+228
-16
lines changed

admin-js/src/App.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
ReferenceManyField,
1313
SelectInput,
1414
TextField, TextInput,
15-
WithRecord, required
15+
WithRecord,
16+
email, maxLength, maxValue, minLength, minValue, regex, required
1617
} from "react-admin";
1718
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
1819

@@ -33,6 +34,7 @@ const STATE = JSON.parse(_body.dataset.state);
3334
// Create a mapping of components, so we can reference them by name later.
3435
const COMPONENTS = {BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, TextField,
3536
BooleanInput, DateInput, NumberInput, ReferenceInput, TextInput};
37+
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
3638

3739
/** Make an authenticated API request and return the response object. */
3840
function apiRequest(url, options) {
@@ -162,7 +164,16 @@ function createInputs(resource, name, perm_type, permissions) {
162164
const C = COMPONENTS[state["type"]];
163165
if (C === undefined)
164166
throw Error(`Unknown component '${state["type"]}'`);
165-
const c = <C source={field} {...state["props"]} />;
167+
168+
let validators = [];
169+
if (perm_type !== "view") {
170+
for (let validator of state["validators"]) {
171+
if (validator[0] === "regex")
172+
validator[1] = new RegExp(validator[1]);
173+
validators.push(VALIDATORS[validator[0]](...validator.slice(1)))
174+
}
175+
}
176+
const c = <C source={field} validate={validators} {...state["props"]} />;
166177
if (perm_type === "edit")
167178
// Don't render if filters disallow editing this field.
168179
components.push(<WithRecord source={field} render={

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import operator
34
from typing import Any, Iterator, Type, Union
45

56
import sqlalchemy as sa
@@ -57,7 +58,9 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, Type[Declara
5758
if c.computed is None:
5859
# TODO: Allow custom props (e.g. disabled, multiline, rows etc.)
5960
show = c is not table.autoincrement_column
60-
self.inputs[c.name] = {"type": inp, "props": props, "show_create": show}
61+
validators = self._get_validators(table, c)
62+
self.inputs[c.name] = {"type": inp, "props": props, "show_create": show,
63+
"validators": validators}
6164

6265
if not isinstance(model_or_table, sa.Table):
6366
# Append fields to represent ORM relationships.
@@ -187,3 +190,52 @@ async def delete_many(self, params: DeleteManyParams) -> list[Union[str, int]]:
187190
if ids:
188191
return ids
189192
raise web.HTTPNotFound()
193+
194+
def _get_validators(
195+
self, table: sa.Table, c: sa.Column[object]
196+
) -> list[tuple[Union[str, int], ...]]:
197+
validators: list[tuple[Union[str, int], ...]] = []
198+
if c.default is None and c.server_default is None and not c.nullable:
199+
validators.append(("required",))
200+
max_length = getattr(c.type, "length", None)
201+
if max_length:
202+
validators.append(("maxLength", max_length))
203+
204+
for constr in table.constraints:
205+
if not isinstance(constr, sa.CheckConstraint):
206+
continue
207+
if isinstance(constr.sqltext, sa.BinaryExpression):
208+
left = constr.sqltext.left
209+
right = constr.sqltext.right
210+
op = constr.sqltext.operator
211+
if left.expression is c:
212+
if not isinstance(right, sa.BindParameter) or right.value is None:
213+
continue
214+
if op is operator.ge: # type: ignore[comparison-overlap]
215+
validators.append(("minValue", right.value))
216+
elif op is operator.gt: # type: ignore[comparison-overlap]
217+
validators.append(("minValue", right.value + 1))
218+
elif op is operator.le: # type: ignore[comparison-overlap]
219+
validators.append(("maxValue", right.value))
220+
elif op is operator.lt: # type: ignore[comparison-overlap]
221+
validators.append(("maxValue", right.value - 1))
222+
elif isinstance(left, sa.Function):
223+
if left.name == "char_length":
224+
if next(iter(left.clauses)) is not c:
225+
continue
226+
if not isinstance(right, sa.BindParameter) or right.value is None:
227+
continue
228+
if op is operator.ge: # type: ignore[comparison-overlap]
229+
validators.append(("minLength", right.value))
230+
elif op is operator.gt: # type: ignore[comparison-overlap]
231+
validators.append(("minLength", right.value + 1))
232+
elif isinstance(constr.sqltext, sa.Function):
233+
if constr.sqltext.name in ("regexp", "regexp_like"):
234+
clauses = tuple(constr.sqltext.clauses)
235+
if clauses[0] is not c or not isinstance(clauses[1], sa.BindParameter):
236+
continue
237+
if clauses[1].value is None:
238+
continue
239+
validators.append(("regex", clauses[1].value))
240+
241+
return validators

aiohttp_admin/routes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from . import views
88
from .types import Schema
99

10+
_VALIDATORS = ("email", "maxLength", "maxValue", "minLength", "minValue", "regex", "required")
11+
1012

1113
def setup_resources(admin: web.Application, schema: Schema) -> None:
1214
admin["resources"] = []
@@ -31,7 +33,14 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
3133
if k not in omit_fields:
3234
v["props"]["alwaysOn"] = "alwaysOn" # Always display filter
3335

34-
state = {"fields": m.fields, "inputs": m.inputs, "list_omit": tuple(omit_fields),
36+
inputs = m.inputs.copy() # Don't modify the resource.
37+
for name, validators in r.get("validators", {}).items():
38+
if not all(v[0] in _VALIDATORS for v in validators):
39+
raise ValueError(f"First value in validators must be one of {_VALIDATORS}")
40+
inputs[name] = inputs[name].copy()
41+
inputs[name]["validators"] = tuple(inputs[name]["validators"]) + tuple(validators)
42+
43+
state = {"fields": m.fields, "inputs": inputs, "list_omit": tuple(omit_fields),
3544
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
3645
"bulk_update": r.get("bulk_update", {})}
3746
admin["state"]["resources"][m.name] = state

aiohttp_admin/types.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Collection
2-
from typing import Any, Awaitable, Callable, Optional, Sequence, TypedDict
2+
from typing import Any, Awaitable, Callable, Optional, Sequence, TypedDict, Union
33

44

55
class FieldState(TypedDict):
@@ -10,6 +10,9 @@ class FieldState(TypedDict):
1010
class InputState(FieldState):
1111
# Whether to show this input in the create form.
1212
show_create: bool
13+
# Validators to add to the input. Each validator is the name of the validator
14+
# function, followed by arguments for that function. e.g. ("minValue", 5)
15+
validators: Sequence[Sequence[Union[str, int]]]
1316

1417

1518
class _IdentityDict(TypedDict, total=False):
@@ -66,6 +69,8 @@ class _Resource(TypedDict, total=False):
6669
# Format: {"Button Label": {"field_to_update": "value_to_set"}}
6770
# e.g. {"Reset Views": {"views": 0}}
6871
bulk_update: dict[str, dict[str, Any]]
72+
# Custom validators to add to inputs.
73+
validators: dict[str, Sequence[Sequence[Union[str, int]]]]
6974

7075

7176
class Resource(_Resource):

examples/validators.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Minimal example with simple database models.
2+
3+
When running this file, admin will be accessible at /admin.
4+
"""
5+
6+
import sqlalchemy as sa
7+
from aiohttp import web
8+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
9+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
10+
11+
import aiohttp_admin
12+
from aiohttp_admin.backends.sqlalchemy import SAResource
13+
14+
15+
class Base(DeclarativeBase):
16+
"""Base model."""
17+
18+
19+
class User(Base):
20+
__tablename__ = "user"
21+
22+
id: Mapped[int] = mapped_column(primary_key=True)
23+
username: Mapped[str] = mapped_column(sa.String(32))
24+
email: Mapped[str | None]
25+
note: Mapped[str | None]
26+
votes: Mapped[int] = mapped_column()
27+
28+
__table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3),
29+
sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 5))
30+
31+
32+
async def check_credentials(username: str, password: str) -> bool:
33+
return username == "admin" and password == "admin"
34+
35+
36+
async def create_app() -> web.Application:
37+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
38+
session = async_sessionmaker(engine, expire_on_commit=False)
39+
40+
# Create some sample data
41+
async with engine.begin() as conn:
42+
await conn.run_sync(Base.metadata.create_all)
43+
async with session.begin() as sess:
44+
sess.add(User(username="Foo", votes=4))
45+
sess.add(User(username="Spam", votes=1, note="Second user"))
46+
47+
app = web.Application()
48+
49+
# This is the setup required for aiohttp-admin.
50+
schema: aiohttp_admin.Schema = {
51+
"security": {
52+
"check_credentials": check_credentials,
53+
"secure": False
54+
},
55+
"resources": ({"model": SAResource(engine, User),
56+
"validators": {"username": (("regex", r"^[A-Z][a-z]+$"),),
57+
"email": (("email",),)}},)
58+
}
59+
aiohttp_admin.setup(app, schema)
60+
61+
return app
62+
63+
if __name__ == "__main__":
64+
web.run_app(create_app())

tests/test_admin.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@ def test_path() -> None:
1919
assert str(admin.router["index"].url_for()) == "/another/admin"
2020

2121

22+
def test_validators() -> None:
23+
dummy = DummyResource(
24+
"dummy", {"id": {"type": "NumberField", "props": {}}},
25+
{"id": {"type": "NumberInput", "props": {}, "show_create": True,
26+
"validators": (("required",),)}}, "id")
27+
app = web.Application()
28+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
29+
"resources": ({"model": dummy,
30+
"validators": {"id": (("minValue", 3),)}},)}
31+
admin = aiohttp_admin.setup(app, schema)
32+
validators = admin["state"]["resources"]["dummy"]["inputs"]["id"]["validators"]
33+
# TODO(Pydantic2): Should be int 3 in both lines.
34+
assert validators == (("required",), ("minValue", "3"))
35+
assert ("minValue", "3") not in dummy.inputs["id"]["validators"]
36+
37+
# Invalid validator
38+
schema = {"security": {"check_credentials": check_credentials},
39+
"resources": ({"model": dummy, "validators": {"id": (("bad", 3),)}},)}
40+
with pytest.raises(ValueError, match="validators must be one of"):
41+
aiohttp_admin.setup(app, schema)
42+
43+
2244
def test_re() -> None:
2345
test_re = DummyResource("testre", {"id": {"type": "NumberField", "props": {}},
2446
"value": {"type": "TextField", "props": {}}}, {}, "id")
@@ -57,8 +79,9 @@ def test_display() -> None:
5779
model = DummyResource(
5880
"test",
5981
{"id": {"type": "TextField", "props": {}}, "foo": {"type": "TextField", "props": {}}},
60-
{"id": {"type": "TextInput", "props": {}, "show_create": False},
61-
"foo": {"type": "TextInput", "props": {}, "show_create": True}},
82+
{"id": {"type": "TextInput", "props": {}, "show_create": False,
83+
"validators": (("required",),)},
84+
"foo": {"type": "TextInput", "props": {}, "show_create": True, "validators": ()}},
6285
"id")
6386
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
6487
"resources": ({"model": model, "display": ("foo",)},)}

tests/test_backends_sqlalchemy.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import json
2-
from typing import Awaitable, Callable, Type
2+
from typing import Awaitable, Callable, Type, Union
33

44
import pytest
55
import sqlalchemy as sa
@@ -30,8 +30,10 @@ class TestModel(base): # type: ignore[misc,valid-type]
3030
}
3131
# Autoincremented PK should not be in create form
3232
assert r.inputs == {
33-
"id": {"type": "NumberInput", "show_create": False, "props": {}},
34-
"num": {"type": "TextInput", "show_create": True, "props": {}}
33+
"id": {"type": "NumberInput", "show_create": False, "props": {},
34+
"validators": [("required",)]},
35+
"num": {"type": "TextInput", "show_create": True, "props": {},
36+
"validators": [("required",)]}
3537
}
3638

3739

@@ -49,8 +51,10 @@ def test_table(mock_engine: AsyncEngine) -> None:
4951
}
5052
# Autoincremented PK should not be in create form
5153
assert r.inputs == {
52-
"id": {"type": "NumberInput", "show_create": False, "props": {}},
53-
"num": {"type": "TextInput", "show_create": True, "props": {}}
54+
"id": {"type": "NumberInput", "show_create": False, "props": {},
55+
"validators": [("required",)]},
56+
"num": {"type": "TextInput", "show_create": True, "props": {},
57+
"validators": [("maxLength", 30)]}
5458
}
5559

5660

@@ -69,7 +73,8 @@ class TestChildModel(base): # type: ignore[misc,valid-type]
6973
assert r.fields == {"id": {"type": "ReferenceField", "props": {"reference": "dummy"}}}
7074
# PK with FK constraint should be shown in create form.
7175
assert r.inputs == {"id": {
72-
"type": "ReferenceInput", "show_create": True, "props": {"reference": "dummy"}}}
76+
"type": "ReferenceInput", "show_create": True, "props": {"reference": "dummy"},
77+
"validators": [("required",)]}}
7378

7479

7580
def test_relationship(base: Type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
@@ -92,6 +97,47 @@ class TestOne(base): # type: ignore[misc,valid-type]
9297
assert "ones" not in r.inputs
9398

9499

100+
def test_check_constraints(base: Type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
101+
class TestCC(base): # type: ignore[misc,valid-type]
102+
__tablename__ = "test"
103+
pk: Mapped[int] = mapped_column(primary_key=True)
104+
default: Mapped[int] = mapped_column(default=5)
105+
server_default: Mapped[int] = mapped_column(server_default="4")
106+
nullable: Mapped[Union[int, None]]
107+
not_nullable: Mapped[int]
108+
max_length: Mapped[str] = mapped_column(sa.String(16))
109+
gt: Mapped[int] = mapped_column()
110+
gte: Mapped[int] = mapped_column()
111+
lt: Mapped[int] = mapped_column()
112+
lte: Mapped[Union[int, None]] = mapped_column()
113+
min_length: Mapped[str] = mapped_column()
114+
min_length_gt: Mapped[str] = mapped_column()
115+
regex: Mapped[str] = mapped_column()
116+
117+
__table_args__ = (sa.CheckConstraint(gt > 3), sa.CheckConstraint(gte >= 3),
118+
sa.CheckConstraint(lt < 3), sa.CheckConstraint(lte <= 3),
119+
sa.CheckConstraint(sa.func.char_length(min_length) >= 5),
120+
sa.CheckConstraint(sa.func.char_length(min_length_gt) > 5),
121+
sa.CheckConstraint(sa.func.regexp(regex, r"abc.*")))
122+
123+
r = SAResource(mock_engine, TestCC)
124+
125+
f = r.inputs
126+
assert f["pk"]["validators"] == [("required",)]
127+
assert f["default"]["validators"] == []
128+
assert f["server_default"]["validators"] == []
129+
assert f["nullable"]["validators"] == []
130+
assert f["not_nullable"]["validators"] == [("required",)]
131+
assert f["max_length"]["validators"] == [("required",), ("maxLength", 16)]
132+
assert f["gt"]["validators"] == [("required",), ("minValue", 4)]
133+
assert f["gte"]["validators"] == [("required",), ("minValue", 3)]
134+
assert f["lt"]["validators"] == [("required",), ("maxValue", 2)]
135+
assert f["lte"]["validators"] == [("maxValue", 3)]
136+
assert f["min_length"]["validators"] == [("required",), ("minLength", 5)]
137+
assert f["min_length_gt"]["validators"] == [("required",), ("minLength", 6)]
138+
assert f["regex"]["validators"] == [("required",), ("regex", "abc.*")]
139+
140+
95141
async def test_nonid_pk(base: Type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
96142
class TestModel(base): # type: ignore[misc,valid-type]
97143
__tablename__ = "test"
@@ -106,8 +152,10 @@ class TestModel(base): # type: ignore[misc,valid-type]
106152
"other": {"type": "TextField", "props": {}}
107153
}
108154
assert r.inputs == {
109-
"num": {"type": "NumberInput", "show_create": False, "props": {}},
110-
"other": {"type": "TextInput", "show_create": True, "props": {}}
155+
"num": {"type": "NumberInput", "show_create": False, "props": {},
156+
"validators": [("required",)]},
157+
"other": {"type": "TextInput", "show_create": True, "props": {},
158+
"validators": [("required",)]}
111159
}
112160

113161

tests/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def test_admin_view(admin_client: TestClient) -> None:
3131
assert r["list_omit"] == []
3232
assert r["fields"] == {"id": {"type": "NumberField", "props": {"alwaysOn": "alwaysOn"}}}
3333
assert r["inputs"] == {"id": {"type": "NumberInput", "props": {"alwaysOn": "alwaysOn"},
34-
"show_create": False}}
34+
"show_create": False, "validators": [["required"]]}}
3535
assert r["repr"] == "id"
3636
assert state["urls"] == {"token": "/admin/token", "logout": "/admin/logout"}
3737

0 commit comments

Comments
 (0)