Skip to content

Commit 09a45b6

Browse files
committed
handle negative and out-of-range decimal scales in numeric casts
1 parent bc42706 commit 09a45b6

2 files changed

Lines changed: 68 additions & 36 deletions

File tree

src/iceberg/expression/literal.cc

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -104,69 +104,83 @@ Literal LiteralCaster::AboveMaxLiteral(std::shared_ptr<PrimitiveType> type) {
104104
return Literal(Literal::AboveMax{}, std::move(type));
105105
}
106106

107+
namespace {
108+
109+
// Rescale `unscaled` (interpreted at `from_scale`) to `to_scale` using HALF_UP rounding
110+
// (round half away from zero), matching Java's BigDecimal.setScale(scale, HALF_UP).
111+
// Unlike Decimal::Rescale, which only truncates and rejects any dropped remainder, this
112+
// rounds, and it supports the full negative..positive scale range Iceberg decimals allow.
113+
Result<Decimal> RescaleHalfUp(const Decimal& unscaled, int32_t from_scale,
114+
int32_t to_scale, bool negative) {
115+
const int32_t delta = to_scale - from_scale;
116+
if (delta == 0) {
117+
return unscaled;
118+
}
119+
if (delta > 0) {
120+
// Growing the scale multiplies by 10^delta and is exact; Rescale rejects overflow.
121+
if (delta > Decimal::kMaxScale) {
122+
return InvalidArgument("scale change {} exceeds the maximum {}", delta,
123+
Decimal::kMaxScale);
124+
}
125+
return unscaled.Rescale(from_scale, to_scale);
126+
}
127+
// Shrinking the scale drops `drop` digits with HALF_UP rounding. A drop larger than the
128+
// digits any decimal can hold rounds everything away, so the result is zero (e.g.
129+
// 1e-100 to decimal(9, 2)); this also keeps the divisor within the powers-of-ten table.
130+
const int32_t drop = -delta;
131+
if (drop > Decimal::kMaxScale) {
132+
return Decimal(0);
133+
}
134+
ICEBERG_ASSIGN_OR_RAISE(auto divisor, Decimal(1).Rescale(0, drop));
135+
ICEBERG_ASSIGN_OR_RAISE(auto divmod, unscaled.Divide(divisor));
136+
Decimal quotient = divmod.first;
137+
Decimal remainder = Decimal::Abs(divmod.second);
138+
if (remainder * Decimal(2) >= divisor) {
139+
quotient += negative ? Decimal(-1) : Decimal(1);
140+
}
141+
return quotient;
142+
}
143+
144+
} // namespace
145+
107146
Result<Literal> LiteralCaster::CastIntegerToDecimal(
108147
int64_t value, const std::shared_ptr<PrimitiveType>& target_type) {
109148
const auto& decimal_type = internal::checked_cast<const DecimalType&>(*target_type);
110-
const int32_t scale = decimal_type.scale();
111-
// DecimalType does not bound its scale, but Rescale indexes a powers-of-ten table sized
112-
// for [0, kMaxScale]; reject an out-of-range scale here rather than reading past it.
113-
if (scale < 0 || scale > Decimal::kMaxScale) {
114-
return InvalidArgument("Cannot cast {} as a {} value", value,
115-
target_type->ToString());
116-
}
117-
// An integer has scale 0, so scaling it to the target scale multiplies the unscaled
118-
// value by 10^scale; Rescale rejects a target scale that would overflow the value.
119-
ICEBERG_ASSIGN_OR_RAISE(auto decimal, Decimal(value).Rescale(0, scale));
120-
if (!decimal.FitsInPrecision(decimal_type.precision())) {
149+
// An integer has scale 0; rescale it to the target scale, rounding HALF_UP when the
150+
// target scale is negative (matching Java's numeric-to-decimal default handling).
151+
ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(Decimal(value), /*from_scale=*/0,
152+
decimal_type.scale(), value < 0));
153+
if (!unscaled.FitsInPrecision(decimal_type.precision())) {
121154
return InvalidArgument("Cannot cast {} as a {} value", value,
122155
target_type->ToString());
123156
}
124-
return Literal::Decimal(decimal.value(), decimal_type.precision(),
157+
return Literal::Decimal(unscaled.value(), decimal_type.precision(),
125158
decimal_type.scale());
126159
}
127160

128161
Result<Literal> LiteralCaster::CastRealToDecimal(
129162
double value, const std::shared_ptr<PrimitiveType>& target_type) {
130163
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-
}
136164
if (!std::isfinite(value)) {
137165
return InvalidArgument("Cannot cast {} as a {} value", value,
138166
target_type->ToString());
139167
}
140168

141169
// Parse the shortest round-tripping decimal representation of the value (matching
142170
// Java's BigDecimal.valueOf(double), which goes through Double.toString) into a
143-
// full-precision decimal, then round to the target scale below.
171+
// full-precision decimal, then round to the target scale.
144172
int32_t parsed_scale = 0;
145173
ICEBERG_ASSIGN_OR_RAISE(
146174
auto parsed, Decimal::FromString(std::format("{}", value), nullptr, &parsed_scale));
147175

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-
176+
ICEBERG_ASSIGN_OR_RAISE(auto unscaled, RescaleHalfUp(parsed, parsed_scale,
177+
decimal_type.scale(), value < 0));
165178
if (!unscaled.FitsInPrecision(decimal_type.precision())) {
166179
return InvalidArgument("Cannot cast {} as a {} value", value,
167180
target_type->ToString());
168181
}
169-
return Literal::Decimal(unscaled.value(), decimal_type.precision(), target_scale);
182+
return Literal::Decimal(unscaled.value(), decimal_type.precision(),
183+
decimal_type.scale());
170184
}
171185

172186
Result<Literal> LiteralCaster::CastFromInt(

src/iceberg/test/literal_test.cc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ TEST(LiteralTest, IntegerCastToDecimal) {
171171
// (DecimalType does not bound its scale on construction).
172172
EXPECT_THAT(Literal::Int(1).CastTo(std::make_shared<DecimalType>(9, 40)),
173173
IsError(ErrorKind::kInvalidArgument));
174+
175+
// Negative scale: decimal(9, -2) scales by 10^2, so 149 rounds HALF_UP to 100 (unscaled
176+
// value 1 at scale -2), matching Java's setScale(-2, HALF_UP).
177+
auto neg_scale = Literal::Int(149).CastTo(decimal(9, -2));
178+
ASSERT_THAT(neg_scale, IsOk());
179+
EXPECT_EQ(*neg_scale, Literal::Decimal(1, 9, -2));
174180
}
175181

176182
TEST(LiteralTest, RealCastToDecimal) {
@@ -206,6 +212,18 @@ TEST(LiteralTest, RealCastToDecimal) {
206212
EXPECT_THAT(
207213
Literal::Double(std::numeric_limits<double>::quiet_NaN()).CastTo(decimal(4, 2)),
208214
IsError(ErrorKind::kInvalidArgument));
215+
216+
// A magnitude far smaller than the target scale rounds to zero rather than indexing
217+
// past the powers-of-ten table (Java rounds 1e-100 to 0.00).
218+
auto tiny = Literal::Double(1e-100).CastTo(decimal(9, 2));
219+
ASSERT_THAT(tiny, IsOk());
220+
EXPECT_EQ(*tiny, Literal::Decimal(0, 9, 2));
221+
222+
// Negative scale: decimal(9, -2) scales by 10^2, so 149 rounds HALF_UP to 100 (unscaled
223+
// value 1 at scale -2), matching Java's setScale(-2, HALF_UP).
224+
auto neg_scale = Literal::Double(149.0).CastTo(decimal(9, -2));
225+
ASSERT_THAT(neg_scale, IsOk());
226+
EXPECT_EQ(*neg_scale, Literal::Decimal(1, 9, -2));
209227
}
210228

211229
// Error cases for casts

0 commit comments

Comments
 (0)