Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
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));
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));

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<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