Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions pointblank/_interrogation.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 11 additions & 4 deletions pointblank/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
77 changes: 77 additions & 0 deletions tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading