fix(sqla): coerce ARRAY element type in conv_ARRAY (closes #1724)#2902
fix(sqla): coerce ARRAY element type in conv_ARRAY (closes #1724)#2902jbbqqf wants to merge 4 commits into
Conversation
…eco#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 pallets-eco#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). Use the column's `item_type.python_type` -- when available -- as the per-tag coerce callable so the inferred element type lines up with the column. Fall back silently for inner types that don't implement `python_type` (e.g. SQLAlchemy's `JSON`) and skip `coerce` entirely for `str` (the existing default) so the previous behavior for `ARRAY(String)` is preserved unchanged. Add four tests in `flask_admin/tests/sqla/test_form.py::TestArrayConverter`: - `ARRAY(Integer)` -> `[1, 2, 3]` (fails on `origin/master`, passes here) - `ARRAY(Float)` -> `[1.5, 2.0]` (fails on `origin/master`, passes here) - `ARRAY(String)` -> `['alpha', 'beta', 'gamma']` (no regression) - `column.type` without `item_type` -> falls back without raising
The `typing` job in CI flagged two things: 1. `flask_admin/contrib/sqla/form.py:643` -- the `# type: ignore[typeddict-unknown-key]` I used doesn't match the actual mypy error code (`typeddict-item`). Rather than play whack-a-mole, cast `field_args` to `dict[str, Any]` for the one setdefault call -- `coerce` isn't part of the `T_FIELD_ARGS_VALIDATORS` TypedDict (it's a Select2TagsField-specific kwarg), so smuggling it via cast keeps the TypedDict narrow. 2. `flask_admin/tests/sqla/test_form.py:86,100,115` -- mypy with `disallow_any_generics = true` insists on a type annotation for `Column(...)` literals. Annotate as `column: Column[Any]`. Verified locally: - `mypy --python-version 3.10 flask_admin/contrib/sqla/form.py flask_admin/tests/sqla/test_form.py` clean on the diff lines. - `pytest flask_admin/tests/sqla/test_form.py::TestArrayConverter -v` -> 4 passed.
c363201 to
35e7505
Compare
|
Thank you for your PR! |
|
Thanks for the greate job. import enum
import inspect
import typing as t
from unittest.mock import MagicMock
import pytest
import sqlalchemy as sa
import sqlalchemy_utils as sau
import wtforms
from wtforms.fields.simple import StringField
from wtforms.validators import Length
from wtforms.validators import NumberRange
from flask_admin.contrib.sqla.form import AdminModelConverter
from flask_admin.tests.conftest import skip_or_return_session_or_db
from flask_admin.tests.sqla.test_basic import CustomModelView
class EnumChoices(enum.Enum):
First = 101
Second = 150
class StrEnumChoices(enum.Enum):
First = "101"
Second = "150"
def create_models(sqla_db_ext):
class Model1(sqla_db_ext.Base): # type: ignore[name-defined, misc]
__tablename__ = "model1"
def __init__(self, test1, int_field):
self.test1 = test1
self.int_field = int_field
id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
test1 = sa.Column(sa.String(20))
int_field = sa.Column(sa.Integer)
float_field = sa.Column(sa.Float)
choice_field = sa.Column(sa.String, nullable=True)
enum_field = sa.Column(sa.Enum("101", "150"), nullable=True) # type: ignore[var-annotated]
enum_type_field = sa.Column(sa.Enum(EnumChoices), nullable=True) # type: ignore[var-annotated]
sau_choicetype = sa.Column(
sau.ChoiceType(
[("101", "First"), ("150", "Second")]
) # default impl=sa.String()
)
sau_choicetype_impl_int = sa.Column(
sau.ChoiceType([(101, "First"), (150, "Second")], impl=sa.Integer())
)
sau_choicetype_with_enum = sa.Column(
sau.ChoiceType(EnumChoices, impl=sa.Integer())
)
sau_choicetype_with_strenum = sa.Column(
sau.ChoiceType(StrEnumChoices, impl=sa.String())
)
def __str__(self):
return self.test1
sqla_db_ext.create_all()
return Model1
def prepare_kwargs(
expected_coerce: type[t.Any],
use_coerce_explicitly: bool,
field_name: str,
) -> dict[str, t.Any]:
validators: list[t.Any] = []
kwargs: dict[str, t.Any] = dict()
if expected_coerce in [int, float]:
f_choices = [(expected_coerce(101), "First"), (expected_coerce(150), "Second")]
validators = [NumberRange(min=100, max=199)]
kwargs["form_choices"] = {field_name: f_choices}
elif expected_coerce in [
str,
]:
f_choices = [(expected_coerce(101), "First"), (expected_coerce(150), "Second")]
validators = [Length(min=1, max=3)]
kwargs["form_choices"] = {field_name: f_choices}
elif expected_coerce in [EnumChoices, StrEnumChoices]:
pass
kwargs["form_columns"] = [field_name]
if use_coerce_explicitly:
kwargs["form_args"] = dict()
kwargs["form_args"][field_name] = {"validators": validators}
kwargs["form_args"][field_name]["coerce"] = expected_coerce
return kwargs
@pytest.mark.parametrize("use_coerce_explicitly", [False, True])
@pytest.mark.parametrize(
"field_name, expected_coerce, coerced_value, model_value",
[
("int_field", int, None, 101),
("float_field", float, None, 101.0),
("choice_field", str, None, "101"),
("enum_field", str, None, "101"),
("enum_type_field", EnumChoices, "First", EnumChoices.First),
("sau_choicetype", str, None, sau.Choice("101", "First")),
("sau_choicetype_impl_int", int, None, sau.Choice(101, "First")),
("sau_choicetype_with_enum", EnumChoices, "101", EnumChoices.First),
("sau_choicetype_with_strenum", StrEnumChoices, "101", StrEnumChoices.First),
],
)
def test_coerce(
app,
admin,
sqla_db_ext,
session_or_db,
field_name,
expected_coerce,
use_coerce_explicitly,
coerced_value,
model_value,
):
with app.app_context():
Model1 = create_models(sqla_db_ext)
sqla_db_ext.db.session.add_all(
[
Model1("101", int_field=101),
Model1("102", int_field=102),
]
)
sqla_db_ext.db.session.commit()
kwargs = prepare_kwargs(expected_coerce, use_coerce_explicitly, field_name)
param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
view1 = CustomModelView(Model1, param, name="My Model1", **kwargs)
admin.add_view(view1)
value = expected_coerce(101) if coerced_value is None else coerced_value
client = app.test_client()
rv = client.get("/admin/model1/new/")
data = rv.data.decode("utf-8")
assert f'value="{value}"' in data
assert ">First</option>" in data
rv = client.post(
"/admin/model1/new/",
data={field_name: f"{value}"},
follow_redirects=True,
)
data = rv.data.decode("utf-8")
assert "Record was successfully created" in data
iserted = sqla_db_ext.db.session.query(Model1).order_by(Model1.id.desc()).first()
assert iserted is not None
assert getattr(iserted, field_name) == model_value
|
|
Thanks! Does this PR pass this test case? |
Yes,
this for those people who spcified coerce casting explicitly as a workarround before merging this PR. If this PR has the right logic then both explicit and auto coerce should lead to the same results. Note: the above code is modified |
|
But the test we are talking about is not testing coerce casting explicitly, so I would just check the orm accepted the types |
correct me if i'm wrong, in the next upcomming version, won't you recommend the users to eliminate the explicit coerce for all selectable fields and leave to Flask-Admin to decide the right one ? i'm expecting that user's code would be like: class UserAdmin(ModelView):
form_choices = [(2006, "2006"), (2007, "2007"), (2008, "2008")]
form_args = {
"join_in": {
"validators" : [Length(min=2005, max=2030)] ,
"corece" : int
}
}and after this PR it should be like: class UserAdmin(ModelView):
form_choices = [(2006, "2006"), (2007, "2007"), (2008, "2008")]
form_args = {
"join_in": {
"validators" : [Length(min=2005, max=2030)] ,
}
}my test code make sure that user explicitly define the coerce=int or it is auto determined by Flask-Admin, then this won't change data in the model and even in the database. |
Summary
AdminModelConverter.conv_ARRAYreturned aSelect2TagsFieldwith nocoercecallable, so every submitted value was cast via the defaulttext_typeand the resulting Python list was alwayslist[str]. For aPostgres
ARRAY(Integer)column that round-trip fails on save with(originally reported in #1724 in 2018 and still reproducible on
master; the long-standing workaround in that thread is to subclassSelect2TagsFieldand overrideprocess_formdatato callint()oneach entry).
Fixes #1724.
Context
The buggy converter is at
flask_admin/contrib/sqla/form.py:620-626(pre-change):Select2TagsFieldalready supports acoerceparameter(
flask_admin/form/fields.py:228) that is applied to each tag inprocess_formdata(flask_admin/form/fields.py:253-256).SQLAlchemy's
ARRAYexposescolumn.type.item_type, and standardinner types implement
.python_type. Detect that and use it ascoerce.Changes
flask_admin/contrib/sqla/form.py: inconv_ARRAY, look up thecolumn's
item_type.python_type. When that returns a non-strtype(
int,float,bool, ...) pass it through toSelect2TagsFieldascoerce. Fall back silently whenpython_typeis missing(
AttributeError/NotImplementedError) or the column object can'tbe introspected -- the previous behavior for
ARRAY(String)ispreserved unchanged.
flask_admin/tests/sqla/test_form.py: four new tests inTestArrayConverter:test_conv_ARRAY_integer_coerces_each_item_to_inttest_conv_ARRAY_float_coerces_each_item_to_floattest_conv_ARRAY_string_keeps_string_defaulttest_conv_ARRAY_missing_item_type_falls_back_to_textdoc/changelog.rst:[unreleased]bugfix entry.Reproduce BEFORE/AFTER yourself (copy-paste)
What I ran locally
parametrized "can_override_widget" matrix where
conv_ARRAYisalready exercised):
Edge cases
field.dataARRAY(Integer)Integer->int"1,2,3"[1, 2, 3]test_conv_ARRAY_integer_coerces_each_item_to_intARRAY(Float)Float->float"1.5, 2.0"[1.5, 2.0]test_conv_ARRAY_float_coerces_each_item_to_floatARRAY(String)String->str"alpha,beta,gamma"["alpha", "beta", "gamma"](unchanged)test_conv_ARRAY_string_keeps_string_defaultcolumn.typewithoutitem_type(mocked/legacy)"x,y"["x", "y"](fallback)test_conv_ARRAY_missing_item_type_falls_back_to_textpython_type(e.g.JSON)NotImplementedErrorcoerceinjected, no crashtry/exceptbranch in the new code pathRisk / blast radius
conv_ARRAYis modified, plus its tests.ARRAY(String)-- the only inner type that has historicallyworked end-to-end -- the new code path explicitly skips injecting a
coerce(it's already the default), so existing apps using stringarrays see no behavior change.
Select2TagsFieldto add their ownper-element
int()cast (per the workaround in the issue thread)keep working as before, because:
coercewhen the inner type'spython_typeis notstr;setdefault, so an explicit user-providedfield_args["coerce"]wins.Release note
PR drafted with assistance from Claude Code (Anthropic). The change was
reviewed manually against flask-admin's source -- file:line references in
the body were checked against the cloned tree, and the copy-paste
BEFORE/AFTER reproducer block is the one I used during development;
reviewers can paste it verbatim.