Skip to content

Commit 890c15d

Browse files
authored
Implementing DateTimePropery in ndb. (#6448)
1 parent 8810e4e commit 890c15d

3 files changed

Lines changed: 271 additions & 6 deletions

File tree

ndb/MIGRATION_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ The primary differences come from:
9696
- The `BlobProperty` constructor only sets `_compressed` if explicitly
9797
passed. The original set `_compressed` always (and used `False` as default).
9898
In the exact same fashion the `JsonProperty` constructor only sets
99-
`_json_type` if explicitly passed.
99+
`_json_type` if explicitly passed. Similarly, the `DateTimeProperty`
100+
constructor only sets `_auto_now` and `_auto_now_add` if explicitly passed.
100101
- `TextProperty(indexed=True)` and `StringProperty(indexed=False)` are no
101102
longer supported (see docstrings for more info)
102103
- `model.GeoPt` is an alias for `google.cloud.datastore.helpers.GeoPoint`

ndb/src/google/cloud/ndb/model.py

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Model classes for datastore objects and properties for models."""
1616

1717

18+
import datetime
1819
import inspect
1920
import json
2021
import pickle
@@ -470,6 +471,11 @@ def _from_base_type(self, value):
470471
original value is kept. (Returning a different value not equal to
471472
:data:`None` will substitute the different value.)
472473
474+
Additionally, :meth:`_prepare_for_put` can be used to integrate with
475+
datastore save hooks used by :class:`Model` instances.
476+
477+
.. automethod:: _prepare_for_put
478+
473479
Args:
474480
name (str): The name of the property.
475481
indexed (bool): Indicates if the value should be indexed.
@@ -1749,8 +1755,8 @@ class BlobProperty(Property):
17491755
def __init__(
17501756
self,
17511757
name=None,
1752-
compressed=None,
17531758
*,
1759+
compressed=None,
17541760
indexed=None,
17551761
repeated=None,
17561762
required=None,
@@ -2249,9 +2255,154 @@ def __init__(self, *args, **kwargs):
22492255

22502256

22512257
class DateTimeProperty(Property):
2252-
__slots__ = ()
2258+
"""A property that contains :class:`~datetime.datetime` values.
22532259
2254-
def __init__(self, *args, **kwargs):
2260+
This property expects "naive" datetime stamps, i.e. no timezone can
2261+
be set. Furthermore, the assumption is that naive datetime stamps
2262+
represent UTC.
2263+
2264+
.. note::
2265+
2266+
Unlike Django, ``auto_now_add`` can be overridden by setting the
2267+
value before writing the entity. And unlike the legacy
2268+
``google.appengine.ext.db``, ``auto_now`` does not supply a default
2269+
value. Also unlike legacy ``db``, when the entity is written, the
2270+
property values are updated to match what was written. Finally, beware
2271+
that this also updates the value in the in-process cache, **and** that
2272+
``auto_now_add`` may interact weirdly with transaction retries (a retry
2273+
of a property with ``auto_now_add`` set will reuse the value that was
2274+
set on the first try).
2275+
2276+
.. automethod:: _validate
2277+
.. automethod:: _prepare_for_put
2278+
2279+
Args:
2280+
name (str): The name of the property.
2281+
auto_now (bool): Indicates that the property should be set to the
2282+
current datetime when an entity is created and whenever it is
2283+
updated.
2284+
auto_now_add (bool): Indicates that the property should be set to the
2285+
current datetime when an entity is created.
2286+
indexed (bool): Indicates if the value should be indexed.
2287+
repeated (bool): Indicates if this property is repeated, i.e. contains
2288+
multiple values.
2289+
required (bool): Indicates if this property is required on the given
2290+
model type.
2291+
default (bytes): The default value for this property.
2292+
choices (Iterable[bytes]): A container of allowed values for this
2293+
property.
2294+
validator (Callable[[~google.cloud.ndb.model.Property, Any], bool]): A
2295+
validator to be used to check values.
2296+
verbose_name (str): A longer, user-friendly name for this property.
2297+
write_empty_list (bool): Indicates if an empty list should be written
2298+
to the datastore.
2299+
2300+
Raises:
2301+
ValueError: If ``repeated=True`` and ``auto_now=True``.
2302+
ValueError: If ``repeated=True`` and ``auto_now_add=True``.
2303+
"""
2304+
2305+
_auto_now = False
2306+
_auto_now_add = False
2307+
2308+
def __init__(
2309+
self,
2310+
name=None,
2311+
*,
2312+
auto_now=None,
2313+
auto_now_add=None,
2314+
indexed=None,
2315+
repeated=None,
2316+
required=None,
2317+
default=None,
2318+
choices=None,
2319+
validator=None,
2320+
verbose_name=None,
2321+
write_empty_list=None
2322+
):
2323+
super(DateTimeProperty, self).__init__(
2324+
name=name,
2325+
indexed=indexed,
2326+
repeated=repeated,
2327+
required=required,
2328+
default=default,
2329+
choices=choices,
2330+
validator=validator,
2331+
verbose_name=verbose_name,
2332+
write_empty_list=write_empty_list,
2333+
)
2334+
if self._repeated:
2335+
if auto_now:
2336+
raise ValueError(
2337+
"DateTimeProperty {} could use auto_now and be "
2338+
"repeated, but there would be no point.".format(self._name)
2339+
)
2340+
elif auto_now_add:
2341+
raise ValueError(
2342+
"DateTimeProperty {} could use auto_now_add and be "
2343+
"repeated, but there would be no point.".format(self._name)
2344+
)
2345+
if auto_now is not None:
2346+
self._auto_now = auto_now
2347+
if auto_now_add is not None:
2348+
self._auto_now_add = auto_now_add
2349+
2350+
def _validate(self, value):
2351+
"""Validate a ``value`` before setting it.
2352+
2353+
Args:
2354+
value (~datetime.datetime): The value to check.
2355+
2356+
Raises:
2357+
.BadValueError: If ``value`` is not a :class:`~datetime.datetime`.
2358+
"""
2359+
if not isinstance(value, datetime.datetime):
2360+
raise exceptions.BadValueError(
2361+
"Expected datetime, got {!r}".format(value)
2362+
)
2363+
2364+
@staticmethod
2365+
def _now():
2366+
"""datetime.datetime: Return current time.
2367+
2368+
This is in place so it can be patched in tests.
2369+
"""
2370+
return datetime.datetime.utcnow()
2371+
2372+
def _prepare_for_put(self, entity):
2373+
"""Sets the current timestamp when "auto" is set.
2374+
2375+
If one of the following scenarios occur
2376+
2377+
* ``auto_now=True``
2378+
* ``auto_now_add=True`` and the ``entity`` doesn't have a value set
2379+
2380+
then this hook will run before the ``entity`` is ``put()`` into
2381+
the datastore.
2382+
2383+
Args:
2384+
entity (Model): An entity with values.
2385+
"""
2386+
if self._auto_now or (
2387+
self._auto_now_add and not self._has_value(entity)
2388+
):
2389+
value = self._now()
2390+
self._store_value(entity, value)
2391+
2392+
def _db_set_value(self, v, p, value):
2393+
"""Helper for :meth:`_serialize`.
2394+
2395+
Raises:
2396+
NotImplementedError: Always. This method is virtual.
2397+
"""
2398+
raise NotImplementedError
2399+
2400+
def _db_get_value(self, v, unused_p):
2401+
"""Helper for :meth:`_deserialize`.
2402+
2403+
Raises:
2404+
NotImplementedError: Always. This method is virtual.
2405+
"""
22552406
raise NotImplementedError
22562407

22572408

ndb/tests/unit/test_model.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import datetime
1516
import pickle
1617
import types
1718
import unittest.mock
@@ -1797,9 +1798,121 @@ def test_constructor():
17971798

17981799
class TestDateTimeProperty:
17991800
@staticmethod
1800-
def test_constructor():
1801+
def test_constructor_defaults():
1802+
prop = model.DateTimeProperty()
1803+
# Check that none of the constructor defaults were used.
1804+
assert prop.__dict__ == {}
1805+
1806+
@staticmethod
1807+
def test_constructor_explicit():
1808+
now = datetime.datetime.utcnow()
1809+
prop = model.DateTimeProperty(
1810+
name="dt_val",
1811+
auto_now=True,
1812+
auto_now_add=False,
1813+
indexed=False,
1814+
repeated=False,
1815+
required=True,
1816+
default=now,
1817+
validator=TestProperty._example_validator,
1818+
verbose_name="VALUE FOR READING",
1819+
write_empty_list=False,
1820+
)
1821+
assert prop._name == b"dt_val" and prop._name != "dt_val"
1822+
assert prop._auto_now
1823+
assert not prop._auto_now_add
1824+
assert not prop._indexed
1825+
assert not prop._repeated
1826+
assert prop._required
1827+
assert prop._default == now
1828+
assert prop._choices is None
1829+
assert prop._validator is TestProperty._example_validator
1830+
assert prop._verbose_name == "VALUE FOR READING"
1831+
assert not prop._write_empty_list
1832+
1833+
@staticmethod
1834+
def test_constructor_repeated():
1835+
with pytest.raises(ValueError):
1836+
model.DateTimeProperty(name="dt_val", auto_now=True, repeated=True)
1837+
with pytest.raises(ValueError):
1838+
model.DateTimeProperty(
1839+
name="dt_val", auto_now_add=True, repeated=True
1840+
)
1841+
1842+
prop = model.DateTimeProperty(name="dt_val", repeated=True)
1843+
assert prop._repeated
1844+
1845+
@staticmethod
1846+
def test__validate():
1847+
prop = model.DateTimeProperty(name="dt_val")
1848+
value = datetime.datetime.utcnow()
1849+
assert prop._validate(value) is None
1850+
1851+
@staticmethod
1852+
def test__validate_invalid():
1853+
prop = model.DateTimeProperty(name="dt_val")
1854+
with pytest.raises(exceptions.BadValueError):
1855+
prop._validate(None)
1856+
1857+
@staticmethod
1858+
def test__now():
1859+
dt_val = model.DateTimeProperty._now()
1860+
assert isinstance(dt_val, datetime.datetime)
1861+
1862+
@staticmethod
1863+
def test__prepare_for_put():
1864+
prop = model.DateTimeProperty(name="dt_val")
1865+
entity = unittest.mock.Mock(_values={}, spec=("_values",))
1866+
1867+
with unittest.mock.patch.object(prop, "_now") as _now:
1868+
prop._prepare_for_put(entity)
1869+
assert entity._values == {}
1870+
_now.assert_not_called()
1871+
1872+
@staticmethod
1873+
def test__prepare_for_put_auto_now():
1874+
prop = model.DateTimeProperty(name="dt_val", auto_now=True)
1875+
values1 = {}
1876+
values2 = {prop._name: unittest.mock.sentinel.dt}
1877+
for values in (values1, values2):
1878+
entity = unittest.mock.Mock(_values=values, spec=("_values",))
1879+
1880+
with unittest.mock.patch.object(prop, "_now") as _now:
1881+
prop._prepare_for_put(entity)
1882+
assert entity._values == {prop._name: _now.return_value}
1883+
_now.assert_called_once_with()
1884+
1885+
@staticmethod
1886+
def test__prepare_for_put_auto_now_add():
1887+
prop = model.DateTimeProperty(name="dt_val", auto_now_add=True)
1888+
values1 = {}
1889+
values2 = {prop._name: unittest.mock.sentinel.dt}
1890+
for values in (values1, values2):
1891+
entity = unittest.mock.Mock(
1892+
_values=values.copy(), spec=("_values",)
1893+
)
1894+
1895+
with unittest.mock.patch.object(prop, "_now") as _now:
1896+
prop._prepare_for_put(entity)
1897+
if values:
1898+
assert entity._values == values
1899+
_now.assert_not_called()
1900+
else:
1901+
assert entity._values != values
1902+
assert entity._values == {prop._name: _now.return_value}
1903+
_now.assert_called_once_with()
1904+
1905+
@staticmethod
1906+
def test__db_set_value():
1907+
prop = model.DateTimeProperty(name="dt_val")
18011908
with pytest.raises(NotImplementedError):
1802-
model.DateTimeProperty()
1909+
prop._db_set_value(None, None, None)
1910+
1911+
@staticmethod
1912+
def test__db_get_value():
1913+
prop = model.DateTimeProperty(name="dt_val")
1914+
with pytest.raises(NotImplementedError):
1915+
prop._db_get_value(None, None)
18031916

18041917

18051918
class TestDateProperty:

0 commit comments

Comments
 (0)