Skip to content
Open
97 changes: 97 additions & 0 deletions src/iceberg/expression/literal.cc
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
#include <cmath>
#include <concepts>
#include <cstdint>
#include <format>
#include <string>
#include <vector>

#include "iceberg/result.h"
#include "iceberg/type.h"
#include "iceberg/util/checked_cast.h"
#include "iceberg/util/conversions.h"
Expand Down Expand Up @@ -85,6 +87,14 @@ class LiteralCaster {
/// Cast from Fixed type to target type.
static Result<Literal> CastFromFixed(const Literal& literal,
const std::shared_ptr<PrimitiveType>& target_type);

/// Cast an integer value (from Int or Long) to a decimal target type.
static Result<Literal> CastIntegerToDecimal(
int64_t value, const std::shared_ptr<PrimitiveType>& target_type);

/// Cast a floating-point value (from Float or Double) to a decimal target type.
static Result<Literal> CastRealToDecimal(
double value, const std::shared_ptr<PrimitiveType>& target_type);
};

Literal LiteralCaster::BelowMinLiteral(std::shared_ptr<PrimitiveType> type) {
Expand All @@ -95,6 +105,85 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr<PrimitiveType> 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<Decimal> 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);
}
Comment thread
huan233usc marked this conversation as resolved.
Outdated
return quotient;
}

} // namespace

Result<Literal> LiteralCaster::CastIntegerToDecimal(
int64_t value, const std::shared_ptr<PrimitiveType>& target_type) {
const auto& decimal_type = internal::checked_cast<const DecimalType&>(*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));
Comment thread
huan233usc marked this conversation as resolved.
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<Literal> LiteralCaster::CastRealToDecimal(
double value, const std::shared_ptr<PrimitiveType>& target_type) {
const auto& decimal_type = internal::checked_cast<const DecimalType&>(*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));
Comment thread
huan233usc marked this conversation as resolved.
Outdated

ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(parsed, parsed_scale,
decimal_type.scale(), value < 0));
Comment thread
huan233usc marked this conversation as resolved.
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<Literal> LiteralCaster::CastFromInt(
const Literal& literal, const std::shared_ptr<PrimitiveType>& target_type) {
auto int_val = std::get<int32_t>(literal.value_);
Expand All @@ -109,6 +198,8 @@ Result<Literal> LiteralCaster::CastFromInt(
return Literal::Double(static_cast<double>(int_val));
case TypeId::kDate:
return Literal::Date(int_val);
case TypeId::kDecimal:
return CastIntegerToDecimal(static_cast<int64_t>(int_val), target_type);
default:
return NotSupported("Cast from Int to {} is not implemented",
target_type->ToString());
Expand Down Expand Up @@ -153,6 +244,8 @@ Result<Literal> 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());
Expand All @@ -166,6 +259,8 @@ Result<Literal> LiteralCaster::CastFromFloat(
switch (target_type->type_id()) {
case TypeId::kDouble:
return Literal::Double(static_cast<double>(float_val));
case TypeId::kDecimal:
return CastRealToDecimal(static_cast<double>(float_val), target_type);
default:
return NotSupported("Cast from Float to {} is not supported",
target_type->ToString());
Expand All @@ -186,6 +281,8 @@ Result<Literal> LiteralCaster::CastFromDouble(
}
return Literal::Float(static_cast<float>(double_val));
}
case TypeId::kDecimal:
return CastRealToDecimal(double_val, target_type);
default:
return NotSupported("Cast from Double to {} is not supported",
target_type->ToString());
Expand Down
78 changes: 78 additions & 0 deletions src/iceberg/test/literal_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
#include "iceberg/expression/literal.h"

#include <limits>
#include <memory>
#include <numbers>
#include <unordered_set>
#include <vector>

#include <gtest/gtest.h>

#include "iceberg/result.h"
#include "iceberg/test/matchers.h"
#include "iceberg/test/temporal_test_helper.h"
#include "iceberg/type.h"
Expand Down Expand Up @@ -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<DecimalType>(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<double>::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<uint8_t> data = {0x01, 0x02, 0x03, 0x04};
Expand Down
Loading