From 8fc93c4c68c28038c7d15e595cdc6d13cabccfe1 Mon Sep 17 00:00:00 2001 From: ArockiaRajamanickam Date: Thu, 30 Jul 2026 19:31:57 +0530 Subject: [PATCH 1/3] Detect augmented JSON types when skipping batch migrate CAST 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: #1120 --- alembic/ddl/sqlite.py | 5 ++++- docs/build/unreleased/1120.rst | 12 ++++++++++++ tests/test_batch.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/build/unreleased/1120.rst diff --git a/alembic/ddl/sqlite.py b/alembic/ddl/sqlite.py index c260d53fa..1a38d22d4 100644 --- a/alembic/ddl/sqlite.py +++ b/alembic/ddl/sqlite.py @@ -182,9 +182,12 @@ def cast_for_batch_migrate( existing_transfer: Dict[str, Union[TypeEngine, Cast]], new_type: TypeEngine, ) -> None: + # the JSON check uses the type affinity rather than isinstance() so + # that a TypeDecorator which augments JSON is also detected; CASTing + # to JSON in SQLite yields 0 and would destroy the data if ( existing.type._type_affinity is not new_type._type_affinity - and not isinstance(new_type, JSON) + and new_type._type_affinity is not JSON ): existing_transfer["expr"] = cast( existing_transfer["expr"], new_type diff --git a/docs/build/unreleased/1120.rst b/docs/build/unreleased/1120.rst new file mode 100644 index 000000000..79fbb80a4 --- /dev/null +++ b/docs/build/unreleased/1120.rst @@ -0,0 +1,12 @@ +.. change:: + :tags: bug, batch migrations + :tickets: 1120 + + Fixed data loss in SQLite batch migrations when altering a column to a + :class:`~sqlalchemy.types.TypeDecorator` that augments + :class:`~sqlalchemy.types.JSON`. The data transfer step suppresses the + ``CAST`` for :class:`~sqlalchemy.types.JSON`, as CASTing to ``JSON`` in + SQLite yields ``0``, however the check used ``isinstance()`` and therefore + did not match a ``TypeDecorator`` wrapping ``JSON``; every value in the + column was replaced with ``0``. The check now compares type affinity, so + augmented types are detected as well. diff --git a/tests/test_batch.py b/tests/test_batch.py index 0dfd273b3..cb76bffc4 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -21,6 +21,7 @@ from sqlalchemy import String from sqlalchemy import Table from sqlalchemy import Text +from sqlalchemy import TypeDecorator from sqlalchemy import UniqueConstraint from sqlalchemy.dialects import sqlite as sqlite_dialect from sqlalchemy.schema import CreateIndex @@ -1154,6 +1155,33 @@ def test_change_type(self): "ALTER TABLE _alembic_tmp_foo RENAME TO foo", ) + def test_change_type_json_typedecorator(self): + """a TypeDecorator that augments JSON must not be CAST either. + + CASTing to JSON in SQLite yields 0, so emitting the CAST here + would destroy the column's data. + + """ + + class CustomJson(TypeDecorator): + impl = JSON + cache_ok = True + + context = self._fixture() + self.table.append_column(Column("toj", Text)) + with self.op.batch_alter_table( + "foo", copy_from=self.table + ) as batch_op: + batch_op.alter_column("toj", type_=CustomJson) + context.assert_( + "CREATE TABLE _alembic_tmp_foo (id INTEGER NOT NULL, " + "data VARCHAR(50), x INTEGER, toj JSON, PRIMARY KEY (id))", + "INSERT INTO _alembic_tmp_foo (id, data, x, toj) " + "SELECT foo.id, foo.data, foo.x, foo.toj FROM foo", + "DROP TABLE foo", + "ALTER TABLE _alembic_tmp_foo RENAME TO foo", + ) + def test_change_type_from_schematype(self): context = self._fixture() self.table.append_column( From 91421edd81c801dc0ee253870bcc706e4a5f4c62 Mon Sep 17 00:00:00 2001 From: Arockia Rajamanickam Date: Sun, 16 Aug 2026 13:56:54 +0530 Subject: [PATCH 2/3] tests: note that _type_affinity resolves through nested decorators 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 #1839. --- tests/test_batch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_batch.py b/tests/test_batch.py index cb76bffc4..b7f990d89 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1161,6 +1161,10 @@ def test_change_type_json_typedecorator(self): CASTing to JSON in SQLite yields 0, so emitting the CAST here would destroy the column's data. + ``_type_affinity`` resolves through any number of TypeDecorator + layers, so a decorator whose impl is itself a decorator over JSON + is covered by the same check. + """ class CustomJson(TypeDecorator): From c97b3644b540a33d094aba34173014a088c620c9 Mon Sep 17 00:00:00 2001 From: Arockia Rajamanickam Date: Wed, 19 Aug 2026 16:52:25 +0530 Subject: [PATCH 3/3] tests: assert the doubly wrapped TypeDecorator case 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 #1839. --- tests/test_batch.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_batch.py b/tests/test_batch.py index b7f990d89..f35e33212 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1163,7 +1163,7 @@ def test_change_type_json_typedecorator(self): ``_type_affinity`` resolves through any number of TypeDecorator layers, so a decorator whose impl is itself a decorator over JSON - is covered by the same check. + is covered by the same check; both depths are asserted below. """ @@ -1186,6 +1186,25 @@ class CustomJson(TypeDecorator): "ALTER TABLE _alembic_tmp_foo RENAME TO foo", ) + class DoublyWrappedJson(TypeDecorator): + impl = CustomJson + cache_ok = True + + context = self._fixture() + self.table.append_column(Column("toj", Text)) + with self.op.batch_alter_table( + "foo", copy_from=self.table + ) as batch_op: + batch_op.alter_column("toj", type_=DoublyWrappedJson) + context.assert_( + "CREATE TABLE _alembic_tmp_foo (id INTEGER NOT NULL, " + "data VARCHAR(50), x INTEGER, toj JSON, PRIMARY KEY (id))", + "INSERT INTO _alembic_tmp_foo (id, data, x, toj) " + "SELECT foo.id, foo.data, foo.x, foo.toj FROM foo", + "DROP TABLE foo", + "ALTER TABLE _alembic_tmp_foo RENAME TO foo", + ) + def test_change_type_from_schematype(self): context = self._fixture() self.table.append_column(