Skip to content

Commit 74304f3

Browse files
Merge branch 'rc/6.1.0' into housekeeping/more-type-hinting
2 parents eae86c3 + 6cde765 commit 74304f3

File tree

12 files changed

+617
-162
lines changed

12 files changed

+617
-162
lines changed

Changelog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Version 6.1.0 2026-01
2+
* Add new exists operator in filter()
3+
* Add allow_reload to config to allow node redefinition in hot reload environments for development purposes
4+
15
Version 6.0.1 2025-12
26
* Make async iterator fully async, like : async for node in MyNodeClass.nodes
37
* Add thresholding to semantic indexes. Thanks to @greengori11a

doc/source/extending.rst

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,52 @@ labels, the `__optional_labels__` property must be defined as a list of strings:
5151

5252
.. note:: The size of the node class mapping grows exponentially with optional labels. Use with some caution.
5353

54+
.. _allowing_class_reloading:
55+
56+
Allowing Class Reloading
57+
-------------------------
58+
By default, neomodel prevents class redefinition to ensure the integrity of the node-class registry.
59+
However, in development environments with hot-reloading (like Streamlit or Django's development server),
60+
this behavior can be problematic as classes get redefined on every code change.
61+
62+
To support hot-reload workflows, you can enable the ``allow_reload`` global config parameter:
63+
64+
.. code-block:: python
65+
66+
from neomodel import config
67+
config.ALLOW_RELOAD = True
68+
69+
Or using the modern config API:
70+
71+
.. code-block:: python
72+
73+
from neomodel.config import get_config
74+
get_config().allow_reload = True
75+
76+
When ``allow_reload`` is enabled:
77+
78+
* Class redefinitions will issue a ``UserWarning`` instead of raising ``NodeClassAlreadyDefined``
79+
* The class registry will be updated with the new definition
80+
* This works for both standard classes and database-specific classes (with ``__target_databases__``)
81+
82+
.. warning:: Only enable ``allow_reload`` in development environments. In production, the default behavior of raising ``NodeClassAlreadyDefined`` helps catch unintentional class redefinitions that could lead to subtle bugs.
83+
84+
Example with Streamlit:
85+
86+
.. code-block:: python
87+
88+
import streamlit as st
89+
from neomodel import StructuredNode, StringProperty, config
90+
91+
config.DATABASE_URL = 'bolt://neo4j:neo4j@localhost:7687'
92+
config.ALLOW_RELOAD = True # Allows Streamlit page reloads
93+
94+
class User(StructuredNode):
95+
name = StringProperty(unique_index=True)
96+
email = StringProperty()
97+
98+
st.write("User model loaded successfully")
99+
54100
55101
Mixins
56102
------
@@ -173,7 +219,9 @@ This automatic class resolution however, requires a bit of caution:
173219

174220
2. Since the only way to resolve objects at runtime is this mapping of a set of labels to a class, then
175221
this mapping **must** be guaranteed to be unique. Therefore, if for any reason a class gets **redefined**, then
176-
exception ``neomodel.exceptions.ClassAlreadyDefined`` will be raised.
222+
exception ``neomodel.exceptions.NodeClassAlreadyDefined`` will be raised.
223+
* This behavior can be overridden in development environments by setting ``__allow_reload__ = True`` on the class,
224+
which will issue a warning instead of raising an exception. See the section on :ref:`allowing_class_reloading` for more details.
177225
* Given the above class hierarchy, suppose that an attempt was made to redefine one of the existing classes in
178226
the local scope of some function ::
179227

@@ -199,10 +247,10 @@ This automatic class resolution however, requires a bit of caution:
199247
``{"BasePerson", "PilotPerson"}`` to ``PilotPerson`` **in the global scope** with a mapping of the same
200248
set of labels but towards the class defined within the **local scope** of ``some_function``.
201249

202-
3. Two classes with different names but the same __label__ override will also result in a ``ClassAlreadyDefined`` exception.
203-
This can be avoided under certain circumstances, as explained in the next section on 'Database specific labels'.
250+
3. Two classes with different names but the same __label__ override will also result in a ``NodeClassAlreadyDefined`` exception.
251+
This can be avoided under certain circumstances, as explained in the next section on 'Database specific labels'.
204252

205-
Both ``ModelDefinitionMismatch`` and ``ClassAlreadyDefined`` produce an error message that returns the labels of the
253+
Both ``ModelDefinitionMismatch`` and ``NodeClassAlreadyDefined`` produce an error message that returns the labels of the
206254
node that created the problem (either the `Node` returned from the database or the class that was attempted to be
207255
redefined) as well as the state of the current *node-class registry*. These two pieces of information can be used to
208256
debug the model mismatch further.
@@ -231,7 +279,7 @@ based on the database it was fetched from ::
231279
db.set_connection("bolt://neo4j:password@localhost:7687/db_one")
232280
patients = db.cypher_query("MATCH (n:Patient) RETURN n", resolve_objects=True) --> instance of PatientOne
233281

234-
The following will result in a ``ClassAlreadyDefined`` exception, because when retrieving from ``db_one``,
282+
The following will result in a ``NodeClassAlreadyDefined`` exception, because when retrieving from ``db_one``,
235283
neomodel would not be able to decide which model to parse into ::
236284
class GeneralPatient(AsyncStructuredNode):
237285
__label__ = "Patient"

neomodel/_version.py

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

neomodel/async_/match.py

Lines changed: 122 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from neomodel.properties import AliasProperty, ArrayProperty, Property
1717
from neomodel.semantic_filters import FulltextFilter, VectorFilter
1818
from neomodel.typing import Subquery, Transformation
19-
from neomodel.util import RelationshipDirection
19+
from neomodel.util import RelationshipDirection, deprecated
2020

2121
CYPHER_ACTIONS_WITH_SIDE_EFFECT_EXPR = re.compile(r"(?i:MERGE|CREATE|DELETE|DETACH)")
2222

@@ -162,6 +162,7 @@ def _rel_merge_helper(
162162
_SPECIAL_OPERATOR_ISNULL = "IS NULL"
163163
_SPECIAL_OPERATOR_ISNOTNULL = "IS NOT NULL"
164164
_SPECIAL_OPERATOR_REGEX = "=~"
165+
_SPECIAL_OPERATOR_EXISTS = "EXISTS"
165166

166167
_UNARY_OPERATORS = (_SPECIAL_OPERATOR_ISNULL, _SPECIAL_OPERATOR_ISNOTNULL)
167168

@@ -198,6 +199,7 @@ def _rel_merge_helper(
198199
"isnull": _SPECIAL_OPERATOR_ISNULL,
199200
"regex": _SPECIAL_OPERATOR_REGEX,
200201
"exact": "=",
202+
"exists": "EXISTS",
201203
}
202204
# add all regex operators
203205
OPERATOR_TABLE.update(_REGEX_OPERATOR_TABLE)
@@ -243,6 +245,13 @@ def _handle_special_operators(
243245
raise ValueError(f"Value must be a bool for isnull operation on {key}")
244246
operator = "IS NULL" if value else "IS NOT NULL"
245247
deflated_value = None
248+
elif operator == _SPECIAL_OPERATOR_EXISTS:
249+
if not isinstance(value, bool):
250+
raise ValueError(f"Value must be a bool for exists operation on {key}")
251+
operator = (
252+
f"NOT {_SPECIAL_OPERATOR_EXISTS}" if not value else _SPECIAL_OPERATOR_EXISTS
253+
)
254+
deflated_value = value
246255
elif operator in _REGEX_OPERATOR_TABLE.values():
247256
deflated_value = property_obj.deflate(value)
248257
if not isinstance(deflated_value, str):
@@ -309,6 +318,7 @@ def _process_filter_key(
309318
prop,
310319
) = _initialize_filter_args_variables(cls, key)
311320

321+
hop_name = None
312322
for part in re.split(path_split_regex, key):
313323
defined_props = current_class.defined_properties(rels=True)
314324
# update defined props dictionary with relationship properties if
@@ -322,6 +332,7 @@ def _process_filter_key(
322332
defined_props[part].lookup_node_class()
323333
current_class = defined_props[part].definition["node_class"]
324334
current_rel_model = defined_props[part].definition["model"]
335+
hop_name = part
325336
elif part in OPERATOR_TABLE:
326337
operator = OPERATOR_TABLE[part]
327338
prop, _ = prop.rsplit("__", 1)
@@ -334,7 +345,11 @@ def _process_filter_key(
334345

335346
if leaf_prop is None:
336347
raise ValueError(f"Badly formed filter, no property found in {key}")
337-
if is_rel_property and current_rel_model:
348+
349+
if hop_name == leaf_prop:
350+
# Path ended on a hop, not a property
351+
property_obj = None
352+
elif is_rel_property and current_rel_model:
338353
property_obj = getattr(current_rel_model, leaf_prop)
339354
else:
340355
property_obj = getattr(current_class, leaf_prop)
@@ -391,6 +406,71 @@ def process_has_args(
391406
return match, dont_match
392407

393408

409+
def generate_traversal_from_path(
410+
relation: "Path",
411+
source_class: Any,
412+
create_ids: bool = False,
413+
node_id_generator=None,
414+
rel_id_generator=None,
415+
namespace: str | None = None,
416+
):
417+
"""
418+
Generator function to construct a cypher traversal from the given path.
419+
"""
420+
path: str = relation.value
421+
stmt: str = ""
422+
source_class_iterator = source_class
423+
parts = re.split(path_split_regex, path)
424+
rel_iterator: str = ""
425+
for index, part in enumerate(parts):
426+
relationship = getattr(source_class_iterator, part)
427+
if rel_iterator:
428+
rel_iterator += "__"
429+
rel_iterator += part
430+
# build source
431+
if "node_class" not in relationship.definition:
432+
relationship.lookup_node_class()
433+
lhs_name = None
434+
if not stmt:
435+
lhs_label = source_class_iterator.__label__
436+
lhs_name = lhs_label.lower()
437+
if create_ids and not namespace:
438+
lhs_ident = f"{lhs_name}:{lhs_label}"
439+
else:
440+
lhs_ident = lhs_name
441+
else:
442+
lhs_ident = stmt
443+
444+
rel_ident = None
445+
rhs_name = None
446+
rhs_label = relationship.definition["node_class"].__label__
447+
if create_ids:
448+
rel_ident = rel_id_generator()
449+
if relation.relation_filtering:
450+
rhs_name = rel_ident
451+
rhs_ident = f":{rhs_label}"
452+
else:
453+
if index + 1 == len(parts) and relation.alias:
454+
# If an alias is defined, use it to store the last hop in the path
455+
rhs_name = relation.alias
456+
else:
457+
rhs_name = f"{rhs_label.lower()}_{rel_iterator}"
458+
rhs_name = node_id_generator(rhs_name, rel_iterator)
459+
rhs_ident = f"{rhs_name}:{rhs_label}"
460+
else:
461+
rhs_ident = f":{rhs_label}"
462+
463+
stmt = _rel_helper(
464+
lhs=lhs_ident,
465+
rhs=rhs_ident,
466+
ident=rel_ident,
467+
direction=relationship.definition["direction"],
468+
relation_type=relationship.definition["relation_type"],
469+
)
470+
yield stmt, lhs_name, rhs_name, rel_ident, part, source_class_iterator
471+
source_class_iterator = relationship.definition["node_class"]
472+
473+
394474
class QueryAST:
395475
match: list[str]
396476
optional_match: list[str]
@@ -659,54 +739,27 @@ def _additional_return(self, name: str) -> None:
659739
def build_traversal_from_path(
660740
self, relation: "Path", source_class: Any
661741
) -> tuple[str, Any]:
662-
path: str = relation.value
663-
stmt: str = ""
664-
source_class_iterator = source_class
665-
parts = re.split(path_split_regex, path)
666742
subgraph = self._ast.subgraph
667-
rel_iterator: str = ""
668-
already_present = False
669-
existing_rhs_name = ""
670-
for index, part in enumerate(parts):
743+
generator = generate_traversal_from_path(
744+
relation,
745+
source_class,
746+
True,
747+
self.create_node_identifier,
748+
self.create_relation_identifier,
749+
self._subquery_namespace,
750+
)
751+
for index, items in enumerate(generator):
752+
stmt, lhs_name, rhs_name, rel_ident, part, source_class_iterator = items
671753
relationship = getattr(source_class_iterator, part)
672-
if rel_iterator:
673-
rel_iterator += "__"
674-
rel_iterator += part
675-
# build source
676-
if "node_class" not in relationship.definition:
677-
relationship.lookup_node_class()
678-
if not stmt:
679-
lhs_label = source_class_iterator.__label__
680-
lhs_name = lhs_label.lower()
681-
lhs_ident = f"{lhs_name}:{lhs_label}"
682-
if not index:
683-
# This is the first one, we make sure that 'return'
684-
# contains the primary node so _contains() works
685-
# as usual
686-
self._ast.return_clause = lhs_name
687-
if self._subquery_namespace:
688-
# Don't include label in identifier if we are in a subquery
689-
lhs_ident = lhs_name
690-
elif relation.include_nodes_in_return:
691-
self._additional_return(lhs_name)
692-
else:
693-
lhs_ident = stmt
694754

695-
already_present = part in subgraph
696-
rel_ident = self.create_relation_identifier()
697-
rhs_label = relationship.definition["node_class"].__label__
698-
if relation.relation_filtering:
699-
rhs_name = rel_ident
700-
rhs_ident = f":{rhs_label}"
701-
else:
702-
if index + 1 == len(parts) and relation.alias:
703-
# If an alias is defined, use it to store the last hop in the path
704-
rhs_name = relation.alias
705-
else:
706-
rhs_name = f"{rhs_label.lower()}_{rel_iterator}"
707-
rhs_name = self.create_node_identifier(rhs_name, rel_iterator)
708-
rhs_ident = f"{rhs_name}:{rhs_label}"
755+
if not index:
756+
# This is the first one, we make sure that 'return'
757+
# contains the primary node so _contains() works
758+
# as usual
759+
self._ast.return_clause = lhs_name
760+
self._additional_return(lhs_name)
709761

762+
already_present = part in subgraph
710763
if relation.include_nodes_in_return and not already_present:
711764
self._additional_return(rhs_name)
712765

@@ -727,14 +780,6 @@ def build_traversal_from_path(
727780
]
728781
if relation.include_rels_in_return and not already_present:
729782
self._additional_return(rel_ident)
730-
stmt = _rel_helper(
731-
lhs=lhs_ident,
732-
rhs=rhs_ident,
733-
ident=rel_ident,
734-
direction=relationship.definition["direction"],
735-
relation_type=relationship.definition["relation_type"],
736-
)
737-
source_class_iterator = relationship.definition["node_class"]
738783
subgraph = subgraph[part]["children"]
739784

740785
if not already_present:
@@ -842,6 +887,11 @@ def _finalize_filter_statement(
842887
if operator in _UNARY_OPERATORS:
843888
# unary operators do not have a parameter
844889
statement = f"{ident}.{prop} {operator}"
890+
elif _SPECIAL_OPERATOR_EXISTS in operator:
891+
statement = list(
892+
generate_traversal_from_path(Path(prop), self.node_set.source)
893+
)[-1][0]
894+
statement = f"{'NOT ' if not val else ''}EXISTS {{ {statement} }}"
845895
else:
846896
place_holder = self._register_place_holder(ident + "_" + prop)
847897
if operator == _SPECIAL_OPERATOR_ARRAY_IN:
@@ -864,21 +914,22 @@ def _build_filter_statements(
864914
source_class: type[AsyncStructuredNode],
865915
) -> None:
866916
for prop, op_and_val in filters.items():
867-
is_rel_filter = "|" in prop
868-
target_class = source_class
869-
is_optional_relation = False
870-
if "__" in prop or is_rel_filter:
871-
(
872-
ident,
873-
prop,
874-
target_class,
875-
is_optional_relation,
876-
) = self._parse_path(source_class, prop)
877917
operator, val = op_and_val
878-
if not is_rel_filter:
879-
prop = target_class.defined_properties(rels=False)[
880-
prop
881-
].get_db_property_name(prop)
918+
is_optional_relation = False
919+
if _SPECIAL_OPERATOR_EXISTS not in operator:
920+
is_rel_filter = "|" in prop
921+
target_class = source_class
922+
if "__" in prop or is_rel_filter:
923+
(
924+
ident,
925+
prop,
926+
target_class,
927+
is_optional_relation,
928+
) = self._parse_path(source_class, prop)
929+
if not is_rel_filter:
930+
prop = target_class.defined_properties(rels=False)[
931+
prop
932+
].get_db_property_name(prop)
882933
statement = self._finalize_filter_statement(operator, ident, prop, val)
883934
target.append((statement, is_optional_relation))
884935

@@ -1715,6 +1766,9 @@ def exclude(self, *args: Any, **kwargs: Any) -> Self:
17151766
self.q_filters = Q(self.q_filters & ~Q(*args, **kwargs))
17161767
return self
17171768

1769+
@deprecated(
1770+
"This method is deprecated and set to be removed in a future release. Please use .filter(has_rel__exists=True) instead."
1771+
)
17181772
def has(self, **kwargs: Any) -> Self:
17191773
must_match, dont_match = process_has_args(self.source_class, kwargs)
17201774
self.must_match.update(must_match)

0 commit comments

Comments
 (0)