Skip to content

Commit 0d38b1a

Browse files
Merge pull request #862 from neo4j-contrib/rc/5.4.4
Rc/5.4.4
2 parents fd1c691 + 38febda commit 0d38b1a

File tree

9 files changed

+210
-79
lines changed

9 files changed

+210
-79
lines changed

Changelog

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
Version 5.4.4 2025-03
2+
* Fix filter() causing performance problems
3+
14
Version 5.4.3 2025-02
25
* Add Size() scalar function
36
* Fix potential duplicate variable name in multiple traversals

doc/source/configuration.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Adjust driver configuration - these options are only available for this connecti
3232
config.MAX_TRANSACTION_RETRY_TIME = 30.0 # default
3333
config.RESOLVER = None # default
3434
config.TRUST = neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES # default
35-
config.USER_AGENT = neomodel/v5.4.3 # default
35+
config.USER_AGENT = neomodel/v5.4.4 # default
3636

3737
Setting the database name, if different from the default one::
3838

neomodel/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "5.4.3"
1+
__version__ = "5.4.4"

neomodel/async_/match.py

+91-35
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,14 @@ def _rel_merge_helper(
164164

165165
_UNARY_OPERATORS = (_SPECIAL_OPERATOR_ISNULL, _SPECIAL_OPERATOR_ISNOTNULL)
166166

167-
_REGEX_INSESITIVE = _SPECIAL_OPERATOR_INSENSITIVE + "{}"
167+
_REGEX_INSENSITIVE = _SPECIAL_OPERATOR_INSENSITIVE + "{}"
168168
_REGEX_CONTAINS = ".*{}.*"
169169
_REGEX_STARTSWITH = "{}.*"
170170
_REGEX_ENDSWITH = ".*{}"
171171

172172
# regex operations that require escaping
173173
_STRING_REGEX_OPERATOR_TABLE = {
174-
"iexact": _REGEX_INSESITIVE,
174+
"iexact": _REGEX_INSENSITIVE,
175175
"contains": _REGEX_CONTAINS,
176176
"icontains": _SPECIAL_OPERATOR_INSENSITIVE + _REGEX_CONTAINS,
177177
"startswith": _REGEX_STARTSWITH,
@@ -181,7 +181,7 @@ def _rel_merge_helper(
181181
}
182182
# regex operations that do not require escaping
183183
_REGEX_OPERATOR_TABLE = {
184-
"iregex": _REGEX_INSESITIVE,
184+
"iregex": _REGEX_INSENSITIVE,
185185
}
186186
# list all regex operations, these will require formatting of the value
187187
_REGEX_OPERATOR_TABLE.update(_STRING_REGEX_OPERATOR_TABLE)
@@ -409,6 +409,7 @@ def __init__(
409409
match: TOptional[list[str]] = None,
410410
optional_match: TOptional[list[str]] = None,
411411
where: TOptional[list[str]] = None,
412+
optional_where: TOptional[list[str]] = None,
412413
with_clause: TOptional[str] = None,
413414
return_clause: TOptional[str] = None,
414415
order_by: TOptional[list[str]] = None,
@@ -422,6 +423,7 @@ def __init__(
422423
self.match = match if match else []
423424
self.optional_match = optional_match if optional_match else []
424425
self.where = where if where else []
426+
self.optional_where = optional_where if optional_where else []
425427
self.with_clause = with_clause
426428
self.return_clause = return_clause
427429
self.order_by = order_by
@@ -482,6 +484,8 @@ async def build_source(
482484
if hasattr(source, "order_by_elements"):
483485
self.build_order_by(ident, source)
484486

487+
# source.filters seems to be used only by Traversal objects
488+
# source.q_filters is used by NodeSet objects
485489
if source.filters or source.q_filters:
486490
self.build_where_stmt(
487491
ident=ident,
@@ -676,11 +680,19 @@ def build_label(self, ident: str, cls: type[AsyncStructuredNode]) -> str:
676680
"""
677681
ident_w_label = ident + ":" + cls.__label__
678682

679-
if not self._ast.return_clause and (
680-
not self._ast.additional_return or ident not in self._ast.additional_return
681-
):
683+
if not self._ast.return_clause:
684+
if (
685+
not self._ast.additional_return
686+
or ident not in self._ast.additional_return
687+
):
688+
self._ast.match.append(f"({ident_w_label})")
689+
self._ast.return_clause = ident
690+
self._ast.result_class = cls
691+
elif not self._ast.match:
692+
# If we get here, it means return_clause was filled because of an
693+
# optional match, so we add a regular match for root node.
694+
# Not very elegant, this part would deserve a refactoring...
682695
self._ast.match.append(f"({ident_w_label})")
683-
self._ast.return_clause = ident
684696
self._ast.result_class = cls
685697
return ident
686698

@@ -693,16 +705,16 @@ def build_additional_match(self, ident: str, node_set: "AsyncNodeSet") -> None:
693705
for _, value in node_set.must_match.items():
694706
if isinstance(value, dict):
695707
label = ":" + value["node_class"].__label__
696-
stmt = _rel_helper(lhs=source_ident, rhs=label, ident="", **value)
708+
stmt = f"EXISTS ({_rel_helper(lhs=source_ident, rhs=label, ident='', **value)})"
697709
self._ast.where.append(stmt)
698710
else:
699711
raise ValueError("Expecting dict got: " + repr(value))
700712

701713
for _, val in node_set.dont_match.items():
702714
if isinstance(val, dict):
703715
label = ":" + val["node_class"].__label__
704-
stmt = _rel_helper(lhs=source_ident, rhs=label, ident="", **val)
705-
self._ast.where.append("NOT " + stmt)
716+
stmt = f"NOT EXISTS ({_rel_helper(lhs=source_ident, rhs=label, ident='', **val)})"
717+
self._ast.where.append(stmt)
706718
else:
707719
raise ValueError("Expecting dict got: " + repr(val))
708720

@@ -718,13 +730,14 @@ def _register_place_holder(self, key: str) -> str:
718730

719731
def _parse_path(
720732
self, source_class: type[AsyncStructuredNode], prop: str
721-
) -> Tuple[str, str, str, Any]:
733+
) -> Tuple[str, str, str, Any, bool]:
722734
is_rel_filter = "|" in prop
723735
if is_rel_filter:
724736
path, prop = prop.rsplit("|", 1)
725737
else:
726738
path, prop = prop.rsplit("__", 1)
727739
result = self.lookup_query_variable(path, return_relation=is_rel_filter)
740+
is_optional_relation = False
728741
if not result:
729742
ident, target_class = self.build_traversal_from_path(
730743
{
@@ -735,8 +748,8 @@ def _parse_path(
735748
source_class,
736749
)
737750
else:
738-
ident, target_class = result
739-
return ident, path, prop, target_class
751+
ident, target_class, is_optional_relation = result
752+
return ident, path, prop, target_class, is_optional_relation
740753

741754
def _finalize_filter_statement(
742755
self, operator: str, ident: str, prop: str, val: Any
@@ -762,41 +775,66 @@ def _build_filter_statements(
762775
self,
763776
ident: str,
764777
filters: dict[str, tuple],
765-
target: list[str],
778+
target: list[tuple[str, bool]],
766779
source_class: type[AsyncStructuredNode],
767780
) -> None:
768781
for prop, op_and_val in filters.items():
769782
path = None
770783
is_rel_filter = "|" in prop
771784
target_class = source_class
785+
is_optional_relation = False
772786
if "__" in prop or is_rel_filter:
773-
ident, path, prop, target_class = self._parse_path(source_class, prop)
787+
(
788+
ident,
789+
path,
790+
prop,
791+
target_class,
792+
is_optional_relation,
793+
) = self._parse_path(source_class, prop)
774794
operator, val = op_and_val
775795
if not is_rel_filter:
776796
prop = target_class.defined_properties(rels=False)[
777797
prop
778798
].get_db_property_name(prop)
779799
statement = self._finalize_filter_statement(operator, ident, prop, val)
780-
target.append(statement)
800+
target.append((statement, is_optional_relation))
781801

782802
def _parse_q_filters(
783803
self, ident: str, q: Union[QBase, Any], source_class: type[AsyncStructuredNode]
784-
) -> str:
785-
target = []
804+
) -> tuple[str, str]:
805+
target: list[tuple[str, bool]] = []
806+
807+
def add_to_target(statement: str, connector: Q, optional: bool) -> None:
808+
if not statement:
809+
return
810+
if connector == Q.OR:
811+
statement = f"({statement})"
812+
target.append((statement, optional))
813+
786814
for child in q.children:
787815
if isinstance(child, QBase):
788-
q_childs = self._parse_q_filters(ident, child, source_class)
789-
if child.connector == Q.OR:
790-
q_childs = "(" + q_childs + ")"
791-
target.append(q_childs)
816+
q_childs, q_opt_childs = self._parse_q_filters(
817+
ident, child, source_class
818+
)
819+
add_to_target(q_childs, child.connector, False)
820+
add_to_target(q_opt_childs, child.connector, True)
792821
else:
793822
kwargs = {child[0]: child[1]}
794823
filters = process_filter_args(source_class, kwargs)
795824
self._build_filter_statements(ident, filters, target, source_class)
796-
ret = f" {q.connector} ".join(target)
797-
if q.negated:
825+
match_filters = [filter[0] for filter in target if not filter[1]]
826+
opt_match_filters = [filter[0] for filter in target if filter[1]]
827+
if q.connector == Q.OR and match_filters and opt_match_filters:
828+
raise ValueError(
829+
"Cannot filter using OR operator on variables coming from both MATCH and OPTIONAL MATCH statements"
830+
)
831+
ret = f" {q.connector} ".join(match_filters)
832+
if ret and q.negated:
798833
ret = f"NOT ({ret})"
799-
return ret
834+
opt_ret = f" {q.connector} ".join(opt_match_filters)
835+
if opt_ret and q.negated:
836+
opt_ret = f"NOT ({opt_ret})"
837+
return ret, opt_ret
800838

801839
def build_where_stmt(
802840
self,
@@ -806,12 +844,18 @@ def build_where_stmt(
806844
q_filters: Union[QBase, Any, None] = None,
807845
) -> None:
808846
"""
809-
construct a where statement from some filters
847+
Construct a where statement from some filters.
848+
849+
We make a difference between filters applied to variables coming from MATCH and
850+
OPTIONAL MATCH statements.
851+
810852
"""
811853
if q_filters is not None:
812-
stmt = self._parse_q_filters(ident, q_filters, source_class)
854+
stmt, opt_stmt = self._parse_q_filters(ident, q_filters, source_class)
813855
if stmt:
814856
self._ast.where.append(stmt)
857+
if opt_stmt:
858+
self._ast.optional_where.append(opt_stmt)
815859
else:
816860
stmts = []
817861
for row in filters:
@@ -839,7 +883,7 @@ def build_where_stmt(
839883

840884
def lookup_query_variable(
841885
self, path: str, return_relation: bool = False
842-
) -> TOptional[Tuple[str, Any]]:
886+
) -> TOptional[Tuple[str, Any, bool]]:
843887
"""Retrieve the variable name generated internally for the given traversal path."""
844888
subgraph = self._ast.subgraph
845889
if not subgraph:
@@ -849,10 +893,19 @@ def lookup_query_variable(
849893
raise ValueError("Can only lookup traversal variables")
850894
if traversals[0] not in subgraph:
851895
return None
896+
897+
# Check if relation is coming from an optional MATCH
898+
# (declared using fetch|traverse_relations)
899+
is_optional_relation = False
900+
for relation in self.node_set.relations_to_fetch:
901+
if relation["path"] == path:
902+
is_optional_relation = relation.get("optional", False)
903+
break
904+
852905
subgraph = subgraph[traversals[0]]
853906
if len(traversals) == 1:
854907
variable_to_return = f"{subgraph['rel_variable_name' if return_relation else 'variable_name']}"
855-
return variable_to_return, subgraph["target"]
908+
return variable_to_return, subgraph["target"], is_optional_relation
856909
variable_to_return = ""
857910
last_property = traversals[-1]
858911
for part in traversals[1:]:
@@ -864,7 +917,7 @@ def lookup_query_variable(
864917
# if last part of prop is the last traversal
865918
# we are safe to lookup the variable from the query
866919
variable_to_return = f"{subgraph['rel_variable_name' if return_relation else 'variable_name']}"
867-
return variable_to_return, subgraph["target"]
920+
return variable_to_return, subgraph["target"], is_optional_relation
868921

869922
def build_query(self) -> str:
870923
query: str = ""
@@ -881,16 +934,19 @@ def build_query(self) -> str:
881934
query += " MATCH "
882935
query += " MATCH ".join(i for i in self._ast.match)
883936

937+
if self._ast.where:
938+
query += " WHERE "
939+
query += " AND ".join(self._ast.where)
940+
884941
if self._ast.optional_match:
885942
query += " OPTIONAL MATCH "
886943
query += " OPTIONAL MATCH ".join(i for i in self._ast.optional_match)
887944

888-
if self._ast.where:
889-
if self._ast.optional_match:
890-
# Make sure filtering works as expected with optional match, even if it's not performant...
891-
query += " WITH *"
945+
if self._ast.optional_where:
946+
# Make sure filtering works as expected with optional match, even if it's not performant...
947+
query += " WITH *"
892948
query += " WHERE "
893-
query += " AND ".join(self._ast.where)
949+
query += " AND ".join(self._ast.optional_where)
894950

895951
if self._ast.with_clause:
896952
query += " WITH "

0 commit comments

Comments
 (0)