From 28b9f0ca565bb593caccbaab8a95375bac9c4015 Mon Sep 17 00:00:00 2001 From: Thomas Meschede Date: Mon, 23 Nov 2020 00:11:02 +0100 Subject: [PATCH 01/24] support native neo4j datetime type --- neomodel/properties.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/neomodel/properties.py b/neomodel/properties.py index b7e251c0..4000a120 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -6,6 +6,7 @@ import uuid import warnings from datetime import date, datetime +import neo4j.time import pytz @@ -531,6 +532,38 @@ def deflate(self, value): return float((value - epoch_date).total_seconds()) +class DateTimeFormatNeo4j(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 = 'DateTimeFormatFieldNeo4j' + + def __init__(self, default_now=False, **kwargs): + if default_now: + if 'default' in kwargs: + raise ValueError('too many defaults') + kwargs['default'] = lambda: datetime.now() + + self.format = format + super(DateTimeFormatNeo4j, 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. From 2f4054c79b8149b6e774e10b2be4ed3b2998453f Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Thu, 30 May 2024 11:04:54 +0200 Subject: [PATCH 02/24] Add db specific node class registry --- neomodel/async_/core.py | 88 ++++++++++++++++--------- neomodel/async_/relationship_manager.py | 5 +- neomodel/exceptions.py | 34 ++++++++-- neomodel/sync_/core.py | 88 ++++++++++++++++--------- neomodel/sync_/relationship_manager.py | 5 +- test/async_/test_registry.py | 80 ++++++++++++++++++++++ test/sync_/test_registry.py | 76 +++++++++++++++++++++ 7 files changed, 308 insertions(+), 68 deletions(-) create mode 100644 test/async_/test_registry.py create mode 100644 test/sync_/test_registry.py diff --git a/neomodel/async_/core.py b/neomodel/async_/core.py index 234edc68..d6f95be6 100644 --- a/neomodel/async_/core.py +++ b/neomodel/async_/core.py @@ -91,6 +91,7 @@ class AsyncDatabase(local): """ _NODE_CLASS_REGISTRY = {} + _DB_SPECIFIC_CLASS_REGISTRY = {} def __init__(self): self._active_transaction = None @@ -352,13 +353,42 @@ def _object_resolution(self, object_to_resolve): # Consequently, the type checking was changed for both # Node, Relationship objects if isinstance(object_to_resolve, Node): - return self._NODE_CLASS_REGISTRY[ - frozenset(object_to_resolve.labels) - ].inflate(object_to_resolve) + _labels = frozenset(object_to_resolve.labels) + if _labels in self._NODE_CLASS_REGISTRY: + return self._NODE_CLASS_REGISTRY[_labels].inflate(object_to_resolve) + elif ( + self._database_name is not None + and self._database_name in self._DB_SPECIFIC_CLASS_REGISTRY + and _labels in self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name] + ): + return self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name][ + _labels + ].inflate(object_to_resolve) + else: + raise NodeClassNotDefined( + object_to_resolve, + self._NODE_CLASS_REGISTRY, + self._DB_SPECIFIC_CLASS_REGISTRY, + ) if isinstance(object_to_resolve, Relationship): rel_type = frozenset([object_to_resolve.type]) - return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + if rel_type in self._NODE_CLASS_REGISTRY: + return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + elif ( + self._database_name is not None + and self._database_name in self._DB_SPECIFIC_CLASS_REGISTRY + and rel_type in self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name] + ): + return self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name][ + rel_type + ].inflate(object_to_resolve) + else: + raise RelationshipClassNotDefined( + object_to_resolve, + self._NODE_CLASS_REGISTRY, + self._DB_SPECIFIC_CLASS_REGISTRY, + ) if isinstance(object_to_resolve, Path): from neomodel.async_.path import AsyncNeomodelPath @@ -388,30 +418,13 @@ def _result_resolution(self, result_list): # Object resolution occurs in-place for a_result_item in enumerate(result_list): for a_result_attribute in enumerate(a_result_item[1]): - try: - # Primitive types should remain primitive types, - # Nodes to be resolved to native objects - resolved_object = a_result_attribute[1] - - resolved_object = self._object_resolution(resolved_object) - - result_list[a_result_item[0]][ - a_result_attribute[0] - ] = resolved_object - - except KeyError as exc: - # Not being able to match the label set of a node with a known object results - # in a KeyError in the internal dictionary used for resolution. If it is impossible - # to match, then raise an exception with more details about the error. - if isinstance(a_result_attribute[1], Node): - raise NodeClassNotDefined( - a_result_attribute[1], self._NODE_CLASS_REGISTRY - ) from exc - - if isinstance(a_result_attribute[1], Relationship): - raise RelationshipClassNotDefined( - a_result_attribute[1], self._NODE_CLASS_REGISTRY - ) from exc + # Primitive types should remain primitive types, + # Nodes to be resolved to native objects + resolved_object = a_result_attribute[1] + + resolved_object = self._object_resolution(resolved_object) + + result_list[a_result_item[0]][a_result_attribute[0]] = resolved_object return result_list @@ -1083,10 +1096,23 @@ def build_class_registry(cls): possible_label_combinations.append(base_label_set) for label_set in possible_label_combinations: - if label_set not in adb._NODE_CLASS_REGISTRY: - adb._NODE_CLASS_REGISTRY[label_set] = cls + if not hasattr(cls, "__target_databases__"): + if label_set not in adb._NODE_CLASS_REGISTRY: + adb._NODE_CLASS_REGISTRY[label_set] = cls + else: + raise NodeClassAlreadyDefined( + cls, adb._NODE_CLASS_REGISTRY, adb._DB_SPECIFIC_CLASS_REGISTRY + ) else: - raise NodeClassAlreadyDefined(cls, adb._NODE_CLASS_REGISTRY) + for database in cls.__target_databases__: + if database not in adb._DB_SPECIFIC_CLASS_REGISTRY: + adb._DB_SPECIFIC_CLASS_REGISTRY[database] = {} + if label_set not in adb._DB_SPECIFIC_CLASS_REGISTRY[database]: + adb._DB_SPECIFIC_CLASS_REGISTRY[database][label_set] = cls + else: + raise NodeClassAlreadyDefined( + cls, adb._NODE_CLASS_REGISTRY, adb._DB_SPECIFIC_CLASS_REGISTRY + ) NodeBase = NodeMeta("NodeBase", (AsyncPropertyManager,), {"__abstract_node__": True}) diff --git a/neomodel/async_/relationship_manager.py b/neomodel/async_/relationship_manager.py index 4182d143..35c07f0c 100644 --- a/neomodel/async_/relationship_manager.py +++ b/neomodel/async_/relationship_manager.py @@ -457,7 +457,10 @@ def __init__( is_parent = issubclass(model_from_registry, model) if is_direct_subclass(model, AsyncStructuredRel) and not is_parent: raise RelationshipClassRedefined( - relation_type, adb._NODE_CLASS_REGISTRY, model + relation_type, + adb._NODE_CLASS_REGISTRY, + adb._DB_SPECIFIC_CLASS_REGISTRY, + model, ) else: adb._NODE_CLASS_REGISTRY[label_set] = model diff --git a/neomodel/exceptions.py b/neomodel/exceptions.py index 36b3ba5b..1ffa339e 100644 --- a/neomodel/exceptions.py +++ b/neomodel/exceptions.py @@ -38,7 +38,12 @@ class ModelDefinitionException(NeomodelException): Abstract exception to handle error conditions related to the node-to-class registry. """ - def __init__(self, db_node_rel_class, current_node_class_registry): + def __init__( + self, + db_node_rel_class, + current_node_class_registry, + current_db_specific_node_class_registry, + ): """ Initialises the exception with the database node that caused the missmatch. @@ -46,9 +51,14 @@ def __init__(self, db_node_rel_class, current_node_class_registry): from the DBMS, or a data model class from an application's hierarchy. :param current_node_class_registry: Dictionary that maps frozenset of node labels to model classes + :param current_db_specific_node_class_registry: Dictionary that maps frozenset of + node labels to model classes for specific databases """ self.db_node_rel_class = db_node_rel_class self.current_node_class_registry = current_node_class_registry + self.current_db_specific_node_class_registry = ( + current_db_specific_node_class_registry + ) def _get_node_class_registry_formatted(self): """ @@ -57,13 +67,23 @@ def _get_node_class_registry_formatted(self): :return: str """ - ncr_items = list( + output = "\n".join( map( lambda x: f"{','.join(x[0])} --> {x[1]}", self.current_node_class_registry.items(), ) ) - return "\n".join(ncr_items) + for db, db_registry in self.current_db_specific_node_class_registry.items(): + output += f"\n\nDatabase-specific: {db}\n" + output += "\n".join( + list( + map( + lambda x: f"{','.join(x[0])} --> {x[1]}", + db_registry.items(), + ) + ) + ) + return output class NodeClassNotDefined(ModelDefinitionException): @@ -102,6 +122,7 @@ def __init__( self, db_rel_class_type, current_node_class_registry, + current_db_specific_node_class_registry, remapping_to_class, ): """ @@ -110,11 +131,16 @@ def __init__( :param db_rel_class_type: The type of the relationship that caused the error. :type db_rel_class_type: str (The label of the relationship that caused the error) :param current_node_class_registry: The current db object's node-class registry. + :param current_db_specific_node_class_registry: The current db object's node-class registry for specific databases. :type current_node_class_registry: dict :param remapping_to_class: The relationship class the relationship type was attempted to be redefined to. :type remapping_to_class: class """ - super().__init__(db_rel_class_type, current_node_class_registry) + super().__init__( + db_rel_class_type, + current_node_class_registry, + current_db_specific_node_class_registry, + ) self.remapping_to_class = remapping_to_class def __str__(self): diff --git a/neomodel/sync_/core.py b/neomodel/sync_/core.py index d4d7e3af..ba2477f1 100644 --- a/neomodel/sync_/core.py +++ b/neomodel/sync_/core.py @@ -91,6 +91,7 @@ class Database(local): """ _NODE_CLASS_REGISTRY = {} + _DB_SPECIFIC_CLASS_REGISTRY = {} def __init__(self): self._active_transaction = None @@ -350,13 +351,42 @@ def _object_resolution(self, object_to_resolve): # Consequently, the type checking was changed for both # Node, Relationship objects if isinstance(object_to_resolve, Node): - return self._NODE_CLASS_REGISTRY[ - frozenset(object_to_resolve.labels) - ].inflate(object_to_resolve) + _labels = frozenset(object_to_resolve.labels) + if _labels in self._NODE_CLASS_REGISTRY: + return self._NODE_CLASS_REGISTRY[_labels].inflate(object_to_resolve) + elif ( + self._database_name is not None + and self._database_name in self._DB_SPECIFIC_CLASS_REGISTRY + and _labels in self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name] + ): + return self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name][ + _labels + ].inflate(object_to_resolve) + else: + raise NodeClassNotDefined( + object_to_resolve, + self._NODE_CLASS_REGISTRY, + self._DB_SPECIFIC_CLASS_REGISTRY, + ) if isinstance(object_to_resolve, Relationship): rel_type = frozenset([object_to_resolve.type]) - return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + if rel_type in self._NODE_CLASS_REGISTRY: + return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + elif ( + self._database_name is not None + and self._database_name in self._DB_SPECIFIC_CLASS_REGISTRY + and rel_type in self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name] + ): + return self._DB_SPECIFIC_CLASS_REGISTRY[self._database_name][ + rel_type + ].inflate(object_to_resolve) + else: + raise RelationshipClassNotDefined( + object_to_resolve, + self._NODE_CLASS_REGISTRY, + self._DB_SPECIFIC_CLASS_REGISTRY, + ) if isinstance(object_to_resolve, Path): from neomodel.sync_.path import NeomodelPath @@ -386,30 +416,13 @@ def _result_resolution(self, result_list): # Object resolution occurs in-place for a_result_item in enumerate(result_list): for a_result_attribute in enumerate(a_result_item[1]): - try: - # Primitive types should remain primitive types, - # Nodes to be resolved to native objects - resolved_object = a_result_attribute[1] - - resolved_object = self._object_resolution(resolved_object) - - result_list[a_result_item[0]][ - a_result_attribute[0] - ] = resolved_object - - except KeyError as exc: - # Not being able to match the label set of a node with a known object results - # in a KeyError in the internal dictionary used for resolution. If it is impossible - # to match, then raise an exception with more details about the error. - if isinstance(a_result_attribute[1], Node): - raise NodeClassNotDefined( - a_result_attribute[1], self._NODE_CLASS_REGISTRY - ) from exc - - if isinstance(a_result_attribute[1], Relationship): - raise RelationshipClassNotDefined( - a_result_attribute[1], self._NODE_CLASS_REGISTRY - ) from exc + # Primitive types should remain primitive types, + # Nodes to be resolved to native objects + resolved_object = a_result_attribute[1] + + resolved_object = self._object_resolution(resolved_object) + + result_list[a_result_item[0]][a_result_attribute[0]] = resolved_object return result_list @@ -1079,10 +1092,23 @@ def build_class_registry(cls): possible_label_combinations.append(base_label_set) for label_set in possible_label_combinations: - if label_set not in db._NODE_CLASS_REGISTRY: - db._NODE_CLASS_REGISTRY[label_set] = cls + if not hasattr(cls, "__target_databases__"): + if label_set not in db._NODE_CLASS_REGISTRY: + db._NODE_CLASS_REGISTRY[label_set] = cls + else: + raise NodeClassAlreadyDefined( + cls, db._NODE_CLASS_REGISTRY, db._DB_SPECIFIC_CLASS_REGISTRY + ) else: - raise NodeClassAlreadyDefined(cls, db._NODE_CLASS_REGISTRY) + for database in cls.__target_databases__: + if database not in db._DB_SPECIFIC_CLASS_REGISTRY: + db._DB_SPECIFIC_CLASS_REGISTRY[database] = {} + if label_set not in db._DB_SPECIFIC_CLASS_REGISTRY[database]: + db._DB_SPECIFIC_CLASS_REGISTRY[database][label_set] = cls + else: + raise NodeClassAlreadyDefined( + cls, db._NODE_CLASS_REGISTRY, db._DB_SPECIFIC_CLASS_REGISTRY + ) NodeBase = NodeMeta("NodeBase", (PropertyManager,), {"__abstract_node__": True}) diff --git a/neomodel/sync_/relationship_manager.py b/neomodel/sync_/relationship_manager.py index f975ec5f..fadaee99 100644 --- a/neomodel/sync_/relationship_manager.py +++ b/neomodel/sync_/relationship_manager.py @@ -440,7 +440,10 @@ def __init__( is_parent = issubclass(model_from_registry, model) if is_direct_subclass(model, StructuredRel) and not is_parent: raise RelationshipClassRedefined( - relation_type, db._NODE_CLASS_REGISTRY, model + relation_type, + db._NODE_CLASS_REGISTRY, + db._DB_SPECIFIC_CLASS_REGISTRY, + model, ) else: db._NODE_CLASS_REGISTRY[label_set] = model diff --git a/test/async_/test_registry.py b/test/async_/test_registry.py new file mode 100644 index 00000000..47ac523c --- /dev/null +++ b/test/async_/test_registry.py @@ -0,0 +1,80 @@ +from test._async_compat import mark_async_test + +from pytest import raises + +from neomodel import AsyncStructuredNode, StringProperty, adb, config +from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined + + +@mark_async_test +async def test_db_specific_classes(): + db_one = "one" + db_two = "two" + await adb.cypher_query(f"CREATE DATABASE {db_one} IF NOT EXISTS") + await adb.cypher_query(f"CREATE DATABASE {db_two} IF NOT EXISTS") + + class Experiment(AsyncStructuredNode): + __label__ = "Experiment" + name = StringProperty() + + class PatientOne(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = [db_one] + name = StringProperty() + + class PatientTwo(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = [db_two] + identifier = StringProperty() + + # This should have reached this point without failing + # It means db specific registry is allowing reuse of labels in different databases + # Next test will check if the standard registry still denies reuse of labels + with raises(NodeClassAlreadyDefined): + + class ExperimentTwo(AsyncStructuredNode): + __label__ = "Experiment" + name = StringProperty() + + await ExperimentTwo(name="experiment2").save() + + # Finally, this tests that db specific registry denies reuse of labels in the same db + with raises(NodeClassAlreadyDefined): + + class PatientOneBis(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = [db_one] + name = StringProperty() + + await PatientOneBis(name="patient1.2").save() + + # Now, we will test object resolution + await adb.close_connection() + await adb.set_connection(url=f"{config.DATABASE_URL}/{db_one}") + await adb.clear_neo4j_database() + patient1 = await PatientOne(name="patient1").save() + patients, _ = await adb.cypher_query( + "MATCH (n:Patient) RETURN n", resolve_objects=True + ) + # This means that the auto object resolution is working + assert patients[0][0] == patient1 + + # TODO : Note, this does NOT prevent from saving to the wrong database + await adb.close_connection() + await adb.set_connection(url=f"{config.DATABASE_URL}/{db_two}") + await adb.clear_neo4j_database() + patient2 = await PatientTwo(identifier="patient2").save() + patients, _ = await adb.cypher_query( + "MATCH (n:Patient) RETURN n", resolve_objects=True + ) + assert patients[0][0] == patient2 + + await adb.close_connection() + await adb.set_connection(url=config.DATABASE_URL) + + +@mark_async_test +async def test_resolution_not_defined_class(): + await adb.cypher_query("CREATE (n:Gabagool)") + with raises(NodeClassNotDefined): + _ = await adb.cypher_query("MATCH (n:Gabagool) RETURN n", resolve_objects=True) diff --git a/test/sync_/test_registry.py b/test/sync_/test_registry.py new file mode 100644 index 00000000..e19d4082 --- /dev/null +++ b/test/sync_/test_registry.py @@ -0,0 +1,76 @@ +from test._async_compat import mark_sync_test + +from pytest import raises + +from neomodel import StringProperty, StructuredNode, config, db +from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined + + +@mark_sync_test +def test_db_specific_classes(): + db_one = "one" + db_two = "two" + db.cypher_query(f"CREATE DATABASE {db_one} IF NOT EXISTS") + db.cypher_query(f"CREATE DATABASE {db_two} IF NOT EXISTS") + + class Experiment(StructuredNode): + __label__ = "Experiment" + name = StringProperty() + + class PatientOne(StructuredNode): + __label__ = "Patient" + __target_databases__ = [db_one] + name = StringProperty() + + class PatientTwo(StructuredNode): + __label__ = "Patient" + __target_databases__ = [db_two] + identifier = StringProperty() + + # This should have reached this point without failing + # It means db specific registry is allowing reuse of labels in different databases + # Next test will check if the standard registry still denies reuse of labels + with raises(NodeClassAlreadyDefined): + + class ExperimentTwo(StructuredNode): + __label__ = "Experiment" + name = StringProperty() + + ExperimentTwo(name="experiment2").save() + + # Finally, this tests that db specific registry denies reuse of labels in the same db + with raises(NodeClassAlreadyDefined): + + class PatientOneBis(StructuredNode): + __label__ = "Patient" + __target_databases__ = [db_one] + name = StringProperty() + + PatientOneBis(name="patient1.2").save() + + # Now, we will test object resolution + db.close_connection() + db.set_connection(url=f"{config.DATABASE_URL}/{db_one}") + db.clear_neo4j_database() + patient1 = PatientOne(name="patient1").save() + patients, _ = db.cypher_query("MATCH (n:Patient) RETURN n", resolve_objects=True) + # This means that the auto object resolution is working + assert patients[0][0] == patient1 + + # TODO : Note, this does NOT prevent from saving to the wrong database + db.close_connection() + db.set_connection(url=f"{config.DATABASE_URL}/{db_two}") + db.clear_neo4j_database() + patient2 = PatientTwo(identifier="patient2").save() + patients, _ = db.cypher_query("MATCH (n:Patient) RETURN n", resolve_objects=True) + assert patients[0][0] == patient2 + + db.close_connection() + db.set_connection(url=config.DATABASE_URL) + + +@mark_sync_test +def test_resolution_not_defined_class(): + db.cypher_query("CREATE (n:Gabagool)") + with raises(NodeClassNotDefined): + _ = db.cypher_query("MATCH (n:Gabagool) RETURN n", resolve_objects=True) From 7eaf2476e70270743c48c5fcb3a8f095433fa7c8 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Thu, 30 May 2024 11:13:07 +0200 Subject: [PATCH 03/24] Skip test for community edition --- test/async_/test_registry.py | 4 +++- test/sync_/test_registry.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/async_/test_registry.py b/test/async_/test_registry.py index 47ac523c..45c8d67e 100644 --- a/test/async_/test_registry.py +++ b/test/async_/test_registry.py @@ -1,6 +1,6 @@ from test._async_compat import mark_async_test -from pytest import raises +from pytest import raises, skip from neomodel import AsyncStructuredNode, StringProperty, adb, config from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined @@ -8,6 +8,8 @@ @mark_async_test async def test_db_specific_classes(): + if not await adb.edition_is_enterprise(): + skip("Skipping test for community edition") db_one = "one" db_two = "two" await adb.cypher_query(f"CREATE DATABASE {db_one} IF NOT EXISTS") diff --git a/test/sync_/test_registry.py b/test/sync_/test_registry.py index e19d4082..166fcd77 100644 --- a/test/sync_/test_registry.py +++ b/test/sync_/test_registry.py @@ -1,6 +1,6 @@ from test._async_compat import mark_sync_test -from pytest import raises +from pytest import raises, skip from neomodel import StringProperty, StructuredNode, config, db from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined @@ -8,6 +8,8 @@ @mark_sync_test def test_db_specific_classes(): + if not db.edition_is_enterprise(): + skip("Skipping test for community edition") db_one = "one" db_two = "two" db.cypher_query(f"CREATE DATABASE {db_one} IF NOT EXISTS") From 53ff6fc8ec4cf132672ef8cd78b8fe183e9d8ac6 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Thu, 30 May 2024 15:21:16 +0200 Subject: [PATCH 04/24] Add doc ; extend test coverage --- doc/source/extending.rst | 55 ++++++++++++++++++++++++++++++++++++ test/async_/test_registry.py | 33 ++++++++++++++++++---- test/sync_/test_registry.py | 33 ++++++++++++++++++---- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/doc/source/extending.rst b/doc/source/extending.rst index df50f84d..78fe6537 100644 --- a/doc/source/extending.rst +++ b/doc/source/extending.rst @@ -21,6 +21,16 @@ Creating purely abstract classes is achieved using the `__abstract_node__` prope self.balance = self.balance + int(amount) self.save() +Custom label +------------ +By default, neomodel uses the class name as the label for nodes. This can be overridden by setting the __label__ property on the class:: + + class PersonClass(StructuredNode): + __label__ = "Person" + name = StringProperty(unique_index=True) + +Creating a PersonClass instance and saving it to the database will result in a node with the label "Person". + Optional Labels --------------- @@ -131,11 +141,16 @@ Consider for example the following snippet of code:: class PilotPerson(BasePerson): pass + + class UserClass(StructuredNode): + __label__ = "User" + Once this script is executed, the *node-class registry* would contain the following entries: :: {"BasePerson"} --> class BasePerson {"BasePerson", "TechnicalPerson"} --> class TechnicalPerson {"BasePerson", "PilotPerson"} --> class PilotPerson + {"User"} --> class UserClass Therefore, a ``Node`` with labels ``"BasePerson", "TechnicalPerson"`` would lead to the instantiation of a ``TechnicalPerson`` object. This automatic resolution is **optional** and can be invoked automatically via @@ -184,12 +199,52 @@ This automatic class resolution however, requires a bit of caution: ``{"BasePerson", "PilotPerson"}`` to ``PilotPerson`` **in the global scope** with a mapping of the same set of labels but towards the class defined within the **local scope** of ``some_function``. +3. Two classes with different names but the same __label__ override will also result in a ``ClassAlreadyDefined`` exception. + This can be avoided under certain circumstances, as explained in the next section on 'Database specific labels'. + Both ``ModelDefinitionMismatch`` and ``ClassAlreadyDefined`` produce an error message that returns the labels of the node that created the problem (either the `Node` returned from the database or the class that was attempted to be redefined) as well as the state of the current *node-class registry*. These two pieces of information can be used to debug the model mismatch further. +Database specific labels +------------------------ +**Only for Neo4j Enterprise Edition, with multiple databases** + +In some cases, it is necessary to have a class with a label that is not unique across the database. +This can be achieved by setting the `__target_databases__` property to a list of strings :: + class PatientOne(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = ["db_one"] + name = StringProperty() + + class PatientTwo(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = ["db_two"] + identifier = StringProperty() + +In this example, both `PatientOne` and `PatientTwo` have the label "Patient", but these will be mapped in a database-specific *node-class registry*. + +Now, if you fetch a node with label Patient from your database with auto resolution enabled, neomodel will try to resolve it to the correct class +based on the database it was fetched from :: + db.set_connection("bolt://neo4j:password@localhost:7687/db_one") + patients = db.cypher_query("MATCH (n:Patient) RETURN n", resolve_objects=True) --> instance of PatientOne + +The following will result in a ``ClassAlreadyDefined`` exception, because when retrieving from ``db_one``, +neomodel would not be able to decide which model to parse into :: + class GeneralPatient(AsyncStructuredNode): + __label__ = "Patient" + name = StringProperty() + + class PatientOne(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = ["db_one"] + name = StringProperty() + +.. warning:: This does not prevent you from saving a node to the "wrong database". So you can still save an instance of PatientTwo to database "db_one". + + ``neomodel`` under multiple processes and threads ------------------------------------------------- It is very important to realise that neomodel preserves a mapping of the set of labels associated with the Neo4J diff --git a/test/async_/test_registry.py b/test/async_/test_registry.py index 45c8d67e..aac8fa64 100644 --- a/test/async_/test_registry.py +++ b/test/async_/test_registry.py @@ -2,12 +2,25 @@ from pytest import raises, skip -from neomodel import AsyncStructuredNode, StringProperty, adb, config -from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined +from neomodel import ( + AsyncRelationshipTo, + AsyncStructuredNode, + AsyncStructuredRel, + DateProperty, + IntegerProperty, + StringProperty, + adb, + config, +) +from neomodel.exceptions import ( + NodeClassAlreadyDefined, + NodeClassNotDefined, + RelationshipClassRedefined, +) @mark_async_test -async def test_db_specific_classes(): +async def test_db_specific_node_labels(): if not await adb.edition_is_enterprise(): skip("Skipping test for community edition") db_one = "one" @@ -61,7 +74,6 @@ class PatientOneBis(AsyncStructuredNode): # This means that the auto object resolution is working assert patients[0][0] == patient1 - # TODO : Note, this does NOT prevent from saving to the wrong database await adb.close_connection() await adb.set_connection(url=f"{config.DATABASE_URL}/{db_two}") await adb.clear_neo4j_database() @@ -77,6 +89,17 @@ class PatientOneBis(AsyncStructuredNode): @mark_async_test async def test_resolution_not_defined_class(): + if not await adb.edition_is_enterprise(): + skip("Skipping test for community edition") + + class PatientX(AsyncStructuredNode): + __label__ = "Patient" + __target_databases__ = ["db_x"] + name = StringProperty() + await adb.cypher_query("CREATE (n:Gabagool)") - with raises(NodeClassNotDefined): + with raises( + NodeClassNotDefined, + match=r"Node with labels Gabagool does not resolve to any of the known objects[\s\S]*Database-specific: db_x.*", + ): _ = await adb.cypher_query("MATCH (n:Gabagool) RETURN n", resolve_objects=True) diff --git a/test/sync_/test_registry.py b/test/sync_/test_registry.py index 166fcd77..d47b0ca8 100644 --- a/test/sync_/test_registry.py +++ b/test/sync_/test_registry.py @@ -2,12 +2,25 @@ from pytest import raises, skip -from neomodel import StringProperty, StructuredNode, config, db -from neomodel.exceptions import NodeClassAlreadyDefined, NodeClassNotDefined +from neomodel import ( + DateProperty, + IntegerProperty, + RelationshipTo, + StringProperty, + StructuredNode, + StructuredRel, + config, + db, +) +from neomodel.exceptions import ( + NodeClassAlreadyDefined, + NodeClassNotDefined, + RelationshipClassRedefined, +) @mark_sync_test -def test_db_specific_classes(): +def test_db_specific_node_labels(): if not db.edition_is_enterprise(): skip("Skipping test for community edition") db_one = "one" @@ -59,7 +72,6 @@ class PatientOneBis(StructuredNode): # This means that the auto object resolution is working assert patients[0][0] == patient1 - # TODO : Note, this does NOT prevent from saving to the wrong database db.close_connection() db.set_connection(url=f"{config.DATABASE_URL}/{db_two}") db.clear_neo4j_database() @@ -73,6 +85,17 @@ class PatientOneBis(StructuredNode): @mark_sync_test def test_resolution_not_defined_class(): + if not db.edition_is_enterprise(): + skip("Skipping test for community edition") + + class PatientX(StructuredNode): + __label__ = "Patient" + __target_databases__ = ["db_x"] + name = StringProperty() + db.cypher_query("CREATE (n:Gabagool)") - with raises(NodeClassNotDefined): + with raises( + NodeClassNotDefined, + match=r"Node with labels Gabagool does not resolve to any of the known objects[\s\S]*Database-specific: db_x.*", + ): _ = db.cypher_query("MATCH (n:Gabagool) RETURN n", resolve_objects=True) From 23fcd0cc7237479cf164b4f42309a5276055509d Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Wed, 5 Jun 2024 17:23:24 +0200 Subject: [PATCH 05/24] Improve exception content; fix documentation --- doc/source/getting_started.rst | 23 ++++++++++------- neomodel/exceptions.py | 7 +++++- test/async_/test_match_api.py | 45 +++++++++++++++++++++++++++++++++- test/sync_/test_match_api.py | 45 +++++++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index f1af1c73..dfeca802 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -239,7 +239,7 @@ additional relations with a single call:: # The following call will generate one MATCH with traversal per # item in .fetch_relations() call - results = Person.nodes.all().fetch_relations('country') + results = Person.nodes.fetch_relations('country').all() for result in results: print(result[0]) # Person print(result[1]) # associated Country @@ -248,14 +248,23 @@ You can traverse more than one hop in your relations using the following syntax:: # Go from person to City then Country - Person.nodes.all().fetch_relations('city__country') + Person.nodes.fetch_relations('city__country').all() You can also force the use of an ``OPTIONAL MATCH`` statement using the following syntax:: from neomodel.match import Optional - results = Person.nodes.all().fetch_relations(Optional('country')) + results = Person.nodes.fetch_relations(Optional('country')).all() + +.. note:: + + Any relationship that you intend to traverse using this method **MUST have a model defined**, even if only the default StructuredRel, like:: + + class Person(StructuredNode): + country = RelationshipTo(Country, 'IS_FROM', model=StructuredRel) + + Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail. .. note:: @@ -263,18 +272,14 @@ the following syntax:: to `.fetch_relations()` and you can mix optional and non-optional relations, like:: - Person.nodes.all().fetch_relations('city__country', Optional('country')) + Person.nodes.fetch_relations('city__country', Optional('country')).all() .. note:: - This feature is still a work in progress for extending path traversal and fecthing. - It currently stops at returning the resolved objects as they are returned in Cypher. - So for instance, if your path looks like ``(startNode:Person)-[r1]->(middleNode:City)<-[r2]-(endNode:Country)``, + If your path looks like ``(startNode:Person)-[r1]->(middleNode:City)<-[r2]-(endNode:Country)``, then you will get a list of results, where each result is a list of ``(startNode, r1, middleNode, r2, endNode)``. These will be resolved by neomodel, so ``startNode`` will be a ``Person`` class as defined in neomodel for example. - If you want to go further in the resolution process, you have to develop your own parser (for now). - Async neomodel ============== diff --git a/neomodel/exceptions.py b/neomodel/exceptions.py index 36b3ba5b..bb1e1f94 100644 --- a/neomodel/exceptions.py +++ b/neomodel/exceptions.py @@ -90,7 +90,12 @@ class RelationshipClassNotDefined(ModelDefinitionException): def __str__(self): relationship_type = self.db_node_rel_class.type - return f"Relationship of type {relationship_type} does not resolve to any of the known objects\n{self._get_node_class_registry_formatted()}\n" + return f""" + Relationship of type {relationship_type} does not resolve to any of the known objects + {self._get_node_class_registry_formatted()} + Note that when using the fetch_relations method, the relationship type must be defined in the model, even if only defined to StructuredRel. + Otherwise, neomodel will not be able to determine which relationship model to resolve into. + """ class RelationshipClassRedefined(ModelDefinitionException): diff --git a/test/async_/test_match_api.py b/test/async_/test_match_api.py index 9ce4234e..e3195448 100644 --- a/test/async_/test_match_api.py +++ b/test/async_/test_match_api.py @@ -14,6 +14,7 @@ IntegerProperty, Q, StringProperty, + UniqueIdProperty, ) from neomodel._async_compat.util import AsyncUtil from neomodel.async_.match import ( @@ -22,7 +23,7 @@ AsyncTraversal, Optional, ) -from neomodel.exceptions import MultipleNodesReturned +from neomodel.exceptions import MultipleNodesReturned, RelationshipClassNotDefined class SupplierRel(AsyncStructuredRel): @@ -56,6 +57,28 @@ class Extension(AsyncStructuredNode): extension = AsyncRelationshipTo("Extension", "extension") +class CountryX(AsyncStructuredNode): + code = StringProperty(unique_index=True, required=True) + inhabitant = AsyncRelationshipFrom("PersonX", "IS_FROM") + + +class CityX(AsyncStructuredNode): + name = StringProperty(required=True) + country = AsyncRelationshipTo(CountryX, "FROM_COUNTRY") + + +class PersonX(AsyncStructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, default=0) + + # traverse outgoing IS_FROM relations, inflate to Country objects + country = AsyncRelationshipTo(CountryX, "IS_FROM") + + # traverse outgoing LIVES_IN relations, inflate to City objects + city = AsyncRelationshipTo(CityX, "LIVES_IN") + + @mark_async_test async def test_filter_exclude_via_labels(): await Coffee(name="Java", price=99).save() @@ -532,6 +555,7 @@ async def test_fetch_relations(): .fetch_relations("coffees__species") .all() ) + assert len(result[0]) == 5 assert arabica in result[0] assert robusta not in result[0] assert tesco in result[0] @@ -571,6 +595,25 @@ async def test_fetch_relations(): ) +@mark_async_test +async def test_issue_795(): + jim = await PersonX(name="Jim", age=3).save() # Create + jim.age = 4 + await jim.save() # Update, (with validation) + + germany = await CountryX(code="DE").save() + await jim.country.connect(germany) + berlin = await CityX(name="Berlin").save() + await berlin.country.connect(germany) + await jim.city.connect(berlin) + + with raises( + RelationshipClassNotDefined, + match=r"[\s\S]*Note that when using the fetch_relations method, the relationship type must be defined in the model.*", + ): + _ = await PersonX.nodes.fetch_relations("country").all() + + @mark_async_test async def test_in_filter_with_array_property(): tags = ["smoother", "sweeter", "chocolate", "sugar"] diff --git a/test/sync_/test_match_api.py b/test/sync_/test_match_api.py index 57da468f..170a7363 100644 --- a/test/sync_/test_match_api.py +++ b/test/sync_/test_match_api.py @@ -14,9 +14,10 @@ StringProperty, StructuredNode, StructuredRel, + UniqueIdProperty, ) from neomodel._async_compat.util import Util -from neomodel.exceptions import MultipleNodesReturned +from neomodel.exceptions import MultipleNodesReturned, RelationshipClassNotDefined from neomodel.sync_.match import NodeSet, Optional, QueryBuilder, Traversal @@ -49,6 +50,28 @@ class Extension(StructuredNode): extension = RelationshipTo("Extension", "extension") +class CountryX(StructuredNode): + code = StringProperty(unique_index=True, required=True) + inhabitant = RelationshipFrom("PersonX", "IS_FROM") + + +class CityX(StructuredNode): + name = StringProperty(required=True) + country = RelationshipTo(CountryX, "FROM_COUNTRY") + + +class PersonX(StructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, default=0) + + # traverse outgoing IS_FROM relations, inflate to Country objects + country = RelationshipTo(CountryX, "IS_FROM") + + # traverse outgoing LIVES_IN relations, inflate to City objects + city = RelationshipTo(CityX, "LIVES_IN") + + @mark_sync_test def test_filter_exclude_via_labels(): Coffee(name="Java", price=99).save() @@ -521,6 +544,7 @@ def test_fetch_relations(): .fetch_relations("coffees__species") .all() ) + assert len(result[0]) == 5 assert arabica in result[0] assert robusta not in result[0] assert tesco in result[0] @@ -560,6 +584,25 @@ def test_fetch_relations(): ) +@mark_sync_test +def test_issue_795(): + jim = PersonX(name="Jim", age=3).save() # Create + jim.age = 4 + jim.save() # Update, (with validation) + + germany = CountryX(code="DE").save() + jim.country.connect(germany) + berlin = CityX(name="Berlin").save() + berlin.country.connect(germany) + jim.city.connect(berlin) + + with raises( + RelationshipClassNotDefined, + match=r"[\s\S]*Note that when using the fetch_relations method, the relationship type must be defined in the model.*", + ): + _ = PersonX.nodes.fetch_relations("country").all() + + @mark_sync_test def test_in_filter_with_array_property(): tags = ["smoother", "sweeter", "chocolate", "sugar"] From 399dfc55e3cbadf189a630ec0ed49ed99d9d156a Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 14:54:31 +0200 Subject: [PATCH 06/24] Add test --- neomodel/__init__.py | 1 + neomodel/properties.py | 21 +++++++++---------- neomodel/scripts/neomodel_generate_diagram.py | 7 +++++-- test/async_/test_properties.py | 14 +++++++++++++ test/sync_/test_properties.py | 14 +++++++++++++ 5 files changed, 44 insertions(+), 13 deletions(-) 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 dbbafe30..ce810f7e 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -4,7 +4,6 @@ import sys import uuid from datetime import date, datetime -import neo4j.time import neo4j.time import pytz @@ -448,25 +447,26 @@ def deflate(self, value): return float((value - epoch_date).total_seconds()) -class DateTimeFormatNeo4j(Property): +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 = 'DateTimeFormatFieldNeo4j' + + 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'] = lambda: datetime.now() + if "default" in kwargs: + raise ValueError("too many defaults") + kwargs["default"] = datetime.now() self.format = format - super(DateTimeFormatNeo4j, self).__init__(**kwargs) + super(DateTimeNeo4jFormatProperty, self).__init__(**kwargs) @validator def inflate(self, value): @@ -475,11 +475,10 @@ def inflate(self, value): @validator def deflate(self, value): if not isinstance(value, datetime): - raise ValueError('datetime object expected, got {0}.'.format(type(value))) + 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. 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..f2e87047 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -1,6 +1,7 @@ from datetime import date, datetime from test._async_compat import mark_async_test +from neo4j import time from pytest import mark, raises from pytz import timezone @@ -16,6 +17,7 @@ ArrayProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, EmailProperty, IntegerProperty, @@ -132,6 +134,18 @@ def test_datetime_format(): assert prop.inflate("2019-03-19 15:36:25") == some_datetime +def test_datetime_neo4j_format(): + prop = DateTimeNeo4jFormatProperty() + prop.name = "foo" + prop.owner = FooBar + some_datetime = datetime(2022, 12, 10, 14, 00, 00) + 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) + + def test_datetime_exceptions(): prop = DateTimeProperty() prop.name = "created" diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index 0f9a162f..ee10f725 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -1,6 +1,7 @@ from datetime import date, datetime from test._async_compat import mark_sync_test +from neo4j import time from pytest import mark, raises from pytz import timezone @@ -16,6 +17,7 @@ ArrayProperty, DateProperty, DateTimeFormatProperty, + DateTimeNeo4jFormatProperty, DateTimeProperty, EmailProperty, IntegerProperty, @@ -132,6 +134,18 @@ def test_datetime_format(): assert prop.inflate("2019-03-19 15:36:25") == some_datetime +def test_datetime_neo4j_format(): + prop = DateTimeNeo4jFormatProperty() + prop.name = "foo" + prop.owner = FooBar + some_datetime = datetime(2022, 12, 10, 14, 00, 00) + 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) + + def test_datetime_exceptions(): prop = DateTimeProperty() prop.name = "created" From 7cd5567fe5b868990b55b5b6eba49e16291fc243 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 15:00:38 +0200 Subject: [PATCH 07/24] Remove Python2 leftover --- neomodel/properties.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/neomodel/properties.py b/neomodel/properties.py index ce810f7e..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) @@ -462,7 +461,7 @@ class DateTimeNeo4jFormatProperty(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"] = datetime.now() self.format = format @@ -550,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) From 4fde92c0b3e7e2f6e7ab31a6aa798e8e072caee0 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 15:10:28 +0200 Subject: [PATCH 08/24] Improve test coverage --- test/async_/test_properties.py | 17 ++++++++++++++++- test/sync_/test_properties.py | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index f2e87047..5924b8ae 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta from test._async_compat import mark_async_test from neo4j import time @@ -139,12 +139,27 @@ def test_datetime_neo4j_format(): 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() diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index ee10f725..39fd16b3 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta from test._async_compat import mark_sync_test from neo4j import time @@ -139,12 +139,27 @@ def test_datetime_neo4j_format(): 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() From 79fe25b8a2371923c6bc3f6a238abddce0108315 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 15:55:26 +0200 Subject: [PATCH 09/24] Improve test coverage --- test/async_/test_properties.py | 76 ++++++++++++++++++++++++++++++++++ test/sync_/test_properties.py | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index 5924b8ae..655dfd87 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -15,6 +15,7 @@ ) from neomodel.properties import ( ArrayProperty, + BooleanProperty, DateProperty, DateTimeFormatProperty, DateTimeNeo4jFormatProperty, @@ -26,6 +27,7 @@ RegexProperty, StringProperty, UniqueIdProperty, + validator, ) from neomodel.util import get_graph_entity_properties @@ -78,6 +80,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) @@ -98,6 +106,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() + 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.has_default is False + def test_datetimes_timezones(): prop = DateTimeProperty() @@ -203,6 +230,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" @@ -214,6 +270,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): @@ -477,6 +544,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): diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index 39fd16b3..ef6ca3eb 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -15,6 +15,7 @@ ) from neomodel.properties import ( ArrayProperty, + BooleanProperty, DateProperty, DateTimeFormatProperty, DateTimeNeo4jFormatProperty, @@ -26,6 +27,7 @@ RegexProperty, StringProperty, UniqueIdProperty, + validator, ) from neomodel.util import get_graph_entity_properties @@ -78,6 +80,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) @@ -98,6 +106,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() + 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.has_default is False + def test_datetimes_timezones(): prop = DateTimeProperty() @@ -203,6 +230,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" @@ -214,6 +270,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): @@ -473,6 +540,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): From 1f074f60aad3cb323d24be5beae7176f17af2638 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 16:27:38 +0200 Subject: [PATCH 10/24] Improve coverage further --- test/async_/test_properties.py | 71 ++++++++++++++++++++++++++++++++-- test/sync_/test_properties.py | 65 +++++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index 655dfd87..6dec67b4 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -5,7 +5,13 @@ 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, @@ -14,6 +20,7 @@ UniqueProperty, ) from neomodel.properties import ( + AliasProperty, ArrayProperty, BooleanProperty, DateProperty, @@ -115,7 +122,7 @@ def tartiflate(self, value): def test_boolean_property(): - prop = BooleanProperty() + prop = BooleanProperty(default=False) prop.name = "foo" prop.owner = FooBar assert prop.deflate(True) is True @@ -123,7 +130,7 @@ def test_boolean_property(): assert prop.inflate(True) is True assert prop.inflate(False) is False - assert prop.has_default is False + assert prop.default_value() is False def test_datetimes_timezones(): @@ -141,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() @@ -150,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" @@ -160,6 +181,22 @@ 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() @@ -516,6 +553,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) @@ -625,3 +676,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 ef6ca3eb..83662f21 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -5,7 +5,7 @@ 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, @@ -14,6 +14,7 @@ UniqueProperty, ) from neomodel.properties import ( + AliasProperty, ArrayProperty, BooleanProperty, DateProperty, @@ -115,7 +116,7 @@ def tartiflate(self, value): def test_boolean_property(): - prop = BooleanProperty() + prop = BooleanProperty(default=False) prop.name = "foo" prop.owner = FooBar assert prop.deflate(True) is True @@ -123,7 +124,7 @@ def test_boolean_property(): assert prop.inflate(True) is True assert prop.inflate(False) is False - assert prop.has_default is False + assert prop.default_value() is False def test_datetimes_timezones(): @@ -141,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() @@ -150,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" @@ -160,6 +175,22 @@ 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() @@ -512,6 +543,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) @@ -621,3 +666,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 From 0bfc4e375616fc79bd5c2c7aac55bf16f7808390 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 16:49:37 +0200 Subject: [PATCH 11/24] Last test coverage improvement --- test/async_/test_properties.py | 3 +++ test/sync_/test_properties.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/test/async_/test_properties.py b/test/async_/test_properties.py index 6dec67b4..4f3eab2d 100644 --- a/test/async_/test_properties.py +++ b/test/async_/test_properties.py @@ -245,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() diff --git a/test/sync_/test_properties.py b/test/sync_/test_properties.py index 83662f21..1afe52a2 100644 --- a/test/sync_/test_properties.py +++ b/test/sync_/test_properties.py @@ -239,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() From 5d1148436a7b998a5f6ef702dc83deb95f05ef43 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 17:00:45 +0200 Subject: [PATCH 12/24] Update doc --- doc/source/properties.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 From 3b9ad3e2ba0b4dd00610a9066befdbf9925b06a7 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 7 Jun 2024 17:02:48 +0200 Subject: [PATCH 13/24] Update version tag and changelog --- Changelog | 3 +++ doc/source/configuration.rst | 2 +- neomodel/_version.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Changelog b/Changelog index 2f1226d5..3749566d 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,6 @@ +Version 5.3.2 2024-06 +* Add DateTimeNeo4jFormatProperty for Neo4j native datetime format + Version 5.3.1 2024-05 * Add neomodel_generate_diagram script, which generates a graph model diagram based on your neomodel class definitions. Arrows and PlantUML dot options * Fix bug in async iterator async for MyClass.nodes diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 2d2c745a..e8c0a38b 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -32,7 +32,7 @@ Adjust driver configuration - these options are only available for this connecti config.MAX_TRANSACTION_RETRY_TIME = 30.0 # default config.RESOLVER = None # default config.TRUST = neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES # default - config.USER_AGENT = neomodel/v5.3.1 # default + config.USER_AGENT = neomodel/v5.3.2 # default Setting the database name, if different from the default one:: diff --git a/neomodel/_version.py b/neomodel/_version.py index 0419a93d..07f0e9e2 100644 --- a/neomodel/_version.py +++ b/neomodel/_version.py @@ -1 +1 @@ -__version__ = "5.3.1" +__version__ = "5.3.2" From fea0c3b0f1d4523978297e38585f58348b9eb887 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 16:49:42 +0200 Subject: [PATCH 14/24] Add support for full-text index creation #810 --- doc/source/getting_started.rst | 2 + doc/source/index.rst | 1 + doc/source/schema_management.rst | 91 +++++++++++++++++++++ neomodel/async_/core.py | 100 +++++++++++++++++++++++ neomodel/properties.py | 8 ++ neomodel/sync_/core.py | 100 +++++++++++++++++++++++ test/async_/test_label_install.py | 125 +++++++++++++++++++++++++++++ test/sync_/test_label_install.py | 128 +++++++++++++++++++++++++++++- 8 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 doc/source/schema_management.rst diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index f1af1c73..77ffa523 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -73,6 +73,8 @@ in the case of ``Relationship`` it will be possible to be queried in either dire Neomodel automatically creates a label for each ``StructuredNode`` class in the database with the corresponding indexes and constraints. +.. _inspect_database_doc: + Database Inspection - Requires APOC =================================== You can inspect an existing Neo4j database to generate a neomodel definition file using the ``inspect`` command:: diff --git a/doc/source/index.rst b/doc/source/index.rst index 9ee82ef6..91a728c0 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -73,6 +73,7 @@ Contents relationships properties spatial_properties + schema_management queries cypher transactions diff --git a/doc/source/schema_management.rst b/doc/source/schema_management.rst new file mode 100644 index 00000000..dbb0b308 --- /dev/null +++ b/doc/source/schema_management.rst @@ -0,0 +1,91 @@ +================= +Schema management +================= + +Neo4j allows a flexible schema management, where you can define indexes and constraints on the properties of nodes and relationships. +To learn more, please refer to the `Neo4j schema documentation `_. + +Defining your model +------------------- + +neomodel allows you to define indexes and constraints in your node and relationship classes, like so: :: + + from neomodel import (StructuredNode, StructuredRel, StringProperty, + IntegerProperty, RelationshipTo) + + class LocatedIn(StructuredRel): + since = IntegerProperty(index=True) + + class Country(StructuredNode): + code = StringProperty(unique_index=True) + + class City(StructuredNode): + name = StringProperty(index=True) + country = RelationshipTo(Country, 'FROM_COUNTRY', model=LocatedIn) + + +Applying constraints and indexes +-------------------------------- +After creating your model, any constraints or indexes must be applied to Neo4j and ``neomodel`` provides a +script (:ref:`neomodel_install_labels`) to automate this: :: + + $ neomodel_install_labels yourapp.py someapp.models --db bolt://neo4j_username:neo4j_password@localhost:7687 + +It is important to execute this after altering the schema and observe the number of classes it reports. + +Ommitting the ``--db`` argument will default to the ``NEO4J_BOLT_URL`` environment variable. This is useful for masking +your credentials. + +.. note:: + The script will only create indexes and constraints that are defined in your model classes. It will not remove any + existing indexes or constraints that are not defined in your model classes. + +Indexes +======= + +The following indexes are supported: + +- ``index=True``: This will create the default Neo4j index on the property (currently RANGE). +- ``fulltext_index=True``: This will create a FULLTEXT index on the property. Only available for Neo4j version 5.16 or higher. With this one, you can define the following options: + - ``fulltext_analyzer``: The analyzer to use. The default is ``standard-no-stop-words``. + - ``fulltext_eventually_consistent``: Whether the index should be eventually consistent. The default is ``False``. + +Please refer to the `Neo4j documentation `_. for more information on fulltext indexes. + +- Vector indexes (Work in progress) + +Those indexes are available for both node- and relationship properties. + +.. note:: + Yes, you can create multiple indexes of a different type on the same property. For example, a default index and a fulltext index. + +.. note:: + For the semantic indexes (fulltext and vector), this allows you to create indexes, but searching those indexes require using Cypher queries. + This is because Cypher only supports querying those indexes through a specific procedure for now. + +Full example: :: + + class VeryIndexedNode(StructuredNode): + name = StringProperty( + index=True, + fulltext_index=True, + fulltext_analyzer='english', + fulltext_eventually_consistent=True + ) + +Constraints +=========== + +The following constraints are supported: + +- ``unique_index=True``: This will create a uniqueness constraint on the property. Available for both nodes and relationships (Neo4j version 5.7 or higher). + +.. note:: + The uniquess constraint of Neo4j is not supported as such, but using ``required=True`` on a property serves the same purpose. + + +Extracting the schema from a database +===================================== + +You can extract the schema from an existing database using the ``neomodel_inspect_database`` script (:ref:`inspect_database_doc`). +This script will output the schema in the neomodel format, including indexes and constraints. \ No newline at end of file diff --git a/neomodel/async_/core.py b/neomodel/async_/core.py index 234edc68..3f675cda 100644 --- a/neomodel/async_/core.py +++ b/neomodel/async_/core.py @@ -738,6 +738,43 @@ async def _create_node_index(self, label: str, property_name: str, stdout): else: raise + async def _create_node_fulltext_index( + self, + label: str, + property_name: str, + stdout, + analyzer: str = None, + eventually_consistent: bool = False, + ): + if await self.version_is_higher_than("5.16"): + query = f"CREATE FULLTEXT INDEX fulltext_index_{label}_{property_name} FOR (n:{label}) ON EACH [n.{property_name}]" + if analyzer or eventually_consistent: + if analyzer is None: + analyzer = "standard-no-stop-words" + query += f""" + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{analyzer}', + `fulltext.eventually_consistent`: {eventually_consistent} + }} + }} + """ + query += ";" + try: + await self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.16 or higher." + ) + async def _create_node_constraint(self, label: str, property_name: str, stdout): try: await self.cypher_query( @@ -769,6 +806,43 @@ async def _create_relationship_index( else: raise + async def _create_relationship_fulltext_index( + self, + relationship_type: str, + property_name: str, + stdout, + analyzer: str = None, + eventually_consistent: bool = False, + ): + if await self.version_is_higher_than("5.16"): + query = f"CREATE FULLTEXT INDEX fulltext_index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}]" + if analyzer or eventually_consistent: + if analyzer is None: + analyzer = "standard-no-stop-words" + query += f""" + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{analyzer}', + `fulltext.eventually_consistent`: {eventually_consistent} + }} + }} + """ + query += ";" + try: + await self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.16 or higher." + ) + async def _create_relationship_constraint( self, relationship_type: str, property_name: str, stdout ): @@ -812,6 +886,19 @@ async def _install_node(self, cls, name, property, quiet, stdout): label=cls.__label__, property_name=db_property, stdout=stdout ) + if property.fulltext_index: + if not quiet: + stdout.write( + f" + Creating fulltext index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" + ) + await self._create_node_fulltext_index( + label=cls.__label__, + property_name=db_property, + stdout=stdout, + analyzer=property.fulltext_analyzer, + eventually_consistent=property.fulltext_eventually_consistent, + ) + async def _install_relationship(self, cls, relationship, quiet, stdout): # Create indexes and constraints for relationship property relationship_cls = relationship.definition["model"] @@ -842,6 +929,19 @@ async def _install_relationship(self, cls, relationship, quiet, stdout): stdout=stdout, ) + if property.fulltext_index: + if not quiet: + stdout.write( + f" + Creating fulltext index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" + ) + await self._create_relationship_fulltext_index( + relationship_type=relationship_type, + property_name=db_property, + stdout=stdout, + analyzer=property.fulltext_analyzer, + eventually_consistent=property.fulltext_eventually_consistent, + ) + # Create a singleton instance of the database object adb = AsyncDatabase() diff --git a/neomodel/properties.py b/neomodel/properties.py index 1cc1992f..50f54a83 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -67,6 +67,10 @@ def __init__( self, unique_index=False, index=False, + fulltext_index=False, + vector_index=False, + fulltext_analyzer=None, + fulltext_eventually_consistent=False, required=False, default=None, db_property=None, @@ -86,6 +90,10 @@ def __init__( self.required = required self.unique_index = unique_index self.index = index + self.fulltext_index = fulltext_index + self.fulltext_analyzer = fulltext_analyzer + self.fulltext_eventually_consistent = fulltext_eventually_consistent + self.vector_index = vector_index self.default = default self.has_default = self.default is not None self.db_property = db_property diff --git a/neomodel/sync_/core.py b/neomodel/sync_/core.py index d4d7e3af..84778322 100644 --- a/neomodel/sync_/core.py +++ b/neomodel/sync_/core.py @@ -736,6 +736,43 @@ def _create_node_index(self, label: str, property_name: str, stdout): else: raise + def _create_node_fulltext_index( + self, + label: str, + property_name: str, + stdout, + analyzer: str = None, + eventually_consistent: bool = False, + ): + if self.version_is_higher_than("5.16"): + query = f"CREATE FULLTEXT INDEX fulltext_index_{label}_{property_name} FOR (n:{label}) ON EACH [n.{property_name}]" + if analyzer or eventually_consistent: + if analyzer is None: + analyzer = "standard-no-stop-words" + query += f""" + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{analyzer}', + `fulltext.eventually_consistent`: {eventually_consistent} + }} + }} + """ + query += ";" + try: + self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.16 or higher." + ) + def _create_node_constraint(self, label: str, property_name: str, stdout): try: self.cypher_query( @@ -767,6 +804,43 @@ def _create_relationship_index( else: raise + def _create_relationship_fulltext_index( + self, + relationship_type: str, + property_name: str, + stdout, + analyzer: str = None, + eventually_consistent: bool = False, + ): + if self.version_is_higher_than("5.16"): + query = f"CREATE FULLTEXT INDEX fulltext_index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}]" + if analyzer or eventually_consistent: + if analyzer is None: + analyzer = "standard-no-stop-words" + query += f""" + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{analyzer}', + `fulltext.eventually_consistent`: {eventually_consistent} + }} + }} + """ + query += ";" + try: + self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.16 or higher." + ) + def _create_relationship_constraint( self, relationship_type: str, property_name: str, stdout ): @@ -810,6 +884,19 @@ def _install_node(self, cls, name, property, quiet, stdout): label=cls.__label__, property_name=db_property, stdout=stdout ) + if property.fulltext_index: + if not quiet: + stdout.write( + f" + Creating fulltext index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" + ) + self._create_node_fulltext_index( + label=cls.__label__, + property_name=db_property, + stdout=stdout, + analyzer=property.fulltext_analyzer, + eventually_consistent=property.fulltext_eventually_consistent, + ) + def _install_relationship(self, cls, relationship, quiet, stdout): # Create indexes and constraints for relationship property relationship_cls = relationship.definition["model"] @@ -840,6 +927,19 @@ def _install_relationship(self, cls, relationship, quiet, stdout): stdout=stdout, ) + if property.fulltext_index: + if not quiet: + stdout.write( + f" + Creating fulltext index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" + ) + self._create_relationship_fulltext_index( + relationship_type=relationship_type, + property_name=db_property, + stdout=stdout, + analyzer=property.fulltext_analyzer, + eventually_consistent=property.fulltext_eventually_consistent, + ) + # Create a singleton instance of the database object db = Database() diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 2d710a19..05bf1d99 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -1,4 +1,6 @@ +import io from test._async_compat import mark_async_test +from unittest.mock import patch import pytest @@ -177,6 +179,129 @@ class NodeWithUniqueIndexRelationship(AsyncStructuredNode): rel2 = await node1.has_rel.connect(node3, {"name": "rel1"}) +@mark_async_test +async def test_fulltext_index(): + if not await adb.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + class FullTextIndexNode(AsyncStructuredNode): + name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + + await adb.install_labels(FullTextIndexNode) + indexes = await adb.list_indexes() + index_names = [index["name"] for index in indexes] + assert "fulltext_index_FullTextIndexNode_name" in index_names + + await adb.cypher_query("DROP INDEX fulltext_index_FullTextIndexNode_name") + + +@mark_async_test +async def test_fulltext_index_conflict(): + if not await adb.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + await adb.cypher_query( + "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" + ) + + class FullTextIndexNode(AsyncStructuredNode): + name = StringProperty(fulltext_index=True) + + await adb.install_labels(FullTextIndexNode) + + console_output = stream.getvalue() + assert "There already exists an index" in console_output + + +@mark_async_test +async def test_fulltext_index_not_supported(): + if await adb.version_is_higher_than("5.16"): + pytest.skip("Test only for versions lower than 5.16") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" + ): + + class FullTextIndexNode(AsyncStructuredNode): + name = StringProperty(fulltext_index=True) + + await adb.install_labels(FullTextIndexNode) + + +@mark_async_test +async def test_rel_fulltext_index(): + if not await adb.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + class FullTextIndexRel(AsyncStructuredRel): + name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + + class FullTextIndexRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + await adb.install_labels(FullTextIndexRelNode) + indexes = await adb.list_indexes() + index_names = [index["name"] for index in indexes] + assert "fulltext_index_FULLTEXT_INDEX_REL_name" in index_names + + await adb.cypher_query("DROP INDEX fulltext_index_FULLTEXT_INDEX_REL_name") + + +@mark_async_test +async def test_rel_fulltext_index_conflict(): + if not await adb.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + await adb.cypher_query( + "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" + ) + + class FullTextIndexRel(AsyncStructuredRel): + name = StringProperty( + fulltext_index=True, fulltext_eventually_consistent=True + ) + + class FullTextIndexRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + await adb.install_labels(FullTextIndexRelNode) + + console_output = stream.getvalue() + assert "There already exists an index" in console_output + + +@mark_async_test +async def test_rel_fulltext_index_not_supported(): + if await adb.version_is_higher_than("5.16"): + pytest.skip("Test only for versions lower than 5.16") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" + ): + + class FullTextIndexRel(AsyncStructuredRel): + name = StringProperty( + fulltext_index=True, fulltext_eventually_consistent=True + ) + + class FullTextIndexRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + await adb.install_labels(FullTextIndexRelNode) + + async def _drop_constraints_for_label_and_property( label: str = None, property: str = None ): diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 14bfe107..82c3c260 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -1,4 +1,6 @@ +import io from test._async_compat import mark_sync_test +from unittest.mock import patch import pytest @@ -21,8 +23,7 @@ class NodeWithConstraint(StructuredNode): name = StringProperty(unique_index=True) -class NodeWithRelationship(StructuredNode): - ... +class NodeWithRelationship(StructuredNode): ... class IndexedRelationship(StructuredRel): @@ -177,6 +178,129 @@ class NodeWithUniqueIndexRelationship(StructuredNode): rel2 = node1.has_rel.connect(node3, {"name": "rel1"}) +@mark_sync_test +def test_fulltext_index(): + if not db.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + class FullTextIndexNode(StructuredNode): + name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + + db.install_labels(FullTextIndexNode) + indexes = db.list_indexes() + index_names = [index["name"] for index in indexes] + assert "fulltext_index_FullTextIndexNode_name" in index_names + + db.cypher_query("DROP INDEX fulltext_index_FullTextIndexNode_name") + + +@mark_sync_test +def test_fulltext_index_conflict(): + if not db.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + db.cypher_query( + "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" + ) + + class FullTextIndexNode(StructuredNode): + name = StringProperty(fulltext_index=True) + + db.install_labels(FullTextIndexNode) + + console_output = stream.getvalue() + assert "There already exists an index" in console_output + + +@mark_sync_test +def test_fulltext_index_not_supported(): + if db.version_is_higher_than("5.16"): + pytest.skip("Test only for versions lower than 5.16") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" + ): + + class FullTextIndexNode(StructuredNode): + name = StringProperty(fulltext_index=True) + + db.install_labels(FullTextIndexNode) + + +@mark_sync_test +def test_rel_fulltext_index(): + if not db.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + class FullTextIndexRel(StructuredRel): + name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + + class FullTextIndexRelNode(StructuredNode): + has_rel = RelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + db.install_labels(FullTextIndexRelNode) + indexes = db.list_indexes() + index_names = [index["name"] for index in indexes] + assert "fulltext_index_FULLTEXT_INDEX_REL_name" in index_names + + db.cypher_query("DROP INDEX fulltext_index_FULLTEXT_INDEX_REL_name") + + +@mark_sync_test +def test_rel_fulltext_index_conflict(): + if not db.version_is_higher_than("5.16"): + pytest.skip("Not supported before 5.16") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + db.cypher_query( + "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" + ) + + class FullTextIndexRel(StructuredRel): + name = StringProperty( + fulltext_index=True, fulltext_eventually_consistent=True + ) + + class FullTextIndexRelNode(StructuredNode): + has_rel = RelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + db.install_labels(FullTextIndexRelNode) + + console_output = stream.getvalue() + assert "There already exists an index" in console_output + + +@mark_sync_test +def test_rel_fulltext_index_not_supported(): + if db.version_is_higher_than("5.16"): + pytest.skip("Test only for versions lower than 5.16") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" + ): + + class FullTextIndexRel(StructuredRel): + name = StringProperty( + fulltext_index=True, fulltext_eventually_consistent=True + ) + + class FullTextIndexRelNode(StructuredNode): + has_rel = RelationshipTo( + FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + ) + + db.install_labels(FullTextIndexRelNode) + + def _drop_constraints_for_label_and_property(label: str = None, property: str = None): results, meta = db.cypher_query("SHOW CONSTRAINTS") results_as_dict = [dict(zip(meta, row)) for row in results] From 36108c0d19ffa58f166caab919c1adac05cd322f Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 16:55:52 +0200 Subject: [PATCH 15/24] Fix test --- test/async_/test_label_install.py | 8 +++++--- test/sync_/test_label_install.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 05bf1d99..3fb83a68 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -241,7 +241,7 @@ class FullTextIndexRel(AsyncStructuredRel): class FullTextIndexRelNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", "FULLTEXT_INDEX_REL", model=FullTextIndexRel ) await adb.install_labels(FullTextIndexRelNode) @@ -264,14 +264,16 @@ async def test_rel_fulltext_index_conflict(): "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" ) - class FullTextIndexRel(AsyncStructuredRel): + class FullTextIndexRelConflict(AsyncStructuredRel): name = StringProperty( fulltext_index=True, fulltext_eventually_consistent=True ) class FullTextIndexRelNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", + "FULLTEXT_INDEX_REL", + model=FullTextIndexRelConflict, ) await adb.install_labels(FullTextIndexRelNode) diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 82c3c260..57acef12 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -240,7 +240,7 @@ class FullTextIndexRel(StructuredRel): class FullTextIndexRelNode(StructuredNode): has_rel = RelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", "FULLTEXT_INDEX_REL", model=FullTextIndexRel ) db.install_labels(FullTextIndexRelNode) @@ -263,14 +263,16 @@ def test_rel_fulltext_index_conflict(): "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" ) - class FullTextIndexRel(StructuredRel): + class FullTextIndexRelConflict(StructuredRel): name = StringProperty( fulltext_index=True, fulltext_eventually_consistent=True ) class FullTextIndexRelNode(StructuredNode): has_rel = RelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", + "FULLTEXT_INDEX_REL", + model=FullTextIndexRelConflict, ) db.install_labels(FullTextIndexRelNode) From 307b7c31b9b32206fb607db040ce0bb49b95bf9e Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 17:00:02 +0200 Subject: [PATCH 16/24] Fix test --- test/async_/test_label_install.py | 8 +++++--- test/sync_/test_label_install.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 3fb83a68..0c66d569 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -272,7 +272,7 @@ class FullTextIndexRelConflict(AsyncStructuredRel): class FullTextIndexRelNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( "FullTextIndexRelNode", - "FULLTEXT_INDEX_REL", + "FULLTEXT_INDEX_REL_CONFLICT", model=FullTextIndexRelConflict, ) @@ -291,14 +291,16 @@ async def test_rel_fulltext_index_not_supported(): FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" ): - class FullTextIndexRel(AsyncStructuredRel): + class FullTextIndexRelOld(AsyncStructuredRel): name = StringProperty( fulltext_index=True, fulltext_eventually_consistent=True ) class FullTextIndexRelNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", + "FULLTEXT_INDEX_REL_OLD", + model=FullTextIndexRelOld, ) await adb.install_labels(FullTextIndexRelNode) diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 57acef12..cbf27ad3 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -271,7 +271,7 @@ class FullTextIndexRelConflict(StructuredRel): class FullTextIndexRelNode(StructuredNode): has_rel = RelationshipTo( "FullTextIndexRelNode", - "FULLTEXT_INDEX_REL", + "FULLTEXT_INDEX_REL_CONFLICT", model=FullTextIndexRelConflict, ) @@ -290,14 +290,16 @@ def test_rel_fulltext_index_not_supported(): FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" ): - class FullTextIndexRel(StructuredRel): + class FullTextIndexRelOld(StructuredRel): name = StringProperty( fulltext_index=True, fulltext_eventually_consistent=True ) class FullTextIndexRelNode(StructuredNode): has_rel = RelationshipTo( - FullTextIndexRel, "FULLTEXT_INDEX_REL", model=FullTextIndexRel + "FullTextIndexRelNode", + "FULLTEXT_INDEX_REL_OLD", + model=FullTextIndexRelOld, ) db.install_labels(FullTextIndexRelNode) From 20bd6aa9a898ecb2e888f132c4a5db05a19e51a9 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 17:07:00 +0200 Subject: [PATCH 17/24] Fix test --- test/async_/test_label_install.py | 12 ++++++------ test/sync_/test_label_install.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 0c66d569..99cda7df 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -269,14 +269,14 @@ class FullTextIndexRelConflict(AsyncStructuredRel): fulltext_index=True, fulltext_eventually_consistent=True ) - class FullTextIndexRelNode(AsyncStructuredNode): + class FullTextIndexRelConflictNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( - "FullTextIndexRelNode", + "FullTextIndexRelConflictNode", "FULLTEXT_INDEX_REL_CONFLICT", model=FullTextIndexRelConflict, ) - await adb.install_labels(FullTextIndexRelNode) + await adb.install_labels(FullTextIndexRelConflictNode) console_output = stream.getvalue() assert "There already exists an index" in console_output @@ -296,14 +296,14 @@ class FullTextIndexRelOld(AsyncStructuredRel): fulltext_index=True, fulltext_eventually_consistent=True ) - class FullTextIndexRelNode(AsyncStructuredNode): + class FullTextIndexRelOldNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( - "FullTextIndexRelNode", + "FullTextIndexRelOldNode", "FULLTEXT_INDEX_REL_OLD", model=FullTextIndexRelOld, ) - await adb.install_labels(FullTextIndexRelNode) + await adb.install_labels(FullTextIndexRelOldNode) async def _drop_constraints_for_label_and_property( diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index cbf27ad3..134cb0e5 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -268,14 +268,14 @@ class FullTextIndexRelConflict(StructuredRel): fulltext_index=True, fulltext_eventually_consistent=True ) - class FullTextIndexRelNode(StructuredNode): + class FullTextIndexRelConflictNode(StructuredNode): has_rel = RelationshipTo( - "FullTextIndexRelNode", + "FullTextIndexRelConflictNode", "FULLTEXT_INDEX_REL_CONFLICT", model=FullTextIndexRelConflict, ) - db.install_labels(FullTextIndexRelNode) + db.install_labels(FullTextIndexRelConflictNode) console_output = stream.getvalue() assert "There already exists an index" in console_output @@ -295,14 +295,14 @@ class FullTextIndexRelOld(StructuredRel): fulltext_index=True, fulltext_eventually_consistent=True ) - class FullTextIndexRelNode(StructuredNode): + class FullTextIndexRelOldNode(StructuredNode): has_rel = RelationshipTo( - "FullTextIndexRelNode", + "FullTextIndexRelOldNode", "FULLTEXT_INDEX_REL_OLD", model=FullTextIndexRelOld, ) - db.install_labels(FullTextIndexRelNode) + db.install_labels(FullTextIndexRelOldNode) def _drop_constraints_for_label_and_property(label: str = None, property: str = None): From edecda96bec7b4409848166c5d50b2289b5adf6a Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 17:15:24 +0200 Subject: [PATCH 18/24] Fix test --- test/async_/test_label_install.py | 8 ++++---- test/sync_/test_label_install.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 99cda7df..ad1c8db1 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -207,10 +207,10 @@ async def test_fulltext_index_conflict(): "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" ) - class FullTextIndexNode(AsyncStructuredNode): + class FullTextIndexNodeConflict(AsyncStructuredNode): name = StringProperty(fulltext_index=True) - await adb.install_labels(FullTextIndexNode) + await adb.install_labels(FullTextIndexNodeConflict) console_output = stream.getvalue() assert "There already exists an index" in console_output @@ -225,10 +225,10 @@ async def test_fulltext_index_not_supported(): FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" ): - class FullTextIndexNode(AsyncStructuredNode): + class FullTextIndexNodeOld(AsyncStructuredNode): name = StringProperty(fulltext_index=True) - await adb.install_labels(FullTextIndexNode) + await adb.install_labels(FullTextIndexNodeOld) @mark_async_test diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 134cb0e5..03808507 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -206,10 +206,10 @@ def test_fulltext_index_conflict(): "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" ) - class FullTextIndexNode(StructuredNode): + class FullTextIndexNodeConflict(StructuredNode): name = StringProperty(fulltext_index=True) - db.install_labels(FullTextIndexNode) + db.install_labels(FullTextIndexNodeConflict) console_output = stream.getvalue() assert "There already exists an index" in console_output @@ -224,10 +224,10 @@ def test_fulltext_index_not_supported(): FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.16 or higher" ): - class FullTextIndexNode(StructuredNode): + class FullTextIndexNodeOld(StructuredNode): name = StringProperty(fulltext_index=True) - db.install_labels(FullTextIndexNode) + db.install_labels(FullTextIndexNodeOld) @mark_sync_test From a43c5b22dc625dfa606ca07d67403dc0c015fef1 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Mon, 17 Jun 2024 17:28:47 +0200 Subject: [PATCH 19/24] Fix test --- test/async_/test_label_install.py | 4 ++-- test/sync_/test_label_install.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index ad1c8db1..a0c5029e 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -204,7 +204,7 @@ async def test_fulltext_index_conflict(): with patch("sys.stdout", new=stream): await adb.cypher_query( - "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" + "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNodeConflict) ON EACH [n.name]" ) class FullTextIndexNodeConflict(AsyncStructuredNode): @@ -261,7 +261,7 @@ async def test_rel_fulltext_index_conflict(): with patch("sys.stdout", new=stream): await adb.cypher_query( - "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" + "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL_CONFLICT]-() ON EACH [r.name]" ) class FullTextIndexRelConflict(AsyncStructuredRel): diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 03808507..1c60455e 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -203,7 +203,7 @@ def test_fulltext_index_conflict(): with patch("sys.stdout", new=stream): db.cypher_query( - "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNode) ON EACH [n.name]" + "CREATE FULLTEXT INDEX FOR (n:FullTextIndexNodeConflict) ON EACH [n.name]" ) class FullTextIndexNodeConflict(StructuredNode): @@ -260,7 +260,7 @@ def test_rel_fulltext_index_conflict(): with patch("sys.stdout", new=stream): db.cypher_query( - "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL]-() ON EACH [r.name]" + "CREATE FULLTEXT INDEX FOR ()-[r:FULLTEXT_INDEX_REL_CONFLICT]-() ON EACH [r.name]" ) class FullTextIndexRelConflict(StructuredRel): From 7ad5e4964dab8c270ac72056393ff0a618b2fe50 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Tue, 18 Jun 2024 09:10:41 +0200 Subject: [PATCH 20/24] Increase test coverage --- test/async_/test_label_install.py | 6 ++++-- test/sync_/test_label_install.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index a0c5029e..7355a429 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -210,9 +210,10 @@ async def test_fulltext_index_conflict(): class FullTextIndexNodeConflict(AsyncStructuredNode): name = StringProperty(fulltext_index=True) - await adb.install_labels(FullTextIndexNodeConflict) + await adb.install_labels(FullTextIndexNodeConflict, quiet=False) console_output = stream.getvalue() + assert "Creating fulltext index" in console_output assert "There already exists an index" in console_output @@ -276,9 +277,10 @@ class FullTextIndexRelConflictNode(AsyncStructuredNode): model=FullTextIndexRelConflict, ) - await adb.install_labels(FullTextIndexRelConflictNode) + await adb.install_labels(FullTextIndexRelConflictNode, quiet=False) console_output = stream.getvalue() + assert "Creating fulltext index" in console_output assert "There already exists an index" in console_output diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 1c60455e..5fe05b13 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -209,9 +209,10 @@ def test_fulltext_index_conflict(): class FullTextIndexNodeConflict(StructuredNode): name = StringProperty(fulltext_index=True) - db.install_labels(FullTextIndexNodeConflict) + db.install_labels(FullTextIndexNodeConflict, quiet=False) console_output = stream.getvalue() + assert "Creating fulltext index" in console_output assert "There already exists an index" in console_output @@ -275,9 +276,10 @@ class FullTextIndexRelConflictNode(StructuredNode): model=FullTextIndexRelConflict, ) - db.install_labels(FullTextIndexRelConflictNode) + db.install_labels(FullTextIndexRelConflictNode, quiet=False) console_output = stream.getvalue() + assert "Creating fulltext index" in console_output assert "There already exists an index" in console_output From a01b0ad6b8713df033dfb64ba1c66df386b2026d Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Tue, 18 Jun 2024 15:28:55 +0200 Subject: [PATCH 21/24] Support for Vector indexes #756 --- doc/source/schema_management.rst | 18 +- neomodel/__init__.py | 2 + neomodel/async_/core.py | 265 +++++++++++++++++++------- neomodel/properties.py | 48 ++++- neomodel/sync_/core.py | 263 +++++++++++++++++++------- test/async_/test_label_install.py | 298 +++++++++++++++++++++++++++++- test/sync_/test_label_install.py | 296 ++++++++++++++++++++++++++++- 7 files changed, 1024 insertions(+), 166 deletions(-) diff --git a/doc/source/schema_management.rst b/doc/source/schema_management.rst index dbb0b308..61ad94a3 100644 --- a/doc/source/schema_management.rst +++ b/doc/source/schema_management.rst @@ -46,13 +46,15 @@ Indexes The following indexes are supported: - ``index=True``: This will create the default Neo4j index on the property (currently RANGE). -- ``fulltext_index=True``: This will create a FULLTEXT index on the property. Only available for Neo4j version 5.16 or higher. With this one, you can define the following options: - - ``fulltext_analyzer``: The analyzer to use. The default is ``standard-no-stop-words``. - - ``fulltext_eventually_consistent``: Whether the index should be eventually consistent. The default is ``False``. +- ``fulltext_index=FulltextIndex()``: This will create a FULLTEXT index on the property. Only available for Neo4j version 5.16 or higher. With this one, you can define the following options: + - ``analyzer``: The analyzer to use. The default is ``standard-no-stop-words``. + - ``eventually_consistent``: Whether the index should be eventually consistent. The default is ``False``. Please refer to the `Neo4j documentation `_. for more information on fulltext indexes. -- Vector indexes (Work in progress) +- ``vector_index=VectorIndex()``: This will create a VECTOR index on the property. Only available for Neo4j version 5.15 (node) and 5.18 (relationship) or higher. With this one, you can define the following options: + - ``dimensions``: The dimension of the vector. The default is 1536. + - ``similarity_function``: The similarity algorithm to use. The default is ``cosine``. Those indexes are available for both node- and relationship properties. @@ -65,12 +67,12 @@ Those indexes are available for both node- and relationship properties. Full example: :: + from neomodel import StructuredNode, StringProperty, FulltextIndex, VectorIndex class VeryIndexedNode(StructuredNode): name = StringProperty( - index=True, - fulltext_index=True, - fulltext_analyzer='english', - fulltext_eventually_consistent=True + index=True, + fulltext_index=FulltextIndex(analyzer='english', eventually_consistent=True) + vector_index=VectorIndex(dimensions=512, similarity_function='euclidean') ) Constraints diff --git a/neomodel/__init__.py b/neomodel/__init__.py index b7e1520a..ef524bdc 100644 --- a/neomodel/__init__.py +++ b/neomodel/__init__.py @@ -29,12 +29,14 @@ DateTimeProperty, EmailProperty, FloatProperty, + FulltextIndex, IntegerProperty, JSONProperty, NormalizedProperty, RegexProperty, StringProperty, UniqueIdProperty, + VectorIndex, ) from neomodel.sync_.cardinality import One, OneOrMore, ZeroOrMore, ZeroOrOne from neomodel.sync_.core import ( diff --git a/neomodel/async_/core.py b/neomodel/async_/core.py index 3f675cda..f3223ee8 100644 --- a/neomodel/async_/core.py +++ b/neomodel/async_/core.py @@ -38,7 +38,7 @@ UniqueProperty, ) from neomodel.hooks import hooks -from neomodel.properties import Property +from neomodel.properties import FulltextIndex, Property, VectorIndex from neomodel.util import ( _UnsavedNode, classproperty, @@ -724,10 +724,18 @@ async def install_labels(self, cls, quiet=True, stdout=None): ).items(): await self._install_relationship(cls, relationship, quiet, stdout) - async def _create_node_index(self, label: str, property_name: str, stdout): + async def _create_node_index( + self, target_cls, property_name: str, stdout, quiet: bool + ): + label = target_cls.__label__ + index_name = f"index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating node index for {property_name} on label {label} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) try: await self.cypher_query( - f"CREATE INDEX index_{label}_{property_name} FOR (n:{label}) ON (n.{property_name}); " + f"CREATE INDEX {index_name} FOR (n:{label}) ON (n.{property_name}); " ) except ClientError as e: if e.code in ( @@ -740,26 +748,28 @@ async def _create_node_index(self, label: str, property_name: str, stdout): async def _create_node_fulltext_index( self, - label: str, + target_cls, property_name: str, stdout, - analyzer: str = None, - eventually_consistent: bool = False, + fulltext_index: FulltextIndex, + quiet: bool, ): if await self.version_is_higher_than("5.16"): - query = f"CREATE FULLTEXT INDEX fulltext_index_{label}_{property_name} FOR (n:{label}) ON EACH [n.{property_name}]" - if analyzer or eventually_consistent: - if analyzer is None: - analyzer = "standard-no-stop-words" - query += f""" - OPTIONS {{ - indexConfig: {{ - `fulltext.analyzer`: '{analyzer}', - `fulltext.eventually_consistent`: {eventually_consistent} - }} + label = target_cls.__label__ + index_name = f"fulltext_index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating fulltext index for {property_name} on label {target_cls.__label__} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) + query = f""" + CREATE FULLTEXT INDEX {index_name} FOR (n:{label}) ON EACH [n.{property_name}] + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{fulltext_index.analyzer}', + `fulltext.eventually_consistent`: {fulltext_index.eventually_consistent} }} - """ - query += ";" + }}; + """ try: await self.cypher_query(query) except ClientError as e: @@ -775,10 +785,57 @@ async def _create_node_fulltext_index( f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.16 or higher." ) - async def _create_node_constraint(self, label: str, property_name: str, stdout): + async def _create_node_vector_index( + self, + target_cls, + property_name: str, + stdout, + vector_index: VectorIndex, + quiet: bool, + ): + if await self.version_is_higher_than("5.15"): + label = target_cls.__label__ + index_name = f"vector_index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating vector index for {property_name} on label {label} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) + query = f""" + CREATE VECTOR INDEX {index_name} FOR (n:{label}) ON n.{property_name} + OPTIONS {{ + indexConfig: {{ + `vector.dimensions`: {vector_index.dimensions}, + `vector.similarity_function`: '{vector_index.similarity_function}' + }} + }}; + """ + try: + await self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of vector indexes from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.15 or higher." + ) + + async def _create_node_constraint( + self, target_cls, property_name: str, stdout, quiet: bool + ): + label = target_cls.__label__ + constraint_name = f"constraint_unique_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating node unique constraint for {property_name} on label {target_cls.__label__} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) try: await self.cypher_query( - f"""CREATE CONSTRAINT constraint_unique_{label}_{property_name} + f"""CREATE CONSTRAINT {constraint_name} FOR (n:{label}) REQUIRE n.{property_name} IS UNIQUE""" ) except ClientError as e: @@ -791,11 +848,22 @@ async def _create_node_constraint(self, label: str, property_name: str, stdout): raise async def _create_relationship_index( - self, relationship_type: str, property_name: str, stdout + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + quiet: bool, ): + index_name = f"index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating relationship index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) try: await self.cypher_query( - f"CREATE INDEX index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON (r.{property_name}); " + f"CREATE INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON (r.{property_name}); " ) except ClientError as e: if e.code in ( @@ -809,25 +877,28 @@ async def _create_relationship_index( async def _create_relationship_fulltext_index( self, relationship_type: str, + target_cls, + relationship_cls, property_name: str, stdout, - analyzer: str = None, - eventually_consistent: bool = False, + fulltext_index: FulltextIndex, + quiet: bool, ): if await self.version_is_higher_than("5.16"): - query = f"CREATE FULLTEXT INDEX fulltext_index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}]" - if analyzer or eventually_consistent: - if analyzer is None: - analyzer = "standard-no-stop-words" - query += f""" - OPTIONS {{ - indexConfig: {{ - `fulltext.analyzer`: '{analyzer}', - `fulltext.eventually_consistent`: {eventually_consistent} - }} + index_name = f"fulltext_index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating fulltext index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) + query = f""" + CREATE FULLTEXT INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}] + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{fulltext_index.analyzer}', + `fulltext.eventually_consistent`: {fulltext_index.eventually_consistent} }} - """ - query += ";" + }}; + """ try: await self.cypher_query(query) except ClientError as e: @@ -843,13 +914,64 @@ async def _create_relationship_fulltext_index( f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.16 or higher." ) + async def _create_relationship_vector_index( + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + vector_index: VectorIndex, + quiet: bool, + ): + if await self.version_is_higher_than("5.18"): + index_name = f"vector_index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating vector index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) + query = f""" + CREATE VECTOR INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON r.{property_name} + OPTIONS {{ + indexConfig: {{ + `vector.dimensions`: {vector_index.dimensions}, + `vector.similarity_function`: '{vector_index.similarity_function}' + }} + }}; + """ + try: + await self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of vector indexes for relationships from neomodel is not supported for Neo4j in version {await self.database_version}. Please upgrade to Neo4j 5.18 or higher." + ) + async def _create_relationship_constraint( - self, relationship_type: str, property_name: str, stdout + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + quiet: bool, ): if await self.version_is_higher_than("5.7"): + constraint_name = f"constraint_unique_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating relationship unique constraint for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) try: await self.cypher_query( - f"""CREATE CONSTRAINT constraint_unique_{relationship_type}_{property_name} + f"""CREATE CONSTRAINT {constraint_name} FOR ()-[r:{relationship_type}]-() REQUIRE r.{property_name} IS UNIQUE""" ) except ClientError as e: @@ -869,34 +991,30 @@ async def _install_node(self, cls, name, property, quiet, stdout): # Create indexes and constraints for node property db_property = property.get_db_property_name(name) if property.index: - if not quiet: - stdout.write( - f" + Creating node index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) await self._create_node_index( - label=cls.__label__, property_name=db_property, stdout=stdout + target_cls=cls, property_name=db_property, stdout=stdout, quiet=quiet ) - elif property.unique_index: - if not quiet: - stdout.write( - f" + Creating node unique constraint for {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) await self._create_node_constraint( - label=cls.__label__, property_name=db_property, stdout=stdout + target_cls=cls, property_name=db_property, stdout=stdout, quiet=quiet ) if property.fulltext_index: - if not quiet: - stdout.write( - f" + Creating fulltext index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) await self._create_node_fulltext_index( - label=cls.__label__, + target_cls=cls, + property_name=db_property, + stdout=stdout, + fulltext_index=property.fulltext_index, + quiet=quiet, + ) + + if property.vector_index: + await self._create_node_vector_index( + target_cls=cls, property_name=db_property, stdout=stdout, - analyzer=property.fulltext_analyzer, - eventually_consistent=property.fulltext_eventually_consistent, + vector_index=property.vector_index, + quiet=quiet, ) async def _install_relationship(self, cls, relationship, quiet, stdout): @@ -909,37 +1027,44 @@ async def _install_relationship(self, cls, relationship, quiet, stdout): ).items(): db_property = property.get_db_property_name(prop_name) if property.index: - if not quiet: - stdout.write( - f" + Creating relationship index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" - ) await self._create_relationship_index( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, + quiet=quiet, ) 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" - ) await self._create_relationship_constraint( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, + quiet=quiet, ) if property.fulltext_index: - if not quiet: - stdout.write( - f" + Creating fulltext index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" - ) await self._create_relationship_fulltext_index( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, + property_name=db_property, + stdout=stdout, + fulltext_index=property.fulltext_index, + quiet=quiet, + ) + + if property.vector_index: + await self._create_relationship_vector_index( + relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, - analyzer=property.fulltext_analyzer, - eventually_consistent=property.fulltext_eventually_consistent, + vector_index=property.vector_index, + quiet=quiet, ) diff --git a/neomodel/properties.py b/neomodel/properties.py index 24f7a112..d4a91885 100644 --- a/neomodel/properties.py +++ b/neomodel/properties.py @@ -37,6 +37,42 @@ def _validator(self, value, obj=None, rethrow=True): return _validator +class FulltextIndex(object): + """ + Fulltext index definition + """ + + def __init__( + self, + analyzer="standard-no-stop-words", + eventually_consistent=False, + ): + """ + Initializes new fulltext index definition with analyzer and eventually consistent + + :param str analyzer: The analyzer to use. Defaults to "standard-no-stop-words". + :param bool eventually_consistent: Whether the index should be eventually consistent. Defaults to False. + """ + self.analyzer = analyzer + self.eventually_consistent = eventually_consistent + + +class VectorIndex(object): + """ + Vector index definition + """ + + def __init__(self, dimensions=1536, similarity_function="cosine"): + """ + Initializes new vector index definition with dimensions and similarity + + :param int dimensions: The number of dimensions of the vector. Defaults to 1536. + :param str similarity_function: The similarity algorithm to use. Defaults to "cosine". + """ + self.dimensions = dimensions + self.similarity_function = similarity_function + + class Property: """ Base class for object properties. @@ -46,6 +82,10 @@ class Property: :type unique_index: :class:`bool` :param index: Creates an index for this property. Defaults to ``False``. :type index: :class:`bool` + :param fulltext_index: Creates a fulltext index for this property. Defaults to ``None``. + :type fulltext_index: :class:`FulltextIndex` + :param vector_index: Creates a vector index for this property. Defaults to ``None``. + :type vector_index: :class:`VectorIndex` :param required: Marks the property as required. Defaults to ``False``. :type required: :class:`bool` :param default: A default value or callable that returns one to set when a @@ -66,10 +106,8 @@ def __init__( self, unique_index=False, index=False, - fulltext_index=False, - vector_index=False, - fulltext_analyzer=None, - fulltext_eventually_consistent=False, + fulltext_index: FulltextIndex = None, + vector_index: VectorIndex = None, required=False, default=None, db_property=None, @@ -90,8 +128,6 @@ def __init__( self.unique_index = unique_index self.index = index self.fulltext_index = fulltext_index - self.fulltext_analyzer = fulltext_analyzer - self.fulltext_eventually_consistent = fulltext_eventually_consistent self.vector_index = vector_index self.default = default self.has_default = self.default is not None diff --git a/neomodel/sync_/core.py b/neomodel/sync_/core.py index 84778322..22b833a6 100644 --- a/neomodel/sync_/core.py +++ b/neomodel/sync_/core.py @@ -37,7 +37,7 @@ UniqueProperty, ) from neomodel.hooks import hooks -from neomodel.properties import Property +from neomodel.properties import FulltextIndex, Property, VectorIndex from neomodel.sync_.property_manager import PropertyManager from neomodel.util import ( _UnsavedNode, @@ -722,10 +722,16 @@ def install_labels(self, cls, quiet=True, stdout=None): ).items(): self._install_relationship(cls, relationship, quiet, stdout) - def _create_node_index(self, label: str, property_name: str, stdout): + def _create_node_index(self, target_cls, property_name: str, stdout, quiet: bool): + label = target_cls.__label__ + index_name = f"index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating node index for {property_name} on label {label} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) try: self.cypher_query( - f"CREATE INDEX index_{label}_{property_name} FOR (n:{label}) ON (n.{property_name}); " + f"CREATE INDEX {index_name} FOR (n:{label}) ON (n.{property_name}); " ) except ClientError as e: if e.code in ( @@ -738,26 +744,28 @@ def _create_node_index(self, label: str, property_name: str, stdout): def _create_node_fulltext_index( self, - label: str, + target_cls, property_name: str, stdout, - analyzer: str = None, - eventually_consistent: bool = False, + fulltext_index: FulltextIndex, + quiet: bool, ): if self.version_is_higher_than("5.16"): - query = f"CREATE FULLTEXT INDEX fulltext_index_{label}_{property_name} FOR (n:{label}) ON EACH [n.{property_name}]" - if analyzer or eventually_consistent: - if analyzer is None: - analyzer = "standard-no-stop-words" - query += f""" - OPTIONS {{ - indexConfig: {{ - `fulltext.analyzer`: '{analyzer}', - `fulltext.eventually_consistent`: {eventually_consistent} - }} + label = target_cls.__label__ + index_name = f"fulltext_index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating fulltext index for {property_name} on label {target_cls.__label__} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) + query = f""" + CREATE FULLTEXT INDEX {index_name} FOR (n:{label}) ON EACH [n.{property_name}] + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{fulltext_index.analyzer}', + `fulltext.eventually_consistent`: {fulltext_index.eventually_consistent} }} - """ - query += ";" + }}; + """ try: self.cypher_query(query) except ClientError as e: @@ -773,10 +781,57 @@ def _create_node_fulltext_index( f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.16 or higher." ) - def _create_node_constraint(self, label: str, property_name: str, stdout): + def _create_node_vector_index( + self, + target_cls, + property_name: str, + stdout, + vector_index: VectorIndex, + quiet: bool, + ): + if self.version_is_higher_than("5.15"): + label = target_cls.__label__ + index_name = f"vector_index_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating vector index for {property_name} on label {label} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) + query = f""" + CREATE VECTOR INDEX {index_name} FOR (n:{label}) ON n.{property_name} + OPTIONS {{ + indexConfig: {{ + `vector.dimensions`: {vector_index.dimensions}, + `vector.similarity_function`: '{vector_index.similarity_function}' + }} + }}; + """ + try: + self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of vector indexes from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.15 or higher." + ) + + def _create_node_constraint( + self, target_cls, property_name: str, stdout, quiet: bool + ): + label = target_cls.__label__ + constraint_name = f"constraint_unique_{label}_{property_name}" + if not quiet: + stdout.write( + f" + Creating node unique constraint for {property_name} on label {target_cls.__label__} for class {target_cls.__module__}.{target_cls.__name__}\n" + ) try: self.cypher_query( - f"""CREATE CONSTRAINT constraint_unique_{label}_{property_name} + f"""CREATE CONSTRAINT {constraint_name} FOR (n:{label}) REQUIRE n.{property_name} IS UNIQUE""" ) except ClientError as e: @@ -789,11 +844,22 @@ def _create_node_constraint(self, label: str, property_name: str, stdout): raise def _create_relationship_index( - self, relationship_type: str, property_name: str, stdout + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + quiet: bool, ): + index_name = f"index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating relationship index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) try: self.cypher_query( - f"CREATE INDEX index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON (r.{property_name}); " + f"CREATE INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON (r.{property_name}); " ) except ClientError as e: if e.code in ( @@ -807,25 +873,28 @@ def _create_relationship_index( def _create_relationship_fulltext_index( self, relationship_type: str, + target_cls, + relationship_cls, property_name: str, stdout, - analyzer: str = None, - eventually_consistent: bool = False, + fulltext_index: FulltextIndex, + quiet: bool, ): if self.version_is_higher_than("5.16"): - query = f"CREATE FULLTEXT INDEX fulltext_index_{relationship_type}_{property_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}]" - if analyzer or eventually_consistent: - if analyzer is None: - analyzer = "standard-no-stop-words" - query += f""" - OPTIONS {{ - indexConfig: {{ - `fulltext.analyzer`: '{analyzer}', - `fulltext.eventually_consistent`: {eventually_consistent} - }} + index_name = f"fulltext_index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating fulltext index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) + query = f""" + CREATE FULLTEXT INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON EACH [r.{property_name}] + OPTIONS {{ + indexConfig: {{ + `fulltext.analyzer`: '{fulltext_index.analyzer}', + `fulltext.eventually_consistent`: {fulltext_index.eventually_consistent} }} - """ - query += ";" + }}; + """ try: self.cypher_query(query) except ClientError as e: @@ -841,13 +910,64 @@ def _create_relationship_fulltext_index( f"Creation of full-text indexes from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.16 or higher." ) + def _create_relationship_vector_index( + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + vector_index: VectorIndex, + quiet: bool, + ): + if self.version_is_higher_than("5.18"): + index_name = f"vector_index_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating vector index for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) + query = f""" + CREATE VECTOR INDEX {index_name} FOR ()-[r:{relationship_type}]-() ON r.{property_name} + OPTIONS {{ + indexConfig: {{ + `vector.dimensions`: {vector_index.dimensions}, + `vector.similarity_function`: '{vector_index.similarity_function}' + }} + }}; + """ + try: + self.cypher_query(query) + except ClientError as e: + if e.code in ( + RULE_ALREADY_EXISTS, + INDEX_ALREADY_EXISTS, + ): + stdout.write(f"{str(e)}\n") + else: + raise + else: + raise FeatureNotSupported( + f"Creation of vector indexes for relationships from neomodel is not supported for Neo4j in version {self.database_version}. Please upgrade to Neo4j 5.18 or higher." + ) + def _create_relationship_constraint( - self, relationship_type: str, property_name: str, stdout + self, + relationship_type: str, + target_cls, + relationship_cls, + property_name: str, + stdout, + quiet: bool, ): if self.version_is_higher_than("5.7"): + constraint_name = f"constraint_unique_{relationship_type}_{property_name}" + if not quiet: + stdout.write( + f" + Creating relationship unique constraint for {property_name} on relationship type {relationship_type} for relationship model {target_cls.__module__}.{relationship_cls.__name__}\n" + ) try: self.cypher_query( - f"""CREATE CONSTRAINT constraint_unique_{relationship_type}_{property_name} + f"""CREATE CONSTRAINT {constraint_name} FOR ()-[r:{relationship_type}]-() REQUIRE r.{property_name} IS UNIQUE""" ) except ClientError as e: @@ -867,34 +987,30 @@ def _install_node(self, cls, name, property, quiet, stdout): # Create indexes and constraints for node property db_property = property.get_db_property_name(name) if property.index: - if not quiet: - stdout.write( - f" + Creating node index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) self._create_node_index( - label=cls.__label__, property_name=db_property, stdout=stdout + target_cls=cls, property_name=db_property, stdout=stdout, quiet=quiet ) - elif property.unique_index: - if not quiet: - stdout.write( - f" + Creating node unique constraint for {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) self._create_node_constraint( - label=cls.__label__, property_name=db_property, stdout=stdout + target_cls=cls, property_name=db_property, stdout=stdout, quiet=quiet ) if property.fulltext_index: - if not quiet: - stdout.write( - f" + Creating fulltext index {name} on label {cls.__label__} for class {cls.__module__}.{cls.__name__}\n" - ) self._create_node_fulltext_index( - label=cls.__label__, + target_cls=cls, + property_name=db_property, + stdout=stdout, + fulltext_index=property.fulltext_index, + quiet=quiet, + ) + + if property.vector_index: + self._create_node_vector_index( + target_cls=cls, property_name=db_property, stdout=stdout, - analyzer=property.fulltext_analyzer, - eventually_consistent=property.fulltext_eventually_consistent, + vector_index=property.vector_index, + quiet=quiet, ) def _install_relationship(self, cls, relationship, quiet, stdout): @@ -907,37 +1023,44 @@ def _install_relationship(self, cls, relationship, quiet, stdout): ).items(): db_property = property.get_db_property_name(prop_name) if property.index: - if not quiet: - stdout.write( - f" + Creating relationship index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" - ) self._create_relationship_index( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, + quiet=quiet, ) 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" - ) self._create_relationship_constraint( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, + quiet=quiet, ) if property.fulltext_index: - if not quiet: - stdout.write( - f" + Creating fulltext index {prop_name} on relationship type {relationship_type} for relationship model {cls.__module__}.{relationship_cls.__name__}\n" - ) self._create_relationship_fulltext_index( relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, + property_name=db_property, + stdout=stdout, + fulltext_index=property.fulltext_index, + quiet=quiet, + ) + + if property.vector_index: + self._create_relationship_vector_index( + relationship_type=relationship_type, + target_cls=cls, + relationship_cls=relationship_cls, property_name=db_property, stdout=stdout, - analyzer=property.fulltext_analyzer, - eventually_consistent=property.fulltext_eventually_consistent, + vector_index=property.vector_index, + quiet=quiet, ) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index 7355a429..a23345eb 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -3,13 +3,16 @@ from unittest.mock import patch import pytest +from neo4j.exceptions import ClientError from neomodel import ( AsyncRelationshipTo, AsyncStructuredNode, AsyncStructuredRel, + FulltextIndex, StringProperty, UniqueIdProperty, + VectorIndex, adb, ) from neomodel.exceptions import ConstraintValidationFailed, FeatureNotSupported @@ -185,7 +188,7 @@ async def test_fulltext_index(): pytest.skip("Not supported before 5.16") class FullTextIndexNode(AsyncStructuredNode): - name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + name = StringProperty(fulltext_index=FulltextIndex(eventually_consistent=True)) await adb.install_labels(FullTextIndexNode) indexes = await adb.list_indexes() @@ -208,7 +211,7 @@ async def test_fulltext_index_conflict(): ) class FullTextIndexNodeConflict(AsyncStructuredNode): - name = StringProperty(fulltext_index=True) + name = StringProperty(fulltext_index=FulltextIndex()) await adb.install_labels(FullTextIndexNodeConflict, quiet=False) @@ -227,7 +230,7 @@ async def test_fulltext_index_not_supported(): ): class FullTextIndexNodeOld(AsyncStructuredNode): - name = StringProperty(fulltext_index=True) + name = StringProperty(fulltext_index=FulltextIndex()) await adb.install_labels(FullTextIndexNodeOld) @@ -238,7 +241,7 @@ async def test_rel_fulltext_index(): pytest.skip("Not supported before 5.16") class FullTextIndexRel(AsyncStructuredRel): - name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + name = StringProperty(fulltext_index=FulltextIndex(eventually_consistent=True)) class FullTextIndexRelNode(AsyncStructuredNode): has_rel = AsyncRelationshipTo( @@ -267,7 +270,7 @@ async def test_rel_fulltext_index_conflict(): class FullTextIndexRelConflict(AsyncStructuredRel): name = StringProperty( - fulltext_index=True, fulltext_eventually_consistent=True + fulltext_index=FulltextIndex(eventually_consistent=True) ) class FullTextIndexRelConflictNode(AsyncStructuredNode): @@ -295,7 +298,7 @@ async def test_rel_fulltext_index_not_supported(): class FullTextIndexRelOld(AsyncStructuredRel): name = StringProperty( - fulltext_index=True, fulltext_eventually_consistent=True + fulltext_index=FulltextIndex(eventually_consistent=True) ) class FullTextIndexRelOldNode(AsyncStructuredNode): @@ -308,6 +311,289 @@ class FullTextIndexRelOldNode(AsyncStructuredNode): await adb.install_labels(FullTextIndexRelOldNode) +@mark_async_test +async def test_vector_index(): + if not await adb.version_is_higher_than("5.15"): + pytest.skip("Not supported before 5.15") + + class VectorIndexNode(AsyncStructuredNode): + name = StringProperty( + vector_index=VectorIndex(dimensions=256, similarity_function="euclidean") + ) + + await adb.install_labels(VectorIndexNode) + indexes = await adb.list_indexes() + index_names = [index["name"] for index in indexes] + assert "vector_index_VectorIndexNode_name" in index_names + + await adb.cypher_query("DROP INDEX vector_index_VectorIndexNode_name") + + +@mark_async_test +async def test_vector_index_conflict(): + if not await adb.version_is_higher_than("5.15"): + pytest.skip("Not supported before 5.15") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + await adb.cypher_query( + "CREATE VECTOR INDEX FOR (n:VectorIndexNodeConflict) ON n.name OPTIONS{indexConfig:{`vector.similarity_function`:'cosine', `vector.dimensions`:1536}}" + ) + + class VectorIndexNodeConflict(AsyncStructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + await adb.install_labels(VectorIndexNodeConflict, quiet=False) + + console_output = stream.getvalue() + assert "Creating vector index" in console_output + assert "There already exists an index" in console_output + + +@mark_async_test +async def test_vector_index_not_supported(): + if await adb.version_is_higher_than("5.15"): + pytest.skip("Test only for versions lower than 5.15") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.15 or higher" + ): + + class VectorIndexNodeOld(AsyncStructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + await adb.install_labels(VectorIndexNodeOld) + + +@mark_async_test +async def test_rel_vector_index(): + if not await adb.version_is_higher_than("5.18"): + pytest.skip("Not supported before 5.18") + + class VectorIndexRel(AsyncStructuredRel): + name = StringProperty( + vector_index=VectorIndex(dimensions=256, similarity_function="euclidean") + ) + + class VectorIndexRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "VectorIndexRelNode", "VECTOR_INDEX_REL", model=VectorIndexRel + ) + + await adb.install_labels(VectorIndexRelNode) + indexes = await adb.list_indexes() + index_names = [index["name"] for index in indexes] + assert "vector_index_VECTOR_INDEX_REL_name" in index_names + + await adb.cypher_query("DROP INDEX vector_index_VECTOR_INDEX_REL_name") + + +@mark_async_test +async def test_rel_vector_index_conflict(): + if not await adb.version_is_higher_than("5.18"): + pytest.skip("Not supported before 5.18") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + await adb.cypher_query( + "CREATE VECTOR INDEX FOR ()-[r:VECTOR_INDEX_REL_CONFLICT]-() ON r.name OPTIONS{indexConfig:{`vector.similarity_function`:'cosine', `vector.dimensions`:1536}}" + ) + + class VectorIndexRelConflict(AsyncStructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class VectorIndexRelConflictNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "VectorIndexRelConflictNode", + "VECTOR_INDEX_REL_CONFLICT", + model=VectorIndexRelConflict, + ) + + await adb.install_labels(VectorIndexRelConflictNode, quiet=False) + + console_output = stream.getvalue() + assert "Creating vector index" in console_output + assert "There already exists an index" in console_output + + +@mark_async_test +async def test_rel_vector_index_not_supported(): + if await adb.version_is_higher_than("5.18"): + pytest.skip("Test only for versions lower than 5.18") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.18 or higher" + ): + + class VectorIndexRelOld(AsyncStructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class VectorIndexRelOldNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "VectorIndexRelOldNode", + "VECTOR_INDEX_REL_OLD", + model=VectorIndexRelOld, + ) + + await adb.install_labels(VectorIndexRelOldNode) + + +@mark_async_test +async def test_unauthorized_index_creation(): + if not await adb.edition_is_enterprise(): + pytest.skip("Skipping test for community edition") + + unauthorized_user = "troygreene" + expected_message_index = ( + r".*Schema operation 'create_index' on database.*is not allowed for user.*" + ) + expected_message_constraint = ( + r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" + ) + + # Standard node index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedIndexNode(AsyncStructuredNode): + name = StringProperty(index=True) + + await adb.install_labels(UnauthorizedIndexNode) + + # Node uniqueness constraint + with pytest.raises( + ClientError, + match=expected_message_constraint, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedUniqueIndexNode(AsyncStructuredNode): + name = StringProperty(unique_index=True) + + await adb.install_labels(UnauthorizedUniqueIndexNode) + + # Relationship index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedRelIndex(AsyncStructuredRel): + name = StringProperty(index=True) + + class UnauthorizedRelIndexNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "UnauthorizedRelIndexNode", + "UNAUTHORIZED_REL_INDEX", + model=UnauthorizedRelIndex, + ) + + await adb.install_labels(UnauthorizedRelIndexNode) + + +@mark_async_test +async def test_unauthorized_index_creation_recent_features(): + if not await adb.edition_is_enterprise() or not await adb.version_is_higher_than( + "5.18" + ): + pytest.skip("Skipping test for community edition and versions lower than 5.18") + + unauthorized_user = "troygreene" + expected_message_index = ( + r".*Schema operation 'create_index' on database.*is not allowed for user.*" + ) + expected_message_constraint = ( + r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" + ) + + # Node fulltext index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedFulltextNode(AsyncStructuredNode): + name = StringProperty(fulltext_index=FulltextIndex()) + + await adb.install_labels(UnauthorizedFulltextNode) + + # Node vector index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedVectorNode(AsyncStructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + await adb.install_labels(UnauthorizedVectorNode) + + # Relationship uniqueness constraint + with pytest.raises( + ClientError, + match=expected_message_constraint, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedUniqueRel(AsyncStructuredRel): + name = StringProperty(unique_index=True) + + class UnauthorizedUniqueRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "UnauthorizedUniqueRelNode", + "UNAUTHORIZED_UNIQUE_REL", + model=UnauthorizedUniqueRel, + ) + + await adb.install_labels(UnauthorizedUniqueRelNode) + + # Relationship fulltext index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedFulltextRel(AsyncStructuredRel): + name = StringProperty(fulltext_index=FulltextIndex()) + + class UnauthorizedFulltextRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "UnauthorizedFulltextRelNode", + "UNAUTHORIZED_FULLTEXT_REL", + model=UnauthorizedFulltextRel, + ) + + await adb.install_labels(UnauthorizedFulltextRelNode) + + # Relationship vector index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with await adb.impersonate(unauthorized_user): + + class UnauthorizedVectorRel(AsyncStructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class UnauthorizedVectorRelNode(AsyncStructuredNode): + has_rel = AsyncRelationshipTo( + "UnauthorizedVectorRelNode", + "UNAUTHORIZED_VECTOR_REL", + model=UnauthorizedVectorRel, + ) + + await adb.install_labels(UnauthorizedVectorRelNode) + + async def _drop_constraints_for_label_and_property( label: str = None, property: str = None ): diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 5fe05b13..81f73cf3 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -3,13 +3,16 @@ from unittest.mock import patch import pytest +from neo4j.exceptions import ClientError from neomodel import ( + FulltextIndex, RelationshipTo, StringProperty, StructuredNode, StructuredRel, UniqueIdProperty, + VectorIndex, db, ) from neomodel.exceptions import ConstraintValidationFailed, FeatureNotSupported @@ -184,7 +187,7 @@ def test_fulltext_index(): pytest.skip("Not supported before 5.16") class FullTextIndexNode(StructuredNode): - name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + name = StringProperty(fulltext_index=FulltextIndex(eventually_consistent=True)) db.install_labels(FullTextIndexNode) indexes = db.list_indexes() @@ -207,7 +210,7 @@ def test_fulltext_index_conflict(): ) class FullTextIndexNodeConflict(StructuredNode): - name = StringProperty(fulltext_index=True) + name = StringProperty(fulltext_index=FulltextIndex()) db.install_labels(FullTextIndexNodeConflict, quiet=False) @@ -226,7 +229,7 @@ def test_fulltext_index_not_supported(): ): class FullTextIndexNodeOld(StructuredNode): - name = StringProperty(fulltext_index=True) + name = StringProperty(fulltext_index=FulltextIndex()) db.install_labels(FullTextIndexNodeOld) @@ -237,7 +240,7 @@ def test_rel_fulltext_index(): pytest.skip("Not supported before 5.16") class FullTextIndexRel(StructuredRel): - name = StringProperty(fulltext_index=True, fulltext_eventually_consistent=True) + name = StringProperty(fulltext_index=FulltextIndex(eventually_consistent=True)) class FullTextIndexRelNode(StructuredNode): has_rel = RelationshipTo( @@ -266,7 +269,7 @@ def test_rel_fulltext_index_conflict(): class FullTextIndexRelConflict(StructuredRel): name = StringProperty( - fulltext_index=True, fulltext_eventually_consistent=True + fulltext_index=FulltextIndex(eventually_consistent=True) ) class FullTextIndexRelConflictNode(StructuredNode): @@ -294,7 +297,7 @@ def test_rel_fulltext_index_not_supported(): class FullTextIndexRelOld(StructuredRel): name = StringProperty( - fulltext_index=True, fulltext_eventually_consistent=True + fulltext_index=FulltextIndex(eventually_consistent=True) ) class FullTextIndexRelOldNode(StructuredNode): @@ -307,6 +310,287 @@ class FullTextIndexRelOldNode(StructuredNode): db.install_labels(FullTextIndexRelOldNode) +@mark_sync_test +def test_vector_index(): + if not db.version_is_higher_than("5.15"): + pytest.skip("Not supported before 5.15") + + class VectorIndexNode(StructuredNode): + name = StringProperty( + vector_index=VectorIndex(dimensions=256, similarity_function="euclidean") + ) + + db.install_labels(VectorIndexNode) + indexes = db.list_indexes() + index_names = [index["name"] for index in indexes] + assert "vector_index_VectorIndexNode_name" in index_names + + db.cypher_query("DROP INDEX vector_index_VectorIndexNode_name") + + +@mark_sync_test +def test_vector_index_conflict(): + if not db.version_is_higher_than("5.15"): + pytest.skip("Not supported before 5.15") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + db.cypher_query( + "CREATE VECTOR INDEX FOR (n:VectorIndexNodeConflict) ON n.name OPTIONS{indexConfig:{`vector.similarity_function`:'cosine', `vector.dimensions`:1536}}" + ) + + class VectorIndexNodeConflict(StructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + db.install_labels(VectorIndexNodeConflict, quiet=False) + + console_output = stream.getvalue() + assert "Creating vector index" in console_output + assert "There already exists an index" in console_output + + +@mark_sync_test +def test_vector_index_not_supported(): + if db.version_is_higher_than("5.15"): + pytest.skip("Test only for versions lower than 5.15") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.15 or higher" + ): + + class VectorIndexNodeOld(StructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + db.install_labels(VectorIndexNodeOld) + + +@mark_sync_test +def test_rel_vector_index(): + if not db.version_is_higher_than("5.18"): + pytest.skip("Not supported before 5.18") + + class VectorIndexRel(StructuredRel): + name = StringProperty( + vector_index=VectorIndex(dimensions=256, similarity_function="euclidean") + ) + + class VectorIndexRelNode(StructuredNode): + has_rel = RelationshipTo( + "VectorIndexRelNode", "VECTOR_INDEX_REL", model=VectorIndexRel + ) + + db.install_labels(VectorIndexRelNode) + indexes = db.list_indexes() + index_names = [index["name"] for index in indexes] + assert "vector_index_VECTOR_INDEX_REL_name" in index_names + + db.cypher_query("DROP INDEX vector_index_VECTOR_INDEX_REL_name") + + +@mark_sync_test +def test_rel_vector_index_conflict(): + if not db.version_is_higher_than("5.18"): + pytest.skip("Not supported before 5.18") + + stream = io.StringIO() + + with patch("sys.stdout", new=stream): + db.cypher_query( + "CREATE VECTOR INDEX FOR ()-[r:VECTOR_INDEX_REL_CONFLICT]-() ON r.name OPTIONS{indexConfig:{`vector.similarity_function`:'cosine', `vector.dimensions`:1536}}" + ) + + class VectorIndexRelConflict(StructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class VectorIndexRelConflictNode(StructuredNode): + has_rel = RelationshipTo( + "VectorIndexRelConflictNode", + "VECTOR_INDEX_REL_CONFLICT", + model=VectorIndexRelConflict, + ) + + db.install_labels(VectorIndexRelConflictNode, quiet=False) + + console_output = stream.getvalue() + assert "Creating vector index" in console_output + assert "There already exists an index" in console_output + + +@mark_sync_test +def test_rel_vector_index_not_supported(): + if db.version_is_higher_than("5.18"): + pytest.skip("Test only for versions lower than 5.18") + + with pytest.raises( + FeatureNotSupported, match=r".*Please upgrade to Neo4j 5.18 or higher" + ): + + class VectorIndexRelOld(StructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class VectorIndexRelOldNode(StructuredNode): + has_rel = RelationshipTo( + "VectorIndexRelOldNode", + "VECTOR_INDEX_REL_OLD", + model=VectorIndexRelOld, + ) + + db.install_labels(VectorIndexRelOldNode) + + +@mark_sync_test +def test_unauthorized_index_creation(): + if not db.edition_is_enterprise(): + pytest.skip("Skipping test for community edition") + + unauthorized_user = "troygreene" + expected_message_index = ( + r".*Schema operation 'create_index' on database.*is not allowed for user.*" + ) + expected_message_constraint = ( + r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" + ) + + # Standard node index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedIndexNode(StructuredNode): + name = StringProperty(index=True) + + db.install_labels(UnauthorizedIndexNode) + + # Node uniqueness constraint + with pytest.raises( + ClientError, + match=expected_message_constraint, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedUniqueIndexNode(StructuredNode): + name = StringProperty(unique_index=True) + + db.install_labels(UnauthorizedUniqueIndexNode) + + # Relationship index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedRelIndex(StructuredRel): + name = StringProperty(index=True) + + class UnauthorizedRelIndexNode(StructuredNode): + has_rel = RelationshipTo( + "UnauthorizedRelIndexNode", + "UNAUTHORIZED_REL_INDEX", + model=UnauthorizedRelIndex, + ) + + db.install_labels(UnauthorizedRelIndexNode) + + +@mark_sync_test +def test_unauthorized_index_creation_recent_features(): + if not db.edition_is_enterprise() or not db.version_is_higher_than("5.18"): + pytest.skip("Skipping test for community edition and versions lower than 5.18") + + unauthorized_user = "troygreene" + expected_message_index = ( + r".*Schema operation 'create_index' on database.*is not allowed for user.*" + ) + expected_message_constraint = ( + r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" + ) + + # Node fulltext index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedFulltextNode(StructuredNode): + name = StringProperty(fulltext_index=FulltextIndex()) + + db.install_labels(UnauthorizedFulltextNode) + + # Node vector index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedVectorNode(StructuredNode): + name = StringProperty(vector_index=VectorIndex()) + + db.install_labels(UnauthorizedVectorNode) + + # Relationship uniqueness constraint + with pytest.raises( + ClientError, + match=expected_message_constraint, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedUniqueRel(StructuredRel): + name = StringProperty(unique_index=True) + + class UnauthorizedUniqueRelNode(StructuredNode): + has_rel = RelationshipTo( + "UnauthorizedUniqueRelNode", + "UNAUTHORIZED_UNIQUE_REL", + model=UnauthorizedUniqueRel, + ) + + db.install_labels(UnauthorizedUniqueRelNode) + + # Relationship fulltext index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedFulltextRel(StructuredRel): + name = StringProperty(fulltext_index=FulltextIndex()) + + class UnauthorizedFulltextRelNode(StructuredNode): + has_rel = RelationshipTo( + "UnauthorizedFulltextRelNode", + "UNAUTHORIZED_FULLTEXT_REL", + model=UnauthorizedFulltextRel, + ) + + db.install_labels(UnauthorizedFulltextRelNode) + + # Relationship vector index + with pytest.raises( + ClientError, + match=expected_message_index, + ): + with db.impersonate(unauthorized_user): + + class UnauthorizedVectorRel(StructuredRel): + name = StringProperty(vector_index=VectorIndex()) + + class UnauthorizedVectorRelNode(StructuredNode): + has_rel = RelationshipTo( + "UnauthorizedVectorRelNode", + "UNAUTHORIZED_VECTOR_REL", + model=UnauthorizedVectorRel, + ) + + db.install_labels(UnauthorizedVectorRelNode) + + def _drop_constraints_for_label_and_property(label: str = None, property: str = None): results, meta = db.cypher_query("SHOW CONSTRAINTS") results_as_dict = [dict(zip(meta, row)) for row in results] From 5d36d6563407fe5b18b36a6a2090c0bb6e23540b Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Tue, 18 Jun 2024 15:35:22 +0200 Subject: [PATCH 22/24] Fix test --- test/async_/test_label_install.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/test/async_/test_label_install.py b/test/async_/test_label_install.py index a23345eb..832578f5 100644 --- a/test/async_/test_label_install.py +++ b/test/async_/test_label_install.py @@ -446,12 +446,7 @@ async def test_unauthorized_index_creation(): pytest.skip("Skipping test for community edition") unauthorized_user = "troygreene" - expected_message_index = ( - r".*Schema operation 'create_index' on database.*is not allowed for user.*" - ) - expected_message_constraint = ( - r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" - ) + expected_message_index = r".*Schema operation.* not allowed for user.*" # Standard node index with pytest.raises( @@ -468,7 +463,7 @@ class UnauthorizedIndexNode(AsyncStructuredNode): # Node uniqueness constraint with pytest.raises( ClientError, - match=expected_message_constraint, + match=expected_message_index, ): with await adb.impersonate(unauthorized_user): @@ -505,12 +500,7 @@ async def test_unauthorized_index_creation_recent_features(): pytest.skip("Skipping test for community edition and versions lower than 5.18") unauthorized_user = "troygreene" - expected_message_index = ( - r".*Schema operation 'create_index' on database.*is not allowed for user.*" - ) - expected_message_constraint = ( - r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" - ) + expected_message_index = r".*Schema operation.* not allowed for user.*" # Node fulltext index with pytest.raises( @@ -539,7 +529,7 @@ class UnauthorizedVectorNode(AsyncStructuredNode): # Relationship uniqueness constraint with pytest.raises( ClientError, - match=expected_message_constraint, + match=expected_message_index, ): with await adb.impersonate(unauthorized_user): From 7e001ca2c291649ccaf162452681626f2e52d13c Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Tue, 18 Jun 2024 15:36:15 +0200 Subject: [PATCH 23/24] Fix test --- test/sync_/test_label_install.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/test/sync_/test_label_install.py b/test/sync_/test_label_install.py index 81f73cf3..8b3c5b3a 100644 --- a/test/sync_/test_label_install.py +++ b/test/sync_/test_label_install.py @@ -26,7 +26,8 @@ class NodeWithConstraint(StructuredNode): name = StringProperty(unique_index=True) -class NodeWithRelationship(StructuredNode): ... +class NodeWithRelationship(StructuredNode): + ... class IndexedRelationship(StructuredRel): @@ -445,12 +446,7 @@ def test_unauthorized_index_creation(): pytest.skip("Skipping test for community edition") unauthorized_user = "troygreene" - expected_message_index = ( - r".*Schema operation 'create_index' on database.*is not allowed for user.*" - ) - expected_message_constraint = ( - r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" - ) + expected_message_index = r".*Schema operation.* not allowed for user.*" # Standard node index with pytest.raises( @@ -467,7 +463,7 @@ class UnauthorizedIndexNode(StructuredNode): # Node uniqueness constraint with pytest.raises( ClientError, - match=expected_message_constraint, + match=expected_message_index, ): with db.impersonate(unauthorized_user): @@ -502,12 +498,7 @@ def test_unauthorized_index_creation_recent_features(): pytest.skip("Skipping test for community edition and versions lower than 5.18") unauthorized_user = "troygreene" - expected_message_index = ( - r".*Schema operation 'create_index' on database.*is not allowed for user.*" - ) - expected_message_constraint = ( - r".*Schema operation 'create_constraint' on database.*is not allowed for user.*" - ) + expected_message_index = r".*Schema operation.* not allowed for user.*" # Node fulltext index with pytest.raises( @@ -536,7 +527,7 @@ class UnauthorizedVectorNode(StructuredNode): # Relationship uniqueness constraint with pytest.raises( ClientError, - match=expected_message_constraint, + match=expected_message_index, ): with db.impersonate(unauthorized_user): From a92e4911803c7fc74822647d4f0d3fdb93c6e2f9 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Tue, 2 Jul 2024 09:06:20 +0200 Subject: [PATCH 24/24] Update changelog --- Changelog | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog b/Changelog index 3749566d..fe655005 100644 --- a/Changelog +++ b/Changelog @@ -1,4 +1,5 @@ Version 5.3.2 2024-06 +* Add support for Vector and Fulltext indexes creation * Add DateTimeNeo4jFormatProperty for Neo4j native datetime format Version 5.3.1 2024-05