Skip to content

Commit

Permalink
fix: Dates before 1000AD should use 4-digit years (#1132)
Browse files Browse the repository at this point in the history
This is required for compliance with RFC3339/ISO8401 and timestamps
which do not comply will be rejected by Spanner.

Fixes #1131
  • Loading branch information
c2nes committed Apr 17, 2024
1 parent 37ac4c1 commit 0ef6565
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 8 deletions.
39 changes: 36 additions & 3 deletions google/cloud/spanner_v1/_helpers.py
Expand Up @@ -24,7 +24,6 @@

from google.api_core import datetime_helpers
from google.cloud._helpers import _date_from_iso8601_date
from google.cloud._helpers import _datetime_to_rfc3339
from google.cloud.spanner_v1 import TypeCode
from google.cloud.spanner_v1 import ExecuteSqlRequest
from google.cloud.spanner_v1 import JsonObject
Expand Down Expand Up @@ -122,6 +121,40 @@ def _assert_numeric_precision_and_scale(value):
raise ValueError(NUMERIC_MAX_PRECISION_ERR_MSG.format(precision + scale))


def _datetime_to_rfc3339(value):
"""Format the provided datatime in the RFC 3339 format.
:type value: datetime.datetime
:param value: value to format
:rtype: str
:returns: RFC 3339 formatted datetime string
"""
# Convert to UTC and then drop the timezone so we can append "Z" in lieu of
# allowing isoformat to append the "+00:00" zone offset.
value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return value.isoformat(sep="T", timespec="microseconds") + "Z"


def _datetime_to_rfc3339_nanoseconds(value):
"""Format the provided datatime in the RFC 3339 format.
:type value: datetime_helpers.DatetimeWithNanoseconds
:param value: value to format
:rtype: str
:returns: RFC 3339 formatted datetime string
"""

if value.nanosecond == 0:
return _datetime_to_rfc3339(value)
nanos = str(value.nanosecond).rjust(9, "0").rstrip("0")
# Convert to UTC and then drop the timezone so we can append "Z" in lieu of
# allowing isoformat to append the "+00:00" zone offset.
value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return "{}.{}Z".format(value.isoformat(sep="T", timespec="seconds"), nanos)


def _make_value_pb(value):
"""Helper for :func:`_make_list_value_pbs`.
Expand Down Expand Up @@ -150,9 +183,9 @@ def _make_value_pb(value):
return Value(string_value="-Infinity")
return Value(number_value=value)
if isinstance(value, datetime_helpers.DatetimeWithNanoseconds):
return Value(string_value=value.rfc3339())
return Value(string_value=_datetime_to_rfc3339_nanoseconds(value))
if isinstance(value, datetime.datetime):
return Value(string_value=_datetime_to_rfc3339(value, ignore_zone=False))
return Value(string_value=_datetime_to_rfc3339(value))
if isinstance(value, datetime.date):
return Value(string_value=value.isoformat())
if isinstance(value, bytes):
Expand Down
49 changes: 44 additions & 5 deletions tests/unit/test__helpers.py
Expand Up @@ -190,6 +190,15 @@ def test_w_date(self):
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, today.isoformat())

def test_w_date_pre1000ad(self):
import datetime
from google.protobuf.struct_pb2 import Value

when = datetime.date(800, 2, 25)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, "0800-02-25")

def test_w_timestamp_w_nanos(self):
import datetime
from google.protobuf.struct_pb2 import Value
Expand All @@ -200,7 +209,19 @@ def test_w_timestamp_w_nanos(self):
)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, when.rfc3339())
self.assertEqual(value_pb.string_value, "2016-12-20T21:13:47.123456789Z")

def test_w_timestamp_w_nanos_pre1000ad(self):
import datetime
from google.protobuf.struct_pb2 import Value
from google.api_core import datetime_helpers

when = datetime_helpers.DatetimeWithNanoseconds(
850, 12, 20, 21, 13, 47, nanosecond=123456789, tzinfo=datetime.timezone.utc
)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, "0850-12-20T21:13:47.123456789Z")

def test_w_listvalue(self):
from google.protobuf.struct_pb2 import Value
Expand All @@ -214,12 +235,20 @@ def test_w_listvalue(self):
def test_w_datetime(self):
import datetime
from google.protobuf.struct_pb2 import Value
from google.api_core import datetime_helpers

now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
value_pb = self._callFUT(now)
when = datetime.datetime(2021, 2, 8, 0, 0, 0, tzinfo=datetime.timezone.utc)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, "2021-02-08T00:00:00.000000Z")

def test_w_datetime_pre1000ad(self):
import datetime
from google.protobuf.struct_pb2 import Value

when = datetime.datetime(916, 2, 8, 0, 0, 0, tzinfo=datetime.timezone.utc)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, datetime_helpers.to_rfc3339(now))
self.assertEqual(value_pb.string_value, "0916-02-08T00:00:00.000000Z")

def test_w_timestamp_w_tz(self):
import datetime
Expand All @@ -231,6 +260,16 @@ def test_w_timestamp_w_tz(self):
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, "2021-02-07T23:00:00.000000Z")

def test_w_timestamp_w_tz_pre1000ad(self):
import datetime
from google.protobuf.struct_pb2 import Value

zone = datetime.timezone(datetime.timedelta(hours=+1), name="CET")
when = datetime.datetime(721, 2, 8, 0, 0, 0, tzinfo=zone)
value_pb = self._callFUT(when)
self.assertIsInstance(value_pb, Value)
self.assertEqual(value_pb.string_value, "0721-02-07T23:00:00.000000Z")

def test_w_unknown_type(self):
with self.assertRaises(ValueError):
self._callFUT(object())
Expand Down

0 comments on commit 0ef6565

Please sign in to comment.