[BUG] ExpectColumnValuesToBeOfType cannot pass on any Nullable(...) ClickHouse column — observed_value is 'Nullable' and no type name matches
What's wrong
On ClickHouse, ExpectColumnValuesToBeOfType and ExpectColumnValuesToBeInTypeList cannot pass on any Nullable(...) column — which is most columns in a real ClickHouse schema. clickhouse-sqlalchemy reflects Nullable(Int64) as a Nullable type instance whose nested_type is Int64, and Nullable is not a subclass of Int64. GX's ClickHouse type resolution (great_expectations/util.py:960-974, get_clickhouse_sqlalchemy_potential_type) unwraps nested_type on the expected side only (:970-971); the actual column type reaches the comparison at type_comparison.py:116 / :156 still wrapped. So isinstance(Nullable(Int64), Int64) is False, observed_value is the string 'Nullable', and there is no type_ a user can supply that passes — not Int64, not INTEGER, nothing.
Impact
- Fails closed, with a useless diagnostic. A correct assertion on a correctly typed column fails, and the reported
observed_value('Nullable') is not a type name anyone can ask for.raised_exceptionisfalse, so nothing says why. - On a non-nullable column the same comparison works and discriminates (
Int64passes,Stringfails), so users who test against a non-nullable table and then point at production data see the Expectation start failing for no visible reason. - Present since the ClickHouse integration landed in
0.17.0(#7719, 2023) through1.23.0; confirmed withclickhouse-sqlalchemy 0.3.2againstclickhouse/clickhouse-server:25.8.29. - Workaround: declare the column non-nullable — usually not the GX user's call.
Reproduction
Reproduced on 1.23.0 (673082d4b), Python 3.11, against the repository's ClickHouse container (assets/docker/clickhouse). The harness creates every ClickHouse column as Nullable(...), so any integer column it loads exhibits this.
Add to tests/integration/data_sources_and_expectations/expectations/test_expect_column_values_to_be_of_type.py (import ClickHouseDatasourceTestConfig alongside the module's other configs; the test carries its own frame because the module's shared DATA has an all-NULL column the harness cannot currently load into ClickHouse — see Context):
# Own frame: the harness cannot load the module's all-NULL column into ClickHouse.
CLICKHOUSE_NULLABLE_DATA = pd.DataFrame({INTEGER_COLUMN: [1, 2, 3]})
@parameterize_batch_for_data_sources(
data_source_configs=[ClickHouseDatasourceTestConfig()],
data=CLICKHOUSE_NULLABLE_DATA,
)
def test_success_for_type__Int64_nullable_clickhouse(batch_for_datasource: Batch) -> None:
"""A ``Nullable(Int64)`` column is an Int64 column that admits NULL, and must compare as one.
The harness creates every ClickHouse column as ``Nullable(...)``. The reflected type is the
``Nullable`` wrapper, which is not a subclass of ``Int64``; only the expected side of the
comparison unwraps ``nested_type``, so ``isinstance(Nullable(Int64), Int64)`` is False and
``observed_value`` is the string ``'Nullable'`` -- a name no ``type_`` can satisfy.
"""
result = batch_for_datasource.validate(
gxe.ExpectColumnValuesToBeOfType(column=INTEGER_COLUMN, type_="Int64")
)
assert result.result["observed_value"] == "Int64"
assert result.successpytest tests/integration/data_sources_and_expectations/expectations/test_expect_column_values_to_be_of_type.py::test_success_for_type__Int64_nullable_clickhouse -m clickhouseObserved:
FAILED ...::test_success_for_type__Int64_nullable_clickhouse[clickhouse]
assert result.result["observed_value"] == "Int64"
E AssertionError: assert equals failed
E -'Nullable' +'Int64'The same picture from a standalone table, for contrast with a non-nullable column:
column=i (Int64) type_='Int64' -> success=True observed_value='Int64'
column=i (Int64) type_='String' -> success=False observed_value='Int64'
column=n (Nullable(Int64)) type_='Int64' -> success=False observed_value='Nullable'
column=n (Nullable(Int64)) type_='String' -> success=False observed_value='Nullable'Expected:
column=n (Nullable(Int64)) type_='Int64' -> success=True observed_value='Int64'
column=n (Nullable(Int64)) type_='String' -> success=False observed_value='Int64'Requirements
- When
ExpectColumnValuesToBeOfTypeorExpectColumnValuesToBeInTypeListruns against a ClickHouseNullable(T)column, the comparison must be made againstT:type_="Int64"on aNullable(Int64)column returnssuccess=True, andtype_="String"on the same column returnssuccess=False. observed_valuefor aNullable(T)column must reportT's name ('Int64'), so a failing result names a type the user can actually ask for.- Behaviour on non-nullable ClickHouse columns, and on every other dialect, must be unchanged.
- The public signatures and parameters of both Expectations must not change.
- Both Expectations must have a regression case on a nullable ClickHouse column in their modules under
tests/integration/data_sources_and_expectations/expectations/.
Out of scope: the ClickHouse regex branch (#12217); the type-name resolution defect on the isinstance path when the expected name resolves in neither namespace (#12215 — a Nullable column with a typo'd type_ will still hit that after this fix, and that fix covers it); the harness's handling of all-NULL columns on ClickHouse (a test-utility fix, separate); type-vocabulary questions (INTEGER versus Int64 is a naming choice, not this defect).
Context
- Root cause:
util.py:970-971unwrapsnested_typeonly for the expected type. The actual type flows from thetable.column_typesmetric (expect_column_values_to_be_of_type.py:461-478) intocompare_column_type(type_comparison.py:93-117) andcompare_column_type_list(:120-157) unchanged;observed_valueistype(actual_column_type).__name__, hence'Nullable'. - Fix shape, verified: two lines ahead of the dispatch in
compare_column_type(:109) —if hasattr(actual_column_type, "nested_type"): actual_column_type = actual_column_type.nested_type— turn the test above green withInt64passing,StringandINTEGERfailing, andobserved_value'Int64'.compare_column_type_list(:134) needs the same unwrap; an unpatched control kept failing there. A single shared spot before both is the cleaner shape. Only ClickHouse's type engine hasnested_type, so the check is inert elsewhere; a dialect-name guard is fine if preferred. clickhouse-sqlalchemy 0.3.2:Nullable(Int64).__mro__isNullable → ClickHouseTypeEngine → TypeEngine;isinstance(Nullable(Int64), Int64)isFalse;.nested_typeisInt64.LowCardinality(...)wraps the same way and should get the same treatment — not verified here.- Ruled out: type-name vocabulary as the cause — on a non-nullable column
Int64andStringpass andINTEGER/VARCHARfail, so the names resolve; only the wrapped actual type fails. Not verified:LowCardinality,Array, and other wrapping types. - The module's shared
DATAincludes an all-NULL column that the integration harness creates as a non-nullableInt32on ClickHouse (tests/integration/test_utils/data_source_config/sql.py:437-439sends an all-null column straight tosqltypes.INTEGER, bypassing the dialect'scolumn_type_overrides), so the insert fails before any test runs. That is why the test above carries its own frame, and why this module has never run ClickHouse. - Also recorded in
tests/integration/data_sources_and_expectations/data_source_backlog.md("Fifteen candidates were measured…"), which attributes ClickHouse's two type failures to case vocabulary; that is the top layer only — with the right vocabulary this defect still fails both cases.
Source: fivetran/great_expectations