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
152 changes: 147 additions & 5 deletions src/viur/core/bones/numeric.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys
import typing as t
import warnings
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation

from viur.core import db, i18n
from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
Expand Down Expand Up @@ -33,6 +34,7 @@ def __init__(
min: int | float = MIN,
max: int | float = MAX,
precision: int = 0,
decimal: bool = False,
mode=None, # deprecated!
**kwargs
):
Expand All @@ -42,6 +44,7 @@ def __init__(
:param min: Minimum accepted value (including).
:param max: Maximum accepted value (including).
:param precision: How may decimal places should be saved. Zero casts the value to int instead of float.
:param decimal: If True, use decimal.Decimal internally for exact arithmetic.
"""
super().__init__(**kwargs)

Expand All @@ -61,6 +64,9 @@ def __init__(
self.precision = precision
self.min = min
self.max = max
self.decimal = decimal
if decimal:
self._quantize_exp = Decimal(10) ** -precision

def __setattr__(self, key, value):
"""
Expand All @@ -80,18 +86,76 @@ def __setattr__(self, key, value):

return super().__setattr__(key, value)

def _to_decimal(self, value) -> Decimal | None:
"""Convert *value* to a quantized Decimal. Uses str() roundtrip for floats.
Accepts comma as decimal separator in strings."""
if value is None:
return None
if isinstance(value, Decimal):
return value.quantize(self._quantize_exp, rounding=ROUND_HALF_UP)
if isinstance(value, str):
value = value.replace(",", ".", 1)
return Decimal(value).quantize(self._quantize_exp, rounding=ROUND_HALF_UP)
if isinstance(value, (int, float)):
return Decimal(str(value)).quantize(self._quantize_exp, rounding=ROUND_HALF_UP)
raise ValueError(f"Cannot convert {type(value).__name__} to Decimal")

def singleValueUnserialize(self, val):
if val is not None:
try:
if self.decimal:
return self._to_decimal(val)
return self._convert_to_numeric(val)
except (ValueError, TypeError):
except (ValueError, TypeError, InvalidOperation):
return self.getDefaultValue(None) # FIXME: callable needs the skeleton instance

return val

def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
if self.decimal:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return float(self._to_decimal(value))
return self.singleValueUnserialize(value) # same logic for unserialize here!

def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
if not self.decimal:
return super().serialize(skel, name, parentIndexed)

# Capture Decimal values before super() converts to float
raw_values = skel.accessedValues.get(name)

result = super().serialize(skel, name, parentIndexed)
if not result:
return False

# Write exact string representation to _decimal field
decimal_name = f"{name}_decimal"
if self.multiple and isinstance(raw_values, list):
skel.dbEntity[decimal_name] = [
str(self._to_decimal(v)) if v is not None else None
for v in raw_values
]
elif raw_values is not None:
skel.dbEntity[decimal_name] = str(self._to_decimal(raw_values))
else:
skel.dbEntity[decimal_name] = None

# _decimal field is never queried, always exclude from indexes
skel.dbEntity.exclude_from_indexes.add(decimal_name)

return True

def unserialize(self, skel: "SkeletonInstance", name: str) -> bool:
if self.decimal:
decimal_name = f"{name}_decimal"
if decimal_name in skel.dbEntity:
# Prefer exact string representation over float
skel.dbEntity[name] = skel.dbEntity[decimal_name]
return super().unserialize(skel, name)

def isInvalid(self, value):
"""
This method checks if a given value is invalid (e.g., NaN) for the NumericBone instance.
Expand All @@ -110,6 +174,8 @@ def getEmptyValue(self):
:return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when
precision is non-zero).
"""
if self.decimal:
return Decimal(0).quantize(self._quantize_exp)
if self.precision:
return 0.0
else:
Expand All @@ -124,15 +190,54 @@ def isEmpty(self, value: t.Any):
:param value: The raw value to be checked for emptiness.
:return: Returns True if the raw value is considered empty, otherwise False.
"""
if value is None:
return True
if isinstance(value, str) and not value:
return True
try:
value = self._convert_to_numeric(value)
except (ValueError, TypeError):
if self.decimal:
value = self._to_decimal(value)
else:
value = self._convert_to_numeric(value)
except (ValueError, TypeError, InvalidOperation):
return True
return value == self.getEmptyValue()

def fromClient(self, skel, name: str, data: dict):
if self.decimal:
return self._decimal_from_client_full(skel, name, data)
return super().fromClient(skel, name, data)

def _decimal_from_client_full(self, skel, name: str, data: dict):
"""Full fromClient override for decimal mode.

Bypasses BaseBone's isEmpty pre-check so that zero is accepted as a
valid (non-empty) value. Only None and blank strings are treated as not submitted.
"""
from viur.core.bones.base import MultipleConstraints
parsedData, fieldSubmitted = self.collectRawClientData(
name, data, self.multiple, self.languages, self.parseSubfieldsFromClient()
)
if not fieldSubmitted:
return [ReadFromClientError(ReadFromClientErrorSeverity.NotSet)]

if self.multiple or self.languages:
# Fall back to base behaviour for complex cases
return super().fromClient(skel, name, data)

# Simple, non-multiple, non-language case
if parsedData is None or (isinstance(parsedData, str) and not parsedData.strip()):
skel[name] = self.getEmptyValue()
return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)]

res, parseErrors = self._decimal_from_client(parsedData, skel, name, data)
skel[name] = res
return parseErrors or None

def singleValueFromClient(self, value, skel, bone_name, client_data):
if self.decimal:
return self._decimal_from_client(value, skel, bone_name, client_data)

if not isinstance(value, (int, float)):
# Replace , with .
try:
Expand Down Expand Up @@ -173,6 +278,37 @@ def singleValueFromClient(self, value, skel, bone_name, client_data):

return value, None

def _decimal_from_client(self, value, skel, bone_name, client_data):
"""Handle client input for decimal mode."""
if value is None or (isinstance(value, str) and not value.strip()):
return self.getEmptyValue(), [
ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")
]
try:
if isinstance(value, str):
value = value.replace(",", ".", 1)
dec = self._to_decimal(value)
except (InvalidOperation, ValueError, TypeError):
return self.getEmptyValue(), [
ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid decimal value")
]
if not (self.min <= dec <= self.max):
return self.getEmptyValue(), [
ReadFromClientError(
ReadFromClientErrorSeverity.Invalid,
i18n.translate(
"core.bones.error.minmax"
"Value not between {{min}} and {{max}}",
default_variables={"min": self.min, "max": self.max},
)
)
]
if err := self.isInvalid(dec):
return self.getEmptyValue(), [
ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)
]
return dec, None

def buildDBFilter(
self,
name: str,
Expand Down Expand Up @@ -240,9 +376,12 @@ def refresh(self, skel: "SkeletonInstance", boneName: str) -> None:
"""
super().refresh(skel, boneName)

def refresh_single_value(value: t.Any) -> float | int:
def refresh_single_value(value: t.Any) -> float | int | Decimal:
if value == "":
return self.getEmptyValue()
elif self.decimal:
if not isinstance(value, (Decimal, type(None))):
return self._to_decimal(value)
elif not isinstance(value, (int, float, type(None))):
return self._convert_to_numeric(value)
return value
Expand Down Expand Up @@ -272,8 +411,11 @@ def iter_bone_value(
yield from super().iter_bone_value(skel, name)

def structure(self) -> dict:
return super().structure() | {
ret = super().structure() | {
"min": self.min,
"max": self.max,
"precision": self.precision,
}
if self.decimal:
ret["decimal"] = True
return ret
15 changes: 14 additions & 1 deletion src/viur/core/render/json/default.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
import typing as t
import logging
import warnings
from decimal import Decimal
from enum import Enum
from viur.core import db, current
from viur.core.bones import BaseBone
Expand All @@ -20,7 +22,18 @@ class CustomJsonEncoder(json.JSONEncoder):

def default(self, o: t.Any) -> t.Any:

if isinstance(o, translate):
if isinstance(o, Decimal):
if "json.decimal.as_string" in conf.compatibility:
return str(o)
warnings.warn(
"Decimal values are currently serialized as float in JSON responses. "
"This will change to string in a future version for exact representation. "
'Add "json.decimal.as_string" to conf.compatibility to opt-in now.',
DeprecationWarning,
stacklevel=2,
)
return float(o)
elif isinstance(o, translate):
return str(o)
elif isinstance(o, datetime):
return o.isoformat()
Expand Down
Loading
Loading