Skip to content

Commit 8725d66

Browse files
committed
Preserve residual visitor compatibility
1 parent 2c31747 commit 8725d66

3 files changed

Lines changed: 91 additions & 232 deletions

File tree

pyiceberg/expressions/visitors.py

Lines changed: 23 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,28 +1785,37 @@ def _can_contain_nans(self, field_id: int) -> bool:
17851785
return (nan_count := self.nan_counts.get(field_id)) is not None and nan_count > 0
17861786

17871787

1788-
class _ResidualEvaluationVisitor(BoundBooleanExpressionVisitor[BooleanExpression]):
1789-
"""Evaluate a residual expression for one partition."""
1788+
class ResidualVisitor(BoundBooleanExpressionVisitor[BooleanExpression], ABC):
1789+
"""Find residuals for an expression using partition values.
1790+
1791+
A residual expression is made by partially evaluating an expression using partition values.
1792+
For example, if a table is partitioned by day(utc_timestamp) and is read with a filter expression
1793+
utc_timestamp > a and utc_timestamp < b, then there are 4 possible residual expressions
1794+
for the partition data, d:
1795+
1796+
1. If d > day(a) and d < day(b), the residual is always true
1797+
2. If d == day(a) and d != day(b), the residual is utc_timestamp > a
1798+
3. If d == day(b) and d != day(a), the residual is utc_timestamp < b
1799+
4. If d == day(a) == day(b), the residual is utc_timestamp > a and utc_timestamp < b
1800+
"""
17901801

17911802
schema: Schema
17921803
spec: PartitionSpec
17931804
case_sensitive: bool
1805+
expr: BooleanExpression
17941806
partition_schema: Schema
17951807
struct: Record
17961808

1797-
def __init__(
1798-
self,
1799-
schema: Schema,
1800-
spec: PartitionSpec,
1801-
case_sensitive: bool,
1802-
partition_schema: Schema,
1803-
partition_data: Record,
1804-
) -> None:
1809+
def __init__(self, schema: Schema, spec: PartitionSpec, case_sensitive: bool, expr: BooleanExpression) -> None:
18051810
self.schema = schema
18061811
self.spec = spec
18071812
self.case_sensitive = case_sensitive
1808-
self.partition_schema = partition_schema
1813+
self.expr = expr
1814+
self.partition_schema = Schema(*spec.partition_type(schema).fields)
1815+
1816+
def eval(self, partition_data: Record) -> BooleanExpression:
18091817
self.struct = partition_data
1818+
return visit(self.expr, visitor=self)
18101819

18111820
def visit_true(self) -> BooleanExpression:
18121821
return AlwaysTrue()
@@ -1844,10 +1853,10 @@ def visit_is_nan(self, term: BoundTerm) -> BooleanExpression:
18441853

18451854
def visit_not_nan(self, term: BoundTerm) -> BooleanExpression:
18461855
val = term.eval(self.struct)
1847-
if isinstance(val, SupportsFloat) and not math.isnan(val):
1848-
return self.visit_true()
1849-
else:
1856+
if isinstance(val, SupportsFloat) and math.isnan(val):
18501857
return self.visit_false()
1858+
else:
1859+
return self.visit_true()
18511860

18521861
def visit_less_than(self, term: BoundTerm, literal: LiteralValue) -> BooleanExpression:
18531862
if term.eval(self.struct) < literal.value:
@@ -1970,46 +1979,6 @@ def visit_unbound_predicate(self, predicate: UnboundPredicate) -> BooleanExpress
19701979
return bound
19711980

19721981

1973-
class ResidualVisitor:
1974-
"""Find residuals for an expression using partition values.
1975-
1976-
A residual expression is made by partially evaluating an expression using partition values.
1977-
For example, if a table is partitioned by day(utc_timestamp) and is read with a filter expression
1978-
utc_timestamp > a and utc_timestamp < b, then there are 4 possible residual expressions
1979-
for the partition data, d:
1980-
1981-
1. If d > day(a) and d < day(b), the residual is always true
1982-
2. If d == day(a) and d != day(b), the residual is utc_timestamp > a
1983-
3. If d == day(b) and d != day(a), the residual is utc_timestamp < b
1984-
4. If d == day(a) == day(b), the residual is utc_timestamp > a and utc_timestamp < b
1985-
"""
1986-
1987-
schema: Schema
1988-
spec: PartitionSpec
1989-
case_sensitive: bool
1990-
expr: BooleanExpression
1991-
partition_schema: Schema
1992-
1993-
def __init__(self, schema: Schema, spec: PartitionSpec, case_sensitive: bool, expr: BooleanExpression) -> None:
1994-
self.schema = schema
1995-
self.spec = spec
1996-
self.case_sensitive = case_sensitive
1997-
self.expr = expr
1998-
self.partition_schema = Schema(*spec.partition_type(schema).fields)
1999-
2000-
def eval(self, partition_data: Record) -> BooleanExpression:
2001-
return visit(
2002-
self.expr,
2003-
visitor=_ResidualEvaluationVisitor(
2004-
schema=self.schema,
2005-
spec=self.spec,
2006-
case_sensitive=self.case_sensitive,
2007-
partition_schema=self.partition_schema,
2008-
partition_data=partition_data,
2009-
),
2010-
)
2011-
2012-
20131982
class ResidualEvaluator(ResidualVisitor):
20141983
def residual_for(self, partition_data: Record) -> BooleanExpression:
20151984
return self.eval(partition_data)

tests/expressions/test_residual_evaluator.py

Lines changed: 16 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,13 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
# pylint:disable=redefined-outer-name
18-
from concurrent.futures import ThreadPoolExecutor
19-
from threading import Event
20-
from typing import Any
21-
2218
import pytest
2319

2420
from pyiceberg.expressions import (
2521
AlwaysFalse,
2622
AlwaysTrue,
2723
And,
24+
BooleanExpression,
2825
EqualTo,
2926
GreaterThan,
3027
GreaterThanOrEqual,
@@ -45,7 +42,7 @@
4542
from pyiceberg.schema import Schema
4643
from pyiceberg.transforms import DayTransform, IdentityTransform
4744
from pyiceberg.typedef import Record
48-
from pyiceberg.types import DoubleType, FloatType, IntegerType, NestedField, StringType, StructType, TimestampType
45+
from pyiceberg.types import DoubleType, FloatType, IntegerType, NestedField, StringType, TimestampType
4946

5047

5148
def test_identity_transform_residual() -> None:
@@ -92,102 +89,25 @@ def test_identity_transform_residual() -> None:
9289
assert residual == AlwaysFalse()
9390

9491

95-
def test_residual_evaluator_does_not_mutate_prepared_state() -> None:
96-
schema = Schema(NestedField(1, "a", IntegerType()), NestedField(2, "b", IntegerType()))
97-
spec = PartitionSpec(
98-
PartitionField(1, 1001, IdentityTransform(), "a_part"),
99-
PartitionField(2, 1002, IdentityTransform(), "b_part"),
100-
)
101-
evaluator = residual_evaluator_of(
102-
spec=spec,
103-
expr=And(EqualTo("a", 1), EqualTo("b", 1)),
104-
case_sensitive=True,
105-
schema=schema,
106-
)
107-
initial_state = vars(evaluator).copy()
108-
109-
assert evaluator.residual_for(Record(1, 1)) == AlwaysTrue()
110-
assert evaluator.residual_for(Record(0, 0)) == AlwaysFalse()
111-
assert evaluator.residual_for(Record(1, 1)) == AlwaysTrue()
112-
113-
assert isinstance(evaluator, ResidualVisitor)
114-
assert vars(evaluator) == initial_state
115-
116-
11792
def test_residual_visitor_preserves_public_eval_api() -> None:
11893
schema = Schema(NestedField(1, "a", IntegerType()))
11994
spec = PartitionSpec(PartitionField(1, 1001, IdentityTransform(), "a_part"))
12095
visitor = ResidualVisitor(schema=schema, spec=spec, case_sensitive=True, expr=EqualTo("a", 1))
121-
initial_state = vars(visitor).copy()
12296

12397
assert visitor.eval(Record(1)) == AlwaysTrue()
12498
assert visitor.eval(Record(0)) == AlwaysFalse()
125-
assert vars(visitor) == initial_state
126-
127-
128-
def test_residual_evaluator_concurrent_calls_do_not_share_partitions() -> None:
129-
class BlockingRecord(Record):
130-
def __init__(self, first_read: Event, release_first_read: Event, *values: Any) -> None:
131-
super().__init__(*values)
132-
self.first_read = first_read
133-
self.release_first_read = release_first_read
134-
135-
def __getitem__(self, pos: int) -> Any:
136-
value = super().__getitem__(pos)
137-
if pos == 0:
138-
self.first_read.set()
139-
if not self.release_first_read.wait(timeout=5):
140-
raise TimeoutError("Timed out waiting to interleave residual evaluations")
141-
return value
142-
143-
schema = Schema(NestedField(1, "a", IntegerType()), NestedField(2, "b", IntegerType()))
144-
spec = PartitionSpec(
145-
PartitionField(1, 1001, IdentityTransform(), "a_part"),
146-
PartitionField(2, 1002, IdentityTransform(), "b_part"),
147-
)
148-
evaluator = residual_evaluator_of(
149-
spec=spec,
150-
expr=And(EqualTo("a", 1), EqualTo("b", 1)),
151-
case_sensitive=True,
152-
schema=schema,
153-
)
154-
first_read = Event()
155-
release_first_read = Event()
15699

157-
with ThreadPoolExecutor(max_workers=2) as executor:
158-
matching_result = executor.submit(
159-
evaluator.residual_for,
160-
BlockingRecord(first_read, release_first_read, 1, 1),
161-
)
162-
assert first_read.wait(timeout=5)
163100

164-
try:
165-
non_matching_result = executor.submit(evaluator.residual_for, Record(0, 0)).result(timeout=5)
166-
finally:
167-
release_first_read.set()
101+
def test_residual_visitor_subclass_can_customize_evaluation() -> None:
102+
class FalseForTrueResidualVisitor(ResidualVisitor):
103+
def visit_true(self) -> BooleanExpression:
104+
return AlwaysFalse()
168105

169-
assert matching_result.result(timeout=5) == AlwaysTrue()
170-
assert non_matching_result == AlwaysFalse()
171-
172-
173-
def test_partition_schema_reused_across_residuals(monkeypatch: pytest.MonkeyPatch) -> None:
174-
schema = Schema(NestedField(50, "dateint", IntegerType()))
175-
spec = PartitionSpec(PartitionField(50, 1050, IdentityTransform(), "dateint_part"))
176-
partition_type_calls = 0
177-
original_partition_type = PartitionSpec.partition_type
178-
179-
def counting_partition_type(self: PartitionSpec, schema: Schema) -> StructType:
180-
nonlocal partition_type_calls
181-
partition_type_calls += 1
182-
return original_partition_type(self, schema)
183-
184-
monkeypatch.setattr(PartitionSpec, "partition_type", counting_partition_type)
185-
186-
evaluator = residual_evaluator_of(spec=spec, expr=EqualTo("dateint", 20170815), case_sensitive=True, schema=schema)
106+
schema = Schema(NestedField(1, "a", IntegerType()))
107+
spec = PartitionSpec(PartitionField(1, 1001, IdentityTransform(), "a_part"))
108+
visitor = FalseForTrueResidualVisitor(schema=schema, spec=spec, case_sensitive=True, expr=AlwaysTrue())
187109

188-
assert evaluator.residual_for(Record(20170815)) == AlwaysTrue()
189-
assert evaluator.residual_for(Record(20170816)) == AlwaysFalse()
190-
assert partition_type_calls == 1
110+
assert visitor.eval(Record(1)) == AlwaysFalse()
191111

192112

193113
def test_case_insensitive_identity_transform_residuals() -> None:
@@ -313,6 +233,9 @@ def test_is_not_nan() -> None:
313233
res_eval = residual_evaluator_of(spec=spec, expr=predicate, case_sensitive=True, schema=schema)
314234

315235
residual = res_eval.residual_for(Record(None))
236+
assert residual == AlwaysTrue()
237+
238+
residual = res_eval.residual_for(Record(float("nan")))
316239
assert residual == AlwaysFalse()
317240

318241
residual = res_eval.residual_for(Record(2))
@@ -325,6 +248,9 @@ def test_is_not_nan() -> None:
325248
res_eval = residual_evaluator_of(spec=spec, expr=predicate, case_sensitive=True, schema=schema)
326249

327250
residual = res_eval.residual_for(Record(None))
251+
assert residual == AlwaysTrue()
252+
253+
residual = res_eval.residual_for(Record(float("nan")))
328254
assert residual == AlwaysFalse()
329255

330256
residual = res_eval.residual_for(Record(2))

0 commit comments

Comments
 (0)