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

rusackas 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 298aa2f0ba4 fix(dataset): bump changed_on on column refresh so chart 
cache invalidates (#43921)
298aa2f0ba4 is described below

commit 298aa2f0ba4b58c1a4c87fb9bc36be4dcd917c92
Author: Prajasree <[email protected]>
AuthorDate: Thu Sep 24 02:04:06 2026 +0530

    fix(dataset): bump changed_on on column refresh so chart cache invalidates 
(#43921)
    
    Co-authored-by: rusackas <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 superset/connectors/sqla/models.py              |  53 +++++-
 tests/integration_tests/datasets/api_tests.py   |  15 +-
 tests/unit_tests/connectors/sqla/models_test.py | 234 ++++++++++++++++++++++++
 3 files changed, 292 insertions(+), 10 deletions(-)

diff --git a/superset/connectors/sqla/models.py 
b/superset/connectors/sqla/models.py
index fc58e885d58..7bb0b48855a 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -23,7 +23,7 @@ import re
 from collections import defaultdict
 from collections.abc import Hashable
 from dataclasses import dataclass, field
-from datetime import timedelta
+from datetime import datetime, timedelta
 from typing import Any, Callable, cast, Optional, Union
 from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
 
@@ -153,6 +153,27 @@ class MetadataResult:
     modified: list[str] = field(default_factory=list)
 
 
+def _is_calculated_column(column: TableColumn) -> bool:
+    """Return whether *column* is a user-defined virtual column.
+
+    ``fetch_metadata`` keeps calculated columns that the source table does
+    not list. Engine specs such as Trino also store an ``expression`` on
+    expanded nested ``ROW`` fields, whose name is the dotted path (e.g.
+    ``metadata.uuid``) and whose expression is always that same path
+    quoted per-part (e.g. ``"metadata"."uuid"``). Those are still physical
+    columns: if the source no longer lists them they must be dropped so
+    chart cache keys invalidate. A user-authored calculated column can
+    also have a dotted name, so the dot alone cannot be the signal; only
+    drop columns whose expression matches Trino's quoted-path pattern for
+    its own name. See #43918.
+    """
+    if not column.expression:
+        return False
+    name = column.column_name or ""
+    quoted_path = ".".join(f'"{part}"' for part in name.split("."))
+    return column.expression != quoted_path
+
+
 METRIC_FORM_DATA_PARAMS = [
     "metric",
     "metric_2",
@@ -2385,7 +2406,12 @@ class SqlaTable(
                     new_column.expression = expression
             else:
                 new_column = old_column
-                if new_column.type != col["type"]:
+                # Type and physical expression both feed generated SQL, so
+                # either change is schema drift that must invalidate chart
+                # cache keys (see changed_on bump below).
+                if new_column.type != col["type"] or (
+                    (new_column.expression or "") != expression
+                ):
                     results.modified.append(col["column_name"])
                 new_column.type = col["type"]
                 new_column.expression = expression
@@ -2400,11 +2426,15 @@ class SqlaTable(
 
         # Add back calculated (virtual) columns, i.e. those that weren't 
matched
         # against `new_columns` above and are thus still present in
-        # `old_columns_by_name`. Columns that were matched are already 
appended to
-        # `columns` in the loop above, and re-adding them here (e.g. via 
`old_columns`)
-        # would duplicate any synced physical column that also carries a truthy
-        # `expression`, such as Trino's expanded nested `ROW` fields.
-        columns.extend([col for col in old_columns_by_name.values() if 
col.expression])
+        # `old_columns_by_name`. Nested physical ROW fields also carry an
+        # expression; they are not calculated columns and must not be kept
+        # when the source no longer lists them (delete-orphan then removes
+        # the TableColumn row).
+        leftover_columns = list(old_columns_by_name.values())
+        dropped_physical_columns = any(
+            not _is_calculated_column(col) for col in leftover_columns
+        )
+        columns.extend(col for col in leftover_columns if 
_is_calculated_column(col))
         self.columns = columns
 
         if not self.main_dttm_col:
@@ -2414,6 +2444,15 @@ class SqlaTable(
         # Apply config supplied mutations.
         current_app.config["SQLA_TABLE_MUTATOR"](self)
 
+        # Child TableColumn rows own the FK, so mutating them (and reassigning
+        # ``self.columns``) does not emit an UPDATE on this tables row.
+        # AuditMixinNullable.changed_on onupdate therefore never fires, and
+        # query_cache_key() keeps serving results computed against the previous
+        # column definitions. Force the same bump DatasetDAO.update() applies
+        # when columns are saved. See #43918.
+        if results.added or results.modified or dropped_physical_columns:
+            self.changed_on = datetime.now()
+
         db.session.merge(self)
         return results
 
diff --git a/tests/integration_tests/datasets/api_tests.py 
b/tests/integration_tests/datasets/api_tests.py
index d5fa10902a6..6a6ff7e8a23 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -2349,17 +2349,22 @@ class TestDatasetApi(SupersetTestCase):
         """
 
         dataset = self.insert_default_dataset()
-        # delete a column
+        # delete a column so refresh has schema drift to sync
         id_column = (
             db.session.query(TableColumn)
             .filter_by(table_id=dataset.id, column_name="id")
             .one()
         )
-        self.items_to_delete = [id_column]
+        db.session.delete(id_column)
+        db.session.commit()
+        db.session.refresh(dataset)
+        current_changed_on = dataset.changed_on
 
         self.login(ADMIN_USERNAME)
         uri = f"api/v1/dataset/{dataset.id}/refresh"
-        rv = self.put_assert_metric(uri, {}, "refresh")
+        with freeze_time() as frozen:
+            frozen.tick(delta=timedelta(seconds=3))
+            rv = self.put_assert_metric(uri, {}, "refresh")
         assert rv.status_code == 200
         # Assert the column is restored on refresh
         id_column = (
@@ -2368,6 +2373,10 @@ class TestDatasetApi(SupersetTestCase):
             .one()
         )
         assert id_column is not None
+        # Refresh mutates child TableColumn rows only, so changed_on must be
+        # force-bumped or chart cache keys stay stale. See #43918.
+        updated_dataset = 
db.session.query(SqlaTable).filter_by(id=dataset.id).first()
+        assert updated_dataset.changed_on > current_changed_on
         self.items_to_delete = [dataset]
 
     def test_dataset_item_refresh_not_found(self):
diff --git a/tests/unit_tests/connectors/sqla/models_test.py 
b/tests/unit_tests/connectors/sqla/models_test.py
index 4d9b9e92bd7..41212930f9e 100644
--- a/tests/unit_tests/connectors/sqla/models_test.py
+++ b/tests/unit_tests/connectors/sqla/models_test.py
@@ -15,10 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from datetime import datetime
 from unittest.mock import MagicMock
 
 import pandas as pd
 import pytest
+from freezegun import freeze_time
 from jinja2.exceptions import UndefinedError
 from pytest_mock import MockerFixture
 from sqlalchemy import create_engine
@@ -937,6 +939,238 @@ def 
test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> N
     assert columns_by_name["col_with_valid_comment"].description == "Valid 
comment"
 
 
+def _table_for_fetch_metadata(
+    mocker: MockerFixture,
+    source_columns: list[dict[str, str]],
+    existing: list[dict[str, str]] | None = None,
+) -> SqlaTable:
+    """Build a SqlaTable whose ``fetch_metadata`` reads *source_columns*."""
+    database = mocker.MagicMock()
+    database.get_metrics.return_value = []
+    database.db_engine_spec = mocker.MagicMock()
+    table = SqlaTable(table_name="test_table", database=database)
+    table.id = 1
+    existing_cols = [
+        TableColumn(
+            column_name=spec["column_name"],
+            type=spec["type"],
+            table=table,
+            expression=spec.get("expression") or "",
+        )
+        for spec in existing or []
+    ]
+    table.columns = existing_cols
+    mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
+    mock_session.query.return_value.filter.return_value.all.return_value = 
existing_cols
+    mocker.patch.object(table, "external_metadata", 
return_value=source_columns)
+    return table
+
+
+def test_fetch_metadata_bumps_changed_on_when_column_type_changes(
+    mocker: MockerFixture,
+) -> None:
+    """Schema drift on an existing column must bump ``changed_on``.
+
+    ``query_cache_key`` includes ``datasource.changed_on``; without this bump
+    a Refresh-columns action would keep serving chart results computed
+    against the previous type. See #43918.
+    """
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "revenue", "type": "INTEGER"}],
+        existing=[{"column_name": "revenue", "type": "VARCHAR"}],
+    )
+    table.changed_on = datetime(2024, 6, 1, 12, 0, 0)
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert result.modified == ["revenue"]
+    assert table.changed_on == datetime(2024, 6, 1, 12, 0, 5)
+
+
+def test_fetch_metadata_bumps_changed_on_when_column_added(
+    mocker: MockerFixture,
+) -> None:
+    """A newly discovered source column must bump ``changed_on``."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[
+            {"column_name": "id", "type": "INTEGER"},
+            {"column_name": "name", "type": "VARCHAR"},
+        ],
+        existing=[{"column_name": "id", "type": "INTEGER"}],
+    )
+    table.changed_on = datetime(2024, 6, 1, 12, 0, 0)
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert result.added == ["name"]
+    assert table.changed_on == datetime(2024, 6, 1, 12, 0, 5)
+
+
+def test_fetch_metadata_bumps_changed_on_when_physical_column_removed(
+    mocker: MockerFixture,
+) -> None:
+    """Dropping a physical source column must bump ``changed_on``."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "id", "type": "INTEGER"}],
+        existing=[
+            {"column_name": "id", "type": "INTEGER"},
+            {"column_name": "name", "type": "VARCHAR"},
+        ],
+    )
+    table.changed_on = datetime(2024, 6, 1, 12, 0, 0)
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert result.removed == ["name"]
+    assert table.changed_on == datetime(2024, 6, 1, 12, 0, 5)
+
+
+def test_fetch_metadata_drops_nested_physical_column_and_bumps_changed_on(
+    mocker: MockerFixture,
+) -> None:
+    """A dropped nested ROW field has an expression but is still physical.
+
+    Keeping it would leave a stale TableColumn and skip the changed_on bump.
+    """
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "id", "type": "INTEGER"}],
+        existing=[
+            {"column_name": "id", "type": "INTEGER"},
+            {
+                "column_name": "metadata.uuid",
+                "type": "VARCHAR",
+                "expression": '"metadata"."uuid"',
+            },
+        ],
+    )
+    table.changed_on = datetime(2024, 6, 1, 12, 0, 0)
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert "metadata.uuid" in result.removed
+    assert all(col.column_name != "metadata.uuid" for col in table.columns)
+    assert table.changed_on == datetime(2024, 6, 1, 12, 0, 5)
+
+
+def test_fetch_metadata_bumps_changed_on_when_expression_changes(
+    mocker: MockerFixture,
+) -> None:
+    """A physical expression change is schema drift and must bump 
``changed_on``."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[
+            {
+                "column_name": "metadata.uuid",
+                "type": "VARCHAR",
+                "expression": '"metadata"."uuid"',
+            }
+        ],
+        existing=[
+            {
+                "column_name": "metadata.uuid",
+                "type": "VARCHAR",
+                "expression": "",
+            }
+        ],
+    )
+    table.changed_on = datetime(2024, 6, 1, 12, 0, 0)
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert result.modified == ["metadata.uuid"]
+    assert table.changed_on == datetime(2024, 6, 1, 12, 0, 5)
+
+
+def test_fetch_metadata_does_not_bump_changed_on_when_schema_unchanged(
+    mocker: MockerFixture,
+) -> None:
+    """A no-op refresh must not invalidate chart cache keys."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "revenue", "type": "INTEGER"}],
+        existing=[{"column_name": "revenue", "type": "INTEGER"}],
+    )
+    original_changed_on = datetime(2024, 6, 1, 12, 0, 0)
+    table.changed_on = original_changed_on
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert result.added == []
+    assert result.modified == []
+    assert result.removed == []
+    assert table.changed_on == original_changed_on
+
+
+def test_fetch_metadata_does_not_bump_changed_on_for_kept_virtual_columns(
+    mocker: MockerFixture,
+) -> None:
+    """Calculated columns are reported in ``removed`` but kept; that is not 
drift."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "id", "type": "INTEGER"}],
+        existing=[
+            {"column_name": "id", "type": "INTEGER"},
+            {
+                "column_name": "profit",
+                "type": "INTEGER",
+                "expression": "revenue - cost",
+            },
+        ],
+    )
+    original_changed_on = datetime(2024, 6, 1, 12, 0, 0)
+    table.changed_on = original_changed_on
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert "profit" in result.removed
+    assert any(col.column_name == "profit" for col in table.columns)
+    assert table.changed_on == original_changed_on
+
+
+def test_fetch_metadata_keeps_dotted_calculated_column(
+    mocker: MockerFixture,
+) -> None:
+    """A user calculated column with a dotted name must survive a refresh.
+
+    Only Trino's quoted-path expression signature for expanded ``ROW``
+    fields identifies a leftover as physical; a bare dot in the name is
+    not enough, since ``DatasetColumnsPutSchema.column_name`` allows any
+    name up to 255 characters. See #43918.
+    """
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "id", "type": "INTEGER"}],
+        existing=[
+            {"column_name": "id", "type": "INTEGER"},
+            {
+                "column_name": "revenue.usd",
+                "type": "INTEGER",
+                "expression": "revenue * fx_rate",
+            },
+        ],
+    )
+    original_changed_on = datetime(2024, 6, 1, 12, 0, 0)
+    table.changed_on = original_changed_on
+
+    with freeze_time("2024-06-01 12:00:05"):
+        result = table.fetch_metadata()
+
+    assert "revenue.usd" in result.removed
+    assert any(col.column_name == "revenue.usd" for col in table.columns)
+    assert table.changed_on == original_changed_on
+
+
 @pytest.mark.parametrize(
     
"supports_cross_catalog,table_name,catalog,schema,expected_name,expected_schema",
     [

Reply via email to