rusackas commented on code in PR #44657: URL: https://github.com/apache/superset/pull/44657#discussion_r4176191832
########## tests/unit_tests/migrations/test_tag_pivot_tables_with_restored_aggregation.py: ########## @@ -0,0 +1,276 @@ +# 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. +"""Tests for migration +``141b8ada7731_tag_pivot_tables_with_restored_aggregation``. + +Covers the helper (_has_legacy_aggregate_function), the full upgrade() path +across multiple slices (tagging, idempotency, and that unaffected slices are +left alone -- and that query_context is never touched, see the module +docstring for why), and that downgrade() strips the default "Metric" value. +""" + +from __future__ import annotations + +from importlib import import_module +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from superset.tags.models import ObjectType, TagType +from superset.utils import json + +migration = import_module( + "superset.migrations.versions." + "2026-09-25_00-00_141b8ada7731_tag_pivot_tables_with_restored_" +) + +Slice = migration.Slice +Tag = migration.Tag +TaggedObject = migration.TaggedObject +_has_legacy_aggregate_function = migration._has_legacy_aggregate_function +_VIZ_TYPE = migration._VIZ_TYPE +_FIELD = migration._FIELD +_LEGACY_AGGREGATE_FUNCTIONS = migration._LEGACY_AGGREGATE_FUNCTIONS +LEGACY_AGGREGATION_TAG = migration.LEGACY_AGGREGATION_TAG + + [email protected] +def engine(): + engine = create_engine("sqlite:///:memory:") + migration.Base.metadata.create_all(engine) + return engine + + +# --------------------------------------------------------------------------- +# _has_legacy_aggregate_function +# --------------------------------------------------------------------------- + + [email protected]("value", sorted(_LEGACY_AGGREGATE_FUNCTIONS)) +def test_has_legacy_aggregate_function_true_for_every_recognized_value(value): + slc = Slice(params=json.dumps({"viz_type": _VIZ_TYPE, _FIELD: value})) + assert _has_legacy_aggregate_function(slc) + + +def test_has_legacy_aggregate_function_false_for_unrecognized_value(): + params = json.dumps({"viz_type": _VIZ_TYPE, _FIELD: "Metric"}) + assert not _has_legacy_aggregate_function(Slice(params=params)) + + +def test_has_legacy_aggregate_function_false_when_field_absent(): + params = json.dumps({"viz_type": _VIZ_TYPE, "colTotals": True}) + assert not _has_legacy_aggregate_function(Slice(params=params)) + + [email protected]("malformed_value", [[], {}, 1, None]) +def test_has_legacy_aggregate_function_false_when_field_not_a_string( + malformed_value, +): + """A malformed non-string aggregateFunction value (e.g. `[]`/`{}` from an + unrelated historical bug) must be skipped, not raise -- `in` on a + frozenset requires a hashable left operand, so an unguarded membership + check would TypeError on a list/dict and abort `superset db upgrade` + partway through paginated_update's batches.""" + params = json.dumps({"viz_type": _VIZ_TYPE, _FIELD: malformed_value}) + assert not _has_legacy_aggregate_function(Slice(params=params)) + + +def test_has_legacy_aggregate_function_false_when_params_empty(): + assert not _has_legacy_aggregate_function(Slice(params=None)) + + [email protected]("malformed_params", ["[]", "null", "1", '"a string"']) +def test_has_legacy_aggregate_function_false_when_params_not_dict(malformed_params): + """A historically malformed non-dict params value must be skipped, not + raise, so one bad row can't abort `superset db upgrade` partway through + paginated_update's batches.""" + assert not _has_legacy_aggregate_function(Slice(params=malformed_params)) + + +def test_has_legacy_aggregate_function_false_on_invalid_json(): + assert not _has_legacy_aggregate_function(Slice(params="not-json")) + + +# --------------------------------------------------------------------------- +# Full upgrade() integration test +# --------------------------------------------------------------------------- + + +def _run_upgrade(engine) -> None: + # Simulate the Alembic transaction the same way the real migration runs: + # bind the session to an explicit connection so paginated_update's + # internal commits are visible once the outer transaction closes. + with engine.begin() as conn: + upgrade_session = Session(bind=conn) + with ( + patch.object(migration, "op") as mock_op, + patch.object(migration, "db") as mock_db, + ): + mock_op.get_bind.return_value = conn + mock_db.Session.return_value = upgrade_session + migration.upgrade() + + +def test_upgrade_tags_and_invalidates_only_affected_pivot_tables(engine) -> None: + with Session(engine) as seed: + seed.add_all( + [ + # A legacy aggregateFunction value -- must be tagged. + Slice( + id=1, + viz_type=_VIZ_TYPE, + params=json.dumps({"viz_type": _VIZ_TYPE, _FIELD: "Median"}), + ), + # "Metric" (today's default, "Use metric definition") is not a + # legacy value -- was never affected, must be left untouched. + Slice( + id=2, + viz_type=_VIZ_TYPE, + params=json.dumps({"viz_type": _VIZ_TYPE, _FIELD: "Metric"}), + ), + # No aggregateFunction at all -- must be left untouched. + Slice( + id=3, + viz_type=_VIZ_TYPE, + params=json.dumps({"viz_type": _VIZ_TYPE}), + ), + # A different viz type that happens to reuse the same field + # name and a legacy-looking value coincidentally -- must be + # left untouched, since result aggregation only exists for + # pivot_table_v2. + Slice( + id=4, + viz_type="table", + params=json.dumps({"viz_type": "table", _FIELD: "Average"}), + ), + ] + ) + seed.commit() + + _run_upgrade(engine) + + with Session(engine) as verify: + tag = verify.query(Tag).filter_by(name=LEGACY_AGGREGATION_TAG).one() + assert tag.type == TagType.custom + + tagged_object_ids = { + row.object_id + for row in verify.query(TaggedObject).filter_by( + tag_id=tag.id, object_type=ObjectType.chart + ) + } + assert tagged_object_ids == {1} + + +def test_upgrade_is_idempotent_across_repeated_runs(engine) -> None: + """A re-run (or a slice matching an already-created tag) must not violate + the (tag_id, object_id, object_type) unique constraint, and must not + create a second Tag row for the same name.""" + with Session(engine) as seed: + seed.add( + Slice( + id=1, + viz_type=_VIZ_TYPE, + params=json.dumps({"viz_type": _VIZ_TYPE, _FIELD: "Sum"}), + ) + ) + seed.commit() + + _run_upgrade(engine) + _run_upgrade(engine) + + with Session(engine) as verify: + tags = verify.query(Tag).filter_by(name=LEGACY_AGGREGATION_TAG).all() + assert len(tags) == 1 + + tagged = ( + verify.query(TaggedObject) + .filter_by(tag_id=tags[0].id, object_id=1, object_type=ObjectType.chart) + .all() + ) + assert len(tagged) == 1 + + +def test_upgrade_populates_tag_audit_timestamps(engine) -> None: + """The tag API subtracts created_on/changed_on from now(); NULLs break it.""" + with Session(engine) as seed: + seed.add( + Slice( + id=1, + viz_type=_VIZ_TYPE, + params=json.dumps({"viz_type": _VIZ_TYPE, _FIELD: "Median"}), + ) + ) + seed.commit() + + _run_upgrade(engine) + + with Session(engine) as verify: + tag = verify.query(Tag).filter_by(name=LEGACY_AGGREGATION_TAG).one() + assert tag.created_on is not None + assert tag.changed_on is not None + tagged = verify.query(TaggedObject).one() + assert tagged.created_on is not None + assert tagged.changed_on is not None + + +def test_downgrade_strips_default_metric_from_params_and_query_context( Review Comment: Added the annotations in 108f0f5. ########## superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx: ########## @@ -173,7 +177,10 @@ const config: ControlPanelConfig = { name: 'rowTotals', config: { type: 'CheckboxControl', - label: t('Show rows total'), + // The displayed value may be a result aggregation (Median, + // Average, ...) rather than a plain total once `aggregateFunction` + // is set below, so "summary" rather than "total". + label: () => t('Show row summaries'), Review Comment: Switched to the static label in 108f0f5. ########## superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx: ########## @@ -197,7 +204,7 @@ const config: ControlPanelConfig = { name: 'colTotals', config: { type: 'CheckboxControl', - label: t('Show columns total'), + label: () => t('Show column summaries'), Review Comment: Switched to the static label in 108f0f5. ########## superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx: ########## @@ -243,6 +250,34 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'aggregateFunction', + config: { + type: 'SelectControl', + label: () => t('Aggregation function'), Review Comment: Switched to the static label in 108f0f5. ########## superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx: ########## @@ -243,6 +250,34 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'aggregateFunction', + config: { + type: 'SelectControl', + label: () => t('Aggregation function'), + default: 'Metric', Review Comment: Pulled it into an exported `METRIC_DEFINITION_AGGREGATION` constant in 108f0f5. -- 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]
