mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Datasets on Hadoop-family engines are often 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, and today the only workaround is hand-writing the predicate as custom SQL in a virtual dataset. This is the first of four PRs making that a dataset setting. It adds the storage and the save-time validation; nothing reads the mapping yet. Four columns, following the `always_filter_main_dttm` / `currency_code_column` precedent for "a dataset-level setting that names a column": tables.partition_column the physical partition column tables.partition_mapped_column override; NULL follows main_dttm_col table_columns.partition_value_transform the `:value` expression table_columns.partition_transform_is_monotonic gates range mirroring The monotonic flag is NOT NULL DEFAULT false rather than a nullable tri-state, matching `normalize_columns` -- a nullable boolean invites `if x:` bugs where None and False need distinguishing and don't get it. Validation runs in two tiers. Structural and safety errors block the save: unknown columns, a column mapped onto itself, Jinja in the transform, and non-deterministic functions. Everything else -- an unparseable transform, a transform missing `:value` -- saves and leaves the mapping inactive, so a half-written transform doesn't cost the owner the rest of their edits. Note the self-mapping check validates the *effective* mapped column. Checking only the explicit override misses the case an owner actually hits: pointing `partition_column` at the column that is already `main_dttm_col`. `SQLStatement.get_niladic_functions` is added because the denylist cannot be purely name-based: on Hive and Impala `unix_timestamp()` means "now" while `unix_timestamp(x)` -- the canonical transform for this feature -- is pure. Gated behind the `PARTITION_FILTER_MAPPING` feature flag, off by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
8.5 KiB
Python
246 lines
8.5 KiB
Python
# 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.
|
|
"""
|
|
The partition mapping has to survive every layer it passes through.
|
|
|
|
``always_filter_main_dttm`` is the template these follow: a dataset-level
|
|
setting that names a column and appears in the ORM, the export fields, the
|
|
``data`` payload, the API schemas and the frontend types. A field missing from
|
|
any one of them is dropped silently, which is exactly the failure mode these
|
|
tests exist to catch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
|
from superset.datasets.schemas import (
|
|
DatasetColumnsPutSchema,
|
|
DatasetPutSchema,
|
|
ImportV1ColumnSchema,
|
|
ImportV1DatasetSchema,
|
|
)
|
|
from superset.models.core import Database
|
|
|
|
DATASET_FIELDS = ["partition_column", "partition_mapped_column"]
|
|
COLUMN_FIELDS = ["partition_value_transform", "partition_transform_is_monotonic"]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def enable_partition_filter_mapping(app: Flask) -> Any:
|
|
app.config["DEFAULT_FEATURE_FLAGS"]["PARTITION_FILTER_MAPPING"] = True
|
|
yield
|
|
del app.config["DEFAULT_FEATURE_FLAGS"]["PARTITION_FILTER_MAPPING"]
|
|
|
|
|
|
def _table() -> SqlaTable:
|
|
database = Database(database_name="test_db", sqlalchemy_uri="sqlite://")
|
|
column = TableColumn(column_name="event_time", is_dttm=True, type="TIMESTAMP")
|
|
column.partition_value_transform = "unix_timestamp(:value)"
|
|
column.partition_transform_is_monotonic = True
|
|
table = SqlaTable(
|
|
table_name="web_events",
|
|
database=database,
|
|
main_dttm_col="event_time",
|
|
columns=[column, TableColumn(column_name="dt_epoch", type="BIGINT")],
|
|
)
|
|
table.partition_column = "dt_epoch"
|
|
table.partition_mapped_column = None
|
|
return table
|
|
|
|
|
|
@pytest.mark.parametrize("field", DATASET_FIELDS)
|
|
def test_dataset_fields_are_exported(field: str) -> None:
|
|
"""
|
|
Being in ``export_fields`` is what makes the mapping travel in dataset YAML,
|
|
and what makes ``update_from_object`` write it back on import.
|
|
"""
|
|
assert field in SqlaTable.export_fields
|
|
|
|
|
|
@pytest.mark.parametrize("field", COLUMN_FIELDS)
|
|
def test_column_fields_are_exported(field: str) -> None:
|
|
assert field in TableColumn.export_fields
|
|
|
|
|
|
@pytest.mark.parametrize("field", DATASET_FIELDS)
|
|
def test_dataset_fields_reach_the_explore_payload(app: Flask, field: str) -> None:
|
|
with app.app_context():
|
|
data = _table().data
|
|
assert field in data
|
|
|
|
|
|
@pytest.mark.parametrize("field", COLUMN_FIELDS)
|
|
def test_column_fields_reach_the_explore_payload(app: Flask, field: str) -> None:
|
|
with app.app_context():
|
|
data = _table().data
|
|
assert field in data["columns"][0]
|
|
|
|
|
|
def test_the_mapping_summary_survives_dashboard_payload_pruning(app: Flask) -> None:
|
|
"""
|
|
``data_for_slices`` prunes columns no chart references, and the partition
|
|
column is typically referenced by none of them. The Explore indicator
|
|
therefore reads a self-contained dataset-level dict rather than looking the
|
|
column up inside ``datasource.columns``.
|
|
"""
|
|
with app.app_context():
|
|
data = _table().data_for_slices([])
|
|
|
|
assert data["partition_filter_mapping"] == {
|
|
"partition_column": "dt_epoch",
|
|
"mapped_column": "event_time",
|
|
"active": True,
|
|
}
|
|
|
|
|
|
def test_the_mapping_summary_reports_inactive_without_a_transform(
|
|
app: Flask,
|
|
) -> None:
|
|
table = _table()
|
|
table.columns[0].partition_value_transform = None
|
|
|
|
with app.app_context():
|
|
assert table.data["partition_filter_mapping"]["active"] is False
|
|
|
|
|
|
def test_there_is_no_mapping_summary_without_a_partition_column(
|
|
app: Flask,
|
|
) -> None:
|
|
table = _table()
|
|
table.partition_column = None
|
|
|
|
with app.app_context():
|
|
assert table.data["partition_filter_mapping"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("field", DATASET_FIELDS)
|
|
def test_put_schema_accepts_the_dataset_fields(field: str) -> None:
|
|
loaded = DatasetPutSchema().load({field: "dt_epoch"})
|
|
assert loaded[field] == "dt_epoch"
|
|
|
|
|
|
def test_put_schema_accepts_the_column_fields() -> None:
|
|
loaded = DatasetColumnsPutSchema().load(
|
|
{
|
|
"column_name": "event_time",
|
|
"partition_value_transform": "unix_timestamp(:value)",
|
|
"partition_transform_is_monotonic": True,
|
|
}
|
|
)
|
|
assert loaded["partition_value_transform"] == "unix_timestamp(:value)"
|
|
assert loaded["partition_transform_is_monotonic"] is True
|
|
|
|
|
|
def test_put_schema_allows_clearing_the_mapping() -> None:
|
|
"""Removing a mapping is a null, not an omission."""
|
|
loaded = DatasetPutSchema().load({"partition_column": None})
|
|
assert loaded["partition_column"] is None
|
|
|
|
|
|
def test_import_schema_round_trips_the_mapping() -> None:
|
|
loaded = ImportV1DatasetSchema().load(
|
|
{
|
|
"table_name": "web_events",
|
|
"uuid": "00000000-0000-0000-0000-000000000001",
|
|
"database_uuid": "00000000-0000-0000-0000-000000000002",
|
|
"version": "1.0.0",
|
|
"partition_column": "dt_epoch",
|
|
"partition_mapped_column": "event_time",
|
|
}
|
|
)
|
|
assert loaded["partition_column"] == "dt_epoch"
|
|
assert loaded["partition_mapped_column"] == "event_time"
|
|
|
|
|
|
def test_import_column_schema_round_trips_the_transform() -> None:
|
|
loaded = ImportV1ColumnSchema().load(
|
|
{
|
|
"column_name": "event_time",
|
|
"partition_value_transform": "unix_timestamp(:value)",
|
|
"partition_transform_is_monotonic": True,
|
|
}
|
|
)
|
|
assert loaded["partition_value_transform"] == "unix_timestamp(:value)"
|
|
assert loaded["partition_transform_is_monotonic"] is True
|
|
|
|
|
|
def test_import_column_schema_defaults_the_monotonic_flag_to_false() -> None:
|
|
"""
|
|
The flag gates range mirroring. A dataset imported from a bundle that
|
|
predates the field must not silently claim its transform preserves ordering.
|
|
"""
|
|
loaded = ImportV1ColumnSchema().load({"column_name": "event_time"})
|
|
assert loaded["partition_transform_is_monotonic"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("field", DATASET_FIELDS)
|
|
def test_the_api_exposes_and_accepts_the_dataset_fields(field: str) -> None:
|
|
from superset.datasets.api import DatasetRestApi
|
|
|
|
assert field in DatasetRestApi.show_select_columns
|
|
assert field in DatasetRestApi.edit_columns
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §10 — a column sync can pull the partition column out from under the mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_sync_that_removes_the_partition_column_clears_the_mapping() -> None:
|
|
"""
|
|
An API-driven ``override_columns=true`` sync must not leave a dangling
|
|
mapping. This is the authoritative path -- the client-side sync clears the
|
|
mapping too, but a caller can bypass the editor entirely.
|
|
"""
|
|
from superset.daos.dataset import DatasetDAO
|
|
|
|
table = _table()
|
|
DatasetDAO.clear_dangling_partition_mapping(table, {"event_time"})
|
|
|
|
assert table.partition_column is None
|
|
assert table.partition_mapped_column is None
|
|
|
|
|
|
def test_a_sync_that_removes_the_mapped_column_clears_only_the_override() -> None:
|
|
"""
|
|
The partition column is still real, so the designation survives; the mapping
|
|
falls back to "no mapped column" and goes inactive until one is chosen.
|
|
"""
|
|
from superset.daos.dataset import DatasetDAO
|
|
|
|
table = _table()
|
|
table.partition_mapped_column = "event_time"
|
|
|
|
DatasetDAO.clear_dangling_partition_mapping(table, {"dt_epoch"})
|
|
|
|
assert table.partition_column == "dt_epoch"
|
|
assert table.partition_mapped_column is None
|
|
|
|
|
|
def test_a_sync_that_keeps_both_columns_leaves_the_mapping_alone() -> None:
|
|
from superset.daos.dataset import DatasetDAO
|
|
|
|
table = _table()
|
|
DatasetDAO.clear_dangling_partition_mapping(table, {"event_time", "dt_epoch"})
|
|
|
|
assert table.partition_column == "dt_epoch"
|