From c2b47673b9cc0e898a31cd3cec739faaff219a30 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 22:15:26 -0400 Subject: [PATCH 1/3] Reject incompatible comparison types --- pointblank/_interrogation.py | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pointblank/_interrogation.py b/pointblank/_interrogation.py index 5b3e97f62..062345f63 100644 --- a/pointblank/_interrogation.py +++ b/pointblank/_interrogation.py @@ -1,6 +1,8 @@ from __future__ import annotations +import datetime import functools +import numbers import operator from collections.abc import Callable from dataclasses import dataclass @@ -1083,6 +1085,8 @@ def interrogate_between( nw_tbl = nw.from_native(tbl) assert isinstance(nw_tbl, (nw.DataFrame, nw.LazyFrame)) + _check_comparable(nw_tbl, column, low) + _check_comparable(nw_tbl, column, high) low_val = _safe_modify_datetime_compare_val(nw_tbl, column, low_val) high_val = _safe_modify_datetime_compare_val(nw_tbl, column, high_val) @@ -1153,6 +1157,8 @@ def interrogate_outside( nw_tbl = nw.from_native(tbl) assert isinstance(nw_tbl, (nw.DataFrame, nw.LazyFrame)) + _check_comparable(nw_tbl, column, low) + _check_comparable(nw_tbl, column, high) low_val = _safe_modify_datetime_compare_val(nw_tbl, column, low_val) high_val = _safe_modify_datetime_compare_val(nw_tbl, column, high_val) @@ -1226,6 +1232,50 @@ def interrogate_isin(tbl: IntoFrame, column: str, set_values: Any) -> Any: return result_tbl.to_native() +def _value_kind(value: Any) -> str | None: + """Classify a Python comparison value like `_dtype_kind()` does dtypes (`None` if other).""" + if isinstance(value, bool): + return "boolean" + if isinstance(value, numbers.Number): + return "numeric" + if isinstance(value, datetime.date): # includes datetimes + return "temporal" + if isinstance(value, str): + return "string" + return None + + +def _check_comparable(nw_tbl: Any, column: str, compare: Any) -> None: + """ + Raise a `StepEvaluationError` if a column can't be compared with a value or another column. + + Comparing, e.g., a numeric column with a string crashes on some backends and silently gives + wrong results on others (pandas compares `1 == "x"` as False; Polars compares dates with + integers). Columns that aren't in the table are skipped (their own error is clearer), as are + types that can't be classified. Date and datetime columns can be compared with date strings. + """ + schema = nw_tbl.collect_schema() + if column not in schema: + return + kind = _dtype_kind(schema[column]) + + if isinstance(compare, Column): + if not isinstance(compare.exprs, str) or compare.exprs not in schema: + return + other_kind = _dtype_kind(schema[compare.exprs]) + other = f"column '{compare.exprs}' ({schema[compare.exprs]})" + else: + other_kind = _value_kind(compare) + other = f"the value {compare!r}" + if kind == "temporal" and other_kind == "string": + return + + if kind is not None and other_kind is not None and kind != other_kind: + raise StepEvaluationError( + f"Column '{column}' ({schema[column]}) can't be compared with {other}." + ) + + def _dtype_kind(dtype: Any) -> str | None: """Classify a Narwhals dtype as numeric, string, temporal, or boolean (`None` if other).""" if dtype.is_numeric(): @@ -2205,6 +2255,7 @@ def _interrogate_comparison_base( nw_tbl = nw.from_native(tbl) assert isinstance(nw_tbl, (nw.DataFrame, nw.LazyFrame)) + _check_comparable(nw_tbl, column, compare) compare_expr = _safe_modify_datetime_compare_val(nw_tbl, column, compare_expr) compared_columns = [column, compare.name] if isinstance(compare, Column) else [column] From bd74208c2604d0fc26b6c75441e71b4548c35694 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 22:15:37 -0400 Subject: [PATCH 2/3] Handle incompatible column types as step errors --- pointblank/validate.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pointblank/validate.py b/pointblank/validate.py index 22727a1aa..4417b8757 100644 --- a/pointblank/validate.py +++ b/pointblank/validate.py @@ -16780,10 +16780,17 @@ def interrogate( "col_vals_within_spec", "col_vals_str_len", ]: - # Process table for column validation - tbl = _column_test_prep( - df=data_tbl_step, column=column, allowed_types=compatible_dtypes - ) + # Process table for column validation; a column of a type the method + # can't check (e.g., a string column in `col_vals_gt()`) is an evaluation + # error rather than a crash + try: + tbl = _column_test_prep( + df=data_tbl_step, column=column, allowed_types=compatible_dtypes + ) + except TypeError as e: + raise StepEvaluationError( + f"{str(e).rstrip('.')}, which {assertion_type}() can't check." + ) from e if assertion_method == "gt": results_tbl = interrogate_gt( From 3f19b745ecc1b5afc1173b57aa0740fe893bbb16 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 22:15:49 -0400 Subject: [PATCH 3/3] Test comparison type compatibility across backends --- tests/test_validate.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/test_validate.py b/tests/test_validate.py index 50aad7e52..11ec64e03 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -2800,6 +2800,83 @@ def test_col_vals_ne(request, tbl_fixture) -> None: assert validation_2.n_failed(i=1, scalar=True) == 0 +def _type_mismatch_table(tbl_type: str): + tbl = pl.DataFrame( + { + "a": [1, 2, 3], + "s": ["x", "y", "z"], + "d": [datetime.date(2024, 1, day) for day in (1, 2, 3)], + "f": [1.5, 2.5, 3.5], + } + ) + if tbl_type == "polars_lazy": + return tbl.lazy() + if tbl_type == "pandas": + return tbl.to_pandas() + if tbl_type == "duckdb": + return ibis.duckdb.connect().create_table("tbl", tbl) + if tbl_type == "memtable": + return ibis.memtable(tbl) + return tbl + + +TYPE_MISMATCH_TBL_TYPES = ["polars", "polars_lazy", "pandas", "duckdb", "memtable"] + + +@pytest.mark.parametrize("tbl_type", TYPE_MISMATCH_TBL_TYPES) +@pytest.mark.parametrize( + "method, kwargs, note", + [ + # A column compared with a column of another type + ("col_vals_eq", {"columns": "a", "value": col("s")}, "can't be compared with column 's'"), + ("col_vals_ne", {"columns": "s", "value": col("a")}, "can't be compared with column 'a'"), + ( + "col_vals_between", + {"columns": "a", "left": col("s"), "right": 10}, + "can't be compared with column 's'", + ), + # A column compared with a value of another type + ("col_vals_gt", {"columns": "d", "value": 5}, "can't be compared with the value 5"), + ("col_vals_eq", {"columns": "s", "value": 5}, "can't be compared with the value 5"), + # A column of a type the method can't check + ("col_vals_lt", {"columns": "s", "value": 5}, "which col_vals_lt() can't check"), + ], +) +def test_comparison_type_mismatch_is_eval_error(tbl_type, method, kwargs, note) -> None: + # Instead of crashing `interrogate()` (lazy Polars, DuckDB, Ibis) or silently giving wrong + # results (pandas, and Polars for dates vs. numbers), the step can't be evaluated + validation = getattr(Validate(_type_mismatch_table(tbl_type)), method)(**kwargs) + validation = validation.col_vals_not_null(columns="a").interrogate() + + step = validation.validation_info[0] + assert step.eval_error is True + assert step.n_passed is None + assert note in step.notes["eval_error"]["text"] + + # The other steps are still interrogated + assert validation.n_passed(i=2, scalar=True) == 3 + + +@pytest.mark.parametrize("tbl_type", TYPE_MISMATCH_TBL_TYPES) +@pytest.mark.parametrize( + "method, kwargs, n_passed", + [ + ("col_vals_gt", {"columns": "d", "value": datetime.date(2024, 1, 1)}, 2), + # Date and datetime columns can be compared with date strings + ("col_vals_gt", {"columns": "d", "value": "2024-01-01"}, 2), + # Integers and floats are both numeric + ("col_vals_gt", {"columns": "a", "value": 1.5}, 2), + ("col_vals_lt", {"columns": "a", "value": col("f")}, 3), + ("col_vals_between", {"columns": "a", "left": 0, "right": col("f")}, 3), + ], +) +def test_comparison_compatible_types(tbl_type, method, kwargs, n_passed) -> None: + validation = getattr(Validate(_type_mismatch_table(tbl_type)), method)(**kwargs).interrogate() + + assert validation.validation_info[0].eval_error is not True + assert validation.n_passed(i=1, scalar=True) == n_passed + + # Columns `a` and `b` with nulls in `a` only, in `b` only, or in both, and the expected # (n_passed, n_failed) for `col_vals_eq()`/`col_vals_ne()` with `na_pass=False`/`True`. A row with # a missing value on either side fails (or passes with `na_pass=True`); the others are compared.