diff --git a/src/iceberg/expression/literal.cc b/src/iceberg/expression/literal.cc index d11ab2656..d2f51d5e1 100644 --- a/src/iceberg/expression/literal.cc +++ b/src/iceberg/expression/literal.cc @@ -22,9 +22,11 @@ #include #include #include +#include #include #include +#include "iceberg/result.h" #include "iceberg/type.h" #include "iceberg/util/checked_cast.h" #include "iceberg/util/conversions.h" @@ -85,6 +87,14 @@ 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); + + /// 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) { @@ -95,6 +105,85 @@ 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); + // 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(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); + 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. + int32_t parsed_scale = 0; + ICEBERG_ASSIGN_OR_RAISE( + auto parsed, Decimal::FromString(std::format("{}", value), nullptr, &parsed_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(), + decimal_type.scale()); +} + Result LiteralCaster::CastFromInt( const Literal& literal, const std::shared_ptr& target_type) { auto int_val = std::get(literal.value_); @@ -109,6 +198,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 +244,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()); @@ -166,6 +259,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()); @@ -186,6 +281,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 433c4fbed..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" @@ -150,6 +152,82 @@ 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)); + + // 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)); + + // 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) { + // 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)); + + // 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 TEST(LiteralTest, CastToError) { std::vector data = {0x01, 0x02, 0x03, 0x04};