sadpandajoe commented on code in PR #43870: URL: https://github.com/apache/superset/pull/43870#discussion_r4260730919
########## superset/connectors/sqla/partition_mapping.py: ########## @@ -0,0 +1,3041 @@ +# 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. + +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. +NON_DETERMINISTIC_FUNCTIONS = { Review Comment: This wall-clock list is matched by SQL function name, so `clock_timestamp()` and `statement_timestamp()` on PostgreSQL, `sysdate()` on MySQL, and `SYSTIMESTAMP`/`LOCALTIMESTAMP` pass the gate. A transform like `datediff(day, :value, LOCALTIMESTAMP)` for a relative partition key is accepted, its probe result is cached, and after midnight the cached partition value is emitted for a filter it no longer matches, so the chart silently drops rows. Should these be added here, or should the engine specs supply their own volatile names? ########## tests/unit_tests/connectors/sqla/partition_mapping_test.py: ########## @@ -0,0 +1,2928 @@ +# 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. +"""Unit tests for ``superset.connectors.sqla.partition_mapping``.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from importlib import import_module +from typing import Any, cast +from unittest.mock import MagicMock, patch, PropertyMock + +import pandas as pd +import pytest +from dateutil.relativedelta import relativedelta +from flask import Flask +from pytest_mock import MockerFixture +from sqlalchemy.dialects import mysql, postgresql + +from superset.config import DISALLOWED_SQL_FUNCTIONS +from superset.connectors.sqla.models import SqlaTable, TableColumn +from superset.connectors.sqla.partition_mapping import ( + _mirror_verdict_cache_key, + _placeholder_is_bindable, + _probe_cache_key, + _reads_as_temporal, + _render_literal, + build_mirrored_predicates, + build_probe_sql, + contains_executable_comment, + contains_jinja, + contains_value_placeholder, + drop_unmapped_value_transforms, + equality_mirrors_safely, + evaluate_transform, + find_non_deterministic_functions, + grain_bucket_width, + GRAIN_BUCKET_WIDTHS, + is_bare_expression, + is_parseable, + is_transform_active, + is_unfinished, + known_mirror_verdict, + MappingValidationIssue, + mirror_operator, + MIRRORABLE_ALWAYS, + MIRRORABLE_IF_MONOTONIC, + mirrorable_operators, + parse_error_detail, + placeholder_is_executable, + probe_sql_is_evaluable, + probed_value_type_error, + RawProbeValue, + resolve_partition_mapping, + stored_expression_error, + validate_partition_mapping, + validate_transform, +) +from superset.constants import TimeGrain +from superset.db_engine_specs.base import BaseEngineSpec +from superset.db_engine_specs.oracle import OracleEngineSpec +from superset.db_engine_specs.postgres import PostgresEngineSpec +from superset.models.core import Database +from superset.sql.parse import SQLStatement +from superset.utils.core import FilterOperator, GenericDataType, override_user + + [email protected](autouse=True) +def real_probe_cache(app: Flask) -> Any: + """ + The test app runs a null cache, which would make every cache assertion here + vacuously pass. Swap in a real in-memory one for the duration. + """ + from flask_caching import Cache + + from superset.extensions import cache_manager + + cache = Cache(config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": 300}) + cache.init_app(app) + original = cache_manager._cache # noqa: SLF001 + cache_manager._cache = cache # noqa: SLF001 + yield + cache_manager._cache = original # noqa: SLF001 + + [email protected](autouse=True) +def enable_partition_filter_mapping(app: Flask) -> Any: + """The feature ships off; every test here exercises it on.""" + original = app.config["DEFAULT_FEATURE_FLAGS"].get("PARTITION_FILTER_MAPPING") + app.config["DEFAULT_FEATURE_FLAGS"]["PARTITION_FILTER_MAPPING"] = True + yield + if original is None: + del app.config["DEFAULT_FEATURE_FLAGS"]["PARTITION_FILTER_MAPPING"] + else: + app.config["DEFAULT_FEATURE_FLAGS"]["PARTITION_FILTER_MAPPING"] = original + + +def _table(**kwargs: Any) -> SqlaTable: + database = Database(database_name="test_db", sqlalchemy_uri="sqlite://") + defaults: dict[str, Any] = { + "table_name": "web_events", + "database": database, + "main_dttm_col": "event_time", + "columns": [ + TableColumn(column_name="event_time", is_dttm=True, type="TIMESTAMP"), + TableColumn( + column_name="dt_epoch", + type="BIGINT", + partition_value_transform=None, + ), + TableColumn(column_name="country", type="VARCHAR"), + TableColumn(column_name="region_key", type="VARCHAR"), + ], + } + defaults.update(kwargs) + return SqlaTable(**defaults) + + +def _mapped_table( + *, + partition_column: str = "dt_epoch", + mapped_column: str = "event_time", + transform: str | None = "unix_timestamp(:value)", + monotonic: bool = True, + partition_mapped_column: str | None = None, + main_dttm_col: str | None = "event_time", +) -> SqlaTable: + table = _table(main_dttm_col=main_dttm_col) + table.partition_column = partition_column + table.partition_mapped_column = partition_mapped_column + for column in table.columns: + if column.column_name == mapped_column: + column.partition_value_transform = transform + column.partition_transform_is_monotonic = monotonic + return table + + +# --------------------------------------------------------------------------- +# §2 — operator safety matrix +# --------------------------------------------------------------------------- + + +def test_equality_and_in_are_always_mirrorable() -> None: + """``=`` and ``IN`` are safe for any function ``T``.""" + assert MIRRORABLE_ALWAYS == {FilterOperator.EQUALS, FilterOperator.IN} + + +def test_range_operators_require_a_monotonic_transform() -> None: + assert MIRRORABLE_IF_MONOTONIC == { + FilterOperator.GREATER_THAN, + FilterOperator.GREATER_THAN_OR_EQUALS, + FilterOperator.LESS_THAN, + FilterOperator.LESS_THAN_OR_EQUALS, + FilterOperator.TEMPORAL_RANGE, + } + + +def test_mirrorable_operators_excludes_ranges_when_not_monotonic() -> None: + assert mirrorable_operators(is_monotonic=False) == MIRRORABLE_ALWAYS + + +def test_mirrorable_operators_includes_ranges_when_monotonic() -> None: + assert mirrorable_operators(is_monotonic=True) == ( + MIRRORABLE_ALWAYS | MIRRORABLE_IF_MONOTONIC + ) + + [email protected]( + "operator", + [ + FilterOperator.NOT_EQUALS, + FilterOperator.NOT_IN, + FilterOperator.LIKE, + FilterOperator.ILIKE, + FilterOperator.NOT_LIKE, + FilterOperator.NOT_ILIKE, + FilterOperator.IS_NULL, + FilterOperator.IS_NOT_NULL, + FilterOperator.IS_TRUE, + FilterOperator.IS_FALSE, + ], +) +def test_negations_and_pattern_matches_are_never_mirrorable( + operator: FilterOperator, +) -> None: + """ + ``T`` is not injective, so ``col != v`` does **not** imply ``T(col) != T(v)``: + mirroring it would drop rows the original filter keeps. + """ + assert operator not in mirrorable_operators(is_monotonic=True) + + +def test_equality_is_dropped_when_the_engine_does_not_compare_byte_exactly() -> None: + """ + "Safe for any function ``T``" reasons about value equality: ``T`` is a + function, so ``col = v`` gives ``T(col) = T(v)``. The engine reasons about + *SQL* equality, and the two part company under a case-insensitive + collation -- a stored ``'us'`` satisfies a filter for ``'US'`` while the + mirror ``hex('US')`` excludes the row, and the chart loses it silently. + """ + assert mirrorable_operators(is_monotonic=False, equality_is_safe=False) == set() + + +def test_ranges_survive_an_unsafe_equality() -> None: + """ + Range mirroring already rests on the owner declaring ``T`` + order-preserving with respect to the column's own order -- an assertion + about the very comparison semantics this gate checks for. Equality has no + such declaration behind it, which is why only equality is withdrawn. + """ + assert ( + mirrorable_operators(is_monotonic=True, equality_is_safe=False) + == MIRRORABLE_IF_MONOTONIC + ) + + [email protected]( + "operator,expected", + [ + (FilterOperator.GREATER_THAN, FilterOperator.GREATER_THAN_OR_EQUALS), + (FilterOperator.LESS_THAN, FilterOperator.LESS_THAN_OR_EQUALS), + ( + FilterOperator.GREATER_THAN_OR_EQUALS, + FilterOperator.GREATER_THAN_OR_EQUALS, + ), + (FilterOperator.LESS_THAN_OR_EQUALS, FilterOperator.LESS_THAN_OR_EQUALS), + (FilterOperator.EQUALS, FilterOperator.EQUALS), + (FilterOperator.IN, FilterOperator.IN), + ], +) +def test_a_strict_bound_mirrors_non_strictly( + operator: FilterOperator, expected: FilterOperator +) -> None: + """ + Order-preserving means non-decreasing, not injective, so ``col < v`` + implies only ``T(col) <= T(v)``. A day-key transform maps a whole day onto + one value and a strict mirror drops the boundary bucket. The non-strict + cases are already safe and pass through, which is what makes applying this + twice harmless. + """ + assert mirror_operator(operator) == expected + + +def test_relaxing_a_bound_stays_inside_the_mirrorable_set() -> None: + """ + The substitution is about strictness, not about which filters mirror. The + Explore indicator reads `mirrorable_operators`, so leaving that set alone is + what keeps the glyph from promising pruning the SQL does not do -- and it is + also what guarantees `handle_comparison_filter` is never handed an operator + it has no case for. + """ + assert { + mirror_operator(operator) for operator in MIRRORABLE_IF_MONOTONIC + } <= MIRRORABLE_IF_MONOTONIC + + +def _column(type_: str) -> TableColumn: + database = Database(database_name="t", sqlalchemy_uri="sqlite://") + table = SqlaTable(table_name="t", database=database) + return TableColumn(column_name="c", type=type_, table=table) + + [email protected]( + "type_, binary, expected", + [ + ("VARCHAR", True, True), + ("VARCHAR", False, False), + # Numeric and temporal comparison is exact everywhere. + ("BIGINT", False, True), + ("TIMESTAMP", False, True), + ("DOUBLE", False, True), + ], +) +def test_equality_mirrors_safely_only_guards_string_columns( + app: Flask, type_: str, binary: bool, expected: bool +) -> None: + class _Spec(BaseEngineSpec): + binary_string_comparison = binary + + with app.app_context(): + assert equality_mirrors_safely(_column(type_), _Spec) is expected + + [email protected]( + "type_, expected", + [ + # The padded types: `'US'` and `'US '` are equal in the column, and + # `lower()` of the two is not. + ("CHAR(2)", False), + ("CHAR", False), + ("CHARACTER(2)", False), + ("NCHAR(2)", False), + # What PostgreSQL reflection reports for a `CHAR(n)` column. + ("BPCHAR", False), + ("bpchar(2)", False), + ("NATIONAL CHARACTER(2)", False), + # The variable-width types start with the same letters and must not be + # caught by it. + ("VARCHAR(2)", True), + ("NVARCHAR(2)", True), + ("CHARACTER VARYING(2)", True), + ("TEXT", True), + ], +) +def test_a_padded_character_column_declines_equality_on_a_byte_exact_engine( + app: Flask, type_: str, expected: bool +) -> None: + """ + `binary_string_comparison` speaks for the engine's default comparison, + which PostgreSQL declares -- and a `CHAR(2)` there still matches a filter + for `'US '` against a stored `'US'`, so the mirror `lower('US ')` excludes + the row. Padding is a property of the type, so it is asked per column. + """ + + class _Spec(BaseEngineSpec): + binary_string_comparison = True + + with app.app_context(): + assert equality_mirrors_safely(_column(type_), _Spec) is expected + + +def test_redshift_does_not_opt_in_to_byte_exact_comparison() -> None: + """ + Redshift ignores trailing blanks for `VARCHAR` as well as `CHAR`, so the + `True` it inherits from `PostgresBaseEngineSpec` would be wrong. + """ + from superset.db_engine_specs.redshift import RedshiftEngineSpec + + assert RedshiftEngineSpec.binary_string_comparison is False + + +def test_equality_mirrors_safely_fails_closed_on_an_unresolvable_type( + app: Flask, +) -> None: + """ + The gate exists to stop a silent wrong answer, so a column whose type it + cannot read is treated as the risky case rather than waved through. + """ + column = MagicMock() + type(column).type_generic = PropertyMock(side_effect=ValueError("no type")) + + with app.app_context(): + assert equality_mirrors_safely(column, BaseEngineSpec) is False + + +def test_no_mapped_column_leaves_the_matrix_alone(app: Flask) -> None: + """Nothing to reason about, and the resolver bails out on it anyway.""" + with app.app_context(): + assert equality_mirrors_safely(None, BaseEngineSpec) is True + + [email protected]( + "module, spec_name", + [ + ("presto", "PrestoEngineSpec"), + ("hive", "HiveEngineSpec"), + ("trino", "TrinoEngineSpec"), + ("impala", "ImpalaEngineSpec"), + ("postgres", "PostgresEngineSpec"), + ("bigquery", "BigQueryEngineSpec"), + ("sqlite", "SqliteEngineSpec"), + ], +) +def test_the_byte_exact_engines_opt_in(module: str, spec_name: str) -> None: + """ + The engines this feature targets compare text byte-exactly, so the gate + must not cost them the second canonical mapping (`lower(:value)` onto a + lowercased key). + """ + spec = getattr(import_module(f"superset.db_engine_specs.{module}"), spec_name) + assert spec.binary_string_comparison is True + + [email protected]("module, spec_name", [("mysql", "MySQLEngineSpec")]) +def test_a_case_insensitive_engine_does_not_opt_in(module: str, spec_name: str) -> None: + """ + MySQL's default collation is `utf8mb4_0900_ai_ci`, which is exactly the + case the reviewer raised. + """ + spec = getattr(import_module(f"superset.db_engine_specs.{module}"), spec_name) + assert spec.binary_string_comparison is False + + +def test_the_base_spec_does_not_opt_in() -> None: + """A spec that has not said so does not mirror string equality.""" + assert BaseEngineSpec.binary_string_comparison is False + + +# --------------------------------------------------------------------------- +# Time grain bucket widths +# --------------------------------------------------------------------------- + + [email protected]("grain", list(TimeGrain)) +def test_every_builtin_grain_has_a_known_bucket_width( + app: Flask, grain: TimeGrain +) -> None: + """ + The widening is only sound for a grain whose bucket width we know, so a + grain added upstream without a width here would silently stop mirroring. + This is the test that notices. + """ + with app.app_context(): + assert grain_bucket_width(grain.value, "sqlite") is not None + + +def test_a_calendar_grain_widens_by_a_calendar_month(app: Flask) -> None: + """A month is not thirty days, and the month it lands in decides its width.""" + with app.app_context(): + width = grain_bucket_width(TimeGrain.MONTH.value, "sqlite") + + assert width is not None + assert datetime(2026, 1, 31) + width == datetime(2026, 2, 28) + assert datetime(2026, 2, 1) + width == datetime(2026, 3, 1) + + +def test_a_fractional_grain_has_a_width(app: Flask) -> None: + """ + ``PT0.5H`` and ``P0.25Y`` are why the widths are written out rather than + parsed -- and the anchored week grains are intervals ``parse_duration`` + rejects outright. + """ + with app.app_context(): + assert grain_bucket_width(TimeGrain.HALF_HOUR.value, "sqlite") == relativedelta( + minutes=30 + ) + assert grain_bucket_width( + TimeGrain.WEEK_ENDING_SATURDAY.value, "sqlite" + ) == relativedelta(days=7) + + +def test_an_unknown_grain_has_no_known_width(app: Flask) -> None: + with app.app_context(): + assert grain_bucket_width("P13X", "sqlite") is None + assert grain_bucket_width(None, "sqlite") is None + + +def test_an_addon_grain_has_no_known_width(app: Flask) -> None: + """An operator-declared grain carries whatever duration string they typed.""" + with app.app_context(): + app.config["TIME_GRAIN_ADDONS"] = {"PT2S": "2 second"} + try: + assert grain_bucket_width("PT2S", "sqlite") is None + finally: + app.config["TIME_GRAIN_ADDONS"] = {} + + +def test_a_builtin_grain_an_operator_redefined_has_no_known_width( + app: Flask, +) -> None: + """ + ``TIME_GRAIN_ADDON_EXPRESSIONS`` replaces a built-in grain's SQL for one + engine, so its duration no longer bounds the displacement *there* -- but it + still does on every other engine. + """ + with app.app_context(): + app.config["TIME_GRAIN_ADDON_EXPRESSIONS"] = { + "sqlite": {"P1D": "DATETIME({col}, 'start of year')"} + } + try: + assert grain_bucket_width(TimeGrain.DAY.value, "sqlite") is None + assert grain_bucket_width(TimeGrain.DAY.value, "hive") is not None + finally: + app.config["TIME_GRAIN_ADDON_EXPRESSIONS"] = {} + + +def test_the_width_table_is_keyed_on_grain_durations(app: Flask) -> None: + """Keys are the ISO duration a filter carries, not the enum member name.""" + assert GRAIN_BUCKET_WIDTHS[TimeGrain.DAY] == relativedelta(days=1) + assert set(GRAIN_BUCKET_WIDTHS) == {grain.value for grain in TimeGrain} + + +# --------------------------------------------------------------------------- +# §4.1 — resolving the effective mapping, and the defensive bail-outs +# --------------------------------------------------------------------------- + + +def test_resolve_returns_the_mapping_when_everything_lines_up(app: Flask) -> None: + with app.app_context(): + mapping = resolve_partition_mapping(_mapped_table()) + + assert mapping is not None + assert mapping.partition_column == "dt_epoch" + assert mapping.mapped_column == "event_time" + assert mapping.value_transform == "unix_timestamp(:value)" + assert mapping.is_monotonic is True + + +def test_effective_mapped_column_follows_main_dttm_col(app: Flask) -> None: + """No explicit override: the mapping follows the default datetime column.""" + with app.app_context(): + mapping = resolve_partition_mapping(_mapped_table()) + + assert mapping is not None + assert mapping.mapped_column == "event_time" + + +def test_explicit_override_wins_over_main_dttm_col(app: Flask) -> None: + table = _mapped_table( + mapped_column="country", + transform="lower(:value)", + monotonic=False, + partition_mapped_column="country", + ) + table.partition_column = "region_key" + + with app.app_context(): + mapping = resolve_partition_mapping(table) + + assert mapping is not None + assert mapping.mapped_column == "country" + assert mapping.partition_column == "region_key" + assert mapping.is_monotonic is False + + +def test_resolve_returns_none_when_the_feature_flag_is_off(app: Flask) -> None: + table = _mapped_table() + with app.app_context(): + with patch( + "superset.connectors.sqla.partition_mapping.feature_flag_manager." + "is_feature_enabled", + return_value=False, + ): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_without_a_partition_column(app: Flask) -> None: + table = _mapped_table() + table.partition_column = None + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_when_partition_column_no_longer_exists( + app: Flask, +) -> None: + """A column sync can drop the physical partition column out from under us.""" + table = _mapped_table() + table.partition_column = "dt_epoch_gone" + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_when_the_mapped_column_no_longer_exists( + app: Flask, +) -> None: + table = _mapped_table(main_dttm_col="vanished") + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_on_self_mapping(app: Flask) -> None: + """ + ``partition_column == effective mapped column`` mirrors a column onto itself. + Save-time validation rejects it, but rows predating that validation exist. + """ + table = _mapped_table( + partition_column="event_time", + mapped_column="event_time", + ) + with app.app_context(): + assert resolve_partition_mapping(table) is None + + [email protected]("transform", [None, "", " "]) +def test_resolve_returns_none_without_a_transform( + app: Flask, transform: str | None +) -> None: + table = _mapped_table(transform=transform) + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_when_the_transform_lacks_the_placeholder( + app: Flask, +) -> None: + table = _mapped_table(transform="unix_timestamp(event_time)") + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_for_a_non_deterministic_transform(app: Flask) -> None: + """ + The query-time gate is `is_transform_active`, the same one the Explore + indicator reads. Anything narrower would let a transform that reached + storage without passing `UpdateDatasetCommand` -- through import, or a + hand-written bundle -- mirror a filter with a value frozen at probe time, + while the editor reported the mapping as inactive and nothing on screen + said otherwise. + """ + table = _mapped_table(transform="unix_timestamp(:value) + rand()") + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_for_a_multi_expression_transform(app: Flask) -> None: + """A select list is not an expression; the probe cannot align its columns.""" + table = _mapped_table(transform="lower(:value), 'x'") + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_for_an_unparseable_transform(app: Flask) -> None: + table = _mapped_table(transform="unix_timestamp(:value") + with app.app_context(): + assert resolve_partition_mapping(table) is None + + +def test_resolve_returns_none_when_the_mapped_column_has_an_advanced_data_type( + app: Flask, +) -> None: + """ + ``translate_filter`` builds its own predicate shape from *translated* values, + so the ``(operator, value)`` pair the operator matrix reasons about does not + exist. Mirroring anyway would silently apply the wrong values (§4.1). + """ + table = _mapped_table() + for column in table.columns: + if column.column_name == "event_time": + column.advanced_data_type = "port" + + with app.app_context(): + with patch.dict(app.config["ADVANCED_DATA_TYPES"], {"port": MagicMock()}): + with patch( + "superset.connectors.sqla.partition_mapping.feature_flag_manager." + "is_feature_enabled", + side_effect=lambda flag: ( + flag in {"PARTITION_FILTER_MAPPING", "ENABLE_ADVANCED_DATA_TYPES"} + ), + ): + assert resolve_partition_mapping(table) is None + + +def test_advanced_data_type_does_not_block_when_the_flag_is_off(app: Flask) -> None: + """An inert ``advanced_data_type`` is not a reason to skip mirroring.""" + table = _mapped_table() + for column in table.columns: + if column.column_name == "event_time": + column.advanced_data_type = "port" + + with app.app_context(): + with patch( + "superset.connectors.sqla.partition_mapping.feature_flag_manager." + "is_feature_enabled", + side_effect=lambda flag: flag == "PARTITION_FILTER_MAPPING", + ): + assert resolve_partition_mapping(table) is not None + + +# --------------------------------------------------------------------------- +# §5 — transform inspection helpers +# --------------------------------------------------------------------------- + + [email protected]( + "transform,expected", + [ + ("unix_timestamp(:value)", True), + ("lower(:value)", True), + ("CAST(:value AS BIGINT)", True), + ("unix_timestamp(event_time)", False), + (":values", False), + ("", False), + (None, False), + ], +) +def test_contains_value_placeholder(transform: str | None, expected: bool) -> None: + assert contains_value_placeholder(transform) is expected + + [email protected]( + "transform,expected", + [ + ("unix_timestamp(:value)", False), + ("{{ current_username() }}", True), + ("lower({% if x %}:value{% endif %})", True), + ("lower(:value) -- {# comment #}", True), + ], +) +def test_contains_jinja(transform: str, expected: bool) -> None: + assert contains_jinja(transform) is expected + + +def test_contains_jinja_answers_as_the_block_pattern_does() -> None: + """ + The scan has to agree with the lazy, ``DOTALL`` pattern it stands in for + on every input, including overlapping and unclosed delimiters, so that no + stored transform changes verdict. Every string of up to seven characters + over the delimiter characters, a newline and a filler is checked. + """ + import itertools + import re + + pattern = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}", re.DOTALL) + for length in range(8): + for chars in itertools.product("{}%#\na", repeat=length): + text = "".join(chars) + assert contains_jinja(text) is (pattern.search(text) is not None), text + + [email protected]("opening", ["{{", "{%", "{#"]) +def test_contains_jinja_is_linear_in_a_run_of_opening_delimiters( + opening: str, +) -> None: + """ + A run of opening delimiters with no closing one made the lazy pattern retry + from every position, which is quadratic. Stored transforms are not + length-bounded here, so a long run has to stay cheap. + """ + import time + + started = time.monotonic() + assert contains_jinja(opening * 200_000) is False + assert time.monotonic() - started < 1 + + [email protected]( + ("transform", "expected"), + [ + ("lower(:value)", False), + ("lower(:value) /* an ordinary comment */", False), + ("lower(:value) -- a line comment", False), + (":value /*!50000 + 1 */", True), + (":value /*M!50000 + 1 */", True), + (":value /*! + 1 */", True), + ("", False), + (None, False), + ], +) +def test_contains_executable_comment(transform: str | None, expected: bool) -> None: + assert contains_executable_comment(transform) is expected + + [email protected]( + "transform", + [ + "unix_timestamp(:value)", + "lower(:value)", + "CAST(:value AS BIGINT)", + "date_format(:value, 'yyyyMMdd')", + ], +) +def test_pure_transforms_report_no_non_deterministic_functions( + transform: str, +) -> None: + assert find_non_deterministic_functions(transform, "hive") == set() + + [email protected]( + "transform,expected_name", + [ + ("date_diff(:value, now())", "NOW"), + ("CAST(:value AS BIGINT) + rand()", "RAND"), + ("CAST(:value AS DATE) - current_date", "CURRENT_DATE"), + ], +) +def test_non_deterministic_functions_are_reported( + transform: str, expected_name: str +) -> None: + """ + The probe runs at a different moment and in a different session from the + chart query, and its result is cached, so anything time- or + randomness-dependent freezes a snapshot of probe time into the predicate. + """ + assert expected_name in find_non_deterministic_functions(transform, "hive") + + +def test_niladic_unix_timestamp_is_rejected_but_the_unary_form_is_not() -> None: + """ + On Hive/Impala ``unix_timestamp()`` means "now" while ``unix_timestamp(x)`` + -- the canonical temporal transform -- is pure. The distinction is the whole + reason this check inspects arity rather than just the name. + """ + assert find_non_deterministic_functions("unix_timestamp(:value)", "hive") == set() + assert find_non_deterministic_functions( + "unix_timestamp(:value) - unix_timestamp()", "hive" + ) + + +# --------------------------------------------------------------------------- +# §4.2 — probe evaluation +# --------------------------------------------------------------------------- + + +def _user(username: str) -> Any: + """A stand-in for `override_user`, which only needs `.username` here.""" + user = MagicMock() + user.username = username + user.is_anonymous = False + return user + + +def _database_returning(values: list[Any]) -> Database: + """A real ``Database`` (so the dialect is real) with only ``get_df`` stubbed.""" + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + database.get_df = MagicMock( # type: ignore[method-assign] + return_value=pd.DataFrame( + [values], columns=[f"v{i}" for i in range(len(values))] + ) + ) + return database + + +def _database_returning_frame(frame: pd.DataFrame) -> Database: + """ + ``_database_returning`` for a probe result whose column dtypes matter. + + The flat-list sibling builds its frame from one Python list, so pandas + infers a single dtype and the row-versus-cell distinction cannot be seen. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + database.get_df = MagicMock( # type: ignore[method-assign] + return_value=frame + ) + return database + + +def _probe(database: Database) -> MagicMock: + """ + The stubbed ``get_df``, typed for call assertions. + + ``Database.get_df`` is a real method, so mypy reads its declared signature + rather than the mock that replaced it and rejects ``.call_args`` and + friends. + """ + return cast(MagicMock, database.get_df) + + +def test_evaluate_transform_returns_one_value_per_input(app: Flask) -> None: + database = _database_returning([1767225600, 1769904000]) + + with app.app_context(): + result = evaluate_transform( + database, + None, + "default", + "unix_timestamp(:value)", + ["2026-01-01 00:00:00", "2026-02-01 00:00:00"], + ) + + assert result == [1767225600, 1769904000] + _probe(database).assert_called_once() + + +def test_evaluate_transform_binds_values_rather_than_interpolating( + app: Flask, +) -> None: + """ + Filter values are attacker-controlled (a Gamma user picks them), so they must + be bound and escaped, never pasted into the probe SQL. + """ + database = _database_returning(["o''brien"]) + + with app.app_context(): + evaluate_transform(database, None, None, "lower(:value)", ["O'Brien"]) + + sql = _probe(database).call_args.kwargs["sql"] + assert "O'Brien" not in sql + assert "O''Brien" in sql + + [email protected]( + ("transform", "expected"), + [ + ("lower(:value)", True), + ("CAST(:value AS BIGINT)", True), + ("date_format(:value, '%Y%m%d')", True), + # The cast shorthand: a trailing colon stops SQLAlchemy's bind scan. + (":value::bigint", False), + ("(:value::date)::text", False), + ], +) +def test_a_postgres_cast_shorthand_is_not_bindable( + transform: str, expected: bool +) -> None: + """ + `VALUE_PLACEHOLDER_RE` is `:value\b`, which matches inside `:value::bigint`. + SQLAlchemy's own scan ends with `(?![:\\w$])`, so the following colon stops + it: `text(":value::bigint")` reports a parameter named `valu`, and asking it + to bind `value` raises. + + Pinned because the two regexes disagreeing is the whole bug: every + write-side gate accepted the transform and the probe then died on an + `ArgumentError` that `_probe` swallows. + """ + assert _placeholder_is_bindable(transform) is expected + + +def test_a_cast_shorthand_transform_still_probes(app: Flask) -> None: + """ + The transform is valid SQL, passes every gate, and used to prune nothing: + `build_probe_sql` raised, `_probe` swallowed it, and the only symptom was a + mapping that silently never mirrored -- with the preview reporting an engine + failure for SQL that was never sent. + """ + database = _database_returning([20260115]) + + with app.app_context(): + result = evaluate_transform( + database, None, None, ":value::bigint", ["20260115"] + ) + + assert result == [20260115] + assert _probe(database).call_count == 1 + assert "::bigint" in _probe(database).call_args.kwargs["sql"] + + +def test_a_cast_shorthand_value_is_still_escaped_by_the_dialect( + app: Flask, +) -> None: + """ + The fallback substitutes where the bind path binds, so it has to earn the + same guarantee its sibling above pins: the value is rendered by the + dialect's literal processor, not pasted in. + + Binding was only ever the vehicle for reaching that processor -- + `_compile_literal` inlines the result either way -- so this renders first + and substitutes second. Same string, different order. + """ + database = _database_returning(["x"]) + + with app.app_context(): + evaluate_transform(database, None, None, ":value::text", ["O'Brien"]) + + sql = _probe(database).call_args.kwargs["sql"] + assert "O'Brien" not in sql + assert "O''Brien" in sql + + +def test_evaluate_transform_dedupes_repeated_values(app: Flask) -> None: + """Three inputs, two distinct: the probe evaluates the transform twice.""" + database = _database_returning(["us", "us"]) + + with app.app_context(): + result = evaluate_transform( + database, None, None, "lower(:value)", ["US", "US", "us"] + ) + + assert result == ["us", "us", "us"] + sql = _probe(database).call_args.kwargs["sql"] + assert sql.count("lower") == 2 + + +def test_evaluate_transform_fails_open_on_a_short_result_row(app: Flask) -> None: + """A row narrower than the probe asked for means the results cannot be + aligned back to their inputs; pruning is skipped rather than guessed at.""" + database = _database_returning(["us"]) + + with app.app_context(): + assert ( + evaluate_transform(database, None, None, "lower(:value)", ["US", "CA"]) + is None + ) + + +def test_evaluate_transform_fails_open_on_a_wide_result_row(app: Flask) -> None: + """ + Too many columns is the dangerous direction. A transform whose select list + holds two expressions returns 2N columns for N inputs, and reading the first + N interleaves the expressions instead of taking one per value -- a predicate + built from the wrong values rather than one that is merely short. A frame of + `('us', 'x', 'ca', 'x')` read as the first two columns would have yielded + `('us', 'x')` and dropped every CA row. + + The transform is a perfectly ordinary `lower(:value)` and the width comes + from the stubbed frame, which is the only way to reach the guard: a + transform that really does carry two expressions -- `lower(:value), 'x'` -- + is refused by `stored_expression_error` for not being a single expression, + so `get_df` is never called and `None` comes back for an unrelated reason. + Asserting the probe *ran* is what separates the two, and it is what makes + relaxing the guard from `!=` to `<` fail here rather than pass: four columns + for two inputs is not fewer than two. + """ + database = _database_returning(["us", "x", "ca", "x"]) + + with app.app_context(): + assert ( + evaluate_transform(database, None, None, "lower(:value)", ["US", "CA"]) + is None + ) + + assert _probe(database).call_count == 1 + + +def test_the_probe_select_carries_the_engine_s_from_clause(app: Flask) -> None: + """ + A `SELECT` with no `FROM` is not universal SQL: Oracle and Db2 need a + one-row table to select from. Without this every probe on those engines + raises, which `evaluate_transform` swallows -- so the only symptom is a + correctly configured mapping that silently never prunes. + """ + assert build_probe_sql("lower(:value)", ["US"]) == "SELECT lower('US') AS v0" + assert build_probe_sql("lower(:value)", ["US"], None, " FROM DUAL").endswith( + " FROM DUAL" + ) + assert OracleEngineSpec.select_without_from_suffix == " FROM DUAL" + assert BaseEngineSpec.select_without_from_suffix == "" + + [email protected]( + ("module", "spec_name", "suffix"), + [ + ("oracle", "OracleEngineSpec", " FROM DUAL"), + ("db2", "Db2EngineSpec", " FROM SYSIBM.SYSDUMMY1"), + ], +) +def test_the_probe_reads_the_from_suffix_off_the_engine_spec( + app: Flask, mocker: MockerFixture, module: str, spec_name: str, suffix: str +) -> None: + """ + The sibling above hands `build_probe_sql` a suffix directly, so it never + exercises the step that reads `select_without_from_suffix` off the engine + spec and forwards it. Drop that forwarding and the sibling stays green + while Oracle and Db2 stop pruning entirely -- a bare `SELECT ...` raises + there, `evaluate_transform` swallows it, and the only symptom is a + correctly configured mapping that silently never prunes. + + So this drives the real entry point and asserts on the SQL the engine is + handed, plus that the result is accepted. + """ + spec = getattr(import_module(f"superset.db_engine_specs.{module}"), spec_name) + database = _database_returning(["us"]) + mocker.patch.object( + type(database), "db_engine_spec", PropertyMock(return_value=spec) + ) + + with app.app_context(): + assert evaluate_transform(database, None, None, "lower(:value)", ["US"]) == [ + "us" + ] + + sql = _probe(database).call_args.kwargs["sql"] + assert sql.endswith(suffix) + # And the gate that re-parses the compiled probe accepts that shape, rather + # than the suffix being appended only to be refused downstream. + assert probe_sql_is_evaluable(sql, spec.engine, 1, suffix) is True + + [email protected]("dialect", [mysql.dialect(), postgresql.dialect()]) +def test_the_probe_select_does_not_double_percent_signs(dialect: Any) -> None: + """ + `TextClause` compilation runs the dialect's `post_process_text`, which + doubles every `%` on any paramstyle whose DBAPI later interpolates + parameters -- MySQL and Postgres, and every spec inheriting from them. The + probe has no such interpolation pass: it executes with no bound parameters + at all. Left doubled, `date_format(:value, '%Y%m%d')` asks the warehouse + for the literal string `%%Y%%m%%d`, so the probe returns that instead of a + date and the mirrored predicate drops every row the filter keeps. + """ + sql = build_probe_sql("date_format(:value, '%Y%m%d')", ["2026-01-15"], dialect) + + assert "'%Y%m%d'" in sql + assert "%%" not in sql + + +def test_the_probe_select_is_unchanged_on_a_dialect_that_does_not_double() -> None: + """ + Pins the no-dialect path, which is also the blind spot: the default dialect + does not double percent signs, so no assertion made through it can see the + bug the two cases above cover. + """ + assert ( + build_probe_sql("date_format(:value, '%Y%m%d')", ["2026-01-15"]) + == "SELECT date_format('2026-01-15', '%Y%m%d') AS v0" + ) + + +def test_a_raw_probe_value_is_substituted_literally() -> None: + r""" + `RawProbeValue.sql` is rendered from a filter value, so it reaches + `parse_skeleton` as the *replacement* for `:value`. Read as a replacement + template, a backslash in it is an escape: `array('a\nb')` -- which is how a + dialect renders the two characters `\` and `n` -- would become a real + newline, so the engine is asked about a different array than the predicate + compares and the mirror can exclude rows the filter keeps. + """ + sql = build_probe_sql("toYYYYMM(:value)", [RawProbeValue(r"array('a\nb')")]) + + assert sql == r"SELECT toYYYYMM(array('a\nb')) AS v0" + + +def test_a_raw_probe_value_does_not_re_inject_the_placeholder() -> None: + r""" + `\g<0>` in a replacement template expands to the whole match, which would + put `:value` back into the probe -- an unbound parameter the engine refuses. + """ + sql = build_probe_sql("toYYYYMM(:value)", [RawProbeValue(r"'a\g<0>b'")]) + + assert sql == r"SELECT toYYYYMM('a\g<0>b') AS v0" + assert ":value" not in sql + + +def test_a_raw_probe_value_with_a_group_reference_does_not_raise() -> None: + r""" + `\1` is an invalid group reference for this pattern, so as a replacement + template it raised `re.PatternError` rather than probing anything. + """ + sql = build_probe_sql("toYYYYMM(:value)", [RawProbeValue(r"'a\1b'")]) + + assert sql == r"SELECT toYYYYMM('a\1b') AS v0" + + +def test_a_probed_literal_keeps_its_percent_sign(app: Flask) -> None: + """ + The preview panel renders probed values through the same dialect, so a + value that merely contains a `%` would be shown doubled. + """ + database = Database(database_name="pct_db", sqlalchemy_uri="postgresql://h/d") + + with app.app_context(): + assert _render_literal(database, "100%") == "'100%'" + + +def test_the_probe_reads_each_cell_without_row_wide_dtype_coercion( + app: Flask, +) -> None: + """ + A row is a Series, so it carries one dtype across 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. The mirror then asks for a key + the warehouse does not hold, and the row the filter matched is dropped. + """ + database = _database_returning_frame( + pd.DataFrame({"v0": [9007199254740993], "v1": [1.5]}) + ) + + with app.app_context(): + probed = evaluate_transform( + database, None, None, "CAST(:value AS NUMERIC)", ["9007199254740993", "1.5"] + ) + + assert probed == [9007199254740993, 1.5] + + +def test_evaluate_transform_pins_catalog_and_schema(app: Flask) -> None: + """ + The probe runs in a different session from the chart query; pinning the + catalog and schema keeps session settings as close as the pool allows. + """ + database = _database_returning([1]) + + with app.app_context(): + evaluate_transform(database, "prod", "analytics", "lower(:value)", ["x"]) + + kwargs = _probe(database).call_args.kwargs + assert kwargs["catalog"] == "prod" + assert kwargs["schema"] == "analytics" + + +def test_evaluate_transform_fails_open_when_the_probe_raises(app: Flask) -> None: + """A wedged engine must not break the chart — it just stops pruning.""" + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + database.get_df = MagicMock( # type: ignore[method-assign] + side_effect=RuntimeError("connection reset") + ) + + with app.app_context(): + assert evaluate_transform(database, None, None, "lower(:value)", ["x"]) is None + + +def test_evaluate_transform_fails_open_on_an_empty_result(app: Flask) -> None: + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + database.get_df = MagicMock( # type: ignore[method-assign] + return_value=pd.DataFrame() + ) + + with app.app_context(): + assert evaluate_transform(database, None, None, "lower(:value)", ["x"]) is None + + +def test_evaluate_transform_returns_none_for_no_values(app: Flask) -> None: + database = _database_returning([]) + + with app.app_context(): + assert evaluate_transform(database, None, None, "lower(:value)", []) is None + + _probe(database).assert_not_called() + + [email protected]( + "transform", + [ + "(SELECT password FROM ab_user LIMIT 1) || :value", + "lower(:value) UNION ALL SELECT password FROM ab_user", + "lower(:value); DROP TABLE ab_user", + "password || :value FROM ab_user", + "lower(:value) FROM ab_user WHERE 1 = 1", + ], + ids=[ + "subquery", + "set-operation", + "multi-statement", + "bare-from", + "from-and-where", + ], +) +def test_a_transform_that_is_not_a_storable_expression_never_runs( + app: Flask, transform: str +) -> None: + """ + The probe is where a transform stops being text and becomes SQL. + + `build_probe_sql` binds `:value` and splices the rest in verbatim, so a + transform that was never a single harmless expression is arbitrary SQL + against the dataset's database -- read back out of the emitted predicate + by anyone who can open "View query". The write-side gates refuse these, + but they can only speak for rows written after they existed, so the last + word belongs here. + """ + database = _database_returning(["irrelevant"]) + + with app.app_context(): + assert evaluate_transform(database, None, None, transform, ["us"]) is None + + _probe(database).assert_not_called() + + [email protected]( + "transform, unfinished", + [ + ("unix_timestamp(:value", True), + ("unix_timestamp(:value))", True), + ("unix_timestamp(:value) +", True), + ("unix_timestamp(:value)", False), + ("unix_timestamp(:value); DROP TABLE ab_user", False), + ("unix_timestamp(:value) UNION ALL SELECT password FROM ab_user", False), + ("password || :value FROM ab_user", False), + ], +) +def test_is_unfinished_separates_not_sql_yet_from_the_wrong_sql( + transform: str, unfinished: bool +) -> None: + """ + The two deserve opposite treatment -- a half-typed transform is stored and + parked inactive, the wrong SQL is refused -- and neither `is_parseable` nor + parsing as a single statement tells them apart, because the multi-statement + form fails both exactly as unfinished text does. Parsing as a script does. + """ + assert is_unfinished(transform, "hive") is unfinished + + +def test_a_transform_smuggling_its_own_from_clause_is_refused(app: Flask) -> None: + """ + The hole this gate was added for. + + `is_parseable` counts only the projection, so `password || :value FROM + ab_user` reads as a single select expression and passed every write-side + gate -- including with `ALLOW_ADHOC_SUBQUERY` off, since a top-level FROM + is not a sub-query. `build_probe_sql` then emitted `SELECT password || 'us' + AS v0 FROM ab_user`, where its own alias is read as a table alias on the + FROM clause the transform brought with it: valid SQL, one row, and the + secret handed back through the predicate the preview panel renders. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + transform = "password || :value FROM ab_user" + + assert is_parseable(transform, "sqlite") is True + assert is_bare_expression(transform, "sqlite") is False + + with app.app_context(): + reason = stored_expression_error(database, None, None, transform) + assert reason is not None + + blocking = _blocking(validate_transform(transform, "sqlite")) + assert len(blocking) == 1 + assert "single SQL expression" in str(blocking[0].message) + assert is_transform_active(transform, "sqlite") is False + + +def test_a_subquery_transform_is_refused_even_with_adhoc_subquery_enabled( + app: Flask, +) -> None: + """ + `validate_adhoc_subquery` admits a sub-query under `ALLOW_ADHOC_SUBQUERY` + and returns RLS-rewritten SQL -- which this path has no way to propagate, + because `build_probe_sql` splices the original transform text. So the probe + ran a sub-query with no row-level-security predicates at all and reported + the hidden row in `emitted_predicate`. A transform is a scalar function of + `:value`, so refusing the sub-query outright is both correct and what makes + the missing RLS rewrite moot. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + transform = "(SELECT secret FROM vault LIMIT 1) || :value" + + with app.app_context(): + app.config["DEFAULT_FEATURE_FLAGS"]["ALLOW_ADHOC_SUBQUERY"] = True + try: + reason = stored_expression_error(database, None, None, transform) + finally: + del app.config["DEFAULT_FEATURE_FLAGS"]["ALLOW_ADHOC_SUBQUERY"] + + assert reason is not None + assert "sub-query" in reason + + [email protected]( + "comment", + ["/*!50000 + (SELECT secret FROM vault LIMIT 1) */", "/*M!50000 + 1 */"], +) +def test_an_executable_comment_is_refused_by_every_gate( + app: Flask, comment: str +) -> None: + """ + The one construct that made the shape gates describe a statement the engine + would not run. + + sqlglot reads a MySQL executable comment as comment data: the pinned parse + below reports no sub-query and no clause, so `stored_expression_error` and + `probe_sql_is_evaluable` both passed it, while MySQL and MariaDB execute + what is inside. An owner with dataset write and no SQL Lab could put one in + a transform, call the preview and read a table back out of + `emitted_predicate`, with no RLS on the hidden query. + + Refused on every engine, not only the two that honour it -- hence SQLite + here. There is no legitimate transform carrying one, and a per-engine gate + would store on Postgres what detonates after the dataset is re-pointed. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + transform = f"(:value {comment})" + + # The premise: no parse-tree gate can see it. + statement = SQLStatement(f"SELECT (1 {comment}) AS v0", "mysql") + assert statement.has_subquery() is False + assert statement.get_clause_names() == set() + + with app.app_context(): + assert stored_expression_error(database, None, None, transform) is not None + + blocking = [ + issue for issue in validate_transform(transform, "mysql") if issue.blocking + ] + assert len(blocking) == 1 + assert "executable comment" in str(blocking[0].message) + + +def test_an_executable_comment_stops_the_probe_before_the_engine(app: Flask) -> None: + """ + The gate above is the save path. This is the row an earlier release stored: + `_probe` consults `stored_expression_error` on every call, so a transform + already in the metadata database never reaches `get_df`. + """ + database = _database_returning(["x"]) + transform = "(:value /*!50000 + (SELECT secret FROM vault LIMIT 1) */)" + + with app.app_context(): + assert evaluate_transform(database, None, None, transform, ["US"]) is None + + assert _probe(database).call_count == 0 + + +def test_a_unicode_escaped_identifier_is_refused_by_every_gate(app: Flask) -> None: + """ + The second construct that made the gates reason about a name the engine + does not use. sqlglot reports `U&"\\0071uery_to_xml"` as an anonymous + function literally called `\\0071uery_to_xml`, so the function denylist -- + which matches on the reported name -- never sees `query_to_xml`, while + PostgreSQL decodes the escape and runs it. An owner with dataset write and + no SQL Lab could read rows outside their RLS scope back out of + `emitted_predicate`. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="postgresql://u@h/d") + transform = "CAST(U&\"\\0071uery_to_xml\"(:value, false, true, '') AS TEXT)" + + # The premise: the denylist is matching the wrong name. + statement = SQLStatement( + "SELECT CAST(U&\"\\0071uery_to_xml\"('x', false, true, '') AS TEXT) AS v0", + "postgresql", + ) + assert statement.get_disallowed_functions({"query_to_xml"}) == set() + + with app.app_context(): + assert stored_expression_error(database, None, None, transform) is not None + + blocking = [ + issue for issue in validate_transform(transform, "postgresql") if issue.blocking + ] + assert len(blocking) == 1 + assert "escape sequence" in str(blocking[0].message) + + +def test_a_unicode_escaped_identifier_stops_the_probe_before_the_engine( + app: Flask, +) -> None: + """The row an earlier release stored, as for the executable comment.""" + database = _database_returning(["x"]) + transform = "U&\"\\0071uery_to_xml\"(:value, false, true, '') || ''" + + with app.app_context(): + assert evaluate_transform(database, None, None, transform, ["US"]) is None + + assert _probe(database).call_count == 0 + + +def test_an_ordinary_quoted_identifier_is_still_allowed(app: Flask) -> None: + """ + Only the escape prefix is refused. A plainly quoted identifier names what + it says, so the denylist sees the real name and rejecting it would cost an + owner a transform that works. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="postgresql://u@h/d") + + with app.app_context(): + assert ( + stored_expression_error(database, None, None, 'lower("Mixed Case")') is None + ) + + +def test_the_escape_gate_reads_raw_text_and_says_so(app: Flask) -> None: + """ + The prefix is refused wherever it appears, including inside a string + literal -- the same trade the executable-comment gate makes, and for the + same reason: a gate whose whole job is to fire before the parse cannot ask + the parse whether the text is quoted. `concat(:value, 'u&"x"')` is refused, + which costs an owner nothing anyone writes. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="postgresql://u@h/d") + + with app.app_context(): + assert ( + stored_expression_error(database, None, None, "concat(:value, 'u&\"x\"')") + is not None + ) + + +def test_a_plain_comment_is_still_allowed_through_the_shape_gates( + app: Flask, +) -> None: + """ + Only the executable form is refused. An ordinary comment is inert on every + engine, so rejecting it would cost an owner a transform that works -- and + the placeholder gate already covers the case that matters, a `:value` the + comment swallows. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + + with app.app_context(): + assert ( + stored_expression_error(database, None, None, "lower(:value) /* ok */") + is None + ) + + +def test_a_transform_calling_a_disallowed_function_is_refused(app: Flask) -> None: + """ + The probe executes the transform, so the operator's function denylist has + to apply to it for the same reason it applies in SQL Lab. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="sqlite://") + transform = "version() || :value" + + with app.app_context(): + original = app.config["DISALLOWED_SQL_FUNCTIONS"] + try: + app.config["DISALLOWED_SQL_FUNCTIONS"] = {"sqlite": {"version"}} + reason = stored_expression_error(database, None, None, transform) + assert reason is not None + assert "version" in reason + + # Named, rather than echoing the operator's whole denylist back. + assert "current_user" not in reason + + app.config["DISALLOWED_SQL_FUNCTIONS"] = {} + assert stored_expression_error(database, None, None, transform) is None + finally: + # Restored: `app.config` is shared, and leaving it emptied silently + # disabled this gate for every test that ran afterwards. + app.config["DISALLOWED_SQL_FUNCTIONS"] = original + + [email protected]( + "function", + [ + "database_to_xml", + "database_to_xmlschema", + "database_to_xml_and_xmlschema", + "schema_to_xml", + "schema_to_xmlschema", + "schema_to_xml_and_xmlschema", + "table_to_xml", + "table_to_xmlschema", + "table_to_xml_and_xmlschema", + "query_to_xml", + "query_to_xmlschema", + "query_to_xml_and_xmlschema", + ], +) +def test_the_whole_postgres_xml_family_is_denied_by_default(function: str) -> None: + """ + These read tables with no FROM clause and no sub-query, so every gate that + reasons about table references is blind to them -- the denylist is the only + thing that can refuse them, and it has to name each shape. `schema_to_xml` + and three siblings were missing, so a bare scalar call cleared + `stored_expression_error` entirely and returned connection-user-readable + schema contents through preview. + + Asserted against the shipped default rather than through the gate, because + the gap was an incomplete enumeration rather than broken wiring -- and + because `app.config` is ambient here, which the behavioural test below + handles by setting the denylist itself. + """ + assert function in DISALLOWED_SQL_FUNCTIONS["postgresql"] + + +def test_a_denied_read_capable_function_is_refused_despite_having_no_from( + app: Flask, +) -> None: + """ + The wiring half: a bare scalar call passes the length, single-statement, + bare-expression, sub-query and placeholder gates, so the denylist is what + has to stop it. + """ + database = Database(database_name="probe_db", sqlalchemy_uri="postgresql://u@h/d") + transform = "schema_to_xml('public', true, false, '') || :value" + + with app.app_context(): + original = app.config["DISALLOWED_SQL_FUNCTIONS"] + app.config["DISALLOWED_SQL_FUNCTIONS"] = {"postgresql": {"schema_to_xml"}} + try: + reason = stored_expression_error(database, None, None, transform) + finally: + app.config["DISALLOWED_SQL_FUNCTIONS"] = original + + assert reason is not None + assert "schema_to_xml" in reason + + [email protected]("function", ["ts_rewrite", "ts_stat"]) +def test_the_postgres_full_text_query_functions_are_denied_by_default( + function: str, +) -> None: + """ + `ts_stat('select ...')` and `ts_rewrite(q, 'select ...')` run their *text* + argument as a query. The query is a string, so these have the same blind + spot the XML family above does: no FROM clause and no sub-query for a + table-reference gate to reason about, which leaves the denylist as the only + thing that can refuse them. + """ + assert function in DISALLOWED_SQL_FUNCTIONS["postgresql"] + + [email protected]( + "function", + [ + "getxml", + "getxmltype", + "newcontext", + "newcontextfromhierarchy", + # No function argument to inspect at all: the table is named by the + # path string handed to the constructor. + "dburitype", Review Comment: The new `geturi`, `xdburitype` and `httpuritype` denylist entries in `config.py` have no case here; this parametrization stops at `dburitype`. Deleting those three names would leave every test green and reopen the factory path to the same table read. Could this add cases like `URIFACTORY.GETURI('/oradb/HR/EMPLOYEES/ROW[ID=1]/SALARY').getclob() || :value` and assert that `stored_expression_error` names the denied function? -- 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]
