Detect augmented JSON types when skipping batch migrate CAST - #1839
Detect augmented JSON types when skipping batch migrate CAST#1839ArockiaRajamanickam wants to merge 3 commits into
Conversation
The SQLite batch migration data transfer suppresses the CAST for JSON columns, since CASTing to JSON in SQLite yields 0. The check used isinstance(), which does not match a TypeDecorator that augments JSON, so the CAST was emitted and the column data was replaced with 0. Compare type affinity instead, as the preceding condition already does. Fixes: sqlalchemy#1120
MohammedAlkindi
left a comment
There was a problem hiding this comment.
Checked this out and ran it on Windows 11 (10.0.26200), CPython 3.13.13, against main at c116cbc0.
The diagnosis holds and the failure mode is nasty — silent data loss rather than an error. CAST(x AS JSON) in SQLite yields 0, so suppressing the CAST for JSON is load-bearing, and isinstance(new_type, JSON) genuinely misses a TypeDecorator whose impl is JSON: the decorator is not a JSON subclass. Every value in the column becomes 0 with no diagnostic.
_type_affinity is the right instrument here, and it is already this codebase's idiom — worth saying because it is a private SQLAlchemy attribute and might otherwise look like a reach:
alembic/autogenerate/compare/types.py:35 conn_type._type_affinity is sqltypes.NullType
alembic/autogenerate/compare/types.py:42 metadata_type._type_affinity is sqltypes.NullType
alembic/ddl/impl.py:645 existing.type._type_affinity is not new_type._type_affinity
alembic/ddl/mysql.py:211 type_._type_affinity is sqltypes.DateTime
So this is consistent with four existing call sites rather than introducing a new dependency, and impl.py:645 is doing the same affinity-not-isinstance comparison in the adjacent code path.
Test evidence — failing-test set diff against base:
| failing tests | |
|---|---|
main @ c116cbc0 |
13 |
| this PR | 13 |
The sets are identical — no regressions, none fixed. (The two clusters are test_post_write.py::RunHookTest and test_script_production.py::ScriptNamingTest timezone tests; both fail on main on this machine and are unrelated to this change.) I'm reporting the set rather than the counts deliberately: raw pass counts differ between base and PR here (1805 vs 1758) only because the PR branches from an older commit with fewer tests, which would look like a regression if read as a delta.
The new test_change_type_json_typedecorator is the right shape — a TypeDecorator with impl = JSON and cache_ok = True, asserted through the batch path rather than by unit-testing the predicate.
One question, not a blocker. _type_affinity walks to the underlying type for a TypeDecorator, but for a doubly wrapped decorator (TypeDecorator whose impl is another TypeDecorator over JSON) — does affinity still resolve through both layers? I did not construct that case, so I am asking rather than asserting. If it does resolve, a one-line note in the test docstring would save the next reader the same question.
I could not exercise the MySQL or PostgreSQL paths — no server on this machine — so my run covers the SQLite batch path only, which is what this PR touches.
Answers a question raised in review: a TypeDecorator whose impl is another TypeDecorator over JSON resolves to the same affinity, so the check covers it. Reported by Mohammed Alkindi in review of sqlalchemy#1839.
|
Thanks, and the point about reporting the failing-test set rather than the counts is well made. The 1805 vs 1758 gap would have looked like a regression to anyone reading it as a delta. On your question, it does resolve through both layers. I built the case you did not: class SingleJSON(TypeDecorator):
impl = JSON
cache_ok = True
class DoubleJSON(TypeDecorator):
impl = SingleJSON # decorator over a decorator over JSON
cache_ok = True
Added your suggested note to the test docstring in 91421ed rather than adding a second test, since the existing one already exercises the code path and the nesting depth is a property of
Agreed on |
|
@ArockiaRajamanickam I'd take the asserted version — it's cheap where you already are, and it pins the one claim in the docstring that a future refactor could silently falsify (the recursion, not the single wrap). Inside the same test, something like: class DoublyWrappedJson(TypeDecorator):
impl = CustomJson
cache_ok = Truewith one more Glad the set-vs-count point was useful — that one has saved me from reporting phantom regressions more than once. |
Replaces the docstring claim about nested decorators with an assertion, so a refactor that only unwrapped a single layer would be caught rather than silently falsifying the comment. Requested by Mohammed Alkindi in review of sqlalchemy#1839.
|
Taken, in c97b364. You were right that it is worth asserting rather than describing, and I can show it rather than just claim it.
To check the new assertion actually earns its place I ran two negative controls against it:
That is the scenario you described, so the assertion is doing the job you wanted from it rather than just restating the passing case. One wrinkle worth noting for anyone reading the diff: the second block needs its own
|
Fixes the data loss reported in #1120.
cast_for_batch_migrateskips the data transferCASTforJSON, because CASTing toJSONin SQLite yields0. The check wasisinstance(new_type, JSON), which aTypeDecoratorwrappingJSONdoes not satisfy, so theCASTwent out and every value in the column became0.Reproduced on an in-memory SQLite database with one row holding
{"keep": "me", "n": 123}:The change compares type affinity, which the condition on the line above already does:
One note on the approach suggested in the issue.
isinstance(typ.dialect_impl(dialect), JSON)does not catch this case, because for aTypeDecoratordialect_impl()returns the decorator itself rather than its impl. I measured each candidate against the SQLite dialect:isinstancedialect_impl_type_affinity is JSONsa.JSON()TypeDecorator(impl=JSON)sqlite.JSON()postgresql.JSONB()String,Integer,TypeDecorator(impl=String)Affinity is the only one that catches the
TypeDecoratorwhile still excluding non-JSON types.impl_instancealso works, but only for the decorator, so it would have to be combined with the existingisinstancecheck.Worth flagging: this is a behaviour change in one narrow direction.
CASTsuppression now covers any type whose affinity isJSON, not onlyJSONinstances, so a change from a genuinely different affinity to an augmented JSON type no longer emits theCAST. That is what the original guard was for, just applied to the augmented case too.The test is
tests/test_batch.py::CopyFromTest::test_change_type_json_typedecorator, next totest_change_typeas suggested in the issue. It fails on main withCAST(foo.toj AS JSON) AS tojin the transfer statement and passes with the fix. Full suite is 1770 passed, 133 skipped, and there is a changelog fragment indocs/build/unreleased/1120.rst.I used an AI assistant while working on this. The reproduction and the comparison table are things I ran myself.