Skip to content

Commit 83c28e2

Browse files
fix(checks): restore column-vs-literal comparisons in comparator family (#227)
Deequ 2.0.x's Check.isLessThan/isLessThanOrEqualTo/isGreaterThan/ isGreaterThanOrEqualTo forward columns = List(columnA, columnB) to satisfies, which makes Deequ require both operands to be existing columns. This regressed the long-supported column-vs-literal usage (e.g. isGreaterThanOrEqualTo("cluster_size", "1")), failing with 'Input data does not include column 1!' (issue #227). Route the comparator family through Deequ's satisfies with an empty columns list (the pre-2.0 behaviour), building the SQL predicate in the wrapper. columnB may now be a column name or a SQL literal/expression. Column-vs-column comparisons are unchanged. Adds regression tests for column-vs-literal comparisons.
1 parent 2e628b2 commit 83c28e2

2 files changed

Lines changed: 91 additions & 36 deletions

File tree

pydeequ/checks.py

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -727,81 +727,93 @@ def isPositive(self, column, assertion=None, hint=None):
727727
self._Check = self._Check.isPositive(column, assertion_func, hint)
728728
return self
729729

730+
def _column_comparison(self, columnA, columnB, operator, description, assertion, hint):
731+
"""Builds a column comparison constraint via Deequ's ``satisfies``.
732+
733+
Deequ's native ``isLessThan``/``isGreaterThan`` family (since Deequ 2.0.x)
734+
forwards ``columns = List(columnA, columnB)`` to ``satisfies``, which makes
735+
Deequ require *both* operands to be existing columns. That breaks the long
736+
supported column-vs-literal usage (e.g. ``isGreaterThanOrEqualTo("col", "1")``),
737+
failing with ``Input data does not include column 1!`` (see issue #227).
738+
739+
We instead build the same Spark SQL predicate ourselves and call ``satisfies``
740+
with an empty ``columns`` list (the pre-2.0 behaviour), so ``columnB`` may be
741+
either a column name or a SQL literal/expression.
742+
743+
``columnA`` is always a real column, so it is backtick-quoted to stay valid
744+
when the name contains spaces/special characters or is a SQL reserved word.
745+
``columnB`` is left raw on purpose: it may be a column, a literal, or a SQL
746+
expression, and quoting is the caller's responsibility (same as Deequ's
747+
``satisfies``).
748+
"""
749+
# The comparator family now genuinely routes through ``satisfies``, so its
750+
# default assertion (``satisfies$default$3`` == ``_ == 1.0``) is the correct
751+
# one to use; it matches each comparator's own Deequ default (also ``_ == 1.0``).
752+
assertion_func = (
753+
ScalaFunction1(self._spark_session.sparkContext._gateway, assertion)
754+
if assertion
755+
else getattr(self._Check, "satisfies$default$3")()
756+
)
757+
hint = self._jvm.scala.Option.apply(hint)
758+
column_condition = f"`{columnA}` {operator} {columnB}"
759+
constraint_name = f"{columnA} is {description} {columnB}"
760+
self._Check = self._Check.satisfies(
761+
column_condition,
762+
constraint_name,
763+
assertion_func,
764+
hint,
765+
self._jvm.scala.collection.Seq.empty(),
766+
self._jvm.scala.Option.apply(None),
767+
)
768+
return self
769+
730770
def isLessThan(self, columnA, columnB, assertion=None, hint=None):
731771
"""
732772
Asserts that, in each row, the value of columnA is less than the value of columnB
733773
734774
:param str columnA: Column in DataFrame to run the assertion on.
735-
:param str columnB: Column in DataFrame to run the assertion on.
775+
:param str columnB: Column in DataFrame to compare against, or a SQL literal/expression.
736776
:param lambda assertion: A function that accepts an int or float parameter.
737777
:param str hint: A hint that states why a constraint could have failed.
738778
:return: isLessThan self : A Check object that checks the assertion on the columns.
739779
"""
740-
assertion_func = (
741-
ScalaFunction1(self._spark_session.sparkContext._gateway, assertion)
742-
if assertion
743-
else getattr(self._Check, "isLessThan$default$3")()
744-
)
745-
hint = self._jvm.scala.Option.apply(hint)
746-
self._Check = self._Check.isLessThan(columnA, columnB, assertion_func, hint)
747-
return self
780+
return self._column_comparison(columnA, columnB, "<", "less than", assertion, hint)
748781

749782
def isLessThanOrEqualTo(self, columnA, columnB, assertion=None, hint=None):
750783
"""
751784
Asserts that, in each row, the value of columnA is less than or equal to the value of columnB.
752785
753786
:param str columnA: Column in DataFrame to run the assertion on.
754-
:param str columnB: Column in DataFrame to run the assertion on.
787+
:param str columnB: Column in DataFrame to compare against, or a SQL literal/expression.
755788
:param lambda assertion: A function that accepts an int or float parameter.
756789
:param str hint: A hint that states why a constraint could have failed.
757790
:return: isLessThanOrEqualTo self (isLessThanOrEqualTo): A Check object that checks the assertion on the columns.
758791
"""
759-
assertion_func = (
760-
ScalaFunction1(self._spark_session.sparkContext._gateway, assertion)
761-
if assertion
762-
else getattr(self._Check, "isLessThanOrEqualTo$default$3")()
763-
)
764-
hint = self._jvm.scala.Option.apply(hint)
765-
self._Check = self._Check.isLessThanOrEqualTo(columnA, columnB, assertion_func, hint)
766-
return self
792+
return self._column_comparison(columnA, columnB, "<=", "less than or equal to", assertion, hint)
767793

768794
def isGreaterThan(self, columnA, columnB, assertion=None, hint=None):
769795
"""
770796
Asserts that, in each row, the value of columnA is greater than the value of columnB
771797
772798
:param str columnA: Column in DataFrame to run the assertion on.
773-
:param str columnB: Column in DataFrame to run the assertion on.
799+
:param str columnB: Column in DataFrame to compare against, or a SQL literal/expression.
774800
:param lambda assertion: A function that accepts an int or float parameter.
775801
:param str hint: A hint that states why a constraint could have failed.
776802
:return: isGreaterThan self: A Check object that runs the assertion on the columns.
777803
"""
778-
assertion_func = (
779-
ScalaFunction1(self._spark_session.sparkContext._gateway, assertion)
780-
if assertion
781-
else getattr(self._Check, "isGreaterThan$default$3")()
782-
)
783-
hint = self._jvm.scala.Option.apply(hint)
784-
self._Check = self._Check.isGreaterThan(columnA, columnB, assertion_func, hint)
785-
return self
804+
return self._column_comparison(columnA, columnB, ">", "greater than", assertion, hint)
786805

787806
def isGreaterThanOrEqualTo(self, columnA, columnB, assertion=None, hint=None):
788807
"""
789808
Asserts that, in each row, the value of columnA is greather than or equal to the value of columnB
790809
791810
:param str columnA: Column in DataFrame to run the assertion on.
792-
:param str columnB: Column in DataFrame to run the assertion on.
811+
:param str columnB: Column in DataFrame to compare against, or a SQL literal/expression.
793812
:param lambda assertion: A function that accepts an int or float parameter.
794813
:param str hint: A hint that states why a constraint could have failed.
795814
:return: isGreaterThanOrEqualTo self: A Check object that runs the assertion on the columns.
796815
"""
797-
assertion_func = (
798-
ScalaFunction1(self._spark_session.sparkContext._gateway, assertion)
799-
if assertion
800-
else getattr(self._Check, "isGreaterThanOrEqualTo$default$3")()
801-
)
802-
hint = self._jvm.scala.Option.apply(hint)
803-
self._Check = self._Check.isGreaterThanOrEqualTo(columnA, columnB, assertion_func, hint)
804-
return self
816+
return self._column_comparison(columnA, columnB, ">=", "greater than or equal to", assertion, hint)
805817

806818
def isContainedIn(self, column, allowed_values, assertion=None, hint=None):
807819
"""

tests/test_checks.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,49 @@ def test_fail_isGreaterThanOrEqualTo(self):
10201020
self.isGreaterThanOrEqualTo("h", "f", lambda x: x == 1), [Row(constraint_status="Failure")]
10211021
)
10221022

1023+
def test_comparator_against_literal(self):
1024+
# Regression test for issue #227: comparing a column to a SQL literal
1025+
# (rather than another column) must not fail with
1026+
# "Input data does not include column <literal>!".
1027+
# Column "b" holds values 1, 2, 3 -> all are >= 1 and all are <= 10.
1028+
self.assertEqual(
1029+
self.isGreaterThanOrEqualTo("b", "1", hint="Cluster should have at least one element"),
1030+
[Row(constraint_status="Success")],
1031+
)
1032+
self.assertEqual(
1033+
self.isLessThanOrEqualTo("b", "10", hint="b never exceeds 10"),
1034+
[Row(constraint_status="Success")],
1035+
)
1036+
self.assertEqual(
1037+
self.isGreaterThan("b", "0"),
1038+
[Row(constraint_status="Success")],
1039+
)
1040+
self.assertEqual(
1041+
self.isLessThan("b", "100"),
1042+
[Row(constraint_status="Success")],
1043+
)
1044+
1045+
def test_fail_comparator_against_literal(self):
1046+
# Column "b" holds values 1, 2, 3 -> not all are >= 3.
1047+
self.assertEqual(
1048+
self.isGreaterThanOrEqualTo("b", "3"), [Row(constraint_status="Failure")]
1049+
)
1050+
1051+
def test_comparator_column_name_with_space(self):
1052+
# Regression test for issue #227 review: columnA is always a real column
1053+
# and must be backtick-quoted so a name containing spaces/special chars
1054+
# (or a SQL reserved word) produces valid SQL.
1055+
df = self.df.withColumnRenamed("b", "my col")
1056+
check = Check(self.spark, CheckLevel.Warning, "test spaced column").isGreaterThanOrEqualTo(
1057+
"my col", "1", hint="values in 'my col' are at least 1"
1058+
)
1059+
result = VerificationSuite(self.spark).onData(df).addCheck(check).run()
1060+
result_df = VerificationResult.checkResultsAsDataFrame(self.spark, result)
1061+
self.assertEqual(
1062+
result_df.select("constraint_status").collect(),
1063+
[Row(constraint_status="Success")],
1064+
)
1065+
10231066
def test_where(self):
10241067
self.assertEqual(
10251068
self.where(lambda x: x == 2.0, "boolean='true'", "column 'boolean' has two values true"),

0 commit comments

Comments
 (0)