|
4 | 4 |
|
5 | 5 | import pytest |
6 | 6 | 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 |
7 | 12 | from wtforms.fields.simple import StringField |
8 | 13 |
|
9 | 14 | from flask_admin.contrib.sqla.form import AdminModelConverter |
@@ -53,3 +58,87 @@ class TestForm(wtforms.Form): |
53 | 58 | pass |
54 | 59 |
|
55 | 60 | 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