From 7a5cbddd32e4a5a65929ce04f09c6364d60df442 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 22 Nov 2024 10:15:45 +0100 Subject: [PATCH 1/4] Improved intermediate_transform method. New syntax to allow more complex statements when injecting intermediate transformation: * Use of DISTINCT clause * Indicate item property instead of complete item * Indicate if transformed variable should be returned by query or not --- doc/source/advanced_query_operations.rst | 6 +- neomodel/async_/match.py | 70 ++++++++++++++---------- neomodel/sync_/match.py | 70 ++++++++++++++---------- neomodel/typing.py | 13 +++++ test/async_/test_match_api.py | 12 ++-- test/sync_/test_match_api.py | 12 ++-- 6 files changed, 108 insertions(+), 75 deletions(-) create mode 100644 neomodel/typing.py diff --git a/doc/source/advanced_query_operations.rst b/doc/source/advanced_query_operations.rst index e602d479..a1d3aa36 100644 --- a/doc/source/advanced_query_operations.rst +++ b/doc/source/advanced_query_operations.rst @@ -54,7 +54,7 @@ As discussed in the note above, this is for example useful when you need to orde # This will return all Coffee nodes, with their most expensive supplier Coffee.nodes.traverse_relations(suppliers="suppliers") .intermediate_transform( - {"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"] + {"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"] ) .annotate(supps=Last(Collect("suppliers"))) @@ -71,7 +71,7 @@ The `subquery` method allows you to perform a `Cypher subquery str: query += " WITH " query += self._ast.with_clause + returned_items: list[str] = [] if hasattr(self.node_set, "_intermediate_transforms"): for transform in self.node_set._intermediate_transforms: query += " WITH " @@ -845,25 +847,19 @@ def build_query(self) -> str: # Reset return list since we'll probably invalidate most variables self._ast.return_clause = "" self._ast.additional_return = [] - for name, source in transform["vars"].items(): - if type(source) is str: - injected_vars.append(f"{source} AS {name}") - elif isinstance(source, RelationNameResolver): - result = self.lookup_query_variable( - source.relation, return_relation=True - ) - if not result: - raise ValueError( - f"Unable to resolve variable name for relation {source.relation}." - ) - injected_vars.append(f"{result[0]} AS {name}") - elif isinstance(source, NodeNameResolver): - result = self.lookup_query_variable(source.node) - if not result: - raise ValueError( - f"Unable to resolve variable name for node {source.node}." - ) - injected_vars.append(f"{result[0]} AS {name}") + for name, varprops in transform["vars"].items(): + source = varprops["source"] + transformation = "DISTINCT " if varprops.get("distinct") else "" + if isinstance(source, (NodeNameResolver, RelationNameResolver)): + transformation += source.resolve(self) + else: + transformation += source + if varprops.get("source_prop"): + transformation += f".{varprops['source_prop']}" + transformation += f" AS {name}" + if varprops.get("include_in_return"): + returned_items += [name] + injected_vars.append(transformation) query += ",".join(injected_vars) if not transform["ordering"]: continue @@ -879,7 +875,6 @@ def build_query(self) -> str: ordering.append(item) query += ",".join(ordering) - returned_items: list[str] = [] if hasattr(self.node_set, "_subqueries"): for subquery, return_set in self.node_set._subqueries: outer_primary_var = self._ast.return_clause @@ -1098,6 +1093,14 @@ class RelationNameResolver: relation: str + def resolve(self, qbuilder: AsyncQueryBuilder) -> str: + result = qbuilder.lookup_query_variable(self.relation, True) + if result is None: + raise ValueError( + f"Unable to resolve variable name for relation {self.relation}" + ) + return result[0] + @dataclass class NodeNameResolver: @@ -1111,6 +1114,12 @@ class NodeNameResolver: node: str + def resolve(self, qbuilder: AsyncQueryBuilder) -> str: + result = qbuilder.lookup_query_variable(self.node) + if result is None: + raise ValueError(f"Unable to resolve variable name for node {self.node}") + return result[0] + @dataclass class BaseFunction: @@ -1123,15 +1132,15 @@ def get_internal_name(self) -> str: return self._internal_name def resolve_internal_name(self, qbuilder: AsyncQueryBuilder) -> str: - if isinstance(self.input_name, NodeNameResolver): - result = qbuilder.lookup_query_variable(self.input_name.node) - elif isinstance(self.input_name, RelationNameResolver): - result = qbuilder.lookup_query_variable(self.input_name.relation, True) + if isinstance(self.input_name, (NodeNameResolver, RelationNameResolver)): + self._internal_name = self.input_name.resolve(qbuilder) else: result = (str(self.input_name), None) - if result is None: - raise ValueError(f"Unknown variable {self.input_name} used in Collect()") - self._internal_name = result[0] + if result is None: + raise ValueError( + f"Unknown variable {self.input_name} used in Collect()" + ) + self._internal_name = result[0] return self._internal_name def render(self, qbuilder: AsyncQueryBuilder) -> str: @@ -1538,15 +1547,16 @@ async def subquery( return self def intermediate_transform( - self, vars: Dict[str, Any], ordering: TOptional[list] = None + self, vars: Dict[str, Transformation], ordering: TOptional[list] = None ) -> "AsyncNodeSet": if not vars: raise ValueError( "You must provide one variable at least when calling intermediate_transform()" ) - for name, source in vars.items(): + for name, props in vars.items(): + source = props["source"] if type(source) is not str and not isinstance( - source, (NodeNameResolver, RelationNameResolver) + source, (NodeNameResolver, RelationNameResolver, RawCypher) ): raise ValueError( f"Wrong source type specified for variable '{name}', should be a string or an instance of NodeNameResolver or RelationNameResolver" diff --git a/neomodel/sync_/match.py b/neomodel/sync_/match.py index 966b2601..8acc4c3d 100644 --- a/neomodel/sync_/match.py +++ b/neomodel/sync_/match.py @@ -12,6 +12,7 @@ from neomodel.sync_ import relationship_manager from neomodel.sync_.core import StructuredNode, db from neomodel.sync_.relationship import StructuredRel +from neomodel.typing import Transformation from neomodel.util import INCOMING, OUTGOING CYPHER_ACTIONS_WITH_SIDE_EFFECT_EXPR = re.compile(r"(?i:MERGE|CREATE|DELETE|DETACH)") @@ -840,6 +841,7 @@ def build_query(self) -> str: query += " WITH " query += self._ast.with_clause + returned_items: list[str] = [] if hasattr(self.node_set, "_intermediate_transforms"): for transform in self.node_set._intermediate_transforms: query += " WITH " @@ -847,25 +849,19 @@ def build_query(self) -> str: # Reset return list since we'll probably invalidate most variables self._ast.return_clause = "" self._ast.additional_return = [] - for name, source in transform["vars"].items(): - if type(source) is str: - injected_vars.append(f"{source} AS {name}") - elif isinstance(source, RelationNameResolver): - result = self.lookup_query_variable( - source.relation, return_relation=True - ) - if not result: - raise ValueError( - f"Unable to resolve variable name for relation {source.relation}." - ) - injected_vars.append(f"{result[0]} AS {name}") - elif isinstance(source, NodeNameResolver): - result = self.lookup_query_variable(source.node) - if not result: - raise ValueError( - f"Unable to resolve variable name for node {source.node}." - ) - injected_vars.append(f"{result[0]} AS {name}") + for name, varprops in transform["vars"].items(): + source = varprops["source"] + transformation = "DISTINCT " if varprops.get("distinct") else "" + if isinstance(source, (NodeNameResolver, RelationNameResolver)): + transformation += source.resolve(self) + else: + transformation += source + if varprops.get("source_prop"): + transformation += f".{varprops['source_prop']}" + transformation += f" AS {name}" + if varprops.get("include_in_return"): + returned_items += [name] + injected_vars.append(transformation) query += ",".join(injected_vars) if not transform["ordering"]: continue @@ -881,7 +877,6 @@ def build_query(self) -> str: ordering.append(item) query += ",".join(ordering) - returned_items: list[str] = [] if hasattr(self.node_set, "_subqueries"): for subquery, return_set in self.node_set._subqueries: outer_primary_var = self._ast.return_clause @@ -1098,6 +1093,14 @@ class RelationNameResolver: relation: str + def resolve(self, qbuilder: QueryBuilder) -> str: + result = qbuilder.lookup_query_variable(self.relation, True) + if result is None: + raise ValueError( + f"Unable to resolve variable name for relation {self.relation}" + ) + return result[0] + @dataclass class NodeNameResolver: @@ -1111,6 +1114,12 @@ class NodeNameResolver: node: str + def resolve(self, qbuilder: QueryBuilder) -> str: + result = qbuilder.lookup_query_variable(self.node) + if result is None: + raise ValueError(f"Unable to resolve variable name for node {self.node}") + return result[0] + @dataclass class BaseFunction: @@ -1123,15 +1132,15 @@ def get_internal_name(self) -> str: return self._internal_name def resolve_internal_name(self, qbuilder: QueryBuilder) -> str: - if isinstance(self.input_name, NodeNameResolver): - result = qbuilder.lookup_query_variable(self.input_name.node) - elif isinstance(self.input_name, RelationNameResolver): - result = qbuilder.lookup_query_variable(self.input_name.relation, True) + if isinstance(self.input_name, (NodeNameResolver, RelationNameResolver)): + self._internal_name = self.input_name.resolve(qbuilder) else: result = (str(self.input_name), None) - if result is None: - raise ValueError(f"Unknown variable {self.input_name} used in Collect()") - self._internal_name = result[0] + if result is None: + raise ValueError( + f"Unknown variable {self.input_name} used in Collect()" + ) + self._internal_name = result[0] return self._internal_name def render(self, qbuilder: QueryBuilder) -> str: @@ -1536,15 +1545,16 @@ def subquery(self, nodeset: "NodeSet", return_set: List[str]) -> "NodeSet": return self def intermediate_transform( - self, vars: Dict[str, Any], ordering: TOptional[list] = None + self, vars: Dict[str, Transformation], ordering: TOptional[list] = None ) -> "NodeSet": if not vars: raise ValueError( "You must provide one variable at least when calling intermediate_transform()" ) - for name, source in vars.items(): + for name, props in vars.items(): + source = props["source"] if type(source) is not str and not isinstance( - source, (NodeNameResolver, RelationNameResolver) + source, (NodeNameResolver, RelationNameResolver, RawCypher) ): raise ValueError( f"Wrong source type specified for variable '{name}', should be a string or an instance of NodeNameResolver or RelationNameResolver" diff --git a/neomodel/typing.py b/neomodel/typing.py new file mode 100644 index 00000000..2a6afb05 --- /dev/null +++ b/neomodel/typing.py @@ -0,0 +1,13 @@ +"""Custom types used for annotations.""" + +from typing import Any, Optional, TypedDict + +Transformation = TypedDict( + "Transformation", + { + "source": Any, + "source_prop": Optional[str], + "distinct": Optional[bool], + "include_in_return": Optional[bool], + }, +) diff --git a/test/async_/test_match_api.py b/test/async_/test_match_api.py index 39e96957..2b9c86bf 100644 --- a/test/async_/test_match_api.py +++ b/test/async_/test_match_api.py @@ -879,7 +879,7 @@ async def test_subquery(): result = await Coffee.nodes.subquery( Coffee.nodes.traverse_relations(suppliers="suppliers") .intermediate_transform( - {"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"] + {"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"] ) .annotate(supps=Last(Collect("suppliers"))), ["supps"], @@ -916,9 +916,9 @@ async def test_intermediate_transform(): await Coffee.nodes.fetch_relations("suppliers") .intermediate_transform( { - "coffee": "coffee", - "suppliers": NodeNameResolver("suppliers"), - "r": RelationNameResolver("suppliers"), + "coffee": {"source": "coffee"}, + "suppliers": {"source": NodeNameResolver("suppliers")}, + "r": {"source": RelationNameResolver("suppliers")}, }, ordering=["-r.since"], ) @@ -937,7 +937,7 @@ async def test_intermediate_transform(): ): Coffee.nodes.traverse_relations(suppliers="suppliers").intermediate_transform( { - "test": Collect("suppliers"), + "test": {"source": Collect("suppliers")}, } ) with raises( @@ -1008,7 +1008,7 @@ async def test_mix_functions(): .subquery( Student.nodes.fetch_relations("courses") .intermediate_transform( - {"rel": RelationNameResolver("courses")}, + {"rel": {"source": RelationNameResolver("courses")}}, ordering=[ RawCypher("toInteger(split(rel.level, '.')[0])"), RawCypher("toInteger(split(rel.level, '.')[1])"), diff --git a/test/sync_/test_match_api.py b/test/sync_/test_match_api.py index 78909860..61021990 100644 --- a/test/sync_/test_match_api.py +++ b/test/sync_/test_match_api.py @@ -863,7 +863,7 @@ def test_subquery(): result = Coffee.nodes.subquery( Coffee.nodes.traverse_relations(suppliers="suppliers") .intermediate_transform( - {"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"] + {"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"] ) .annotate(supps=Last(Collect("suppliers"))), ["supps"], @@ -900,9 +900,9 @@ def test_intermediate_transform(): Coffee.nodes.fetch_relations("suppliers") .intermediate_transform( { - "coffee": "coffee", - "suppliers": NodeNameResolver("suppliers"), - "r": RelationNameResolver("suppliers"), + "coffee": {"source": "coffee"}, + "suppliers": {"source": NodeNameResolver("suppliers")}, + "r": {"source": RelationNameResolver("suppliers")}, }, ordering=["-r.since"], ) @@ -921,7 +921,7 @@ def test_intermediate_transform(): ): Coffee.nodes.traverse_relations(suppliers="suppliers").intermediate_transform( { - "test": Collect("suppliers"), + "test": {"source": Collect("suppliers")}, } ) with raises( @@ -992,7 +992,7 @@ def test_mix_functions(): .subquery( Student.nodes.fetch_relations("courses") .intermediate_transform( - {"rel": RelationNameResolver("courses")}, + {"rel": {"source": RelationNameResolver("courses")}}, ordering=[ RawCypher("toInteger(split(rel.level, '.')[0])"), RawCypher("toInteger(split(rel.level, '.')[1])"), From ab8f4ba47198dd8b92fdbc6b47258f1049c0b82b Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 22 Nov 2024 10:27:30 +0100 Subject: [PATCH 2/4] Removed useless code --- neomodel/async_/match.py | 7 +------ neomodel/sync_/match.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/neomodel/async_/match.py b/neomodel/async_/match.py index 6cd39917..acdc76ff 100644 --- a/neomodel/async_/match.py +++ b/neomodel/async_/match.py @@ -1135,12 +1135,7 @@ def resolve_internal_name(self, qbuilder: AsyncQueryBuilder) -> str: if isinstance(self.input_name, (NodeNameResolver, RelationNameResolver)): self._internal_name = self.input_name.resolve(qbuilder) else: - result = (str(self.input_name), None) - if result is None: - raise ValueError( - f"Unknown variable {self.input_name} used in Collect()" - ) - self._internal_name = result[0] + self._internal_name = str(self.input_name) return self._internal_name def render(self, qbuilder: AsyncQueryBuilder) -> str: diff --git a/neomodel/sync_/match.py b/neomodel/sync_/match.py index 8acc4c3d..d59000ca 100644 --- a/neomodel/sync_/match.py +++ b/neomodel/sync_/match.py @@ -1135,12 +1135,7 @@ def resolve_internal_name(self, qbuilder: QueryBuilder) -> str: if isinstance(self.input_name, (NodeNameResolver, RelationNameResolver)): self._internal_name = self.input_name.resolve(qbuilder) else: - result = (str(self.input_name), None) - if result is None: - raise ValueError( - f"Unknown variable {self.input_name} used in Collect()" - ) - self._internal_name = result[0] + self._internal_name = str(self.input_name) return self._internal_name def render(self, qbuilder: QueryBuilder) -> str: From d1720df2709b86410acd2a150926cb64993b89b3 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 22 Nov 2024 11:32:10 +0100 Subject: [PATCH 3/4] DISTINCT must be at first place when used within a WITH call --- neomodel/async_/match.py | 15 ++++++++++----- neomodel/sync_/match.py | 15 ++++++++++----- neomodel/typing.py | 1 - test/async_/test_match_api.py | 10 ++++++++-- test/sync_/test_match_api.py | 10 ++++++++-- 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/neomodel/async_/match.py b/neomodel/async_/match.py index acdc76ff..a3718d46 100644 --- a/neomodel/async_/match.py +++ b/neomodel/async_/match.py @@ -843,17 +843,17 @@ def build_query(self) -> str: if hasattr(self.node_set, "_intermediate_transforms"): for transform in self.node_set._intermediate_transforms: query += " WITH " + query += "DISTINCT " if transform.get("distinct") else "" injected_vars: list = [] # Reset return list since we'll probably invalidate most variables self._ast.return_clause = "" self._ast.additional_return = [] for name, varprops in transform["vars"].items(): source = varprops["source"] - transformation = "DISTINCT " if varprops.get("distinct") else "" if isinstance(source, (NodeNameResolver, RelationNameResolver)): - transformation += source.resolve(self) + transformation = source.resolve(self) else: - transformation += source + transformation = source if varprops.get("source_prop"): transformation += f".{varprops['source_prop']}" transformation += f" AS {name}" @@ -1542,7 +1542,10 @@ async def subquery( return self def intermediate_transform( - self, vars: Dict[str, Transformation], ordering: TOptional[list] = None + self, + vars: Dict[str, Transformation], + distinct: bool = False, + ordering: TOptional[list] = None, ) -> "AsyncNodeSet": if not vars: raise ValueError( @@ -1556,7 +1559,9 @@ def intermediate_transform( raise ValueError( f"Wrong source type specified for variable '{name}', should be a string or an instance of NodeNameResolver or RelationNameResolver" ) - self._intermediate_transforms.append({"vars": vars, "ordering": ordering}) + self._intermediate_transforms.append( + {"vars": vars, "distinct": distinct, "ordering": ordering} + ) return self diff --git a/neomodel/sync_/match.py b/neomodel/sync_/match.py index d59000ca..cd9a7f43 100644 --- a/neomodel/sync_/match.py +++ b/neomodel/sync_/match.py @@ -845,17 +845,17 @@ def build_query(self) -> str: if hasattr(self.node_set, "_intermediate_transforms"): for transform in self.node_set._intermediate_transforms: query += " WITH " + query += "DISTINCT " if transform.get("distinct") else "" injected_vars: list = [] # Reset return list since we'll probably invalidate most variables self._ast.return_clause = "" self._ast.additional_return = [] for name, varprops in transform["vars"].items(): source = varprops["source"] - transformation = "DISTINCT " if varprops.get("distinct") else "" if isinstance(source, (NodeNameResolver, RelationNameResolver)): - transformation += source.resolve(self) + transformation = source.resolve(self) else: - transformation += source + transformation = source if varprops.get("source_prop"): transformation += f".{varprops['source_prop']}" transformation += f" AS {name}" @@ -1540,7 +1540,10 @@ def subquery(self, nodeset: "NodeSet", return_set: List[str]) -> "NodeSet": return self def intermediate_transform( - self, vars: Dict[str, Transformation], ordering: TOptional[list] = None + self, + vars: Dict[str, Transformation], + distinct: bool = False, + ordering: TOptional[list] = None, ) -> "NodeSet": if not vars: raise ValueError( @@ -1554,7 +1557,9 @@ def intermediate_transform( raise ValueError( f"Wrong source type specified for variable '{name}', should be a string or an instance of NodeNameResolver or RelationNameResolver" ) - self._intermediate_transforms.append({"vars": vars, "ordering": ordering}) + self._intermediate_transforms.append( + {"vars": vars, "distinct": distinct, "ordering": ordering} + ) return self diff --git a/neomodel/typing.py b/neomodel/typing.py index 2a6afb05..9438bd54 100644 --- a/neomodel/typing.py +++ b/neomodel/typing.py @@ -7,7 +7,6 @@ { "source": Any, "source_prop": Optional[str], - "distinct": Optional[bool], "include_in_return": Optional[bool], }, ) diff --git a/test/async_/test_match_api.py b/test/async_/test_match_api.py index 2b9c86bf..c83d826f 100644 --- a/test/async_/test_match_api.py +++ b/test/async_/test_match_api.py @@ -916,10 +916,15 @@ async def test_intermediate_transform(): await Coffee.nodes.fetch_relations("suppliers") .intermediate_transform( { - "coffee": {"source": "coffee"}, + "coffee": {"source": "coffee", "include_in_return": True}, "suppliers": {"source": NodeNameResolver("suppliers")}, "r": {"source": RelationNameResolver("suppliers")}, + "cost": { + "source": NodeNameResolver("suppliers"), + "source_prop": "delivery_cost", + }, }, + distinct=True, ordering=["-r.since"], ) .annotate(oldest_supplier=Last(Collect("suppliers"))) @@ -927,7 +932,8 @@ async def test_intermediate_transform(): ) assert len(result) == 1 - assert result[0] == supplier2 + assert result[0][0] == nescafe + assert result[0][1] == supplier2 with raises( ValueError, diff --git a/test/sync_/test_match_api.py b/test/sync_/test_match_api.py index 61021990..4a5684ea 100644 --- a/test/sync_/test_match_api.py +++ b/test/sync_/test_match_api.py @@ -900,10 +900,15 @@ def test_intermediate_transform(): Coffee.nodes.fetch_relations("suppliers") .intermediate_transform( { - "coffee": {"source": "coffee"}, + "coffee": {"source": "coffee", "include_in_return": True}, "suppliers": {"source": NodeNameResolver("suppliers")}, "r": {"source": RelationNameResolver("suppliers")}, + "cost": { + "source": NodeNameResolver("suppliers"), + "source_prop": "delivery_cost", + }, }, + distinct=True, ordering=["-r.since"], ) .annotate(oldest_supplier=Last(Collect("suppliers"))) @@ -911,7 +916,8 @@ def test_intermediate_transform(): ) assert len(result) == 1 - assert result[0] == supplier2 + assert result[0][0] == nescafe + assert result[0][1] == supplier2 with raises( ValueError, From a13697f675beae140c25f649e89bbc50d0cdeb70 Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Fri, 22 Nov 2024 14:35:25 +0100 Subject: [PATCH 4/4] Add doc and changelog --- Changelog | 3 +++ doc/source/advanced_query_operations.rst | 31 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Changelog b/Changelog index 079cab72..9d62721d 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,6 @@ +Version 5.4.1 2024-11 +* Add options for intermediate_transform : distinct, include_in_return, use a prop as source + Version 5.4.0 2024-11 * Traversal option for filtering and ordering * Insert raw Cypher for ordering diff --git a/doc/source/advanced_query_operations.rst b/doc/source/advanced_query_operations.rst index a1d3aa36..73c5bbd6 100644 --- a/doc/source/advanced_query_operations.rst +++ b/doc/source/advanced_query_operations.rst @@ -58,6 +58,37 @@ As discussed in the note above, this is for example useful when you need to orde ) .annotate(supps=Last(Collect("suppliers"))) +Options for `intermediate_transform` *variables* are: + +- `source`: `string`or `Resolver` - the variable to use as source for the transformation. Works with resolvers (see below). +- `source_prop`: `string` - optionally, a property of the source variable to use as source for the transformation. +- `include_in_return`: `bool` - whether to include the variable in the return statement. Defaults to False. + +Additional options for the `intermediate_transform` method are: +- `distinct`: `bool` - whether to deduplicate the results. Defaults to False. + +Here is a full example:: + + await Coffee.nodes.fetch_relations("suppliers") + .intermediate_transform( + { + "coffee": "coffee", + "suppliers": NodeNameResolver("suppliers"), + "r": RelationNameResolver("suppliers"), + "coffee": {"source": "coffee", "include_in_return": True}, # Only coffee will be returned + "suppliers": {"source": NodeNameResolver("suppliers")}, + "r": {"source": RelationNameResolver("suppliers")}, + "cost": { + "source": NodeNameResolver("suppliers"), + "source_prop": "delivery_cost", + }, + }, + distinct=True, + ordering=["-r.since"], + ) + .annotate(oldest_supplier=Last(Collect("suppliers"))) + .all() + Subqueries ----------