This is an automated email from the ASF dual-hosted git repository.

sha174n pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 57a92170bd5 fix(chart): apply the query-context datasource check on 
create and update (#43997)
57a92170bd5 is described below

commit 57a92170bd531d6db83d1a8d008757664d92f0bf
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 9 14:03:14 2026 +0100

    fix(chart): apply the query-context datasource check on create and update 
(#43997)
    
    Co-authored-by: sha174n <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 superset/commands/chart/create.py              |  8 +++
 superset/commands/chart/update.py              | 47 ++------------
 superset/commands/chart/utils.py               | 71 +++++++++++++++++++++
 tests/unit_tests/commands/chart/create_test.py | 87 +++++++++++++++++++++++++-
 4 files changed, 171 insertions(+), 42 deletions(-)

diff --git a/superset/commands/chart/create.py 
b/superset/commands/chart/create.py
index 3fbd5715fa3..c53bf7f2a23 100644
--- a/superset/commands/chart/create.py
+++ b/superset/commands/chart/create.py
@@ -32,6 +32,7 @@ from superset.commands.chart.exceptions import (
     DashboardsForbiddenError,
     DashboardsNotFoundValidationError,
 )
+from superset.commands.chart.utils import validate_query_context_datasource
 from superset.commands.exceptions import DatasourceTypeInvalidError
 from superset.commands.utils import get_datasource_by_id, populate_subjects
 from superset.daos.chart import ChartDAO
@@ -91,6 +92,13 @@ class CreateChartCommand(CreateMixin, BaseCommand):
         except ValidationError as ex:
             exceptions.append(ex)
 
+        validate_query_context_datasource(
+            self._properties.get("query_context"),
+            datasource_id,
+            datasource_type,
+            exceptions,
+        )
+
         # Validate/Populate dashboards
         dashboards = DashboardDAO.find_by_ids(dashboard_ids)
         if len(dashboards) != len(dashboard_ids):
diff --git a/superset/commands/chart/update.py 
b/superset/commands/chart/update.py
index 25603056d3a..069c9829a41 100644
--- a/superset/commands/chart/update.py
+++ b/superset/commands/chart/update.py
@@ -29,12 +29,12 @@ from superset.commands.chart.exceptions import (
     ChartForbiddenError,
     ChartInvalidError,
     ChartNotFoundError,
-    ChartQueryContextDatasourceMismatchValidationError,
     ChartUpdateFailedError,
     DashboardsForbiddenError,
     DashboardsNotFoundValidationError,
     DatasourceTypeUpdateRequiredValidationError,
 )
+from superset.commands.chart.utils import validate_query_context_datasource
 from superset.commands.exceptions import DatasourceTypeInvalidError
 from superset.commands.utils import (
     compute_subjects,
@@ -49,7 +49,6 @@ from superset.extensions import db
 from superset.models.dashboard import Dashboard
 from superset.models.slice import Slice
 from superset.tags.models import ObjectType
-from superset.utils import json
 from superset.utils.core import DatasourceType
 from superset.utils.decorators import on_error, transaction
 from superset.versioning.changes.normalization import (
@@ -135,49 +134,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
     ) -> None:
         """
         Ensure a query-context-only update keeps the chart's own datasource.
-
-        The submitted query context is only verified when it carries a 
parseable
-        ``datasource`` object; a payload that references a different 
datasource than
-        the chart's persisted one is rejected. Payloads without a datasource 
fall
-        back to the chart's datasource at execution time and need no check.
         """
         if not self._model:
             return
-
-        raw_query_context = self._properties.get("query_context")
-        if not raw_query_context:
-            return
-
-        try:
-            query_context = json.loads(raw_query_context)
-        except (TypeError, ValueError):
-            # TypeError covers a query_context that isn't a string (e.g. an
-            # already-parsed dict); that shape is intentionally out of scope
-            # here since the schema serializes it as a JSON string. ValueError
-            # covers a string that failed to parse. Either way, an unverifiable
-            # payload is left for downstream handling rather than guessed at.
-            return
-
-        datasource = (
-            query_context.get("datasource") if isinstance(query_context, dict) 
else None
+        validate_query_context_datasource(
+            self._properties.get("query_context"),
+            self._model.datasource_id,
+            self._model.datasource_type,
+            exceptions,
         )
-        if not isinstance(datasource, dict):
-            return
-
-        try:
-            ids_match = int(datasource["id"]) == self._model.datasource_id
-        except (KeyError, TypeError, ValueError):
-            ids_match = False
-
-        # A datasource object must carry a type that matches the chart's own.
-        # Treating a missing type as valid would let an id-only payload 
through,
-        # and query-context loading reads datasource["type"] directly, so that
-        # payload raises KeyError when the saved context is later replayed.
-        datasource_type = datasource.get("type")
-        types_match = str(datasource_type) == self._model.datasource_type
-
-        if not ids_match or not types_match:
-            
exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
 
     def validate(self) -> None:  # noqa: C901
         exceptions: list[ValidationError] = []
diff --git a/superset/commands/chart/utils.py b/superset/commands/chart/utils.py
new file mode 100644
index 00000000000..62a55334d87
--- /dev/null
+++ b/superset/commands/chart/utils.py
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any
+
+from marshmallow import ValidationError
+
+from superset.commands.chart.exceptions import (
+    ChartQueryContextDatasourceMismatchValidationError,
+)
+from superset.utils import json
+
+
+def validate_query_context_datasource(
+    raw_query_context: Any,
+    expected_datasource_id: Any,
+    expected_datasource_type: Any,
+    exceptions: list[ValidationError],
+) -> None:
+    """
+    Ensure a submitted query context targets the expected datasource.
+
+    Shared by the chart create and update commands so both apply the same
+    binding. The query context is only checked when it carries a parseable
+    ``datasource`` object; a payload that references a different datasource 
than
+    the expected one is rejected. A payload without a datasource falls back to
+    the chart's datasource at execution time and needs no check.
+    """
+    if not raw_query_context:
+        return
+
+    try:
+        query_context = json.loads(raw_query_context)
+    except (TypeError, ValueError):
+        # A query context that isn't a parseable JSON string (e.g. an
+        # already-parsed dict, or an unparseable string) is left for downstream
+        # handling rather than guessed at.
+        return
+
+    datasource = (
+        query_context.get("datasource") if isinstance(query_context, dict) 
else None
+    )
+    if not isinstance(datasource, dict):
+        return
+
+    try:
+        ids_match = int(datasource["id"]) == int(expected_datasource_id)
+    except (KeyError, TypeError, ValueError):
+        ids_match = False
+
+    # A datasource object must carry a type that matches the expected one.
+    # Treating a missing type as valid would let an id-only payload through,
+    # and query-context loading reads datasource["type"] directly, so that
+    # payload raises KeyError when the saved context is later replayed.
+    types_match = str(datasource.get("type")) == str(expected_datasource_type)
+
+    if not ids_match or not types_match:
+        exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
diff --git a/tests/unit_tests/commands/chart/create_test.py 
b/tests/unit_tests/commands/chart/create_test.py
index 3cc26b4e7a2..e61a46f0009 100644
--- a/tests/unit_tests/commands/chart/create_test.py
+++ b/tests/unit_tests/commands/chart/create_test.py
@@ -28,10 +28,15 @@ import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.chart.create import CreateChartCommand
-from superset.commands.chart.exceptions import ChartForbiddenError, 
ChartInvalidError
+from superset.commands.chart.exceptions import (
+    ChartForbiddenError,
+    ChartInvalidError,
+    ChartQueryContextDatasourceMismatchValidationError,
+)
 from superset.commands.exceptions import DatasourceTypeInvalidError
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import SupersetSecurityException
+from superset.utils import json
 
 
 def _base_mocks(mocker: MockerFixture) -> None:
@@ -152,3 +157,83 @@ def 
test_create_chart_datasource_access_denied_still_raises_forbidden(
                 "viz_type": "table",
             }
         ).validate()
+
+
+def _create_payload(query_context: str) -> dict[str, object]:
+    return {
+        "datasource_id": 42,
+        "datasource_type": "table",
+        "slice_name": "some_name",
+        "viz_type": "table",
+        "query_context": query_context,
+    }
+
+
+def _mock_table_datasource(mocker: MockerFixture) -> None:
+    _base_mocks(mocker)
+    datasource = mocker.MagicMock()
+    datasource.name = "my_table"
+    mocker.patch(
+        "superset.commands.chart.create.get_datasource_by_id",
+        return_value=datasource,
+    )
+    
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
+
+
+def test_create_chart_query_context_matching_datasource_is_allowed(
+    mocker: MockerFixture,
+) -> None:
+    """A query context targeting the chart's own datasource is accepted."""
+    _mock_table_datasource(mocker)
+
+    CreateChartCommand(
+        _create_payload(
+            json.dumps({"datasource": {"id": 42, "type": "table"}, "queries": 
[]})
+        )
+    ).validate()
+
+
[email protected](
+    "datasource",
+    [
+        {"id": 99, "type": "table"},  # different id
+        {"id": 42, "type": "query"},  # different type
+        {"id": "99", "type": "table"},  # different id as string
+        {"id": 42},  # matching id but missing type
+    ],
+)
+def test_create_chart_query_context_mismatched_datasource_is_rejected(
+    mocker: MockerFixture,
+    datasource: dict[str, object],
+) -> None:
+    """A query context pointing at a different datasource than the one the
+    chart is created against is rejected."""
+    _mock_table_datasource(mocker)
+
+    with pytest.raises(ChartInvalidError) as exc_info:
+        CreateChartCommand(
+            _create_payload(json.dumps({"datasource": datasource, "queries": 
[]}))
+        ).validate()
+
+    assert any(
+        isinstance(ex, ChartQueryContextDatasourceMismatchValidationError)
+        for ex in exc_info.value._exceptions
+    )
+
+
[email protected](
+    "query_context",
+    [
+        "{}",  # no datasource key
+        '{"datasource": null}',  # null datasource
+        "not-json",  # unparseable payload
+    ],
+)
+def test_create_chart_query_context_without_datasource_is_allowed(
+    mocker: MockerFixture,
+    query_context: str,
+) -> None:
+    """Payloads with no verifiable datasource fall back to the chart's own."""
+    _mock_table_datasource(mocker)
+
+    CreateChartCommand(_create_payload(query_context)).validate()

Reply via email to