From 81300e69a6fefd76330230320ff54c26c1df9298 Mon Sep 17 00:00:00 2001 From: Xin Huang Date: Fri, 3 Jul 2026 10:39:02 -0700 Subject: [PATCH 1/5] feat(expression): cast integer literals to decimal type --- src/iceberg/expression/literal.cc | 22 ++++++++++++++++++++++ src/iceberg/test/literal_test.cc | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index d11ab2656..45985dd6f 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -85,6 +85,10 @@ class LiteralCaster { /// Cast from Fixed type to target type. static Result CastFromFixed(const Literal& literal, const std::shared_ptr& target_type); + + /// Cast an integer value (from Int or Long) to a decimal target type. + static Result CastIntegerToDecimal( + int64_t value, const std::shared_ptr& target_type); }; Literal LiteralCaster::BelowMinLiteral(std::shared_ptr type) { @@ -95,6 +99,20 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr type) { return Literal(Literal::AboveMax{}, std::move(type)); } +Result LiteralCaster::CastIntegerToDecimal( + int64_t value, const std::shared_ptr& target_type) { + const auto& decimal_type = internal::checked_cast(*target_type); + // An integer has scale 0, so scaling it to the target scale multiplies the unscaled + // value by 10^scale; Rescale rejects a target scale that would overflow the value. + ICEBERG_ASSIGN_OR_RAISE(auto decimal, Decimal(value).Rescale(0, decimal_type.scale())); + if (!decimal.FitsInPrecision(decimal_type.precision())) { + return InvalidArgument("Cannot cast {} as a {} value", value, + target_type->ToString()); + } + return Literal::Decimal(decimal.value(), decimal_type.precision(), + decimal_type.scale()); +} + Result LiteralCaster::CastFromInt( const Literal& literal, const std::shared_ptr& target_type) { auto int_val = std::get(literal.value_); @@ -109,6 +127,8 @@ Result LiteralCaster::CastFromInt( return Literal::Double(static_cast(int_val)); case TypeId::kDate: return Literal::Date(int_val); + case TypeId::kDecimal: + return CastIntegerToDecimal(static_cast(int_val), target_type); default: return NotSupported("Cast from Int to {} is not implemented", target_type->ToString()); @@ -153,6 +173,8 @@ Result LiteralCaster::CastFromLong( return Literal::TimestampNs(long_val); case TypeId::kTimestampTzNs: return Literal::TimestampTzNs(long_val); + case TypeId::kDecimal: + return CastIntegerToDecimal(long_val, target_type); default: return NotSupported("Cast from Long to {} is not supported", target_type->ToString()); diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc index 433c4fbed..5425d78df 100644 --- a/src/iceberg/test/literal_test.cc +++ b/src/iceberg/test/literal_test.cc @@ -150,6 +150,24 @@ TEST(LiteralTest, DoubleCastToOverflow) { EXPECT_TRUE(min_result->IsBelowMin()); } +TEST(LiteralTest, IntegerCastToDecimal) { + // An integer default is scaled up to the target decimal scale: 12 -> 12.00 keeps the + // unscaled value 1200 at precision/scale (9, 2). + auto int_result = Literal::Int(12).CastTo(decimal(9, 2)); + ASSERT_THAT(int_result, IsOk()); + EXPECT_EQ(*int_result, Literal::Decimal(1200, 9, 2)); + + auto long_result = Literal::Long(int64_t{12}).CastTo(decimal(18, 3)); + ASSERT_THAT(long_result, IsOk()); + EXPECT_EQ(*long_result, Literal::Decimal(12000, 18, 3)); + + // A value whose scaled form needs more digits than the target precision is rejected. + EXPECT_THAT(Literal::Int(12345).CastTo(decimal(4, 0)), + IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(Literal::Long(int64_t{100}).CastTo(decimal(4, 2)), + IsError(ErrorKind::kInvalidArgument)); +} + // Error cases for casts TEST(LiteralTest, CastToError) { std::vector data = {0x01, 0x02, 0x03, 0x04}; From 3262207d2565fb9beddecbe24ae104f4d507d6a9 Mon Sep 17 00:00:00 2001 From: Xin Huang Date: Fri, 3 Jul 2026 13:38:29 -0700 Subject: [PATCH 2/5] reject out-of-range decimal scale before rescaling integer literals --- src/iceberg/expression/literal.cc | 9 ++++++++- src/iceberg/test/literal_test.cc | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index 45985dd6f..08aadce69 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -102,9 +102,16 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr type) { Result LiteralCaster::CastIntegerToDecimal( int64_t value, const std::shared_ptr& target_type) { const auto& decimal_type = internal::checked_cast(*target_type); + const int32_t scale = decimal_type.scale(); + // DecimalType does not bound its scale, but Rescale indexes a powers-of-ten table sized + // for [0, kMaxScale]; reject an out-of-range scale here rather than reading past it. + if (scale < 0 || scale > Decimal::kMaxScale) { + return InvalidArgument("Cannot cast {} as a {} value", value, + target_type->ToString()); + } // An integer has scale 0, so scaling it to the target scale multiplies the unscaled // value by 10^scale; Rescale rejects a target scale that would overflow the value. - ICEBERG_ASSIGN_OR_RAISE(auto decimal, Decimal(value).Rescale(0, decimal_type.scale())); + ICEBERG_ASSIGN_OR_RAISE(auto decimal, Decimal(value).Rescale(0, scale)); if (!decimal.FitsInPrecision(decimal_type.precision())) { return InvalidArgument("Cannot cast {} as a {} value", value, target_type->ToString()); diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc index 5425d78df..bac48c14c 100644 --- a/src/iceberg/test/literal_test.cc +++ b/src/iceberg/test/literal_test.cc @@ -166,6 +166,11 @@ TEST(LiteralTest, IntegerCastToDecimal) { IsError(ErrorKind::kInvalidArgument)); EXPECT_THAT(Literal::Long(int64_t{100}).CastTo(decimal(4, 2)), IsError(ErrorKind::kInvalidArgument)); + + // A scale beyond the decimal powers-of-ten table is rejected rather than read past it + // (DecimalType does not bound its scale on construction). + EXPECT_THAT(Literal::Int(1).CastTo(std::make_shared(9, 40)), + IsError(ErrorKind::kInvalidArgument)); } // Error cases for casts From bc4270635ce591c556167963fc45185f8331782a Mon Sep 17 00:00:00 2001 From: Xin Huang Date: Fri, 3 Jul 2026 14:18:01 -0700 Subject: [PATCH 3/5] cast floating-point literals to decimal type with HALF_UP rounding --- src/iceberg/expression/literal.cc | 53 +++++++++++++++++++++++++++++++ src/iceberg/test/literal_test.cc | 35 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index 08aadce69..e4fad8b88 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -89,6 +90,10 @@ class LiteralCaster { /// Cast an integer value (from Int or Long) to a decimal target type. static Result CastIntegerToDecimal( int64_t value, const std::shared_ptr& target_type); + + /// Cast a floating-point value (from Float or Double) to a decimal target type. + static Result CastRealToDecimal( + double value, const std::shared_ptr& target_type); }; Literal LiteralCaster::BelowMinLiteral(std::shared_ptr type) { @@ -120,6 +125,50 @@ Result LiteralCaster::CastIntegerToDecimal( decimal_type.scale()); } +Result LiteralCaster::CastRealToDecimal( + double value, const std::shared_ptr& target_type) { + const auto& decimal_type = internal::checked_cast(*target_type); + const int32_t target_scale = decimal_type.scale(); + if (target_scale < 0 || target_scale > Decimal::kMaxScale) { + return InvalidArgument("Cannot cast {} as a {} value", value, + target_type->ToString()); + } + if (!std::isfinite(value)) { + return InvalidArgument("Cannot cast {} as a {} value", value, + target_type->ToString()); + } + + // Parse the shortest round-tripping decimal representation of the value (matching + // Java's BigDecimal.valueOf(double), which goes through Double.toString) into a + // full-precision decimal, then round to the target scale below. + int32_t parsed_scale = 0; + ICEBERG_ASSIGN_OR_RAISE( + auto parsed, Decimal::FromString(std::format("{}", value), nullptr, &parsed_scale)); + + Decimal unscaled = parsed; + if (parsed_scale > target_scale) { + // Drop excess fractional digits with HALF_UP rounding (round half away from zero, as + // Java does), since Rescale itself only truncates and rejects any dropped remainder. + const int32_t drop = parsed_scale - target_scale; + ICEBERG_ASSIGN_OR_RAISE(auto divisor, Decimal(1).Rescale(0, drop)); + ICEBERG_ASSIGN_OR_RAISE(auto divmod, parsed.Divide(divisor)); + Decimal quotient = divmod.first; + Decimal remainder = Decimal::Abs(divmod.second); + if (remainder * Decimal(2) >= divisor) { + quotient += (value < 0) ? Decimal(-1) : Decimal(1); + } + unscaled = quotient; + } else if (parsed_scale < target_scale) { + ICEBERG_ASSIGN_OR_RAISE(unscaled, parsed.Rescale(parsed_scale, target_scale)); + } + + if (!unscaled.FitsInPrecision(decimal_type.precision())) { + return InvalidArgument("Cannot cast {} as a {} value", value, + target_type->ToString()); + } + return Literal::Decimal(unscaled.value(), decimal_type.precision(), target_scale); +} + Result LiteralCaster::CastFromInt( const Literal& literal, const std::shared_ptr& target_type) { auto int_val = std::get(literal.value_); @@ -195,6 +244,8 @@ Result LiteralCaster::CastFromFloat( switch (target_type->type_id()) { case TypeId::kDouble: return Literal::Double(static_cast(float_val)); + case TypeId::kDecimal: + return CastRealToDecimal(static_cast(float_val), target_type); default: return NotSupported("Cast from Float to {} is not supported", target_type->ToString()); @@ -215,6 +266,8 @@ Result LiteralCaster::CastFromDouble( } return Literal::Float(static_cast(double_val)); } + case TypeId::kDecimal: + return CastRealToDecimal(double_val, target_type); default: return NotSupported("Cast from Double to {} is not supported", target_type->ToString()); diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc index bac48c14c..b13276af2 100644 --- a/src/iceberg/test/literal_test.cc +++ b/src/iceberg/test/literal_test.cc @@ -173,6 +173,41 @@ TEST(LiteralTest, IntegerCastToDecimal) { IsError(ErrorKind::kInvalidArgument)); } +TEST(LiteralTest, RealCastToDecimal) { + // A double is scaled to the target scale keeping its unscaled value: 9.99 -> + // 999@(10,2). + auto d = Literal::Double(9.99).CastTo(decimal(10, 2)); + ASSERT_THAT(d, IsOk()); + EXPECT_EQ(*d, Literal::Decimal(999, 10, 2)); + + auto f = Literal::Float(1.5f).CastTo(decimal(4, 3)); + ASSERT_THAT(f, IsOk()); + EXPECT_EQ(*f, Literal::Decimal(1500, 4, 3)); + + // Rounding is HALF_UP (round half away from zero), matching Java: 2.5 -> 3 (not the + // banker's-rounding 2), and -2.5 -> -3. + auto half_up = Literal::Double(2.5).CastTo(decimal(2, 0)); + ASSERT_THAT(half_up, IsOk()); + EXPECT_EQ(*half_up, Literal::Decimal(3, 2, 0)); + + auto neg_half_up = Literal::Double(-2.5).CastTo(decimal(2, 0)); + ASSERT_THAT(neg_half_up, IsOk()); + EXPECT_EQ(*neg_half_up, Literal::Decimal(-3, 2, 0)); + + // Below the halfway point rounds down. + auto round_down = Literal::Double(2.4).CastTo(decimal(2, 0)); + ASSERT_THAT(round_down, IsOk()); + EXPECT_EQ(*round_down, Literal::Decimal(2, 2, 0)); + + // A value whose rounded form exceeds the target precision is rejected. + EXPECT_THAT(Literal::Double(123.4).CastTo(decimal(2, 0)), + IsError(ErrorKind::kInvalidArgument)); + // Non-finite values cannot be represented as a decimal. + EXPECT_THAT( + Literal::Double(std::numeric_limits::quiet_NaN()).CastTo(decimal(4, 2)), + IsError(ErrorKind::kInvalidArgument)); +} + // Error cases for casts TEST(LiteralTest, CastToError) { std::vector data = {0x01, 0x02, 0x03, 0x04}; From 09a45b6d451984fa971faee378fef3f69a97775f Mon Sep 17 00:00:00 2001 From: Xin Huang Date: Sun, 12 Jul 2026 14:20:30 -0700 Subject: [PATCH 4/5] handle negative and out-of-range decimal scales in numeric casts --- src/iceberg/expression/literal.cc | 86 ++++++++++++++++++------------- src/iceberg/test/literal_test.cc | 18 +++++++ 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index e4fad8b88..1b6e644ca 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -104,35 +104,63 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr type) { return Literal(Literal::AboveMax{}, std::move(type)); } +namespace { + +// Rescale `unscaled` (interpreted at `from_scale`) to `to_scale` using HALF_UP rounding +// (round half away from zero), matching Java's BigDecimal.setScale(scale, HALF_UP). +// Unlike Decimal::Rescale, which only truncates and rejects any dropped remainder, this +// rounds, and it supports the full negative..positive scale range Iceberg decimals allow. +Result RescaleHalfUp(const Decimal& unscaled, int32_t from_scale, + int32_t to_scale, bool negative) { + const int32_t delta = to_scale - from_scale; + if (delta == 0) { + return unscaled; + } + if (delta > 0) { + // Growing the scale multiplies by 10^delta and is exact; Rescale rejects overflow. + if (delta > Decimal::kMaxScale) { + return InvalidArgument("scale change {} exceeds the maximum {}", delta, + Decimal::kMaxScale); + } + return unscaled.Rescale(from_scale, to_scale); + } + // Shrinking the scale drops `drop` digits with HALF_UP rounding. A drop larger than the + // digits any decimal can hold rounds everything away, so the result is zero (e.g. + // 1e-100 to decimal(9, 2)); this also keeps the divisor within the powers-of-ten table. + const int32_t drop = -delta; + if (drop > Decimal::kMaxScale) { + return Decimal(0); + } + ICEBERG_ASSIGN_OR_RAISE(auto divisor, Decimal(1).Rescale(0, drop)); + ICEBERG_ASSIGN_OR_RAISE(auto divmod, unscaled.Divide(divisor)); + Decimal quotient = divmod.first; + Decimal remainder = Decimal::Abs(divmod.second); + if (remainder * Decimal(2) >= divisor) { + quotient += negative ? Decimal(-1) : Decimal(1); + } + return quotient; +} + +} // namespace + Result LiteralCaster::CastIntegerToDecimal( int64_t value, const std::shared_ptr& target_type) { const auto& decimal_type = internal::checked_cast(*target_type); - const int32_t scale = decimal_type.scale(); - // DecimalType does not bound its scale, but Rescale indexes a powers-of-ten table sized - // for [0, kMaxScale]; reject an out-of-range scale here rather than reading past it. - if (scale < 0 || scale > Decimal::kMaxScale) { - return InvalidArgument("Cannot cast {} as a {} value", value, - target_type->ToString()); - } - // An integer has scale 0, so scaling it to the target scale multiplies the unscaled - // value by 10^scale; Rescale rejects a target scale that would overflow the value. - ICEBERG_ASSIGN_OR_RAISE(auto decimal, Decimal(value).Rescale(0, scale)); - if (!decimal.FitsInPrecision(decimal_type.precision())) { + // An integer has scale 0; rescale it to the target scale, rounding HALF_UP when the + // target scale is negative (matching Java's numeric-to-decimal default handling). + ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(Decimal(value), /*from_scale=*/0, + decimal_type.scale(), value < 0)); + if (!unscaled.FitsInPrecision(decimal_type.precision())) { return InvalidArgument("Cannot cast {} as a {} value", value, target_type->ToString()); } - return Literal::Decimal(decimal.value(), decimal_type.precision(), + return Literal::Decimal(unscaled.value(), decimal_type.precision(), decimal_type.scale()); } Result LiteralCaster::CastRealToDecimal( double value, const std::shared_ptr& target_type) { const auto& decimal_type = internal::checked_cast(*target_type); - const int32_t target_scale = decimal_type.scale(); - if (target_scale < 0 || target_scale > Decimal::kMaxScale) { - return InvalidArgument("Cannot cast {} as a {} value", value, - target_type->ToString()); - } if (!std::isfinite(value)) { return InvalidArgument("Cannot cast {} as a {} value", value, target_type->ToString()); @@ -140,33 +168,19 @@ Result LiteralCaster::CastRealToDecimal( // Parse the shortest round-tripping decimal representation of the value (matching // Java's BigDecimal.valueOf(double), which goes through Double.toString) into a - // full-precision decimal, then round to the target scale below. + // full-precision decimal, then round to the target scale. int32_t parsed_scale = 0; ICEBERG_ASSIGN_OR_RAISE( auto parsed, Decimal::FromString(std::format("{}", value), nullptr, &parsed_scale)); - Decimal unscaled = parsed; - if (parsed_scale > target_scale) { - // Drop excess fractional digits with HALF_UP rounding (round half away from zero, as - // Java does), since Rescale itself only truncates and rejects any dropped remainder. - const int32_t drop = parsed_scale - target_scale; - ICEBERG_ASSIGN_OR_RAISE(auto divisor, Decimal(1).Rescale(0, drop)); - ICEBERG_ASSIGN_OR_RAISE(auto divmod, parsed.Divide(divisor)); - Decimal quotient = divmod.first; - Decimal remainder = Decimal::Abs(divmod.second); - if (remainder * Decimal(2) >= divisor) { - quotient += (value < 0) ? Decimal(-1) : Decimal(1); - } - unscaled = quotient; - } else if (parsed_scale < target_scale) { - ICEBERG_ASSIGN_OR_RAISE(unscaled, parsed.Rescale(parsed_scale, target_scale)); - } - + ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(parsed, parsed_scale, + decimal_type.scale(), value < 0)); if (!unscaled.FitsInPrecision(decimal_type.precision())) { return InvalidArgument("Cannot cast {} as a {} value", value, target_type->ToString()); } - return Literal::Decimal(unscaled.value(), decimal_type.precision(), target_scale); + return Literal::Decimal(unscaled.value(), decimal_type.precision(), + decimal_type.scale()); } Result LiteralCaster::CastFromInt( diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc index b13276af2..34a8c4129 100644 --- a/src/iceberg/test/literal_test.cc +++ b/src/iceberg/test/literal_test.cc @@ -171,6 +171,12 @@ TEST(LiteralTest, IntegerCastToDecimal) { // (DecimalType does not bound its scale on construction). EXPECT_THAT(Literal::Int(1).CastTo(std::make_shared(9, 40)), IsError(ErrorKind::kInvalidArgument)); + + // Negative scale: decimal(9, -2) scales by 10^2, so 149 rounds HALF_UP to 100 (unscaled + // value 1 at scale -2), matching Java's setScale(-2, HALF_UP). + auto neg_scale = Literal::Int(149).CastTo(decimal(9, -2)); + ASSERT_THAT(neg_scale, IsOk()); + EXPECT_EQ(*neg_scale, Literal::Decimal(1, 9, -2)); } TEST(LiteralTest, RealCastToDecimal) { @@ -206,6 +212,18 @@ TEST(LiteralTest, RealCastToDecimal) { EXPECT_THAT( Literal::Double(std::numeric_limits::quiet_NaN()).CastTo(decimal(4, 2)), IsError(ErrorKind::kInvalidArgument)); + + // A magnitude far smaller than the target scale rounds to zero rather than indexing + // past the powers-of-ten table (Java rounds 1e-100 to 0.00). + auto tiny = Literal::Double(1e-100).CastTo(decimal(9, 2)); + ASSERT_THAT(tiny, IsOk()); + EXPECT_EQ(*tiny, Literal::Decimal(0, 9, 2)); + + // Negative scale: decimal(9, -2) scales by 10^2, so 149 rounds HALF_UP to 100 (unscaled + // value 1 at scale -2), matching Java's setScale(-2, HALF_UP). + auto neg_scale = Literal::Double(149.0).CastTo(decimal(9, -2)); + ASSERT_THAT(neg_scale, IsOk()); + EXPECT_EQ(*neg_scale, Literal::Decimal(1, 9, -2)); } // Error cases for casts From 0d949dfcd52bb9ededfd4f6958cf3ce671ff6168 Mon Sep 17 00:00:00 2001 From: Xin Huang Date: Sun, 12 Jul 2026 15:07:12 -0700 Subject: [PATCH 5/5] add direct includes for Result and memory to satisfy include-cleaner --- src/iceberg/expression/literal.cc | 1 + src/iceberg/test/literal_test.cc | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index 1b6e644ca..d2f51d5e1 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -26,6 +26,7 @@ #include #include +#include "iceberg/result.h" #include "iceberg/type.h" #include "iceberg/util/checked_cast.h" #include "iceberg/util/conversions.h" diff --git a/src/iceberg/test/literal_test.cc b/src/iceberg/test/literal_test.cc index 34a8c4129..a596044ec 100644 --- a/src/iceberg/test/literal_test.cc +++ b/src/iceberg/test/literal_test.cc @@ -20,12 +20,14 @@ #include "iceberg/expression/literal.h" #include +#include #include #include #include #include +#include "iceberg/result.h" #include "iceberg/test/matchers.h" #include "iceberg/test/temporal_test_helper.h" #include "iceberg/type.h"