Skip to content

Commit

Permalink
Merge pull request #749 from neo4j-contrib/rc/5.1.2
Browse files Browse the repository at this point in the history
Rc/5.1.2
  • Loading branch information
mariusconjeaud committed Oct 4, 2023
2 parents 90b7a5d + 8c6b874 commit fb3ad1a
Show file tree
Hide file tree
Showing 17 changed files with 314 additions and 63 deletions.
6 changes: 6 additions & 0 deletions Changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Version 5.1.2 2023-09
* Raise ValueError on reserved keywords ; add tests #590 #623
* Add support for relationship property uniqueness constraints. Introduced in Neo4j 5.7.
* Fix various issues, including fetching self-referencing relationship with same name as node labels #589
* Bumped neo4j-driver to 5.12.0

Version 5.1.1 2023-08
* Add impersonation
* Bumped neo4j-driver to 5.11.0
Expand Down
13 changes: 13 additions & 0 deletions doc/source/properties.rst
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ This is useful when hiding graph properties behind a python property::
def name(self, value):
self.name_ = value

Reserved properties
===================

To prevent conflicts with neomodel / Neo4j internals, the following properties are reserved, and will throw a ValueError if you try to define them in elements.

* Nodes :
* deleted - used to mark an object for deletion by neomodel
* Relationships :
* source - id of the source node for a relationship
* target - id of the target node
* Both :
* id - internal Neo4j id of elements in version 4 ; deprecated in 5
* element_id - internal Neo4j id of elements in version 5

.. _properties_notes:

Expand Down
3 changes: 3 additions & 0 deletions doc/source/relationships.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Neomodel uses :mod:`~neomodel.relationship` models to define the properties stor
index=True
)
met = StringProperty()
# Uniqueness constraints for relationship properties
# are only available from Neo4j version 5.7 onwards
meeting_id = StringProperty(unique_index=True)

class Person(StructuredNode):
name = StringProperty()
Expand Down
2 changes: 1 addition & 1 deletion neomodel/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "5.1.1"
__version__ = "5.1.2"
54 changes: 51 additions & 3 deletions neomodel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from neo4j.exceptions import ClientError

from neomodel import config
from neomodel.exceptions import DoesNotExist, NodeClassAlreadyDefined
from neomodel.exceptions import (
DoesNotExist,
FeatureNotSupported,
NodeClassAlreadyDefined,
)
from neomodel.hooks import hooks
from neomodel.properties import Property, PropertyManager
from neomodel.util import Database, _get_node_properties, _UnsavedNode, classproperty
Expand Down Expand Up @@ -160,6 +164,27 @@ def _create_relationship_index(relationship_type: str, property_name: str, stdou
raise


def _create_relationship_constraint(relationship_type: str, property_name: str, stdout):
if db.version_is_higher_than("5.7"):
try:
db.cypher_query(
f"""CREATE CONSTRAINT constraint_unique_{relationship_type}_{property_name}
FOR ()-[r:{relationship_type}]-() REQUIRE r.{property_name} IS UNIQUE"""
)
except ClientError as e:
if e.code in (
RULE_ALREADY_EXISTS,
CONSTRAINT_ALREADY_EXISTS,
):
stdout.write(f"{str(e)}\n")
else:
raise
else:
raise FeatureNotSupported(
f"Unique indexes on relationships are not supported in Neo4j version {db.database_version}. Please upgrade to Neo4j 5.7 or higher."
)


def _install_node(cls, name, property, quiet, stdout):
# Create indexes and constraints for node property
db_property = property.db_property or name
Expand Down Expand Up @@ -201,6 +226,16 @@ def _install_relationship(cls, relationship, quiet, stdout):
property_name=db_property,
stdout=stdout,
)
elif property.unique_index:
if not quiet:
stdout.write(
f" + Creating relationship unique constraint for {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n"
)
_create_relationship_constraint(
relationship_type=relationship_type,
property_name=db_property,
stdout=stdout,
)


def install_all_labels(stdout=None):
Expand Down Expand Up @@ -246,8 +281,21 @@ def __new__(mcs, name, bases, namespace):
else:
if "deleted" in namespace:
raise ValueError(
"Class property called 'deleted' conflicts "
"with neomodel internals."
"Property name 'deleted' is not allowed as it conflicts with neomodel internals."
)
elif "id" in namespace:
raise ValueError(
"""
Property name 'id' is not allowed as it conflicts with neomodel internals.
Consider using 'uid' or 'identifier' as id is also a Neo4j internal.
"""
)
elif "element_id" in namespace:
raise ValueError(
"""
Property name 'element_id' is not allowed as it conflicts with neomodel internals.
Consider using 'uid' or 'identifier' as element_id is also a Neo4j internal.
"""
)
for key, value in (
(x, y) for x, y in namespace.items() if isinstance(y, Property)
Expand Down
15 changes: 9 additions & 6 deletions neomodel/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ def process_filter_args(cls, kwargs):
operator = "="

if prop not in cls.defined_properties(rels=False):
raise ValueError(f"No such property {prop} on {cls.__name__}")
raise ValueError(
f"No such property {prop} on {cls.__name__}. Note that Neo4j internals like id or element_id are not allowed for use in this operation."
)

property_obj = getattr(cls, prop)
if isinstance(property_obj, AliasProperty):
Expand Down Expand Up @@ -420,12 +422,13 @@ def build_traversal(self, traversal):
rhs_label = ":" + traversal.target_class.__label__

# build source
rel_ident = self.create_ident()
lhs_ident = self.build_source(traversal.source)
rhs_ident = traversal.name + rhs_label
self._ast.return_clause = traversal.name
traversal_ident = f"{traversal.name}_{rel_ident}"
rhs_ident = traversal_ident + rhs_label
self._ast.return_clause = traversal_ident
self._ast.result_class = traversal.target_class

rel_ident = self.create_ident()
stmt = _rel_helper(
lhs=lhs_ident,
rhs=rhs_ident,
Expand All @@ -437,7 +440,7 @@ def build_traversal(self, traversal):
if traversal.filters:
self.build_where_stmt(rel_ident, traversal.filters)

return traversal.name
return traversal_ident

def _additional_return(self, name):
if name not in self._ast.additional_return and name != self._ast.return_clause:
Expand Down Expand Up @@ -929,7 +932,7 @@ def order_by(self, *props):

if prop not in self.source_class.defined_properties(rels=False):
raise ValueError(
f"No such property {prop} on {self.source_class.__name__}"
f"No such property {prop} on {self.source_class.__name__}. Note that Neo4j internals like id or element_id are not allowed for use in this operation."
)

property_obj = getattr(self.source_class, prop)
Expand Down
19 changes: 18 additions & 1 deletion neomodel/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ def __new__(mcs, name, bases, dct):
inst = super().__new__(mcs, name, bases, dct)
for key, value in dct.items():
if issubclass(value.__class__, Property):
if key == "source" or key == "target":
raise ValueError(
"Property names 'source' and 'target' are not allowed as they conflict with neomodel internals."
)
elif key == "id":
raise ValueError(
"""
Property name 'id' is not allowed as it conflicts with neomodel internals.
Consider using 'uid' or 'identifier' as id is also a Neo4j internal.
"""
)
elif key == "element_id":
raise ValueError(
"""
Property name 'element_id' is not allowed as it conflicts with neomodel internals.
Consider using 'uid' or 'identifier' as element_id is also a Neo4j internal.
"""
)
value.name = key
value.owner = inst

Expand Down Expand Up @@ -153,4 +171,3 @@ def inflate(cls, rel):
srel._end_node_element_id_property = rel.end_node.element_id
srel.element_id_property = rel.element_id
return srel

39 changes: 39 additions & 0 deletions neomodel/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,27 @@ def list_constraints(self) -> Sequence[dict]:

return constraints_as_dict

def version_is_higher_than(self, version_tag: str) -> bool:
"""Returns true if the database version is higher or equal to a given tag
Args:
version_tag (str): The version to compare against
Returns:
bool: True if the database version is higher or equal to the given version
"""
return version_tag_to_integer(self.database_version) >= version_tag_to_integer(
version_tag
)

def edition_is_enterprise(self) -> bool:
"""Returns true if the database edition is enterprise
Returns:
bool: True if the database edition is enterprise
"""
return self.database_edition == "enterprise"


class TransactionProxy:
bookmarks: Optional[Bookmarks] = None
Expand Down Expand Up @@ -602,3 +623,21 @@ def enumerate_traceback(initial_frame):
yield depth, frame
frame = frame.f_back
depth += 1


def version_tag_to_integer(version_tag):
"""
Converts a version string to an integer representation to allow for quick comparisons between versions.
:param a_version_string: The version string to be converted (e.g. '3.4.0')
:type a_version_string: str
:return: An integer representation of the version string (e.g. '3.4.0' --> 340)
:rtype: int
"""
components = version_tag.split(".")
while len(components) < 3:
components.append("0")
num = 0
for index, component in enumerate(components):
num += (10 ** ((len(components) - 1) - index)) * int(component)
return num
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ classifiers = [
"Topic :: Database",
]
dependencies = [
"neo4j==5.11.0",
"neo4j==5.12.0",
"pytz>=2021.1",
"neobolt==1.7.17",
"six==1.16.0",
]
version='5.1.1'
version='5.1.2'

[project.urls]
documentation = "https://neomodel.readthedocs.io/en/latest/"
Expand Down
37 changes: 11 additions & 26 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
import warnings

import pytest
from neo4j.exceptions import ClientError as CypherError
from neobolt.exceptions import ClientError

from neomodel import change_neo4j_password, clear_neo4j_database, config, db
from neomodel import clear_neo4j_database, config, db
from neomodel.util import version_tag_to_integer


def pytest_addoption(parser):
Expand Down Expand Up @@ -82,23 +81,6 @@ def pytest_sessionstart(session):
db.cypher_query("GRANT IMPERSONATE (troygreene) ON DBMS TO admin")


def version_to_dec(a_version_string):
"""
Converts a version string to a number to allow for quick checks on the versions of specific components.
:param a_version_string: The version string under test (e.g. '3.4.0')
:type a_version_string: str
:return: An integer representation of the string version, e.g. '3.4.0' --> 340
"""
components = a_version_string.split(".")
while len(components) < 3:
components.append("0")
num = 0
for a_component in enumerate(components):
num += (10 ** ((len(components) - 1) - a_component[0])) * int(a_component[1])
return num


def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
"""
Checks if the NEO4J_VERSION is at least `required_least_neo4j_version` and skips a test if not.
Expand All @@ -112,12 +94,15 @@ def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
:type message: str
:return: A boolean value of True if the version reported is at least `required_least_neo4j_version`
"""
if "NEO4J_VERSION" in os.environ:
if version_to_dec(os.environ["NEO4J_VERSION"]) < required_least_neo4j_version:
pytest.skip(
"Neo4j version: {}. {}."
"Skipping test.".format(os.environ["NEO4J_VERSION"], message)
)
if (
"NEO4J_VERSION" in os.environ
and version_tag_to_integer(os.environ["NEO4J_VERSION"])
< required_least_neo4j_version
):
pytest.skip(
"Neo4j version: {}. {}."
"Skipping test.".format(os.environ["NEO4J_VERSION"], message)
)


@pytest.fixture
Expand Down
23 changes: 5 additions & 18 deletions test/test_contrib/test_spatial_datatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,7 @@

import neomodel
import neomodel.contrib.spatial_properties


def version_to_dec(a_version_string):
"""
Converts a version string to a number to allow for quick checks on the versions of specific components.
:param a_version_string: The version string under test (e.g. '3.4.0')
:type a_version_string: str
:return: An integer representation of the string version, e.g. '3.4.0' --> 340
"""
components = a_version_string.split(".")
while len(components) < 3:
components.append("0")
num = 0
for a_component in enumerate(components):
num += (10 ** ((len(components) - 1) - a_component[0])) * int(a_component[1])
return num
from neomodel.util import version_tag_to_integer


def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
Expand All @@ -46,7 +30,10 @@ def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
:return: A boolean value of True if the version reported is at least `required_least_neo4j_version`
"""
if "NEO4J_VERSION" in os.environ:
if version_to_dec(os.environ["NEO4J_VERSION"]) < required_least_neo4j_version:
if (
version_tag_to_integer(os.environ["NEO4J_VERSION"])
< required_least_neo4j_version
):
pytest.skip(
"Neo4j version: {}. {}."
"Skipping test.".format(os.environ["NEO4J_VERSION"], message)
Expand Down
22 changes: 22 additions & 0 deletions test/test_dbms_awareness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from pytest import mark

from neomodel import db


@mark.skipif(
db.database_version != "5.7.0", reason="Testing a specific database version"
)
def test_version_awareness():
assert db.database_version == "5.7.0"
assert db.version_is_higher_than("5.7")
assert db.version_is_higher_than("5")
assert db.version_is_higher_than("4")

assert not db.version_is_higher_than("5.8")


def test_edition_awareness():
if db.database_edition == "enterprise":
assert db.edition_is_enterprise()
else:
assert not db.edition_is_enterprise()
Loading

0 comments on commit fb3ad1a

Please sign in to comment.