Repository: great-expectations/great_expectations

Issue:
Use cleaner solution for non-truncating division in python 2
Prefer `from __future__ import division` to `1.*x/y`


Discussion:
Done inline during improve_partitions push.

Resolving patch:
diff --git a/great_expectations/expectations/metrics/map_metric_provider.py b/great_expectations/expectations/metrics/map_metric_provider.py
--- a/great_expectations/expectations/metrics/map_metric_provider.py
+++ b/great_expectations/expectations/metrics/map_metric_provider.py
@@ -1,5 +1,4 @@
 import logging
-import uuid
 from functools import wraps
 from typing import Any, Callable, Dict, List, Optional, Type, Union
 
@@ -22,11 +21,13 @@
 from great_expectations.execution_engine.sqlalchemy_execution_engine import (
     OperationalError,
 )
+from great_expectations.expectations.metrics import MetaMetricProvider
 from great_expectations.expectations.metrics.import_manager import F, Window, sa
 from great_expectations.expectations.metrics.metric_provider import (
     MetricProvider,
     metric_partial,
 )
+from great_expectations.expectations.metrics.util import Engine, Insert, Label, Select
 from great_expectations.expectations.registry import (
     get_metric_provider,
     register_metric,
@@ -389,7 +390,7 @@ def inner_func(
                         message=f'Error: The column "{column_name}" in BatchData does not exist.'
                     )
 
-                sqlalchemy_engine: sa.engine.Engine = execution_engine.engine
+                sqlalchemy_engine: Engine = execution_engine.engine
 
                 dialect = execution_engine.dialect_module
                 expected_condition = metric_fn(
@@ -843,7 +844,7 @@ def inner_func(
                             message=f'Error: The column "{column_name}" in BatchData does not exist.'
                         )
 
-                sqlalchemy_engine: sa.engine.Engine = execution_engine.engine
+                sqlalchemy_engine: Engine = execution_engine.engine
 
                 dialect = execution_engine.dialect_module
                 expected_condition = metric_fn(
@@ -1044,12 +1045,16 @@ def inner_func(
 
                 column_list = accessor_domain_kwargs["column_list"]
 
+                table_columns = metrics["table.columns"]
+
                 for column_name in column_list:
-                    if column_name not in metrics["table.columns"]:
+                    if column_name not in table_columns:
                         raise ge_exceptions.InvalidMetricAccessorDomainKwargsKeyError(
                             message=f'Error: The column "{column_name}" in BatchData does not exist.'
                         )
 
+                sqlalchemy_engine: Engine = execution_engine.engine
+
                 column_selector = [
                     sa.column(column_name) for column_name in column_list
                 ]
@@ -1058,8 +1063,11 @@ def inner_func(
                     cls,
                     column_selector,
                     **metric_value_kwargs,
+                    _column_names=column_list,
+                    _table_columns=table_columns,
                     _dialect=dialect,
                     _table=selectable,
+                    _sqlalchemy_engine=sqlalchemy_engine,
                     _metrics=metrics,
                 )
                 return (
@@ -1256,7 +1264,7 @@ def inner_func(
                             message=f'Error: The column "{column_name}" in BatchData does not exist.'
                         )
 
-                sqlalchemy_engine: sa.engine.Engine = execution_engine.engine
+                sqlalchemy_engine: Engine = execution_engine.engine
 
                 column_selector = [
                     sa.column(column_name) for column_name in column_list
@@ -1921,17 +1929,21 @@ def _sqlalchemy_map_condition_unexpected_count_value(
         domain_kwargs=domain_kwargs,
     )
 
-    count_case_statement: List[sa.sql.elements.Label] = [
-        sa.case(
-            [
-                (
-                    unexpected_condition,
-                    sa.sql.expression.cast(1, sa.Numeric),
-                )
-            ],
-            else_=0,
-        ).label("condition")
-    ]
+    # The integral values are cast to SQL Numeric in order to avoid a bug in AWS Redshift (converted to integer later).
+    count_case_statement: List[Label] = sa.case(
+        [
+            (
+                unexpected_condition,
+                sa.sql.expression.cast(1, sa.Numeric),
+            )
+        ],
+        else_=sa.sql.expression.cast(0, sa.Numeric),
+    ).label("condition")
+
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        count_selectable: Select = sa.select([count_case_statement])
+    else:
+        count_selectable = sa.select([count_case_statement]).select_from(selectable)
 
     try:
         if execution_engine.engine.dialect.name.lower() == "mssql":
@@ -1950,35 +1962,32 @@ def _sqlalchemy_map_condition_unexpected_count_value(
                 )
                 temp_table_obj.create(execution_engine.engine, checkfirst=True)
 
-                inner_case_query: sa.sql.dml.Insert = (
-                    temp_table_obj.insert().from_select(
-                        count_case_statement,
-                        sa.select(count_case_statement).select_from(selectable),
-                    )
+                inner_case_query: Insert = temp_table_obj.insert().from_select(
+                    [count_case_statement],
+                    count_selectable,
                 )
                 execution_engine.engine.execute(inner_case_query)
 
-                selectable_count = temp_table_obj
-        else:
-            selectable_count = sa.select(count_case_statement).select_from(selectable)
+                count_selectable = temp_table_obj
 
-        unexpected_count_query: sa.Select = (
+        unexpected_count_query: Select = (
             sa.select(
                 [
                     sa.func.sum(sa.column("condition")).label("unexpected_count"),
                 ]
             )
-            .select_from(selectable_count)
+            .select_from(count_selectable)
             .alias("UnexpectedCountSubquery")
         )
 
-        unexpected_count = execution_engine.engine.execute(
+        unexpected_count: Union[float, int] = execution_engine.engine.execute(
             sa.select(
                 [
                     unexpected_count_query.c.unexpected_count,
                 ]
             )
         ).scalar()
+        unexpected_count = int(unexpected_count)
     except OperationalError as oe:
         exception_message: str = f"An SQL execution Exception occurred: {str(oe)}."
         raise ge_exceptions.InvalidMetricAccessorDomainKwargsKeyError(
@@ -2021,11 +2030,16 @@ def _sqlalchemy_column_map_condition_values(
             message=f'Error: The column "{column_name}" in BatchData does not exist.'
         )
 
-    query = (
-        sa.select([sa.column(column_name).label("unexpected_values")])
-        .select_from(selectable)
-        .where(unexpected_condition)
-    )
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        query = sa.select([sa.column(column_name).label("unexpected_values")]).where(
+            unexpected_condition
+        )
+    else:
+        query = (
+            sa.select([sa.column(column_name).label("unexpected_values")])
+            .where(unexpected_condition)
+            .select_from(selectable)
+        )
 
     result_format = metric_value_kwargs["result_format"]
     if result_format["result_format"] != "COMPLETE":
@@ -2073,14 +2087,20 @@ def _sqlalchemy_column_pair_map_condition_values(
                 message=f'Error: The column "{column_name}" in BatchData does not exist.'
             )
 
-    query = (
-        sa.select(
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        query = sa.select(
             sa.column(column_A_name).label("unexpected_values_A"),
             sa.column(column_B_name).label("unexpected_values_B"),
+        ).where(boolean_mapped_unexpected_values)
+    else:
+        query = (
+            sa.select(
+                sa.column(column_A_name).label("unexpected_values_A"),
+                sa.column(column_B_name).label("unexpected_values_B"),
+            )
+            .where(boolean_mapped_unexpected_values)
+            .select_from(selectable)
         )
-        .select_from(selectable)
-        .where(boolean_mapped_unexpected_values)
-    )
 
     result_format = metric_value_kwargs["result_format"]
     if result_format["result_format"] != "COMPLETE":
@@ -2169,11 +2189,15 @@ def _sqlalchemy_multicolumn_map_condition_values(
             )
 
     column_selector = [sa.column(column_name) for column_name in column_list]
-    query = (
-        sa.select(column_selector)
-        .select_from(selectable)
-        .where(boolean_mapped_unexpected_values)
-    )
+
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        query = sa.select(column_selector).where(boolean_mapped_unexpected_values)
+    else:
+        query = (
+            sa.select(column_selector)
+            .where(boolean_mapped_unexpected_values)
+            .select_from(selectable)
+        )
 
     result_format = metric_value_kwargs["result_format"]
     if result_format["result_format"] != "COMPLETE":
@@ -2256,12 +2280,21 @@ def _sqlalchemy_column_map_condition_value_counts(
 
     column: sa.Column = sa.column(column_name)
 
-    return execution_engine.engine.execute(
-        sa.select([column, sa.func.count(column)])
-        .select_from(selectable)
-        .where(unexpected_condition)
-        .group_by(column)
-    ).fetchall()
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        query = (
+            sa.select([column, sa.func.count(column)])
+            .where(unexpected_condition)
+            .group_by(column)
+        )
+    else:
+        query = (
+            sa.select([column, sa.func.count(column)])
+            .where(unexpected_condition)
+            .group_by(column)
+            .select_from(selectable)
+        )
+
+    return execution_engine.engine.execute(query).fetchall()
 
 
 def _sqlalchemy_map_condition_rows(
@@ -2288,9 +2321,17 @@ def _sqlalchemy_map_condition_rows(
         domain_kwargs=domain_kwargs,
     )
 
-    query = (
-        sa.select([sa.text("*")]).select_from(selectable).where(unexpected_condition)
-    )
+    table_columns = metrics.get("table.columns")
+    column_selector = [sa.column(column_name) for column_name in table_columns]
+    if MapMetricProvider.is_sqlalchemy_metric_selectable(map_metric_provider=cls):
+        query = sa.select(column_selector).where(unexpected_condition)
+    else:
+        query = (
+            sa.select(column_selector)
+            .where(unexpected_condition)
+            .select_from(selectable)
+        )
+
     result_format = metric_value_kwargs["result_format"]
     if result_format["result_format"] != "COMPLETE":
         query = query.limit(result_format["partial_unexpected_count"])
@@ -2675,6 +2716,11 @@ class MapMetricProvider(MetricProvider):
     function_value_keys = tuple()
     filter_column_isnull = True
 
+    SQLALCHEMY_SELECTABLE_METRICS = {
+        "compound_columns.count",
+        "compound_columns.unique",
+    }
+
     @classmethod
     def _register_metric_functions(cls):
         if not hasattr(cls, "function_metric_name") and not hasattr(
@@ -2704,6 +2750,7 @@ def _register_metric_functions(cls):
                     )
 
                 condition_provider = candidate_metric_fn
+                # noinspection PyUnresolvedReferences
                 metric_name = cls.condition_metric_name
                 metric_domain_keys = cls.condition_domain_keys
                 metric_value_keys = cls.condition_value_keys
@@ -3059,6 +3106,7 @@ def _register_metric_functions(cls):
                         "A MapMetricProvider must have a function_metric_name to have a decorated column_function_partial method."
                     )
                 map_function_provider = candidate_metric_fn
+                # noinspection PyUnresolvedReferences
                 metric_name = cls.function_metric_name
                 metric_domain_keys = cls.function_domain_keys
                 metric_value_keys = cls.function_value_keys
@@ -3141,6 +3189,27 @@ def _get_evaluation_dependencies(
 
         return dependencies
 
+    @staticmethod
+    def is_sqlalchemy_metric_selectable(
+        map_metric_provider: MetaMetricProvider,
+    ) -> bool:
+        """
+        :param map_metric_provider: object of type "MapMetricProvider", whose SQLAlchemy implementation is inspected
+        :return: boolean indicating whether or not the returned value of a method implementing the metric resolves all
+        columns -- hence the caller must not use "select_from" clause as part of its own SQLAlchemy query; otherwise an
+        unwanted selectable (e.g., table) will be added to "FROM", leading to duplicated and/or erroneous results.
+        """
+        # noinspection PyUnresolvedReferences
+        return (
+            hasattr(map_metric_provider, "condition_metric_name")
+            and map_metric_provider.condition_metric_name
+            in MapMetricProvider.SQLALCHEMY_SELECTABLE_METRICS
+        ) or (
+            hasattr(map_metric_provider, "function_metric_name")
+            and map_metric_provider.function_metric_name
+            in MapMetricProvider.SQLALCHEMY_SELECTABLE_METRICS
+        )
+
 
 class ColumnMapMetricProvider(MapMetricProvider):
     condition_domain_keys = (
diff --git a/great_expectations/expectations/metrics/multicolumn_map_metrics/compound_columns_unique.py b/great_expectations/expectations/metrics/multicolumn_map_metrics/compound_columns_unique.py
--- a/great_expectations/expectations/metrics/multicolumn_map_metrics/compound_columns_unique.py
+++ b/great_expectations/expectations/metrics/multicolumn_map_metrics/compound_columns_unique.py
@@ -1,4 +1,8 @@
+from typing import Optional
+
+from great_expectations.core import ExpectationConfiguration
 from great_expectations.execution_engine import (
+    ExecutionEngine,
     PandasExecutionEngine,
     SparkDFExecutionEngine,
     SqlAlchemyExecutionEngine,
@@ -7,11 +11,21 @@
 from great_expectations.expectations.metrics.map_metric_provider import (
     MulticolumnMapMetricProvider,
     multicolumn_condition_partial,
+    multicolumn_function_partial,
 )
+from great_expectations.validator.validation_graph import MetricConfiguration
 
 
 class CompoundColumnsUnique(MulticolumnMapMetricProvider):
-    condition_metric_name = "compound_columns.unique"
+    """
+    While the support for "PandasExecutionEngine" and "SparkDFExecutionEngine" is accomplished using a compact
+    implementation, which combines the "map" and "condition" parts in a single step, the support for
+    "SqlAlchemyExecutionEngine" is more detailed.  Thus, the "map" and "condition" parts for "SqlAlchemyExecutionEngine"
+    are handled separately, with the "condition" part relying on the "map" part as a metric dependency.
+    """
+
+    function_metric_name = "compound_columns.count"  # pre-requisite "map" style metric
+    condition_metric_name = "compound_columns.unique"  # "condition" style metric required to be implemented by provider
     condition_domain_keys = (
         "batch_id",
         "table",
@@ -21,12 +35,110 @@ class CompoundColumnsUnique(MulticolumnMapMetricProvider):
         "ignore_row_if",
     )
 
-    # TODO: <Alex>ALEX -- only Pandas and Spark implementations are provided (SQLAlchemy to follow).</Alex>
     @multicolumn_condition_partial(engine=PandasExecutionEngine)
     def _pandas(cls, column_list, **kwargs):
         row_wise_cond = ~column_list.duplicated(keep=False)
         return row_wise_cond
 
+    @multicolumn_function_partial(engine=SqlAlchemyExecutionEngine)
+    def _sqlalchemy_function(self, column_list, **kwargs):
+        """
+        Computes the "map" between the specified "column_list" (treated as a group so as to model the "compound" aspect)
+        and the number of occurrences of every permutation of the values of "column_list" as the grouped subset of all
+        rows of the table.  In the present context, the term "compound" refers to having to treat the specified columns
+        as unique together (e.g., as a multi-column primary key).  For example, suppose that in the example below, all
+        three columns ("A", "B", and "C") of the table are included as part of the "compound" columns list (i.e.,
+        column_list = ["A", "B", "C"]):
+
+            A B C _num_rows
+            1 1 2 2
+            1 2 3 1
+            1 1 2 2
+            2 2 2 1
+            3 2 3 1
+
+        The fourth column, "_num_rows", holds the value of the "map" function -- the number of rows the group occurs in.
+        """
+
+        # Needed as keys (hence, string valued) to access "ColumnElement" objects contained within the "FROM" clauses.
+        column_names = kwargs.get("_column_names")
+
+        # Need all columns of the table for the purposes of reporting entire rows satisfying unexpected condition logic.
+        table_columns = kwargs.get("_table_columns")
+
+        table = kwargs.get("_table")
+
+        # First, obtain the SQLAlchemy "FromClause" version of the original "table" for the purposes of gaining the
+        # "FromClause.c" attribute, which is a namespace of all the columns contained within the "FROM" clause (these
+        # elements are themselves subclasses of the SQLAlchemy "ColumnElement" class).
+        table_columns_selector = [
+            sa.column(column_name) for column_name in table_columns
+        ]
+        original_table_query = (
+            sa.select(table_columns_selector).select_from(table).alias("original_table")
+        )
+
+        # Second, "SELECT FROM" the original table, represented by the "FromClause" object, querying all columns of the
+        # table and the count of occurrences of distinct "compound" (i.e., group, as specified by "column_list") values.
+        # Give this aggregated group count a distinctive label.
+        # Give the resulting sub-query a unique alias in order to disambiguate column names in subsequent queries.
+        count_selector = column_list + [sa.func.count().label("_num_rows")]
+        group_count_query = (
+            sa.select(count_selector)
+            .group_by(*column_list)
+            .select_from(original_table_query)
+            .alias("group_counts")
+        )
+
+        # The above "group_count_query", if executed, will produce the result set containing the number of rows that
+        # equals the number of distinct values of the group -- unique grouping (e.g., as in a multi-column primary key).
+        # Hence, in order for the "_num_rows" column values to provide an entry for each row of the original table, the
+        # "SELECT FROM" of "group_count_query" must undergo an "INNER JOIN" operation with the "original_table_query"
+        # object, whereby all table columns in the two "FromClause" objects must match, respectively, as the conditions.
+        conditions = sa.and_(
+            *(
+                group_count_query.c[name] == original_table_query.c[name]
+                for name in column_names
+            )
+        )
+        # noinspection PyProtectedMember
+        compound_columns_count_query = (
+            sa.select(
+                [original_table_query, group_count_query.c._num_rows.label("_num_rows")]
+            )
+            .select_from(
+                original_table_query.join(
+                    right=group_count_query, onclause=conditions, isouter=False
+                )
+            )
+            .alias("records_with_grouped_column_counts")
+        )
+
+        # The returned SQLAlchemy "FromClause" "compound_columns_count_query" object realizes the "map" metric function.
+        return compound_columns_count_query
+
+    @multicolumn_condition_partial(engine=SqlAlchemyExecutionEngine)
+    def _sqlalchemy_condition(cls, column_list, **kwargs):
+        """
+        Retrieve the specified "map" metric dependency value as the "FromClause" "compound_columns_count_query" object
+        and extract from it -- using the supported SQLAlchemy column access method -- the "_num_rows" columns.  The
+        uniqueness of "compound" columns (as a group) is expressed by the "BinaryExpression" "row_wise_cond" returned.
+
+        Importantly, since the "compound_columns_count_query" is the "FromClause" object that incorporates all columns
+        of the original table, no additional "FromClause" objects ("select_from") must augment this "condition" metric.
+        Other than boolean operations, column access, argument of filtering, and limiting the size of the result set,
+        this "row_wise_cond", serving as the main component of the unexpected condition logic, carries along with it
+        the entire object hierarchy, making any encapsulating query ready for execution against the database engine.
+        """
+
+        metrics = kwargs.get("_metrics")
+        compound_columns_count_query, _, _ = metrics["compound_columns.count.map"]
+
+        # noinspection PyProtectedMember
+        row_wise_cond = compound_columns_count_query.c._num_rows < 2
+
+        return row_wise_cond
+
     @multicolumn_condition_partial(engine=SparkDFExecutionEngine)
     def _spark(cls, column_list, **kwargs):
         column_names = column_list.columns
@@ -34,3 +146,34 @@ def _spark(cls, column_list, **kwargs):
             F.count(F.lit(1)).over(Window.partitionBy(F.struct(*column_names))) <= 1
         )
         return row_wise_cond
+
+    @classmethod
+    def _get_evaluation_dependencies(
+        cls,
+        metric: MetricConfiguration,
+        configuration: Optional[ExpectationConfiguration] = None,
+        execution_engine: Optional[ExecutionEngine] = None,
+        runtime_configuration: Optional[dict] = None,
+    ):
+        """
+        Returns a dictionary of given metric names and their corresponding configuration, specifying the metric types
+        and their respective domains.
+        """
+
+        dependencies: dict = super()._get_evaluation_dependencies(
+            metric=metric,
+            configuration=configuration,
+            execution_engine=execution_engine,
+            runtime_configuration=runtime_configuration,
+        )
+
+        if isinstance(execution_engine, SqlAlchemyExecutionEngine):
+            if metric.metric_name == "compound_columns.unique.condition":
+                dependencies["compound_columns.count.map"] = MetricConfiguration(
+                    metric_name="compound_columns.count.map",
+                    metric_domain_kwargs=metric.metric_domain_kwargs,
+                    metric_value_kwargs=None,
+                    metric_dependencies=None,
+                )
+
+        return dependencies
diff --git a/great_expectations/expectations/metrics/util.py b/great_expectations/expectations/metrics/util.py
--- a/great_expectations/expectations/metrics/util.py
+++ b/great_expectations/expectations/metrics/util.py
@@ -1,5 +1,5 @@
 import logging
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Set
 
 import numpy as np
 from dateutil.parser import parse
@@ -23,20 +23,29 @@
     from sqlalchemy.engine import Engine, reflection
     from sqlalchemy.engine.interfaces import Dialect
     from sqlalchemy.exc import OperationalError
-    from sqlalchemy.sql import Select
-    from sqlalchemy.sql.elements import BinaryExpression, TextClause, literal
+    from sqlalchemy.sql import Insert, Select
+    from sqlalchemy.sql.elements import (
+        BinaryExpression,
+        ColumnElement,
+        Label,
+        TextClause,
+        literal,
+    )
     from sqlalchemy.sql.operators import custom_op
 except ImportError:
     sa = None
     registry = None
+    Engine = None
+    reflection = None
+    Dialect = None
+    Insert = None
     Select = None
     BinaryExpression = None
+    ColumnElement = None
+    Label = None
     TextClause = None
     literal = None
     custom_op = None
-    Engine = None
-    reflection = None
-    Dialect = None
     OperationalError = None
 
 try:
@@ -78,6 +87,7 @@
 except ImportError:
     bigquery_types_tuple = None
     pybigquery = None
+    namedtuple = None
 
 
 def get_dialect_regex_expression(column, regex, dialect, positive=True):
@@ -93,6 +103,7 @@ def get_dialect_regex_expression(column, regex, dialect, positive=True):
 
     try:
         # redshift
+        # noinspection PyUnresolvedReferences
         if issubclass(dialect.dialect, sqlalchemy_redshift.dialect.RedshiftDialect):
             if positive:
                 return BinaryExpression(column, literal(regex), custom_op("~"))
@@ -158,6 +169,7 @@ def _get_dialect_type_module(dialect=None):
         return sa
     try:
         # Redshift does not (yet) export types to top level; only recognize base SA types
+        # noinspection PyUnresolvedReferences
         if isinstance(dialect, sqlalchemy_redshift.dialect.RedshiftDialect):
             return dialect.sa
     except (TypeError, AttributeError):
@@ -180,6 +192,7 @@ def _get_dialect_type_module(dialect=None):
 
 
 def attempt_allowing_relative_error(dialect):
+    # noinspection PyUnresolvedReferences
     detected_redshift: bool = (
         sqlalchemy_redshift is not None
         and check_sql_engine_dialect(
@@ -262,6 +275,7 @@ def column_reflection_fallback(
 ) -> List[Dict[str, str]]:
     """If we can't reflect the table, use a query to at least get column names."""
     col_info_dict_list: List[Dict[str, str]]
+    # noinspection PyUnresolvedReferences
     if dialect.name.lower() == "mssql":
         # Get column names and types from the database
         # Reference: https://dataedo.com/kb/query/sql-server/list-table-columns-in-database
@@ -342,6 +356,7 @@ def get_dialect_like_pattern_expression(column, dialect, like_pattern, positive=
         dialect_supported = True
 
     try:
+        # noinspection PyUnresolvedReferences
         if isinstance(dialect, sqlalchemy_redshift.dialect.RedshiftDialect):
             dialect_supported = True
     except (AttributeError, TypeError):


Test patch:
diff --git a/tests/expectations/metrics/test_core.py b/tests/expectations/metrics/test_core.py
--- a/tests/expectations/metrics/test_core.py
+++ b/tests/expectations/metrics/test_core.py
@@ -713,7 +713,10 @@ def test_map_unique_column_exists_sa(sa):
         metric_value_kwargs={
             "result_format": {"result_format": "BASIC", "partial_unexpected_count": 20}
         },
-        metric_dependencies={"unexpected_condition": condition_metric},
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
     )
     results = engine.resolve_metrics(
         metrics_to_resolve=(desired_metric,), metrics=metrics
@@ -3869,6 +3872,230 @@ def test_map_select_column_values_unique_within_record_pd():
     ]
 
 
+def test_map_compound_columns_unique_sa(sa):
+    engine = build_sa_engine(
+        pd.DataFrame(data={"a": [0, 1, 1], "b": [1, 2, 3], "c": [0, 2, 2]}),
+        sa,
+    )
+
+    metrics: dict = {}
+
+    table_columns_metric: MetricConfiguration
+    results: dict
+
+    table_columns_metric, results = get_table_columns_metric(engine=engine)
+    metrics.update(results)
+
+    """
+    Two tests:
+    1. Pass -- no duplicated compound column keys.
+    2. Fail -- one or more duplicated compound column keys.
+    """
+
+    # Save original metrics for testing unexpected results.
+    metrics_save: dict = copy.deepcopy(metrics)
+
+    prerequisite_function_metric_name: str = "compound_columns.count.map"
+
+    prerequisite_function_metric = MetricConfiguration(
+        metric_name=prerequisite_function_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "b"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(prerequisite_function_metric,),
+        metrics=metrics,
+    )
+    metrics.update(results)
+
+    metric_name: str = "compound_columns.unique"
+    condition_metric_name: str = f"{metric_name}.condition"
+    unexpected_count_metric_name: str = f"{metric_name}.unexpected_count"
+    unexpected_rows_metric_name: str = f"{metric_name}.unexpected_rows"
+    unexpected_values_metric_name: str = f"{metric_name}.unexpected_values"
+
+    # First, assert Pass (no unexpected results).
+
+    condition_metric = MetricConfiguration(
+        metric_name=condition_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "b"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "compound_columns.count.map": prerequisite_function_metric,
+            "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=unexpected_count_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "b"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_count_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    # Condition metrics return SQLAlchemy ColumnElement object.
+    assert metrics[unexpected_count_metric.id] == 0
+
+    unexpected_rows_metric = MetricConfiguration(
+        metric_name=unexpected_rows_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "b"],
+        },
+        metric_value_kwargs={
+            "result_format": {"result_format": "SUMMARY", "partial_unexpected_count": 3}
+        },
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_rows_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    assert len(metrics[unexpected_rows_metric.id]) == 0
+
+    unexpected_values_metric = MetricConfiguration(
+        metric_name=unexpected_values_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "b"],
+        },
+        metric_value_kwargs={
+            "result_format": {"result_format": "SUMMARY", "partial_unexpected_count": 3}
+        },
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_values_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    assert len(metrics[unexpected_values_metric.id]) == 0
+
+    # Restore from saved original metrics in order to start fresh on testing for unexpected results.
+    metrics = copy.deepcopy(metrics_save)
+
+    # Second, assert Fail (one or more unexpected results).
+
+    prerequisite_function_metric = MetricConfiguration(
+        metric_name=prerequisite_function_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "c"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(prerequisite_function_metric,),
+        metrics=metrics,
+    )
+    metrics.update(results)
+
+    condition_metric = MetricConfiguration(
+        metric_name=condition_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "c"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "compound_columns.count.map": prerequisite_function_metric,
+            "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=unexpected_count_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "c"],
+        },
+        metric_value_kwargs=None,
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_count_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    # Condition metrics return SQLAlchemy ColumnElement object.
+    assert metrics[unexpected_count_metric.id] == 2
+
+    unexpected_rows_metric = MetricConfiguration(
+        metric_name=unexpected_rows_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "c"],
+        },
+        metric_value_kwargs={
+            "result_format": {"result_format": "SUMMARY", "partial_unexpected_count": 3}
+        },
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_rows_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    assert metrics[unexpected_rows_metric.id] == [(1, 2, 2), (1, 3, 2)]
+
+    unexpected_values_metric = MetricConfiguration(
+        metric_name=unexpected_values_metric_name,
+        metric_domain_kwargs={
+            "column_list": ["a", "c"],
+        },
+        metric_value_kwargs={
+            "result_format": {"result_format": "SUMMARY", "partial_unexpected_count": 3}
+        },
+        metric_dependencies={
+            "unexpected_condition": condition_metric,
+            "table.columns": table_columns_metric,
+        },
+    )
+    results = engine.resolve_metrics(
+        metrics_to_resolve=(unexpected_values_metric,), metrics=metrics
+    )
+    metrics.update(results)
+
+    assert len(metrics[unexpected_values_metric.id]) == 2
+    assert metrics[unexpected_values_metric.id] == [{"a": 1, "c": 2}, {"a": 1, "c": 2}]
+
+
 def test_map_select_column_values_unique_within_record_sa(sa):
     engine = build_sa_engine(
         pd.DataFrame(
diff --git a/tests/expectations/metrics/test_map_metric.py b/tests/expectations/metrics/test_map_metric.py
--- a/tests/expectations/metrics/test_map_metric.py
+++ b/tests/expectations/metrics/test_map_metric.py
@@ -2,9 +2,15 @@
     PandasExecutionEngine,
     SqlAlchemyExecutionEngine,
 )
-from great_expectations.expectations.metrics import ColumnMax, ColumnValuesNonNull
+from great_expectations.expectations.metrics import (
+    ColumnMax,
+    ColumnValuesNonNull,
+    CompoundColumnsUnique,
+    MetricProvider,
+)
 from great_expectations.expectations.metrics.map_metric_provider import (
     ColumnMapMetricProvider,
+    MapMetricProvider,
 )
 from great_expectations.validator.metric_configuration import MetricConfiguration
 
@@ -85,3 +91,13 @@ def test_get_map_metric_dependencies():
     metric = MetricConfiguration("foo.unexpected_index_list", {}, {})
     dependencies = mp.get_evaluation_dependencies(metric)
     assert dependencies["unexpected_condition"].id[0] == "foo.condition"
+
+
+def test_is_sqlalchemy_metric_selectable():
+    assert MapMetricProvider.is_sqlalchemy_metric_selectable(
+        map_metric_provider=CompoundColumnsUnique
+    )
+
+    assert not MapMetricProvider.is_sqlalchemy_metric_selectable(
+        map_metric_provider=ColumnValuesNonNull
+    )