Skip to content

Commit f848e98

Browse files
breken-aiclaude
andcommitted
fix(expressions): reject fractional decimal literals on integer columns
DecimalLiteral.to(IntegerType/LongType) rounded the value with to_integral_value(), so an unquoted filter such as "x > 2.6" on an int column was bound as x > 3 and silently dropped x = 3; "x < 2.5" became x < 2 and "x = 2.5" matched x = 2. Raise a ValueError for a value with a fractional part instead, the same way a Decimal with a mismatched scale is rejected. Integral values such as 2.00 still convert. Generated-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 39440e5 commit f848e98

3 files changed

Lines changed: 36 additions & 4 deletions

File tree

‎pyiceberg/expressions/literals.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ def _parse_numeric_string(value: str) -> Decimal:
7575
return number
7676

7777

78+
def _to_integral(value: Decimal, type_var: IcebergType) -> int:
79+
"""Convert a Decimal to an int, rejecting values with a fractional part instead of rounding them."""
80+
if value != value.to_integral_value():
81+
raise ValueError(f"Could not convert {value} into a {type_var}, value has a fractional part")
82+
return int(value)
83+
84+
7885
class Literal(IcebergRootModel[L], Generic[L], ABC): # type: ignore
7986
"""Literal which has a value and can be converted between types."""
8087

@@ -527,24 +534,24 @@ def _(self, type_var: DecimalType) -> Literal[Decimal]:
527534
raise ValueError(f"Could not convert {self.value} into a {type_var}")
528535

529536
@to.register(IntegerType)
530-
def _(self, _: IntegerType) -> Literal[int]:
537+
def _(self, type_var: IntegerType) -> Literal[int]:
531538
value_int = int(self.value.to_integral_value())
532539
if value_int > IntegerType.max:
533540
return IntAboveMax()
534541
elif value_int < IntegerType.min:
535542
return IntBelowMin()
536543
else:
537-
return LongLiteral(value_int)
544+
return LongLiteral(_to_integral(self.value, type_var))
538545

539546
@to.register(LongType)
540-
def _(self, _: LongType) -> Literal[int]:
547+
def _(self, type_var: LongType) -> Literal[int]:
541548
value_int = int(self.value.to_integral_value())
542549
if value_int > LongType.max:
543550
return LongAboveMax()
544551
elif value_int < LongType.min:
545552
return LongBelowMin()
546553
else:
547-
return LongLiteral(value_int)
554+
return LongLiteral(_to_integral(self.value, type_var))
548555

549556
@to.register(FloatType)
550557
def _(self, _: FloatType) -> Literal[float]:

‎tests/catalog/test_catalog_behaviors.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,18 @@ def test_append_invalid_input_type_raises(catalog: Catalog) -> None:
13221322
tbl.append("not an arrow object")
13231323

13241324

1325+
def test_scan_integer_column_with_decimal_literal(catalog: Catalog) -> None:
1326+
catalog.create_namespace("default")
1327+
identifier = f"default.scan_integer_decimal_literal_{catalog.name}"
1328+
tbl = catalog.create_table(identifier=identifier, schema=pa.schema([pa.field("x", pa.int32())]))
1329+
tbl.append(pa.table({"x": pa.array([1, 2, 3, 4], pa.int32())}))
1330+
1331+
assert sorted(tbl.scan(row_filter="x > 2.0").to_arrow()["x"].to_pylist()) == [3, 4]
1332+
# Rounding 2.6 to 3 would turn x > 2.6 into x > 3 and silently drop x = 3
1333+
with pytest.raises(ValueError, match="Could not convert 2.6 into a int"):
1334+
tbl.scan(row_filter="x > 2.6").to_arrow()
1335+
1336+
13251337
def test_record_batch_reader_consumed_exactly_once(catalog: Catalog) -> None:
13261338
"""The streaming path must consume the underlying generator exactly once.
13271339
A regression that drained the reader twice (e.g. an extra .schema access

‎tests/expressions/test_literals.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,19 @@ def test_decimal_to_long_below_min() -> None:
919919
assert isinstance(DecimalLiteral(Decimal(LongType.min - 1)).to(LongType()), LongBelowMin)
920920

921921

922+
@pytest.mark.parametrize("value", ["2.5", "2.6", "-2.5", "0.1"])
923+
@pytest.mark.parametrize("target_type", [IntegerType(), LongType()])
924+
def test_fractional_decimal_to_integral_type_raises(value: str, target_type: PrimitiveType) -> None:
925+
# Rounding would change the predicate, e.g. x > 2.6 would become x > 3 and drop x = 3
926+
with pytest.raises(ValueError, match=f"Could not convert {value} into a {target_type}"):
927+
_ = DecimalLiteral(Decimal(value)).to(target_type)
928+
929+
930+
@pytest.mark.parametrize("target_type", [IntegerType(), LongType()])
931+
def test_integral_decimal_to_integral_type(target_type: PrimitiveType) -> None:
932+
assert DecimalLiteral(Decimal("2.00")).to(target_type) == LongLiteral(2)
933+
934+
922935
def test_string_to_integer_type_invalid_value() -> None:
923936
with pytest.raises(ValueError) as e:
924937
_ = literal("abc").to(IntegerType())

0 commit comments

Comments
 (0)