Skip to content

Commit 556478f

Browse files
fix(data-classes): keep the value of large DynamoDB numbers (#8513)
* fix(data-classes): keep the value of large DynamoDB numbers * fix(data-classes): reject inexact numbers and preserve scientific zero --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 3311cc1 commit 556478f

3 files changed

Lines changed: 109 additions & 18 deletions

File tree

‎aws_lambda_powertools/shared/dynamodb_deserializer.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
traps=[Clamped, Overflow, Inexact, Rounded, Underflow],
1616
)
1717

18+
# Allow removing trailing zeros, but reject any loss of precision.
19+
_NORMALIZE_CONTEXT = Context(prec=38)
20+
_NORMALIZE_CONTEXT.traps[Inexact] = True
21+
1822

1923
class TypeDeserializer:
2024
"""
@@ -75,20 +79,18 @@ def _deserialize_bool(self, value: bool) -> bool:
7579
return value
7680

7781
def _deserialize_n(self, value: str) -> Decimal:
78-
# value is None or "."? It's zero
79-
# then return early
80-
value = value.lstrip("0")
81-
if not value or value == ".":
82+
# Preserve the existing fallback for empty strings and a lone decimal point.
83+
if value in ("", "."):
8284
return DYNAMODB_CONTEXT.create_decimal(0)
8385

84-
if len(value) > 38:
86+
number = Decimal(value)
87+
if len(number.as_tuple().digits) > 38:
8588
# See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.DataTypes.Number
86-
# Calculate the number of trailing zeros after the 38th character
87-
tail = len(value[38:]) - len(value[38:].rstrip("0"))
88-
# Trim the value: remove trailing zeros if any, or just take the first 38 characters
89-
value = value[:-tail] if tail > 0 else value[:38]
89+
# Trailing zeros don't count towards the 38 digits of precision, so move them
90+
# into the exponent. Cutting characters off the string would change the value.
91+
number = number.normalize(_NORMALIZE_CONTEXT)
9092

91-
return DYNAMODB_CONTEXT.create_decimal(value)
93+
return DYNAMODB_CONTEXT.create_decimal(number)
9294

9395
def _deserialize_s(self, value: str) -> str:
9496
return value

‎tests/unit/data_classes/required_dependencies/test_dynamo_db_stream_event.py‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from decimal import Clamped, Context, Inexact, Overflow, Rounded, Underflow
44

5+
import pytest
6+
57
from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import (
68
DynamoDBRecordEventName,
79
DynamoDBStreamEvent,
@@ -93,23 +95,23 @@ def test_dynamodb_stream_record_deserialization_large_int():
9395
record = StreamRecord(data)
9496
assert record.new_image == {
9597
"Name": "Joe",
96-
"Age": DECIMAL_CONTEXT.create_decimal("11011111111111111000000000000000000000"),
98+
"Age": DECIMAL_CONTEXT.create_decimal("1.1011111111111111E+46"),
9799
}
98100

99101

100-
def test_dynamodb_stream_record_deserialization_large_int_without_trailing_zeros():
102+
@pytest.mark.parametrize("image", ["Keys", "NewImage", "OldImage"])
103+
def test_dynamodb_stream_record_deserialization_rejects_inexact_numbers(image):
101104
data = {
102-
"Keys": {"key1": {"attr1": "value1"}},
103-
"NewImage": {
105+
image: {
104106
"Name": {"S": "Joe"},
105107
"Age": {"N": "000000011011111111111112222222222221111111111111111111111"},
106108
},
107109
}
108110
record = StreamRecord(data)
109-
assert record.new_image == {
110-
"Name": "Joe",
111-
"Age": DECIMAL_CONTEXT.create_decimal("11011111111111112222222222221111111111"),
112-
}
111+
attribute = {"Keys": "keys", "NewImage": "new_image", "OldImage": "old_image"}[image]
112+
113+
with pytest.raises(Inexact):
114+
getattr(record, attribute)
113115

114116

115117
def test_dynamodb_stream_record_deserialization_zero_value():

‎tests/unit/shared/test_dynamodb_deserializer.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from decimal import ROUND_UP, Decimal, Inexact, Overflow, Rounded, localcontext
34
from typing import Any
45

56
import pytest
@@ -51,3 +52,89 @@ def test_deserializer_error():
5152

5253
with pytest.raises(TypeError):
5354
model.data.get("Id")
55+
56+
57+
@pytest.mark.parametrize(
58+
"value",
59+
[
60+
"-12345678901234567890123456789012345678",
61+
"1.2345678901234567890123456789012345678",
62+
"110111111111111110000000000000000000000",
63+
"12345678901234567890123456789012345678000",
64+
],
65+
)
66+
def test_deserializer_keeps_value_of_numbers_with_38_digits_of_precision(value):
67+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(value)
68+
69+
70+
@pytest.mark.parametrize(
71+
"value",
72+
[
73+
"9" * 39,
74+
"-" + "9" * 39,
75+
"1" * 37 + "25",
76+
"1" * 37 + "35",
77+
"1." + "2" * 38,
78+
"123456789012345678901234567890123456789000",
79+
"123456789012345678901234567890123456789E-100",
80+
"1" * 39 + "E-9999999",
81+
],
82+
)
83+
def test_deserializer_rejects_inexact_numbers(value):
84+
with pytest.raises(Inexact):
85+
TypeDeserializer().deserialize({"N": value})
86+
87+
88+
@pytest.mark.parametrize("value", ["0E+0", "0E-100", "0e-130", "000E+5", "+0E-10", "-0E-100"])
89+
def test_deserializer_preserves_scientific_zero(value):
90+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(0)
91+
92+
93+
@pytest.mark.parametrize("value", ["", ".", "0", "000", "000.", "0.000", "-0", "+0"])
94+
def test_deserializer_preserves_existing_zero_handling(value):
95+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(0)
96+
97+
98+
@pytest.mark.parametrize(
99+
"value",
100+
[
101+
"00012345678901234567890123456789012345678000",
102+
"-12345678901234567890123456789012345678000",
103+
"1.2345678901234567890123456789012345678000",
104+
"12345678901234567890123456789012345678000E-40",
105+
"1000000000000000000000000000000000000000E-167",
106+
"99999999999999999999999999999999999999000E+85",
107+
],
108+
)
109+
def test_deserializer_removes_only_exact_trailing_zeros(value):
110+
# Application Decimal settings must not affect deserialization.
111+
with localcontext() as context:
112+
context.prec = 6
113+
context.rounding = ROUND_UP
114+
context.traps[Inexact] = True
115+
context.traps[Rounded] = True
116+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(value)
117+
118+
119+
@pytest.mark.parametrize(
120+
"value",
121+
[
122+
{"NS": ["1", "9" * 39]},
123+
{"L": [{"N": "9" * 39}]},
124+
{"M": {"amount": {"N": "9" * 39}}},
125+
],
126+
)
127+
def test_deserializer_rejects_inexact_numbers_in_collections(value):
128+
with pytest.raises(Inexact):
129+
TypeDeserializer().deserialize(value)
130+
131+
132+
def test_deserializer_keeps_distinct_number_set_values():
133+
values = ["1.2345678901234567890123456789012345677", "1.2345678901234567890123456789012345678"]
134+
135+
assert TypeDeserializer().deserialize({"NS": values}) == {Decimal(value) for value in values}
136+
137+
138+
def test_deserializer_normalization_preserves_overflow_errors():
139+
with pytest.raises(Overflow):
140+
TypeDeserializer().deserialize({"N": "1" * 38 + "00E+9999999"})

0 commit comments

Comments
 (0)