sadpandajoe commented on code in PR #43870:
URL: https://github.com/apache/superset/pull/43870#discussion_r4185020733


##########
superset/models/helpers.py:
##########
@@ -5623,6 +6025,33 @@ def get_sqla_query(  # pylint: 
disable=too-many-arguments,too-many-locals,too-ma
                 )
                 having_clause_and += [Grouping(self.text(having))]
 
+        # The mirrors go on last, so the snapshot taken here is everything the
+        # outer query and the series-limit ranking subquery agree about: row
+        # level security, the extras `where`, every filter. The two disagree
+        # about exactly the mirrors, and subtracting them again later is not an
+        # option -- `==` on a SQLAlchemy expression builds a new expression
+        # rather than answering a question, so the list cannot be filtered by
+        # identity.
+        where_clause_and_without_mirrors = list(where_clause_and)
+        inner_partition_mirror_predicates: list[Any] = []
+        if partition_mapping is not None:
+            partition_mirror_predicates = 
self._build_partition_mirror_predicates(

Review Comment:
   On a mapped virtual dataset using `current_username()`, cache-key extraction 
calls `get_sqla_query()` before the chart-cache lookup, so a relative range 
such as Last 24 hours runs a new warehouse probe even when chart data is 
cached. Could extra-cache-key extraction skip these probes and leave them to 
SQL generation for a cache miss?



##########
superset/connectors/sqla/partition_mapping.py:
##########
@@ -0,0 +1,1613 @@
+# 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.
+"""
+Partition filter mapping.
+
+Datasets on Hadoop-family engines are commonly partitioned on a *technical*
+column -- an epoch integer, a lowercased region key -- that no analyst would
+filter on. Unless a query carries a predicate on that column the engine scans
+every partition.
+
+A dataset owner names one partition column ``p``, one business column that
+filters are mirrored from, and a value transform ``T`` (a SQL expression
+containing a ``:value`` placeholder). Superset then appends an equivalent
+predicate on ``p`` to every query, so chart authors change nothing and queries
+prune.
+
+The load-bearing assumption
+---------------------------
+Everything here reasons about ``T(col) op T(v)``, but what is emitted is
+``p op T(v)`` -- a predicate on a *physically different column*. The step from
+one to the other is::
+
+    p = T(mapped_col)   for every row in the table
+
+Superset cannot verify that; it is a property of whatever ETL populates the
+partition column. If that job lags, backfills with different logic, or writes
+the partition key in a different timezone, mirrored predicates silently drop
+real rows. The mapping is only as trustworthy as the pipeline behind it.
+
+Operator safety
+---------------
+A mirrored predicate ``P2`` may only be ``AND``-ed onto a query when the
+original predicate ``P1`` *implies* it:
+
+===========================================  =============================
+Original                                     Safe when
+===========================================  =============================
+``col = v``, ``col IN (...)``                ``T`` is a function *and* the
+                                             engine compares ``col``
+                                             byte-exactly
+``col >=|>|<|<= v``, ``TEMPORAL_RANGE``      only if ``T`` is monotonic
+``col != v``, ``NOT IN``, ``LIKE``, ...      never
+===========================================  =============================
+
+Equality under the engine's own rules
+-------------------------------------
+"``T`` is a function" gives ``col = v`` implies ``T(col) = T(v)`` for *value*
+equality. The engine compares with *SQL* equality, and the two part company
+wherever that comparison is not byte-exact: under a case-insensitive collation
+a stored ``country`` of ``'us'`` satisfies a filter for ``'US'``, while the
+mirror ``region_key = hex('US')`` excludes the row and the chart loses it with
+nothing to indicate why. Trailing-space-insensitive ``CHAR`` comparison says
+the same thing about padding.
+
+So equality and ``IN`` are withdrawn for a *string* mapped column unless the
+engine spec declares ``binary_string_comparison`` -- `equality_mirrors_safely`.
+Numeric and temporal comparison is exact everywhere and is untouched, and range
+mirroring is untouched too: it already rests on the owner declaring ``T``
+order-preserving *with respect to the column's own order*, which is an
+assertion about the very semantics this is checking for.
+
+What the gate cannot see is a column declaring its own collation
+(``country COLLATE NOCASE``) on an otherwise byte-exact engine. Nothing in
+SQLAlchemy's reflection or the engine specs exposes that, so there it remains
+the owner's assumption, like ``p = T(mapped_col)`` itself.
+
+Negations are never safe because ``T`` need not be injective:
+``lower(:value)`` with ``country != 'US'`` mirrors to ``region_key != 'us'``,
+which wrongly excludes rows whose ``country`` is already lowercase ``'us'`` --
+rows the original filter *keeps*.
+
+Monotonicity is a property of the transform, not of the column's data type:
+``hour(:value)``, ``date_format(:value, 'dd')`` and ``dayofweek(:value)`` are
+all reasonable transforms on a ``TIMESTAMP`` column and none of them preserve
+ordering. It is therefore declared by the owner, not inferred.
+
+Time grains
+-----------
+A grained filter -- drill-to-detail, mostly -- compares the *truncated* column,
+``trunc(col) op v``, so the raw bounds it carries do not describe the rows it
+keeps. A row in the final partial bucket satisfies ``trunc(col) < until`` while
+``col < until`` excludes it.
+
+Which direction a grain rounds is not knowable from the duration alone, and the
+obvious guess is wrong: ``WEEK_ENDING_SATURDAY`` rounds *forward* on Hive and
+Presto, and Ocient's grains are ``ROUND``, i.e. to nearest. What every grain
+does satisfy is a bound on the displacement::
+
+    |trunc(ts) - ts| < width(grain)
+
+so widening *both* bounds by one bucket width is no narrower than the real
+predicate whichever way the grain rounds -- and it needs no monotonicity of
+``trunc`` itself, which is what rescues Hive's oddly-anchored ``P1W``. Width is
+a property of the grain, not of the engine, which is what makes this
+maintainable; see `grain_bucket_width`. A grain whose SQL an operator supplied
+has no known width, and does not mirror at all.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+from dataclasses import dataclass
+from functools import lru_cache
+from typing import Any, cast, TYPE_CHECKING
+
+import numpy as np
+import pandas as pd
+import sqlalchemy as sa
+from dateutil.relativedelta import relativedelta
+from flask import current_app as app
+from flask_babel import lazy_gettext as _
+from sqlalchemy.engine.interfaces import Dialect
+from sqlalchemy.sql.elements import ColumnElement
+
+from superset.constants import LRU_CACHE_MAX_SIZE, TimeGrain
+from superset.exceptions import (
+    QueryClauseValidationException,
+    SupersetParseError,
+    SupersetSecurityException,
+)
+from superset.extensions import cache_manager, feature_flag_manager
+from superset.sql.parse import SQLScript, SQLStatement
+from superset.utils import core as utils, json
+from superset.utils.core import FilterOperator
+
+if TYPE_CHECKING:
+    from superset.connectors.sqla.models import SqlaTable, TableColumn
+    from superset.db_engine_specs.base import BaseEngineSpec
+    from superset.models.core import Database
+
+logger = logging.getLogger(__name__)
+
+FEATURE_FLAG = "PARTITION_FILTER_MAPPING"
+
+#: Longest transform accepted anywhere. A transform is one SQL expression an
+#: owner types by hand, so this is generous; the point is that it is bounded.
+#: Every entry point enforces it -- the typed column field, the import schema
+#: and the preview request -- because `is_transform_active` parses the stored
+#: value on each Explore load, and an unbounded string would make that parse
+#: the expensive part of rendering a chart.
+MAX_TRANSFORM_LENGTH = 1024
+
+#: Placeholder the owner writes in the transform, e.g. 
``unix_timestamp(:value)``.
+#: Matched with word boundaries so ``:values`` is not mistaken for it.
+VALUE_PLACEHOLDER_RE = re.compile(r":value\b")
+
+#: Balanced Jinja blocks. The probe would render these in a different context
+#: at a different time from the chart query, so they are rejected at save time.
+JINJA_BLOCK_RE = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}", re.DOTALL)
+
+#: Substituted for ``:value`` before parsing -- sqlglot rejects a bare 
``:value``
+#: on most dialects. Mirrors the ``_JINJA_BLOCK_RE`` -> ``NULL`` trick used by
+#: ``validate_stored_expression``.
+_PARSE_STANDIN = "NULL"
+
+#: Functions whose value depends on wall-clock time or randomness. The probe
+#: runs in a different session at a different moment from the chart query and
+#: its result is then cached, so any of these freezes a snapshot of probe time
+#: into the emitted predicate.
+NON_DETERMINISTIC_FUNCTIONS = {
+    "CURRENT_DATE",
+    "CURRENT_TIME",
+    "CURRENT_TIMESTAMP",
+    "NOW",
+    "RAND",
+    "RANDOM",
+    "UUID",
+}
+
+#: Functions that mean "now" only in their zero-argument form. On Hive and
+#: Impala ``unix_timestamp()`` is the current time while ``unix_timestamp(x)``
+#: -- the canonical transform for this feature -- is pure.
+NON_DETERMINISTIC_WHEN_NILADIC = {"UNIX_TIMESTAMP"}
+
+#: Safe for any function ``T``.
+MIRRORABLE_ALWAYS = {FilterOperator.EQUALS, FilterOperator.IN}
+
+#: Safe only when ``T`` preserves ordering.
+MIRRORABLE_IF_MONOTONIC = {
+    FilterOperator.GREATER_THAN,
+    FilterOperator.GREATER_THAN_OR_EQUALS,
+    FilterOperator.LESS_THAN,
+    FilterOperator.LESS_THAN_OR_EQUALS,
+    FilterOperator.TEMPORAL_RANGE,
+}
+
+
+#: Every operator that can be mirrored under *some* transform. The preview
+#: endpoint accepts these; whether a given one actually mirrors still depends 
on
+#: the monotonicity declaration.
+MIRRORABLE_OPERATORS = MIRRORABLE_ALWAYS | MIRRORABLE_IF_MONOTONIC
+
+#: Operators the preview endpoint can construct. `TEMPORAL_RANGE` is mirrored 
by
+#: the query path, but as *two* bounds that `_collect_partition_mirror_range`
+#: decomposes a time range into -- the preview request carries sample values, 
not
+#: a since/until pair, so there is no range for it to build. The editor never
+#: asks for one either: `previewOperatorFor` sends `>=`, `=` or `IN`.
+PREVIEWABLE_OPERATORS = MIRRORABLE_OPERATORS - {FilterOperator.TEMPORAL_RANGE}
+
+
+def mirrorable_operators(
+    is_monotonic: bool, *, equality_is_safe: bool = True
+) -> set[FilterOperator]:
+    """
+    The operators whose predicates may be mirrored onto the partition column.
+
+    :param is_monotonic: whether the owner declared the transform
+        order-preserving
+    :param equality_is_safe: whether the engine's ``=`` on the mapped column
+        compares the way ``T`` was written against -- see
+        `equality_mirrors_safely`
+
+    Both call sites -- the query path through `PartitionMapping.mirrors` and 
the
+    Explore indicator through `partition_filter_mapping_summary` -- come 
through
+    here, which is what keeps the glyph from promising pruning the SQL will not
+    do.
+    """
+    operators = MIRRORABLE_ALWAYS if equality_is_safe else set()
+    if is_monotonic:
+        operators = operators | MIRRORABLE_IF_MONOTONIC
+    return set(operators)
+
+
+def equality_mirrors_safely(
+    column: TableColumn | None,
+    db_engine_spec: type[BaseEngineSpec],
+) -> bool:
+    """
+    Whether ``col = v`` on this column implies ``T(col) = T(v)`` in the engine.
+
+    The operator matrix calls equality safe "for any function ``T``" because
+    ``T`` is a function, so ``col = v`` gives ``T(col) = T(v)``. That reasons
+    about *value* equality while the engine reasons about *SQL* equality, and
+    the two part company under any comparison that is not byte-exact: with a
+    case-insensitive collation a stored ``country`` of ``'us'`` satisfies a
+    filter for ``'US'``, but the mirror ``region_key = hex('US')`` excludes the
+    row and the chart loses it with no indication why. 
Trailing-space-insensitive
+    ``CHAR`` comparison says the same thing about padding.
+
+    Only string columns are at risk; numeric and temporal comparison is exact
+    everywhere. And only equality: range mirroring already requires the owner
+    to declare ``T`` order-preserving *with respect to the column's own order*,
+    which is an assertion about the same comparison semantics this is checking
+    for -- so the declaration covers it where equality has nothing to cover it.
+
+    What this cannot see is a column that declares its own collation
+    (``country COLLATE NOCASE``) on an otherwise byte-exact engine. Neither
+    SQLAlchemy's reflection nor the engine specs expose it, so on such a column
+    the assumption stays where ``p = T(mapped_col)`` already is: with the 
owner.
+    """
+    if column is None:
+        return True
+    try:
+        is_string = column.type_generic == utils.GenericDataType.STRING
+    except Exception:  # pylint: disable=broad-except  # noqa: BLE001
+        # An unresolvable type is treated as a string: the gate exists to stop
+        # a silent wrong answer, so it fails closed.
+        return False
+    if not is_string:
+        return True
+    return bool(db_engine_spec.binary_string_comparison)
+
+
+#: How wide one bucket of each built-in time grain is.
+#:
+#: Keyed on the ISO duration a filter carries in its ``grain``. Written out
+#: rather than parsed: four of the week grains are ISO *intervals* with an
+#: anchor (``P1W/1970-01-03T00:00:00Z``) that ``isodate.parse_duration``
+#: rejects outright, and ``PT0.5H`` / ``P0.25Y`` are fractional. A literal
+#: table is also the thing a reviewer can check a line at a time.
+#:
+#: ``relativedelta`` rather than ``timedelta`` so the calendar grains stay
+#: calendar arithmetic: a month is not 30 days.
+GRAIN_BUCKET_WIDTHS: dict[str, relativedelta] = {
+    TimeGrain.SECOND: relativedelta(seconds=1),
+    TimeGrain.FIVE_SECONDS: relativedelta(seconds=5),
+    TimeGrain.THIRTY_SECONDS: relativedelta(seconds=30),
+    TimeGrain.MINUTE: relativedelta(minutes=1),
+    TimeGrain.FIVE_MINUTES: relativedelta(minutes=5),
+    TimeGrain.TEN_MINUTES: relativedelta(minutes=10),
+    TimeGrain.FIFTEEN_MINUTES: relativedelta(minutes=15),
+    TimeGrain.THIRTY_MINUTES: relativedelta(minutes=30),
+    TimeGrain.HALF_HOUR: relativedelta(minutes=30),
+    TimeGrain.HOUR: relativedelta(hours=1),
+    TimeGrain.SIX_HOURS: relativedelta(hours=6),
+    TimeGrain.DAY: relativedelta(days=1),
+    TimeGrain.WEEK: relativedelta(days=7),
+    TimeGrain.WEEK_STARTING_SUNDAY: relativedelta(days=7),
+    TimeGrain.WEEK_STARTING_MONDAY: relativedelta(days=7),
+    TimeGrain.WEEK_ENDING_SATURDAY: relativedelta(days=7),
+    TimeGrain.WEEK_ENDING_SUNDAY: relativedelta(days=7),
+    TimeGrain.MONTH: relativedelta(months=1),
+    TimeGrain.QUARTER: relativedelta(months=3),
+    TimeGrain.QUARTER_YEAR: relativedelta(months=3),
+    TimeGrain.YEAR: relativedelta(years=1),
+}
+
+
+def grain_bucket_width(grain: str | None, engine: str) -> relativedelta | None:
+    """
+    How far a grain's truncation can move a timestamp, or ``None`` if unknown.
+
+    A grained filter compares the *truncated* column, so the raw bounds it
+    carries do not describe the rows it keeps. Widening both bounds by one
+    bucket recovers a predicate that is no narrower than the real one -- see
+    `_collect_partition_mirror_range` for the argument. That only works for a
+    grain whose bucket width Superset knows, which excludes anything an
+    operator supplied.
+
+    :param grain: the ISO duration from the filter's ``grain``
+    :param engine: the engine spec's ``engine``, to check per-engine overrides
+    """
+    if not grain:
+        return None
+
+    # An operator-declared grain carries whatever duration string they typed,
+    # and `TIME_GRAIN_ADDON_EXPRESSIONS` can redefine a *built-in* grain's SQL
+    # per engine -- `P1D` could be anything at all. Neither has a width we can
+    # claim to know, so both fall back to not mirroring.
+    if grain in app.config["TIME_GRAIN_ADDONS"]:
+        return None
+    if grain in app.config["TIME_GRAIN_ADDON_EXPRESSIONS"].get(engine, {}):
+        return None
+
+    return GRAIN_BUCKET_WIDTHS.get(grain)
+
+
+@dataclass(frozen=True)
+class PartitionMapping:
+    """A resolved, usable partition filter mapping."""
+
+    partition_column: str
+    mapped_column: str
+    value_transform: str
+    is_monotonic: bool
+    #: Whether the engine compares the mapped column the way ``T`` was written
+    #: against. Resolved once where the column and the engine are both in hand,
+    #: rather than re-derived at each `mirrors` call.
+    equality_is_safe: bool = True
+
+    def mirrors(self, operator: FilterOperator) -> bool:
+        return operator in mirrorable_operators(
+            self.is_monotonic, equality_is_safe=self.equality_is_safe
+        )
+
+
+def contains_value_placeholder(transform: str | None) -> bool:
+    """Whether the transform contains the ``:value`` placeholder."""
+    return bool(transform) and VALUE_PLACEHOLDER_RE.search(transform or "") is 
not None
+
+
+def contains_jinja(transform: str | None) -> bool:
+    """Whether the transform contains a balanced Jinja block."""
+    return bool(transform) and JINJA_BLOCK_RE.search(transform or "") is not 
None
+
+
+def parse_skeleton(transform: str) -> str:
+    """
+    The transform with ``:value`` substituted out, ready for a SQL parser.
+
+    ``sanitize_clause`` / sqlglot choke on a bare ``:value`` on most dialects,
+    so the placeholder is swapped for a benign literal first -- the same trick
+    ``validate_stored_expression`` uses for Jinja blocks.
+    """
+    return VALUE_PLACEHOLDER_RE.sub(_PARSE_STANDIN, transform)
+
+
+#: Prefix the transform is wrapped in before parsing. Its length is subtracted
+#: from any reported column so positions refer to what the owner actually 
typed.
+_SELECT_PREFIX = "SELECT "
+
+
+def _parse_skeleton(transform: str, engine: str) -> SQLStatement | None:
+    """
+    Parse ``SELECT <transform>`` with the placeholder substituted out.
+
+    Returns ``None`` when the transform does not parse.
+    """
+    try:
+        return SQLStatement(f"{_SELECT_PREFIX}{parse_skeleton(transform)}", 
engine)
+    except SupersetParseError:
+        return None
+
+
+def parse_error_detail(transform: str, engine: str) -> str | None:
+    """
+    Where the parser gave up on the transform.
+
+    Returns ``None`` when it parses, or when the parser offered no position.
+    Note that sqlglot parses unknown functions happily -- a misspelled function
+    name is not a parse error, it is an engine error, and surfaces only when 
the
+    transform is evaluated.
+
+    The parser's own ``highlight`` is deliberately dropped: it would name the
+    ``NULL`` we substituted for ``:value``, which is not a token the owner 
typed.
+    """
+    try:
+        SQLStatement(f"{_SELECT_PREFIX}{parse_skeleton(transform)}", engine)
+    except SupersetParseError as ex:
+        column = (ex.error.extra or {}).get("column")
+        if not isinstance(column, int):
+            return None
+        return str(
+            _(
+                "syntax error at position %(position)d.",
+                position=_position_in_transform(transform, column),
+            )
+        )
+    return None
+
+
+def _position_in_transform(transform: str, parsed_column: int) -> int:
+    """
+    Map a column in the parsed skeleton back to the transform as typed.
+
+    Two substitutions stand between them: the ``SELECT`` prefix, and every
+    ``:value`` that became a shorter ``NULL``. Without unwinding both, a
+    reported position drifts left by two characters per placeholder ahead of it
+    -- which is worst exactly where transforms usually break, at the end.
+    """
+    position = max(parsed_column - len(_SELECT_PREFIX), 0)
+    shift = len(":value") - len(_PARSE_STANDIN)
+    preceding = sum(
+        1
+        for index, match in enumerate(VALUE_PLACEHOLDER_RE.finditer(transform))
+        if match.start() - index * shift < position
+    )
+    return position + preceding * shift
+
+
+def is_parseable(transform: str | None, engine: str) -> bool:
+    """
+    Whether the transform parses as a single select expression.
+
+    "Single" is the load-bearing word. `SELECT lower(:value), 'x'` parses just
+    as happily as `SELECT lower(:value)`, but it returns two columns per input
+    value, and the probe reads one column per input -- so an `IN` filter would
+    get a predicate built from the wrong halves of the wrong rows. Rejecting 
the
+    list here is what lets the probe trust its own column count.
+    """
+    if not transform or not transform.strip():
+        return False
+    statement = _parse_skeleton(transform, engine)
+    return statement is not None and statement.count_select_expressions() == 1
+
+
+def is_bare_expression(transform: str | None, engine: str) -> bool:
+    """
+    Whether the transform is a scalar expression and nothing more.
+
+    `is_parseable` is not enough, because it counts only the projection.
+    ``secret || :value FROM vault`` holds exactly one select expression, so it
+    parses as "a single expression" -- and `build_probe_sql` then emits
+    ``SELECT secret || 'us' AS v0``, where its own alias is read as a table
+    alias on the FROM clause the transform smuggled in. The probe reads a
+    column the owner was never granted and hands it back through the predicate
+    the preview panel renders.
+
+    A transform is a scalar function of ``:value`` by definition -- the whole
+    feature rests on ``partition_col = T(mapped_col)``, which only type-checks
+    for scalar ``T`` -- so there is no legitimate transform with a clause of
+    its own, and none with a sub-query either.
+    """
+    if not transform or not transform.strip():
+        return False
+    statement = _parse_skeleton(transform, engine)
+    return (
+        statement is not None
+        and statement.is_bare_select_expression()
+        and not statement.has_subquery()
+    )
+
+
+def is_unfinished(transform: str | None, engine: str) -> bool:
+    """
+    Whether the transform is not SQL yet, as opposed to the wrong SQL.
+
+    The distinction matters because the two deserve opposite treatment. A
+    half-typed ``unix_timestamp(:value`` is what a text input produces on the
+    way to something valid: a PUT stores it and reports the mapping inactive,
+    so discarding it would mean an export could not round-trip the dataset it
+    came from. ``unix_timestamp(:value); DROP TABLE t`` is not on the way to
+    anything, and has to be refused wherever it is offered.
+
+    Both fail `is_parseable`, and both fail to parse as a single *statement* --
+    so neither of those tells them apart. Parsing as a *script* does: the
+    multi-statement form is two valid statements, while unfinished text is no
+    statement at all.
+    """
+    if not transform or not transform.strip():
+        return False
+    try:
+        SQLScript(f"{_SELECT_PREFIX}{parse_skeleton(transform)}", engine)
+    except SupersetParseError:
+        return True
+    return False
+
+
+def find_non_deterministic_functions(transform: str, engine: str) -> set[str]:
+    """
+    Names of non-deterministic functions the transform calls.
+
+    ``UNIX_TIMESTAMP`` is only reported in its zero-argument form, which means
+    "now" on Hive and Impala; the one-argument form is the canonical temporal
+    transform and stays allowed.
+    """
+    statement = _parse_skeleton(transform, engine)
+    if statement is None:
+        return set()
+
+    found = {
+        name
+        for name in NON_DETERMINISTIC_FUNCTIONS
+        if statement.check_functions_present({name})
+    }
+    return found | _find_niladic_calls(statement)
+
+
+def _find_niladic_calls(statement: SQLStatement) -> set[str]:
+    """
+    Names from ``NON_DETERMINISTIC_WHEN_NILADIC`` called with no arguments.
+
+    Note some dialects resolve the zero-argument form themselves -- Hive parses
+    ``unix_timestamp()`` straight to ``CURRENT_TIMESTAMP`` -- in which case the
+    name-based check above has already caught it. This is the backstop for the
+    dialects that do not.
+    """
+    return NON_DETERMINISTIC_WHEN_NILADIC & statement.get_niladic_functions()
+
+
+def resolve_partition_mapping(datasource: SqlaTable) -> PartitionMapping | 
None:
+    """
+    Resolve the dataset's mapping, or ``None`` when nothing may be mirrored.
+
+    Every bail-out here is defensive as well as functional: save-time 
validation
+    rejects most of these, but rows predating the validation can still violate
+    the invariants, and a column sync can invalidate a mapping that was fine
+    when it was written.
+
+    The transform gate is `is_transform_active`, the same function the Explore
+    indicator reads, which is `validate_transform` with the messages discarded.
+    Anything narrower here would be a second, weaker statement of the same 
rule:
+    a transform calling `now()` that reached storage without passing
+    `UpdateDatasetCommand` -- through import, or through a bundle written by 
hand
+    -- would be reported inactive by the editor and still mirrored by this
+    function, freezing a snapshot of probe time into the predicate with nothing
+    on screen to say so.
+    """
+    if not feature_flag_manager.is_feature_enabled(FEATURE_FLAG):
+        return None
+
+    partition_column = getattr(datasource, "partition_column", None)
+    if not partition_column:
+        return None
+
+    columns_by_name = {column.column_name: column for column in 
datasource.columns}
+    if partition_column not in columns_by_name:
+        # The partition column was dropped by a column sync or at the source.
+        return None
+
+    mapped_column_name = (
+        getattr(datasource, "partition_mapped_column", None) or 
datasource.main_dttm_col
+    )
+    if not mapped_column_name or mapped_column_name not in columns_by_name:
+        return None
+
+    if mapped_column_name == partition_column:
+        # Self-mapping: the mirrored predicate would duplicate the original.
+        return None
+
+    mapped_column = columns_by_name[mapped_column_name]
+    transform = getattr(mapped_column, "partition_value_transform", None)
+    if not is_transform_active(transform, datasource.database.backend):
+        return None
+
+    if has_active_advanced_data_type(mapped_column):
+        # `translate_filter` builds its own predicate shape from *translated*
+        # values, so the `(operator, value)` pair the operator matrix reasons
+        # about does not exist and mirroring would apply the wrong values.
+        return None
+
+    return PartitionMapping(
+        partition_column=str(partition_column),
+        mapped_column=str(mapped_column_name),
+        value_transform=cast(str, transform),
+        is_monotonic=bool(
+            getattr(mapped_column, "partition_transform_is_monotonic", False)
+        ),
+        equality_is_safe=equality_mirrors_safely(
+            mapped_column, datasource.database.db_engine_spec
+        ),
+    )
+
+
+def has_active_advanced_data_type(column: TableColumn) -> bool:
+    """
+    Whether the column's advanced data type is configured and switched on.
+
+    Such a column is never mirrored: ``translate_filter`` builds its own
+    predicate shape from translated values, so the ``(operator, value)`` pair
+    the operator matrix reasons about does not exist.
+    """
+    advanced_data_type = getattr(column, "advanced_data_type", None)
+    if not advanced_data_type:
+        return False
+    if not 
feature_flag_manager.is_feature_enabled("ENABLE_ADVANCED_DATA_TYPES"):
+        return False
+    return advanced_data_type in app.config.get("ADVANCED_DATA_TYPES", {})
+
+
+def _denylist_engine_key(database: "Database") -> str:
+    """
+    The name ``DISALLOWED_SQL_*`` is keyed by for this database.
+
+    The engine spec's own ``engine`` first, because that is what every other
+    denylist gate uses (`_raise_for_disallowed_sql`, `sql_lab`) and what an
+    operator writing the config reads in the documentation; a spec covering
+    several SQLAlchemy backends through ``engine_aliases`` reports the one
+    canonical name under which its entry is written.
+
+    Falling back to the URL's backend, because resolving the spec loads the
+    SQLAlchemy dialect entrypoint, which imports the driver package. On a
+    deployment missing an optional driver that raises rather than merely being
+    absent, asking for the spec here would turn a dataset import into a hard
+    failure -- and for every engine without aliases the two names are the same
+    string anyway.
+    """
+    try:
+        return database.db_engine_spec.engine
+    except Exception:  # pylint: disable=broad-except  # noqa: BLE001
+        return database.backend
+
+
+def stored_expression_error(
+    database: "Database",
+    catalog: str | None,
+    schema: str | None,
+    transform: str,
+) -> str | None:
+    """
+    Why this transform may not be stored or run, if there is a reason.
+
+    Stricter than the policy a general stored expression goes through, because
+    a value transform is a narrower thing: `build_probe_sql` binds only
+    `:value` and splices the rest of the transform in as SQL text, which the
+    engine then executes, so an ungated transform is arbitrary SQL. A dataset
+    editor without SQL Lab could store `(SELECT secret FROM protected_table
+    LIMIT 1) || :value`, or `secret || :value FROM protected_table`, and read
+    the answer back out of the emitted predicate the preview panel renders.
+
+    So the transform must be a bare scalar expression: no clause of its own and
+    no sub-query, whatever `ALLOW_ADHOC_SUBQUERY` says. That is not a
+    restriction the feature pays for -- it rests on
+    ``partition_col = T(mapped_col)``, which only type-checks for scalar ``T``
+    -- and it is what makes the table denylist and the RLS rewrite moot here,
+    since neither has a table reference left to govern.
+
+    Returns the engine-agnostic reason as a string rather than raising,
+    because its four callers disagree about what to do with it: the preview
+    reports it at the field, a PUT and the legacy datasource save refuse the
+    write, the importer drops the transform and keeps the dataset, and the
+    probe simply declines to run. Raising would make three of those four
+    write a `try` around a question.
+
+    Imported inside the function rather than at module scope:
+    `connectors.sqla.models` imports this module, so the dependency only runs
+    one way at import time.
+    """
+    from superset.connectors.sqla.models import (  # pylint: 
disable=import-outside-toplevel,cyclic-import
+        validate_stored_expression,
+    )
+
+    statement = _parse_skeleton(transform, database.backend)
+    if statement is None:
+        return str(
+            _("A partition value transform must parse as a single SQL 
expression.")
+        )
+
+    # The shape gate again, even though `validate_transform` blocks the same
+    # thing at save time. This is the last door before the transform becomes 
SQL
+    # an engine runs, and the only door a row written by an earlier release --
+    # or by an importer that ran with the feature flag off -- still passes
+    # through.
+    if not statement.is_bare_select_expression():
+        return str(
+            _(
+                "A partition value transform must be a single SQL expression, "
+                "with no FROM, WHERE or other clause."
+            )
+        )
+
+    # Unconditionally, not under `ALLOW_ADHOC_SUBQUERY`. A transform is a 
scalar

Review Comment:
   A bare scalar can still read tables: PostgreSQL's `schema_to_xml` passes 
these checks and the default function denylist, then returns 
connection-user-readable schema contents through preview, bypassing the 
editor's dataset grants and Superset RLS ([function 
semantics](https://www.postgresql.org/docs/current/functions-xml.html#FUNCTIONS-XML-MAPPING)).
 Could read-capable functions be excluded before executing a transform, rather 
than assuming the absence of FROM/subqueries makes it read-free?



##########
superset/connectors/sqla/models.py:
##########
@@ -1689,6 +1726,8 @@ class SqlaTable(
         "normalize_columns",
         "always_filter_main_dttm",
         "folders",
+        "partition_column",

Review Comment:
   Duplicating a mapped virtual dataset copies these references through 
`table.override()`, but `DuplicateDatasetCommand` reconstructs its columns 
without `partition_value_transform` or `partition_transform_is_monotonic`, 
silently disabling the clone's mapping. Could duplication preserve the 
column-side configuration too?



##########
superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx:
##########
@@ -617,100 +674,160 @@ function ColumnCollectionTable({
     />
   );
 
-  return (
-    <CollectionTable
-      tableColumns={tableColumns}
-      sortColumns={tableColumns}
-      allowDeletes
-      allowAddItem={allowAddItem}
-      itemGenerator={itemGenerator}
-      collection={columns}
-      columnLabelTooltips={columnLabelTooltips}
-      filterTerm={filterTerm}
-      filterFields={filterFields}
-      stickyHeader
-      expandFieldset={
-        <FormContainer>
-          <Fieldset compact>
-            {showExpression && (
-              <Field
-                fieldKey="expression"
-                label={t('SQL expression')}
-                control={
-                  <TextAreaControl
-                    language="sql"
-                    offerEditInModal={false}
-                    maxLines={25}
-                    debounceDelay={300}
-                  />
-                }
-              />
-            )}
-            <Field
-              fieldKey="verbose_name"
-              label={t('Label')}
-              control={
-                <TextControl
-                  controlId="verbose_name"
-                  placeholder={t('Label')}
-                />
-              }
+  const partitionMappingEnabled =
+    isFeatureEnabled(FeatureFlag.PartitionFilterMapping) && 
Boolean(datasource);
+  const partitionColumn = partitionMappingEnabled
+    ? datasource?.partition_column
+    : null;
+
+  // The two `itemRenderers` variants below differ only in which widget edits
+  // the name, so the certified badge and the PARTITION tag are shared here
+  // rather than written out four times.
+  const renderColumnName =
+    (EditControl: 'editableTitle' | 'textControl') =>
+    (
+      v: unknown,
+      onItemChange: (value: any) => void,
+      _: unknown,
+      record: Column,
+    ): ReactNode => (
+      <StyledLabelWrapper>
+        {record.is_certified && (
+          <CertifiedBadge
+            certifiedBy={record.certified_by}
+            details={record.certification_details}
+          />
+        )}
+        {editableColumnName ? (
+          EditControl === 'editableTitle' ? (
+            <EditableTitle
+              canEdit
+              title={v as string}
+              onSaveTitle={onItemChange}
             />
-            <Field
-              fieldKey="description"
-              label={t('Description')}
-              control={
-                <TextControl
-                  controlId="description"
-                  placeholder={t('Description')}
+          ) : (
+            <TextControl value={v as string} onChange={onItemChange} />
+          )
+        ) : (
+          (v as ReactNode)
+        )}
+        {partitionColumn === record.column_name && (
+          <Tooltip
+            title={t(
+              'Filters on the mapped column are mirrored onto this column so 
the engine can prune partitions.',
+            )}
+          >
+            <Label data-test="partition-tag">
+              <Icons.FilterOutlined iconSize="s" /> {t('PARTITION')}
+            </Label>
+          </Tooltip>
+        )}
+      </StyledLabelWrapper>
+    );
+
+  return (
+    <StyledColumnsTableWrapper>
+      <CollectionTable
+        tableColumns={tableColumns}
+        sortColumns={tableColumns}
+        allowDeletes
+        allowAddItem={allowAddItem}
+        itemGenerator={itemGenerator}
+        collection={columns}
+        columnLabelTooltips={columnLabelTooltips}
+        filterTerm={filterTerm}
+        filterFields={filterFields}
+        rowClassName={record =>
+          partitionColumn === record.column_name ? 'partition-column-row' : ''
+        }
+        expandItemWhere={

Review Comment:
   Clicking Customize the value transform now filters to the mapped column but 
leaves its row collapsed: the current `CollectionTable` no longer consumes 
`expandItemWhere`, so the transform editor never opens. Could the expansion 
consumer be restored and the real editor navigation covered without manually 
expanding the row?



##########
docs/admin_docs/configuration/partition-filter-mapping.mdx:
##########
@@ -0,0 +1,204 @@
+---
+title: Partition Filter Mapping
+hide_title: true
+sidebar_position: 15
+version: 1
+---
+
+<!--
+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.
+-->
+
+# Partition Filter Mapping
+
+Tables on Hadoop-family engines are often partitioned on a *technical* column 
— an epoch
+integer, a lowercased region key — that no analyst would ever filter on. 
Unless a query
+carries a predicate on that column, the engine scans every partition.
+
+Partition filter mapping makes this a dataset setting instead of a per-chart 
chore. A
+dataset owner names the partition column, the business column whose filters 
should be
+mirrored onto it, and a value transform. Superset then appends an equivalent 
predicate on
+the partition column to every query. Chart authors change nothing; queries 
prune.
+
+:::caution Experimental
+This feature is behind the `PARTITION_FILTER_MAPPING` feature flag and is off 
by default.
+:::
+
+## Enabling it
+
+```python
+FEATURE_FLAGS = {
+    "PARTITION_FILTER_MAPPING": True,
+}
+```
+
+Configure it as a **static boolean**. `FEATURE_FLAGS` also accepts per-request 
callables,
+but a flag that resolves differently per user or tenant would let a user with 
the feature
+off read a cached chart result that was produced from pruned SQL by a user 
with it on.
+
+## Configuring a mapping
+
+In the dataset editor's **Columns** tab, under *Default Column Settings*, pick 
a
+**Partition column**. By default the mapping follows the dataset's default 
datetime column,
+so re-pointing that column moves the mapping with it; set an explicit override 
if you want it
+pinned to a different column.
+
+Expand the mapped column's row in *Column Settings* and set the **value 
transform**: a SQL
+expression containing a `:value` placeholder, which stands for the filter 
value being
+mirrored. The **Transform preserves ordering** checkbox sits directly beneath 
it.
+
+| Mapped column | Partition column | Transform |
+|---|---|---|
+| `event_time` (`TIMESTAMP`) | `dt_epoch` (`BIGINT`) | 
`unix_timestamp(:value)` |
+| `country` (`VARCHAR`) | `region_key` (`VARCHAR`) | `lower(:value)` |
+
+A filter of `event_time >= '2026-01-01'` then adds `dt_epoch >= 1767225600` to 
the query.
+The added predicate is an ordinary `WHERE` clause and shows up in **View 
query**.
+
+### Transform preserves ordering
+
+Range filters — including the Explore time range, the most important case — 
are only
+mirrored when you check **Transform preserves ordering**.
+
+Monotonicity is a property of the *transform*, not of the column's data type.
+`unix_timestamp(:value)` preserves ordering. `hour(:value)`,
+`date_format(:value, 'dd')` and `dayofweek(:value)` are all perfectly 
reasonable
+partition transforms on a `TIMESTAMP` column and none of them do: 
`hour('2026-01-01 23:00')`
+is greater than `hour('2026-01-02 01:00')` even though the first instant is 
earlier. Mirroring
+a range through one of those would silently return wrong numbers, so Superset 
asks you to
+declare it rather than guessing.
+
+When the box is unchecked, `=` and `IN` filters still mirror; ranges do not.
+
+## What is and isn't mirrored
+
+| Filter | Mirrored |
+|---|---|
+| `=`, `IN` | Always, except on a text column whose engine does not compare 
text exactly |
+| `>`, `>=`, `<`, `<=`, time ranges | Only when the transform preserves 
ordering |
+| `!=`, `NOT IN`, `LIKE`, `ILIKE`, `IS NULL`, `IS TRUE` | Never |
+
+Negations are never safe. A transform need not be injective: `lower(:value)` 
with
+`country != 'US'` would mirror to `region_key != 'us'`, which excludes rows 
whose `country`
+is already lowercase `'us'` — rows the original filter *keeps*.
+
+**Text comparison.** Mirroring `col = v` onto `partition_col = T(v)` assumes 
the engine
+compares `col` the way the transform was written against. Under a 
case-insensitive collation
+it does not: a stored `country` of `'us'` satisfies a filter for `'US'`, while 
the mirror is
+derived from `'US'` and excludes the row. So `=` and `IN` on a **text** mapped 
column only
+mirror on engines whose default text comparison is exact — Hive, Impala, 
Trino, Presto,
+Spark, Databricks, PostgreSQL, Snowflake, BigQuery and SQLite. On MySQL and 
SQL Server, whose
+default collations are case-insensitive, a text mapping is stored and shown 
but no `=` or
+`IN` filter mirrors through it; map a numeric or temporal column instead. 
Numeric and
+temporal comparison is exact everywhere, and ranges are unaffected either way 
— checking
+**Transform preserves ordering** is already a statement about the column's own 
ordering.
+
+One case Superset cannot check for you: a column that declares its own 
collation, such as
+`country COLLATE NOCASE`, on an engine that is otherwise exact. Nothing in the 
metadata
+exposes that, so it falls under the same pipeline assumption as the partition 
key itself.
+
+**Time grains.** A filter that carries a time grain — drill-to-detail, mostly 
— compares the
+*truncated* column, so the raw bounds it carries do not describe the rows it 
keeps: a row in
+the final partial bucket satisfies `DATE_TRUNC(...) < until` while `col < 
until` excludes it.
+Grained *ranges* still mirror, with both bounds widened by one bucket so the 
mirror stays no
+narrower than the real filter. A `P1D` drill therefore reads three days of 
partitions rather
+than one, instead of scanning the table. Grained `=` and `IN` filters do not 
mirror.
+
+Widening is only applied to grains whose bucket width Superset knows, which 
means the
+built-in ones. A grain you added through `TIME_GRAIN_ADDONS`, or a built-in 
grain whose SQL
+you replaced through `TIME_GRAIN_ADDON_EXPRESSIONS`, has no width Superset can 
rely on and
+simply does not mirror.
+
+Known gaps, all of which are out of scope rather than bugs:
+
+- **Filter-value dropdowns do not prune.** Populating a filter's value list 
runs its own
+  `SELECT DISTINCT`, which never goes through the chart query path. There is 
no filter to
+  mirror from.
+- **Row-level security predicates do not mirror.** They are stored as raw SQL 
and appended
+  downstream of the structured filters.
+- **Custom SQL `WHERE` clauses do not mirror**, for the same reason.
+- **Columns with an active advanced data type do not mirror.** Those build 
their own
+  predicate shape from translated values, so there is no operator/value pair 
to mirror.
+- Dashboard native filters and cross-filters *do* mirror — they arrive as 
ordinary filters —
+  they just carry no visual indicator in the filter bar.
+
+## The assumption this rests on
+
+Superset emits a predicate on the partition column that stands in for one on 
the mapped
+column. That substitution is only valid if, for every row in the table:
+
+```
+partition_column = <transform>(mapped_column)
+```
+
+**Superset cannot verify this.** It is a property of whatever ETL populates 
the partition
+column. If that job lags, backfills with different logic, or writes the 
partition key in a
+different timezone than the transform resolves, mirrored predicates silently 
drop real rows
+and charts show quietly wrong numbers. Confirm the invariant with whoever owns 
the pipeline
+before enabling a mapping on a production dataset.
+
+Rows in a `NULL` partition are the one case Superset *does* defend against. A 
predicate like
+`dt_epoch >= X` is `NULL` — and so drops the row — wherever `dt_epoch` is 
`NULL`, even when
+the original filter matches it. That is reachable: a dynamic-partition insert 
on Hive or
+Impala parks rows whose partition key was `NULL` in the default partition
+(`__HIVE_DEFAULT_PARTITION__`), and the column reads back as `NULL` when you 
query it. A
+transform that returns `NULL` for an input it cannot convert produces the same 
thing on any
+engine.
+
+The mirrored predicate is therefore emitted as:
+
+```sql
+(dt_epoch >= X AND dt_epoch < Y) OR dt_epoch IS NULL
+```
+
+The mirror only has to be *no narrower* than the filter it stands in for, so 
admitting the
+`NULL` partition costs one extra partition read and keeps those rows. Engines 
still prune
+everything else.
+
+## How the transform is evaluated
+
+The transform is evaluated against the engine — pinned to the dataset's 
database, catalog
+and schema — and the result is emitted as a literal constant. Results are 
cached
+(`PARTITION_TRANSFORM_PROBE_CACHE_TIMEOUT`, 24 hours by default), which 
matters because this
+adds a round trip to the chart query path. Day-aligned ranges like "Last 
month" hit the cache
+constantly; second-granularity relative ranges like "Last 24 hours" 
essentially never do.
+
+If the evaluation fails for any reason, no predicate is added: the query still 
runs and is
+still correct, it just scans more partitions.
+
+Because the evaluation happens in a **different session** from the chart 
query, transforms
+that call non-deterministic functions are rejected when you save. That 
includes `now()`,
+`current_date`, `current_timestamp`, `rand()` and the zero-argument 
`unix_timestamp()`, which
+means "now" on Hive and Impala. The one-argument `unix_timestamp(:value)` is 
fine.
+
+Session-dependent behaviour that Superset cannot detect is still your 
responsibility:
+`unix_timestamp(:value)` is timezone-dependent on Hive and Impala, so if the 
evaluating
+session and the query session resolve different timezones the emitted bounds 
will disagree
+with the timestamp bounds they mirror. Prefer explicitly-anchored transforms.
+
+Jinja templating is not supported in a transform. The template would render in 
a different
+context and at a different time from the chart query.
+
+## Related configuration
+
+| Setting | Default | Purpose |
+|---|---|---|
+| `PARTITION_TRANSFORM_PROBE_CACHE_TIMEOUT` | 24 hours | How long an evaluated 
transform stays cached |
+| `PARTITION_TRANSFORM_PREVIEW_RATE_LIMIT` | 30 | Per-user, per-dataset 
preview requests per minute; the editor's preview panel runs a real query |

Review Comment:
   With the default `CACHE_CONFIG` (NullCache), enabling this feature leaves 
previews unthrottled and probe results uncached, despite the advertised request 
budget and timeout; configuring only `DATA_CACHE_CONFIG` does not provide 
either guard. Could this document the functioning shared `CACHE_CONFIG` backend 
prerequisite and the fail-open throttling behavior?



##########
superset/commands/dataset/update.py:
##########
@@ -416,6 +423,146 @@ def _validate_expressions(
                     )
                 )
 
+    def _validate_partition_mapping(self, exceptions: list[ValidationError]) 
-> None:
+        """
+        Validate the dataset's partition filter mapping.
+
+        Only the blocking (Tier 1) issues become validation errors. Tier 2
+        issues -- an unparseable transform, a transform missing `:value` --
+        deliberately let the save through and leave the mapping inactive, per
+        the PRD, so a half-written transform doesn't cost the owner the rest of
+        their edits. They are surfaced by the editor, not by rejecting the PUT.
+
+        The transform is authored by a dataset owner, the same principal and
+        trust level as a calculated-column expression, so it also goes through
+        `validate_stored_expression` -- the parser gate that already governs
+        stored expressions. That gate only applies to a transform that parses:
+        it rejects an unparseable expression outright, which would turn a 
Tier-2
+        issue into a blocking one and undo the paragraph above. Skipping it
+        there costs nothing -- an unparseable transform is never emitted into a
+        query -- and it is not a hole for templating, because Jinja in a
+        transform is already a Tier-1 blocking issue of its own.
+
+        Every path that *reads* a mapping is gated on the feature flag, so this
+        one is too: with the flag off nothing mirrors, and rejecting a save 
over
+        a mapping that can never be consumed would be a validation error the
+        owner has no way to act on.
+        """
+        if not is_feature_enabled("PARTITION_FILTER_MAPPING"):
+            return
+
+        self._model = cast(SqlaTable, self._model)
+
+        columns = self._properties.get("columns")
+        column_names = (
+            {column["column_name"] for column in columns}
+            if columns is not None
+            else {column.column_name for column in self._model.columns}
+        )
+
+        partition_column = self._properties.get(
+            "partition_column", self._model.partition_column
+        )
+        partition_mapped_column = self._properties.get(
+            "partition_mapped_column", self._model.partition_mapped_column
+        )
+        main_dttm_col = self._properties.get("main_dttm_col", 
self._model.main_dttm_col)
+        if not partition_column:
+            return
+
+        # A column payload drops every column it omits, and `update_columns`
+        # then runs `DatasetDAO.clear_dangling_partition_mapping` to drop a
+        # mapping whose columns went with them -- but that happens later, 
during
+        # `run()`. Validate the state that cleanup leaves behind, or a metadata
+        # sync that legitimately removes the mapped column is rejected before
+        # the cleanup meant to handle it ever runs, which is exactly the
+        # orphaned case the cleanup exists for.
+        #
+        # Only a *stored* reference is forgiven. Asking in this very request to
+        # map onto a column the same request does not define is a mistake worth
+        # reporting, not something to quietly clean up.
+        if columns is not None:

Review Comment:
   After the dedicated metadata-refresh endpoint removes a dropped partition 
column, its stored mapping reference remains because that writer bypasses DAO 
cleanup; a later description-only PUT has no columns payload and fails this 
validation. Could refresh repair dangling mapping references as the 
columns-payload update path does?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to