1616from neomodel .properties import AliasProperty , ArrayProperty , Property
1717from neomodel .semantic_filters import FulltextFilter , VectorFilter
1818from neomodel .typing import Subquery , Transformation
19- from neomodel .util import RelationshipDirection
19+ from neomodel .util import RelationshipDirection , deprecated
2020
2121CYPHER_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
203205OPERATOR_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+
394474class 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