From 6625a7c5475dfd25b497af6a92dbad46dc3bba5f Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sat, 18 Jul 2026 16:32:10 -0700 Subject: [PATCH 1/3] refactor: set cascade_backrefs=False for SqlaTable Enable the pytest error filter for the "'SqlaTable' object is being merged into a Session along the backref cascade path" RemovedIn20Warning and adopt the SQLAlchemy 2.0 behavior for the Database.tables backref. Constructing SqlaTable(database=...) no longer implicitly merges the new object into the Database's session. All persisting code paths already add tables to the session explicitly. See discussion #40273. Co-Authored-By: Claude Fable 5 --- pytest.ini | 2 +- superset/connectors/sqla/models.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index 1a4054f036bb..62083ff79cac 100644 --- a/pytest.ini +++ b/pytest.ini @@ -31,7 +31,7 @@ filterwarnings = error:Passing a string to Connection.execute\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning # error:"Query" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning # error:"SavedQuery" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning -# error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning + error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning # error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning # error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning # error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index b8244b60d1d2..a834e043267d 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -1364,7 +1364,14 @@ class SqlaTable( database: Database = relationship( "Database", - backref=backref("tables", cascade="all, delete-orphan"), + backref=backref( + "tables", + cascade="all, delete-orphan", + # SQLAlchemy 2.0 behavior: assigning `table.database` no longer + # cascades the SqlaTable into the Database's session; callers must + # add objects to a session explicitly. + cascade_backrefs=False, + ), foreign_keys=[database_id], ) schema = Column(String(255)) From 1d2aa9439bf627ba9deca39f43b7f83230a7285b Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 18 Jul 2026 18:03:00 -0700 Subject: [PATCH 2/3] fix: explicitly add SqlaTable to session in example loader and tests With cascade_backrefs=False, assigning `tbl.database = database` no longer implicitly adds a transient SqlaTable to the session. In generic_loader.py, `fetch_metadata()` and the outer caller each call `db.session.merge(tbl)`; previously both calls resolved to the same already-pending object (added via the backref cascade), but now each merge() creates a separate transient copy, producing two pending inserts for the same uuid and a uq_tables_uuid violation on the very first example loaded (which then poisons the session for every subsequent example in the same transaction). Also add the missing explicit `db.session.add()` in two sqla_models_tests.py tests that relied on the same implicit cascade to persist a freshly constructed SqlaTable before committing/deleting. Co-Authored-By: Claude Sonnet 5 --- superset/examples/generic_loader.py | 7 +++++++ tests/integration_tests/sqla_models_tests.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/superset/examples/generic_loader.py b/superset/examples/generic_loader.py index 357ef524cd6e..127ee5aec9d4 100644 --- a/superset/examples/generic_loader.py +++ b/superset/examples/generic_loader.py @@ -173,6 +173,13 @@ def safe_serialize(x: Any, column_name: str) -> Optional[str]: if not tbl: tbl = SqlaTable(table_name=table_name, database_id=database.id) + # Explicitly add the new table to the session. Assigning `tbl.database` + # below no longer implicitly adds `tbl` to the session (SQLAlchemy 2.0 + # behavior, cascade_backrefs=False), so without this, the two + # `db.session.merge()` calls below (one inside `fetch_metadata()`, one + # at the end of this function) would each create a separate transient + # copy of `tbl`, resulting in two pending inserts for the same uuid. + db.session.add(tbl) # Set the database reference tbl.database = database diff --git a/tests/integration_tests/sqla_models_tests.py b/tests/integration_tests/sqla_models_tests.py index 6c337d1003b7..e11e66b90e21 100644 --- a/tests/integration_tests/sqla_models_tests.py +++ b/tests/integration_tests/sqla_models_tests.py @@ -173,6 +173,7 @@ def test_jinja_metrics_and_calc_columns(self, mock_username: MagicMock) -> None: "'{{ 'xyz_' + time_grain }}' as time_grain", database=get_example_database(), ) + db.session.add(table) TableColumn( column_name="expr", expression="case when '{{ current_username() }}' = 'abc' " @@ -275,6 +276,7 @@ def test_adhoc_metrics_and_calc_columns(self): table = SqlaTable( table_name="test_validate_adhoc_sql", database=get_example_database() ) + db.session.add(table) db.session.commit() with pytest.raises(QueryObjectValidationError): From 432fcd154525ab772ff51a9da6f1d5e37c2e5191 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Sat, 18 Jul 2026 21:01:09 -0700 Subject: [PATCH 3/3] fix: silence latent pylint W9001 in generic_loader The previous commit touched generic_loader.py, so CI pylint now lints the whole file and flags two pre-existing session.commit() calls that lack the @transaction decorator. These are example seed-loader scripts, not command-layer units of work; use the inline disable convention already established in security/manager.py. Co-Authored-By: Claude Fable 5 --- superset/examples/generic_loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset/examples/generic_loader.py b/superset/examples/generic_loader.py index 127ee5aec9d4..9d516e2d06ba 100644 --- a/superset/examples/generic_loader.py +++ b/superset/examples/generic_loader.py @@ -194,7 +194,7 @@ def safe_serialize(x: Any, column_name: str) -> Optional[str]: tbl.fetch_metadata() db.session.merge(tbl) - db.session.commit() + db.session.commit() # pylint: disable=consider-using-transaction return tbl @@ -249,7 +249,7 @@ def loader( if description and tbl: tbl.description = description db.session.merge(tbl) - db.session.commit() + db.session.commit() # pylint: disable=consider-using-transaction # Set function name and docstring loader.__name__ = f"load_{parquet_file}"