Asynchronous Python OGM for Neo4j
Project description
PyNeo4j-OGM
pyneo4j-ogm
is a asynchronous Object-Graph-Mapper
for Neo4j 5+
and Python 3.10+
. It is inspired by beanie
and build on top of proven technologies like Pydantic 1.10+
and the Neo4j Python Driver
. It saves you from writing ever-repeating boilerplate queries and allows you to focus on the stuff that actually matters
. It is designed to be simple and easy to use, but also flexible and powerful.
📣 Upcoming features
What's planned for future releases.
- Migration CLI for migrating your pyneo4j-ogm models
- Bookmark support
🎯 Features
- Simple and easy to use: pyneo4j-ogm is designed to be
simple and easy to use
, while also providing a solid foundation for some moreadvanced use-cases
. - Flexible and powerful: pyneo4j-ogm is flexible and powerful. It allows you to do all sorts of things with your data, from
simple CRUD
operations tocomplex queries
. - Fully asynchronous: pyneo4j-ogm is
fully asynchronous
and uses theNeo4j Python Driver
under the hood. - Supports Neo4j 5+: pyneo4j-ogm supports
Neo4j 5+
and is tested against the latest version of Neo4j. - Fully typed: pyneo4j-ogm is
fully typed
out of the box. - Powered by Pydantic: pyneo4j-ogm is powered by
Pydantic
and uses it's powerful validation and serialization features under the hood.
📦 Installation
Using pip
:
pip install pyneo4j-ogm
or when using Poetry
:
poetry add pyneo4j-ogm
🚀 Quickstart
Before we can jump right in, we have to take care of some things:
- We need to define our models which will represent our nodes and relationships.
- We need to initialize a
Pyneo4jClient
instance that will be used to interact with the database.
So let's start with the first one. We are going to define a few models that will represent our nodes and relationships
inside the graph. For this example, we will look at Developers
and their Coffee
consumption:
from pyneo4j_ogm import (
NodeModel,
RelationshipModel,
RelationshipProperty,
RelationshipPropertyCardinality,
RelationshipPropertyDirection,
WithOptions,
)
from pydantic import Field
from uuid import UUID, uuid4
class Developer(NodeModel):
"""
This class represents a `Developer` node inside the graph. All interaction
with nodes of this type will be handled by this class.
"""
uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)
name: str
age: int
coffee: RelationshipProperty["Coffee", "Consumed"] = RelationshipProperty(
target_model="Coffee",
relationship_model="Consumed",
direction=RelationshipPropertyDirection.OUTGOING,
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
allow_multiple=True,
)
class Settings:
# Hooks are available for all methods that interact with the database.
post_hooks = {
"coffee.connect": lambda self, *args, **kwargs: print(f"{self.name} chugged another one!")
}
class Coffee(NodeModel):
"""
This class represents a node with the labels `Beverage` and `Hot`. Notice
that the labels of this model are explicitly defined in the `Settings` class.
"""
flavor: str
sugar: bool
milk: bool
developers: RelationshipProperty["Developer", "Consumed"] = RelationshipProperty(
target_model=Developer,
relationship_model="Consumed",
direction=RelationshipPropertyDirection.INCOMING,
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
allow_multiple=True,
)
class Settings:
labels = {"Beverage", "Hot"}
class Consumed(RelationshipModel):
"""
Unlike the models above, this class represents a relationship between two
nodes. In this case, it represents the relationship between the `Developer`
and `Coffee` models. Like with node-models, the `Settings` class allows us to
define some settings for this relationship.
Note that the relationship itself does not define it's start- and end-nodes,
making it reusable for other models as well.
"""
liked: bool
class Settings:
type = "CHUGGED"
The models above are pretty straight forward. They are basically just Pydantic
models with some sugar on top, though there are some special things to note:
- We are defining some model-specific settings inside the
Settings
class. These settings are used bypyneo4j-ogm
to determine how to handle the model. For example, thelabels
setting of theCoffee
model tellspyneo4j-ogm
that this model should have the labelsBeverage
andHot
inside the graph. Thetype
setting of theConsumed
model tellspyneo4j-ogm
that this relationship should have the typeCHUGGED
inside the graph. - We are defining a
post_hook
for thecoffee
relationship of theDeveloper
model. This hook will be called whenever aCoffee
node is connected to aDeveloper
node via thecoffee
relationship-property. - We are defining a
uniqueness constraint
for theuid
field of theDeveloper
model. This will create a uniqueness constraint inside the graph for theuid
field of theDeveloper
model. This means that there can only be oneDeveloper
node with a specificuid
inside the graph.
Now that we have our models defined, we can initialize a Pyneo4jClient
instance that will be used to interact with the database. The client will handle most of the heavy lifting for us and our models, so let's initialize a new one and connect to the database:
from pyneo4j_ogm import Pyneo4jClient
async def main():
# We initialize a new `Pyneo4jClient` instance and connect to the database.
client = Pyneo4jClient()
await client.connect(uri="<connection-uri-to-database>", auth=("<username>", "<password>"))
# To use our models for running queries later on, we have to register
# them with the client.
# **Note**: You only have to register the models that you want to use
# for queries and you can even skip this step if you want to use the
# `Pyneo4jClient` instance for running raw queries.
await client.register_models([Developer, Coffee, Consumed])
Now we are ready to do some fun stuff with our models! For the sake of this quickstart guide
we are going to keep it nice and simple, since a full-blown example would become way to long. So let's create a new Developer
and some Coffee
and give our developer something to drink!
# Imagine your models have been defined above...
async def main():
# And your client has been initialized and connected to the database...
# We create a new `Developer` node and the `Coffee` he is going to drink.
john = Developer(name="John", age=25)
await john.create()
cappuccino = Coffee(flavor="Cappuccino", milk=True, sugar=False)
await cappuccino.create()
# Here we create a new relationship between `john` and his `cappuccino`.
# Additionally, we set the `liked` property of the relationship to `True`.
await john.coffee.connect(cappuccino, {"liked": True}) # Will print `John chugged another one!`
Full quickstart example
Now all you have to do is to start a Neo4j instance somewhere and get to work! We can put all of the steps together and end up with something like the code below:
import asyncio
from pyneo4j_ogm import (
NodeModel,
Pyneo4jClient,
RelationshipModel,
RelationshipProperty,
RelationshipPropertyCardinality,
RelationshipPropertyDirection,
WithOptions,
)
from pydantic import Field
from uuid import UUID, uuid4
class Developer(NodeModel):
"""
This class represents a `Developer` node inside the graph. All interaction
with nodes of this type will be handled by this class.
"""
uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)
name: str
age: int
coffee: RelationshipProperty["Coffee", "Consumed"] = RelationshipProperty(
target_model="Coffee",
relationship_model="Consumed",
direction=RelationshipPropertyDirection.OUTGOING,
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
allow_multiple=True,
)
class Settings:
# Hooks are available for all methods that interact with the database.
post_hooks = {
"coffee.connect": lambda self, *args, **kwargs: print(f"{self.name} chugged another one!")
}
class Coffee(NodeModel):
"""
This class represents a node with the labels `Beverage` and `Hot`. Notice
that the labels of this model are explicitly defined in the `Settings` class.
"""
flavor: str
sugar: bool
milk: bool
developers: RelationshipProperty["Developer", "Consumed"] = RelationshipProperty(
target_model=Developer,
relationship_model="Consumed",
direction=RelationshipPropertyDirection.INCOMING,
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
allow_multiple=True,
)
class Settings:
labels = {"Beverage", "Hot"}
class Consumed(RelationshipModel):
"""
Unlike the models above, this class represents a relationship between two
nodes. In this case, it represents the relationship between the `Developer`
and `Coffee` models. Like with node-models, the `Settings` class allows us to
define some settings for this relationship.
Note that the relationship itself does not define it's start- and end-nodes,
making it reusable for other models as well.
"""
liked: bool
class Settings:
type = "CHUGGED"
async def main():
# We initialize a new `Pyneo4jClient` instance and connect to the database.
client = Pyneo4jClient()
await client.connect(uri="<connection-uri-to-database>", auth=("<username>", "<password>"))
# To use our models for running queries later on, we have to register
# them with the client.
# **Note**: You only have to register the models that you want to use
# for queries and you can even skip this step if you want to use the
# `Pyneo4jClient` instance for running raw queries.
await client.register_models([Developer, Coffee, Consumed])
# We create a new `Developer` node and the `Coffee` he is going to drink.
john = Developer(name="John", age=25)
await john.create()
cappuccino = Coffee(flavor="Cappuccino", milk=True, sugar=False)
await cappuccino.create()
# Here we create a new relationship between `john` and his `cappuccino`.
# Additionally, we set the `liked` property of the relationship to `True`.
await john.coffee.connect(cappuccino, {"liked": True}) # Will print `John chugged another one!`
# Be a good boy and close your connections after you are done.
await client.close()
asyncio.run(main())
Note: This script should run
as is
. You must change theuri
andauth
parameters in theconnect()
method call to match the one's you need and have a running Neo4j instance before starting the script.
This just scratches the surface of what pyneo4j-ogm
can do. If you want to learn more about the library, you can check out the full Documentation
.
📚 Documentation
In the following we are going to take a closer look at the different parts of pyneo4j-ogm
and how to use them. We will cover everything pyneo4j-ogm has to offer, from the Pyneo4jClient
to the NodeModel
and RelationshipModel
classes all the way to the Query filters
and Auto-fetching relationship-properties
.
Table of contents
- PyNeo4j-OGM
- 📣 Upcoming features
- 🎯 Features
- 📦 Installation
- 🚀 Quickstart
- 📚 Documentation
- Table of contents
- Basic concepts
- Pyneo4jClient
- Models
- Indexes, constraints and properties
- Reserved properties
- Configuration settings
- Available methods
- Instance.update()
- Instance.delete()
- Instance.refresh()
- Model.find_one()
- Model.find_many()
- Model.update_one()
- Model.update_many()
- Model.delete_one()
- Model.delete_many()
- Model.count()
- NodeModelInstance.create()
- NodeModelInstance.find_connected_nodes()
- RelationshipModelInstance.start_node()
- RelationshipModelInstance.end_node()
- Importing/exporting models
- Hooks
- Model settings
- Relationship-properties
- Queries
- Logging
Basic concepts
As you might have guessed by now, pyneo4j-ogm
is a library that allows you to interact with a Neo4j database using Python. It is designed to make your life as simple as possible while also providing a solid foundation for some more advanced use-cases.
The basic concept boils down to the following:
- You define your models that represent your nodes and relationships inside the graph.
- You use these models to do all sorts of things with your data.
Of course, there is a lot more to it than that, but this is the basic idea. So let's take a closer look at the different parts of pyneo4j-ogm
and how to use them.
Note: All of the examples in this documentation assume that you have already connected to a database and registered your models with the client like shown in the
quickstart guide
. The models used in the following examples will build upon the ones defined there. If you are new toNeo4j
orCypher
in general, you should get a basic understanding of how to use them before continuing.
Pyneo4jClient
This is where all the magic happens! The Pyneo4jClient
is the main entry point for interacting with the database. It handles all the heavy lifting for you and your models, so you can focus on the stuff that actually matters. Since it is the brains of the operation, we have to initialize it before we can do anything else.
Connecting to the database
Before you can run any queries, you have to connect to a database. This is done by calling the connect()
method of the Pyneo4jClient
instance. The connect()
method takes a few arguments:
uri
: The connection URI to the database.skip_constraints
: Whether the client should skip creating any constraints defined on models when registering them. Defaults toFalse
.skip_indexes
: Whether the client should skip creating any indexes defined on models when registering them. Defaults toFalse
.*args
: Additional arguments that are passed directly to Neo4j'sAsyncDriver.driver()
method.**kwargs
: Additional keyword arguments that are passed directly to Neo4j'sAsyncDriver.driver()
method.
The connect()
method returns the client instance itself, so you can chain it right after the instantiation of the class. Here is an example of how to connect to a database:
from pyneo4j_ogm import Pyneo4jClient
client = Pyneo4jClient()
await client.connect(uri="<connection-uri-to-database>", auth=("<username>", "<password>"), max_connection_pool_size=10, ...)
# Or chained right after the instantiation of the class
client = await Pyneo4jClient().connect(uri="<connection-uri-to-database>", auth=("<username>", "<password>"), max_connection_pool_size=10, ...)
After connecting the client, you will be able to run any cypher queries against the database. Should you try to run a query without connecting to a database first, you will get a NotConnectedToDatabase
exception.
Closing an existing connection
Once you are done working with a database and the client is no longer needed, you can close the connection to it by calling the close()
method on the client instance. This will close the connection to the database and free up any resources used by the client. Remember to always close your connections when you are done with them, otherwise Santa won't bring you any presents!
# Do some heavy-duty work...
# Finally done, so we close the connection to the database.
await client.close()
Once you closed the client, it will be seen as disconnected
and if you try to run any further queries with it, you will get a NotConnectedToDatabase
exception.
Registering models
As mentioned before, the basic concept is to work with models which reflect the nodes and relationships inside the graph. In order to work with these models, you have to register them with the client. You can do this by calling the register_models()
method on the client and passing in your models as a list. Let's take a look at an example:
# Create a new client instance and connect ...
await client.register_models([Developer, Coffee, Consumed])
This is a crucial step, because if you don't register your models with the client, you won't be able to work with them in any way. Should you try to work with a model that has not been registered, you will get a UnregisteredModel
exception. This exception also gets raised if a database model defines a relationship-property with other (unregistered) models as a target or relationship model and then runs a query with said relationship-property. For more information about relationship-properties, see the Relationship-properties
section.
If you have defined any indexes or constraints on your models, they will be created automatically when registering them. You can prevent this behavior by passing skip_constraints=True
or skip_indexes=True
to the connect()
method. If you do this, you will have to create the indexes and constraints yourself.
If you don't register your models with the client, you will still be able to run cypher queries directly with the client, but you will lose automatic model resolution
from queries. This means that, instead of resolved models, the raw Neo4j query results are returned.
Executing Cypher queries
Node- and RelationshipModels provide many methods for commonly used cypher queries, but sometimes you might want to execute a custom cypher with more complex logic. For this purpose, the client instance provides a cypher()
method that allows you to execute custom cypher queries. The cypher()
method takes three arguments:
query
: The cypher query to execute.parameters
: A dictionary containing the parameters to pass to the query.resolve_models
: Whether the client should try to resolve the models from the query results. Defaults toTrue
.
This method will always return a tuple containing a list of results and a list of variables returned by the query. Internally, the client uses the .values()
method of the Neo4j driver to get the results of the query.
Note: If no models have been registered with the client and resolve_models is set to True, the client will not raise any exceptions but rather return the raw query results.
Here is an example of how to execute a custom cypher query:
results, meta = await client.cypher(
query="CREATE (d:Developer {uid: '553ac2c9-7b2d-404e-8271-40426ae80de0', name: 'John', age: 25}) RETURN d.name as developer_name, d.age",
parameters={"name": "John Doe"},
resolve_models=False, # Explicitly disable model resolution
)
print(results) # [["John", 25]]
print(meta) # ["developer_name", "d.age"]
Batching cypher queries
Since Neo4j is ACID compliant, it is possible to batch multiple cypher queries into a single transaction. This can be useful if you want to execute multiple queries at once and make sure that either all of them succeed or none of them do. The client provides a batch()
method that allows you to batch multiple cypher queries into a single transaction. The batch()
method has to be called with a asynchronous context manager like in the following example:
async with client.batch():
# All queries executed inside the context manager will be batched into a single transaction
# and executed once the context manager exits. If any of the queries fail, the whole transaction
# will be rolled back.
await client.cypher(
query="CREATE (d:Developer {uid: $uid, name: $name, age: $age})",
parameters={"uid": "553ac2c9-7b2d-404e-8271-40426ae80de0", "name": "John Doe", "age": 25},
)
await client.cypher(
query="CREATE (c:Coffee {flavour: $flavour, milk: $milk, sugar: $sugar})",
parameters={"flavour": "Espresso", "milk": False, "sugar": False},
)
# Model queries also can be batched together without any extra work!
coffee = await Coffee(flavour="Americano", milk=False, sugar=False).create()
You can batch anything that runs a query, regardless of whether it is a raw cypher query, a model query or a relationship-property query. If any of the queries fail, the whole transaction will be rolled back and an exception will be raised.
Manual indexing and constraints
By default, the client will automatically create any indexes and constraints defined on models when registering them. If you want to disable this behavior, you can do so by passing the skip_indexes
and skip_constraints
arguments to the connect()
method when connecting your client to a database.
If you want to create custom indexes and constraints, or want to add additional indexes/constraints later on (which should probably be done on the models themselves), you can do so by calling the create_index()
and create_constraint()
methods on the client.
First, let's take a look at how to create a custom index in the database. The create_index()
method takes a few arguments:
name
: The name of the index to create (Make sure this is unique!).entity_type
: The entity type the index is created for. Can be either EntityType.NODE or EntityType.RELATIONSHIP.index_type
: The type of index to create. Can beIndexType.RANGE
,IndexType.TEXT
,IndexType.POINT
orIndexType.LOOKUP
.properties
: A list of properties to create the index for.labels_or_type
: The node labels or relationship type the index is created for.
The create_constraint()
method also takes similar arguments as the create_index()
method. The only difference is that it does not take a constraint_type
argument since only UNIQUENESS
constraints are currently supported.
name
: The name of the constraint to create.entity_type
: The entity type the constraint is created for. Can be either EntityType.NODE or EntityType.RELATIONSHIP.properties
: A list of properties to create the constraint for.labels_or_type
: The node labels or relationship type the constraint is created for.
Here is an example of how to use the methods:
# Creates a `RANGE` index for a `Coffee's` `sugar` and `flavour` properties
await client.create_index("hot_beverage_index", EntityType.NODE, IndexType.RANGE, ["sugar", "flavour"], ["Beverage", "Hot"])
# Creates a UNIQUENESS constraint for a `Developer's` `uid` property
await client.create_constraint("developer_constraint", EntityType.NODE, ["uid"], ["Developer"])
Client utilities
The database client also provides a few utility methods and properties that can be useful when writing automated scripts or tests. These methods are:
is_connected()
: Returns whether the client is currently connected to a database.drop_nodes()
: Drops all nodes from the database.drop_constraints()
: Drops all constraints from the database.drop_indexes()
: Drops all indexes from the database.
Models
As shown in the quickstart guide
, models are the main building blocks of pyneo4j-ogm
. They represent the nodes and relationships inside the graph and provide a lot of useful methods for interacting with them.
A core mechanic of pyneo4j-ogm
is serialization and deserialization of models. Every model method uses this mechanic under the hood to convert the models to and from the format used by the Neo4j driver.
Note: The serialization and deserialization of models is handled automatically by
pyneo4j-ogm
and you don't have to worry about it.
This is necessary because the Neo4j driver can only handle certain data types, which means models with custom or complex data types have to be serialized before they can be saved to the database. Additionally, Neo4j itself does not support nested data structures. To combat this, nested dictionaries and Pydantic models are serialized to a JSON string before being saved to the database. But this causes some new issues, especially when trying to use dictionaries or Pydantic models as properties on a model. Since pyneo4j-ogm
can't know whether a dictionary or Pydantic model is supposed to be serialized or not, it will just not accept lists with dictionaries or Pydantic models as properties on a model.
Filters for nested properties are also not supported, since they are stored as strings inside the database. This means that you can't use filters on nested properties when running queries with models. If you want to use filters on nested properties, you will to run a complex regular expression query.
Indexes, constraints and properties
Since pyneo4j-ogm
is built on top of Pydantic
, all of the features provided by Pydantic
are available to you. This includes defining properties
on your models. For more information about these features, please refer to the Pydantic documentation
.
On the other hand, indexes and constraints
are handled solely by pyneo4j-ogm
. You can define indexes and constraints on your models by using the WithOptions
method wrapped around the type of the property. You can pass the following arguments to the WithOptions
method:
property_type
: The datatype of the property. Must be a validPydantic
type.range_index
: Whether to create a range index on the property. Defaults toFalse
.text_index
: Whether to create a text index on the property. Defaults toFalse
.point_index
: Whether to create a point index on the property. Defaults toFalse
.unique
: Whether to create a uniqueness constraint on the property. Defaults toFalse
.
Note: Using the
WithOptions
without any index or constraint options will behave just like it was never there (but in that case you should probably just remove it).
from pyneo4j_ogm import NodeModel, WithOptions
from pydantic import Field
from uuid import UUID, uuid4
class Developer(NodeModel):
"""
A model representing a developer node in the graph.
"""
# Using the `WithOptions` method on the type, we can still use all of the features provided by
# `Pydantic` while also defining indexes and constraints on the property.
uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)
name: WithOptions(str, text_index=True)
# Has no effect, since no index or constraint options are passed
age: WithOptions(int)
Reserved properties
Node- and RelationshipModels have a few pre-defined properties which reflect the entity inside the graph and are used internally in queries. These properties are:
element_id
: The element id of the entity inside the graph. This property is used internally to identify the entity inside the graph.id
: The id of the entity inside the graph.modified_properties
: A set of properties which have been modified on the
The RelationshipModel
class has some additional properties:
start_node_element_id
: The element id of the start node of the relationship.start_node_id
: The ID of the start node of the relationship.end_node_element_id
: The element id of the end node of the relationship.end_node_id
: The ID of the end node of the relationship.
Configuration settings
Both NodeModel
and RelationshipModel
provide a few properties that can be configured. In this section we are going to take a closer look at how to configure your models and what options are available to you.
Model configuration is done by defining a inner Settings
class inside the model itself. The properties of this class control how the model is handled by pyneo4j-ogm
:
class Coffee(NodeModel):
flavour: str
sugar: bool
milk: bool
class Settings:
# This is the place where the magic happens!
There also is a special type of property called RelationshipProperty
. This property can be used to define relationships between models. For more information about this property, see the Relationship-properties
section.
NodeModel configuration
The Settings
class of a NodeModel
provides the following properties:
Setting name | Type | Description |
---|---|---|
exclude_from_export |
Set[str] | Can be used to always exclude a property from being exported when using the export_model() method on a model. Any RelationshipProperty properties on a model are automatically added . Defaults to set() . |
pre_hooks |
Dict[str, List[Callable]] | A dictionary where the key is the name of the method for which to register the hook and the value is a list of hook functions. The hook function can be synchronous or asynchronous. All hook functions receive the exact same arguments as the method they are registered for and the current model instance as the first argument. Defaults to {} . |
post_hooks |
Dict[str, List[Callable]] | Same as pre_hooks, but the hook functions are executed after the method they are registered for. Additionally, the result of the method is passed to the hook as the second argument. Defaults to {} . |
labels |
Set[str] | A set of labels to use for the node. If no labels are defined, the name of the model will be used as the label. Defaults to the model name split by it's words . |
auto_fetch_nodes |
bool | Whether to automatically fetch nodes of defined relationship-properties when getting a model instance from the database. Auto-fetched nodes are available at the instance.<relationship-property>.nodes property. If no specific models are passed to a method when this setting is set to True , nodes from all defined relationship-properties are fetched. Defaults to False . |
Note: Hooks can be defined for all methods that interact with the database. When defining a hook for a method on a relationship-property, you have to pass a string in the format
<relationship-property>.<method>
as the key. For example, if you want to define a hook for theconnect()
method of a relationship-property namedcoffee
, you would have to passcoffee.connect
as the key.
RelationshipModel configuration
For RelationshipModels, the labels
setting is not available, since relationships don't have labels in Neo4j. Instead, the type
setting can be used to define the type of the relationship. If no type is defined, the name of the model name will be used as the type.
Setting name | Type | Description |
---|---|---|
exclude_from_export |
Set[str] | Can be used to always exclude a property from being exported when using the export_model() method on a model. Any RelationshipProperty properties on a model are automatically added . Defaults to set() . |
pre_hooks |
Dict[str, List[Callable]] | A dictionary where the key is the name of the method for which to register the hook and the value is a list of hook functions. The hook function can be synchronous or asynchronous. All hook functions receive the exact same arguments as the method they are registered for and the current model instance as the first argument. Defaults to {} . |
post_hooks |
Dict[str, List[Callable]] | Same as pre_hooks, but the hook functions are executed after the method they are registered for. Additionally, the result of the method is passed to the hook as the second argument. Defaults to {} . |
type |
str | The type of the relationship to use. If no type is defined, the model name will be used as the type. Defaults to the model name in all uppercase . |
Available methods
Running cypher queries manually is nice, but code running them for you is even better. That's exactly what the model methods are for. They allow you to do all sorts of things with your models and the nodes and relationships they represent. In this section we are going to take a closer look at the different methods available to you.
But before we jump in, let's get one thing out of the way: All of the methods described in this section are asynchronous
methods. This means that they have to be awaited when called. If you are new to asynchronous programming in Python, you should take a look at the asyncio documentation
before continuing.
Additionally, the name of the heading for each method defines what type of model it is available on and whether it is a class method
or an instance method
.
Model.method()
: Theclass method
is available on instances of bothNodeModel
andRelationshipModel
classes.Instance.method()
: Theinstance method
is available on instances of bothNodeModel
andRelationshipModel
classes.<Type>Model.method()
: Theclass method
is available on instances of the<Type>Model
class.<Type>ModelInstance.method()
: Theinstance method
is available on instances of the<Type>Model
class.
Instance.update()
The update()
method can be used to sync the modified properties of a node or relationship-model with the corresponding entity inside the graph. All models also provide a property called modified_properties
that contains a set of all properties that have been modified since the model was created, fetched or synced with the database. This property is used by the update()
method to determine which properties to sync with the database.
# In this context, the developer `john` has been created before and the `name` property has been
# not been changed since.
# Maybe we want to name him James instead?
john.name = "James"
print(john.modified_properties) # {"name"}
# Will update the `name` property of the `john` node inside the graph
# And suddenly he is James!
await john.update()
Instance.delete()
The delete()
method can be used to delete the graph entity tied to the current model instance. Once deleted, the model instance will be marked as destroyed
and any further operations on it will raise a InstanceDestroyed
exception.
# In this context, the developer `john` has been created before and is seen as `hydrated` (aka it
# has been saved to the database before).
# This will delete the `john` node inside the graph and mark your local instance as `destroyed`.
await john.delete()
await john.update() # Raises `InstanceDestroyed` exception
Instance.refresh()
Syncs your local instance with the properties from the corresponding graph entity. ´This method can be useful if you want to make sure that your local instance is always up-to-date with the graph entity.
It is recommended to always call this method when importing a model instance from a dictionary (but does not have to be called necessarily, which in turn could cause a data inconsistency locally, so be careful when!).
# Maybe we want to name him James instead?
john.name = "James"
# Oh no, don't take my `john` away!
await john.refresh()
print(john.name) # 'John'
Model.find_one()
The find_one()
method can be used to find a single node or relationship in the graph. If multiple results are matched, the first one is returned. This method returns a single instance/dictionary or None
if no results were found.
This method takes a mandatory filters
argument, which is used to filter the results. For more about filters, see the Filtering queries
section.
# Return the first encountered node where the name property equals `John`.
# This method always needs a filter to go with it!
john_or_nothing = await Developer.find_one({"name": "John"})
print(developer) # <Developer> or None
Projections
Projections
can be used to only return specific parts of the model as a dictionary. This can help to reduce bandwidth or to just pre-filter the query results to a more suitable format. For more about projections, see Projections
# Return a dictionary with the developers name at the `dev_name` key instead
# of a model instance.
developer = await Developer.find_one({"name": "John"}, {"dev_name": "name"})
print(developer) # {"dev_name": "John"}
Auto-fetching nodes
The auto_fetch_nodes
and auto_fetch_models
arguments can be used to automatically fetch all or selected nodes from defined relationship-properties when running the find_one()
query. The pre-fetched nodes are available on their respective relationship-properties. For more about auto-fetching, see Auto-fetching relationship-properties
.
Note: The
auto_fetch_nodes
andauto_fetch_models
parameters are only available for classes which inherit from theNodeModel
class.
# Returns a developer instance with `instance.<property>.nodes` properties already fetched
developer = await Developer.find_one({"name": "John"}, auto_fetch_nodes=True)
print(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developer.other_property.nodes) # [<OtherModel>, <OtherModel>, ...]
# Returns a developer instance with only the `instance.coffee.nodes` property already fetched
developer = await Developer.find_one({"name": "John"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee])
# Auto-fetch models can also be passed as strings
developer = await Developer.find_one({"name": "John"}, auto_fetch_nodes=True, auto_fetch_models=["Coffee"])
print(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developer.other_property.nodes) # []
Raise on empty result
By default, the find_one()
method will return None
if no results were found. If you want to raise an exception instead, you can pass raise_on_empty=True
to the method.
# Raises a `NoResultFound` exception if no results were found
developer = await Developer.find_one({"name": "John"}, raise_on_empty=True)
Model.find_many()
The find_many()
method can be used to find multiple nodes or relationships in the graph. This method always returns a list of instances/dictionaries or an empty list if no results were found.
# Returns ALL `Developer` nodes
developers = await Developer.find_many()
print(developers) # [<Developer>, <Developer>, <Developer>, ...]
Filters
Just like the find_one()
method, the find_many()
method also takes (optional) filters. For more about filters, see the Filtering queries
section.
# Returns all `Developer` nodes where the age property is greater than or
# equal to 21 and less than 45.
developers = await Developer.find_many({"age": {"$and": [{"$gte": 21}, {"$lt": 45}]}})
print(developers) # [<Developer>, <Developer>, <Developer>, ...]
Projections
Projections
can be used to only return specific parts of the models as dictionaries. For more information about projections, see the Projections
section.
# Returns dictionaries with the developers name at the `dev_name` key instead
# of model instances
developers = await Developer.find_many({"name": "John"}, {"dev_name": "name"})
print(developers) # [{"dev_name": "John"}, {"dev_name": "John"}, ...]
Query options
Query options
can be used to define how results are returned from the query. They are provided via the options
argument. For more about query options, see the Query options
section.
# Skips the first 10 results and returns the next 20
developers = await Developer.find_many({"name": "John"}, options={"limit": 20, "skip": 10})
print(developers) # [<Developer>, <Developer>, ...] up to 20 results
Auto-fetching nodes
The auto_fetch_nodes
and auto_fetch_models
parameters can be used to automatically fetch all or selected nodes from defined relationship-properties when running the find_many()
query. For more about auto-fetching, see Auto-fetching relationship-properties
.
Note: The
auto_fetch_nodes
andauto_fetch_models
parameters are only available for classes which inherit from theNodeModel
class.
# Returns developer instances with `instance.<property>.nodes` properties already fetched
developers = await Developer.find_many({"name": "John"}, auto_fetch_nodes=True)
print(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developers[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]
# Returns developer instances with only the `instance.coffee.nodes` property already fetched
developers = await Developer.find_many({"name": "John"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee])
# Auto-fetch models can also be passed as strings
developers = await Developer.find_many({"name": "John"}, auto_fetch_nodes=True, auto_fetch_models=["Coffee"])
print(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developers[0].other_property.nodes) # []
Model.update_one()
The update_one()
method finds the first matching graph entity and updates it with the provided properties. If no match was found, nothing is updated and None
is returned. Properties provided in the update parameter, which have not been defined on the model, will be ignored.
This method takes two mandatory arguments:
update
: A dictionary containing the properties to update.filters
: A dictionary containing the filters to use when searching for a match. For more about filters, see theFiltering queries
section.
# Updates the `age` property to `30` in the first encountered node where the name property equals `John`
# The `i_do_not_exist` property will be ignored since it has not been defined on the model
developer = await Developer.update_one({"age": 30, "i_do_not_exist": True}, {"name": "John"})
print(developer) # <Developer age=25>
# Or if no match was found
print(developer) # None
Returning the updated entity
By default, the update_one()
method returns the model instance before the update. If you want to return the updated model instance instead, you can do so by passing the new
parameter to the method and setting it to True
.
# Updates the `age` property to `30` in the first encountered node where the name property equals `John`
# and returns the updated node
developer = await Developer.update_one({"age": 30}, {"name": "John"}, True)
print(developer) # <Developer age=30>
Raise on empty result
By default, the update_one()
method will return None
if no results were found. If you want to raise an exception instead, you can pass raise_on_empty=True
to the method.
# Raises a `NoResultFound` exception if no results were matched
developer = await Developer.update_one({"age": 30}, {"name": "John"}, raise_on_empty=True)
Model.update_many()
The update_many()
method finds all matching graph entity and updates them with the provided properties. If no match was found, nothing is updated and a empty list
is returned. Properties provided in the update parameter, which have not been defined on the model, will be ignored.
This method takes one mandatory argument update
which defines which properties to update with which values.
# Updates the `age` property of all `Developer` nodes to 40
developers = await Developer.update_many({"age": 40})
print(developers) # [<Developer age=25>, <Developer age=23>, ...]
# Or if no matches were found
print(developers) # []
Filters
Optionally, a filters
argument can be provided, which defines which entities to update. For more about filters, see the Filtering queries
section.
# Updates all `Developer` nodes where the age property is between `22` and `30`
# to `40`
developers = await Developer.update_many({"age": 40}, {"age": {"$gte": 22, "$lte": 30}})
print(developers) # [<Developer age=25>, <Developer age=23>, ...]
Returning the updated entity
By default, the update_many()
method returns the model instances before the update. If you want to return the updated model instances instead, you can do so by passing the new
parameter to the method and setting it to True
.
# Updates all `Developer` nodes where the age property is between `22` and `30`
# to `40` and return the updated nodes
developers = await Developer.update_many({"age": 40}, {"age": {"$gte": 22, "$lte": 30}})
print(developers) # [<Developer age=40>, <Developer age=40>, ...]
Model.delete_one()
The delete_one()
method finds the first matching graph entity and deletes it. Unlike others, this method returns the number of deleted entities instead of the deleted entity itself. If no match was found, nothing is deleted and 0
is returned.
This method takes one mandatory argument filters
which defines which entity to delete. For more about filters, see the Filtering queries
section.
# Deletes the first `Developer` node where the name property equals `John`
count = await Developer.delete_one({"name": "John"})
print(count) # 1
# Or if no match was found
print(count) # 0
Raise on empty result
By default, the delete_one()
method will return None
if no results were found. If you want to raise an exception instead, you can pass raise_on_empty=True
to the method.
# Raises a `NoResultFound` exception if no results were matched
count = await Developer.delete_one({"name": "John"}, raise_on_empty=True)
Model.delete_many()
The delete_many()
method finds all matching graph entity and deletes them. Like the delete_one()
method, this method returns the number of deleted entities instead of the deleted entity itself. If no match was found, nothing is deleted and 0
is returned.
# Deletes all `Developer` nodes
count = await Developer.delete_many()
print(count) # However many nodes matched the filter
# Or if no match was found
print(count) # 0
Optionally, a filters
argument can be provided, which defines which entities to delete. For more about filters, see the Filtering queries
section.
# Deletes all `Developer` nodes where the age property is greater than `65`
count = await Developer.delete_many({"age": {"$gt": 65}})
print(count) # However many nodes matched the filter
Model.count()
The count()
method returns the total number of entities of this model in the graph.
# Returns the total number of `Developer` nodes inside the database
count = await Developer.count()
print(count) # However many nodes matched the filter
# Or if no match was found
print(count) # 0
Filters
Optionally, a filters
argument can be provided, which defines which entities to count. For more about filters, see the Filtering queries
section.
# Counts all `Developer` nodes where the name property contains the letters `oH`
# The `i` in `icontains` means that the filter is case insensitive
count = await Developer.count({"name": {"$icontains": "oH"}})
print(count) # However many nodes matched the filter
NodeModelInstance.create()
Note: This method is only available for classes inheriting from the
NodeModel
class.
The create()
method allows you to create a new node from a given model instance. All properties defined on the instance will be carried over to the corresponding node inside the graph. After this method has successfully finished, the instance saved to the database will be seen as hydrated
and other methods such as update()
, refresh()
, etc. will be available.
# Creates a node inside the graph with the properties and labels
# from the model below
developer = Developer(name="John", age=24)
await developer.create()
print(developer) # <Developer uid="..." age=24, name="John">
NodeModelInstance.find_connected_nodes()
Note: This method is only available for classes inheriting from the
NodeModel
class.
The find_connected_nodes()
method can be used to find nodes over multiple hops. It returns all matched nodes with the defined labels in the given hop range or an empty list if no nodes where found. The method requires you to define the labels of the nodes you want to find inside the filters (You can only define the labels of one model
at a time). For more about filters, see the Filtering queries
section.
# Picture a structure like this inside the graph:
# (:Producer)-[:SELLS_TO]->(:Barista)-[:PRODUCES {with_love: bool}]->(:Coffee)-[:CONSUMED_BY]->(:Developer)
# If we want to get all `Developer` nodes connected to a `Producer` node over the `Barista` and `Coffee` nodes,
# where the `Barista` created the coffee with love, we can do so like this:
producer = await Producer.find_one({"name": "Coffee Inc."})
if producer is None:
# No producer found, do something else
developers = await producer.find_connected_nodes({
"$node": {
"$labels": ["Developer", "Python"],
# You can use all available filters here as well
},
# You can define filters on specific relationships inside the path
"$relationships": [
{
# Here we define a filter for all `PRODUCES` relationships
# Only nodes where the with_love property is set to `True` will be returned
"$type": "PRODUCES",
"with_love": True
}
]
})
print(developers) # [<Developer>, <Developer>, ...]
# Or if no matches were found
print(developers) # []
Projections
Projections
can be used to only return specific parts of the models as dictionaries. For more information about projections, see the Projections
section.
# Returns dictionaries with the developers name at the `dev_name` key instead
# of model instances
developers = await producer.find_connected_nodes(
{
"$node": {
"$labels": ["Developer", "Python"],
},
"$relationships": [
{
"$type": "PRODUCES",
"with_love": True
}
]
},
{
"dev_name": "name"
}
)
print(developers) # [{"dev_name": "John"}, {"dev_name": "John"}, ...]
Query options
Query options
can be used to define how results are returned from the query. They are provided via the options
argument. For more about query options, see the Query options
section.
# Skips the first 10 results and returns the next 20
developers = await producer.find_connected_nodes(
{
"$node": {
"$labels": ["Developer", "Python"],
},
"$relationships": [
{
"$type": "PRODUCES",
"with_love": True
}
]
},
options={"limit": 20, "skip": 10}
)
print(developers) # [<Developer>, <Developer>, ...]
Auto-fetching nodes
The auto_fetch_nodes
and auto_fetch_models
parameters can be used to automatically fetch all or selected nodes from defined relationship-properties when running the find_connected_nodes()
query. For more about auto-fetching, see Auto-fetching relationship-properties
.
# Skips the first 10 results and returns the next 20
developers = await producer.find_connected_nodes(
{
"$node": {
"$labels": ["Developer", "Python"],
},
"$relationships": [
{
"$type": "PRODUCES",
"with_love": True
}
]
},
auto_fetch_nodes=True
)
print(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developers[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]
# Returns developer instances with only the `instance.coffee.nodes` property already fetched
developers = await producer.find_connected_nodes(
{
"$node": {
"$labels": ["Developer", "Python"],
},
"$relationships": [
{
"$type": "PRODUCES",
"with_love": True
}
]
},
auto_fetch_nodes=True,
auto_fetch_models=[Coffee]
)
developers = await producer.find_connected_nodes(
{
"$node": {
"$labels": ["Developer", "Python"],
},
"$relationships": [
{
"$type": "PRODUCES",
"with_love": True
}
]
},
auto_fetch_nodes=True,
auto_fetch_models=["Coffee"]
)
print(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developers[0].other_property.nodes) # []
RelationshipModelInstance.start_node()
Note: This method is only available for classes inheriting from the
RelationshipModel
class.
This method returns the start node of the current relationship instance. This method takes no arguments.
# The `coffee_relationship` variable is a relationship instance created somewhere above
start_node = await coffee_relationship.start_node()
print(start_node) # <Coffee>
RelationshipModelInstance.end_node()
Note: This method is only available for classes inheriting from the
RelationshipModel
class.
This method returns the end node of the current relationship instance. This method takes no arguments.
# The `coffee_relationship` variable is a relationship instance created somewhere above
end_node = await coffee_relationship.end_node()
print(end_node) # <Developer>
Importing/exporting models
When this library was created, one of the reasons for this implementation was easy usage with gRPC services and frontend applications. To make this as easy as possible, pyneo4j-ogm
provides a few methods to export and import models to and from dictionaries.
Since gRPC can only handle Python's standard types, the export_model()
method can be used to convert a model instance to a dictionary containing only standard types.
Because most frontend applications use camelCase
for their properties, the export_model()
method also provides an option to convert the keys of the dictionary to camelCase
when exporting it.
Instance.export_model()
Export the model instance to a dictionary containing standard python types. Since this method uses pydantic.BaseModel.model_dump_json()
internally, all arguments it accepts are also accepted by this method.
developer = await Developer(
name="John",
age=24
).create()
# Export the model instance to a dictionary
developer_dict = developer.export_model()
print(developer_dict) # {"element_id": "4:08f8a347-1856-487c-8705-26d2b4a69bb7:6", "name": "John", "age": 24}
Case conversion
By default, the export_model()
will just convert the model instance to a dictionary. If you want to convert the keys of the dictionary to camelCase
when exporting it, you can do so by passing the convert_to_camel_case
argument to the method and setting it to True
.
Note: This argument is very opinionated and you will probably not need it in most cases.
developer = await Developer(
name="John",
age=24,
some_multi_level_property=True
).create()
# Export the model instance to a dictionary with camelCase keys
developer_dict = developer.export_model(convert_to_camel_case=True)
print(developer_dict) # {"elementId": "4:08f8a347-1856-487c-8705-26d2b4a69bb7:6", "name": "John", "age": 24, "someMultiLevelProperty": True}
Model.import_model()
Since you can export a model instance to a dictionary, it is also possible to import a model instance from a dictionary. This can be useful if you want to import a model instance from JSON and use a method on it straight away.
Note: Since this method converts the provided dictionary directly to a database model, the
element_id
key must be provided in the dictionary. This is because the library needs to know which entity the model belongs to when importing it. If you don't want to provide theelement_id
key, you can use pydantic's native methods for importing/exporting models and then call theModel.find_one()
method.
developer_dict = {
"element_id": "4:08f8a347-1856-487c-8705-26d2b4a69bb7:6",
"name": "John",
"age": 24
}
# Import the model instance from the dictionary
# After this import, any method can be called on the instance
developer = await Developer.import_model(developer_dict)
print(developer) # <Developer element_id="4:08f8a347-1856-487c-8705-26d2b4a69bb7:6" name="John", age=24>
Case conversion
By default, the import_model()
will just try to import the model. If you converted the keys of the dictionary to camelCase
when exporting it or get them from a JSON response, you need to pass the convert_to_snake_case
argument as True
to let the library know about it.
developer_dict = {
"element_id": "4:08f8a347-1856-487c-8705-26d2b4a69bb7:6",
"name": "John",
"age": 24,
"someMultiLevelProperty": True
}
# Import the model instance from the dictionary
# After this import, any method can be called on the instance
developer = await Developer.import_model(developer_dict, convert_to_snake_case=True)
print(developer) # <Developer element_id="4:08f8a347-1856-487c-8705-26d2b4a69bb7:6" name="John", age=24, some_multi_level_property=True>
Hooks
Hooks are a convenient way to execute code before or after a method is called A pre-hook function always receives the class it is used on
as it's first argument and any arguments the decorated method receives
. They can be used to execute code that is not directly related to the method itself, but still needs to be executed when the method is called. This allows for all sorts of things, such as logging, caching, etc.
pyneo4j-ogm
provides a hooks for all available methods out of the box, and will even work for custom methods. Hooks are simply registered with the method name as the key and a list of hook functions as the value. The hook functions can be synchronous or asynchronous and will receive the exact same arguments as the method they are registered for and the current model instance as the first argument.
For relationship-properties, the key under which the hook is registered has to be in the format <relationship-property>.<method>
. For example, if you want to register a hook for the connect()
method of a relationship-property named coffee
, you would have to pass coffee.connect
as the key. Additionally, instead of the RelationshipProperty class context
, the hook function will receive the NodeModel class context
of the model it has been called on as the first argument.
Note: Custom methods to define the
hook decorator
on the method you want to register hooks for.
Pre-hooks
Pre-hooks are executed before the method they are registered for. They can be defined in the model's Settings
class under the pre_hooks
property or by calling the register_pre_hooks()
method on the model.
class Developer(NodeModel):
...
class Settings:
post_hooks = {
"coffee.connect": lambda self, *args, **kwargs: print(f"{self.name} chugged another one!")
}
# Or by calling the `register_pre_hooks()` method
# Here `hook_func` can be a synchronous or asynchronous function reference
Developer.register_pre_hooks("create", hook_func)
# By using the `register_pre_hooks()` method, you can also overwrite all previously registered hooks
# This will overwrite all previously registered hooks for the defined hook name
Developer.register_pre_hooks("create", hook_func, overwrite=True)
Post-hooks
Post-hooks are executed after the method they are registered for. They can be defined in the model's Settings
class under the post_hooks
property or by calling the register_post_hooks()
method on the model.
In addition to the same arguments a pre-hook function receives, a post-hook function also receives the result of the method it is registered for as the second argument.
Note: Since post-hooks have the exact same usage/registration options as pre-hooks, they are not explained in detail here.
Model settings
Can be used to access the model's settings. For more about model settings, see the Model settings
section.
model_settings = Developer.model_settings()
print(model_settings) # <NodeModelSettings labels={"Developer"}, auto_fetch_nodes=False, ...>
Relationship-properties
Note: Relationship-properties are only available for classes which inherit from the
NodeModel
class.
Relationship-properties are a special type of property that can only be defined on a NodeModel
class. They can be used to define relationships between nodes and other models. They provide a variate of options to fine-tune the relationship and how it behaves. The options are pretty self-explanatory, but let's go through them anyway:
class Developer(NodeModel):
# Here we define a relationship to one or more `Coffee` nodes, both the target
# and relationship-model can be defined as strings (Has to be the exact name of the model)
# Notice that the `RelationshipProperty` class takes two type arguments, the first
# one being the target model and the second one being the relationship-model
# Can can get away without defining these, but it is recommended to do so for
# better type hinting
coffee: RelationshipProperty["Coffee", "Consumed"] = RelationshipProperty(
# The target model is the model we want to connect to
target_model="Coffee",
# The relationship-model is the model which defines the relationship
# between a target model (in this case `Coffee`) and the model it is defined on
relationship_model=Consumed,
# The direction of the relationship inside the graph
direction=RelationshipPropertyDirection.OUTGOING,
# Cardinality defines how many nodes can be connected to the relationship
# **Note**: This only softly enforces cardinality from the model it's defined on
# and does not enforce it on the database level
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
# Whether to allow multiple connections to the same node
allow_multiple=True,
)
Available methods
Just like regular models, relationship-properties also provide a few methods to make working with them easier. In this section we are going to take a closer look at the different methods available to you.
Note: In the following, the terms
source node
andtarget node
will be used. Source node refers to thenode instance the method is called on
and target node refers to thenode/s passed to the method
.
RelationshipProperty.relationships()
Returns the relationships between the source node and the target node. The method expects a single argument node
which has to be the target node of the relationship. This always returns a list of relationship instances or an empty list if no relationships were found.
# The `developer` and `coffee` variables have been defined somewhere above
# Returns the relationships between the two nodes
coffee_relationships = await developer.coffee.relationships(coffee)
print(coffee_relationships) # [<Consumed>, <Consumed>, ...]
# Or if no relationships were found
print(coffee_relationships) # []
Filters
This method also allows for (optional) filters. For more about filters, see the Filtering queries
section.
# Only returns the relationships between the two nodes where
# the `developer liked the coffee`
coffee_relationships = await developer.coffee.relationships(coffee, {"likes_it": True})
print(coffee_relationships) # [<Consumed liked=True>, <Consumed liked=True>, ...]
Projections
Projections
can be used to only return specific parts of the models as dictionaries. For more information about projections, see the Projections
section.
# Returns dictionaries with the relationships `liked` property is at the
# `loved_it` key instead of model instances
coffee_relationships = await developer.coffee.relationships(coffee, projections={"loved_it": "liked"})
print(coffee_relationships) # [{"loved_it": True}, {"loved_it": False}, ...]
Query options
Query options
can be used to define how results are returned from the query. They are provided via the options
argument. For more about query options, see the Query options
section.
# Skips the first 10 results and returns the next 20
coffee_relationships = await developer.coffee.relationships(coffee, options={"limit": 20, "skip": 10})
print(coffee_relationships) # [<Consumed>, <Consumed>, ...] up to 20 results
RelationshipProperty.connect()
Connects the given target node to the source node. The method expects the target node as the first argument, and optional properties as the second argument. The properties provided will be carried over to the relationship inside the graph.
Depending on the allow_multiple
option, which is defined on the relationship-property, this method will either create a new relationship or update the existing one. If the allow_multiple
option is set to True
, this method will always create a new relationship. Otherwise, the query will use a MERGE
statement to update an existing relationship.
# The `developer` and `coffee` variables have been defined somewhere above
coffee_relationship = await developer.coffee.connect(coffee, {"likes_it": True})
print(coffee_relationship) # <Consumed>
RelationshipProperty.disconnect()
Disconnects the target node from the source node and deletes all relationships between them. The only argument to the method is the target node. If no relationships exist between the two, nothing is deleted and 0
is returned. Otherwise, the number of deleted relationships is returned.
Note: If
allow_multiple
was set toTrue
and multiple relationships to the target node exist, all of them will be deleted.
# The `developer` and `coffee` variables have been defined somewhere above
coffee_relationship_count = await developer.coffee.disconnect(coffee)
print(coffee_relationship_count) # However many relationships were deleted
Raise on empty result
By default, the disconnect()
method will return None
if no results were found. If you want to raise an exception instead, you can pass raise_on_empty=True
to the method.
# Raises a `NoResultFound` exception if no results were matched
coffee_relationship_count = await developer.coffee.disconnect(coffee, raise_on_empty=True)
RelationshipProperty.disconnect_all()
Disconnects all target nodes from the source node and deletes all relationships between them. Returns the number of deleted relationships.
# This will delete all relationships to `Coffee` nodes for this `Developer` node
coffee_relationship_count = await developer.coffee.disconnect_all()
print(coffee_relationship_count) # However many relationships were deleted
RelationshipProperty.replace()
Disconnects all relationships from the source node to the old target node and connects them back to the new target node, carrying over all properties defined in the relationship. Returns the replaced relationships.
Note: If
multiple relationships
between the target node and the old source node exist,all of them
will be replaced.
# Currently there are two relationships defined between the `developer` and `coffee_latte`
# nodes where the `likes_it` property is set to `True` and `False` respectively
# Moves the relationships from `coffee_latte` to `coffee_americano`
replaced_coffee_relationships = await developer.coffee.replace(coffee_latte, coffee_americano)
print(replaced_coffee_relationships) # [<Consumed likes_it=True>, <Consumed likes_it=False>]
RelationshipProperty.find_connected_nodes()
Finds and returns all connected nodes for the given relationship-property. This method always returns a list of instances/dictionaries or an empty list if no results were found.
# Returns all `Coffee` nodes
coffees = await developer.coffee.find_connected_nodes()
print(coffees) # [<Coffee>, <Coffee>, ...]
# Or if no matches were found
print(coffees) # []
Filters
You can pass filters using the filters
argument to filter the returned nodes. For more about filters, see the Filtering queries
section.
# Returns all `Coffee` nodes where the `sugar` property is set to `True`
coffees = await developer.coffee.find_connected_nodes({"sugar": True})
print(coffees) # [<Coffee sugar=True>, <Coffee sugar=True>, ...]
Projections
Projections
can be used to only return specific parts of the models as dictionaries. For more information about projections, see the Projections
section.
# Returns dictionaries with the coffee's `sugar` property at the `contains_sugar` key instead
# of model instances
coffees = await developer.coffee.find_connected_nodes({"sugar": True}, {"contains_sugar": "sugar"})
print(coffees) # [{"contains_sugar": True}, {"contains_sugar": False}, ...]
Query options
Query options
can be used to define how results are returned from the query. They are provided via the options
argument. For more about query options, see the Query options
section.
# Skips the first 10 results and returns the next 20
coffees = await developer.coffee.find_connected_nodes({"sugar": True}, options={"limit": 20, "skip": 10})
# Skips the first 10 results and returns up to 20
print(coffees) # [<Coffee>, <Coffee>, ...]
Auto-fetching nodes
The auto_fetch_nodes
and auto_fetch_models
parameters can be used to automatically fetch all or selected nodes from defined relationship-properties when running the find_many()
query. For more about auto-fetching, see Auto-fetching relationship-properties
.
# Returns coffee instances with `instance.<property>.nodes` properties already fetched
coffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True)
print(coffees[0].developer.nodes) # [<Developer>, <Developer>, ...]
print(coffees[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]
# Returns coffee instances with only the `instance.developer.nodes` property already fetched
coffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True, auto_fetch_models=[Developer])
# Auto-fetch models can also be passed as strings
coffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True, auto_fetch_models=["Developer"])
print(coffees[0].developer.nodes) # [<Developer>, <Developer>, ...]
print(coffees[0].other_property.nodes) # []
Hooks with relationship properties
Although slightly different, hooks can also be registered for relationship-properties. The only different lies in the arguments passed to the hook function. Since relationship-properties are defined on a NodeModel
class, the hook function will receive the NodeModel class context
of the model it has been called on as the first argument instead of the RelationshipProperty class context
(like it would for regular models).
Note: The rest of the arguments passed to the hook function are the same as for regular models.
class Developer(NodeModel):
# Here we define a relationship to one or more `Coffee` nodes, both the target
# and relationship-model can be defined as strings (Has to be the exact name of the model)
# Notice that the `RelationshipProperty` class takes two type arguments, the first
# one being the target model and the second one being the relationship-model
# Can can get away without defining these, but it is recommended to do so for
# better type hinting
coffee: RelationshipProperty["Coffee", "Consumed"] = RelationshipProperty(
# The target model is the model we want to connect to
target_model="Coffee",
# The relationship-model is the model which defines the relationship
# between a target model (in this case `Coffee`) and the model it is defined on
relationship_model=Consumed,
# The direction of the relationship inside the graph
direction=RelationshipPropertyDirection.OUTGOING,
# Cardinality defines how many nodes can be connected to the relationship
# **Note**: This only softly enforces cardinality from the model it's defined on
# and does not enforce it on the database level
cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,
# Whether to allow multiple connections to the same node
allow_multiple=True,
)
class Settings:
post_hooks = {
"coffee.connect": lambda self, *args, **kwargs: print(type(self))
}
# Somewhere further down the line...
# Prints `<class '__main__.Developer'>` instead of `<class '__main__.RelationshipProperty'>`
await developer.coffee.connect(coffee)
The reason for this change in the hooks behavior is simple, really. Since relationship-properties are only used to define relationships between nodes, it makes more sense to have the NodeModel class context
available inside the hook function instead of the RelationshipProperty class context
, since the hook function will most likely be used to execute code on the model the relationship-property is defined on.
Queries
As you might have seen by now, pyneo4j-ogm
provides a variate of methods to query the graph. If you followed the documentation up until this point, you might have seen that most of the methods take a filters
argument.
If you have some prior experience
with Neo4j and Cypher
, you may know that it does not provide a easy way to generate queries from given inputs. This is where pyneo4j-ogm
comes in. It provides a variety of filters
to make querying the graph as easy as possible.
The filters are heavily inspired by MongoDB's query language
, so if you have some experience with that, you will feel right at home.
This is really nice to have, not only for normal usage, but especially if you are developing a gRPC service
or REST API
and want to provide a way to query the graph from the outside.
But enough of that, let's take a look at the different filters available to you.
Filtering queries
Since the filters are inspired by MongoDB's query language, they are also very similar. The filters are defined as dictionaries, where the keys are the properties you want to filter on and the values are the values you want to filter for.
We can roughly separate them into the following categories
:
- Comparison operators
- String operators
- List operators
- Logical operators
- Element operators
Comparison operators
Comparison operators are used to compare values to each other. They are the most basic type of filter.
Operator | Description | Corresponding Cypher query |
---|---|---|
$eq |
Matches values that are equal to a specified value. | WHERE node.property = value |
$neq |
Matches all values that are not equal to a specified value. | WHERE node.property <> value |
$gt |
Matches values that are greater than a specified value. | WHERE node.property > value |
$gte |
Matches values that are greater than or equal to a specified value. | WHERE node.property >= value |
$lt |
Matches values that are less than a specified value. | WHERE node.property < value |
$lte |
Matches values that are less than or equal to a specified value. | WHERE node.property <= value |
String operators
String operators are used to compare string values to each other.
Operator | Description | Corresponding Cypher query |
---|---|---|
$contains |
Matches values that contain a specified value. | WHERE node.property CONTAINS value |
$icontains |
Matches values that contain a specified case insensitive value. | WHERE toLower(node.property) CONTAINS toLower(value) |
$startsWith |
Matches values that start with a specified value. | WHERE node.property STARTS WITH value |
$istartsWith |
Matches values that start with a specified case insensitive value. | WHERE toLower(node.property) STARTS WITH toLower(value) |
$endsWith |
Matches values that end with a specified value. | WHERE node.property ENDS WITH value |
$iendsWith |
Matches values that end with a specified case insensitive value. | WHERE toLower(node.property) ENDS WITH toLower(value) |
$regex |
Matches values that match a specified regular expression (Regular expressions used by Neo4j and Cypher). | WHERE node.property =~ value |
List operators
List operators are used to compare list values to each other.
Operator | Description | Corresponding Cypher query |
---|---|---|
$in |
Matches lists where at least one item is in the given list. | WHERE ANY(i IN node.property WHERE i IN value) |
$nin |
Matches lists where no items are in the given list | WHERE NONE(i IN node.property WHERE i IN value) |
$all |
Matches lists where all items are in the given list. | WHERE ALL(i IN node.property WHERE i IN value) |
$size |
Matches lists where the size of the list is equal to the given value. | WHERE size(node.property) = value |
Note: The
$size
operator can also be combined with the comparison operators by nesting them inside the$size
operator. For example:{"$size": {"$gt": 5}}
.
Logical operators
Logical operators are used to combine multiple filters with each other.
Operator | Description | Corresponding Cypher query |
---|---|---|
$and |
Joins query clauses with a logical AND returns all nodes that match the conditions of both clauses (Used by default if multiple filters are present). | WHERE node.property1 = value1 AND node.property2 = value2 |
$or |
Joins query clauses with a logical OR returns all nodes that match the conditions of either clause. | WHERE node.property1 = value1 OR node.property2 = value2 |
$xor |
Joins query clauses with a logical XOR returns all nodes that match the conditions of either clause but not both. | WHERE WHERE node.property1 = value1 XOR node.property2 = value2 |
$not |
Inverts the effect of a query expression nested within and returns nodes that do not match the query expression. | WHERE NOT (node.property = value) |
Element operators
Element operators are a special kind of operator not available for every filter type. They are used to check Neo4j-specific values.
Operator | Description | Corresponding Cypher query |
---|---|---|
$exists |
Matches nodes that have the specified property. | WHERE EXISTS(node.property) |
$elementId |
Matches nodes that have the specified element id. | WHERE elementId(node) = value |
$id |
Matches nodes that have the specified id. | WHERE id(node) = value |
$labels |
Matches nodes that have the specified labels. | WHERE ALL(i IN labels(n) WHERE i IN value) |
$type |
Matches relationships that have the specified type. Can be either a list or a string. | For a string: WHERE type(r) = value , For a list: WHERE type(r) IN value |
Pattern matching
The filters we have seen so far are great for simple queries, but what if we need to filter our nodes based on relationships to other nodes? This is where pattern matching
comes in. Pattern matching allows us to define a pattern
of nodes and relationships we want to match (or ignore). This is done by defining a list of patterns
inside the $patterns
key of the filter. Here is a short summary of the available operators inside a pattern:
$node
: Filters applied to the target node. Expects a dictionary containing basic filters.$relationship
: Filters applied to the relationship between the source node and the target node. Expects a dictionary containing basic filters.$direction
: The direction of the pattern. Can be either INCOMING,OUTGOING or BOTH.$exists
: A boolean value indicating whether the pattern must exist or not.
Note: The
$patterns
key can only be used inside theroot filter
and not inside nested filters. Furthermore, only patterns across a single hop are supported.
To make this as easy to understand as possible, we are going to take a look at a quick example. Let's say our Developer
can define relationships to his Coffee
. We want to get all Developers
who don't drink
their coffee with sugar
:
developers = await Developer.find_many({
"$patterns": [
{
# The `$exists` operator tells the library to match/ignore the pattern
"$exists": False,
# The defines the direction of the relationship inside the pattern
"$direction": RelationshipMatchDirection.OUTGOING,
# The `$node` key is used to define the node we want to filter for. This means
# the filters inside the `$node` key will be applied to our `Coffee` nodes
"$node": {
"$labels": ["Beverage", "Hot"],
"sugar": False
},
# The `$relationship` key is used to filter the relationship between the two nodes
# It can also define property filters for the relationship
"$relationship": {
"$type": "CHUGGED"
}
}
]
})
We can take this even further by defining multiple patters inside the $patterns
key. Let's say this time our Developer
can have some other Developer
friends and we want to get all Developers
who liked their coffee. At the same time, our developer must be FRIENDS_WITH
(now the relationship is an incoming one, because why not?) a developer named Jenny
:
developers = await Developer.find_many({
"$patterns": [
{
"$exists": True,
"$direction": RelationshipMatchDirection.OUTGOING,
"$node": {
"$labels": ["Beverage", "Hot"],
},
"$relationship": {
"$type": "CHUGGED",
"liked": True
}
},
{
"$exists": True,
"$direction": RelationshipMatchDirection.INCOMING,
"$node": {
"$labels": ["Developer"],
"name": "Jenny"
},
"$relationship": {
"$type": "FRIENDS_WITH"
}
}
]
})
Multi-hop filters
Multi-hop filters are a special type of filter which is only available for NodeModelInstance.find_connected_nodes()
. They allow you to specify filter parameters on the target node and all relationships between them over, you guessed it, multiple hops. To define this filter, you have a few operators you can define:
$node
: Filters applied to the target node. Expects a dictionary containing basic filters. Can not contain pattern yet.$minHops
: The minimum number of hops between the source node and the target node. Must be greater than 0.$maxHops
: The maximum number of hops between the source node and the target node. You can pass "*" as a value to define no upper limit. Must be greater than 1.$relationships
: A list of relationship filters. Each filter is a dictionary containing basic filters and must define a $type operator.
# Picture a structure like this inside the graph:
# (:Producer)-[:SELLS_TO]->(:Barista)-[:PRODUCES {with_love: bool}]->(:Coffee)-[:CONSUMED_BY]->(:Developer)
# If we want to get all `Developer` nodes connected to a `Producer` node over the `Barista` and `Coffee` nodes,
# where the `Barista` created the coffee with love.
# Let's say, for the sake of this example, that there are connections possible
# with 10+ hops, but we don't want to include them. To solve this, we can define
# a `$maxHops` filter with a value of `10`.
producer = await Producer.find_one({"name": "Coffee Inc."})
if producer is None:
# No producer found, do something else
developers = await producer.find_connected_nodes({
"$maxHops": 10,
"$node": {
"$labels": ["Developer", "Python"],
# You can use all available filters here as well
},
# You can define filters on specific relationships inside the path
"$relationships": [
{
# Here we define a filter for all `PRODUCES` relationships
# Only nodes where the with_love property is set to `True` will be returned
"$type": "PRODUCES",
"with_love": True
}
]
})
print(developers) # [<Developer>, <Developer>, ...]
# Or if no matches were found
print(developers) # []
Projections
Projections are used to only return specific parts of the models as dictionaries. They are defined as a dictionary where the key is the name of the property in the returned dictionary and the value is the name of the property on the model instance.
Projections can help you to reduce bandwidth usage and speed up queries, since you only return the data you actually need.
Note: Only top-level mapping is supported. This means that you can not map properties to a nested dictionary key.
In the following example, we will return a dictionary with a dev_name
key, which get's mapped to the models name
property and a dev_age
key, which get's mapped to the models age
property. Any defined mapping which does not exist on the model will have None
as it's value. You can also map the result's elementId
and Id
using either $elementId
or $id
as the value for the mapped key.
developer = await Developer.find_one({"name": "John"}, {"dev_name": "name", "dev_age": "age", "i_do_not_exist": "some_non_existing_property"})
print(developer) # {"dev_name": "John", "dev_age": 24, "i_do_not_exist": None}
Query options
Query options are used to define how results are returned from the query. They provide some basic functionality for easily implementing pagination, sorting, etc. They are defined as a dictionary where the key is the name of the option and the value is the value of the option. The following options are available:
limit
: Limits the number of returned results.skip
: Skips the firstn
results.sort
: Sorts the results by the given property. Can be either a string or a list of strings. If a list is provided, the results will be sorted by the first property and then by the second property, etc.order
: Defines the sort direction. Can be eitherASC
orDESC
. Defaults toASC
.
# Returns 50 results, skips the first 10 and sorts them by the `name` property in descending order
developers = await Developer.find_many({}, options={"limit": 50, "skip": 10, "sort": "name", "order": QueryOptionsOrder.DESCENDING})
print(len(developers)) # 50
print(developers) # [<Developer>, <Developer>, ...]
Auto-fetching relationship-properties
You have the option to automatically fetch all defined relationship-properties of matched nodes. This will populate the instance.<property>.nodes
attribute with the fetched nodes. This can be useful in situations where you need to fetch a specific node and get all of it's related nodes at the same time.
Note: Auto-fetching nodes with many relationships can be very expensive and slow down your queries. Use it with caution.
To enable this behavior, you can either set the auto_fetch_nodes
parameter to True
or set the auto_fetch_nodes setting
in the model settings to True
, but doing so will always enable auto-fetching
.
You can also define which relationship-properties to fetch by providing the fetched models to the auto_fetch_models
parameter. This can be useful if you only want to fetch specific relationship-properties.
Now, let's take a look at an example:
# Fetches everything defined in the relationship-properties of the current matched node
developer = await Developer.find_one({"name": "John"}, auto_fetch_nodes=True)
# All nodes for all defined relationship-properties are now fetched
print(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developer.developer.nodes) # [<Developer>, <Developer>, ...]
print(developer.other_property.nodes) # [<OtherModel>, <OtherModel>, ...]
With the auto_fetch_models
parameter, we can define which relationship-properties to fetch:
# Only fetch nodes for `Coffee` and `Developer` models defined in relationship-properties
# The models can also be passed as strings, where the string is the model's name
developer = await Developer.find_one({"name": "John"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee, "Developer"])
# Only the defined models have been fetched
print(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]
print(developer.developer.nodes) # [<Developer>, <Developer>, ...]
print(developer.other_property.nodes) # []
Logging
You can control the log level and whether to log to the console or not by setting the PYNEO4J_OGM_LOG_LEVEL
and PYNEO4J_OGM_ENABLE_LOGGING
as environment variables. The available levels are the same as provided by the build-in logging
module. The default log level is WARNING
and logging to the console is enabled by default.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file pyneo4j_ogm-0.3.0.tar.gz
.
File metadata
- Download URL: pyneo4j_ogm-0.3.0.tar.gz
- Upload date:
- Size: 82.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/4.0.2 CPython/3.11.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 53c4de8745a9a29154ae89468397d85606aac7853f855d76b9584e8eae1a5025 |
|
MD5 | 2fb5bfca99c72f5361063d1825d6346e |
|
BLAKE2b-256 | e75424a8fadded1d04d2adf13fbf0b1dde36b8c87ac6792ee83ae4c35080cd19 |
File details
Details for the file pyneo4j_ogm-0.3.0-py3-none-any.whl
.
File metadata
- Download URL: pyneo4j_ogm-0.3.0-py3-none-any.whl
- Upload date:
- Size: 69.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/4.0.2 CPython/3.11.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 58b58890704421bc98dbebb5ec2f1937636628ec7b30859fe67c5c03b3167eaf |
|
MD5 | 054b633a04b9850a888892b6045eb0b7 |
|
BLAKE2b-256 | cba7e8a5c62f78795671e0552dd6ef0349bf9633ccb3d2b1e22533c3894b455b |