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
+    )

Reply via email to