Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion superset/commands/chart/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
from typing import Optional

from flask_babel import lazy_gettext as _
from sqlalchemy import func

from superset import security_manager
from superset import db, security_manager
from superset.commands.base import BaseCommand
from superset.commands.chart.exceptions import (
ChartDeleteFailedError,
Expand All @@ -31,7 +32,9 @@
from superset.daos.chart import ChartDAO
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from superset.utils.decorators import on_error, transaction

logger = logging.getLogger(__name__)
Expand All @@ -46,6 +49,9 @@ def __init__(self, model_ids: list[int]):
def run(self) -> None:
self.validate()
assert self._models
# Clean up dashboard metadata before deleting charts
for chart in self._models:
self._cleanup_dashboard_metadata(chart.id)
ChartDAO.delete(self._models)
Comment on lines +52 to 55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Personal concern, since I don't have context]

It doesn't feel really safe to do this since the I don't know how many models we could get here and also _cleanup_dashboard_metadata could be time consuming. I would suggest to investigate if this won't cause any performance issues, and if not, I would write a comment denoting why this solution works and what scenario will cover, so if at some point in the future it fails, devs would know what to consider to fix it.


def validate(self) -> None:
Expand All @@ -68,3 +74,140 @@ def validate(self) -> None:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex

def _cleanup_dashboard_metadata( # noqa: C901
self, chart_id: int
) -> None:
"""
Remove references to this chart from all dashboard metadata.

When a chart is deleted, dashboards may still contain references to the
chart ID in various metadata fields (expanded_slices, filter_scopes, etc.).
This method cleans up those references to prevent issues during dashboard
export/import.
"""

# First check how many dashboards contain this chart
dashboard_count = (
db.session.query(func.count(Dashboard.id))
.filter(Dashboard.slices.any(id=chart_id)) # type: ignore[attr-defined]
.scalar()
)

if dashboard_count == 0:
return

# Log warning if cleaning up many dashboards
if dashboard_count > 100:
logger.warning(
"Chart %s is on %d dashboards. "
"Cleaning up metadata may take some time.",
chart_id,
dashboard_count,
)

# Use a reasonable limit to prevent memory issues with extremely popular charts
if dashboard_count > (safety_limit := 1000):
logger.error(
"Chart %s is on %d dashboards (exceeds safety limit of %d). "
"Skipping metadata cleanup. "
"Manual intervention may be required for export/import.",
chart_id,
dashboard_count,
safety_limit,
)
return

# Query only ID and json_metadata (not full Dashboard objects)
dashboards_to_update = (
db.session.query(Dashboard.id, Dashboard.json_metadata)
.filter(Dashboard.slices.any(id=chart_id)) # type: ignore[attr-defined]
.all()
)

for dashboard_id, json_metadata_str in dashboards_to_update:
if not json_metadata_str:
continue

try:
metadata = json.loads(json_metadata_str)
except (json.JSONDecodeError, TypeError):
logger.warning(
"Could not parse metadata for dashboard %s", dashboard_id
)
continue

modified = False

# Clean up expanded_slices
if "expanded_slices" in metadata:
chart_id_str = str(chart_id)
if chart_id_str in metadata["expanded_slices"]:
del metadata["expanded_slices"][chart_id_str]
modified = True

# Clean up timed_refresh_immune_slices
if "timed_refresh_immune_slices" in metadata:
if chart_id in metadata["timed_refresh_immune_slices"]:
metadata["timed_refresh_immune_slices"].remove(chart_id)
Comment thread
ramiroaquinoromero marked this conversation as resolved.
modified = True

# Clean up filter_scopes
if "filter_scopes" in metadata:
chart_id_str = str(chart_id)
if chart_id_str in metadata["filter_scopes"]:
del metadata["filter_scopes"][chart_id_str]
modified = True
# Also clean from immune lists
for filter_scope in metadata["filter_scopes"].values():
for attributes in filter_scope.values():
if chart_id in attributes.get("immune", []):
attributes["immune"].remove(chart_id)
Comment thread
ramiroaquinoromero marked this conversation as resolved.
modified = True

# Clean up default_filters
if "default_filters" in metadata:
default_filters = json.loads(metadata["default_filters"])
Comment thread
ramiroaquinoromero marked this conversation as resolved.
chart_id_str = str(chart_id)
if chart_id_str in default_filters:
del default_filters[chart_id_str]
metadata["default_filters"] = json.dumps(default_filters)
modified = True
Comment on lines +168 to +175
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error Handling Gap

The json.loads call for metadata["default_filters"] lacks error handling, which could raise an exception if the value is malformed. It looks like this should be wrapped in a try-except block similar to the main metadata parsing, to log a warning and continue instead of failing the entire chart deletion.

Code suggestion
Check the AI-generated fix before applying
Suggested change
# Clean up default_filters
if "default_filters" in metadata:
default_filters = json.loads(metadata["default_filters"])
chart_id_str = str(chart_id)
if chart_id_str in default_filters:
del default_filters[chart_id_str]
metadata["default_filters"] = json.dumps(default_filters)
modified = True
# Clean up default_filters
if "default_filters" in metadata:
try:
default_filters = json.loads(metadata["default_filters"])
chart_id_str = str(chart_id)
if chart_id_str in default_filters:
del default_filters[chart_id_str]
metadata["default_filters"] = json.dumps(default_filters)
modified = True
except (json.JSONDecodeError, TypeError):
logger.warning("Could not parse default_filters for dashboard %s", dashboard_id)

Code Review Run #eacd8a


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them


# Clean up native_filter_configuration scope exclusions
if "native_filter_configuration" in metadata:
for native_filter in metadata["native_filter_configuration"]:
scope_excluded = native_filter.get("scope", {}).get("excluded", [])
if chart_id in scope_excluded:
scope_excluded.remove(chart_id)
Comment on lines +180 to +182
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The code compares and removes chart_id as an int from lists that may store IDs as strings (and vice versa), so removals can silently fail (logic bug). Normalize comparisons by checking and removing both the integer and string representations to ensure the chart ID is removed regardless of stored type. [logic error]

Severity Level: Critical 🚨
- ❌ Dashboard export/importers/v1 may fail on orphaned references.
- ❌ Native filter exclusion lists remain stale.
- ⚠️ Chart deletion metadata cleanup incomplete.
Suggested change
scope_excluded = native_filter.get("scope", {}).get("excluded", [])
if chart_id in scope_excluded:
scope_excluded.remove(chart_id)
scope = native_filter.get("scope", {})
scope_excluded = scope.get("excluded", [])
removed = False
# remove both int and string representations if present
if chart_id in scope_excluded:
scope_excluded.remove(chart_id)
removed = True
chart_id_str = str(chart_id)
if chart_id_str in scope_excluded:
scope_excluded.remove(chart_id_str)
removed = True
if removed:
Steps of Reproduction ✅
1. Create a dashboard whose json_metadata["native_filter_configuration"] contains an entry
where scope.excluded is a list of string IDs (e.g., ["123"]) rather than ints. The cleanup
loop is at superset/commands/chart/delete.py:177-183 in
DeleteChartCommand._cleanup_dashboard_metadata (function start at
superset/commands/chart/delete.py:78).

2. Invoke DeleteChartCommand.run with the chart id 123 present on that dashboard
(superset/commands/chart/delete.py:49). The run method calls _cleanup_dashboard_metadata
at superset/commands/chart/delete.py:52-54.

3. The current code checks `if chart_id in scope_excluded` at
superset/commands/chart/delete.py:180. Because scope_excluded contains string "123" and
chart_id is the integer 123, the test is False; no removal occurs and modified remains
False for that dashboard.

4. The string-ID exclusion remains in the dashboard metadata and is not cleaned. This
leaves an orphaned reference that can later trigger import-time KeyError in the importer
(see PR description for superset/commands/dashboard/importers/v1/utils.py). The behavior
is concrete and reproducible using string-stored IDs.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/chart/delete.py
**Line:** 180:182
**Comment:**
	*Logic Error: The code compares and removes `chart_id` as an int from lists that may store IDs as strings (and vice versa), so removals can silently fail (logic bug). Normalize comparisons by checking and removing both the integer and string representations to ensure the chart ID is removed regardless of stored type.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

modified = True
Comment thread
ramiroaquinoromero marked this conversation as resolved.

# Clean up chart_configuration
if "chart_configuration" in metadata:
chart_id_str = str(chart_id)
if chart_id_str in metadata["chart_configuration"]:
del metadata["chart_configuration"][chart_id_str]
modified = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a lot of repeating logic for the if statements I think a util function will clean this up quite a bit

# Clean up global_chart_configuration scope exclusions
if "global_chart_configuration" in metadata:
scope_excluded = (
metadata["global_chart_configuration"]
.get("scope", {})
.get("excluded", [])
)
if chart_id in scope_excluded:
scope_excluded.remove(chart_id)
modified = True

if modified:
# Update only the json_metadata field
db.session.query(Dashboard).filter_by(id=dashboard_id).update(
{"json_metadata": json.dumps(metadata)},
synchronize_session=False,
)
logger.info(
"Cleaned up metadata for dashboard %s after deleting chart %s",
dashboard_id,
chart_id,
)
5 changes: 4 additions & 1 deletion superset/commands/dashboard/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
metadata = fixed.get("metadata", {})
if "timed_refresh_immune_slices" in metadata:
metadata["timed_refresh_immune_slices"] = [
id_map[old_id] for old_id in metadata["timed_refresh_immune_slices"]
id_map[old_id]
for old_id in metadata["timed_refresh_immune_slices"]
Comment thread
ramiroaquinoromero marked this conversation as resolved.
if old_id in id_map
]

if "filter_scopes" in metadata:
Expand All @@ -100,6 +102,7 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
metadata["expanded_slices"] = {
str(id_map[int(old_id)]): value
for old_id, value in metadata["expanded_slices"].items()
if int(old_id) in id_map
}

if "default_filters" in metadata:
Expand Down
128 changes: 128 additions & 0 deletions tests/unit_tests/dashboards/commands/importers/v1/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,131 @@ def test_update_id_refs_cross_filter_handles_string_excluded():
fixed = update_id_refs(config, chart_ids, dataset_info)
# Should not raise and should remap key
assert "1" in fixed["metadata"]["chart_configuration"]


def test_update_id_refs_expanded_slices_with_missing_chart():
"""
Test that missing charts in expanded_slices are gracefully skipped.

When a chart is deleted from a workspace, dashboards may still contain
references to the deleted chart ID in expanded_slices metadata. This test
verifies that the import process skips missing chart references instead of
raising a KeyError.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs

config = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
},
"metadata": {
"expanded_slices": {
"101": True, # This chart exists in the import
"102": False, # This chart was deleted and doesn't exist
"103": True, # Another deleted chart
},
},
}
chart_ids = {"uuid1": 1} # Only uuid1 exists in the import
dataset_info: dict[str, dict[str, Any]] = {}

fixed = update_id_refs(config, chart_ids, dataset_info)

# Should only include the existing chart, missing charts are skipped
assert fixed["metadata"]["expanded_slices"] == {"1": True}
# Should not raise KeyError for missing charts 102 and 103


def test_update_id_refs_timed_refresh_immune_slices_with_missing_chart():
"""
Test that missing charts in timed_refresh_immune_slices are gracefully skipped.

When a chart is deleted from a workspace, dashboards may still contain
references to the deleted chart ID in timed_refresh_immune_slices metadata.
This test verifies that the import process skips missing chart references
instead of raising a KeyError.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs

config = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
"CHART2": {
"id": "CHART2",
"meta": {"chartId": 102, "uuid": "uuid2"},
"type": "CHART",
},
},
"metadata": {
"timed_refresh_immune_slices": [
101, # This chart exists
102, # This chart exists
103, # This chart was deleted and doesn't exist
104, # Another deleted chart
],
},
}
Comment on lines +252 to +273
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could add a general function that builds a new config for you, so you can modify as you wish for each test

chart_ids = {"uuid1": 1, "uuid2": 2} # Only uuid1 and uuid2 exist
dataset_info: dict[str, dict[str, Any]] = {}

fixed = update_id_refs(config, chart_ids, dataset_info)

# Should only include existing charts, missing charts are skipped
assert fixed["metadata"]["timed_refresh_immune_slices"] == [1, 2]
# Should not raise KeyError for missing charts 103 and 104


def test_update_id_refs_multiple_missing_chart_references():
"""
Test that multiple metadata fields with missing charts are all handled gracefully.

This comprehensive test verifies that all metadata fields properly skip
missing chart references during import.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs

config = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
},
"metadata": {
"expanded_slices": {
"101": True,
"999": False, # Missing chart
},
"timed_refresh_immune_slices": [101, 999], # 999 is missing
"filter_scopes": {
"101": {"region": {"immune": [999]}}, # 999 is missing
"999": {"region": {"immune": [101]}}, # Key 999 is missing
},
"default_filters": '{"101": {"col": "value"}, "999": {"col": "other"}}',
"native_filter_configuration": [
{"scope": {"excluded": [101, 999]}} # 999 is missing
],
},
}
chart_ids = {"uuid1": 1} # Only uuid1 exists
dataset_info: dict[str, dict[str, Any]] = {}

fixed = update_id_refs(config, chart_ids, dataset_info)

# All missing chart references should be gracefully skipped
assert fixed["metadata"]["expanded_slices"] == {"1": True}
assert fixed["metadata"]["timed_refresh_immune_slices"] == [1]
assert fixed["metadata"]["filter_scopes"] == {"1": {"region": {"immune": []}}}
assert fixed["metadata"]["default_filters"] == '{"1": {"col": "value"}}'
Comment thread
ramiroaquinoromero marked this conversation as resolved.
assert fixed["metadata"]["native_filter_configuration"] == [
{"scope": {"excluded": [1]}}
]
Comment thread
ramiroaquinoromero marked this conversation as resolved.
Loading