Skip to content

Commit d9eaf90

Browse files
Merge pull request #839 from neo4j-contrib/rc/5.4.0
Rc/5.4.0
2 parents 38ba23d + d7747e7 commit d9eaf90

35 files changed

+3027
-1016
lines changed

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
python-version: ["3.12", "3.11", "3.10", "3.9", "3.8", "3.7"]
18+
python-version: ["3.13", "3.12", "3.11", "3.10", "3.9"]
1919
neo4j-version: ["community", "enterprise", "5.5-enterprise", "4.4-enterprise", "4.4-community"]
2020

2121
steps:

Changelog

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
Version 5.4.0 2024-11
2+
* Traversal option for filtering and ordering
3+
* Insert raw Cypher for ordering
4+
* Possibility to traverse relations, only returning the last element of the path
5+
* Resolve the results of complex queries as a nested subgraph
6+
* Possibility to transform variables, with aggregations methods : Collect() and Last()
7+
* Intermediate transform, for example to order variables before collecting
8+
* Subqueries (Cypher CALL{} clause)
9+
* Allow JSONProperty to actually use non-ascii elements. Thanks to @danikirish
10+
* Bumped neo4j (driver) to 5.26.0
11+
* Special huge thanks to @tonioo for this release
12+
113
Version 5.3.3 2024-09
214
* Fixes vector index doc and test
315

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ GitHub repo found at <https://github.com/neo4j-contrib/neomodel/>.
2424

2525
**For neomodel releases 5.x :**
2626

27-
- Python 3.7+
27+
- Python 3.8+
2828
- Neo4j 5.x, 4.4 (LTS)
2929

3030
**For neomodel releases 4.x :**
@@ -37,6 +37,14 @@ GitHub repo found at <https://github.com/neo4j-contrib/neomodel/>.
3737
Available on
3838
[readthedocs](http://neomodel.readthedocs.org).
3939

40+
# New in 5.4.0
41+
42+
This version adds many new features, expanding neomodel's querying capabilities. Those features were kindly contributed back by the [OpenStudyBuilder team](https://openstudybuilder.com/). A VERY special thanks to @tonioo for the integration work.
43+
44+
There are too many new capabilities here, so I advise you to start by looking at the full summary example in the [Getting Started guide](https://neomodel.readthedocs.io/en/latest/getting_started.html#full-example). It will then point you to the various relevant sections.
45+
46+
We also validated support for [Python 3.13](https://docs.python.org/3/whatsnew/3.13.html).
47+
4048
# New in 5.3.0
4149

4250
neomodel now supports asynchronous programming, thanks to the [Neo4j driver async API](https://neo4j.com/docs/api/python-driver/current/async_api.html). The [documentation](http://neomodel.readthedocs.org) has been updated accordingly, with an updated getting started section, and some specific documentation for the async API.
@@ -96,7 +104,7 @@ Ensure `dbms.security.auth_enabled=true` in your database configuration
96104
file. Setup a virtual environment, install neomodel for development and
97105
run the test suite: :
98106

99-
$ pip install -e '.[dev,pandas,numpy]'
107+
$ pip install -r requirements-dev.txt
100108
$ pytest
101109

102110
The tests in \"test_connection.py\" will fail locally if you don\'t
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
.. _Advanced query operations:
2+
3+
=========================
4+
Advanced query operations
5+
=========================
6+
7+
neomodel provides ways to enhance your queries beyond filtering and traversals.
8+
9+
Annotate - Aliasing
10+
-------------------
11+
12+
The `annotate` method allows you to add transformations to your elements. To learn more about the available transformations, keep reading this section.
13+
14+
Aggregations
15+
------------
16+
17+
neomodel implements some of the aggregation methods available in Cypher:
18+
19+
- Collect (with distinct option)
20+
- Last
21+
22+
These are usable in this way::
23+
24+
from neomodel.sync_.match import Collect, Last
25+
26+
# distinct is optional, and defaults to False. When true, objects are deduplicated
27+
Supplier.nodes.traverse_relations(available_species="coffees__species")
28+
.annotate(Collect("available_species", distinct=True))
29+
.all()
30+
31+
# Last is used to get the last element of a list
32+
Supplier.nodes.traverse_relations(available_species="coffees__species")
33+
.annotate(Last(Collect("last_species")))
34+
.all()
35+
36+
Note how `annotate` is used to add the aggregation method to the query.
37+
38+
.. note::
39+
Using the Last() method right after a Collect() without having set an ordering will return the last element in the list as it was returned by the database.
40+
41+
This is probably not what you want ; which means you must provide an explicit ordering. To do so, you cannot use neomodel's `order_by` method, but need an intermediate transformation step (see below).
42+
43+
This is because the order_by method adds ordering as the very last step of the Cypher query ; whereas in the present example, you want to first order Species, then get the last one, and then finally return your results. In other words, you need an intermediate WITH Cypher clause.
44+
45+
Intermediate transformations
46+
----------------------------
47+
48+
The `intermediate_transform` method basically allows you to add a WITH clause to your query. This is useful when you need to perform some operations on your results before returning them.
49+
50+
As discussed in the note above, this is for example useful when you need to order your results before applying an aggregation method, like so::
51+
52+
from neomodel.sync_.match import Collect, Last
53+
54+
# This will return all Coffee nodes, with their most expensive supplier
55+
Coffee.nodes.traverse_relations(suppliers="suppliers")
56+
.intermediate_transform(
57+
{"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"]
58+
)
59+
.annotate(supps=Last(Collect("suppliers")))
60+
61+
Subqueries
62+
----------
63+
64+
The `subquery` method allows you to perform a `Cypher subquery <https://neo4j.com/docs/cypher-manual/current/subqueries/call-subquery/>`_ inside your query. This allows you to perform operations in isolation to the rest of your query::
65+
66+
from neomodel.sync_match import Collect, Last
67+
68+
# This will create a CALL{} subquery
69+
# And return a variable named supps usable in the rest of your query
70+
Coffee.nodes.filter(name="Espresso")
71+
.subquery(
72+
Coffee.nodes.traverse_relations(suppliers="suppliers")
73+
.intermediate_transform(
74+
{"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"]
75+
)
76+
.annotate(supps=Last(Collect("suppliers"))),
77+
["supps"],
78+
)
79+
80+
.. note::
81+
Notice the subquery starts with Coffee.nodes ; neomodel will use this to know it needs to inject the source "coffee" variable generated by the outer query into the subquery. This means only Espresso coffee nodes will be considered in the subquery.
82+
83+
We know this is confusing to read, but have not found a better wat to do this yet. If you have any suggestions, please let us know.
84+
85+
Helpers
86+
-------
87+
88+
Reading the sections above, you may have noticed that we used explicit aliasing in the examples, as in::
89+
90+
traverse_relations(suppliers="suppliers")
91+
92+
This allows you to reference the generated Cypher variables in your transformation steps, for example::
93+
94+
traverse_relations(suppliers="suppliers").annotate(Collect("suppliers"))
95+
96+
In some cases though, it is not possible to set explicit aliases, for example when using `fetch_relations`. In these cases, neomodel provides `resolver` methods, so you do not have to guess the name of the variable in the generated Cypher. Those are `NodeNameResolver` and `RelationshipNameResolver`. For example::
97+
98+
from neomodel.sync_match import Collect, NodeNameResolver, RelationshipNameResolver
99+
100+
Supplier.nodes.fetch_relations("coffees__species")
101+
.annotate(
102+
all_species=Collect(NodeNameResolver("coffees__species"), distinct=True),
103+
all_species_rels=Collect(
104+
RelationNameResolver("coffees__species"), distinct=True
105+
),
106+
)
107+
.all()
108+
109+
.. note::
110+
111+
When using the resolvers in combination with a traversal as in the example above, it will resolve the variable name of the last element in the traversal - the Species node for NodeNameResolver, and Coffee--Species relationship for RelationshipNameResolver.

doc/source/configuration.rst

Lines changed: 1 addition & 1 deletion
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.3.3 # default
35+
config.USER_AGENT = neomodel/v5.4.0 # default
3636

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

doc/source/cypher.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ Outside of a `StructuredNode`::
2424

2525
The ``resolve_objects`` parameter automatically inflates the returned nodes to their defined classes (this is turned **off** by default). See :ref:`automatic_class_resolution` for details and possible pitfalls.
2626

27+
You can also retrieve a whole path of already instantiated objects corresponding to
28+
the nodes and relationship classes with a single query::
29+
30+
q = db.cypher_query("MATCH p=(:CityOfResidence)<-[:LIVES_IN]-(:PersonOfInterest)-[:IS_FROM]->(:CountryOfOrigin) RETURN p LIMIT 1",
31+
resolve_objects = True)
32+
33+
Notice here that ``resolve_objects`` is set to ``True``. This results in ``q`` being a
34+
list of ``result, result_name`` and ``q[0][0][0]`` being a ``NeomodelPath`` object.
35+
36+
``NeomodelPath`` ``nodes, relationships`` attributes contain already instantiated objects of the
37+
nodes and relationships in the query, *in order of appearance*.
38+
2739
Integrations
2840
============
2941

doc/source/filtering_ordering.rst

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
.. _Filtering and ordering:
2+
3+
======================
4+
Filtering and ordering
5+
======================
6+
7+
For the examples in this section, we will be using the following model::
8+
9+
class SupplierRel(StructuredRel):
10+
since = DateTimeProperty(default=datetime.now)
11+
12+
13+
class Supplier(StructuredNode):
14+
name = StringProperty()
15+
delivery_cost = IntegerProperty()
16+
17+
18+
class Coffee(StructuredNode):
19+
name = StringProperty(unique_index=True)
20+
price = IntegerProperty()
21+
suppliers = RelationshipFrom(Supplier, 'SUPPLIES', model=SupplierRel)
22+
23+
Filtering
24+
=========
25+
26+
neomodel allows filtering on nodes' and relationships' properties. Filters can be combined using Django's Q syntax. It also allows multi-hop relationship traversals to filter on "remote" elements.
27+
28+
Filter methods
29+
--------------
30+
31+
The ``.nodes`` property of a class returns all nodes of that type from the database.
32+
33+
This set (called `NodeSet`) can be iterated over and filtered on, using the `.filter` method::
34+
35+
# nodes with label Coffee whose price is greater than 2
36+
high_end_coffees = Coffee.nodes.filter(price__gt=2)
37+
38+
try:
39+
java = Coffee.nodes.get(name='Java')
40+
except DoesNotExist:
41+
# .filter will not throw an exception if no results are found
42+
# but .get will
43+
print("Couldn't find coffee 'Java'")
44+
45+
The filter method borrows the same Django filter format with double underscore prefixed operators:
46+
47+
- lt - less than
48+
- gt - greater than
49+
- lte - less than or equal to
50+
- gte - greater than or equal to
51+
- ne - not equal
52+
- in - item in list
53+
- isnull - `True` IS NULL, `False` IS NOT NULL
54+
- exact - string equals
55+
- iexact - string equals, case insensitive
56+
- contains - contains string value
57+
- icontains - contains string value, case insensitive
58+
- startswith - starts with string value
59+
- istartswith - starts with string value, case insensitive
60+
- endswith - ends with string value
61+
- iendswith - ends with string value, case insensitive
62+
- regex - matches a regex expression
63+
- iregex - matches a regex expression, case insensitive
64+
65+
These operators work with both `.get` and `.filter` methods.
66+
67+
Combining filters
68+
-----------------
69+
70+
The filter method allows you to combine multiple filters::
71+
72+
cheap_arabicas = Coffee.nodes.filter(price__lt=5, name__icontains='arabica')
73+
74+
These filters are combined using the logical AND operator. To execute more complex logic (for example, queries with OR statements), `Q objects <neomodel.Q>` can be used. This is borrowed from Django.
75+
76+
``Q`` objects can be combined using the ``&`` and ``|`` operators. Statements of arbitrary complexity can be composed by combining ``Q`` objects
77+
with the ``&`` and ``|`` operators and use parenthetical grouping. Also, ``Q``
78+
objects can be negated using the ``~`` operator, allowing for combined lookups
79+
that combine both a normal query and a negated (``NOT``) query::
80+
81+
Q(name__icontains='arabica') | ~Q(name__endswith='blend')
82+
83+
Chaining ``Q`` objects will join them as an AND clause::
84+
85+
not_middle_priced_arabicas = Coffee.nodes.filter(
86+
Q(name__icontains='arabica'),
87+
Q(price__lt=5) | Q(price__gt=10)
88+
)
89+
90+
Traversals and filtering
91+
------------------------
92+
93+
Sometimes you need to filter nodes based on other nodes they are connected to. This can be done by including a traversal in the `filter` method. ::
94+
95+
# Find all suppliers of coffee 'Java' who have been supplying since 2007
96+
# But whose prices are greater than 5
97+
since_date = datetime(2007, 1, 1)
98+
java_old_timers = Coffee.nodes.filter(
99+
name='Java',
100+
suppliers__delivery_cost__gt=5,
101+
**{"suppliers|since__lt": since_date}
102+
)
103+
104+
In the example above, note the following syntax elements:
105+
106+
- The name of relationships as defined in the `StructuredNode` class is used to traverse relationships. `suppliers` in this example.
107+
- Double underscore `__` is used to target a property of a node. `delivery_cost` in this example.
108+
- A pipe `|` is used to separate the relationship traversal from the property filter. The filter also has to included in a `**kwargs` dictionary, because the pipe character would break the syntax. This is a special syntax to indicate that the filter is on the relationship itself, not on the node at the end of the relationship.
109+
- The filter operators like lt, gt, etc. can be used on the filtered property.
110+
111+
Traversals can be of any length, with each relationships separated by a double underscore `__`, for example::
112+
113+
# country is here a relationship between Supplier and Country
114+
Coffee.nodes.filter(suppliers__country__name='Brazil')
115+
116+
Enforcing relationship/path existence
117+
-------------------------------------
118+
119+
The `has` method checks for existence of (one or more) relationships, in this case it returns a set of `Coffee` nodes which have a supplier::
120+
121+
Coffee.nodes.has(suppliers=True)
122+
123+
This can be negated by setting `suppliers=False`, to find `Coffee` nodes without `suppliers`.
124+
125+
You can also filter on the existence of more complex traversals by using the `traverse_relations` method. See :ref:`Path traversal`.
126+
127+
Ordering
128+
========
129+
130+
neomodel allows ordering by nodes' and relationships' properties. Order can be ascending or descending. Is also allows multi-hop relationship traversals to order on "remote" elements. Finally, you can inject raw Cypher clauses to have full control over ordering when necessary.
131+
132+
order_by
133+
--------
134+
135+
Ordering results by a particular property is done via the `order_by` method::
136+
137+
# Ascending sort
138+
for coffee in Coffee.nodes.order_by('price'):
139+
print(coffee, coffee.price)
140+
141+
# Descending sort
142+
for supplier in Supplier.nodes.order_by('-delivery_cost'):
143+
print(supplier, supplier.delivery_cost)
144+
145+
146+
Removing the ordering from a previously defined query, is done by passing `None` to `order_by`::
147+
148+
# Sort in descending order
149+
suppliers = Supplier.nodes.order_by('-delivery_cost')
150+
151+
# Don't order; yield nodes in the order neo4j returns them
152+
suppliers = suppliers.order_by(None)
153+
154+
For random ordering simply pass '?' to the order_by method::
155+
156+
Coffee.nodes.order_by('?')
157+
158+
Traversals and ordering
159+
-----------------------
160+
161+
Sometimes you need to order results based on properties situated on different nodes or relationships. This can be done by including a traversal in the `order_by` method. ::
162+
163+
# Find the most expensive coffee to deliver
164+
# Then order by the date the supplier started supplying
165+
Coffee.nodes.order_by(
166+
'-suppliers__delivery_cost',
167+
'suppliers|since',
168+
)
169+
170+
In the example above, note the following syntax elements:
171+
172+
- The name of relationships as defined in the `StructuredNode` class is used to traverse relationships. `suppliers` in this example.
173+
- Double underscore `__` is used to target a property of a node. `delivery_cost` in this example.
174+
- A pipe `|` is used to separate the relationship traversal from the property filter. This is a special syntax to indicate that the filter is on the relationship itself, not on the node at the end of the relationship.
175+
176+
Traversals can be of any length, with each relationships separated by a double underscore `__`, for example::
177+
178+
# country is here a relationship between Supplier and Country
179+
Coffee.nodes.order_by('suppliers__country__latitude')
180+
181+
RawCypher
182+
---------
183+
184+
When you need more advanced ordering capabilities, for example to apply order to a transformed property, you can use the `RawCypher` method, like so::
185+
186+
from neomodel.sync_.match import RawCypher
187+
188+
class SoftwareDependency(AsyncStructuredNode):
189+
name = StringProperty()
190+
version = StringProperty()
191+
192+
SoftwareDependency(name="Package2", version="1.4.0").save()
193+
SoftwareDependency(name="Package3", version="2.5.5").save()
194+
195+
latest_dep = SoftwareDependency.nodes.order_by(
196+
RawCypher("toInteger(split($n.version, '.')[0]) DESC"),
197+
)
198+
199+
In the example above, note the `$n` placeholder in the `RawCypher` clause. This is a placeholder for the node being ordered (`SoftwareDependency` in this case).

0 commit comments

Comments
 (0)