Skip to content

Commit 58aeaa7

Browse files
committed
feat(api): decimal support for arithmetic and comparison operators
Arithmetic operators (+ - * / % **) fall through to functions_arithmetic_decimal for decimal operands, mirroring the f.* namespace's multi-URN resolution. Comparison and arithmetic against int/Decimal literals coerce the literal to decimal: peer-exact for comparisons (any1,any1 overloads require identical operand types), natural type for arithmetic (the return-type derivation computes the result). Peer-mode comparison coercion raises a clear error when a literal is not exactly representable in the column's decimal type (finer scale or precision overflow) rather than silently rounding or truncating the comparison; _decimal_type rejects precision > 38. Extract the shared multi-URN resolver (_resolve_over_urns), now used by both the operator path and the f.* namespace so their resolution and error text cannot drift. Fix _encode_decimal to scale with exact integer arithmetic instead of Decimal multiplication under the ambient decimal context, so literals with more than the context precision (28) significant digits are no longer silently rounded during encoding.
1 parent 10f70c2 commit 58aeaa7

5 files changed

Lines changed: 364 additions & 41 deletions

File tree

src/substrait/builders/extended_expression.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import itertools
33
import uuid as uuid_module
44
from datetime import date, datetime, time, timedelta, timezone
5-
from decimal import ROUND_HALF_EVEN, Decimal
5+
from decimal import Decimal
66
from typing import Any, Callable, Iterable, Union
77

88
import substrait.algebra_pb2 as stalg
@@ -72,9 +72,32 @@ def _scale_subseconds(microseconds: int, precision: int) -> int:
7272

7373

7474
def _encode_decimal(value: Any, scale: int) -> bytes:
75-
"""Encode a decimal as the 16-byte little-endian two's-complement unscaled value."""
75+
"""Encode a decimal as the 16-byte little-endian two's-complement unscaled value.
76+
77+
Scaling uses pure-integer arithmetic on ``as_tuple()`` rather than ``Decimal``
78+
multiplication, so it is exact regardless of the active ``decimal`` context
79+
precision (which would otherwise silently round a value with more than
80+
``ctx.prec`` significant digits). A value carrying more fractional digits than
81+
``scale`` is rounded half-even.
82+
"""
7683
dec = value if isinstance(value, Decimal) else Decimal(str(value))
77-
unscaled = int((dec * (Decimal(10) ** scale)).to_integral_value(ROUND_HALF_EVEN))
84+
sign, digits, exponent = dec.as_tuple()
85+
if not isinstance(exponent, int): # NaN / Infinity have symbolic exponents
86+
raise ValueError(f"cannot encode a non-finite decimal literal: {value!r}")
87+
coeff = 0
88+
for d in digits:
89+
coeff = coeff * 10 + d
90+
shift = exponent + scale
91+
if shift >= 0:
92+
unscaled = coeff * 10**shift
93+
else: # drop -shift low-order digits, rounding half-even
94+
factor = 10 ** (-shift)
95+
unscaled, remainder = divmod(coeff, factor)
96+
twice = 2 * remainder
97+
if twice > factor or (twice == factor and unscaled % 2):
98+
unscaled += 1
99+
if sign:
100+
unscaled = -unscaled
78101
return unscaled.to_bytes(16, byteorder="little", signed=True)
79102

80103

src/substrait/dataframe/expr.py

Lines changed: 149 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,21 @@
6666
# Standard Substrait function-extension URNs used by the operators below.
6767
FUNCTIONS_COMPARISON = "extension:io.substrait:functions_comparison"
6868
FUNCTIONS_ARITHMETIC = "extension:io.substrait:functions_arithmetic"
69+
FUNCTIONS_ARITHMETIC_DECIMAL = "extension:io.substrait:functions_arithmetic_decimal"
6970
FUNCTIONS_BOOLEAN = "extension:io.substrait:functions_boolean"
7071
FUNCTIONS_STRING = "extension:io.substrait:functions_string"
7172
FUNCTIONS_AGGREGATE_GENERIC = "extension:io.substrait:functions_aggregate_generic"
7273
FUNCTIONS_LIST = "extension:io.substrait:functions_list"
7374

75+
# Arithmetic operators try the base extension first, then its decimal variant, so
76+
# decimal operands resolve against ``functions_arithmetic_decimal`` (there is no
77+
# such fallback for comparisons -- ``functions_comparison`` is generic ``any1``).
78+
_ARITHMETIC_URNS = [FUNCTIONS_ARITHMETIC, FUNCTIONS_ARITHMETIC_DECIMAL]
79+
80+
81+
# Substrait bounds a decimal's precision to 38 (a 128-bit unscaled value).
82+
_MAX_DECIMAL_PRECISION = 38
83+
7484

7585
def _decimal_type(value: _Decimal) -> stp.Type:
7686
exponent = value.as_tuple().exponent
@@ -83,6 +93,12 @@ def _decimal_type(value: _Decimal) -> stp.Type:
8393
# too small for the encoded value.
8494
digits = len(value.as_tuple().digits) + max(exponent, 0)
8595
precision = max(digits, scale, 1)
96+
if precision > _MAX_DECIMAL_PRECISION:
97+
raise ValueError(
98+
f"decimal literal {value!r} needs precision {precision}, exceeding "
99+
f"Substrait's maximum decimal precision of {_MAX_DECIMAL_PRECISION}; "
100+
f"the value has too many significant digits for a decimal"
101+
)
86102
return _t.decimal(scale, precision)
87103

88104

@@ -135,31 +151,134 @@ def infer_literal_type(value: Any) -> stp.Type:
135151
}
136152

137153

138-
def _match_numeric_type(peer_type: stp.Type, value: Any) -> stp.Type:
154+
def _exact_unscaled(value: _Decimal, scale: int) -> Union[int, None]:
155+
"""The unscaled integer of ``value`` at ``scale``, or ``None`` if representing
156+
it at ``scale`` would drop nonzero digits (i.e. the value needs a finer scale).
157+
158+
Pure-integer arithmetic on ``as_tuple()`` so it is exact regardless of the
159+
``decimal`` context precision.
160+
"""
161+
sign, digits, exponent = value.as_tuple()
162+
coeff = 0
163+
for d in digits:
164+
coeff = coeff * 10 + d
165+
shift = exponent + scale
166+
if shift < 0: # value carries more fractional digits than `scale` allows
167+
factor = 10 ** (-shift)
168+
if coeff % factor:
169+
return None
170+
coeff //= factor
171+
else:
172+
coeff *= 10**shift
173+
return -coeff if sign else coeff
174+
175+
176+
def _match_numeric_type(
177+
peer_type: stp.Type, value: Any, *, decimal_literal: str = "natural"
178+
) -> stp.Type:
139179
"""Pick a literal type for ``value`` that matches a numeric ``peer_type``.
140180
141181
Substrait does not implicitly coerce mixed numeric operands, so
142182
``col("price_fp64") * 2`` needs the ``2`` typed as ``fp64`` rather than the
143183
default ``i64`` for the ``multiply`` overload to resolve. A ``float`` value
144184
always stays floating point to avoid a lossy narrowing.
185+
186+
For a ``decimal`` peer the coercion depends on the operation, selected by
187+
``decimal_literal``:
188+
189+
- ``"peer"`` (comparisons) -- the ``functions_comparison`` overloads use
190+
``any1, any1`` and so require *identical* operand types, so the literal must
191+
take the column's exact ``decimal(precision, scale)``. That is only sound
192+
when the value is *exactly* representable there; if it would need a finer
193+
scale or overflow the precision this raises rather than silently rounding or
194+
truncating the comparison, so the user coerces explicitly with
195+
``lit(value, <decimal type>)`` or by casting the column.
196+
- ``"natural"`` (arithmetic) -- ``functions_arithmetic_decimal`` accepts
197+
``decimal<P1,S1>, decimal<P2,S2>`` and derives the result type, so the
198+
literal keeps its own natural decimal type (lossless).
199+
200+
A ``float`` against a decimal peer still stays floating point (so it raises
201+
rather than being silently turned into a decimal); pass a ``Decimal``.
145202
"""
146203
kind = peer_type.WhichOneof("kind")
147204
if isinstance(value, float):
148205
return _t.fp32() if kind == "fp32" else _t.fp64()
206+
if kind == "decimal":
207+
if decimal_literal == "peer":
208+
scale = peer_type.decimal.scale
209+
precision = peer_type.decimal.precision
210+
dec = value if isinstance(value, _Decimal) else _Decimal(value)
211+
if not dec.is_finite():
212+
raise TypeError("cannot compare against a non-finite Decimal literal")
213+
column = f"decimal(precision={precision}, scale={scale})"
214+
unscaled = _exact_unscaled(dec, scale)
215+
if unscaled is None:
216+
raise ValueError(
217+
f"decimal literal {value!r} has a finer scale than the {column} "
218+
f"column it is compared to; comparing them would round it. Wrap "
219+
f"it with lit(value, <decimal type>) or cast the column."
220+
)
221+
if abs(unscaled) >= 10**precision:
222+
raise ValueError(
223+
f"decimal literal {value!r} does not fit the {column} column it "
224+
f"is compared to. Wrap it with lit(value, <decimal type>) or cast "
225+
f"the column."
226+
)
227+
return _t.decimal(scale, precision)
228+
return _decimal_type(value if isinstance(value, _Decimal) else _Decimal(value))
149229
builder = _NUMERIC_BUILDERS.get(kind)
150230
return builder() if builder else _t.i64()
151231

152232

233+
def _resolve_over_urns(
234+
builder, urns, name, bound, base_schema, registry, *, alias=None, options=None
235+
):
236+
"""Resolve ``name`` over ``urns`` (in order) against the bound operands' types.
237+
238+
Builds with the first URN whose overload matches the operands' signature and
239+
raises a uniform error if none do. Shared by the operator path
240+
(:func:`_numeric_binary`) and the ``f.*`` namespace's multi-URN helper
241+
(``substrait.dataframe.functions._multi_urn_helper``) so both resolve
242+
identically and their error text cannot drift apart.
243+
"""
244+
signature = [
245+
typ for b in bound for typ in infer_extended_expression_schema(b).types
246+
]
247+
for urn in urns:
248+
if registry.lookup_function(urn, name, signature):
249+
return builder(urn, name, expressions=bound, alias=alias, options=options)(
250+
base_schema, registry
251+
)
252+
kinds = [t.WhichOneof("kind") for t in signature]
253+
raise Exception(
254+
f"No matching overload for '{name}' across {urns} with signature {kinds}"
255+
)
256+
257+
153258
def _numeric_binary(
154-
self_expr: "Expr", other: Any, urn: str, fn: str, *, swap: bool = False
259+
self_expr: "Expr",
260+
other: Any,
261+
urns: Union[str, list],
262+
fn: str,
263+
*,
264+
swap: bool = False,
265+
decimal_literal: str = "natural",
155266
) -> "Expr":
156267
"""Build a binary comparison/arithmetic expression with literal coercion.
157268
158269
A bare Python number is typed to match the *other* (column) operand at
159270
resolve time, so mixed-width numeric comparisons and arithmetic resolve
160271
against the standard extension overloads. ``swap`` handles reflected
161272
operators (e.g. ``100 - col("a")``), keeping operand order intact.
273+
274+
``urns`` may be a single URN or a list tried in order: arithmetic passes
275+
``[functions_arithmetic, functions_arithmetic_decimal]`` so decimal operands
276+
fall through to the decimal extension when the base one has no overload,
277+
mirroring the ``f.*`` namespace's multi-URN resolution. ``decimal_literal``
278+
(``"natural"`` / ``"peer"``) is forwarded to :func:`_match_numeric_type` to
279+
pick how a decimal literal is coerced against a decimal peer.
162280
"""
281+
urns = [urns] if isinstance(urns, str) else list(urns)
163282
left_operand = other if swap else self_expr
164283
right_operand = self_expr if swap else other
165284

@@ -181,15 +300,23 @@ def bind(operand):
181300
def as_bound(value, is_expr):
182301
if is_expr:
183302
return value
184-
if not isinstance(value, bool) and isinstance(value, (int, float)) and peer:
185-
lit_type = _match_numeric_type(peer, value)
186-
return literal(value, lit_type)(base_schema, registry)
303+
if peer is not None and not isinstance(value, bool):
304+
# int/float coerce to match any numeric peer; a Decimal only when
305+
# the peer is itself decimal (else keep its natural decimal type).
306+
peer_is_decimal = peer.WhichOneof("kind") == "decimal"
307+
if isinstance(value, (int, float)) or (
308+
isinstance(value, _Decimal) and peer_is_decimal
309+
):
310+
lit_type = _match_numeric_type(
311+
peer, value, decimal_literal=decimal_literal
312+
)
313+
return literal(value, lit_type)(base_schema, registry)
187314
return Expr._coerce(value)._unbound(base_schema, registry)
188315

189316
left_bound = as_bound(left_val, left_is_expr)
190317
right_bound = as_bound(right_val, right_is_expr)
191-
return scalar_function(urn, fn, expressions=[left_bound, right_bound])(
192-
base_schema, registry
318+
return _resolve_over_urns(
319+
scalar_function, urns, fn, [left_bound, right_bound], base_schema, registry
193320
)
194321

195322
return Expr(resolve)
@@ -296,7 +423,9 @@ def _compare(self, other: Any, fn: str) -> "Expr":
296423
self._unbound, other.plan, other.reduction_op, _COMPARISON_OPS[fn]
297424
)
298425
)
299-
return _numeric_binary(self, other, FUNCTIONS_COMPARISON, fn)
426+
return _numeric_binary(
427+
self, other, FUNCTIONS_COMPARISON, fn, decimal_literal="peer"
428+
)
300429

301430
def __lt__(self, other: Any) -> "Expr":
302431
return self._compare(other, "lt")
@@ -322,40 +451,40 @@ def __ne__(self, other: Any) -> "Expr": # type: ignore[override]
322451

323452
# -- arithmetic -------------------------------------------------------
324453
def __add__(self, other: Any) -> "Expr":
325-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "add")
454+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "add")
326455

327456
def __sub__(self, other: Any) -> "Expr":
328-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "subtract")
457+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "subtract")
329458

330459
def __mul__(self, other: Any) -> "Expr":
331-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "multiply")
460+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "multiply")
332461

333462
def __truediv__(self, other: Any) -> "Expr":
334-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "divide")
463+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "divide")
335464

336465
def __radd__(self, other: Any) -> "Expr":
337-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "add", swap=True)
466+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "add", swap=True)
338467

339468
def __rsub__(self, other: Any) -> "Expr":
340-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "subtract", swap=True)
469+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "subtract", swap=True)
341470

342471
def __rmul__(self, other: Any) -> "Expr":
343-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "multiply", swap=True)
472+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "multiply", swap=True)
344473

345474
def __rtruediv__(self, other: Any) -> "Expr":
346-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "divide", swap=True)
475+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "divide", swap=True)
347476

348477
def __mod__(self, other: Any) -> "Expr":
349-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "modulus")
478+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "modulus")
350479

351480
def __rmod__(self, other: Any) -> "Expr":
352-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "modulus", swap=True)
481+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "modulus", swap=True)
353482

354483
def __pow__(self, other: Any) -> "Expr":
355-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "power")
484+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "power")
356485

357486
def __rpow__(self, other: Any) -> "Expr":
358-
return _numeric_binary(self, other, FUNCTIONS_ARITHMETIC, "power", swap=True)
487+
return _numeric_binary(self, other, _ARITHMETIC_URNS, "power", swap=True)
359488

360489
def __neg__(self) -> "Expr":
361490
return Expr(

src/substrait/dataframe/functions.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,8 @@
4141
scalar_function,
4242
window_function,
4343
)
44-
from substrait.dataframe.expr import Expr
44+
from substrait.dataframe.expr import Expr, _resolve_over_urns
4545
from substrait.extension_registry.function_entry import FunctionType
46-
from substrait.type_inference import infer_extended_expression_schema
4746

4847
_BUILDERS = {
4948
FunctionType.SCALAR: scalar_function,
@@ -78,22 +77,15 @@ def helper(*args: Any, alias: str | None = None, **options: Any) -> Expr:
7877

7978
def resolve(base_schema, registry):
8079
bound = [resolve_expression(e, base_schema, registry) for e in exprs]
81-
signature = [
82-
typ for b in bound for typ in infer_extended_expression_schema(b).types
83-
]
84-
for urn in urns:
85-
if registry.lookup_function(urn, name, signature):
86-
return builder(
87-
urn,
88-
name,
89-
expressions=bound,
90-
alias=alias,
91-
options=options or None,
92-
)(base_schema, registry)
93-
kinds = [t.WhichOneof("kind") for t in signature]
94-
raise Exception(
95-
f"No matching overload for '{name}' across {urns} "
96-
f"with signature {kinds}"
80+
return _resolve_over_urns(
81+
builder,
82+
urns,
83+
name,
84+
bound,
85+
base_schema,
86+
registry,
87+
alias=alias,
88+
options=options or None,
9789
)
9890

9991
return Expr(resolve)

0 commit comments

Comments
 (0)