diff --git a/doc/source/properties.rst b/doc/source/properties.rst index 4b8e3bec..52d718d9 100644 --- a/doc/source/properties.rst +++ b/doc/source/properties.rst @@ -6,15 +6,15 @@ Property types The following properties are available on nodes and relationships: -==================================================== =========================================================== -:class:`~neomodel.properties.AliasProperty` :class:`~neomodel.properties.IntegerProperty` -:class:`~neomodel.properties.ArrayProperty` :class:`~neomodel.properties.JSONProperty` -:class:`~neomodel.properties.BooleanProperty` :class:`~neomodel.properties.RegexProperty` -:class:`~neomodel.properties.DateProperty` :class:`~neomodel.properties.StringProperty` (:ref:`Notes `) -:class:`~neomodel.properties.DateTimeProperty` :class:`~neomodel.properties.UniqueIdProperty` -:class:`~neomodel.properties.DateTimeFormatProperty` :class:`~neomodel.contrib.spatial_properties.PointProperty` -:class:`~neomodel.properties.FloatProperty` \ -==================================================== =========================================================== +========================================================= =========================================================== +:class:`~neomodel.properties.AliasProperty` :class:`~neomodel.properties.FloatProperty` +:class:`~neomodel.properties.ArrayProperty` :class:`~neomodel.properties.IntegerProperty` +:class:`~neomodel.properties.BooleanProperty` :class:`~neomodel.properties.JSONProperty` +:class:`~neomodel.properties.DateProperty` :class:`~neomodel.properties.RegexProperty` +:class:`~neomodel.properties.DateTimeProperty` :class:`~neomodel.properties.StringProperty` (:ref:`Notes `) +:class:`~neomodel.properties.DateTimeFormatProperty` :class:`~neomodel.properties.UniqueIdProperty` +:class:`~neomodel.properties.DateTimeNeo4jFormatProperty` :class:`~neomodel.contrib.spatial_properties.PointProperty` +========================================================= =========================================================== Naming Convention diff --git a/neomodel/__init__.py b/neomodel/__init__.py index d7d0febb..b7e1520a 100644 --- a/neomodel/__init__.py +++ b/neomodel/__init__.py @@ -25,6 +25,7 @@ BooleanProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, EmailProperty, FloatProperty, diff --git a/neomodel/properties.py b/neomodel/properties.py index 1cc1992f..d3e0652b 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -11,8 +11,7 @@ from neomodel import config from neomodel.exceptions import DeflateError, InflateError -if sys.version_info >= (3, 0): - Unicode = str +TOO_MANY_DEFAULTS = "too many defaults" def validator(fn): @@ -164,7 +163,7 @@ def __init__(self, expression=None, **kwargs): self.expression = actual_re def normalize(self, value): - normal = Unicode(value) + normal = str(value) if not re.match(self.expression, normal): raise ValueError(f"{value!r} does not match {self.expression!r}") return normal @@ -226,7 +225,7 @@ def normalize(self, value): raise ValueError( f"Property max length exceeded. Expected {self.max_length}, got {len(value)} == len('{value}')" ) - return Unicode(value) + return str(value) def default_value(self): return self.normalize(super().default_value()) @@ -356,7 +355,7 @@ def inflate(self, value): value = date(value.year, value.month, value.day) elif isinstance(value, str) and "T" in value: value = value[: value.find("T")] - return datetime.strptime(Unicode(value), "%Y-%m-%d").date() + return datetime.strptime(str(value), "%Y-%m-%d").date() @validator def deflate(self, value): @@ -382,7 +381,7 @@ class DateTimeFormatProperty(Property): def __init__(self, default_now=False, format="%Y-%m-%d", **kwargs): if default_now: if "default" in kwargs: - raise ValueError("too many defaults") + raise ValueError(TOO_MANY_DEFAULTS) kwargs["default"] = datetime.now() self.format = format @@ -390,7 +389,7 @@ def __init__(self, default_now=False, format="%Y-%m-%d", **kwargs): @validator def inflate(self, value): - return datetime.strptime(Unicode(value), self.format) + return datetime.strptime(str(value), self.format) @validator def deflate(self, value): @@ -413,7 +412,7 @@ class DateTimeProperty(Property): def __init__(self, default_now=False, **kwargs): if default_now: if "default" in kwargs: - raise ValueError("too many defaults") + raise ValueError(TOO_MANY_DEFAULTS) kwargs["default"] = lambda: datetime.utcnow().replace(tzinfo=pytz.utc) super().__init__(**kwargs) @@ -447,6 +446,38 @@ def deflate(self, value): return float((value - epoch_date).total_seconds()) +class DateTimeNeo4jFormatProperty(Property): + """ + Store a datetime by native neo4j format + + :param default_now: If ``True``, the creation time (Local) will be used as default. + Defaults to ``False``. + + :type default_now: :class:`bool` + """ + + form_field_class = "DateTimeNeo4jFormatField" + + def __init__(self, default_now=False, **kwargs): + if default_now: + if "default" in kwargs: + raise ValueError(TOO_MANY_DEFAULTS) + kwargs["default"] = datetime.now() + + self.format = format + super(DateTimeNeo4jFormatProperty, self).__init__(**kwargs) + + @validator + def inflate(self, value): + return value.to_native() + + @validator + def deflate(self, value): + if not isinstance(value, datetime): + raise ValueError("datetime object expected, got {0}.".format(type(value))) + return neo4j.time.DateTime.from_native(value) + + class JSONProperty(Property): """ Store a data structure as a JSON string. @@ -518,8 +549,8 @@ def __init__(self, **kwargs): @validator def inflate(self, value): - return Unicode(value) + return str(value) @validator def deflate(self, value): - return Unicode(value) + return str(value) diff --git a/neomodel/scripts/neomodel_generate_diagram.py b/neomodel/scripts/neomodel_generate_diagram.py index 32f2915f..5a81221b 100644 --- a/neomodel/scripts/neomodel_generate_diagram.py +++ b/neomodel/scripts/neomodel_generate_diagram.py @@ -42,6 +42,7 @@ BooleanProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, FloatProperty, IntegerProperty, @@ -107,8 +108,10 @@ def transform_property_type(prop_definition): return "bool" elif isinstance(prop_definition, DateProperty): return "date" - elif isinstance(prop_definition, DateTimeProperty) or isinstance( - prop_definition, DateTimeFormatProperty + elif ( + isinstance(prop_definition, DateTimeProperty) + or isinstance(prop_definition, DateTimeFormatProperty) + or isinstance(prop_definition, DateTimeNeo4jFormatProperty) ): return "datetime" elif isinstance(prop_definition, IntegerProperty): diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index 8102f71d..4f3eab2d 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -1,10 +1,17 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta from test._async_compat import mark_async_test +from neo4j import time from pytest import mark, raises from pytz import timezone -from neomodel import AsyncRelationship, AsyncStructuredNode, AsyncStructuredRel, adb +from neomodel import ( + AsyncRelationship, + AsyncStructuredNode, + AsyncStructuredRel, + adb, + config, +) from neomodel.contrib import AsyncSemiStructuredNode from neomodel.exceptions import ( DeflateError, @@ -13,9 +20,12 @@ UniqueProperty, ) from neomodel.properties import ( + AliasProperty, ArrayProperty, + BooleanProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, EmailProperty, IntegerProperty, @@ -24,6 +34,7 @@ RegexProperty, StringProperty, UniqueIdProperty, + validator, ) from neomodel.util import get_graph_entity_properties @@ -76,6 +87,12 @@ class TestChoices(AsyncStructuredNode): node = await TestChoices(sex="M").save() assert node.get_sex_display() == "Male" + with raises(ValueError): + + class WrongChoices(AsyncStructuredNode): + WRONG = "wrong" + wrong_prop = StringProperty(choices=WRONG) + def test_deflate_inflate(): prop = IntegerProperty(required=True) @@ -96,6 +113,25 @@ def test_deflate_inflate(): else: assert False, "DeflateError not raised." + with raises(ValueError, match="Unknown Property method tartiflate"): + + class CheeseProperty(IntegerProperty): + @validator + def tartiflate(self, value): + return int(value) + + +def test_boolean_property(): + prop = BooleanProperty(default=False) + prop.name = "foo" + prop.owner = FooBar + assert prop.deflate(True) is True + assert prop.deflate(False) is False + assert prop.inflate(True) is True + assert prop.inflate(False) is False + + assert prop.default_value() is False + def test_datetimes_timezones(): prop = DateTimeProperty() @@ -112,6 +148,18 @@ def test_datetimes_timezones(): assert time1.utctimetuple() < time2.utctimetuple() assert time1.tzname() == "UTC" + with raises(ValueError, match="too many defaults"): + _ = DateTimeFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + prev_force_timezone = config.FORCE_TIMEZONE + config.FORCE_TIMEZONE = True + with raises(ValueError, match=r".*No timezone provided."): + prop.deflate(datetime.now()) + + config.FORCE_TIMEZONE = prev_force_timezone + def test_date(): prop = DateProperty() @@ -121,6 +169,8 @@ def test_date(): assert prop.deflate(somedate) == "2012-12-15" assert prop.inflate("2012-12-15") == somedate + assert prop.inflate(time.DateTime(2007, 9, 27)) == date(2007, 9, 27) + def test_datetime_format(): some_format = "%Y-%m-%d %H:%M:%S" @@ -131,6 +181,49 @@ def test_datetime_format(): assert prop.deflate(some_datetime) == "2019-03-19 15:36:25" assert prop.inflate("2019-03-19 15:36:25") == some_datetime + with raises(ValueError, match=r"datetime object expected, got.*"): + prop.deflate(1234) + + with raises(ValueError, match="too many defaults"): + _ = DateTimeFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + secondProp = DateTimeFormatProperty(default_now=True) + assert secondProp.has_default + assert ( + timedelta(seconds=-2) + < secondProp.default - datetime.now() + < timedelta(seconds=2) + ) + + +def test_datetime_neo4j_format(): + prop = DateTimeNeo4jFormatProperty() + prop.name = "foo" + prop.owner = FooBar + some_datetime = datetime(2022, 12, 10, 14, 00, 00) + assert prop.has_default is False + assert prop.default is None + assert prop.deflate(some_datetime) == time.DateTime(2022, 12, 10, 14, 00, 00) + assert prop.inflate(time.DateTime(2022, 12, 10, 14, 00, 00)) == some_datetime + + with raises(ValueError, match=r"datetime object expected, got.*"): + prop.deflate(1234) + + with raises(ValueError, match="too many defaults"): + _ = DateTimeNeo4jFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + secondProp = DateTimeNeo4jFormatProperty(default_now=True) + assert secondProp.has_default + assert ( + timedelta(seconds=-2) + < secondProp.default - datetime.now() + < timedelta(seconds=2) + ) + def test_datetime_exceptions(): prop = DateTimeProperty() @@ -152,6 +245,9 @@ def test_datetime_exceptions(): else: assert False, "DeflateError not raised." + with raises(ValueError, match="too many defaults"): + _ = DateTimeProperty(default_now=True, default=datetime(1900, 1, 1, 0, 0, 0)) + def test_date_exceptions(): prop = DateProperty() @@ -174,6 +270,35 @@ def test_date_exceptions(): assert False, "DeflateError not raised." +def test_base_exceptions(): + # default-required conflict + with raises( + ValueError, + match="The arguments `required` and `default` are mutually exclusive.", + ): + _ = StringProperty(default="kakapo", required=True) + + # unique_index - index conflict + with raises( + ValueError, + match="The arguments `unique_index` and `index` are mutually exclusive.", + ): + _ = IntegerProperty(index=True, unique_index=True) + + # no default value + kakapo = StringProperty() + with raises(ValueError, match="No default value specified"): + kakapo.default_value() + + # missing normalize method + class WoopsProperty(NormalizedProperty): + pass + + woops = WoopsProperty() + with raises(NotImplementedError, match="Specialize normalize method"): + woops.normalize("kakapo") + + def test_json(): prop = JSONProperty() prop.name = "json" @@ -185,6 +310,17 @@ def test_json(): assert prop.inflate('{"test": [1, 2, 3]}') == value +def test_indexed(): + indexed = StringProperty(index=True) + assert indexed.is_indexed is True + + unique_indexed = StringProperty(unique_index=True) + assert unique_indexed.is_indexed is True + + not_indexed = StringProperty() + assert not_indexed.is_indexed is False + + @mark_async_test async def test_default_value(): class DefaultTestValue(AsyncStructuredNode): @@ -420,6 +556,20 @@ class CheckMyId(AsyncStructuredNode): cmid = await CheckMyId().save() assert len(cmid.uid) + matched_exception = r".*argument ignored by.*" + # Test ignored arguments + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(required=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(unique_index=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(index=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(default="kakapo") + class ArrayProps(AsyncStructuredNode): uid = StringProperty(unique_index=True) @@ -448,6 +598,15 @@ async def test_array_properties(): ap2 = await ArrayProps.nodes.get(uid="2") assert 2 in ap2.typed_arr + class Kakapo: + pass + + with raises(TypeError, match="Expecting neomodel Property"): + ArrayProperty(Kakapo) + + with raises(TypeError, match="Cannot have nested ArrayProperty"): + ArrayProperty(ArrayProperty()) + def test_illegal_array_base_prop_raises(): with raises(ValueError): @@ -520,3 +679,17 @@ class UniqueNullableNameNode(AsyncStructuredNode): await x.delete() await y.delete() await z.delete() + + +def test_alias_property(): + class AliasedClass(AsyncStructuredNode): + name = StringProperty(index=True) + national_id = IntegerProperty(unique_index=True) + alias = AliasProperty(to="name") + alias_national_id = AliasProperty(to="national_id") + whatever = StringProperty() + alias_whatever = AliasProperty(to="whatever") + + assert AliasedClass.alias.index is True + assert AliasedClass.alias_national_id.unique_index is True + assert AliasedClass.alias_whatever.index is False diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index 0f9a162f..1afe52a2 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -1,10 +1,11 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta from test._async_compat import mark_sync_test +from neo4j import time from pytest import mark, raises from pytz import timezone -from neomodel import Relationship, StructuredNode, StructuredRel, db +from neomodel import Relationship, StructuredNode, StructuredRel, config, db from neomodel.contrib import SemiStructuredNode from neomodel.exceptions import ( DeflateError, @@ -13,9 +14,12 @@ UniqueProperty, ) from neomodel.properties import ( + AliasProperty, ArrayProperty, + BooleanProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, EmailProperty, IntegerProperty, @@ -24,6 +28,7 @@ RegexProperty, StringProperty, UniqueIdProperty, + validator, ) from neomodel.util import get_graph_entity_properties @@ -76,6 +81,12 @@ class TestChoices(StructuredNode): node = TestChoices(sex="M").save() assert node.get_sex_display() == "Male" + with raises(ValueError): + + class WrongChoices(StructuredNode): + WRONG = "wrong" + wrong_prop = StringProperty(choices=WRONG) + def test_deflate_inflate(): prop = IntegerProperty(required=True) @@ -96,6 +107,25 @@ def test_deflate_inflate(): else: assert False, "DeflateError not raised." + with raises(ValueError, match="Unknown Property method tartiflate"): + + class CheeseProperty(IntegerProperty): + @validator + def tartiflate(self, value): + return int(value) + + +def test_boolean_property(): + prop = BooleanProperty(default=False) + prop.name = "foo" + prop.owner = FooBar + assert prop.deflate(True) is True + assert prop.deflate(False) is False + assert prop.inflate(True) is True + assert prop.inflate(False) is False + + assert prop.default_value() is False + def test_datetimes_timezones(): prop = DateTimeProperty() @@ -112,6 +142,18 @@ def test_datetimes_timezones(): assert time1.utctimetuple() < time2.utctimetuple() assert time1.tzname() == "UTC" + with raises(ValueError, match="too many defaults"): + _ = DateTimeFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + prev_force_timezone = config.FORCE_TIMEZONE + config.FORCE_TIMEZONE = True + with raises(ValueError, match=r".*No timezone provided."): + prop.deflate(datetime.now()) + + config.FORCE_TIMEZONE = prev_force_timezone + def test_date(): prop = DateProperty() @@ -121,6 +163,8 @@ def test_date(): assert prop.deflate(somedate) == "2012-12-15" assert prop.inflate("2012-12-15") == somedate + assert prop.inflate(time.DateTime(2007, 9, 27)) == date(2007, 9, 27) + def test_datetime_format(): some_format = "%Y-%m-%d %H:%M:%S" @@ -131,6 +175,49 @@ def test_datetime_format(): assert prop.deflate(some_datetime) == "2019-03-19 15:36:25" assert prop.inflate("2019-03-19 15:36:25") == some_datetime + with raises(ValueError, match=r"datetime object expected, got.*"): + prop.deflate(1234) + + with raises(ValueError, match="too many defaults"): + _ = DateTimeFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + secondProp = DateTimeFormatProperty(default_now=True) + assert secondProp.has_default + assert ( + timedelta(seconds=-2) + < secondProp.default - datetime.now() + < timedelta(seconds=2) + ) + + +def test_datetime_neo4j_format(): + prop = DateTimeNeo4jFormatProperty() + prop.name = "foo" + prop.owner = FooBar + some_datetime = datetime(2022, 12, 10, 14, 00, 00) + assert prop.has_default is False + assert prop.default is None + assert prop.deflate(some_datetime) == time.DateTime(2022, 12, 10, 14, 00, 00) + assert prop.inflate(time.DateTime(2022, 12, 10, 14, 00, 00)) == some_datetime + + with raises(ValueError, match=r"datetime object expected, got.*"): + prop.deflate(1234) + + with raises(ValueError, match="too many defaults"): + _ = DateTimeNeo4jFormatProperty( + default_now=True, default=datetime(1900, 1, 1, 0, 0, 0) + ) + + secondProp = DateTimeNeo4jFormatProperty(default_now=True) + assert secondProp.has_default + assert ( + timedelta(seconds=-2) + < secondProp.default - datetime.now() + < timedelta(seconds=2) + ) + def test_datetime_exceptions(): prop = DateTimeProperty() @@ -152,6 +239,9 @@ def test_datetime_exceptions(): else: assert False, "DeflateError not raised." + with raises(ValueError, match="too many defaults"): + _ = DateTimeProperty(default_now=True, default=datetime(1900, 1, 1, 0, 0, 0)) + def test_date_exceptions(): prop = DateProperty() @@ -174,6 +264,35 @@ def test_date_exceptions(): assert False, "DeflateError not raised." +def test_base_exceptions(): + # default-required conflict + with raises( + ValueError, + match="The arguments `required` and `default` are mutually exclusive.", + ): + _ = StringProperty(default="kakapo", required=True) + + # unique_index - index conflict + with raises( + ValueError, + match="The arguments `unique_index` and `index` are mutually exclusive.", + ): + _ = IntegerProperty(index=True, unique_index=True) + + # no default value + kakapo = StringProperty() + with raises(ValueError, match="No default value specified"): + kakapo.default_value() + + # missing normalize method + class WoopsProperty(NormalizedProperty): + pass + + woops = WoopsProperty() + with raises(NotImplementedError, match="Specialize normalize method"): + woops.normalize("kakapo") + + def test_json(): prop = JSONProperty() prop.name = "json" @@ -185,6 +304,17 @@ def test_json(): assert prop.inflate('{"test": [1, 2, 3]}') == value +def test_indexed(): + indexed = StringProperty(index=True) + assert indexed.is_indexed is True + + unique_indexed = StringProperty(unique_index=True) + assert unique_indexed.is_indexed is True + + not_indexed = StringProperty() + assert not_indexed.is_indexed is False + + @mark_sync_test def test_default_value(): class DefaultTestValue(StructuredNode): @@ -416,6 +546,20 @@ class CheckMyId(StructuredNode): cmid = CheckMyId().save() assert len(cmid.uid) + matched_exception = r".*argument ignored by.*" + # Test ignored arguments + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(required=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(unique_index=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(index=False) + + with raises(ValueError, match=matched_exception): + _ = UniqueIdProperty(default="kakapo") + class ArrayProps(StructuredNode): uid = StringProperty(unique_index=True) @@ -444,6 +588,15 @@ def test_array_properties(): ap2 = ArrayProps.nodes.get(uid="2") assert 2 in ap2.typed_arr + class Kakapo: + pass + + with raises(TypeError, match="Expecting neomodel Property"): + ArrayProperty(Kakapo) + + with raises(TypeError, match="Cannot have nested ArrayProperty"): + ArrayProperty(ArrayProperty()) + def test_illegal_array_base_prop_raises(): with raises(ValueError): @@ -516,3 +669,17 @@ class UniqueNullableNameNode(StructuredNode): x.delete() y.delete() z.delete() + + +def test_alias_property(): + class AliasedClass(StructuredNode): + name = StringProperty(index=True) + national_id = IntegerProperty(unique_index=True) + alias = AliasProperty(to="name") + alias_national_id = AliasProperty(to="national_id") + whatever = StringProperty() + alias_whatever = AliasProperty(to="whatever") + + assert AliasedClass.alias.index is True + assert AliasedClass.alias_national_id.unique_index is True + assert AliasedClass.alias_whatever.index is False