Skip to content

Commit e5e3610

Browse files
committed
cast floating-point literals to decimal type with HALF_UP rounding
1 parent 6627ed4 commit e5e3610

2 files changed

Lines changed: 88 additions & 0 deletions

File tree

src/iceberg/expression/literal.cc

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <cmath>
2323
#include <concepts>
2424
#include <cstdint>
25+
#include <format>
2526
#include <string>
2627
#include <vector>
2728

@@ -89,6 +90,10 @@ class LiteralCaster {
8990
/// Cast an integer value (from Int or Long) to a decimal target type.
9091
static Result<Literal> CastIntegerToDecimal(
9192
int64_t value, const std::shared_ptr<PrimitiveType>& target_type);
93+
94+
/// Cast a floating-point value (from Float or Double) to a decimal target type.
95+
static Result<Literal> CastRealToDecimal(
96+
double value, const std::shared_ptr<PrimitiveType>& target_type);
9297
};
9398

9499
Literal LiteralCaster::BelowMinLiteral(std::shared_ptr<PrimitiveType> type) {
@@ -120,6 +125,50 @@ Result<Literal> LiteralCaster::CastIntegerToDecimal(
120125
decimal_type.scale());
121126
}
122127

128+
Result<Literal> LiteralCaster::CastRealToDecimal(
129+
double value, const std::shared_ptr<PrimitiveType>& target_type) {
130+
const auto& decimal_type = internal::checked_cast<const DecimalType&>(*target_type);
131+
const int32_t target_scale = decimal_type.scale();
132+
if (target_scale < 0 || target_scale > Decimal::kMaxScale) {
133+
return InvalidArgument("Cannot cast {} as a {} value", value,
134+
target_type->ToString());
135+
}
136+
if (!std::isfinite(value)) {
137+
return InvalidArgument("Cannot cast {} as a {} value", value,
138+
target_type->ToString());
139+
}
140+
141+
// Parse the shortest round-tripping decimal representation of the value (matching
142+
// Java's BigDecimal.valueOf(double), which goes through Double.toString) into a
143+
// full-precision decimal, then round to the target scale below.
144+
int32_t parsed_scale = 0;
145+
ICEBERG_ASSIGN_OR_RAISE(
146+
auto parsed, Decimal::FromString(std::format("{}", value), nullptr, &parsed_scale));
147+
148+
Decimal unscaled = parsed;
149+
if (parsed_scale > target_scale) {
150+
// Drop excess fractional digits with HALF_UP rounding (round half away from zero, as
151+
// Java does), since Rescale itself only truncates and rejects any dropped remainder.
152+
const int32_t drop = parsed_scale - target_scale;
153+
ICEBERG_ASSIGN_OR_RAISE(auto divisor, Decimal(1).Rescale(0, drop));
154+
ICEBERG_ASSIGN_OR_RAISE(auto divmod, parsed.Divide(divisor));
155+
Decimal quotient = divmod.first;
156+
Decimal remainder = Decimal::Abs(divmod.second);
157+
if (remainder * Decimal(2) >= divisor) {
158+
quotient += (value < 0) ? Decimal(-1) : Decimal(1);
159+
}
160+
unscaled = quotient;
161+
} else if (parsed_scale < target_scale) {
162+
ICEBERG_ASSIGN_OR_RAISE(unscaled, parsed.Rescale(parsed_scale, target_scale));
163+
}
164+
165+
if (!unscaled.FitsInPrecision(decimal_type.precision())) {
166+
return InvalidArgument("Cannot cast {} as a {} value", value,
167+
target_type->ToString());
168+
}
169+
return Literal::Decimal(unscaled.value(), decimal_type.precision(), target_scale);
170+
}
171+
123172
Result<Literal> LiteralCaster::CastFromInt(
124173
const Literal& literal, const std::shared_ptr<PrimitiveType>& target_type) {
125174
auto int_val = std::get<int32_t>(literal.value_);
@@ -195,6 +244,8 @@ Result<Literal> LiteralCaster::CastFromFloat(
195244
switch (target_type->type_id()) {
196245
case TypeId::kDouble:
197246
return Literal::Double(static_cast<double>(float_val));
247+
case TypeId::kDecimal:
248+
return CastRealToDecimal(static_cast<double>(float_val), target_type);
198249
default:
199250
return NotSupported("Cast from Float to {} is not supported",
200251
target_type->ToString());
@@ -215,6 +266,8 @@ Result<Literal> LiteralCaster::CastFromDouble(
215266
}
216267
return Literal::Float(static_cast<float>(double_val));
217268
}
269+
case TypeId::kDecimal:
270+
return CastRealToDecimal(double_val, target_type);
218271
default:
219272
return NotSupported("Cast from Double to {} is not supported",
220273
target_type->ToString());

src/iceberg/test/literal_test.cc

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,41 @@ TEST(LiteralTest, IntegerCastToDecimal) {
173173
IsError(ErrorKind::kInvalidArgument));
174174
}
175175

176+
TEST(LiteralTest, RealCastToDecimal) {
177+
// A double is scaled to the target scale keeping its unscaled value: 9.99 ->
178+
// 999@(10,2).
179+
auto d = Literal::Double(9.99).CastTo(decimal(10, 2));
180+
ASSERT_THAT(d, IsOk());
181+
EXPECT_EQ(*d, Literal::Decimal(999, 10, 2));
182+
183+
auto f = Literal::Float(1.5f).CastTo(decimal(4, 3));
184+
ASSERT_THAT(f, IsOk());
185+
EXPECT_EQ(*f, Literal::Decimal(1500, 4, 3));
186+
187+
// Rounding is HALF_UP (round half away from zero), matching Java: 2.5 -> 3 (not the
188+
// banker's-rounding 2), and -2.5 -> -3.
189+
auto half_up = Literal::Double(2.5).CastTo(decimal(2, 0));
190+
ASSERT_THAT(half_up, IsOk());
191+
EXPECT_EQ(*half_up, Literal::Decimal(3, 2, 0));
192+
193+
auto neg_half_up = Literal::Double(-2.5).CastTo(decimal(2, 0));
194+
ASSERT_THAT(neg_half_up, IsOk());
195+
EXPECT_EQ(*neg_half_up, Literal::Decimal(-3, 2, 0));
196+
197+
// Below the halfway point rounds down.
198+
auto round_down = Literal::Double(2.4).CastTo(decimal(2, 0));
199+
ASSERT_THAT(round_down, IsOk());
200+
EXPECT_EQ(*round_down, Literal::Decimal(2, 2, 0));
201+
202+
// A value whose rounded form exceeds the target precision is rejected.
203+
EXPECT_THAT(Literal::Double(123.4).CastTo(decimal(2, 0)),
204+
IsError(ErrorKind::kInvalidArgument));
205+
// Non-finite values cannot be represented as a decimal.
206+
EXPECT_THAT(
207+
Literal::Double(std::numeric_limits<double>::quiet_NaN()).CastTo(decimal(4, 2)),
208+
IsError(ErrorKind::kInvalidArgument));
209+
}
210+
176211
// Error cases for casts
177212
TEST(LiteralTest, CastToError) {
178213
std::vector<uint8_t> data = {0x01, 0x02, 0x03, 0x04};

0 commit comments

Comments
 (0)