Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
112bc85
[BUGFIX] Narrow single-pass column_values.unique on SQLAlchemy
leodrivera May 12, 2026
73aaac4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 12, 2026
23a7a69
Merge branch 'great-expectations:develop' into bugfix/column-values-u…
leodrivera May 12, 2026
8118c18
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
leodrivera May 22, 2026
dc2c097
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
leodrivera May 28, 2026
c3901b0
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
leodrivera Jun 28, 2026
4098256
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
joshua-stauffer Jul 9, 2026
6af2f53
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
leodrivera Jul 22, 2026
294a33b
Address review: fix result_format gaps in single-pass column_values.u…
leodrivera Jul 22, 2026
587546c
Merge branch 'bugfix/column-values-unique-window-function-redshift-wl…
leodrivera Jul 22, 2026
357a3d0
Merge branch 'develop' of https://github.com/great-expectations/great…
leodrivera Jul 24, 2026
0185ea0
Adopt MapMetricProvider row-retrieval provider hooks from #11998
leodrivera Jul 24, 2026
c593d87
Fix mypy errors flagged by CI static-analysis
leodrivera Jul 24, 2026
3b15662
Add explanatory comments to linter ignores
leodrivera Jul 24, 2026
8f58749
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
leodrivera Aug 5, 2026
d220b2b
Resolve count_per_value.map dependency in temp-table test
leodrivera Aug 5, 2026
ff37717
Prevent the duplicate-count label from being shadowed by a source column
joshua-stauffer Aug 6, 2026
9392a83
Read the batch once per statement on MySQL-family engines
joshua-stauffer Aug 6, 2026
19019dd
Merge branch 'develop' into bugfix/column-values-unique-window-functi…
joshua-stauffer Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
SQLALCHEMY_SELECTABLE_METRICS: Set[str] = {
"compound_columns.count",
"compound_columns.unique",
"column_values.unique",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this breaks the unexpected_index_query in data docs, because we didn't override _sqlalchemy_map_condition_query in the map metric provider to handle the new path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 294a33b — registered a dedicated _sqlalchemy_unique_unexpected_index_query provider for the .unexpected_index_query suffix. It builds the same join-back query as the other row-retrieval paths and renders it with sqlalchemy_select_to_sql_string, so the string surfaced in validation results and Data Docs runs as-is against the source database. Covered by test_unexpected_index_query_is_executable_sql from #11964.

}


Expand Down
16 changes: 16 additions & 0 deletions tests/execution_engine/test_sqlalchemy_execution_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1070,12 +1070,28 @@ def validate_tmp_tables(execution_engine):

validate_tmp_tables(execution_engine=execution_engine)

count_per_value_metric = MetricConfiguration(
metric_name=f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
count_per_value_metric.metric_dependencies = {
"table.columns": table_columns_metric,
}
results = execution_engine.resolve_metrics(
metrics_to_resolve=(count_per_value_metric,), metrics=metrics
)
metrics.update(results)

validate_tmp_tables(execution_engine=execution_engine)

condition_metric = MetricConfiguration(
metric_name=f"column_values.unique.{MetricPartialFunctionTypeSuffixes.CONDITION.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
condition_metric.metric_dependencies = {
f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}": count_per_value_metric, # noqa: E501 # metric name exceeds line length
"table.columns": table_columns_metric,
}
results = execution_engine.resolve_metrics(
Expand Down
183 changes: 183 additions & 0 deletions tests/expectations/metrics/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2628,12 +2628,24 @@ def test_map_unique_column_exists_sa(sa):
table_columns_metric, results = get_table_columns_metric(execution_engine=engine)
metrics.update(results)

count_per_value_metric = MetricConfiguration(
metric_name=f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
count_per_value_metric.metric_dependencies = {
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(count_per_value_metric,), metrics=metrics)
metrics.update(results)

condition_metric = MetricConfiguration(
metric_name=f"column_values.unique.{MetricPartialFunctionTypeSuffixes.CONDITION.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
condition_metric.metric_dependencies = {
f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}": count_per_value_metric, # noqa: E501 # metric name exceeds line length
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(condition_metric,), metrics=metrics)
Expand Down Expand Up @@ -2741,12 +2753,22 @@ def test_map_unique_empty_query_sa(sa):
metrics: dict
table_columns_metric, metrics = get_table_columns_metric(execution_engine=engine)

count_per_value_metric = MetricConfiguration(
metric_name=f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
count_per_value_metric.metric_dependencies = {"table.columns": table_columns_metric}
results = engine.resolve_metrics(metrics_to_resolve=(count_per_value_metric,), metrics=metrics)
metrics.update(results)

condition_metric = MetricConfiguration(
metric_name=f"column_values.unique.{MetricPartialFunctionTypeSuffixes.CONDITION.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
condition_metric.metric_dependencies = {
f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}": count_per_value_metric, # noqa: E501 # metric name exceeds line length
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(condition_metric,), metrics=metrics)
Expand All @@ -2768,6 +2790,167 @@ def test_map_unique_empty_query_sa(sa):
assert results[desired_metric.id] == 0


@pytest.mark.sqlite
def test_map_unique_unexpected_count_sql_shape_sa(sa):
"""Regression test for Redshift WLM "low_timeout" fixes.

The single most common path on SQLAlchemy is "unexpected_count" (BASIC
result_format). The generated SQL must:

* scan the source table exactly once (no "col NOT IN (dup_subquery)"
semi-join pattern, which double-scans on column-store backends),
* carry only the target column through the window operator (the inner
windowed subquery must NOT project arbitrary table columns, because
that materializes every column — including JSON/SUPER fields on
Redshift — through the partition sort and was observed to trip the
WLM "low_timeout" rule even after the double-scan was removed).
"""
engine = build_sa_execution_engine(
pd.DataFrame({"a": [1, 2, 3, 3, None], "b": ["x", "y", "z", "z", "w"]}),
sa,
)

executed_sql: list[str] = []

@sa.event.listens_for(engine.engine, "before_cursor_execute")
def capture_sql(conn, cursor, statement, parameters, context, executemany):
executed_sql.append(statement)

table_columns_metric: MetricConfiguration
metrics: dict
table_columns_metric, metrics = get_table_columns_metric(execution_engine=engine)

count_per_value_metric = MetricConfiguration(
metric_name=f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
count_per_value_metric.metric_dependencies = {"table.columns": table_columns_metric}
results = engine.resolve_metrics(metrics_to_resolve=(count_per_value_metric,), metrics=metrics)
metrics.update(results)

condition_metric = MetricConfiguration(
metric_name=f"column_values.unique.{MetricPartialFunctionTypeSuffixes.CONDITION.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
condition_metric.metric_dependencies = {
f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}": count_per_value_metric, # noqa: E501 # metric name exceeds line length
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(condition_metric,), metrics=metrics)
metrics.update(results)

unexpected_count_metric = MetricConfiguration(
metric_name=f"column_values.unique.{SummarizationMetricNameSuffixes.UNEXPECTED_COUNT.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
unexpected_count_metric.metric_dependencies = {
"unexpected_condition": condition_metric,
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(unexpected_count_metric,), metrics=metrics)
assert results[unexpected_count_metric.id] == 2

combined = " ".join(executed_sql)
combined_upper = combined.upper()

# Single-pass: no semi-join double-scan.
assert "NOT IN" not in combined_upper, (
"Duplicate detection must not rely on a NOT IN (dup_subquery) pattern "
"(double-scans source on column-store DBs like Redshift)."
)

# Window must still be used for the count path (single scan, no GROUP BY
# collapse, framework's SUM(CASE) wrapper relies on one row per source row).
assert "PARTITION BY" in combined_upper, (
f"Expected windowed unique check for unexpected_count, got: {executed_sql}"
)

# Narrow projection: only target column "a" must appear inside the windowed
# subquery. Column "b" must NOT be projected through the window operator —
# that was the wide-row failure mode that kept tripping WLM "low_timeout".
assert ", b," not in combined and ", b " not in combined and ', "b"' not in combined, (
"Inner windowed subquery must project only the target column; carrying "
"extra source columns through the window operator re-introduces the "
"wide-row sort that trips Redshift WLM `low_timeout`. "
f"Got SQL: {executed_sql}"
)


@pytest.mark.sqlite
def test_map_unique_unexpected_rows_join_back_sa(sa):
"""The "unexpected_rows" path must hydrate full source rows by joining a
narrow dup-keys aggregate back to the source — never by widening the
windowed subquery to carry every source column.
"""
engine = build_sa_execution_engine(
pd.DataFrame(
{
"a": [1, 2, 3, 3, None],
"b": ["x", "y", "z1", "z2", "w"],
"c": [10, 20, 30, 31, 40],
}
),
sa,
)

executed_sql: list[str] = []

@sa.event.listens_for(engine.engine, "before_cursor_execute")
def capture_sql(conn, cursor, statement, parameters, context, executemany):
executed_sql.append(statement)

table_columns_metric: MetricConfiguration
metrics: dict
table_columns_metric, metrics = get_table_columns_metric(execution_engine=engine)

count_per_value_metric = MetricConfiguration(
metric_name=f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
count_per_value_metric.metric_dependencies = {"table.columns": table_columns_metric}
results = engine.resolve_metrics(metrics_to_resolve=(count_per_value_metric,), metrics=metrics)
metrics.update(results)

condition_metric = MetricConfiguration(
metric_name=f"column_values.unique.{MetricPartialFunctionTypeSuffixes.CONDITION.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs=None,
)
condition_metric.metric_dependencies = {
f"column_values.count_per_value.{MetricPartialFunctionTypeSuffixes.MAP.value}": count_per_value_metric, # noqa: E501 # metric name exceeds line length
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(condition_metric,), metrics=metrics)
metrics.update(results)

unexpected_rows_metric = MetricConfiguration(
metric_name=f"column_values.unique.{SummarizationMetricNameSuffixes.UNEXPECTED_ROWS.value}",
metric_domain_kwargs={"column": "a"},
metric_value_kwargs={
"result_format": {"result_format": "COMPLETE", "partial_unexpected_count": 20}
},
)
unexpected_rows_metric.metric_dependencies = {
"unexpected_condition": condition_metric,
"table.columns": table_columns_metric,
}
results = engine.resolve_metrics(metrics_to_resolve=(unexpected_rows_metric,), metrics=metrics)

rows = results[unexpected_rows_metric.id]
duplicated_a = sorted(r["a"] for r in rows)
assert duplicated_a == [3, 3]

combined_upper = " ".join(executed_sql).upper()
assert "GROUP BY" in combined_upper and "HAVING" in combined_upper, (
"unexpected_rows must use a narrow GROUP BY/HAVING dup-keys subquery "
f"joined back to source; got: {executed_sql}"
)


@pytest.mark.spark
def test_map_unique_column_exists_spark(spark_session):
engine: SparkDFExecutionEngine = build_spark_engine(
Expand Down
31 changes: 15 additions & 16 deletions tests/expectations/metrics/test_metric_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
SqlAlchemyExecutionEngine,
)
from great_expectations.expectations import registry
from great_expectations.expectations.metrics.column_map_metrics.column_values_unique import (
_sqlalchemy_unique_unexpected_index_list,
_sqlalchemy_unique_unexpected_index_query,
_sqlalchemy_unique_unexpected_rows,
)
from great_expectations.expectations.metrics.map_metric_provider import (
ColumnMapMetricProvider,
ColumnPairMapMetricProvider,
Expand All @@ -27,11 +32,6 @@
from great_expectations.expectations.metrics.map_metric_provider.column_pair_condition_partial import ( # noqa: E501 # FIXME CoP
column_pair_condition_partial,
)
from great_expectations.expectations.metrics.map_metric_provider.map_condition_auxilliary_methods import ( # noqa: E501 # FIXME CoP
_sqlalchemy_map_condition_index,
_sqlalchemy_map_condition_query,
_sqlalchemy_map_condition_rows,
)
from great_expectations.expectations.metrics.map_metric_provider.multicolumn_condition_partial import ( # noqa: E501 # FIXME CoP
multicolumn_condition_partial,
)
Expand Down Expand Up @@ -270,16 +270,15 @@ def _spark(cls, column, **kwargs):
assert index_query_fn is _custom_index_query


def test__column_values_unique__sqlalchemy_row_retrieval_providers_are_generic(mock_registry):
def test__column_values_unique__sqlalchemy_row_retrieval_providers_are_narrow(mock_registry):
"""Regression guard for `column_values.unique`'s SqlAlchemy row-retrieval providers.

`ColumnValuesUnique` does not currently override the `MapMetricProvider` row-retrieval
provider hooks (see `test__map_metric_provider__sqlalchemy_row_retrieval_provider_hooks`), so
it still resolves to the generic full-row builders today. Once it overrides
`sqlalchemy_unexpected_rows_provider` / `sqlalchemy_unexpected_index_list_provider` /
`sqlalchemy_unexpected_index_query_provider` with narrow, single-scan providers, this test
must be updated to assert identity against those custom providers instead -- turning it into
a guard against silently reverting to the generic (wide-row) providers.
`ColumnValuesUnique` overrides the `MapMetricProvider` row-retrieval provider hooks
(`sqlalchemy_unexpected_rows_provider` / `sqlalchemy_unexpected_index_list_provider` /
`sqlalchemy_unexpected_index_query_provider`) with narrow, single-scan providers that
join a dup-keys subquery back to source instead of dragging every table column
through the window sort. This test guards against silently reverting to the generic
(wide-row) providers.
""" # FIXME CoP
_, rows_fn = mock_registry.get_sqlalchemy_metric_provider(
"column_values.unique.unexpected_rows"
Expand All @@ -291,9 +290,9 @@ def test__column_values_unique__sqlalchemy_row_retrieval_providers_are_generic(m
"column_values.unique.unexpected_index_query"
)

assert rows_fn is _sqlalchemy_map_condition_rows
assert index_list_fn is _sqlalchemy_map_condition_index
assert index_query_fn is _sqlalchemy_map_condition_query
assert rows_fn is _sqlalchemy_unique_unexpected_rows
assert index_list_fn is _sqlalchemy_unique_unexpected_index_list
assert index_query_fn is _sqlalchemy_unique_unexpected_index_query


def test__column_pair_map_metric__registration(mock_registry):
Expand Down
Loading
Loading