Skip to content
182 changes: 182 additions & 0 deletions src/iceberg/expression/literal.cc
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@

#include "iceberg/expression/literal.h"

#include <array>
#include <charconv>
#include <cmath>
#include <concepts>
#include <cstdint>
#include <format>
#include <string>
#include <string_view>
#include <system_error>
#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 +91,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 +109,166 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr<PrimitiveType> type) {
return Literal(Literal::AboveMax{}, std::move(type));
}

namespace {

Status ValidateDecimalScale(int32_t scale) {
if (scale < -Decimal::kMaxScale || scale > Decimal::kMaxScale) {
return InvalidArgument("decimal scale must be in range [-{}, {}], was {}",
Decimal::kMaxScale, Decimal::kMaxScale, scale);
}
return {};
}

// 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 the coefficient by 10^delta. Report an overflow as an
// invalid argument (consistent with the other rejections here) rather than leaking
// Rescale's "data loss" error to callers.
if (delta > Decimal::kMaxScale) {
return InvalidArgument("scale change {} exceeds the maximum {}", delta,
Decimal::kMaxScale);
}
auto rescaled = unscaled.Rescale(from_scale, to_scale);
if (!rescaled.has_value()) {
return InvalidArgument("value does not fit the target decimal scale");
}
return rescaled.value();
}
// 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);
// Compare against divisor/2 rather than remainder*2: for drop near kMaxScale,
// remainder*2 can overflow int128 and flip the HALF_UP decision. Powers of ten
// with drop >= 1 are always even, so the two comparisons are equivalent.
if (remainder >= divisor / Decimal(2)) {
quotient += negative ? Decimal(-1) : Decimal(1);
}
return quotient;
}

// Parse a `std::to_chars` double rendering (`[-]digits[.digits][e[+-]exp]`) into an
// integer coefficient and the scale at which it is interpreted (value == coefficient *
// 10^-scale). The coefficient is just the significant digits, so it never overflows
// int128; the exponent is folded into the returned scale rather than expanded, leaving
// RescaleHalfUp to combine it with the target scale.
Result<Decimal> ParseRealCoefficient(std::string_view str, int32_t* scale) {
bool negative = !str.empty() && str.front() == '-';
if (negative) {
str.remove_prefix(1);
}

int32_t exponent = 0;
if (auto e = str.find_first_of("eE"); e != std::string_view::npos) {
auto exp_str = str.substr(e + 1);
// std::to_chars writes a leading '+' for positive exponents, which from_chars
// rejects.
if (!exp_str.empty() && exp_str.front() == '+') {
exp_str.remove_prefix(1);
}
if (std::from_chars(exp_str.data(), exp_str.data() + exp_str.size(), exponent).ec !=
std::errc{}) {
return InvalidArgument("Invalid real value '{}'", str);
}
str = str.substr(0, e);
}

int32_t frac_digits = 0;
std::string digits;
digits.reserve(str.size());
if (auto dot = str.find('.'); dot != std::string_view::npos) {
frac_digits = static_cast<int32_t>(str.size() - dot - 1);
digits.append(str.substr(0, dot));
digits.append(str.substr(dot + 1));
} else {
digits.append(str);
}
if (digits.empty() || digits.find_first_not_of("0123456789") != std::string::npos) {
return InvalidArgument("Invalid real value '{}'", str);
}

// Coefficient has scale `frac_digits`; a positive base-10 exponent lowers that scale.
*scale = frac_digits - exponent;
ICEBERG_ASSIGN_OR_RAISE(auto coefficient, Decimal::FromString(digits));
if (negative) {
coefficient.Negate();
}
return coefficient;
}

} // 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);
ICEBERG_RETURN_UNEXPECTED(ValidateDecimalScale(decimal_type.scale()));
// 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);
ICEBERG_RETURN_UNEXPECTED(ValidateDecimalScale(decimal_type.scale()));
if (!std::isfinite(value)) {
return InvalidArgument("Cannot cast {} as a {} value", value,
target_type->ToString());
}

// Convert via the shortest round-tripping decimal string (std::to_chars without a
// format specifier), then round to the target scale. Float callers widen to double
// first, so both float and double sources share this path.
std::array<char, 64> buf{};
auto [ptr, ec] = std::to_chars(buf.data(), buf.data() + buf.size(), value);
if (ec != std::errc{}) {
return InvalidArgument("Cannot cast {} as a {} value", value,
target_type->ToString());
}

// Parse the coefficient and its scale directly instead of via Decimal::FromString,
// which normalizes a negative scale by multiplying the coefficient by 10^-scale and can
// overflow int128 for large magnitudes (e.g. 4e38). The coefficient itself always fits,
// and RescaleHalfUp handles the exponent against the target scale (and rejects
// overflow).
int32_t coeff_scale = 0;
ICEBERG_ASSIGN_OR_RAISE(
auto coefficient,
ParseRealCoefficient(std::string_view(buf.data(), ptr), &coeff_scale));

ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(coefficient, coeff_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 +283,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 +329,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 +344,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 +366,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
98 changes: 98 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,102 @@ 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));
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));

// Shortest round-trip conversion keeps digits beyond a default 6-digit %g.
auto precise = Literal::Double(1.23456789).CastTo(decimal(20, 8));
ASSERT_THAT(precise, IsOk());
EXPECT_EQ(*precise, Literal::Decimal(123456789, 20, 8));

// 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));
EXPECT_THAT(Literal::Double(1.0).CastTo(std::make_shared<DecimalType>(9, -40)),
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));

// A large magnitude must be rejected for exceeding precision, not wrap the int128
// coefficient: 4e38 needs 39 digits, over decimal(38, 0)'s precision.
EXPECT_THAT(Literal::Double(4e38).CastTo(decimal(38, 0)),
IsError(ErrorKind::kInvalidArgument));

// A large magnitude that IS representable at a negative scale is accepted: 1e39 as
// decimal(2, -38) is the unscaled value 10 (10 * 10^38).
auto big_neg_scale = Literal::Double(1e39).CastTo(decimal(2, -38));
ASSERT_THAT(big_neg_scale, IsOk());
EXPECT_EQ(*big_neg_scale, Literal::Decimal(10, 2, -38));
}

// Error cases for casts
TEST(LiteralTest, CastToError) {
std::vector<uint8_t> data = {0x01, 0x02, 0x03, 0x04};
Expand Down
Loading