This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch fix-36074-bulk-untag-error in repository https://gitbox.apache.org/repos/asf/superset.git
commit 4911dcc8213fc0f134011a223487c39af5701d5e Author: Evan Rusackas <[email protected]> AuthorDate: Sat Feb 21 23:54:34 2026 -0800 fix(tags): expire tag relationship after deleting all tagged objects Fixes #36074 When removing all tagged objects from a tag, the Tag model's 'objects' relationship still held references to deleted TaggedObject instances. This caused a SQLAlchemy error when the tag was later added to the session: "Instance has been deleted. Use the make_transient() function to send this object back to the transient state." The fix calls db.session.expire(tag, ["objects"]) after deleting tagged objects to clear the stale references from the relationship, allowing the session to properly reload the relationship on next access. Added a regression test that verifies removing all tagged objects from a tag works without errors. Co-Authored-By: Claude <[email protected]> --- superset/daos/tag.py | 7 ++++ tests/unit_tests/tags/commands/update_test.py | 60 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/superset/daos/tag.py b/superset/daos/tag.py index 2c5cc358265..f351dc18eae 100644 --- a/superset/daos/tag.py +++ b/superset/daos/tag.py @@ -378,4 +378,11 @@ class TagDAO(BaseDAO[Tag]): object_id, tag.name, ) + # After deleting tagged objects, we need to expire the tag's 'objects' + # relationship to clear references to deleted TaggedObject instances. + # This prevents SQLAlchemy errors when the tag is later added to the + # session, as it would otherwise still hold references to deleted objects. + if tagged_objects_to_delete: + db.session.expire(tag, ["objects"]) + db.session.add_all(tagged_objects) diff --git a/tests/unit_tests/tags/commands/update_test.py b/tests/unit_tests/tags/commands/update_test.py index e22fcc2be39..edd41991fce 100644 --- a/tests/unit_tests/tags/commands/update_test.py +++ b/tests/unit_tests/tags/commands/update_test.py @@ -204,3 +204,63 @@ def test_update_command_failed_validation( "objects_to_tag": objects_to_tag, }, ).run() + + +def test_update_command_remove_all_tagged_objects( + session_with_data: Session, mocker: MockerFixture +): + """Test that removing all tagged objects from a tag works correctly. + + This is a regression test for GitHub issue #36074 where bulk untagging + (removing all objects from a tag) caused a SQLAlchemy error because + the tag's 'objects' relationship still held references to deleted + TaggedObject instances. + """ + from superset.commands.tag.create import CreateCustomTagWithRelationshipsCommand + from superset.commands.tag.update import UpdateTagCommand + from superset.daos.tag import TagDAO + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + from superset.tags.models import ObjectType, TaggedObject + + dashboard = db.session.query(Dashboard).first() + chart = db.session.query(Slice).first() + + mocker.patch( + "superset.security.SupersetSecurityManager.is_admin", return_value=True + ) + mocker.patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart) + mocker.patch( + "superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard + ) + + # Create a tag with multiple objects + objects_to_tag = [ + (ObjectType.dashboard, dashboard.id), + (ObjectType.chart, chart.id), + ] + + CreateCustomTagWithRelationshipsCommand( + data={"name": "test_tag", "objects_to_tag": objects_to_tag} + ).run() + + tag_to_update = TagDAO.find_by_name("test_tag") + assert len(tag_to_update.objects) == 2 + + # Remove all tagged objects by passing an empty list + # This should not raise a SQLAlchemy error about deleted instances + updated_tag = UpdateTagCommand( + tag_to_update.id, + { + "name": "test_tag", + "description": "updated description", + "objects_to_tag": [], + }, + ).run() + + assert updated_tag is not None + assert updated_tag.description == "updated description" + # Verify all tagged objects were removed + assert ( + len(db.session.query(TaggedObject).filter_by(tag_id=updated_tag.id).all()) == 0 + )
