Skip to content

Commit dd378c9

Browse files
Jean-Baptiste BraunElLorans
andauthored
fix(sqla): coerce ARRAY element type in conv_ARRAY (closes #1724) (#2902)
* fix(sqla): coerce ARRAY element type in `conv_ARRAY` (closes #1724) `AdminModelConverter.conv_ARRAY` returned a `Select2TagsField` with no `coerce` callable, so every submitted value was cast via the default `text_type` and the resulting Python list was always `list[str]`. For a Postgres `ARRAY(Integer)` column that round-trip fails on save with column "x" is of type integer[] but expression is of type text[] (reported in #1724 in 2018 and still reproducible on `master` as of v2.2.0; the long-standing workaround in that thread is to subclass `Select2TagsField` and override `process_formdata` to call `int()` on each entry). --------- Co-authored-by: ElLorans <lorenzo.cerreta@gmail.com>
1 parent ca18a28 commit dd378c9

4 files changed

Lines changed: 107 additions & 1 deletion

File tree

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Changelog
66

77
Bugfixes:
88
* Fix encoding for editing file in FileAdmin. Now it uses UTF-8 and accepts non-ASCII characters.
9+
* SQLAlchemy backend: ``conv_ARRAY`` now infers the array element's ``python_type`` and passes it through as the ``Select2TagsField`` ``coerce`` callable. Saving a Postgres ``ARRAY(Integer)`` / ``ARRAY(Float)`` column no longer fails with ``column "x" is of type integer[] but expression is of type text[]`` (closes #1724).
910

1011
Type hints:
1112
* Type hints added to all functions and methods (some using `typing.Any` where full typing not yet available)

flask_admin/_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,7 @@ class _T_MONGOENGINE_FIELD_PROTOCOL(t.Protocol):
280280
id: t.Any
281281
data: t.Any
282282
name: str
283+
284+
285+
class T_FIELD_ARGS_VALIDATORS_COERCE(T_FIELD_ARGS_VALIDATORS, total=False):
286+
coerce: t.Callable[[t.Any], t.Any]

flask_admin/contrib/sqla/form.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from ..._types import T_FIELD_ARGS_PLACES
3636
from ..._types import T_FIELD_ARGS_VALIDATORS
3737
from ..._types import T_FIELD_ARGS_VALIDATORS_ALLOW_BLANK
38+
from ..._types import T_FIELD_ARGS_VALIDATORS_COERCE
3839
from ..._types import T_FIELD_ARGS_VALIDATORS_FILES
3940
from ..._types import T_INSTRUMENTED_ATTRIBUTE
4041
from ..._types import T_MODEL_VIEW
@@ -621,8 +622,19 @@ def conv_PGUuid(
621622
"sqlalchemy.dialects.postgresql.base.ARRAY", "sqlalchemy.sql.sqltypes.ARRAY"
622623
)
623624
def conv_ARRAY(
624-
self, field_args: T_FIELD_ARGS_VALIDATORS, **extra: t.Any
625+
self, field_args: T_FIELD_ARGS_VALIDATORS_COERCE, **extra: t.Any
625626
) -> form.Select2TagsField:
627+
# Ensure Select2TagsField uses the correct Python type for ARRAY element values.
628+
# Otherwise, WTForms defaults to text_type, causing Postgres ARRAY type errors.
629+
column = extra.get("column")
630+
item_type = getattr(getattr(column, "type", None), "item_type", None)
631+
if item_type is not None:
632+
try:
633+
python_type = item_type.python_type
634+
except (AttributeError, NotImplementedError):
635+
python_type = None
636+
if python_type is not None and python_type is not str:
637+
field_args.setdefault("coerce", python_type)
626638
return form.Select2TagsField(save_as_list=True, **field_args)
627639

628640
@converts("HSTORE")

flask_admin/tests/sqla/test_form.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
import pytest
66
import wtforms
7+
from sqlalchemy import ARRAY
8+
from sqlalchemy import Column
9+
from sqlalchemy import Float
10+
from sqlalchemy import Integer
11+
from sqlalchemy import String
712
from wtforms.fields.simple import StringField
813

914
from flask_admin.contrib.sqla.form import AdminModelConverter
@@ -53,3 +58,87 @@ class TestForm(wtforms.Form):
5358
pass
5459

5560
assert field() == "<p>widget overridden</p>"
61+
62+
63+
class TestArrayConverter:
64+
"""Regression tests for `AdminModelConverter.conv_ARRAY` -- see issue #1724.
65+
66+
Without an inner-type-aware coerce callable, every submitted value for a
67+
Postgres `ARRAY(Integer)` column is sent back to the DB as a Python
68+
`str`, so the resulting `text[]` value is rejected with
69+
column "x" is of type integer[] but expression is of type text[]
70+
The converter now passes a `coerce` derived from the array element's
71+
`python_type` so the round-trip lines up with the column type.
72+
"""
73+
74+
def _bind(self, unbound_field: t.Any) -> t.Any:
75+
"""The converter returns a wtforms UnboundField. Bind it onto a real
76+
form so we can drive `process_formdata` and inspect `.data`.
77+
"""
78+
79+
class _F(wtforms.Form):
80+
x = unbound_field
81+
82+
return _F().x
83+
84+
def test_conv_ARRAY_integer_coerces_each_item_to_int(self) -> None:
85+
converter = AdminModelConverter(None, None) # type: ignore[arg-type]
86+
column: Column[t.Any] = Column("x", ARRAY(Integer))
87+
bound = self._bind(
88+
converter.conv_ARRAY(field_args={"validators": []}, column=column)
89+
)
90+
91+
bound.process_formdata(["1,2,3"])
92+
93+
assert bound.data == [1, 2, 3]
94+
# Hard-pin element types so a future change of the inner coerce can't
95+
# silently regress to strings.
96+
assert all(isinstance(v, int) for v in bound.data), bound.data
97+
98+
def test_conv_ARRAY_float_coerces_each_item_to_float(self) -> None:
99+
converter = AdminModelConverter(None, None) # type: ignore[arg-type]
100+
column: Column[t.Any] = Column("x", ARRAY(Float))
101+
bound = self._bind(
102+
converter.conv_ARRAY(field_args={"validators": []}, column=column)
103+
)
104+
105+
bound.process_formdata(["1.5, 2.0"])
106+
107+
assert bound.data == [1.5, 2.0]
108+
assert all(isinstance(v, float) for v in bound.data), bound.data
109+
110+
def test_conv_ARRAY_string_keeps_string_default(self) -> None:
111+
"""String arrays must continue to work exactly as before -- no
112+
spurious coercion that would round-trip values through `int()`.
113+
"""
114+
converter = AdminModelConverter(None, None) # type: ignore[arg-type]
115+
column: Column[t.Any] = Column("x", ARRAY(String))
116+
bound = self._bind(
117+
converter.conv_ARRAY(field_args={"validators": []}, column=column)
118+
)
119+
120+
bound.process_formdata(["alpha,beta,gamma"])
121+
122+
assert bound.data == ["alpha", "beta", "gamma"]
123+
124+
def test_conv_ARRAY_missing_item_type_falls_back_to_text(self) -> None:
125+
"""If the column object can't be introspected (e.g. legacy callers
126+
passing a MagicMock), the converter must not raise. The previous
127+
default (string coerce) is preserved.
128+
"""
129+
converter = AdminModelConverter(None, None) # type: ignore[arg-type]
130+
# MagicMock().type.item_type silently returns another MagicMock, whose
131+
# python_type access would itself succeed and return a MagicMock --
132+
# which is precisely the kind of pathological case we need to handle
133+
# without exploding.
134+
column = MagicMock()
135+
# Force item_type to be absent so the fallback branch is exercised.
136+
column.type.spec_set = ["item_type"]
137+
del column.type.item_type
138+
139+
bound = self._bind(
140+
converter.conv_ARRAY(field_args={"validators": []}, column=column)
141+
)
142+
143+
bound.process_formdata(["x,y"])
144+
assert bound.data == ["x", "y"]

0 commit comments

Comments
 (0)