Skip to content

Commit fb3ad1a

Browse files
Merge pull request #749 from neo4j-contrib/rc/5.1.2
Rc/5.1.2
2 parents 90b7a5d + 8c6b874 commit fb3ad1a

17 files changed

+314
-63
lines changed

Changelog

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
Version 5.1.2 2023-09
2+
* Raise ValueError on reserved keywords ; add tests #590 #623
3+
* Add support for relationship property uniqueness constraints. Introduced in Neo4j 5.7.
4+
* Fix various issues, including fetching self-referencing relationship with same name as node labels #589
5+
* Bumped neo4j-driver to 5.12.0
6+
17
Version 5.1.1 2023-08
28
* Add impersonation
39
* Bumped neo4j-driver to 5.11.0

doc/source/properties.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,19 @@ This is useful when hiding graph properties behind a python property::
179179
def name(self, value):
180180
self.name_ = value
181181

182+
Reserved properties
183+
===================
184+
185+
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.
186+
187+
* Nodes :
188+
* deleted - used to mark an object for deletion by neomodel
189+
* Relationships :
190+
* source - id of the source node for a relationship
191+
* target - id of the target node
192+
* Both :
193+
* id - internal Neo4j id of elements in version 4 ; deprecated in 5
194+
* element_id - internal Neo4j id of elements in version 5
182195

183196
.. _properties_notes:
184197

doc/source/relationships.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Neomodel uses :mod:`~neomodel.relationship` models to define the properties stor
5050
index=True
5151
)
5252
met = StringProperty()
53+
# Uniqueness constraints for relationship properties
54+
# are only available from Neo4j version 5.7 onwards
55+
meeting_id = StringProperty(unique_index=True)
5356

5457
class Person(StructuredNode):
5558
name = StringProperty()

neomodel/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "5.1.1"
1+
__version__ = "5.1.2"

neomodel/core.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from neo4j.exceptions import ClientError
66

77
from neomodel import config
8-
from neomodel.exceptions import DoesNotExist, NodeClassAlreadyDefined
8+
from neomodel.exceptions import (
9+
DoesNotExist,
10+
FeatureNotSupported,
11+
NodeClassAlreadyDefined,
12+
)
913
from neomodel.hooks import hooks
1014
from neomodel.properties import Property, PropertyManager
1115
from neomodel.util import Database, _get_node_properties, _UnsavedNode, classproperty
@@ -160,6 +164,27 @@ def _create_relationship_index(relationship_type: str, property_name: str, stdou
160164
raise
161165

162166

167+
def _create_relationship_constraint(relationship_type: str, property_name: str, stdout):
168+
if db.version_is_higher_than("5.7"):
169+
try:
170+
db.cypher_query(
171+
f"""CREATE CONSTRAINT constraint_unique_{relationship_type}_{property_name}
172+
FOR ()-[r:{relationship_type}]-() REQUIRE r.{property_name} IS UNIQUE"""
173+
)
174+
except ClientError as e:
175+
if e.code in (
176+
RULE_ALREADY_EXISTS,
177+
CONSTRAINT_ALREADY_EXISTS,
178+
):
179+
stdout.write(f"{str(e)}\n")
180+
else:
181+
raise
182+
else:
183+
raise FeatureNotSupported(
184+
f"Unique indexes on relationships are not supported in Neo4j version {db.database_version}. Please upgrade to Neo4j 5.7 or higher."
185+
)
186+
187+
163188
def _install_node(cls, name, property, quiet, stdout):
164189
# Create indexes and constraints for node property
165190
db_property = property.db_property or name
@@ -201,6 +226,16 @@ def _install_relationship(cls, relationship, quiet, stdout):
201226
property_name=db_property,
202227
stdout=stdout,
203228
)
229+
elif property.unique_index:
230+
if not quiet:
231+
stdout.write(
232+
f" + Creating relationship unique constraint for {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n"
233+
)
234+
_create_relationship_constraint(
235+
relationship_type=relationship_type,
236+
property_name=db_property,
237+
stdout=stdout,
238+
)
204239

205240

206241
def install_all_labels(stdout=None):
@@ -246,8 +281,21 @@ def __new__(mcs, name, bases, namespace):
246281
else:
247282
if "deleted" in namespace:
248283
raise ValueError(
249-
"Class property called 'deleted' conflicts "
250-
"with neomodel internals."
284+
"Property name 'deleted' is not allowed as it conflicts with neomodel internals."
285+
)
286+
elif "id" in namespace:
287+
raise ValueError(
288+
"""
289+
Property name 'id' is not allowed as it conflicts with neomodel internals.
290+
Consider using 'uid' or 'identifier' as id is also a Neo4j internal.
291+
"""
292+
)
293+
elif "element_id" in namespace:
294+
raise ValueError(
295+
"""
296+
Property name 'element_id' is not allowed as it conflicts with neomodel internals.
297+
Consider using 'uid' or 'identifier' as element_id is also a Neo4j internal.
298+
"""
251299
)
252300
for key, value in (
253301
(x, y) for x, y in namespace.items() if isinstance(y, Property)

neomodel/match.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ def process_filter_args(cls, kwargs):
230230
operator = "="
231231

232232
if prop not in cls.defined_properties(rels=False):
233-
raise ValueError(f"No such property {prop} on {cls.__name__}")
233+
raise ValueError(
234+
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."
235+
)
234236

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

422424
# build source
425+
rel_ident = self.create_ident()
423426
lhs_ident = self.build_source(traversal.source)
424-
rhs_ident = traversal.name + rhs_label
425-
self._ast.return_clause = traversal.name
427+
traversal_ident = f"{traversal.name}_{rel_ident}"
428+
rhs_ident = traversal_ident + rhs_label
429+
self._ast.return_clause = traversal_ident
426430
self._ast.result_class = traversal.target_class
427431

428-
rel_ident = self.create_ident()
429432
stmt = _rel_helper(
430433
lhs=lhs_ident,
431434
rhs=rhs_ident,
@@ -437,7 +440,7 @@ def build_traversal(self, traversal):
437440
if traversal.filters:
438441
self.build_where_stmt(rel_ident, traversal.filters)
439442

440-
return traversal.name
443+
return traversal_ident
441444

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

930933
if prop not in self.source_class.defined_properties(rels=False):
931934
raise ValueError(
932-
f"No such property {prop} on {self.source_class.__name__}"
935+
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."
933936
)
934937

935938
property_obj = getattr(self.source_class, prop)

neomodel/relationship.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ def __new__(mcs, name, bases, dct):
1010
inst = super().__new__(mcs, name, bases, dct)
1111
for key, value in dct.items():
1212
if issubclass(value.__class__, Property):
13+
if key == "source" or key == "target":
14+
raise ValueError(
15+
"Property names 'source' and 'target' are not allowed as they conflict with neomodel internals."
16+
)
17+
elif key == "id":
18+
raise ValueError(
19+
"""
20+
Property name 'id' is not allowed as it conflicts with neomodel internals.
21+
Consider using 'uid' or 'identifier' as id is also a Neo4j internal.
22+
"""
23+
)
24+
elif key == "element_id":
25+
raise ValueError(
26+
"""
27+
Property name 'element_id' is not allowed as it conflicts with neomodel internals.
28+
Consider using 'uid' or 'identifier' as element_id is also a Neo4j internal.
29+
"""
30+
)
1331
value.name = key
1432
value.owner = inst
1533

@@ -153,4 +171,3 @@ def inflate(cls, rel):
153171
srel._end_node_element_id_property = rel.end_node.element_id
154172
srel.element_id_property = rel.element_id
155173
return srel
156-

neomodel/util.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,27 @@ def list_constraints(self) -> Sequence[dict]:
479479

480480
return constraints_as_dict
481481

482+
def version_is_higher_than(self, version_tag: str) -> bool:
483+
"""Returns true if the database version is higher or equal to a given tag
484+
485+
Args:
486+
version_tag (str): The version to compare against
487+
488+
Returns:
489+
bool: True if the database version is higher or equal to the given version
490+
"""
491+
return version_tag_to_integer(self.database_version) >= version_tag_to_integer(
492+
version_tag
493+
)
494+
495+
def edition_is_enterprise(self) -> bool:
496+
"""Returns true if the database edition is enterprise
497+
498+
Returns:
499+
bool: True if the database edition is enterprise
500+
"""
501+
return self.database_edition == "enterprise"
502+
482503

483504
class TransactionProxy:
484505
bookmarks: Optional[Bookmarks] = None
@@ -602,3 +623,21 @@ def enumerate_traceback(initial_frame):
602623
yield depth, frame
603624
frame = frame.f_back
604625
depth += 1
626+
627+
628+
def version_tag_to_integer(version_tag):
629+
"""
630+
Converts a version string to an integer representation to allow for quick comparisons between versions.
631+
632+
:param a_version_string: The version string to be converted (e.g. '3.4.0')
633+
:type a_version_string: str
634+
:return: An integer representation of the version string (e.g. '3.4.0' --> 340)
635+
:rtype: int
636+
"""
637+
components = version_tag.split(".")
638+
while len(components) < 3:
639+
components.append("0")
640+
num = 0
641+
for index, component in enumerate(components):
642+
num += (10 ** ((len(components) - 1) - index)) * int(component)
643+
return num

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ classifiers = [
3333
"Topic :: Database",
3434
]
3535
dependencies = [
36-
"neo4j==5.11.0",
36+
"neo4j==5.12.0",
3737
"pytz>=2021.1",
3838
"neobolt==1.7.17",
3939
"six==1.16.0",
4040
]
41-
version='5.1.1'
41+
version='5.1.2'
4242

4343
[project.urls]
4444
documentation = "https://neomodel.readthedocs.io/en/latest/"

test/conftest.py

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
import warnings
55

66
import pytest
7-
from neo4j.exceptions import ClientError as CypherError
8-
from neobolt.exceptions import ClientError
97

10-
from neomodel import change_neo4j_password, clear_neo4j_database, config, db
8+
from neomodel import clear_neo4j_database, config, db
9+
from neomodel.util import version_tag_to_integer
1110

1211

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

8483

85-
def version_to_dec(a_version_string):
86-
"""
87-
Converts a version string to a number to allow for quick checks on the versions of specific components.
88-
89-
:param a_version_string: The version string under test (e.g. '3.4.0')
90-
:type a_version_string: str
91-
:return: An integer representation of the string version, e.g. '3.4.0' --> 340
92-
"""
93-
components = a_version_string.split(".")
94-
while len(components) < 3:
95-
components.append("0")
96-
num = 0
97-
for a_component in enumerate(components):
98-
num += (10 ** ((len(components) - 1) - a_component[0])) * int(a_component[1])
99-
return num
100-
101-
10284
def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
10385
"""
10486
Checks if the NEO4J_VERSION is at least `required_least_neo4j_version` and skips a test if not.
@@ -112,12 +94,15 @@ def check_and_skip_neo4j_least_version(required_least_neo4j_version, message):
11294
:type message: str
11395
:return: A boolean value of True if the version reported is at least `required_least_neo4j_version`
11496
"""
115-
if "NEO4J_VERSION" in os.environ:
116-
if version_to_dec(os.environ["NEO4J_VERSION"]) < required_least_neo4j_version:
117-
pytest.skip(
118-
"Neo4j version: {}. {}."
119-
"Skipping test.".format(os.environ["NEO4J_VERSION"], message)
120-
)
97+
if (
98+
"NEO4J_VERSION" in os.environ
99+
and version_tag_to_integer(os.environ["NEO4J_VERSION"])
100+
< required_least_neo4j_version
101+
):
102+
pytest.skip(
103+
"Neo4j version: {}. {}."
104+
"Skipping test.".format(os.environ["NEO4J_VERSION"], message)
105+
)
121106

122107

123108
@pytest.fixture

0 commit comments

Comments
 (0)