sadpandajoe commented on code in PR #43870:
URL: https://github.com/apache/superset/pull/43870#discussion_r4330415114
##########
superset/db_engine_specs/postgres.py:
##########
@@ -178,6 +178,9 @@ class PostgresBaseEngineSpec(BaseEngineSpec):
engine = ""
engine_name = "PostgreSQL"
+ # The engine's default text comparison is binary, so a mirrored
+ # ``partition_col = T(v)`` agrees with the ``col = v`` it stands in for.
+ binary_string_comparison = True
Review Comment:
Netezza inherits this flag through `PostgresBaseEngineSpec`, but its string
comparison ignores trailing spaces (unlike PostgreSQL's binary comparison).
With VARCHAR columns and a transform like `:value || 'x'`, the row
`(country='US', p='USx')` satisfies `country = 'US '`, yet the mirrored
predicate asks for `p = 'US x'` and silently drops it. Should this be declared
on the concrete specs where binary comparison is verified instead of on the
shared base?
##########
superset/connectors/sqla/partition_mapping.py:
##########
@@ -0,0 +1,3524 @@
+# 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
+=========================================== =============================
+
+Monotonic here means non-decreasing, not strictly increasing: a bucketing
+transform such as a day key is monotonic and maps a whole day of timestamps
+onto one value. ``col < v`` then only implies ``T(col) <= T(v)``, so a strict
+bound is mirrored non-strictly -- see `mirror_operator`.
+
+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.
+
+Resolution of the value the engine compares
+-------------------------------------------
+Every row of the table above assumes the engine compares the value the mirror
+was built from. It does not always. A ``DATE`` column compared against
+``'2026-07-06 10:08:11'`` is compared on the date part alone -- whether the
+engine writes the literal itself (``TO_DATE('2026-07-06', 'YYYY-MM-DD')`` on
+Postgres, ``DATE '2026-07-06'`` on Presto, Trino, BigQuery and some two dozen
+others) or applies an implicit cast to a bound string. The filter therefore
+keeps the whole of that day while ``p = T(2026-07-06 10:08:11)`` keeps one
+instant of it: narrower than the predicate it stands in for, which is the one
+thing a mirror may never be.
+
+What an engine throws away is detected from the column's type rather than
+enumerated per engine -- `ExploreMixin._engine_literal_resolution` -- and what
+to do about it is a property of the *operator*, not of the value:
+
+* a bound rounds outward, backward for a lower one and forward for an upper
one,
+ through `ExploreMixin._round_bounds_outward`. The mirror then reads one extra
+ bucket at the edge, and the original predicate is still in the query to
+ exclude the rows that bucket adds.
+* an equality, or one member of an ``IN`` list, has nowhere to widen to: these
+ predicates are ``AND``-ed onto the query, so "or the day either side" is not
+ something the mirror can express. They decline, which costs the query its
+ pruning and nothing else.
+
+So a ``DATE``-typed mapped column mirrors a range at day granularity, and does
+not mirror an equality whose value carries a time of day at all. Map a
+``TIMESTAMP`` column instead and every operator is available again.
+
+Writing the value into the probe
+--------------------------------
+The probe evaluates the transform at a *literal*, because it runs with no bound
+parameters at all -- so every value is written into SQL text by the driver's
+literal renderer and read back by the engine's lexer, and the two do not always
+agree. SQLAlchemy's default renderer doubles apostrophes and leaves backslashes
+alone; Hive honours backslash escapes and not doubling, so ``'O''Brien'`` is
+read there as two adjacent literals and ``'x\\''`` ends four characters into a
+value the filter carries whole. A disagreement is either a mirror built from a
+value nobody filtered on -- dropping rows the filter keeps -- or, where the
+value closes its own quote, an expression of the filterer's choosing running in
+the probe.
+
+Which side is right is not knowable from the driver: it is a per-session server
+setting (``standard_conforming_strings``, ``NO_BACKSLASH_ESCAPES``,
+``spark.sql.parser.escapedStringLiterals``), and SQLAlchemy's own view of it is
+only correct on a dialect that has connected. So escaping is not what makes
+this safe. `literal_round_trips` renders the literal and reads it back in the
+engine's own dialect instead, and a value that does not survive the trip is not
+mirrored -- the usual trade, pruning for correctness. `probe_sql_is_evaluable`
+re-reads the finished statement behind it.
+
+The probe's *answers* make the same trip, in the other direction. The chart
+query is compiled with ``literal_binds``, so a probed partition key is rendered
+into its SQL by the same renderer and read by the same lexer -- and
+``concat(:value, chr(92), 'b')`` turns the unremarkable input ``a`` into the
key
+``a\\b``, which Hive reads back as ``a`` followed by a backspace. So
+`_predicates_from_probe` asks `literal_round_trips` about each result too, and
+declines the request rather than emitting a predicate no row can satisfy: the
+mirror is ``AND``-ed onto the query, so a mirror that matches nothing costs the
+chart its rows rather than its pruning.
+
+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 numbers
+import re
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import date, datetime
+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.superset_typing import FilterValues
+from superset.utils import core as utils, json
+from superset.utils.core import FilterOperator, get_username
+
+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.
+#: The bound matters 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.
+#:
+#: The typed column field, the import schema and the preview request each
+#: enforce it through a marshmallow `Length` validator, which is what produces
+#: the per-field message those three callers want. `stored_expression_error`
+#: enforces it too, for the paths that load no schema -- the deprecated
+#: `POST /datasource/save/` is one, and it writes straight through
+#: `update_from_object`.
+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")
+
+#: The opening and closing delimiters of each kind of Jinja block. The probe
+#: would render these in a different context at a different time from the chart
+#: query, so a transform holding one is rejected at save time.
+JINJA_BLOCK_DELIMITERS = (("{{", "}}"), ("{%", "%}"), ("{#", "#}"))
+
+#: MySQL and MariaDB "executable comments": ``/*!50000 ... */`` and
+#: ``/*M!50000 ... */`` are comments to every other reader and *statement text*
+#: to those two engines.
+#:
+#: This is the one construct that breaks the premise the probe's shape gates
+#: rest on -- that the parse this module inspects and the statement the engine
+#: runs are the same statement. ``1 /*!50000 + (SELECT secret FROM vault) */``
+#: parses with no sub-query and no FROM, clears `stored_expression_error` and
+#: `probe_sql_is_evaluable` alike, and then reads a table on MySQL. The result
+#: comes back to a dataset owner through the preview's ``emitted_predicate``,
+#: with no RLS applied and no SQL Lab access needed.
+#:
+#: Refused on every engine rather than only the two that honour it. A transform
+#: is a scalar function of ``:value``; there is no legitimate reason for one to
+#: carry a comment of any kind, let alone this one, and a per-engine gate would
+#: store a transform on Postgres that detonates when the dataset is re-pointed
+#: at MySQL.
+EXECUTABLE_COMMENT_RE = re.compile(r"/\*M?!")
+
+#: A PostgreSQL Unicode-escaped identifier, ``U&"..."``.
+#:
+#: The second construct that breaks the premise above, and in the same way:
+#: ``U&"\\0071uery_to_xml"(...)`` parses as an anonymous function whose name is
+#: the *literal* ``\\0071uery_to_xml``, so the function denylist is matching a
+#: name the engine never sees -- PostgreSQL decodes the escape and runs
+#: ``query_to_xml``, handing a dataset owner rows from outside their RLS scope
+#: through the preview's ``emitted_predicate``.
+#:
+#: Normalizing the escapes and re-checking would be the other repair, and is
+#: worse: it means reimplementing one engine's identifier decoding well enough
+#: to be a security boundary. Refusing the construct is exact, and costs
+#: nothing real -- a transform is a scalar function of ``:value``, and there is
+#: no reason to name a function or column through an escape sequence.
+#:
+#: Refused on every engine, like the executable comment, so a transform stored
+#: on SQLite cannot detonate when the dataset is re-pointed at PostgreSQL. The
+#: ``&`` may be separated from the ``U`` by nothing at all, but whitespace or a
+#: comment between the prefix and the string is not accepted by PostgreSQL
+#: either, so the strict form is the whole surface.
+#:
+#: Matched in raw text, so the prefix is refused inside a string literal too.
+#: That is the same trade the executable comment makes and has the same
+#: justification: a gate whose contract is to fire *before* the parse cannot
+#: ask the parse whether the text was quoted. ``concat(:value, 'u&"x"')`` is
+#: refused, which costs an owner nothing anyone writes.
+UNICODE_ESCAPED_IDENTIFIER_RE = re.compile(r'\bU&"', re.IGNORECASE)
+
+#: 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"
+
+#: Substituted for ``:value`` to find out *where* the placeholder landed, which
+#: `_PARSE_STANDIN` cannot answer: ``NULL`` is a keyword, so after substitution
+#: the parse tree no longer records that a placeholder was ever there. An
+#: identifier does leave a trace -- a column reference -- and one that is
+#: missing is a placeholder the engine will never evaluate.
+#:
+#: Named so it cannot plausibly collide with a real column, but a transform
that
+#: writes it anyway is refused rather than counted. A hand-written occurrence
+#: does not merely make the count disagree: it *supplies* the column reference
a
+#: non-executable placeholder failed to produce, and the two cancel exactly.
See
+#: `placeholder_is_executable`.
+_PLACEHOLDER_STANDIN = "superset_pfm_value_standin"
+
+#: 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: a transform such as
+#: ``datediff(day, :value, LOCALTIMESTAMP)`` for a relative partition key is
+#: evaluated once and then keeps emitting yesterday's answer, so the chart
+#: silently drops rows after midnight.
+#:
+#: Matched by SQL function name, so every engine's spelling of "now" has to be
+#: named -- the ANSI four were not enough. Listed rather than delegated to the
+#: engine specs because the risk is the same everywhere and a spec that had not
+#: got round to declaring its own would fail open; a name no engine has is
+#: inert, so over-listing costs nothing.
+NON_DETERMINISTIC_FUNCTIONS = {
+ # ANSI, and the engines that spell it this way.
+ "CURRENT_DATE",
+ "CURRENT_TIME",
+ "CURRENT_TIMESTAMP",
+ "LOCALTIME",
+ "LOCALTIMESTAMP",
+ "NOW",
+ "RAND",
+ "RANDOM",
+ "UUID",
+ # PostgreSQL. `now()` is statement-stable; these two are not even that.
+ "CLOCK_TIMESTAMP",
+ "STATEMENT_TIMESTAMP",
+ "TIMEOFDAY",
+ # MySQL and MariaDB.
+ "CURDATE",
+ "CURTIME",
+ "SYSDATE",
+ "UTC_DATE",
+ "UTC_TIME",
+ "UTC_TIMESTAMP",
+ "UUID_SHORT",
+ # Oracle.
+ "SYSTIMESTAMP",
+ "SYS_GUID",
+ # SQL Server.
+ "GETDATE",
+ "GETUTCDATE",
+ "SYSDATETIME",
+ "SYSUTCDATETIME",
+ "SYSDATETIMEOFFSET",
+ "NEWID",
+ # BigQuery.
+ "CURRENT_DATETIME",
+ "GENERATE_UUID",
+ # Snowflake.
+ "RANDSTR",
+ "UUID_STRING",
+ # ClickHouse.
+ "NOW64",
+ "RANDCANONICAL",
+ "GENERATEUUIDV4",
+ # H2 and Derby, which the test fixtures use.
+ "RANDOM_UUID",
+}
+
+#: The wall-clock names an engine evaluates with no parentheses at all.
+#:
+#: These parse as *column references*, not functions, so the name-based walk
+#: below cannot see them: `datediff(UTC_DATE, :value)` on MySQL and
+#: `:value - SYSDATE` on Oracle both cleared the gate and froze probe time into
+#: a cached mirror.
+#:
+#: A subset of `NON_DETERMINISTIC_FUNCTIONS` rather than the whole of it,
+#: because refusing a bare name costs an owner a column genuinely called that.
+#: Only the names SQL lets you write without ``()`` are listed -- the datetime
+#: keywords -- so `rand`, `uuid` and `newid`, which always need parentheses and
+#: are plausible column names, stay out.
+BARE_WALL_CLOCK_KEYWORDS = frozenset(
+ {
+ "current_date",
+ "current_time",
+ "current_timestamp",
+ "localtime",
+ "localtimestamp",
+ "utc_date",
+ "utc_time",
+ "utc_timestamp",
+ "sysdate",
+ "systimestamp",
+ }
+)
+
+#: 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}
+
+
+class Unmirrorable:
+ """
+ A filter no mirror can stand in for, and why.
+
+ Returned by `ExploreMixin.mirror_probe_request` for a value the engine
+ compares more coarsely than the filter carries, one it cannot render as a
+ literal, one whose rendered literal the engine would not read back as the
+ same value, one in a time frame the transform would discard, or an ``IN``
+ list with a member of any of those kinds. An object rather than `None`,
+ because `None` is a filter value in its own right.
+
+ The reason rides along because the preview has to explain the decline to
+ the owner, and the two declines want different advice: "the engine compares
+ less of this value than you typed, so map a column it compares in full" is
+ sound guidance for a `DATE` column and actively misleading for an
+ offset-bearing bound on a `TIMESTAMP` one, which already is such a column.
+ Carried rather than re-derived so the preview's explanation cannot come
+ apart from the chart path's decision.
+
+ Lives here rather than beside that method so the preview can read it too:
+ `superset.models.helpers` imports this module, so these can only cross the
+ boundary in this direction. Both callers recognise the same instances -- a
+ preview that answered "valid" for a request the chart path declines is the
+ drift this is here to prevent.
+ """
+
+ __slots__ = ("reason",)
+
+ def __init__(self, reason: str) -> None:
+ self.reason = reason
+
+ def __repr__(self) -> str:
+ return f"<Unmirrorable {self.reason}>"
+
+
+#: The ordinary decline: whatever the mirror cannot be built from, with the
+#: engine's own comparison resolution as the reason an owner can act on.
+UNMIRRORABLE = Unmirrorable("resolution")
+
+#: A value carrying a UTC offset, declined whatever the column's resolution.
+#: A transform such as ``CAST(:value AS TIMESTAMP)`` drops the offset, so even
+#: an engine that compares the whole instant would be probed in the wrong
+#: frame -- which is not something mapping a different column fixes.
+UNMIRRORABLE_OFFSET = Unmirrorable("offset")
+
+#: A value the driver and the engine would not spell the same way, declined
+#: whatever the column's resolution and whatever the operator. Nothing about
+#: the column or the transform fixes it -- see `literal_round_trips` -- so the
+#: advice the resolution reason carries would send an owner after the wrong
+#: thing entirely.
+UNMIRRORABLE_ESCAPING = Unmirrorable("escaping")
+
+
+def is_unmirrorable(value: Any) -> bool:
+ """Whether `mirror_probe_request` declined this request."""
+ return isinstance(value, Unmirrorable)
+
+
+#: A transform that preserves ordering need not be *strictly* increasing: a day
+#: key such as ``to_char(ts, 'YYYYMMDD')`` maps a whole day of timestamps onto
+#: one value. For such a ``T``, ``col < v`` only implies ``T(col) <= T(v)`` and
+#: ``col > v`` only implies ``T(col) >= T(v)`` -- the rows in the bucket at the
+#: boundary satisfy the non-strict form and not the strict one. A strict mirror
+#: therefore drops rows the filter keeps, which is the one thing a mirror may
+#: never do, so a strict bound is mirrored non-strictly.
+_NON_STRICT_EQUIVALENT = {
+ FilterOperator.GREATER_THAN: FilterOperator.GREATER_THAN_OR_EQUALS,
+ FilterOperator.LESS_THAN: FilterOperator.LESS_THAN_OR_EQUALS,
+}
+
+
+def mirror_operator(operator: FilterOperator) -> FilterOperator:
+ """
+ The operator a mirrored predicate uses to stand in for ``operator``.
+
+ Strict bounds widen; everything else passes through. Idempotent, so it is
+ safe wherever a request may already have been widened.
+
+ The widened bound reads one extra partition at the edge, and the original
+ predicate is still in the query to exclude the rows that partition adds --
+ the same trade as the grain widening and the ``NULL``-partition escape.
+ Declaring the transform strictly increasing would buy that partition back,
+ which is not worth a second property only the owner can vouch for.
+ """
+ return _NON_STRICT_EQUIVALENT.get(operator, operator)
+
+
+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 _native_generic_type(
+ column: "TableColumn",
+) -> utils.GenericDataType | None:
+ """
+ What the engine spec makes of ``column``'s declared type.
+
+ `TableColumn.type_generic` answers ``TEMPORAL`` for every column carrying
+ ``is_dttm``, which is the right answer for a chart's time axis and the
+ wrong one for a comparison: an epoch ``BIGINT`` is a number to the engine
+ however it is labelled here. Falls back to the flag where the spec has no
+ answer, so a type Superset cannot resolve is no worse off than before.
+ """
+ try:
+ column_spec = column.db_engine_spec.get_column_spec(
+ column.type, db_extra=column.db_extra
+ )
+ except Exception: # pylint: disable=broad-except # noqa: BLE001
+ column_spec = None
+ if column_spec is not None and column_spec.generic_type is not None:
+ return column_spec.generic_type
+ try:
+ return column.type_generic
+ except Exception: # pylint: disable=broad-except # noqa: BLE001
+ return None
+
+
+#: Declared types whose comparison ignores trailing blanks.
+#:
+#: The fixed-width character types, which the SQL standard has compare as
+#: though both sides were padded to the same length -- so ``'US'`` and
+#: ``'US '`` are equal in a ``CHARACTER(3)``, while ``lower()`` of the two is
+#: not. Spelled out rather than matched by prefix because the variable-width
+#: names start with the same letters: ``VARCHAR`` and ``CHARACTER VARYING``
+#: compare the blanks and must not land here.
+PADDED_CHARACTER_TYPES = frozenset(
+ {
+ "CHAR",
+ "NCHAR",
+ # PostgreSQL's own name for ``CHARACTER``, which is what reflection
+ # reports for a ``CHAR(n)`` column there.
+ "BPCHAR",
+ "CHARACTER",
+ "NATIONAL CHAR",
+ "NATIONAL CHARACTER",
+ }
+)
+
+
+def _is_padded_character_type(column: TableColumn) -> bool:
+ """
+ Whether ``column``'s declared type compares without its trailing blanks.
+
+ Read off the declared type rather than the engine spec's resolved one: the
+ specs map ``CHAR`` and ``VARCHAR`` onto the same `sqlalchemy.String`, so
+ the distinction this needs is gone by then. The length suffix is dropped
+ and the name matched against `PADDED_CHARACTER_TYPES`, so an unfamiliar
+ spelling answers ``False`` and the engine-level flag decides as before --
+ this gate adds a decline rather than lifting one. A column with no
+ declared type has nothing to read, and answers the same way.
+ """
+ if not isinstance(column.type, str):
+ return False
+ native = re.sub(r"\s+", " ", column.type.strip().upper())
+ return native.split("(")[0].strip() in PADDED_CHARACTER_TYPES
+
+
+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.
+
+ Padding is asked about per column, because it is a property of the *type*
+ rather than of the engine. ``binary_string_comparison`` speaks for an
+ engine's default text comparison, and on PostgreSQL -- which declares it
+ -- a ``CHAR(2)`` still matches a filter for ``'US '`` against a stored
+ ``'US'``, because the standard says a padded character type compares
+ without its trailing blanks. The mirror ``region_key = lower('US ')`` then
+ excludes the row. So a fixed-width character type declines whatever the
+ engine says, and an engine that ignores trailing blanks on its *variable*
+ types says so by not declaring the flag at all (see `RedshiftEngineSpec`).
+
+ The type asked for is the engine spec's reading of the column's declared
+ type, not `TableColumn.type_generic`, which answers ``TEMPORAL`` for
+ anything carrying ``is_dttm``. A ``VARCHAR`` flagged temporal is still text
+ to the engine: on MySQL a filter for ``'2026-01-01T00:00:00'`` matches a
+ stored ``'...t00:00:00'``, while the mirror ``p = hex('...T...')`` excludes
+ it -- and asking the semantic flag skipped the check entirely.
+
+ A type the spec cannot classify declines, which is what the paragraph above
+ promises and what only the exception branch used to do. PostgreSQL's
+ ``citext`` matches no type pattern, so it answered ``None`` -- neither
+ string nor not-string -- and was waved through, which is precisely wrong
+ for a type whose whole purpose is case-insensitive comparison. Declining
+ costs the pruning; admitting it costs rows.
+
+ 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
+ # Ahead of the generic-type question, which a padded type can lose: the
+ # specs map ``CHARACTER`` onto no generic type at all on some engines, and
+ # a fixed-width character type is a string whether or not Superset can say
+ # so from the name.
+ if _is_padded_character_type(column):
+ return False
+ generic_type = _native_generic_type(column)
+ if generic_type is None:
+ return False
+ if generic_type != utils.GenericDataType.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_executable_comment(transform: str | None) -> bool:
+ """Whether the transform carries a MySQL/MariaDB executable comment.
+
+ See `EXECUTABLE_COMMENT_RE`. Answered on raw text, like
+ `contains_jinja` and for the same reason: the whole point is that no
+ parser this module can reach reports the construct at all.
+ """
+ return bool(transform) and EXECUTABLE_COMMENT_RE.search(transform or "")
is not None
+
+
+def contains_unicode_escaped_identifier(transform: str | None) -> bool:
+ """Whether the transform names something through a ``U&"..."`` escape.
+
+ See `UNICODE_ESCAPED_IDENTIFIER_RE`. Answered on raw text for the same
+ reason as the comment above: the parse reports a name the engine does not
+ use, which is exactly what makes the denylist blind to it.
+ """
+ return (
+ bool(transform)
+ and UNICODE_ESCAPED_IDENTIFIER_RE.search(transform or "") is not None
+ )
+
+
+#: Sequence pseudo-columns, which advance a sequence when they are *read*.
+#:
+#: ``orders_seq.NEXTVAL`` parses as a qualified column reference, not a
+#: function call, so neither `SQLStatement.is_mutating` -- which walks function
+#: names -- nor the function denylist can see it. The probe would then run
+#: ``SELECT orders_seq.NEXTVAL + 1 FROM DUAL`` and advance the sequence, again
+#: on every cache expiry, for a dataset editor holding neither SQL Lab nor a
+#: DML grant. Reading one is a write however the parser classifies it.
+#:
+#: The function spelling (``nextval('s')`` on PostgreSQL) is a different shape
+#: and is caught as a function; this is only about the suffix form.
+SEQUENCE_PSEUDO_COLUMNS = frozenset({"nextval", "currval"})
+
+#: The same thing written as a call.
+#:
+#: Only the suffix form parses as a column; `nextval('s')` on PostgreSQL and
+#: DuckDB, `NEXTVAL(s)`/`SETVAL(s, 1)` on MySQL and MariaDB, and SQL Server's
+#: `NEXT VALUE FOR s` all parse as functions. `SQLStatement.is_mutating` walks
+#: function names on PostgreSQL alone -- deliberately, since the same names are
+#: read-only elsewhere -- so every other engine let these through.
+#:
+#: Matched on the name whatever the engine, because advancing a sequence is a
+#: write wherever it happens and a transform has no reason to call one.
+SEQUENCE_FUNCTIONS = frozenset(
+ {"nextval", "setval", "next_value_for", "nextvalfor", "prevval", "lastval"}
+)
+
+
+def references_sequence(statement: SQLStatement) -> bool:
+ """Whether the statement reads or advances a sequence.
+
+ Both spellings: the pseudo-column suffix, which parses as a column (see
+ `SEQUENCE_PSEUDO_COLUMNS`), and the call, which parses as a function (see
+ `SEQUENCE_FUNCTIONS`).
+
+ Answered on the parse rather than on raw text, because position is what
+ makes it the construct: a column or alias merely *called* ``nextval`` is
+ an ordinary column, and a string literal holding the word is text.
+ """
+ return bool(
+ statement.get_column_names() & SEQUENCE_PSEUDO_COLUMNS
+ or statement.get_disallowed_functions(set(SEQUENCE_FUNCTIONS))
+ )
+
+
+def contains_jinja(transform: str | None) -> bool:
+ """
+ Whether the transform contains a balanced Jinja block.
+
+ A block is an opening delimiter followed, anywhere later, by its closing
+ one -- the same thing ``{{.*?}}`` and its two siblings match under
+ ``re.DOTALL``. Scanned with `str.find` rather than that pattern, because a
+ lazy match retried from every opening delimiter is quadratic in a run of
+ them, and this runs on stored transforms with no length bound of its own:
+ `is_transform_active` reads whatever the column holds. The earliest opening
+ delimiter is the only one worth trying, since any closing delimiter after a
+ later one is also after it.
+ """
+ if not transform:
+ return False
+ for opening, closing in JINJA_BLOCK_DELIMITERS:
+ start = transform.find(opening)
+ if start != -1 and transform.find(closing, start + len(opening)) != -1:
+ return True
+ return False
+
+
+def parse_skeleton(transform: str, standin: str = _PARSE_STANDIN) -> 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.
+
+ :param standin: what to substitute. `placeholder_is_executable` passes
+ `_PLACEHOLDER_STANDIN` to find out where the placeholder landed.
+ """
+ # Substituted through a function so `standin` is taken literally. As a
+ # replacement *string* `re.sub` would read backslashes in it as escapes,
+ # and `build_probe_sql` passes rendered SQL here: a filter value containing
+ # `\n` would probe a newline instead of the two characters the predicate
+ # compares, and one containing `\1` would raise outright.
+ return VALUE_PLACEHOLDER_RE.sub(lambda _: 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, standin: str = _PARSE_STANDIN
+) -> 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, standin)}", 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 placeholder_is_executable(transform: str | None, engine: str) -> bool:
+ """
+ Whether every ``:value`` sits where the engine will evaluate it.
+
+ `contains_value_placeholder` is a regex over raw text, and `parse_skeleton`
+ substitutes before parsing, so neither notices a placeholder that is not in
+ an executable position. Two shapes reach the probe that way, and both are
+ worse than the inactive mapping a malformed transform earns:
+
+ ``':value'`` -- the placeholder inside a string literal. `build_probe_sql`
+ binds it anyway, because SQLAlchemy's own ``text()`` scan is no more
+ literal-aware than the regex, and the dialect then renders the bind
+ *including its own quotes* in the middle of the owner's quotes. A value of
+ ``" || (SELECT secret FROM vault LIMIT 1) || "`` closes the literal and
+ compiles to a working sub-query -- the very read primitive
+ `stored_expression_error` refuses when it is written openly.
+
+ ``1 -- :value`` -- the placeholder inside a comment. The probe's selections
+ are joined on one line, so the comment swallows its own ``AS v0`` alias and
+ everything after it; a single-value probe still returns one column, the
+ constant is accepted as the transform's result, and the mirror then emits
+ ``partition_col = <constant>`` for every filter value. That is wrong rows,
+ not lost pruning.
+
+ Answered structurally: substitute an identifier rather than a keyword and
+ count the column references it produced. A literal holds no column
+ reference and a comment is not parsed at all, so either case comes back
+ short of the number of placeholders the owner wrote.
+
+ Which is why a transform naming the stand-in itself is refused outright.
The
+ count is the whole gate, and a hand-written occurrence pays the difference:
+ ``concat(superset_pfm_value_standin, ':value')`` has one placeholder,
inside
+ a literal that contributes nothing, and one bare reference the owner wrote
--
+ so the counts match and the gate passes a placeholder the engine will never
+ evaluate. The probe then renders the bound value inside the owner's quotes,
+ where a value closing the literal can alias a column of any table the
+ connection can read into the stand-in's name and have the probe hand it
back
+ through the preview. There is no legitimate transform naming this
identifier,
+ so refusing it costs nothing and makes the count mean what it claims.
+ """
+ if not transform:
+ return False
+ if _PLACEHOLDER_STANDIN in transform.lower():
+ # Lowercased on both sides because `count_bare_column_references` is
+ # case-insensitive too: a dialect may normalize an unquoted identifier,
+ # so `SUPERSET_PFM_VALUE_STANDIN` would otherwise balance the count
+ # while slipping past a case-sensitive check here.
+ return False
+ expected = len(VALUE_PLACEHOLDER_RE.findall(transform))
+ if not expected:
+ return False
+ statement = _parse_skeleton(transform, engine, _PLACEHOLDER_STANDIN)
+ if statement is None:
+ return False
+ return statement.count_bare_column_references(_PLACEHOLDER_STANDIN) ==
expected
+
+
+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.
+
+ Three spellings have to be looked for, because one name reaches the parse
+ three ways: as a function call, as a zero-argument call a dialect resolves
+ itself, and -- for the datetime keywords -- as a bare word that parses as a
+ column reference.
+ """
+ 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})
+ }
+ # The parenthesis-free spellings, which parse as columns rather than
+ # functions and so are invisible to the walk above. See
+ # `BARE_WALL_CLOCK_KEYWORDS`.
+ found |= {
+ name.upper() for name in BARE_WALL_CLOCK_KEYWORDS &
statement.get_column_names()
+ }
+ 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( # noqa: C901
+ 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.
+
+ And it must be a *read*. Shape is a separate question from effect: a bare
+ scalar expression can still write, so `SQLStatement.is_mutating` is asked
+ too -- the same question SQL Lab and the chart executor ask before letting
+ SQL run. See the gate itself for what that reaches and what it does not.
+
+ 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,
+ )
+
+ # Ahead of the parse, both because it is the cheaper question and because
+ # the parse is the cost the bound exists to contain.
+ if len(transform) > MAX_TRANSFORM_LENGTH:
+ return str(
+ _(
+ "A partition value transform cannot be longer than "
+ "%(limit)d characters.",
+ limit=MAX_TRANSFORM_LENGTH,
+ )
+ )
+
+ # Also ahead of the parse, and for a stronger reason than cost: every gate
+ # below reasons about the parse tree, and this is the one construct that
+ # makes the parse tree describe a different statement from the one the
+ # engine runs. Checked here as well as in `validate_transform` because this
+ # is the door a row written by an earlier release still comes through.
+ if contains_executable_comment(transform):
+ return str(
+ _(
+ "A partition value transform cannot contain a MySQL "
+ "executable comment, which the database executes and every "
+ "validator reads as a comment."
+ )
+ )
+
+ if contains_unicode_escaped_identifier(transform):
+ return str(
+ _(
+ "A partition value transform cannot name anything through a "
+ 'U&"..." escape sequence, which the database decodes and '
+ "every validator reads literally."
+ )
+ )
+
+ # Same door, same reason: the name the denylist below would match is not
+ # the name the engine resolves. See `UNICODE_ESCAPED_IDENTIFIER_RE`.
+ 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
+ # function of `:value`, so a sub-query in one has no legitimate use, and it
+ # is a read primitive whose answer the preview panel renders back to its
+ # caller. Refusing it is also what lets this function skip the table
+ # denylist and the row-level-security rewrite: with no FROM clause and no
+ # sub-query there is no table reference for either to govern.
+ if statement.has_subquery():
+ return str(_("A partition value transform cannot contain a
sub-query."))
+
+ # The read-only gate, which nothing above implies. The shape gates ask what
+ # the transform *selects*; this asks what evaluating it *does*, and a bare
+ # scalar expression can still write. `nextval(:value)` on PostgreSQL
+ # advances a sequence: one function call, no clause, no sub-query, and not
+ # in the default `DISALLOWED_SQL_FUNCTIONS` either -- so every gate above
+ # passes it and the probe then runs it. `setval` is the same shape, and
+ # `exp.Execute` and the opaque-`exp.Command` forms arrive the same way.
+ #
+ # The large-object writers (`lo_export`, `lowrite`, `lo_from_bytea`, ...)
+ # are caught here too, and the shipped denylist happens to name them as
+ # well -- but that is config an operator can replace, while this is not.
+ #
+ # `is_mutating` is the same question SQL Lab, `get_virtual_table_metadata`
+ # and the chart executor ask before letting SQL run, so a transform is held
+ # to the bar the rest of Superset already sets rather than to one invented
+ # here. Unconditional rather than gated on `Database.allow_dml`: a
transform
+ # is a scalar function of `:value` by definition, so there is no legitimate
+ # mutating one, and the probe runs on a cache schedule nobody chose -- a
+ # write would repeat every time the entry expired.
+ #
+ # It bears saying what this does not reach. A user-defined function whose
+ # body writes is a plain call no parser can tell from `lower(:value)`, and
+ # `is_mutating`'s function-name walk is PostgreSQL-only by design, since
the
+ # same names are read-only elsewhere. Neither residual is specific to this
+ # feature -- a dataset editor can already have the engine evaluate an
+ # arbitrary expression through a calculated column or a virtual dataset's
+ # SQL -- and closing them means authorizing the preview against the
+ # database, not parsing harder.
+ if statement.is_mutating():
+ return str(
+ _(
+ "A partition value transform cannot change data. It is "
+ "evaluated against the database to check that it mirrors, so "
+ "it has to be a read."
+ )
+ )
+
+ # Not reached by the walk above, which is why it is its own gate: a
+ # sequence pseudo-column is a *column* to the parser and a write to the
+ # engine. See `SEQUENCE_PSEUDO_COLUMNS`.
+ if references_sequence(statement):
+ return str(
+ _(
+ "A partition value transform cannot read a sequence. "
+ "NEXTVAL advances the sequence every time it is evaluated, "
+ "and the transform is evaluated on a cache schedule."
+ )
+ )
+
+ # The shape gates above read the transform with `:value` replaced by the
+ # `NULL` keyword, which leaves no trace of where the placeholder was -- so
+ # none of them can see a placeholder that is not in an executable position.
+ # Checked here as well as in `validate_transform` because this is the door
+ # a row written by an earlier release still passes through, and because
+ # the escape only completes once a filter value is bound.
+ #
+ # Guarded on the placeholder being present at all: a transform without one
+ # is inert rather than refused (`validate_transform` reports it as Tier 2),
+ # and refusing it here would make this gate stricter than the PUT's.
+ if contains_value_placeholder(transform) and not placeholder_is_executable(
+ transform, database.backend
+ ):
+ return str(
+ _(
+ "A partition value transform must use the :value placeholder "
+ "where the engine will evaluate it, not inside a string "
+ "literal or a comment."
+ )
+ )
+
+ denied = app.config["DISALLOWED_SQL_FUNCTIONS"].get(
+ _denylist_engine_key(database), set()
+ )
+ if denied and (found := statement.get_disallowed_functions(denied)):
+ return str(
+ _(
+ "A partition value transform cannot call %(functions)s.",
+ functions=", ".join(sorted(found)),
+ )
+ )
+
+ # Kept as the tail call rather than replaced: this is where
`sanitize_clause`
+ # runs. Its own sub-query branch returns rewritten, RLS-injected SQL that
+ # this function has no way to propagate -- but that branch is unreachable
+ # from here now, because a transform containing a sub-query was refused
+ # above, so discarding the return value is correct rather than a leak.
+ try:
+ validate_stored_expression(database, catalog, schema,
parse_skeleton(transform))
+ except SupersetSecurityException as ex:
+ return str(ex.error.message)
+ except QueryClauseValidationException as ex:
+ return str(ex.message)
+ return None
+
+
+@dataclass(frozen=True)
+class RawProbeValue:
+ """
+ A probe value that is already SQL text rather than a bindable scalar.
+
+ Almost every filter value is a Python scalar, which `build_probe_sql` binds
+ and lets the dialect render. An array column's value is not: the predicate
+ it mirrors compares an engine array literal -- ``array('a', 'b')`` on
+ ClickHouse -- which `BaseEngineSpec.array_literal` returns as a SQLAlchemy
+ expression, and an expression cannot be the value of a bind parameter.
+
+ Frozen, and holding text rather than the expression, for two reasons
+ besides immutability: `_probe_cache_key` keys on ``repr`` of each value,
and
+ an expression's ``repr`` carries its memory address -- so caching the probe
+ would key every call differently. `_hashable` needs it hashable too.
+
+ Build one with `raw_probe_value`, which renders the expression through the
+ same literal compilation the bound path uses.
+ """
+
+ sql: str
+
+
+def raw_probe_value(database: "Database", element: Any) -> RawProbeValue:
+ """
+ Freeze an engine-built expression into probe-ready SQL text.
+
+ The text is produced entirely by SQLAlchemy and the engine spec from values
+ that have already been coerced, so no caller-supplied text reaches the
+ probe uninspected. Rendering is not what makes that true: a string element
+ is checked by `literal_round_trips` before it is rendered -- the dialect's
+ literal processor and the engine's lexer do not always agree about what a
+ rendered literal says -- and `probe_sql_is_evaluable` re-reads the finished
+ query, which is what bounds the splice.
+
+ :param database: the database whose dialect renders the literal
+ :param element: a SQLAlchemy expression, e.g. from ``array_literal``
+ :return: the rendered expression, ready to stand in for ``:value``
+ """
+ return RawProbeValue(_compile_literal(element, _dialect_for(database)))
+
+
+def normalize_mixed_numbers(values: Sequence[Any]) -> list[Any]:
+ """
+ One numeric type across ``values``, so a bind cannot be inferred wrongly.
+
+ SQLAlchemy infers an ``IN`` bind's type from the first element it is given.
+ A list mixing an int with a float therefore binds as integer and truncates
+ every later float: ``[33, 29.02]`` emits ``IN (33, 29)`` and drops the row
+ matching ``29.02``.
+
+ Applied on both sides of the probe, which is why it lives here rather than
+ inline at either: the filter values collected for mirroring have to match
+ the ones the real predicate binds (see #33206), and the probe *results*
+ bound against the partition column have the same problem one step later.
+
+ Leaves the list alone when nothing in it is a float, so an exact integer
+ key above 2^53 is not rounded for no reason.
+
+ :param values: the values about to be bound
+ :return: the values, with every number widened to ``float`` if any was
+ """
+ if not any(isinstance(value, float) for value in values):
+ return list(values)
+ return [
+ float(value) if isinstance(value, (int, float)) else value for value
in values
+ ]
+
+
+def _placeholder_is_bindable(transform: str) -> bool:
+ """
+ Whether SQLAlchemy's ``text()`` can see ``:value`` as a bound parameter.
+
+ `VALUE_PLACEHOLDER_RE` is ``:value\b``, which matches the placeholder in a
+ Postgres cast such as ``:value::bigint``. SQLAlchemy's own bind scan is
+ ``(?<![:\\w$]):([\\w$]+)(?![:\\w$])`` -- note the trailing exclusion -- so
a
+ following colon stops it: ``text(":value::bigint")`` reports a parameter
+ named ``valu``, and ``.bindparams(bindparam("value"))`` then raises
+ ``ArgumentError``.
+
+ `_probe` swallows that, so the only symptom was a perfectly valid transform
+ whose mapping silently never pruned, and a preview reporting an engine
+ failure for SQL that was never sent. Every write-side gate passes the
+ transform: it parses, it is a bare expression, and the placeholder *is* in
+ an executable position.
+
+ Answered by attempting the bind itself rather than by pattern-matching
+ ``::`` or reading ``text()``'s private parameter map, so this cannot
+ disagree with what `build_probe_sql` is about to do -- on any dialect, or
on
+ a SQLAlchemy whose scan changes.
+ """
+ try:
+ sa.text(transform).bindparams(sa.bindparam("value", value=None))
+ except Exception: # pylint: disable=broad-except # noqa: BLE001
+ return False
+ return True
+
+
+def escape_foreign_colons(transform: str) -> str:
+ """
+ ``transform`` with every colon but the ``:value`` placeholder escaped.
+
+ `sqlalchemy.text` reads any ``:word`` as a bind parameter, including one
+ inside a string literal, and an unbound parameter renders as ``NULL``
+ under literal binds rather than raising. So ``:value || \':suffix\'``
+ compiled to ``'us' || 'NULL'``: the mirror asked for a partition value no
+ row holds and the chart dropped rows, with no error for `_probe` to
+ swallow and nothing on screen to say so.
+
+ Escaped rather than refused, because a colon in a literal is ordinary --
+ ``concat(:value, \':\', region)`` builds a composite key -- and the
+ placeholder is the only colon this module means to interpret. `text()`
+ reads ``\\:`` as a literal colon.
+
+ Only the bound path needs this. A `RawProbeValue` or an unbindable
+ placeholder is substituted as text by `parse_skeleton`, which never hands
+ the transform to `text()` at all.
+ """
+ pieces: list[str] = []
+ cursor = 0
+ for match in VALUE_PLACEHOLDER_RE.finditer(transform):
+ pieces.append(transform[cursor : match.start()].replace(":", r"\:"))
+ pieces.append(match.group(0))
+ cursor = match.end()
+ pieces.append(transform[cursor:].replace(":", r"\:"))
+ return "".join(pieces)
+
+
+def literal_round_trips(value: Any, dialect: Dialect | None, engine: str) ->
bool:
+ """
+ Whether the engine reads a rendered ``value`` back as that same value.
+
+ The probe runs with no bound parameters, so every value reaches the engine
+ as literal text the driver wrote (see `build_probe_sql`). Whether that text
+ says what the value says is a question about two independent spellings: the
+ driver's escaping, and the engine's lexer. SQLAlchemy's default renderer
+ doubles apostrophes and passes backslashes through; Hive and Spark honour
+ the backslash and not the doubling, so ``'O''Brien'`` is read there as two
+ adjacent literals -- the transform is evaluated at ``OBrien``, and the
+ mirror asks for a partition key that holds none of the rows the filter
+ keeps. The same gap in the other direction lets a value close its own quote
+ and have the rest of it read as SQL.
+
+ Escaping is not the repair. Whether a backslash introduces an escape is a
+ per-session server setting -- ``standard_conforming_strings`` on Postgres,
+ ``NO_BACKSLASH_ESCAPES`` on MySQL, ``escapedStringLiterals`` on Spark --
and
+ nothing in the engine specs exposes it. SQLAlchemy reads it off the server
+ into ``_backslash_escapes`` during ``initialize(connection)``, which the
+ probe's dialect never reaches: `_dialect_for` asks the ``Database`` for a
+ dialect, not for a connection, so Postgres renders ``a\\b`` as
+ ``'a\\\\b'`` and a server with the standard setting reads four
+ characters. An engine-spec flag would have to be defaulted, and either
+ default is wrong for a dozen engines: one leaves the hole open, the other
+ corrupts the probed value.
+
+ So the premise is verified rather than declared, the way
+ `probe_sql_is_evaluable` verifies the finished statement. A value that does
+ not survive the round trip is not mirrored, which costs the query its
+ pruning and nothing else.
+
+ The short-circuit is a proof, not a guess, and it matters for cost: a
+ 200-member ``IN`` list would otherwise parse 200 times on the chart path.
+ If the renderer emitted the value between two quotes and changed nothing in
+ it, and the value carries neither a quote nor a backslash, then no lexer in
+ play can read it as anything but itself. Both exclusions are load-bearing:
+ on Hive ``a\\b`` renders verbatim and reads as ``a`` followed by a
+ backspace.
+
+ :param value: a filter value about to be rendered into the probe
+ :param dialect: the dialect that will render it, as `_dialect_for` reports
+ :param engine: ``database.backend``, whose lexer reads the result
+ :return: whether the two agree about what the literal says
+ """
+ if isinstance(value, RawProbeValue):
+ # Already SQL, with no single value for a parse to be compared against:
+ # its elements are checked before they are rendered, by
+ # `ExploreMixin._array_mirror_probe_value`. A backslash surviving into
+ # the finished text is refused here all the same, as the backstop for a
+ # renderer this module does not own.
+ return "\\" not in value.sql
+ if not isinstance(value, str):
+ # Numbers and instants are not written between delimiters, so there is
+ # no escaping for the two sides to disagree about.
+ return True
+
+ rendered = _compile_literal(sa.literal(value), dialect)
+ if rendered == f"'{value}'" and "'" not in value and "\\" not in value:
+ return True
+
+ try:
+ return (
+ SQLStatement(f"SELECT {rendered}",
engine).get_select_literal_value()
+ == value
+ )
+ except SupersetParseError:
+ # A rendered literal the engine's own dialect will not parse is the
+ # strongest form of disagreement there is.
+ return False
+
+
+def value_round_trips(database: Database, value: Any) -> bool:
+ """
+ `literal_round_trips` for a caller holding a database, not a dialect.
+
+ Which renderer and which lexer apply is this module's business, so the two
+ callers outside it -- the chart path's own gate in
+ `ExploreMixin.mirror_probe_request` and the mapping summary Explore reads
+ -- ask in these terms and stay out of it.
+
+ :param database: the database whose driver renders and whose engine reads
+ :param value: a filter value about to be rendered into the probe
+ :return: whether the two agree about what the literal says
+ """
+ return literal_round_trips(value, _dialect_for(database), database.backend)
+
+
+def build_probe_sql(
+ transform: str,
+ values: list[Any],
+ dialect: Dialect | None = None,
+ from_suffix: str = "",
+) -> str:
+ """
+ Compile a single ``SELECT`` that evaluates the transform at every value.
+
+ Values are attacker-controlled (a Gamma user picks filter values), so they
+ are bound as parameters and rendered by the dialect's own literal processor
+ rather than interpolated into the SQL text. That is not itself a boundary:
+ the processor's escaping is not always the escaping the engine honours, and
+ a value can close its own quote through the gap. The boundary is
+ `literal_round_trips` on the way in -- which callers must have run, like
+ `stored_expression_error` below -- and `probe_sql_is_evaluable` on the way
+ out.
+
+ ``from_suffix`` comes from the engine spec's
``select_without_from_suffix``.
+ A ``SELECT`` with no ``FROM`` is not universal SQL: Oracle and Db2 need a
+ one-row table to select from, and without it every probe on those engines
+ raises -- which `_run_probe` swallows, so the only symptom is a correctly
+ configured mapping that silently never prunes.
+
+ Note this deliberately does *not* go through ``BaseEngineSpec``'s text
+ helper, which escapes ``:`` on every engine but Athena and would destroy
the
+ ``:value`` placeholder before it can be bound.
+
+ Callers must have run `stored_expression_error` first. The ``AS v{index}``
+ alias and ``from_suffix`` are only safe on a transform with no clause of
its
+ own; on one carrying its own FROM they attach to that instead.
+ """
+ bindable = _placeholder_is_bindable(transform)
+ selections = []
+ for index, value in enumerate(values):
+ if isinstance(value, RawProbeValue):
+ # Already SQL, so substituted rather than bound -- and deliberately
+ # not run through `_compile_literal`, which has nothing to compile
+ # and would undouble percent signs the owner typed on purpose.
+ rendered = parse_skeleton(transform, value.sql)
+ elif bindable:
+ # Every other colon escaped first: `text()` would read one inside a
+ # string literal as a second bind parameter and render it `NULL`.
+ # See `escape_foreign_colons`.
+ clause = sa.text(escape_foreign_colons(transform)).bindparams(
+ sa.bindparam("value", value=value)
+ )
+ rendered = _compile_literal(clause, dialect)
+ else:
+ # `text()` cannot see the placeholder (see
+ # `_placeholder_is_bindable`), so the value is rendered by the
+ # dialect's own literal processor first and substituted as SQL
+ # text. The same two steps in the other order: binding exists to
+ # get the value through that processor, and `_compile_literal`
+ # inlines the result either way, so this is the same string by a
+ # different route -- not a loosening of the "never interpolate a
+ # raw value" rule. `parse_skeleton` substitutes literally, so the
+ # rendered literal arrives here exactly as it was rendered, which
+ # is what makes `literal_round_trips`' verdict on it good for this
+ # branch as much as for the bound one.
+ rendered = parse_skeleton(
+ transform, _compile_literal(sa.literal(value), dialect)
+ )
+ selections.append(f"{rendered} AS v{index}")
+ return "SELECT " + ", ".join(selections) + from_suffix
+
+
+def probe_sql_is_evaluable(
+ sql: str,
+ engine: str,
+ expected: int,
+ from_suffix: str = "",
+) -> bool:
+ """
+ Whether the compiled probe is still the query `build_probe_sql` intended.
+
+ Every write-side gate reads the transform with ``:value`` standing in for a
+ value. The escape this guards against only completes once a *real* value is
+ rendered in its place, so it is invisible until this point -- and the
values
+ are supplied by whoever is filtering a chart, not only by the owner who
+ wrote the transform.
+
+ Five things have to hold, and all five are about the probe still being
+ readable rather than about taste:
+
+ * One statement. A value that closes the expression and starts another
+ would otherwise run both.
+ * No sub-query. A placeholder inside a string literal lets a value close
+ the quote and concatenate one; `stored_expression_error` refuses a
+ sub-query written openly, and this refuses the same thing assembled.
+ * Exactly ``expected`` projections, aliased ``v0`` upward. A comment
+ swallows the rest of its line including its own alias, and the engine
+ still answers with a column -- so the count alone does not notice.
+ Checking the aliases is what makes a missing one visible.
+ * No clause this function did not build, and a ``FROM`` only where
+ ``select_without_from_suffix`` asked for one -- the engine's own, not
+ merely some table. A rendered value can close the projection and append a
+ ``FROM`` of its own without disturbing the aliases that follow it, which
+ is a read of a table the mapping has no business reading; the same value
+ could append a ``WHERE`` and nothing would notice either.
+ * No comment, anywhere. A value that closes its own quote has to hide the
+ text `build_probe_sql` appended after it, and a comment is the only way
+ to do that and still balance the quotes -- so this is what makes the
+ alias check above sufficient rather than merely necessary. Read off the
+ parse rather than the text, so a comment *inside* a literal is data and
+ costs no pruning, and so the question is asked in the engine's own
+ dialect. A transform has no legitimate reason to carry one either; see
+ `EXECUTABLE_COMMENT_RE`.
+
+ Both FROM clauses are compared as sqlglot renders them, so the suffix's own
+ spacing does not have to match what the probe emitted. Identifier case
does,
+ which costs nothing: `_probe` hands the same engine constant to
+ `build_probe_sql` and to this gate.
+ """
+ # Before the parse, because this gate's whole contract is that what the
+ # parse describes is what the engine will run, and an executable comment is
+ # precisely the text where that stops being true. The transform is already
+ # refused for carrying one; this covers the compiled statement, so no later
+ # change to how values are rendered can reintroduce it behind the gates
+ # below.
+ if contains_executable_comment(sql) or
contains_unicode_escaped_identifier(sql):
+ return False
+
+ try:
+ script = SQLScript(sql, engine)
+ except SupersetParseError:
+ return False
+ if len(script.statements) != 1:
+ return False
+ statement = script.statements[0]
+ if not isinstance(statement, SQLStatement):
+ # A dialect whose statements this gate cannot take apart -- KQL -- gets
+ # no probe rather than an unchecked one. It never had one: the clause
+ # inspection below is implemented for SQL statements only, and the
+ # alternative to declining here is an `AttributeError` that `_probe`
+ # swallows into the same outcome, less legibly.
+ return False
+ if statement.has_comment():
+ return False
+ if statement.has_subquery():
+ return False
+ if statement.get_clause_names() - {"from"}:
+ return False
+ if statement.get_from_clause_sql() != _from_suffix_sql(from_suffix,
engine):
+ return False
+ return statement.get_select_aliases() == [f"v{index}" for index in
range(expected)]
+
+
+def _from_suffix_sql(from_suffix: str, engine: str) -> str | None:
+ """
+ The engine's own ``FROM`` suffix as its parsed clause, or ``None`` for
none.
+
+ Parsed rather than string-compared so that the comparison in
+ `probe_sql_is_evaluable` is between two rendered clauses. A suffix that
does
+ not parse answers ``None``, which no statement carrying a FROM can match --
+ the probe then declines, which is the right way for an unreadable engine
+ constant to fail.
+ """
+ if not from_suffix.strip():
+ return None
+ try:
+ return SQLStatement(f"SELECT 1{from_suffix}",
engine).get_from_clause_sql()
+ except SupersetParseError:
+ return None
+
+
+def evaluate_transform(
+ database: Database,
+ catalog: str | None,
+ schema: str | None,
+ transform: str,
+ values: list[Any],
+ *,
+ errors: list[str] | None = None,
+) -> list[Any] | None:
+ """
+ Evaluate ``transform`` against the engine once per distinct value.
+
+ Returns one result per input value, positionally aligned with ``values``,
or
+ ``None`` if anything at all goes wrong. Failing open costs pruning, never
+ correctness: the chart query still runs, it just scans more partitions.
+
+ The probe is pinned to the dataset's catalog and schema so session settings
+ match the chart query as closely as the connection pool allows. It still
+ runs in a *different* session, which is why transforms calling
+ session-dependent functions are rejected at save time.
+
+ :param errors: optional sink for the engine's own account of a failure. The
+ query path passes nothing and stays silent; the editor's preview passes
+ a list so it can tell the owner *why* the transform did not evaluate --
+ a misspelled function is the common case and sqlglot parses it happily.
+ """
+ if not values:
+ return None
+
+ # Dedupe so a 200-value `IN` list costs one column, not 200.
+ distinct: list[Any] = []
+ seen: set[Any] = set()
+ for value in values:
+ key = _hashable(value)
+ if key not in seen:
+ seen.add(key)
+ distinct.append(value)
+
+ # The value side of the premise `probe_sql_is_evaluable` checks on the
+ # finished statement: a value the driver and the engine would spell
+ # differently is not probed at all -- see `literal_round_trips`.
+ #
+ # `ExploreMixin.mirror_probe_request` declines one earlier, with a reason
+ # the preview can explain, and that is the gate an owner sees. This is the
+ # one that cannot be walked around. `_collect_partition_mirror_range`
+ # appends its bounds into the sink without passing through that method at
+ # all, and both paths can hand back a value the gate never saw: on a column
+ # carrying a `python_date_format`, what is probed is
+ # `strftime(that format)` -- text no mapping gate has ever read.
+ #
+ # Above `_run_probe` on purpose. This is a property of one input, not of
+ # the transform, and a verdict recorded here would withdraw the mirror for
+ # every later value of a transform the engine evaluates perfectly well.
+ dialect = _dialect_for(database)
+ if not all(
+ literal_round_trips(value, dialect, database.backend) for value in
distinct
+ ):
+ logger.warning(
+ "A filter value is not written the same way by the driver and the "
+ "database, so the partition transform is not probed at it; this "
+ "query will not prune"
+ )
+ return None
+
+ cache_key = _probe_cache_key(database, catalog, schema, transform,
distinct)
+ cached = _cache_get(cache_key)
+ if cached is None:
+ cached = _run_probe(
+ database, catalog, schema, transform, distinct, errors=errors
+ )
+ if cached is None:
+ # Deliberately not cached: a transient engine blip would otherwise
+ # keep the dataset pruning-free for the whole cache timeout.
+ return None
+ _cache_set(cache_key, cached)
+
+ evaluated = dict(
+ zip((_hashable(value) for value in distinct), cached, strict=False)
+ )
+ return [evaluated[_hashable(value)] for value in values]
+
+
+def _run_probe(
+ database: Database,
+ catalog: str | None,
+ schema: str | None,
+ transform: str,
+ distinct: list[Any],
+ *,
+ errors: list[str] | None = None,
+) -> list[Any] | None:
+ """
+ `_probe` plus the advisory record of how it went.
+
+ One place rather than one per `return`, because every way out of `_probe`
+ is a verdict and the next reader of `known_mirror_verdict` cannot tell the
+ difference between "declined" and "never asked".
+
+ The verdict is about the *transform*: whether the engine evaluated it at
+ all. A ``NULL`` among the results is not that -- it is this input's answer,
+ and `NULLIF(:value, 0)` returning ``NULL`` for ``n = 0`` says nothing about
+ the same transform on ``n = 2``. Counting it withdrew the promise for the
+ whole transform, and nothing restored it: a *failed* probe is deliberately
+ not cached, so the next chart re-probes and re-records, but a ``NULL`` is a
+ successful probe and *is* cached -- so the false verdict outlived every
+ later hit, and the editor reported a working mapping as not mirroring
+ while the query went on pruning. The per-input skip still happens where it
+ belongs, in the caller's own ``NULL`` guard.
+ """
+ values = _probe(database, catalog, schema, transform, distinct,
errors=errors)
+ record_mirror_verdict(
+ database,
+ catalog,
+ schema,
+ transform,
+ mirrors=values is not None,
+ )
+ return values
+
+
+def _probe(
+ database: Database,
+ catalog: str | None,
+ schema: str | None,
+ transform: str,
+ distinct: list[Any],
+ *,
+ errors: list[str] | None = None,
+) -> list[Any] | None:
+ # The last gate before the transform becomes SQL the engine runs, and the
+ # only one that covers a transform already in storage. The write-side
+ # checks can only speak for rows written after they existed; this speaks
+ # for every row, including ones a pre-fix release stored and ones a door
+ # that forgets to ask still lets in.
+ #
+ # Declining costs pruning, never correctness -- see `evaluate_transform`.
+ if reason := stored_expression_error(database, catalog, schema, transform):
+ logger.warning(
+ "Refusing to probe a partition transform that is not a storable "
+ "expression; queries will not prune: %s",
+ reason,
+ )
+ return None
+
+ try:
+ from_suffix = database.db_engine_spec.select_without_from_suffix
+ sql = build_probe_sql(
+ transform,
+ distinct,
+ _dialect_for(database),
+ from_suffix,
+ )
+ if not probe_sql_is_evaluable(
+ sql, database.backend, len(distinct), from_suffix
+ ):
+ # Declining rather than raising, for the same reason every other
+ # failure here declines: the chart query is already correct without
+ # the mirror, and it is the only thing the caller is waiting on.
+ logger.warning(
+ "Refusing to run a partition transform probe whose compiled
SQL "
+ "is not a plain projection per input value; queries will not "
+ "prune"
+ )
+ return None
+ frame = database.get_df(sql=sql, catalog=catalog, schema=schema)
+ if frame is None or frame.empty:
+ logger.warning(
+ "Partition transform probe returned no rows; skipping
mirroring"
+ )
+ return None
+ column_count = frame.shape[1]
+ if column_count != len(distinct):
+ # The results cannot be aligned back to their inputs; skipping
+ # beats guessing which value produced which column. Too *many*
+ # columns is the dangerous direction and the reason this is `!=`
+ # rather than `<`: a transform whose select list holds two
+ # expressions returns 2N columns for N inputs, and reading the
+ # first N of them interleaves the expressions instead of selecting
+ # one per value -- a predicate built from the wrong values rather
+ # than one that is merely short. `is_parseable` rejects that
+ # transform before it is ever stored; this is the backstop for one
+ # that predates the check.
+ logger.warning(
+ "Partition transform probe returned %d values for %d inputs",
+ column_count,
+ len(distinct),
+ )
+ return None
+ # Cell-wise rather than through `frame.iloc[0]`. A row is a Series, so
+ # it carries one dtype for every probe column: a row mixing an exact
+ # int64 with a float unifies to float64, and an integer partition key
+ # above 2^53 comes back rounded. `_to_python_scalar` converts
+ # faithfully but runs after the loss, so the mirror asks for a key the
+ # warehouse does not hold and drops the row the filter matched.
+ return [
+ _to_python_scalar(frame.iat[0, index]) for index in
range(len(distinct))
+ ]
+ except Exception as ex: # pylint: disable=broad-except
+ logger.warning(
+ "Partition transform probe failed; queries will not prune",
+ exc_info=True,
+ )
+ if errors is not None:
+ errors.append(str(ex))
+ return None
+
+
+def _to_python_scalar(value: Any) -> Any:
+ """
+ A probed value as a type SQLAlchemy can render and bind.
+
+ The probe reads its results out of a pandas frame, so a numeric column
+ arrives as a `numpy.int64` and a temporal one as a `pandas.Timestamp`.
+ SQLAlchemy has no literal renderer for either: `sa.literal(np.int64(...))`
+ infers `NullType` and raises `CompileError`, which is a 500 from the
preview
+ endpoint for the canonical epoch transform -- the most ordinary mapping
+ there is.
+
+ Pandas' missing-value sentinels are folded into `None` on the way through.
+ `NaT` is not a `Timestamp` and has no renderer either, and a transform that
+ returns nothing for an input it cannot convert is exactly the NULL case the
+ mirror already admits.
+ """
+ if value is None:
+ return None
+ try:
+ if pd.isna(value):
+ return None
+ except (TypeError, ValueError):
+ # Not something `isna` can judge (a list, a dict); pass it through and
+ # let the dialect decide.
+ pass
+ if isinstance(value, pd.Timestamp):
+ return value.to_pydatetime()
+ if isinstance(value, np.generic):
+ return value.item()
+ return value
+
+
+def _execution_identity(database: Database) -> str | None:
+ """
+ Who the warehouse will run the probe as, when that can vary by caller.
+
+ `_probe_cache_key` keys on the `Database` record, which is the *connection*
+ -- not the identity it is opened under. Two hooks make that identity a
+ function of the Superset user instead:
+
+ * ``impersonate_user`` on the database, which hands the effective username
+ to `db_engine_spec.impersonate_user`;
+ * ``DB_CONNECTION_MUTATOR``, which receives the effective username and may
+ return an entirely different account's URL.
+
+ Under either one, a cached probe result computed under user A's grants was
+ served to user B, and the preview echoed it back in ``emitted_predicate``.
+ A transform wrapping a read-capable function the denylist does not name --
+ Oracle's ``DBMS_XMLGEN.GETXML`` is the worked example -- then leaks across
+ users who were never allowed the same rows.
+
+ Returns ``None`` when neither hook is configured, which is the common case
+ and deliberately leaves the key as it was: the cache exists to keep a
+ synchronous warehouse round trip off the chart-query path, and keying every
+ deployment on the username would cost every deployment the hit rate to fix
+ the two that need it.
+
+ The *Superset* username rather than the resolved warehouse account, because
+ it is the input both hooks branch on, and reading it needs no URL parse and
+ puts no credential in the key.
+ """
+ if not (database.impersonate_user or app.config["DB_CONNECTION_MUTATOR"]):
+ return None
+ return get_username()
+
+
+def _probe_cache_key(
+ database: Database,
+ catalog: str | None,
+ schema: str | None,
+ transform: str,
+ values: list[Any],
+) -> str:
+ """
+ Key on everything that can change the answer.
+
+ Note this cache is independent of the chart-data cache: it is keyed on the
+ transform and its inputs, so it is correct to share across every chart on
+ every dataset that happens to use the same transform.
+ """
+ payload = json.dumps(
+ [
+ database.id,
+ database.backend,
+ # The probe asks this connection to evaluate the transform, so the
+ # answer belongs to it. `id` outlives an edit to the URI or to
+ # `extra` (a session timezone, say), which would otherwise serve
+ # values computed against the old environment until the entry
+ # expires. `changed_on` covers the edits that do not show up here,
+ # such as a rotated password, without putting a secret in the key.
+ database.sqlalchemy_uri,
+ database.extra,
+ str(database.changed_on),
+ catalog,
+ schema,
+ transform,
+ [repr(value) for value in values],
+ _execution_identity(database),
+ ],
+ default=repr,
+ )
+ digest = hashlib.md5(payload.encode("utf-8")).hexdigest() # noqa: S324
Review Comment:
`hashlib.md5` here raises `ValueError` on a FIPS-mode OpenSSL build, and
`_probe_cache_key` is called from `evaluate_transform` with no surrounding try.
With `PARTITION_FILTER_MAPPING` on, any chart filtering on a mapped column (and
the preview endpoint) would then return a 500 instead of just skipping pruning,
which contradicts the module's "a failure costs pruning, never correctness"
promise. Could this use `superset.utils.hashing` (or `usedforsecurity=False`)
like the rest of the codebase?
--
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]