Skip to content

Commit aa36c5e

Browse files
authored
feat: add decimal validation for numeric precision and scale supported by Spanner (#340)
* feat: updated googleapis proto changes for request tags * feat: added support for numberic for python decimal value * feat: add decimal validation for numeric precission and scale supported by spanner * fix: moved decimal validation from spanner_dbapi to spanner_v1/helper function
1 parent bf2791d commit aa36c5e

File tree

3 files changed

+92
-12
lines changed

3 files changed

+92
-12
lines changed

google/cloud/spanner_v1/_helpers.py

+33
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@
3030
from google.cloud.spanner_v1 import ExecuteSqlRequest
3131

3232

33+
# Validation error messages
34+
NUMERIC_MAX_SCALE_ERR_MSG = (
35+
"Max scale for a numeric is 9. The requested numeric has scale {}"
36+
)
37+
NUMERIC_MAX_PRECISION_ERR_MSG = (
38+
"Max precision for the whole component of a numeric is 29. The requested "
39+
+ "numeric has a whole component with precision {}"
40+
)
41+
42+
3343
def _try_to_coerce_bytes(bytestring):
3444
"""Try to coerce a byte string into the right thing based on Python
3545
version and whether or not it is base64 encoded.
@@ -87,6 +97,28 @@ def _merge_query_options(base, merge):
8797
return combined
8898

8999

100+
def _assert_numeric_precision_and_scale(value):
101+
"""
102+
Asserts that input numeric field is within Spanner supported range.
103+
104+
Spanner supports fixed 38 digits of precision and 9 digits of scale.
105+
This number can be optionally prefixed with a plus or minus sign.
106+
Read more here: https://cloud.google.com/spanner/docs/data-types#numeric_type
107+
108+
:type value: decimal.Decimal
109+
:param value: The value to check for Cloud Spanner compatibility.
110+
111+
:raises NotSupportedError: If value is not within supported precision or scale of Spanner.
112+
"""
113+
scale = value.as_tuple().exponent
114+
precision = len(value.as_tuple().digits)
115+
116+
if scale < -9:
117+
raise ValueError(NUMERIC_MAX_SCALE_ERR_MSG.format(abs(scale)))
118+
if precision + scale > 29:
119+
raise ValueError(NUMERIC_MAX_PRECISION_ERR_MSG.format(precision + scale))
120+
121+
90122
# pylint: disable=too-many-return-statements,too-many-branches
91123
def _make_value_pb(value):
92124
"""Helper for :func:`_make_list_value_pbs`.
@@ -129,6 +161,7 @@ def _make_value_pb(value):
129161
if isinstance(value, ListValue):
130162
return Value(list_value=value)
131163
if isinstance(value, decimal.Decimal):
164+
_assert_numeric_precision_and_scale(value)
132165
return Value(string_value=str(value))
133166
raise ValueError("Unknown type: %s" % (value,))
134167

tests/unit/spanner_dbapi/test_parse_utils.py

-12
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,6 @@ def test_rows_for_insert_or_update(self):
254254

255255
@unittest.skipIf(skip_condition, skip_message)
256256
def test_sql_pyformat_args_to_spanner(self):
257-
import decimal
258-
259257
from google.cloud.spanner_dbapi.parse_utils import sql_pyformat_args_to_spanner
260258

261259
cases = [
@@ -300,16 +298,6 @@ def test_sql_pyformat_args_to_spanner(self):
300298
("SELECT * from t WHERE id=10", {"f1": "app", "f2": "name"}),
301299
("SELECT * from t WHERE id=10", {"f1": "app", "f2": "name"}),
302300
),
303-
(
304-
(
305-
"SELECT (an.p + %s) AS np FROM an WHERE (an.p + %s) = %s",
306-
(1, 1.0, decimal.Decimal("31")),
307-
),
308-
(
309-
"SELECT (an.p + @a0) AS np FROM an WHERE (an.p + @a1) = @a2",
310-
{"a0": 1, "a1": 1.0, "a2": decimal.Decimal("31")},
311-
),
312-
),
313301
]
314302
for ((sql_in, params), sql_want) in cases:
315303
with self.subTest(sql=sql_in):

tests/unit/test__helpers.py

+59
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,65 @@ def test_w_unknown_type(self):
233233
with self.assertRaises(ValueError):
234234
self._callFUT(object())
235235

236+
def test_w_numeric_precision_and_scale_valid(self):
237+
import decimal
238+
from google.protobuf.struct_pb2 import Value
239+
240+
cases = [
241+
decimal.Decimal("42"),
242+
decimal.Decimal("9.9999999999999999999999999999999999999E+28"),
243+
decimal.Decimal("-9.9999999999999999999999999999999999999E+28"),
244+
decimal.Decimal("99999999999999999999999999999.999999999"),
245+
decimal.Decimal("1E+28"),
246+
decimal.Decimal("1E-9"),
247+
]
248+
for value in cases:
249+
with self.subTest(value=value):
250+
value_pb = self._callFUT(value)
251+
self.assertIsInstance(value_pb, Value)
252+
self.assertEqual(value_pb.string_value, str(value))
253+
254+
def test_w_numeric_precision_and_scale_invalid(self):
255+
import decimal
256+
from google.cloud.spanner_v1._helpers import (
257+
NUMERIC_MAX_SCALE_ERR_MSG,
258+
NUMERIC_MAX_PRECISION_ERR_MSG,
259+
)
260+
261+
max_precision_error_msg = NUMERIC_MAX_PRECISION_ERR_MSG.format("30")
262+
max_scale_error_msg = NUMERIC_MAX_SCALE_ERR_MSG.format("10")
263+
264+
cases = [
265+
(
266+
decimal.Decimal("9.9999999999999999999999999999999999999E+29"),
267+
max_precision_error_msg,
268+
),
269+
(
270+
decimal.Decimal("-9.9999999999999999999999999999999999999E+29"),
271+
max_precision_error_msg,
272+
),
273+
(
274+
decimal.Decimal("999999999999999999999999999999.99999999"),
275+
max_precision_error_msg,
276+
),
277+
(
278+
decimal.Decimal("-999999999999999999999999999999.99999999"),
279+
max_precision_error_msg,
280+
),
281+
(
282+
decimal.Decimal("999999999999999999999999999999"),
283+
max_precision_error_msg,
284+
),
285+
(decimal.Decimal("1E+29"), max_precision_error_msg),
286+
(decimal.Decimal("1E-10"), max_scale_error_msg),
287+
]
288+
289+
for value, err_msg in cases:
290+
with self.subTest(value=value, err_msg=err_msg):
291+
self.assertRaisesRegex(
292+
ValueError, err_msg, lambda: self._callFUT(value),
293+
)
294+
236295

237296
class Test_make_list_value_pb(unittest.TestCase):
238297
def _callFUT(self, *args, **kw):

0 commit comments

Comments
 (0)