pyneo4j-ogm


Namepyneo4j-ogm JSON
Version 0.5.2 PyPI version JSON
download
home_pagehttps://github.com/groc-prog/pyneo4j-ogm
SummaryAsynchronous Python OGM for Neo4j
upload_time2024-02-22 18:16:09
maintainergroc-prog
docs_urlNone
authorgroc-prog
requires_python>=3.10,<4.0
licenseMIT
keywords neo4j python orm ogm async asynchronous database graph-database pydantic
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # pyneo4j-ogm

[![PyPI](https://img.shields.io/pypi/v/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)
[![PyPI - License](https://img.shields.io/pypi/l/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)

[`pyneo4j-ogm`](https://github.com/groc-prog/pyneo4j-ogm/tree/main) is a asynchronous `Object-Graph-Mapper` for [`Neo4j 5+`](https://neo4j.com/docs/) and [`Python 3.10+`](https://www.python.org/). It is inspired by [`beanie`](https://github.com/roman-right/beanie) and build on top of proven technologies like [`Pydantic 1.10+ and 2+`](https://docs.pydantic.dev/latest/) and the [`Neo4j Python Driver`](https://neo4j.com/docs/api/python-driver/current/index.html). 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.

## 🎯 Features <a name="features"></a>

[`pyneo4j-ogm`](https://github.com/groc-prog/pyneo4j-ogm/tree/main) has a lot to offer, including:

- [x] **Fully typed**: pyneo4j-ogm is `fully typed` out of the box.
- [x] **Powerful validation**: Since we use Pydantic under the hood, you can use it's powerful validation and serialization features without any issues.
- [x] **Focus on developer experience**: Designed to be simple to use, pyneo4j-ogm provides features for both simple queries and more `advanced use-cases` while keeping it's API as simple as possible.
- [x] **Build-in migration tooling**: Shipped with simple, yet flexible migration tooling.
- [x] **Fully asynchronous**: Completely asynchronous code, thanks to the `Neo4j Python Driver`.
- [x] **Supports Neo4j 5+**: pyneo4j-ogm supports `Neo4j 5+` and is tested against the latest version of Neo4j.
- [x] **Multi-version Pydantic support**: Both `Pydantic 1.10+` and `2+` fully supported.

## πŸ“£ Announcements

Things to come in the future. Truly exiting stuff! If you have feature requests which you think might improve `pyneo4j-ogm`, feel free to open up a feature request.

- [ ] Auto-generated migrations

## πŸ“¦ Installation <a name="installation"></a>

Using [`pip`](https://pip.pypa.io/en/stable/):

```bash
pip install pyneo4j-ogm
```

or when using [`Poetry`](https://python-poetry.org/):

```bash
poetry add pyneo4j-ogm
```

## πŸš€ Quickstart <a name="quickstart"></a>

Before we can get going, we have to take care of some things:

- We need to define our models, which will represent the nodes and relationships inside our database.
- We need a database client, which will do the actual work for us.

### Defining our data structures

Since every developer has a coffee addiction one way or another, we are going to use `Coffee` and `Developers` for this guide. So let's start by defining what our data should look like:

```python
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 interactions
  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 configuration 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"
```

Until now everything seems pretty standard if you have worked with other ORM's before. But if you haven't, we are going to go over what happened above:

- We defined 2 node models `Developer` and `Coffee`, and a relationship `Consumed`.
- Some models define a special inner `Settings` class. This is used to customize the behavior of our models inside the graph. More on these settings can be found ['here'](#model-settings).
- The `WithOptions` function has been used to define `constraints and indexes` (more about them [`here`](#manual-indexing-and-constraints)) on model properties.

### Creating a database client

In pyneo4j-ogm, the real work is done by a database client. One of these bad-boys can be created by initializing a `Pyneo4jClient` instance. But for models to work as expected, we have to let our client know that we want to use them like so:

```python
from pyneo4j_ogm import Pyneo4jClient

async def main():
  # We initialize a new `Pyneo4jClient` instance and connect to the database.
  client = Pyneo4jClient()

  # Replace `<connection-uri-to-database>`, `<username>` and `<password>` with the
  # actual values.
  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])
```

### Interacting with the database

Now the fun stuff begins! We are ready to interact with our database. For the sake of this [`quickstart guide`](#quickstart) we are going to keep it nice and simple, but this is just the surface of what pyneo4j-ogm has to offer.

We are going to create a new `Developer` and some `Coffee` and give him something to drink:

```python
# 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 example

```python
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())
```

And that's it! You should now see a `Developer` and a `Hot/Beverage` node, connected by a `CONSUMED` relationship. If you want to learn more about the library, you can check out the full [`Documentation`](#documentation).

## πŸ“š Documentation <a name="documentation"></a>

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](#pyneo4j-ogm)
  - [🎯 Features ](#-features-)
  - [πŸ“£ Announcements](#-announcements)
  - [πŸ“¦ Installation ](#-installation-)
  - [πŸš€ Quickstart ](#-quickstart-)
    - [Defining our data structures](#defining-our-data-structures)
    - [Creating a database client](#creating-a-database-client)
    - [Interacting with the database](#interacting-with-the-database)
    - [Full example](#full-example)
  - [πŸ“š Documentation ](#-documentation-)
    - [Table of contents](#table-of-contents)
    - [Basic concepts ](#basic-concepts-)
    - [A note on Pydantic version support](#a-note-on-pydantic-version-support)
    - [Database client](#database-client)
      - [Connecting to the database ](#connecting-to-the-database-)
      - [Closing an existing connection ](#closing-an-existing-connection-)
      - [Registering models ](#registering-models-)
      - [Executing Cypher queries ](#executing-cypher-queries-)
      - [Batching cypher queries ](#batching-cypher-queries-)
      - [Using bookmarks (Enterprise Edition only) ](#using-bookmarks-enterprise-edition-only-)
      - [Manual indexing and constraints ](#manual-indexing-and-constraints-)
      - [Client utilities ](#client-utilities-)
    - [Models ](#models-)
      - [Indexes, constraints and properties ](#indexes-constraints-and-properties-)
      - [Reserved properties ](#reserved-properties-)
      - [Configuration settings ](#configuration-settings-)
        - [NodeModel configuration ](#nodemodel-configuration-)
        - [RelationshipModel configuration ](#relationshipmodel-configuration-)
      - [Available methods ](#available-methods-)
        - [Instance.update() ](#instanceupdate-)
        - [Instance.delete() ](#instancedelete-)
        - [Instance.refresh() ](#instancerefresh-)
        - [Model.find\_one() ](#modelfind_one-)
          - [Projections ](#projections-)
          - [Auto-fetching nodes ](#auto-fetching-nodes-)
          - [Raise on empty result ](#raise-on-empty-result-)
        - [Model.find\_many() ](#modelfind_many-)
          - [Filters ](#filters-)
          - [Projections ](#projections--1)
          - [Query options ](#query-options-)
          - [Auto-fetching nodes ](#auto-fetching-nodes--1)
        - [Model.update\_one() ](#modelupdate_one-)
          - [Returning the updated entity ](#returning-the-updated-entity-)
          - [Raise on empty result ](#raise-on-empty-result--1)
        - [Model.update\_many() ](#modelupdate_many-)
          - [Filters ](#filters--1)
          - [Returning the updated entity ](#returning-the-updated-entity--1)
        - [Model.delete\_one() ](#modeldelete_one-)
          - [Raise on empty result ](#raise-on-empty-result--2)
        - [Model.delete\_many() ](#modeldelete_many-)
          - [Filters ](#filters--2)
        - [Model.count() ](#modelcount-)
          - [Filters ](#filters--3)
        - [NodeModelInstance.create() ](#nodemodelinstancecreate-)
        - [NodeModelInstance.find\_connected\_nodes() ](#nodemodelinstancefind_connected_nodes-)
          - [Projections ](#projections--2)
          - [Query options ](#query-options--1)
          - [Auto-fetching nodes ](#auto-fetching-nodes--2)
        - [RelationshipModelInstance.start\_node() ](#relationshipmodelinstancestart_node-)
        - [RelationshipModelInstance.end\_node() ](#relationshipmodelinstanceend_node-)
      - [Serializing models ](#serializing-models-)
      - [Hooks ](#hooks-)
        - [Pre-hooks ](#pre-hooks-)
        - [Post-hooks ](#post-hooks-)
      - [Model settings ](#model-settings-)
    - [Relationship-properties ](#relationship-properties-)
      - [Available methods ](#available-methods--1)
        - [RelationshipProperty.relationships() ](#relationshippropertyrelationships-)
          - [Filters ](#filters--4)
          - [Projections ](#projections--3)
          - [Query options ](#query-options--2)
        - [RelationshipProperty.connect() ](#relationshippropertyconnect-)
        - [RelationshipProperty.disconnect() ](#relationshippropertydisconnect-)
          - [Raise on empty result ](#raise-on-empty-result--3)
        - [RelationshipProperty.disconnect\_all() ](#relationshippropertydisconnect_all-)
        - [RelationshipProperty.replace() ](#relationshippropertyreplace-)
        - [RelationshipProperty.find\_connected\_nodes() ](#relationshippropertyfind_connected_nodes-)
          - [Filters ](#filters--5)
          - [Projections ](#projections--4)
          - [Query options ](#query-options--3)
          - [Auto-fetching nodes ](#auto-fetching-nodes--3)
      - [Hooks with relationship properties ](#hooks-with-relationship-properties-)
    - [Queries ](#queries-)
      - [Filtering queries ](#filtering-queries-)
        - [Comparison operators ](#comparison-operators-)
        - [String operators ](#string-operators-)
        - [List operators ](#list-operators-)
        - [Logical operators ](#logical-operators-)
        - [Element operators ](#element-operators-)
        - [Pattern matching ](#pattern-matching-)
        - [Multi-hop filters ](#multi-hop-filters-)
      - [Projections ](#projections--5)
      - [Query options ](#query-options--4)
      - [Auto-fetching relationship-properties ](#auto-fetching-relationship-properties-)
    - [Migrations ](#migrations-)
      - [Initializing migrations for your project ](#initializing-migrations-for-your-project-)
      - [Creating a new migration ](#creating-a-new-migration-)
      - [Running migrations ](#running-migrations-)
      - [Listing migrations ](#listing-migrations-)
      - [Programmatic usage ](#programmatic-usage-)
    - [Logging ](#logging-)
    - [Running the test suite ](#running-the-test-suite-)

### Basic concepts <a name="basic-concepts"></a>

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 still providing the most common operations and some more advanced features.

But first, how does this even work!?! Well, 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`](#quickstart). The models used in the following examples will build upon the ones defined there. If you are new to [`Neo4j`](https://neo4j.com/docs/) or [`Cypher`](https://neo4j.com/docs/cypher-manual/current/) in general, you should get a basic understanding of how to use them before continuing.

### A note on Pydantic version support

As of version [`v0.3.0`](https://github.com/groc-prog/pyneo4j-ogm/blob/main/CHANGELOG.md#v030-2023-11-30), pyneo4j-ogm now supports both `Pydantic 1.10+ and 2+`. All core features of pydantic should work, meaning full support for model serialization, validation and schema generation.

Should you find any issues or run into any problems, feel free to open a issue!

### Database client

This is where 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. Because of this, we have to always have at least one client initialized before doing anything else.

#### Connecting to the database <a name="connecting-to-the-database"></a>

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 to `False`.
- `skip_indexes`: Whether the client should skip creating any indexes defined on models when registering them. Defaults to `False`.
- `*args`: Additional arguments that are passed directly to Neo4j's `AsyncDriver.driver()` method.
- `**kwargs`: Additional keyword arguments that are passed directly to Neo4j's `AsyncDriver.driver()` method.

```python
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 (it happens to the best of us), you will get a `NotConnectedToDatabase` exception.

#### Closing an existing connection <a name="closing-an-existing-connection"></a>

Connections can explicitly be closed by calling the `close()` method. 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!

```python
# 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 <a name="registering-models"></a>

Models are a core feature of pyneo4j-ogm, and therefore you probably want to use some. But to work with them, they have to be registered with the client by calling the `register_models()` method and passing in your models as a list:

```python
# 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.

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.

> **Note**: 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 <a name="executing-cypher-queries"></a>

Models aren't the only things capable of running queries. The client can also be used to run queries, with some additional functionality to make your life easier.

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 to `True`.

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:

```python
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 <a name="batching-cypher-queries"></a>

We provide an easy way to batch multiple database queries together, regardless of whether you are using the client directly or via a model method. To do this you can use the `batch()` method, which has to be called with a asynchronous context manager like in the following example:

```python
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, be that a model method, a custom query or a relationship-property method. If any of the queries fail, the whole transaction will be rolled back and an exception will be raised.

#### Using bookmarks (Enterprise Edition only) <a name="using-bookmarks"></a>

If you are using the Enterprise Edition of Neo4j, you can use bookmarks to keep track of the last transaction that has been committed. The client provides a `last_bookmarks` property that allows you to get the bookmarks from the last session. These bookmarks can be used in combination with the `use_bookmarks()` method. Like the `batch()` method, the `use_bookmarks()` method has to be called with a context manager. All queries run inside the context manager will use the bookmarks passed to the `use_bookmarks()` method. Here is an example of how to use bookmarks:

```python
# Create a new node and get the bookmarks from the last session
await client.cypher("CREATE (d:Developer {name: 'John Doe', age: 25})")
bookmarks = client.last_bookmarks

# Create another node, but this time don't get the bookmark
# When we use the bookmarks from the last session, this node will not be visible
await client.cypher("CREATE (c:Coffee {flavour: 'Espresso', milk: False, sugar: False})")

with client.use_bookmarks(bookmarks=bookmarks):
  # All queries executed inside the context manager will use the bookmarks
  # passed to the `use_bookmarks()` method.

  # Here we will only see the node created in the first query
  results, meta = await client.cypher("MATCH (n) RETURN n")

  # Model queries also can be batched together without any extra work!
  # This will return no results, since the coffee node was created after
  # the bookmarks were taken.
  coffee = await Coffee.find_many()
  print(coffee)  # []
```

#### Manual indexing and constraints <a name="manual-indexing-and-constraints"></a>

Most of the time, the creation of indexes/constraints will be handled by the models themselves. But it can still be handy to have a simple way of creating new ones. This is where the `create_lookup_index()`, `create_range_index`, `create_text_index`, `create_point_index` and `create_uniqueness_constraint()` methods come in.

First, let's take a look at how to create a custom index in the database. The `create_range_index`, `create_text_index` and `create_point_index` methods take 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**.
- `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_lookup_index()` takes the same arguments, except for the `labels_or_type` and `properties` arguments.

The `create_uniqueness_constraint()` method also takes similar arguments.

- `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:

```python
# Creates a `RANGE` index for a `Coffee's` `sugar` and `flavour` properties
await client.create_range_index("hot_beverage_index", EntityType.NODE, ["sugar", "flavour"], ["Beverage", "Hot"])

# Creates a UNIQUENESS constraint for a `Developer's` `uid` property
await client.create_uniqueness_constraint("developer_constraint", EntityType.NODE, ["uid"], ["Developer"])
```

#### Client utilities <a name="client-utilities"></a>

The client also provides some additional utility methods, which mostly exist for convenience when writing tests or setting up environments:

- `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 <a name="models"></a>

As shown in the [`quickstart guide`](#quickstart), 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.

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.

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 <a name="indexes-constraints-and-properties"></a>

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`](https://docs.pydantic.dev/latest/concepts/json_schema/#schema-customization).

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 valid `Pydantic` type.
- `range_index`: Whether to create a range index on the property. Defaults to `False`.
- `text_index`: Whether to create a text index on the property. Defaults to `False`.
- `point_index`: Whether to create a point index on the property. Defaults to `False`.
- `unique`: Whether to create a uniqueness constraint on the property. Defaults to `False`.

> **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).

```python
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)
```

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`](#relationship-properties) section.

#### Reserved properties <a name="reserved-properties"></a>

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.

These properties are implemented as class properties and allow you to access the graph properties of you models.

#### Configuration settings <a name="configuration-settings"></a>

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`:

```python
class Coffee(NodeModel):
  flavour: str
  sugar: bool
  milk: bool

  class Settings:
    # This is the place where the magic happens!
```

##### NodeModel configuration <a name="node-model-configuration"></a>

The `Settings` class of a `NodeModel` provides the following properties:

| Setting name          | Type                          | Description                                                                                                                                                                                                                                                                                                                              |
| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. |

##### RelationshipModel configuration <a name="relationship-model-configuration"></a>

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                                                                                                                                                                                                                                                                                                                              |
| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. |

> **Note:** Hooks can be defined for all native 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 the `connect()` method of a relationship-property named `coffee`, you would have to pass `coffee.connect` as the key. This is true for both Node- and Relationship-models.

#### Available methods <a name="model-available-methods"></a>

Running cypher queries manually is nice and all, but something else 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`](https://docs.python.org/3/library/asyncio.html) before continuing.

> **Note**: 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()`: The `class method` is available on instances of both `NodeModel` and `RelationshipModel` classes.
> - `Instance.method()`: The `instance method` is available on instances of both `NodeModel` and `RelationshipModel` classes.
> - `<Type>Model.method()`: The `class method` is available on instances of the `<Type>Model` class.
> - `<Type>ModelInstance.method()`: The `instance method` is available on instances of the `<Type>Model` class.

##### Instance.update() <a name="instance-update"></a>

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.

```python
# 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() <a name="instance-delete"></a>

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.

```python
# 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() <a name="instance-refresh"></a>

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!).

```python
# 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() <a name="model-find-one"></a>

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`](#query-filters) section.

```python
# 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 <a name="model-find-one-projections"></a>

`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`](#query-projections)

```python
# 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 <a name="model-find-one-auto-fetching-nodes"></a>

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`](#query-auto-fetching).

> **Note**: The `auto_fetch_nodes` and `auto_fetch_models` parameters are only available for classes which inherit from the `NodeModel` class.

```python
# 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 <a name="model-find-one-raise-on-empty-result"></a>

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.

```python
# Raises a `NoResultFound` exception if no results were found
developer = await Developer.find_one({"name": "John"}, raise_on_empty=True)
```

##### Model.find_many() <a name="model-find-many"></a>

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.

```python
# Returns ALL `Developer` nodes
developers = await Developer.find_many()

print(developers) # [<Developer>, <Developer>, <Developer>, ...]
```

###### Filters <a name="model-find-many-filters"></a>

Just like the `find_one()` method, the `find_many()` method also takes (optional) filters. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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 <a name="model-find-many-projections"></a>

`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.

```python
# 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 <a name="model-find-many-query-options"></a>

`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`](#query-options) section.

```python
# 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 <a name="model-find-many-auto-fetching-nodes"></a>

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`](#query-auto-fetching).

> **Note**: The `auto_fetch_nodes` and `auto_fetch_models` parameters are only available for classes which inherit from the `NodeModel` class.

```python
# 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() <a name="model-update-one"></a>

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 the [`Filtering queries`](#query-filters) section.

```python
# 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 <a name="model-update-one-new"></a>

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`.

```python
# 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 <a name="model-update-one-raise-on-empty-result"></a>

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.

```python
# 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() <a name="model-update-many"></a>

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.

```python
# 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 <a name="model-update-many-filters"></a>

Optionally, a `filters` argument can be provided, which defines which entities to update. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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 <a name="model-update-many-new"></a>

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`.

```python
# 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() <a name="model-delete-one"></a>

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`](#query-filters) section.

```python
# 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 <a name="model-delete-one-raise-on-empty-result"></a>

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.

```python
# Raises a `NoResultFound` exception if no results were matched
count = await Developer.delete_one({"name": "John"}, raise_on_empty=True)
```

##### Model.delete_many() <a name="model-delete-many"></a>

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.

```python
# 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
```

###### Filters <a name="model-delete-many-filters"></a>

Optionally, a `filters` argument can be provided, which defines which entities to delete. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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() <a name="model-count"></a>

The `count()` method returns the total number of entities of this model in the graph.

```python
# 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 <a name="model-count-filters"></a>

Optionally, a `filters` argument can be provided, which defines which entities to count. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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() <a name="node-model-instance-create"></a>

> **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.

```python
# 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() <a name="node-model-instance-find-connected-nodes"></a>

> **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`](#query-filters) section.

```python
# 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 <a name="node-model-find-connected-nodes-projections"></a>

`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.

```python
# 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 <a name="node-model-find-connected-nodes-query-options"></a>

`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`](#query-options) section.

```python
# 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 <a name="node-model-find-connected-nodes-auto-fetching-nodes"></a>

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`](#query-auto-fetching).

```python
# 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() <a name="relationship-model-instance-start-node"></a>

> **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.

```python
# 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() <a name="relationship-model-instance-end-node"></a>

> **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.

```python
# The `coffee_relationship` variable is a relationship instance created somewhere above
end_node = await coffee_relationship.end_node()

print(end_node) # <Developer>
```

#### Serializing models <a name="serializing-models"></a>

When serializing models to a dictionary or JSON string, the models `element_id and id` fields are `automatically added` to the corresponding dictionary/JSON string when calling Pydantic's `dict()` or `json()` methods.

If you want to exclude them from serialization, you can easily do so by passing them to the `exclude` parameter of the according method.

On node-models:

- `id`
- `element_id`

Additional properties for relationship-models:

- `start_node_id`
- `start_node_element_id`
- `end_node_id`
- `end_node_element_id`

#### Hooks <a name="hooks"></a>

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:** If you implement custom methods and want to use hooks for them, you can simply define the `hook decorator` on them and then register hooks under the `name of your method`.

##### Pre-hooks <a name="pre-hooks"></a>

Pre-hooks are executed before the method they are registered for. They can be defined in the [`model's Settings`](#configuration-settings) class under the `pre_hooks` property or by calling the `register_pre_hooks()` method on the model.

```python
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 <a name="post-hooks"></a>

Post-hooks are executed after the method they are registered for. They can be defined in the [`model's Settings`](#configuration-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 <a name="model-settings"></a>

Can be used to access the model's settings. For more about model settings, see the [`Model settings`](#model-settings) section.

```python
model_settings = Developer.model_settings()

print(model_settings) # <NodeModelSettings labels={"Developer"}, auto_fetch_nodes=False, ...>
```

### Relationship-properties <a name="relationship-properties"></a>

> **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:

```python
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 <a name="relationship-properties-available-methods"></a>

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` and `target node` will be used. Source node refers to the `node instance the method is called on` and target node refers to the `node/s passed to the method`.

##### RelationshipProperty.relationships() <a name="relationship-property-relationship"></a>

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.

```python
# 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 <a name="relationship-property-relationships-filters"></a>

This method also allows for (optional) filters. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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 <a name="relationship-property-relationships-projections"></a>

`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.

```python
# 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 <a name="relationship-property-relationships-query-options"></a>

`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`](#query-options) section.

```python
# 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() <a name="relationship-property-connect"></a>

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.

```python
# 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() <a name="relationship-property-disconnect"></a>

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 to `True` and multiple relationships to the target node exist, all of them will be deleted.

```python
# 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 <a name="relationship-property-disconnect-raise-on-empty-result"></a>

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.

```python
# Raises a `NoResultFound` exception if no results were matched
coffee_relationship_count = await developer.coffee.disconnect(coffee, raise_on_empty=True)
```

##### RelationshipProperty.disconnect_all() <a name="relationship-property-disconnect-all"></a>

Disconnects all target nodes from the source node and deletes all relationships between them. Returns the number of deleted relationships.

```python
# 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() <a name="relationship-property-replace"></a>

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.

```python
# 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() <a name="relationship-property-find-connected-nodes"></a>

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.

```python
# Returns all `Coffee` nodes
coffees = await developer.coffee.find_connected_nodes()

print(coffees) # [<Coffee>, <Coffee>, ...]

# Or if no matches were found
print(coffees) # []
```

###### Filters <a name="relationship-property-find-connected-nodes-filters"></a>

You can pass filters using the `filters` argument to filter the returned nodes. For more about filters, see the [`Filtering queries`](#query-filters) section.

```python
# 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 <a name="relationship-property-find-connected-nodes-projections"></a>

`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.

```python
# 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 <a name="relationship-property-find-connected-nodes-query-options"></a>

`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`](#query-options) section.

```python
# 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 <a name="relationship-property-find-connected-nodes-auto-fetching-nodes"></a>

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`](#query-auto-fetching).

```python
# 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 <a name="hooks-with-relationship-properties"></a>

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.

```python
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 <a name="queries"></a>

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`](https://docs.mongodb.com/manual/tutorial/query-documents/), 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 <a name="query-filters"></a>

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 <a name="query-filters-comparison-operators"></a>

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 <a name="query-filters-string-operators"></a>

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 <a name="query-filters-list-operators"></a>

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 <a name="query-filters-logical-operators"></a>

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 <a name="query-filters-element-operators"></a>

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 <a name="query-filters-pattern-matching"></a>

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 the `root 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`:

```python
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`:

```python
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 <a name="query-filters-multi-hop-filters"></a>

Multi-hop filters are a special type of filter which is only available for [`NodeModelInstance.find_connected_nodes()`](#node-model-instance-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.

```python
# 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 <a name="query-projections"></a>

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.

```python
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 <a name="query-options"></a>

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 first `n` 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 either `ASC` or `DESC`. Defaults to `ASC`.

```python
# 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 <a name="query-auto-fetching"></a>

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:

```python
# 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:

```python
# 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) # []
```

### Migrations <a name="migrations"></a>

As of version `v0.5.0`, pyneo4j-ogm supports migrations using a built-in migration tool. The migration tool is basic but flexibly, which should cover most use-cases.

#### Initializing migrations for your project <a name="initializing-migrations"></a>

To initialize migrations for your project, you can use the `poetry run pyneo4j_ogm init` command. This will create a `migrations` directory at the given path (which defaults to `./migrations`), which will contain all your migration files.

```bash
poetry run pyneo4j_ogm init --migration-dir ./my/custom/migration/path
```

#### Creating a new migration <a name="creating-a-new-migration"></a>

To create a new migration, you can use the `poetry run pyneo4j_ogm create` command. This will create a new migration file inside the `migrations` directory. The migration file will contain a `up` and `down` function, which you can use to define your migration.

```bash
poetry run pyneo4j_ogm create my_first_migration
```

Both the `up` and `down` functions will receive the client used during the migration as their only arguments. This makes the migrations pretty flexible, since you can not only use the client to execute queries, but also register models on it and use them to execute methods.

> **Note**: When using models inside the migration, you have to make sure that the model used implements the same data structure as the data inside the graph. Otherwise you might run into validation issues.

```python
"""
Auto-generated migration file {name}. Do not
rename this file or the `up` and `down` functions.
"""
from pyneo4j_ogm import Pyneo4jClient


async def up(client: Pyneo4jClient) -> None:
    """
    Write your `UP migration` here.
    """
    await client.cypher("CREATE (n:Node {name: 'John'})")


async def down(client: Pyneo4jClient) -> None:
    """
    Write your `DOWN migration` here.
    """
    await client.cypher("MATCH (n:Node {name: 'John'}) DELETE n")
```

#### Running migrations <a name="running-migrations"></a>

To run the migrations, you can use the `up` or `down` commands. The `up` command will run all migrations that have not been run yet, while the `down` command will run all migrations in reverse order.

Both commands support a `--up-count` or `--down-count` argument, which can be used to limit the number of migrations to run. By default, the `up` command will run `all pending migration` and the `down` command will roll back the `last migration`.

```bash
poetry run pyneo4j_ogm up --up-count 3
poetry run pyneo4j_ogm down --down-count 2
```

#### Listing migrations <a name="listing-migrations"></a>

The current state of all migrations can be viewed anytime using the `status` command. This will show you all migrations that have been run and all migrations that are pending.

```bash
poetry run pyneo4j_ogm status

# Output
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Migration                               β”‚ Applied At          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 20160608155948-my_awesome_migration     β”‚ 2022-03-04 15:40:22 β”‚
β”‚ 20160608155948-my_fixed_migration       β”‚ 2022-03-04 15:41:13 β”‚
β”‚ 20160608155948-final_fix_i_swear        β”‚ PENDING             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

#### Programmatic usage <a name="migrations-programmatic-usage"></a>

The migration tool can also be used programmatically. This can be useful if you want to run migrations inside your application or if you want to integrate the migration tool into your own CLI.

```python
import asyncio
from pyneo4j_ogm.migrations import create, down, init, status, up

# Call with same arguments as you would with cli
init(migration_dir="./my/custom/migration/path")

create("my_first_migration")
asyncio.run(up())
```

### Logging <a name="logging"></a>

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.

### Running the test suite <a name="running-the-test-suite"></a>

To run the test suite, you have to install the development dependencies and run the tests using `pytest`. The tests are located in the `tests` directory. Any tests located in the `tests/integration` directory will require you to have a Neo4j instance running on `localhost:7687` with the credentials (`neo4j:password`). This can easily be done using the provided `docker-compose.yml` file.

```bash
poetry run pytest tests --asyncio-mode=auto -W ignore::DeprecationWarning
```

> **Note:** The `-W ignore::DeprecationWarning` can be omitted but will result in a lot of deprication warnings by Neo4j itself about the usage of the now deprecated `ID`.

As for running the tests with a different pydantic version, you can just run the following command locally:

```bash
poetry add pydantic@1.10
```


            

Raw data

            {
    "_id": null,
    "home_page": "https://github.com/groc-prog/pyneo4j-ogm",
    "name": "pyneo4j-ogm",
    "maintainer": "groc-prog",
    "docs_url": null,
    "requires_python": ">=3.10,<4.0",
    "maintainer_email": "marc.troisner@gmail.com",
    "keywords": "neo4j,python,orm,ogm,async,asynchronous,database,graph-database,pydantic",
    "author": "groc-prog",
    "author_email": "marc.troisner@gmail.com",
    "download_url": "https://files.pythonhosted.org/packages/02/40/cb4e205cd6857ada4ba621339a84b95fd8a82284b8974292399588bcc6cd/pyneo4j_ogm-0.5.2.tar.gz",
    "platform": null,
    "description": "# pyneo4j-ogm\n\n[![PyPI](https://img.shields.io/pypi/v/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)\n[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)\n[![PyPI - License](https://img.shields.io/pypi/l/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)\n[![PyPI - Downloads](https://img.shields.io/pypi/dm/pyneo4j-ogm?style=flat-square)](https://pypi.org/project/pyneo4j-ogm/)\n\n[`pyneo4j-ogm`](https://github.com/groc-prog/pyneo4j-ogm/tree/main) is a asynchronous `Object-Graph-Mapper` for [`Neo4j 5+`](https://neo4j.com/docs/) and [`Python 3.10+`](https://www.python.org/). It is inspired by [`beanie`](https://github.com/roman-right/beanie) and build on top of proven technologies like [`Pydantic 1.10+ and 2+`](https://docs.pydantic.dev/latest/) and the [`Neo4j Python Driver`](https://neo4j.com/docs/api/python-driver/current/index.html). 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.\n\n## \ud83c\udfaf Features <a name=\"features\"></a>\n\n[`pyneo4j-ogm`](https://github.com/groc-prog/pyneo4j-ogm/tree/main) has a lot to offer, including:\n\n- [x] **Fully typed**: pyneo4j-ogm is `fully typed` out of the box.\n- [x] **Powerful validation**: Since we use Pydantic under the hood, you can use it's powerful validation and serialization features without any issues.\n- [x] **Focus on developer experience**: Designed to be simple to use, pyneo4j-ogm provides features for both simple queries and more `advanced use-cases` while keeping it's API as simple as possible.\n- [x] **Build-in migration tooling**: Shipped with simple, yet flexible migration tooling.\n- [x] **Fully asynchronous**: Completely asynchronous code, thanks to the `Neo4j Python Driver`.\n- [x] **Supports Neo4j 5+**: pyneo4j-ogm supports `Neo4j 5+` and is tested against the latest version of Neo4j.\n- [x] **Multi-version Pydantic support**: Both `Pydantic 1.10+` and `2+` fully supported.\n\n## \ud83d\udce3 Announcements\n\nThings to come in the future. Truly exiting stuff! If you have feature requests which you think might improve `pyneo4j-ogm`, feel free to open up a feature request.\n\n- [ ] Auto-generated migrations\n\n## \ud83d\udce6 Installation <a name=\"installation\"></a>\n\nUsing [`pip`](https://pip.pypa.io/en/stable/):\n\n```bash\npip install pyneo4j-ogm\n```\n\nor when using [`Poetry`](https://python-poetry.org/):\n\n```bash\npoetry add pyneo4j-ogm\n```\n\n## \ud83d\ude80 Quickstart <a name=\"quickstart\"></a>\n\nBefore we can get going, we have to take care of some things:\n\n- We need to define our models, which will represent the nodes and relationships inside our database.\n- We need a database client, which will do the actual work for us.\n\n### Defining our data structures\n\nSince every developer has a coffee addiction one way or another, we are going to use `Coffee` and `Developers` for this guide. So let's start by defining what our data should look like:\n\n```python\nfrom pyneo4j_ogm import (\n    NodeModel,\n    RelationshipModel,\n    RelationshipProperty,\n    RelationshipPropertyCardinality,\n    RelationshipPropertyDirection,\n    WithOptions,\n)\nfrom pydantic import Field\nfrom uuid import UUID, uuid4\n\n\nclass Developer(NodeModel):\n  \"\"\"\n  This class represents a `Developer` node inside the graph. All interactions\n  with nodes of this type will be handled by this class.\n  \"\"\"\n  uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)\n  name: str\n  age: int\n\n  coffee: RelationshipProperty[\"Coffee\", \"Consumed\"] = RelationshipProperty(\n    target_model=\"Coffee\",\n    relationship_model=\"Consumed\",\n    direction=RelationshipPropertyDirection.OUTGOING,\n    cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n    allow_multiple=True,\n  )\n\n  class Settings:\n    # Hooks are available for all methods that interact with the database.\n    post_hooks = {\n      \"coffee.connect\": lambda self, *args, **kwargs: print(f\"{self.name} chugged another one!\")\n    }\n\n\nclass Coffee(NodeModel):\n  \"\"\"\n  This class represents a node with the labels `Beverage` and `Hot`. Notice\n  that the labels of this model are explicitly defined in the `Settings` class.\n  \"\"\"\n  flavor: str\n  sugar: bool\n  milk: bool\n\n  developers: RelationshipProperty[\"Developer\", \"Consumed\"] = RelationshipProperty(\n    target_model=Developer,\n    relationship_model=\"Consumed\",\n    direction=RelationshipPropertyDirection.INCOMING,\n    cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n    allow_multiple=True,\n  )\n\n  class Settings:\n    labels = {\"Beverage\", \"Hot\"}\n\nclass Consumed(RelationshipModel):\n  \"\"\"\n  Unlike the models above, this class represents a relationship between two\n  nodes. In this case, it represents the relationship between the `Developer`\n  and `Coffee` models. Like with node-models, the `Settings` class allows us to\n  define some configuration for this relationship.\n\n  Note that the relationship itself does not define it's start- and end-nodes,\n  making it reusable for other models as well.\n  \"\"\"\n  liked: bool\n\n  class Settings:\n    type = \"CHUGGED\"\n```\n\nUntil now everything seems pretty standard if you have worked with other ORM's before. But if you haven't, we are going to go over what happened above:\n\n- We defined 2 node models `Developer` and `Coffee`, and a relationship `Consumed`.\n- Some models define a special inner `Settings` class. This is used to customize the behavior of our models inside the graph. More on these settings can be found ['here'](#model-settings).\n- The `WithOptions` function has been used to define `constraints and indexes` (more about them [`here`](#manual-indexing-and-constraints)) on model properties.\n\n### Creating a database client\n\nIn pyneo4j-ogm, the real work is done by a database client. One of these bad-boys can be created by initializing a `Pyneo4jClient` instance. But for models to work as expected, we have to let our client know that we want to use them like so:\n\n```python\nfrom pyneo4j_ogm import Pyneo4jClient\n\nasync def main():\n  # We initialize a new `Pyneo4jClient` instance and connect to the database.\n  client = Pyneo4jClient()\n\n  # Replace `<connection-uri-to-database>`, `<username>` and `<password>` with the\n  # actual values.\n  await client.connect(uri=\"<connection-uri-to-database>\", auth=(\"<username>\", \"<password>\"))\n\n  # To use our models for running queries later on, we have to register\n  # them with the client.\n  # **Note**: You only have to register the models that you want to use\n  # for queries and you can even skip this step if you want to use the\n  # `Pyneo4jClient` instance for running raw queries.\n  await client.register_models([Developer, Coffee, Consumed])\n```\n\n### Interacting with the database\n\nNow the fun stuff begins! We are ready to interact with our database. For the sake of this [`quickstart guide`](#quickstart) we are going to keep it nice and simple, but this is just the surface of what pyneo4j-ogm has to offer.\n\nWe are going to create a new `Developer` and some `Coffee` and give him something to drink:\n\n```python\n# Imagine your models have been defined above...\n\nasync def main():\n  # And your client has been initialized and connected to the database...\n\n  # We create a new `Developer` node and the `Coffee` he is going to drink.\n  john = Developer(name=\"John\", age=25)\n  await john.create()\n\n  cappuccino = Coffee(flavor=\"Cappuccino\", milk=True, sugar=False)\n  await cappuccino.create()\n\n  # Here we create a new relationship between `john` and his `cappuccino`.\n  # Additionally, we set the `liked` property of the relationship to `True`.\n  await john.coffee.connect(cappuccino, {\"liked\": True}) # Will print `John chugged another one!`\n```\n\n### Full example\n\n```python\nimport asyncio\nfrom pyneo4j_ogm import (\n    NodeModel,\n    Pyneo4jClient,\n    RelationshipModel,\n    RelationshipProperty,\n    RelationshipPropertyCardinality,\n    RelationshipPropertyDirection,\n    WithOptions,\n)\nfrom pydantic import Field\nfrom uuid import UUID, uuid4\n\nclass Developer(NodeModel):\n  \"\"\"\n  This class represents a `Developer` node inside the graph. All interaction\n  with nodes of this type will be handled by this class.\n  \"\"\"\n  uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)\n  name: str\n  age: int\n\n  coffee: RelationshipProperty[\"Coffee\", \"Consumed\"] = RelationshipProperty(\n    target_model=\"Coffee\",\n    relationship_model=\"Consumed\",\n    direction=RelationshipPropertyDirection.OUTGOING,\n    cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n    allow_multiple=True,\n  )\n\n  class Settings:\n    # Hooks are available for all methods that interact with the database.\n    post_hooks = {\n      \"coffee.connect\": lambda self, *args, **kwargs: print(f\"{self.name} chugged another one!\")\n    }\n\n\nclass Coffee(NodeModel):\n  \"\"\"\n  This class represents a node with the labels `Beverage` and `Hot`. Notice\n  that the labels of this model are explicitly defined in the `Settings` class.\n  \"\"\"\n  flavor: str\n  sugar: bool\n  milk: bool\n\n  developers: RelationshipProperty[\"Developer\", \"Consumed\"] = RelationshipProperty(\n    target_model=Developer,\n    relationship_model=\"Consumed\",\n    direction=RelationshipPropertyDirection.INCOMING,\n    cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n    allow_multiple=True,\n  )\n\n  class Settings:\n    labels = {\"Beverage\", \"Hot\"}\n\nclass Consumed(RelationshipModel):\n  \"\"\"\n  Unlike the models above, this class represents a relationship between two\n  nodes. In this case, it represents the relationship between the `Developer`\n  and `Coffee` models. Like with node-models, the `Settings` class allows us to\n  define some settings for this relationship.\n\n  Note that the relationship itself does not define it's start- and end-nodes,\n  making it reusable for other models as well.\n  \"\"\"\n  liked: bool\n\n  class Settings:\n    type = \"CHUGGED\"\n\n\nasync def main():\n  # We initialize a new `Pyneo4jClient` instance and connect to the database.\n  client = Pyneo4jClient()\n  await client.connect(uri=\"<connection-uri-to-database>\", auth=(\"<username>\", \"<password>\"))\n\n  # To use our models for running queries later on, we have to register\n  # them with the client.\n  # **Note**: You only have to register the models that you want to use\n  # for queries and you can even skip this step if you want to use the\n  # `Pyneo4jClient` instance for running raw queries.\n  await client.register_models([Developer, Coffee, Consumed])\n\n  # We create a new `Developer` node and the `Coffee` he is going to drink.\n  john = Developer(name=\"John\", age=25)\n  await john.create()\n\n  cappuccino = Coffee(flavor=\"Cappuccino\", milk=True, sugar=False)\n  await cappuccino.create()\n\n  # Here we create a new relationship between `john` and his `cappuccino`.\n  # Additionally, we set the `liked` property of the relationship to `True`.\n  await john.coffee.connect(cappuccino, {\"liked\": True}) # Will print `John chugged another one!`\n\n  # Be a good boy and close your connections after you are done.\n  await client.close()\n\nasyncio.run(main())\n```\n\nAnd that's it! You should now see a `Developer` and a `Hot/Beverage` node, connected by a `CONSUMED` relationship. If you want to learn more about the library, you can check out the full [`Documentation`](#documentation).\n\n## \ud83d\udcda Documentation <a name=\"documentation\"></a>\n\nIn 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`.\n\n### Table of contents\n\n- [pyneo4j-ogm](#pyneo4j-ogm)\n  - [\ud83c\udfaf Features ](#-features-)\n  - [\ud83d\udce3 Announcements](#-announcements)\n  - [\ud83d\udce6 Installation ](#-installation-)\n  - [\ud83d\ude80 Quickstart ](#-quickstart-)\n    - [Defining our data structures](#defining-our-data-structures)\n    - [Creating a database client](#creating-a-database-client)\n    - [Interacting with the database](#interacting-with-the-database)\n    - [Full example](#full-example)\n  - [\ud83d\udcda Documentation ](#-documentation-)\n    - [Table of contents](#table-of-contents)\n    - [Basic concepts ](#basic-concepts-)\n    - [A note on Pydantic version support](#a-note-on-pydantic-version-support)\n    - [Database client](#database-client)\n      - [Connecting to the database ](#connecting-to-the-database-)\n      - [Closing an existing connection ](#closing-an-existing-connection-)\n      - [Registering models ](#registering-models-)\n      - [Executing Cypher queries ](#executing-cypher-queries-)\n      - [Batching cypher queries ](#batching-cypher-queries-)\n      - [Using bookmarks (Enterprise Edition only) ](#using-bookmarks-enterprise-edition-only-)\n      - [Manual indexing and constraints ](#manual-indexing-and-constraints-)\n      - [Client utilities ](#client-utilities-)\n    - [Models ](#models-)\n      - [Indexes, constraints and properties ](#indexes-constraints-and-properties-)\n      - [Reserved properties ](#reserved-properties-)\n      - [Configuration settings ](#configuration-settings-)\n        - [NodeModel configuration ](#nodemodel-configuration-)\n        - [RelationshipModel configuration ](#relationshipmodel-configuration-)\n      - [Available methods ](#available-methods-)\n        - [Instance.update() ](#instanceupdate-)\n        - [Instance.delete() ](#instancedelete-)\n        - [Instance.refresh() ](#instancerefresh-)\n        - [Model.find\\_one() ](#modelfind_one-)\n          - [Projections ](#projections-)\n          - [Auto-fetching nodes ](#auto-fetching-nodes-)\n          - [Raise on empty result ](#raise-on-empty-result-)\n        - [Model.find\\_many() ](#modelfind_many-)\n          - [Filters ](#filters-)\n          - [Projections ](#projections--1)\n          - [Query options ](#query-options-)\n          - [Auto-fetching nodes ](#auto-fetching-nodes--1)\n        - [Model.update\\_one() ](#modelupdate_one-)\n          - [Returning the updated entity ](#returning-the-updated-entity-)\n          - [Raise on empty result ](#raise-on-empty-result--1)\n        - [Model.update\\_many() ](#modelupdate_many-)\n          - [Filters ](#filters--1)\n          - [Returning the updated entity ](#returning-the-updated-entity--1)\n        - [Model.delete\\_one() ](#modeldelete_one-)\n          - [Raise on empty result ](#raise-on-empty-result--2)\n        - [Model.delete\\_many() ](#modeldelete_many-)\n          - [Filters ](#filters--2)\n        - [Model.count() ](#modelcount-)\n          - [Filters ](#filters--3)\n        - [NodeModelInstance.create() ](#nodemodelinstancecreate-)\n        - [NodeModelInstance.find\\_connected\\_nodes() ](#nodemodelinstancefind_connected_nodes-)\n          - [Projections ](#projections--2)\n          - [Query options ](#query-options--1)\n          - [Auto-fetching nodes ](#auto-fetching-nodes--2)\n        - [RelationshipModelInstance.start\\_node() ](#relationshipmodelinstancestart_node-)\n        - [RelationshipModelInstance.end\\_node() ](#relationshipmodelinstanceend_node-)\n      - [Serializing models ](#serializing-models-)\n      - [Hooks ](#hooks-)\n        - [Pre-hooks ](#pre-hooks-)\n        - [Post-hooks ](#post-hooks-)\n      - [Model settings ](#model-settings-)\n    - [Relationship-properties ](#relationship-properties-)\n      - [Available methods ](#available-methods--1)\n        - [RelationshipProperty.relationships() ](#relationshippropertyrelationships-)\n          - [Filters ](#filters--4)\n          - [Projections ](#projections--3)\n          - [Query options ](#query-options--2)\n        - [RelationshipProperty.connect() ](#relationshippropertyconnect-)\n        - [RelationshipProperty.disconnect() ](#relationshippropertydisconnect-)\n          - [Raise on empty result ](#raise-on-empty-result--3)\n        - [RelationshipProperty.disconnect\\_all() ](#relationshippropertydisconnect_all-)\n        - [RelationshipProperty.replace() ](#relationshippropertyreplace-)\n        - [RelationshipProperty.find\\_connected\\_nodes() ](#relationshippropertyfind_connected_nodes-)\n          - [Filters ](#filters--5)\n          - [Projections ](#projections--4)\n          - [Query options ](#query-options--3)\n          - [Auto-fetching nodes ](#auto-fetching-nodes--3)\n      - [Hooks with relationship properties ](#hooks-with-relationship-properties-)\n    - [Queries ](#queries-)\n      - [Filtering queries ](#filtering-queries-)\n        - [Comparison operators ](#comparison-operators-)\n        - [String operators ](#string-operators-)\n        - [List operators ](#list-operators-)\n        - [Logical operators ](#logical-operators-)\n        - [Element operators ](#element-operators-)\n        - [Pattern matching ](#pattern-matching-)\n        - [Multi-hop filters ](#multi-hop-filters-)\n      - [Projections ](#projections--5)\n      - [Query options ](#query-options--4)\n      - [Auto-fetching relationship-properties ](#auto-fetching-relationship-properties-)\n    - [Migrations ](#migrations-)\n      - [Initializing migrations for your project ](#initializing-migrations-for-your-project-)\n      - [Creating a new migration ](#creating-a-new-migration-)\n      - [Running migrations ](#running-migrations-)\n      - [Listing migrations ](#listing-migrations-)\n      - [Programmatic usage ](#programmatic-usage-)\n    - [Logging ](#logging-)\n    - [Running the test suite ](#running-the-test-suite-)\n\n### Basic concepts <a name=\"basic-concepts\"></a>\n\nAs 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 still providing the most common operations and some more advanced features.\n\nBut first, how does this even work!?! Well, the basic concept boils down to the following:\n\n- You define your models that represent your nodes and relationships inside the graph.\n- You use these models to do all sorts of things with your data.\n\nOf 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.\n\n> **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`](#quickstart). The models used in the following examples will build upon the ones defined there. If you are new to [`Neo4j`](https://neo4j.com/docs/) or [`Cypher`](https://neo4j.com/docs/cypher-manual/current/) in general, you should get a basic understanding of how to use them before continuing.\n\n### A note on Pydantic version support\n\nAs of version [`v0.3.0`](https://github.com/groc-prog/pyneo4j-ogm/blob/main/CHANGELOG.md#v030-2023-11-30), pyneo4j-ogm now supports both `Pydantic 1.10+ and 2+`. All core features of pydantic should work, meaning full support for model serialization, validation and schema generation.\n\nShould you find any issues or run into any problems, feel free to open a issue!\n\n### Database client\n\nThis is where 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. Because of this, we have to always have at least one client initialized before doing anything else.\n\n#### Connecting to the database <a name=\"connecting-to-the-database\"></a>\n\nBefore 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:\n\n- `uri`: The connection URI to the database.\n- `skip_constraints`: Whether the client should skip creating any constraints defined on models when registering them. Defaults to `False`.\n- `skip_indexes`: Whether the client should skip creating any indexes defined on models when registering them. Defaults to `False`.\n- `*args`: Additional arguments that are passed directly to Neo4j's `AsyncDriver.driver()` method.\n- `**kwargs`: Additional keyword arguments that are passed directly to Neo4j's `AsyncDriver.driver()` method.\n\n```python\nfrom pyneo4j_ogm import Pyneo4jClient\n\nclient = Pyneo4jClient()\nawait client.connect(uri=\"<connection-uri-to-database>\", auth=(\"<username>\", \"<password>\"), max_connection_pool_size=10, ...)\n\n# Or chained right after the instantiation of the class\nclient = await Pyneo4jClient().connect(uri=\"<connection-uri-to-database>\", auth=(\"<username>\", \"<password>\"), max_connection_pool_size=10, ...)\n```\n\nAfter 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 (it happens to the best of us), you will get a `NotConnectedToDatabase` exception.\n\n#### Closing an existing connection <a name=\"closing-an-existing-connection\"></a>\n\nConnections can explicitly be closed by calling the `close()` method. 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!\n\n```python\n# Do some heavy-duty work...\n\n# Finally done, so we close the connection to the database.\nawait client.close()\n```\n\nOnce 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\n\n#### Registering models <a name=\"registering-models\"></a>\n\nModels are a core feature of pyneo4j-ogm, and therefore you probably want to use some. But to work with them, they have to be registered with the client by calling the `register_models()` method and passing in your models as a list:\n\n```python\n# Create a new client instance and connect ...\n\nawait client.register_models([Developer, Coffee, Consumed])\n```\n\nThis 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.\n\nIf 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.\n\n> **Note**: 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.\n\n#### Executing Cypher queries <a name=\"executing-cypher-queries\"></a>\n\nModels aren't the only things capable of running queries. The client can also be used to run queries, with some additional functionality to make your life easier.\n\nNode- 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:\n\n- `query`: The cypher query to execute.\n- `parameters`: A dictionary containing the parameters to pass to the query.\n- `resolve_models`: Whether the client should try to resolve the models from the query results. Defaults to `True`.\n\nThis 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.\n\n> **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.\n\nHere is an example of how to execute a custom cypher query:\n\n```python\nresults, meta = await client.cypher(\n  query=\"CREATE (d:Developer {uid: '553ac2c9-7b2d-404e-8271-40426ae80de0', name: 'John', age: 25}) RETURN d.name as developer_name, d.age\",\n  parameters={\"name\": \"John Doe\"},\n  resolve_models=False,  # Explicitly disable model resolution\n)\n\nprint(results)  # [[\"John\", 25]]\nprint(meta)  # [\"developer_name\", \"d.age\"]\n```\n\n#### Batching cypher queries <a name=\"batching-cypher-queries\"></a>\n\nWe provide an easy way to batch multiple database queries together, regardless of whether you are using the client directly or via a model method. To do this you can use the `batch()` method, which has to be called with a asynchronous context manager like in the following example:\n\n```python\nasync with client.batch():\n  # All queries executed inside the context manager will be batched into a single transaction\n  # and executed once the context manager exits. If any of the queries fail, the whole transaction\n  # will be rolled back.\n  await client.cypher(\n    query=\"CREATE (d:Developer {uid: $uid, name: $name, age: $age})\",\n    parameters={\"uid\": \"553ac2c9-7b2d-404e-8271-40426ae80de0\", \"name\": \"John Doe\", \"age\": 25},\n  )\n  await client.cypher(\n    query=\"CREATE (c:Coffee {flavour: $flavour, milk: $milk, sugar: $sugar})\",\n    parameters={\"flavour\": \"Espresso\", \"milk\": False, \"sugar\": False},\n  )\n\n  # Model queries also can be batched together without any extra work!\n  coffee = await Coffee(flavour=\"Americano\", milk=False, sugar=False).create()\n```\n\nYou can batch anything that runs a query, be that a model method, a custom query or a relationship-property method. If any of the queries fail, the whole transaction will be rolled back and an exception will be raised.\n\n#### Using bookmarks (Enterprise Edition only) <a name=\"using-bookmarks\"></a>\n\nIf you are using the Enterprise Edition of Neo4j, you can use bookmarks to keep track of the last transaction that has been committed. The client provides a `last_bookmarks` property that allows you to get the bookmarks from the last session. These bookmarks can be used in combination with the `use_bookmarks()` method. Like the `batch()` method, the `use_bookmarks()` method has to be called with a context manager. All queries run inside the context manager will use the bookmarks passed to the `use_bookmarks()` method. Here is an example of how to use bookmarks:\n\n```python\n# Create a new node and get the bookmarks from the last session\nawait client.cypher(\"CREATE (d:Developer {name: 'John Doe', age: 25})\")\nbookmarks = client.last_bookmarks\n\n# Create another node, but this time don't get the bookmark\n# When we use the bookmarks from the last session, this node will not be visible\nawait client.cypher(\"CREATE (c:Coffee {flavour: 'Espresso', milk: False, sugar: False})\")\n\nwith client.use_bookmarks(bookmarks=bookmarks):\n  # All queries executed inside the context manager will use the bookmarks\n  # passed to the `use_bookmarks()` method.\n\n  # Here we will only see the node created in the first query\n  results, meta = await client.cypher(\"MATCH (n) RETURN n\")\n\n  # Model queries also can be batched together without any extra work!\n  # This will return no results, since the coffee node was created after\n  # the bookmarks were taken.\n  coffee = await Coffee.find_many()\n  print(coffee)  # []\n```\n\n#### Manual indexing and constraints <a name=\"manual-indexing-and-constraints\"></a>\n\nMost of the time, the creation of indexes/constraints will be handled by the models themselves. But it can still be handy to have a simple way of creating new ones. This is where the `create_lookup_index()`, `create_range_index`, `create_text_index`, `create_point_index` and `create_uniqueness_constraint()` methods come in.\n\nFirst, let's take a look at how to create a custom index in the database. The `create_range_index`, `create_text_index` and `create_point_index` methods take a few arguments:\n\n- `name`: The name of the index to create (Make sure this is unique!).\n- `entity_type`: The entity type the index is created for. Can be either **EntityType.NODE** or **EntityType.RELATIONSHIP**.\n- `properties`: A list of properties to create the index for.\n- `labels_or_type`: The node labels or relationship type the index is created for.\n\nThe `create_lookup_index()` takes the same arguments, except for the `labels_or_type` and `properties` arguments.\n\nThe `create_uniqueness_constraint()` method also takes similar arguments.\n\n- `name`: The name of the constraint to create.\n- `entity_type`: The entity type the constraint is created for. Can be either **EntityType.NODE** or **EntityType.RELATIONSHIP**.\n- `properties`: A list of properties to create the constraint for.\n- `labels_or_type`: The node labels or relationship type the constraint is created for.\n\nHere is an example of how to use the methods:\n\n```python\n# Creates a `RANGE` index for a `Coffee's` `sugar` and `flavour` properties\nawait client.create_range_index(\"hot_beverage_index\", EntityType.NODE, [\"sugar\", \"flavour\"], [\"Beverage\", \"Hot\"])\n\n# Creates a UNIQUENESS constraint for a `Developer's` `uid` property\nawait client.create_uniqueness_constraint(\"developer_constraint\", EntityType.NODE, [\"uid\"], [\"Developer\"])\n```\n\n#### Client utilities <a name=\"client-utilities\"></a>\n\nThe client also provides some additional utility methods, which mostly exist for convenience when writing tests or setting up environments:\n\n- `is_connected()`: Returns whether the client is currently connected to a database.\n- `drop_nodes()`: Drops all nodes from the database.\n- `drop_constraints()`: Drops all constraints from the database.\n- `drop_indexes()`: Drops all indexes from the database.\n\n### Models <a name=\"models\"></a>\n\nAs shown in the [`quickstart guide`](#quickstart), 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.\n\nA 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.\n\nThis 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.\n\nFilters 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.\n\n#### Indexes, constraints and properties <a name=\"indexes-constraints-and-properties\"></a>\n\nSince `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`](https://docs.pydantic.dev/latest/concepts/json_schema/#schema-customization).\n\nOn 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:\n\n- `property_type`: The datatype of the property. Must be a valid `Pydantic` type.\n- `range_index`: Whether to create a range index on the property. Defaults to `False`.\n- `text_index`: Whether to create a text index on the property. Defaults to `False`.\n- `point_index`: Whether to create a point index on the property. Defaults to `False`.\n- `unique`: Whether to create a uniqueness constraint on the property. Defaults to `False`.\n\n> **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).\n\n```python\nfrom pyneo4j_ogm import NodeModel, WithOptions\nfrom pydantic import Field\nfrom uuid import UUID, uuid4\n\nclass Developer(NodeModel):\n  \"\"\"\n  A model representing a developer node in the graph.\n  \"\"\"\n  # Using the `WithOptions` method on the type, we can still use all of the features provided by\n  # `Pydantic` while also defining indexes and constraints on the property.\n  uid: WithOptions(UUID, unique=True) = Field(default_factory=uuid4)\n  name: WithOptions(str, text_index=True)\n  # Has no effect, since no index or constraint options are passed\n  age: WithOptions(int)\n```\n\nThere 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`](#relationship-properties) section.\n\n#### Reserved properties <a name=\"reserved-properties\"></a>\n\nNode- and RelationshipModels have a few pre-defined properties which reflect the entity inside the graph and are used internally in queries. These properties are:\n\n- `element_id`: The element id of the entity inside the graph. This property is used internally to identify the entity inside the graph.\n- `id`: The id of the entity inside the graph.\n- `modified_properties`: A set of properties which have been modified on the\n\nThe `RelationshipModel` class has some additional properties:\n\n- `start_node_element_id`: The element id of the start node of the relationship.\n- `start_node_id`: The ID of the start node of the relationship.\n- `end_node_element_id`: The element id of the end node of the relationship.\n- `end_node_id`: The ID of the end node of the relationship.\n\nThese properties are implemented as class properties and allow you to access the graph properties of you models.\n\n#### Configuration settings <a name=\"configuration-settings\"></a>\n\nBoth `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.\n\nModel 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`:\n\n```python\nclass Coffee(NodeModel):\n  flavour: str\n  sugar: bool\n  milk: bool\n\n  class Settings:\n    # This is the place where the magic happens!\n```\n\n##### NodeModel configuration <a name=\"node-model-configuration\"></a>\n\nThe `Settings` class of a `NodeModel` provides the following properties:\n\n| Setting name          | Type                          | Description                                                                                                                                                                                                                                                                                                                              |\n| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 `{}`. |\n| `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 `{}`.                                                                                                                              |\n| `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`.                                                                                                                                                                                                                            |\n| `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`. |\n\n##### RelationshipModel configuration <a name=\"relationship-model-configuration\"></a>\n\nFor 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.\n\n| Setting name          | Type                          | Description                                                                                                                                                                                                                                                                                                                              |\n| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 `{}`. |\n| `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 `{}`.                                                                                                                              |\n| `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`. |\n\n> **Note:** Hooks can be defined for all native 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 the `connect()` method of a relationship-property named `coffee`, you would have to pass `coffee.connect` as the key. This is true for both Node- and Relationship-models.\n\n#### Available methods <a name=\"model-available-methods\"></a>\n\nRunning cypher queries manually is nice and all, but something else 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.\n\nBut 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`](https://docs.python.org/3/library/asyncio.html) before continuing.\n\n> **Note**: 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`.\n>\n> - `Model.method()`: The `class method` is available on instances of both `NodeModel` and `RelationshipModel` classes.\n> - `Instance.method()`: The `instance method` is available on instances of both `NodeModel` and `RelationshipModel` classes.\n> - `<Type>Model.method()`: The `class method` is available on instances of the `<Type>Model` class.\n> - `<Type>ModelInstance.method()`: The `instance method` is available on instances of the `<Type>Model` class.\n\n##### Instance.update() <a name=\"instance-update\"></a>\n\nThe `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.\n\n```python\n# In this context, the developer `john` has been created before and the `name` property has been\n# not been changed since.\n\n# Maybe we want to name him James instead?\njohn.name = \"James\"\n\nprint(john.modified_properties)  # {\"name\"}\n\n# Will update the `name` property of the `john` node inside the graph\n# And suddenly he is James!\nawait john.update()\n```\n\n##### Instance.delete() <a name=\"instance-delete\"></a>\n\nThe `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.\n\n```python\n# In this context, the developer `john` has been created before and is seen as `hydrated` (aka it\n# has been saved to the database before).\n\n# This will delete the `john` node inside the graph and mark your local instance as `destroyed`.\nawait john.delete()\n\nawait john.update()  # Raises `InstanceDestroyed` exception\n```\n\n##### Instance.refresh() <a name=\"instance-refresh\"></a>\n\nSyncs your local instance with the properties from the corresponding graph entity. \u00b4This method can be useful if you want to make sure that your local instance is always up-to-date with the graph entity.\n\nIt 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!).\n\n```python\n# Maybe we want to name him James instead?\njohn.name = \"James\"\n\n# Oh no, don't take my `john` away!\nawait john.refresh()\n\nprint(john.name) # 'John'\n```\n\n##### Model.find_one() <a name=\"model-find-one\"></a>\n\nThe `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.\n\nThis method takes a mandatory `filters` argument, which is used to filter the results. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Return the first encountered node where the name property equals `John`.\n# This method always needs a filter to go with it!\njohn_or_nothing = await Developer.find_one({\"name\": \"John\"})\n\nprint(developer) # <Developer> or None\n```\n\n###### Projections <a name=\"model-find-one-projections\"></a>\n\n`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`](#query-projections)\n\n```python\n# Return a dictionary with the developers name at the `dev_name` key instead\n# of a model instance.\ndeveloper = await Developer.find_one({\"name\": \"John\"}, {\"dev_name\": \"name\"})\n\nprint(developer) # {\"dev_name\": \"John\"}\n```\n\n###### Auto-fetching nodes <a name=\"model-find-one-auto-fetching-nodes\"></a>\n\nThe `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`](#query-auto-fetching).\n\n> **Note**: The `auto_fetch_nodes` and `auto_fetch_models` parameters are only available for classes which inherit from the `NodeModel` class.\n\n```python\n# Returns a developer instance with `instance.<property>.nodes` properties already fetched\ndeveloper = await Developer.find_one({\"name\": \"John\"}, auto_fetch_nodes=True)\n\nprint(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developer.other_property.nodes) # [<OtherModel>, <OtherModel>, ...]\n\n# Returns a developer instance with only the `instance.coffee.nodes` property already fetched\ndeveloper = await Developer.find_one({\"name\": \"John\"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee])\n\n# Auto-fetch models can also be passed as strings\ndeveloper = await Developer.find_one({\"name\": \"John\"}, auto_fetch_nodes=True, auto_fetch_models=[\"Coffee\"])\n\nprint(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developer.other_property.nodes) # []\n```\n\n###### Raise on empty result <a name=\"model-find-one-raise-on-empty-result\"></a>\n\nBy 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.\n\n```python\n# Raises a `NoResultFound` exception if no results were found\ndeveloper = await Developer.find_one({\"name\": \"John\"}, raise_on_empty=True)\n```\n\n##### Model.find_many() <a name=\"model-find-many\"></a>\n\nThe `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.\n\n```python\n# Returns ALL `Developer` nodes\ndevelopers = await Developer.find_many()\n\nprint(developers) # [<Developer>, <Developer>, <Developer>, ...]\n```\n\n###### Filters <a name=\"model-find-many-filters\"></a>\n\nJust like the `find_one()` method, the `find_many()` method also takes (optional) filters. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Returns all `Developer` nodes where the age property is greater than or\n# equal to 21 and less than 45.\ndevelopers = await Developer.find_many({\"age\": {\"$and\": [{\"$gte\": 21}, {\"$lt\": 45}]}})\n\nprint(developers) # [<Developer>, <Developer>, <Developer>, ...]\n```\n\n###### Projections <a name=\"model-find-many-projections\"></a>\n\n`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.\n\n```python\n# Returns dictionaries with the developers name at the `dev_name` key instead\n# of model instances\ndevelopers = await Developer.find_many({\"name\": \"John\"}, {\"dev_name\": \"name\"})\n\nprint(developers) # [{\"dev_name\": \"John\"}, {\"dev_name\": \"John\"}, ...]\n```\n\n###### Query options <a name=\"model-find-many-query-options\"></a>\n\n`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`](#query-options) section.\n\n```python\n# Skips the first 10 results and returns the next 20\ndevelopers = await Developer.find_many({\"name\": \"John\"}, options={\"limit\": 20, \"skip\": 10})\n\nprint(developers) # [<Developer>, <Developer>, ...] up to 20 results\n```\n\n###### Auto-fetching nodes <a name=\"model-find-many-auto-fetching-nodes\"></a>\n\nThe `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`](#query-auto-fetching).\n\n> **Note**: The `auto_fetch_nodes` and `auto_fetch_models` parameters are only available for classes which inherit from the `NodeModel` class.\n\n```python\n# Returns developer instances with `instance.<property>.nodes` properties already fetched\ndevelopers = await Developer.find_many({\"name\": \"John\"}, auto_fetch_nodes=True)\n\nprint(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developers[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]\n\n# Returns developer instances with only the `instance.coffee.nodes` property already fetched\ndevelopers = await Developer.find_many({\"name\": \"John\"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee])\n\n# Auto-fetch models can also be passed as strings\ndevelopers = await Developer.find_many({\"name\": \"John\"}, auto_fetch_nodes=True, auto_fetch_models=[\"Coffee\"])\n\nprint(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developers[0].other_property.nodes) # []\n```\n\n##### Model.update_one() <a name=\"model-update-one\"></a>\n\nThe `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.\n\nThis method takes two mandatory arguments:\n\n- `update`: A dictionary containing the properties to update.\n- `filters`: A dictionary containing the filters to use when searching for a match. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Updates the `age` property to `30` in the first encountered node where the name property equals `John`\n# The `i_do_not_exist` property will be ignored since it has not been defined on the model\ndeveloper = await Developer.update_one({\"age\": 30, \"i_do_not_exist\": True}, {\"name\": \"John\"})\n\nprint(developer) # <Developer age=25>\n\n# Or if no match was found\nprint(developer) # None\n```\n\n###### Returning the updated entity <a name=\"model-update-one-new\"></a>\n\nBy 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`.\n\n```python\n# Updates the `age` property to `30` in the first encountered node where the name property equals `John`\n# and returns the updated node\ndeveloper = await Developer.update_one({\"age\": 30}, {\"name\": \"John\"}, True)\n\nprint(developer) # <Developer age=30>\n```\n\n###### Raise on empty result <a name=\"model-update-one-raise-on-empty-result\"></a>\n\nBy 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.\n\n```python\n# Raises a `NoResultFound` exception if no results were matched\ndeveloper = await Developer.update_one({\"age\": 30}, {\"name\": \"John\"}, raise_on_empty=True)\n```\n\n##### Model.update_many() <a name=\"model-update-many\"></a>\n\nThe `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.\n\nThis method takes one mandatory argument `update` which defines which properties to update with which values.\n\n```python\n# Updates the `age` property of all `Developer` nodes to 40\ndevelopers = await Developer.update_many({\"age\": 40})\n\nprint(developers) # [<Developer age=25>, <Developer age=23>, ...]\n\n# Or if no matches were found\nprint(developers) # []\n```\n\n###### Filters <a name=\"model-update-many-filters\"></a>\n\nOptionally, a `filters` argument can be provided, which defines which entities to update. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Updates all `Developer` nodes where the age property is between `22` and `30`\n# to `40`\ndevelopers = await Developer.update_many({\"age\": 40}, {\"age\": {\"$gte\": 22, \"$lte\": 30}})\n\nprint(developers) # [<Developer age=25>, <Developer age=23>, ...]\n```\n\n###### Returning the updated entity <a name=\"model-update-many-new\"></a>\n\nBy 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`.\n\n```python\n# Updates all `Developer` nodes where the age property is between `22` and `30`\n# to `40` and return the updated nodes\ndevelopers = await Developer.update_many({\"age\": 40}, {\"age\": {\"$gte\": 22, \"$lte\": 30}})\n\nprint(developers) # [<Developer age=40>, <Developer age=40>, ...]\n```\n\n##### Model.delete_one() <a name=\"model-delete-one\"></a>\n\nThe `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.\n\nThis method takes one mandatory argument `filters` which defines which entity to delete. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Deletes the first `Developer` node where the name property equals `John`\ncount = await Developer.delete_one({\"name\": \"John\"})\n\nprint(count) # 1\n\n# Or if no match was found\nprint(count) # 0\n```\n\n###### Raise on empty result <a name=\"model-delete-one-raise-on-empty-result\"></a>\n\nBy 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.\n\n```python\n# Raises a `NoResultFound` exception if no results were matched\ncount = await Developer.delete_one({\"name\": \"John\"}, raise_on_empty=True)\n```\n\n##### Model.delete_many() <a name=\"model-delete-many\"></a>\n\nThe `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.\n\n```python\n# Deletes all `Developer` nodes\ncount = await Developer.delete_many()\n\nprint(count) # However many nodes matched the filter\n\n# Or if no match was found\nprint(count) # 0\n```\n\n###### Filters <a name=\"model-delete-many-filters\"></a>\n\nOptionally, a `filters` argument can be provided, which defines which entities to delete. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Deletes all `Developer` nodes where the age property is greater than `65`\ncount = await Developer.delete_many({\"age\": {\"$gt\": 65}})\n\nprint(count) # However many nodes matched the filter\n```\n\n##### Model.count() <a name=\"model-count\"></a>\n\nThe `count()` method returns the total number of entities of this model in the graph.\n\n```python\n# Returns the total number of `Developer` nodes inside the database\ncount = await Developer.count()\n\nprint(count) # However many nodes matched the filter\n\n# Or if no match was found\nprint(count) # 0\n```\n\n###### Filters <a name=\"model-count-filters\"></a>\n\nOptionally, a `filters` argument can be provided, which defines which entities to count. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Counts all `Developer` nodes where the name property contains the letters `oH`\n# The `i` in `icontains` means that the filter is case insensitive\ncount = await Developer.count({\"name\": {\"$icontains\": \"oH\"}})\n\nprint(count) # However many nodes matched the filter\n```\n\n##### NodeModelInstance.create() <a name=\"node-model-instance-create\"></a>\n\n> **Note**: This method is only available for classes inheriting from the `NodeModel` class.\n\nThe `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.\n\n```python\n# Creates a node inside the graph with the properties and labels\n# from the model below\ndeveloper = Developer(name=\"John\", age=24)\nawait developer.create()\n\nprint(developer) # <Developer uid=\"...\" age=24, name=\"John\">\n```\n\n##### NodeModelInstance.find_connected_nodes() <a name=\"node-model-instance-find-connected-nodes\"></a>\n\n> **Note**: This method is only available for classes inheriting from the `NodeModel` class.\n\nThe `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`](#query-filters) section.\n\n```python\n# Picture a structure like this inside the graph:\n# (:Producer)-[:SELLS_TO]->(:Barista)-[:PRODUCES {with_love: bool}]->(:Coffee)-[:CONSUMED_BY]->(:Developer)\n\n# If we want to get all `Developer` nodes connected to a `Producer` node over the `Barista` and `Coffee` nodes,\n# where the `Barista` created the coffee with love, we can do so like this:\nproducer = await Producer.find_one({\"name\": \"Coffee Inc.\"})\n\nif producer is None:\n  # No producer found, do something else\n\ndevelopers = await producer.find_connected_nodes({\n  \"$node\": {\n    \"$labels\": [\"Developer\", \"Python\"],\n    # You can use all available filters here as well\n  },\n  # You can define filters on specific relationships inside the path\n  \"$relationships\": [\n    {\n      # Here we define a filter for all `PRODUCES` relationships\n      # Only nodes where the with_love property is set to `True` will be returned\n      \"$type\": \"PRODUCES\",\n      \"with_love\": True\n    }\n  ]\n})\n\nprint(developers) # [<Developer>, <Developer>, ...]\n\n# Or if no matches were found\nprint(developers) # []\n```\n\n###### Projections <a name=\"node-model-find-connected-nodes-projections\"></a>\n\n`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.\n\n```python\n# Returns dictionaries with the developers name at the `dev_name` key instead\n# of model instances\ndevelopers = await producer.find_connected_nodes(\n  {\n    \"$node\": {\n      \"$labels\": [\"Developer\", \"Python\"],\n    },\n    \"$relationships\": [\n      {\n        \"$type\": \"PRODUCES\",\n        \"with_love\": True\n      }\n    ]\n  },\n  {\n    \"dev_name\": \"name\"\n  }\n)\n\nprint(developers) # [{\"dev_name\": \"John\"}, {\"dev_name\": \"John\"}, ...]\n```\n\n###### Query options <a name=\"node-model-find-connected-nodes-query-options\"></a>\n\n`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`](#query-options) section.\n\n```python\n# Skips the first 10 results and returns the next 20\ndevelopers = await producer.find_connected_nodes(\n  {\n    \"$node\": {\n      \"$labels\": [\"Developer\", \"Python\"],\n    },\n    \"$relationships\": [\n      {\n        \"$type\": \"PRODUCES\",\n        \"with_love\": True\n      }\n    ]\n  },\n  options={\"limit\": 20, \"skip\": 10}\n)\n\nprint(developers) # [<Developer>, <Developer>, ...]\n```\n\n###### Auto-fetching nodes <a name=\"node-model-find-connected-nodes-auto-fetching-nodes\"></a>\n\nThe `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`](#query-auto-fetching).\n\n```python\n# Skips the first 10 results and returns the next 20\ndevelopers = await producer.find_connected_nodes(\n  {\n    \"$node\": {\n      \"$labels\": [\"Developer\", \"Python\"],\n    },\n    \"$relationships\": [\n      {\n        \"$type\": \"PRODUCES\",\n        \"with_love\": True\n      }\n    ]\n  },\n  auto_fetch_nodes=True\n)\n\nprint(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developers[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]\n\n# Returns developer instances with only the `instance.coffee.nodes` property already fetched\ndevelopers = await producer.find_connected_nodes(\n  {\n    \"$node\": {\n      \"$labels\": [\"Developer\", \"Python\"],\n    },\n    \"$relationships\": [\n      {\n        \"$type\": \"PRODUCES\",\n        \"with_love\": True\n      }\n    ]\n  },\n  auto_fetch_nodes=True,\n  auto_fetch_models=[Coffee]\n)\n\ndevelopers = await producer.find_connected_nodes(\n  {\n    \"$node\": {\n      \"$labels\": [\"Developer\", \"Python\"],\n    },\n    \"$relationships\": [\n      {\n        \"$type\": \"PRODUCES\",\n        \"with_love\": True\n      }\n    ]\n  },\n  auto_fetch_nodes=True,\n  auto_fetch_models=[\"Coffee\"]\n)\n\nprint(developers[0].coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developers[0].other_property.nodes) # []\n```\n\n##### RelationshipModelInstance.start_node() <a name=\"relationship-model-instance-start-node\"></a>\n\n> **Note**: This method is only available for classes inheriting from the `RelationshipModel` class.\n\nThis method returns the start node of the current relationship instance. This method takes no arguments.\n\n```python\n# The `coffee_relationship` variable is a relationship instance created somewhere above\nstart_node = await coffee_relationship.start_node()\n\nprint(start_node) # <Coffee>\n```\n\n##### RelationshipModelInstance.end_node() <a name=\"relationship-model-instance-end-node\"></a>\n\n> **Note**: This method is only available for classes inheriting from the `RelationshipModel` class.\n\nThis method returns the end node of the current relationship instance. This method takes no arguments.\n\n```python\n# The `coffee_relationship` variable is a relationship instance created somewhere above\nend_node = await coffee_relationship.end_node()\n\nprint(end_node) # <Developer>\n```\n\n#### Serializing models <a name=\"serializing-models\"></a>\n\nWhen serializing models to a dictionary or JSON string, the models `element_id and id` fields are `automatically added` to the corresponding dictionary/JSON string when calling Pydantic's `dict()` or `json()` methods.\n\nIf you want to exclude them from serialization, you can easily do so by passing them to the `exclude` parameter of the according method.\n\nOn node-models:\n\n- `id`\n- `element_id`\n\nAdditional properties for relationship-models:\n\n- `start_node_id`\n- `start_node_element_id`\n- `end_node_id`\n- `end_node_element_id`\n\n#### Hooks <a name=\"hooks\"></a>\n\nHooks 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.\n\n`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.\n\nFor 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.\n\n> **Note:** If you implement custom methods and want to use hooks for them, you can simply define the `hook decorator` on them and then register hooks under the `name of your method`.\n\n##### Pre-hooks <a name=\"pre-hooks\"></a>\n\nPre-hooks are executed before the method they are registered for. They can be defined in the [`model's Settings`](#configuration-settings) class under the `pre_hooks` property or by calling the `register_pre_hooks()` method on the model.\n\n```python\nclass Developer(NodeModel):\n  ...\n\n  class Settings:\n    post_hooks = {\n      \"coffee.connect\": lambda self, *args, **kwargs: print(f\"{self.name} chugged another one!\")\n    }\n\n\n# Or by calling the `register_pre_hooks()` method\n# Here `hook_func` can be a synchronous or asynchronous function reference\nDeveloper.register_pre_hooks(\"create\", hook_func)\n\n# By using the `register_pre_hooks()` method, you can also overwrite all previously registered hooks\n# This will overwrite all previously registered hooks for the defined hook name\nDeveloper.register_pre_hooks(\"create\", hook_func, overwrite=True)\n```\n\n##### Post-hooks <a name=\"post-hooks\"></a>\n\nPost-hooks are executed after the method they are registered for. They can be defined in the [`model's Settings`](#configuration-settings) class under the `post_hooks` property or by calling the `register_post_hooks()` method on the model.\n\nIn 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.\n\n> **Note:** Since post-hooks have the exact same usage/registration options as pre-hooks, they are not explained in detail here.\n\n#### Model settings <a name=\"model-settings\"></a>\n\nCan be used to access the model's settings. For more about model settings, see the [`Model settings`](#model-settings) section.\n\n```python\nmodel_settings = Developer.model_settings()\n\nprint(model_settings) # <NodeModelSettings labels={\"Developer\"}, auto_fetch_nodes=False, ...>\n```\n\n### Relationship-properties <a name=\"relationship-properties\"></a>\n\n> **Note**: Relationship-properties are only available for classes which inherit from the `NodeModel` class.\n\nRelationship-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:\n\n```python\nclass Developer(NodeModel):\n\n    # Here we define a relationship to one or more `Coffee` nodes, both the target\n    # and relationship-model can be defined as strings (Has to be the exact name of the model)\n\n    # Notice that the `RelationshipProperty` class takes two type arguments, the first\n    # one being the target model and the second one being the relationship-model\n    # Can can get away without defining these, but it is recommended to do so for\n    # better type hinting\n    coffee: RelationshipProperty[\"Coffee\", \"Consumed\"] = RelationshipProperty(\n        # The target model is the model we want to connect to\n        target_model=\"Coffee\",\n        # The relationship-model is the model which defines the relationship\n        # between a target model (in this case `Coffee`) and the model it is defined on\n        relationship_model=Consumed,\n        # The direction of the relationship inside the graph\n        direction=RelationshipPropertyDirection.OUTGOING,\n        # Cardinality defines how many nodes can be connected to the relationship\n        # **Note**: This only softly enforces cardinality from the model it's defined on\n        # and does not enforce it on the database level\n        cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n        # Whether to allow multiple connections to the same node\n        allow_multiple=True,\n    )\n```\n\n#### Available methods <a name=\"relationship-properties-available-methods\"></a>\n\nJust 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.\n\n> **Note**: In the following, the terms `source node` and `target node` will be used. Source node refers to the `node instance the method is called on` and target node refers to the `node/s passed to the method`.\n\n##### RelationshipProperty.relationships() <a name=\"relationship-property-relationship\"></a>\n\nReturns 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.\n\n```python\n# The `developer` and `coffee` variables have been defined somewhere above\n\n# Returns the relationships between the two nodes\ncoffee_relationships = await developer.coffee.relationships(coffee)\n\nprint(coffee_relationships) # [<Consumed>, <Consumed>, ...]\n\n# Or if no relationships were found\nprint(coffee_relationships) # []\n```\n\n###### Filters <a name=\"relationship-property-relationships-filters\"></a>\n\nThis method also allows for (optional) filters. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Only returns the relationships between the two nodes where\n# the `developer liked the coffee`\ncoffee_relationships = await developer.coffee.relationships(coffee, {\"likes_it\": True})\n\nprint(coffee_relationships) # [<Consumed liked=True>, <Consumed liked=True>, ...]\n```\n\n###### Projections <a name=\"relationship-property-relationships-projections\"></a>\n\n`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.\n\n```python\n# Returns dictionaries with the relationships `liked` property is at the\n# `loved_it` key instead of model instances\ncoffee_relationships = await developer.coffee.relationships(coffee, projections={\"loved_it\": \"liked\"})\n\nprint(coffee_relationships) # [{\"loved_it\": True}, {\"loved_it\": False}, ...]\n```\n\n###### Query options <a name=\"relationship-property-relationships-query-options\"></a>\n\n`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`](#query-options) section.\n\n```python\n# Skips the first 10 results and returns the next 20\ncoffee_relationships = await developer.coffee.relationships(coffee, options={\"limit\": 20, \"skip\": 10})\n\nprint(coffee_relationships) # [<Consumed>, <Consumed>, ...] up to 20 results\n```\n\n##### RelationshipProperty.connect() <a name=\"relationship-property-connect\"></a>\n\nConnects 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.\n\nDepending 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.\n\n```python\n# The `developer` and `coffee` variables have been defined somewhere above\n\ncoffee_relationship = await developer.coffee.connect(coffee, {\"likes_it\": True})\n\nprint(coffee_relationship) # <Consumed>\n```\n\n##### RelationshipProperty.disconnect() <a name=\"relationship-property-disconnect\"></a>\n\nDisconnects 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.\n\n> **Note**: If `allow_multiple` was set to `True` and multiple relationships to the target node exist, all of them will be deleted.\n\n```python\n# The `developer` and `coffee` variables have been defined somewhere above\n\ncoffee_relationship_count = await developer.coffee.disconnect(coffee)\n\nprint(coffee_relationship_count) # However many relationships were deleted\n```\n\n###### Raise on empty result <a name=\"relationship-property-disconnect-raise-on-empty-result\"></a>\n\nBy 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.\n\n```python\n# Raises a `NoResultFound` exception if no results were matched\ncoffee_relationship_count = await developer.coffee.disconnect(coffee, raise_on_empty=True)\n```\n\n##### RelationshipProperty.disconnect_all() <a name=\"relationship-property-disconnect-all\"></a>\n\nDisconnects all target nodes from the source node and deletes all relationships between them. Returns the number of deleted relationships.\n\n```python\n# This will delete all relationships to `Coffee` nodes for this `Developer` node\ncoffee_relationship_count = await developer.coffee.disconnect_all()\n\nprint(coffee_relationship_count) # However many relationships were deleted\n```\n\n##### RelationshipProperty.replace() <a name=\"relationship-property-replace\"></a>\n\nDisconnects 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.\n\n> **Note**: If `multiple relationships` between the target node and the old source node exist, `all of them` will be replaced.\n\n```python\n# Currently there are two relationships defined between the `developer` and `coffee_latte`\n# nodes where the `likes_it` property is set to `True` and `False` respectively\n\n# Moves the relationships from `coffee_latte` to `coffee_americano`\nreplaced_coffee_relationships = await developer.coffee.replace(coffee_latte, coffee_americano)\n\nprint(replaced_coffee_relationships) # [<Consumed likes_it=True>, <Consumed likes_it=False>]\n```\n\n##### RelationshipProperty.find_connected_nodes() <a name=\"relationship-property-find-connected-nodes\"></a>\n\nFinds 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.\n\n```python\n# Returns all `Coffee` nodes\ncoffees = await developer.coffee.find_connected_nodes()\n\nprint(coffees) # [<Coffee>, <Coffee>, ...]\n\n# Or if no matches were found\nprint(coffees) # []\n```\n\n###### Filters <a name=\"relationship-property-find-connected-nodes-filters\"></a>\n\nYou can pass filters using the `filters` argument to filter the returned nodes. For more about filters, see the [`Filtering queries`](#query-filters) section.\n\n```python\n# Returns all `Coffee` nodes where the `sugar` property is set to `True`\ncoffees = await developer.coffee.find_connected_nodes({\"sugar\": True})\n\nprint(coffees) # [<Coffee sugar=True>, <Coffee sugar=True>, ...]\n```\n\n###### Projections <a name=\"relationship-property-find-connected-nodes-projections\"></a>\n\n`Projections` can be used to only return specific parts of the models as dictionaries. For more information about projections, see the [`Projections`](#query-projections) section.\n\n```python\n# Returns dictionaries with the coffee's `sugar` property at the `contains_sugar` key instead\n# of model instances\ncoffees = await developer.coffee.find_connected_nodes({\"sugar\": True}, {\"contains_sugar\": \"sugar\"})\n\nprint(coffees) # [{\"contains_sugar\": True}, {\"contains_sugar\": False}, ...]\n```\n\n###### Query options <a name=\"relationship-property-find-connected-nodes-query-options\"></a>\n\n`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`](#query-options) section.\n\n```python\n# Skips the first 10 results and returns the next 20\ncoffees = await developer.coffee.find_connected_nodes({\"sugar\": True}, options={\"limit\": 20, \"skip\": 10})\n\n# Skips the first 10 results and returns up to 20\nprint(coffees) # [<Coffee>, <Coffee>, ...]\n```\n\n###### Auto-fetching nodes <a name=\"relationship-property-find-connected-nodes-auto-fetching-nodes\"></a>\n\nThe `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`](#query-auto-fetching).\n\n```python\n# Returns coffee instances with `instance.<property>.nodes` properties already fetched\ncoffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True)\n\nprint(coffees[0].developer.nodes) # [<Developer>, <Developer>, ...]\nprint(coffees[0].other_property.nodes) # [<OtherModel>, <OtherModel>, ...]\n\n# Returns coffee instances with only the `instance.developer.nodes` property already fetched\ncoffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True, auto_fetch_models=[Developer])\n\n# Auto-fetch models can also be passed as strings\ncoffees = await developer.coffee.find_connected_nodes(auto_fetch_nodes=True, auto_fetch_models=[\"Developer\"])\n\nprint(coffees[0].developer.nodes) # [<Developer>, <Developer>, ...]\nprint(coffees[0].other_property.nodes) # []\n```\n\n#### Hooks with relationship properties <a name=\"hooks-with-relationship-properties\"></a>\n\nAlthough 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).\n\n> **Note:** The rest of the arguments passed to the hook function are the same as for regular models.\n\n```python\nclass Developer(NodeModel):\n\n    # Here we define a relationship to one or more `Coffee` nodes, both the target\n    # and relationship-model can be defined as strings (Has to be the exact name of the model)\n\n    # Notice that the `RelationshipProperty` class takes two type arguments, the first\n    # one being the target model and the second one being the relationship-model\n    # Can can get away without defining these, but it is recommended to do so for\n    # better type hinting\n    coffee: RelationshipProperty[\"Coffee\", \"Consumed\"] = RelationshipProperty(\n        # The target model is the model we want to connect to\n        target_model=\"Coffee\",\n        # The relationship-model is the model which defines the relationship\n        # between a target model (in this case `Coffee`) and the model it is defined on\n        relationship_model=Consumed,\n        # The direction of the relationship inside the graph\n        direction=RelationshipPropertyDirection.OUTGOING,\n        # Cardinality defines how many nodes can be connected to the relationship\n        # **Note**: This only softly enforces cardinality from the model it's defined on\n        # and does not enforce it on the database level\n        cardinality=RelationshipPropertyCardinality.ZERO_OR_MORE,\n        # Whether to allow multiple connections to the same node\n        allow_multiple=True,\n    )\n\n    class Settings:\n        post_hooks = {\n            \"coffee.connect\": lambda self, *args, **kwargs: print(type(self))\n        }\n\n# Somewhere further down the line...\n# Prints `<class '__main__.Developer'>` instead of `<class '__main__.RelationshipProperty'>`\nawait developer.coffee.connect(coffee)\n```\n\nThe 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.\n\n### Queries <a name=\"queries\"></a>\n\nAs 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.\n\nIf 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.\n\nThe filters are heavily inspired by [`MongoDB's query language`](https://docs.mongodb.com/manual/tutorial/query-documents/), so if you have some experience with that, you will feel right at home.\n\nThis 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.\n\nBut enough of that, let's take a look at the different filters available to you.\n\n#### Filtering queries <a name=\"query-filters\"></a>\n\nSince 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.\n\nWe can roughly separate them into the `following categories`:\n\n- Comparison operators\n- String operators\n- List operators\n- Logical operators\n- Element operators\n\n##### Comparison operators <a name=\"query-filters-comparison-operators\"></a>\n\nComparison operators are used to compare values to each other. They are the most basic type of filter.\n\n| Operator | Description | Corresponding Cypher query |\n| --- | --- | --- |\n| `$eq` | Matches values that are equal to a specified value. | `WHERE node.property = value` |\n| `$neq` | Matches all values that are not equal to a specified value. | `WHERE node.property <> value` |\n| `$gt` | Matches values that are greater than a specified value. | `WHERE node.property > value` |\n| `$gte` | Matches values that are greater than or equal to a specified value. | `WHERE node.property >= value` |\n| `$lt` | Matches values that are less than a specified value. | `WHERE node.property < value` |\n| `$lte` | Matches values that are less than or equal to a specified value. | `WHERE node.property <= value` |\n\n##### String operators <a name=\"query-filters-string-operators\"></a>\n\nString operators are used to compare string values to each other.\n\n| Operator | Description | Corresponding Cypher query |\n| --- | --- | --- |\n| `$contains` | Matches values that contain a specified value. | `WHERE node.property CONTAINS value` |\n| `$icontains` | Matches values that contain a specified case insensitive value. | `WHERE toLower(node.property) CONTAINS toLower(value)` |\n| `$startsWith` | Matches values that start with a specified value. | `WHERE node.property STARTS WITH value` |\n| `$istartsWith` | Matches values that start with a specified case insensitive value. | `WHERE toLower(node.property) STARTS WITH toLower(value)` |\n| `$endsWith` | Matches values that end with a specified value. | `WHERE node.property ENDS WITH value` |\n| `$iendsWith` | Matches values that end with a specified case insensitive value. | `WHERE toLower(node.property) ENDS WITH toLower(value)` |\n| `$regex` | Matches values that match a specified regular expression (Regular expressions used by Neo4j and Cypher). | `WHERE node.property =~ value` |\n\n##### List operators <a name=\"query-filters-list-operators\"></a>\n\nList operators are used to compare list values to each other.\n\n| Operator | Description | Corresponding Cypher query |\n| --- | --- | --- |\n| `$in` | Matches lists where at least one item is in the given list. | `WHERE ANY(i IN node.property WHERE i IN value)` |\n| `$nin` | Matches lists where no items are in the given list | `WHERE NONE(i IN node.property WHERE i IN value)` |\n| `$all` | Matches lists where all items are in the given list. | `WHERE ALL(i IN node.property WHERE i IN value)` |\n| `$size` | Matches lists where the size of the list is equal to the given value. | `WHERE size(node.property) = value` |\n\n> **Note**: The `$size` operator can also be combined with the comparison operators by nesting them inside the `$size` operator. For example: `{\"$size\": {\"$gt\": 5}}`.\n\n##### Logical operators <a name=\"query-filters-logical-operators\"></a>\n\nLogical operators are used to combine multiple filters with each other.\n\n| Operator | Description | Corresponding Cypher query |\n| --- | --- | --- |\n| `$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` |\n| `$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` |\n| `$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` |\n| `$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)` |\n\n##### Element operators <a name=\"query-filters-element-operators\"></a>\n\nElement operators are a special kind of operator not available for every filter type. They are used to check Neo4j-specific values.\n\n| Operator | Description | Corresponding Cypher query |\n| --- | --- | --- |\n| `$exists` | Matches nodes that have the specified property. | `WHERE EXISTS(node.property)` |\n| `$elementId` | Matches nodes that have the specified element id. | `WHERE elementId(node) = value` |\n| `$id` | Matches nodes that have the specified id. | `WHERE id(node) = value` |\n| `$labels` | Matches nodes that have the specified labels. | `WHERE ALL(i IN labels(n) WHERE i IN value)` |\n| `$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` |\n\n##### Pattern matching <a name=\"query-filters-pattern-matching\"></a>\n\nThe 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:\n\n- `$node`: Filters applied to the target node. Expects a dictionary containing basic filters.\n- `$relationship`: Filters applied to the relationship between the source node and the target node. Expects a dictionary containing basic filters.\n- `$direction`: The direction of the pattern. Can be either INCOMING,OUTGOING or BOTH.\n- `$exists`: A boolean value indicating whether the pattern must exist or not.\n\n> **Note**: The `$patterns` key can only be used inside the `root filter` and not inside nested filters. Furthermore, only patterns across a single hop are supported.\n\nTo 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`:\n\n```python\ndevelopers = await Developer.find_many({\n  \"$patterns\": [\n    {\n      # The `$exists` operator tells the library to match/ignore the pattern\n      \"$exists\": False,\n      # The defines the direction of the relationship inside the pattern\n      \"$direction\": RelationshipMatchDirection.OUTGOING,\n      # The `$node` key is used to define the node we want to filter for. This means\n      # the filters inside the `$node` key will be applied to our `Coffee` nodes\n      \"$node\": {\n        \"$labels\": [\"Beverage\", \"Hot\"],\n        \"sugar\": False\n      },\n      # The `$relationship` key is used to filter the relationship between the two nodes\n      # It can also define property filters for the relationship\n      \"$relationship\": {\n        \"$type\": \"CHUGGED\"\n      }\n    }\n  ]\n})\n```\n\nWe 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`:\n\n```python\ndevelopers = await Developer.find_many({\n  \"$patterns\": [\n    {\n      \"$exists\": True,\n      \"$direction\": RelationshipMatchDirection.OUTGOING,\n      \"$node\": {\n        \"$labels\": [\"Beverage\", \"Hot\"],\n      },\n      \"$relationship\": {\n        \"$type\": \"CHUGGED\",\n        \"liked\": True\n      }\n    },\n    {\n      \"$exists\": True,\n      \"$direction\": RelationshipMatchDirection.INCOMING,\n      \"$node\": {\n        \"$labels\": [\"Developer\"],\n        \"name\": \"Jenny\"\n      },\n      \"$relationship\": {\n        \"$type\": \"FRIENDS_WITH\"\n      }\n    }\n  ]\n})\n```\n\n##### Multi-hop filters <a name=\"query-filters-multi-hop-filters\"></a>\n\nMulti-hop filters are a special type of filter which is only available for [`NodeModelInstance.find_connected_nodes()`](#node-model-instance-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:\n\n- `$node`: Filters applied to the target node. Expects a dictionary containing basic filters. Can not contain pattern yet.\n- `$minHops`: The minimum number of hops between the source node and the target node. Must be greater than 0.\n- `$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.\n- `$relationships`: A list of relationship filters. Each filter is a dictionary containing basic filters and must define a $type operator.\n\n```python\n# Picture a structure like this inside the graph:\n# (:Producer)-[:SELLS_TO]->(:Barista)-[:PRODUCES {with_love: bool}]->(:Coffee)-[:CONSUMED_BY]->(:Developer)\n\n# If we want to get all `Developer` nodes connected to a `Producer` node over the `Barista` and `Coffee` nodes,\n# where the `Barista` created the coffee with love.\n\n# Let's say, for the sake of this example, that there are connections possible\n# with 10+ hops, but we don't want to include them. To solve this, we can define\n# a `$maxHops` filter with a value of `10`.\nproducer = await Producer.find_one({\"name\": \"Coffee Inc.\"})\n\nif producer is None:\n  # No producer found, do something else\n\ndevelopers = await producer.find_connected_nodes({\n  \"$maxHops\": 10,\n  \"$node\": {\n    \"$labels\": [\"Developer\", \"Python\"],\n    # You can use all available filters here as well\n  },\n  # You can define filters on specific relationships inside the path\n  \"$relationships\": [\n    {\n      # Here we define a filter for all `PRODUCES` relationships\n      # Only nodes where the with_love property is set to `True` will be returned\n      \"$type\": \"PRODUCES\",\n      \"with_love\": True\n    }\n  ]\n})\n\nprint(developers) # [<Developer>, <Developer>, ...]\n\n# Or if no matches were found\nprint(developers) # []\n```\n\n#### Projections <a name=\"query-projections\"></a>\n\nProjections 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.\n\nProjections can help you to reduce bandwidth usage and speed up queries, since you only return the data you actually need.\n\n> **Note:** Only top-level mapping is supported. This means that you can not map properties to a nested dictionary key.\n\nIn 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.\n\n```python\ndeveloper = await Developer.find_one({\"name\": \"John\"}, {\"dev_name\": \"name\", \"dev_age\": \"age\", \"i_do_not_exist\": \"some_non_existing_property\"})\n\nprint(developer) # {\"dev_name\": \"John\", \"dev_age\": 24, \"i_do_not_exist\": None}\n```\n\n#### Query options <a name=\"query-options\"></a>\n\nQuery 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:\n\n- `limit`: Limits the number of returned results.\n- `skip`: Skips the first `n` results.\n- `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.\n- `order`: Defines the sort direction. Can be either `ASC` or `DESC`. Defaults to `ASC`.\n\n```python\n# Returns 50 results, skips the first 10 and sorts them by the `name` property in descending order\ndevelopers = await Developer.find_many({}, options={\"limit\": 50, \"skip\": 10, \"sort\": \"name\", \"order\": QueryOptionsOrder.DESCENDING})\n\nprint(len(developers)) # 50\nprint(developers) # [<Developer>, <Developer>, ...]\n```\n\n#### Auto-fetching relationship-properties <a name=\"query-auto-fetching\"></a>\n\nYou 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.\n\n> **Note**: Auto-fetching nodes with many relationships can be very expensive and slow down your queries. Use it with caution.\n\nTo 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`.\n\nYou 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.\n\nNow, let's take a look at an example:\n\n```python\n# Fetches everything defined in the relationship-properties of the current matched node\ndeveloper = await Developer.find_one({\"name\": \"John\"}, auto_fetch_nodes=True)\n\n# All nodes for all defined relationship-properties are now fetched\nprint(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developer.developer.nodes) # [<Developer>, <Developer>, ...]\nprint(developer.other_property.nodes) # [<OtherModel>, <OtherModel>, ...]\n```\n\nWith the `auto_fetch_models` parameter, we can define which relationship-properties to fetch:\n\n```python\n# Only fetch nodes for `Coffee` and `Developer` models defined in relationship-properties\n# The models can also be passed as strings, where the string is the model's name\ndeveloper = await Developer.find_one({\"name\": \"John\"}, auto_fetch_nodes=True, auto_fetch_models=[Coffee, \"Developer\"])\n\n# Only the defined models have been fetched\nprint(developer.coffee.nodes) # [<Coffee>, <Coffee>, ...]\nprint(developer.developer.nodes) # [<Developer>, <Developer>, ...]\nprint(developer.other_property.nodes) # []\n```\n\n### Migrations <a name=\"migrations\"></a>\n\nAs of version `v0.5.0`, pyneo4j-ogm supports migrations using a built-in migration tool. The migration tool is basic but flexibly, which should cover most use-cases.\n\n#### Initializing migrations for your project <a name=\"initializing-migrations\"></a>\n\nTo initialize migrations for your project, you can use the `poetry run pyneo4j_ogm init` command. This will create a `migrations` directory at the given path (which defaults to `./migrations`), which will contain all your migration files.\n\n```bash\npoetry run pyneo4j_ogm init --migration-dir ./my/custom/migration/path\n```\n\n#### Creating a new migration <a name=\"creating-a-new-migration\"></a>\n\nTo create a new migration, you can use the `poetry run pyneo4j_ogm create` command. This will create a new migration file inside the `migrations` directory. The migration file will contain a `up` and `down` function, which you can use to define your migration.\n\n```bash\npoetry run pyneo4j_ogm create my_first_migration\n```\n\nBoth the `up` and `down` functions will receive the client used during the migration as their only arguments. This makes the migrations pretty flexible, since you can not only use the client to execute queries, but also register models on it and use them to execute methods.\n\n> **Note**: When using models inside the migration, you have to make sure that the model used implements the same data structure as the data inside the graph. Otherwise you might run into validation issues.\n\n```python\n\"\"\"\nAuto-generated migration file {name}. Do not\nrename this file or the `up` and `down` functions.\n\"\"\"\nfrom pyneo4j_ogm import Pyneo4jClient\n\n\nasync def up(client: Pyneo4jClient) -> None:\n    \"\"\"\n    Write your `UP migration` here.\n    \"\"\"\n    await client.cypher(\"CREATE (n:Node {name: 'John'})\")\n\n\nasync def down(client: Pyneo4jClient) -> None:\n    \"\"\"\n    Write your `DOWN migration` here.\n    \"\"\"\n    await client.cypher(\"MATCH (n:Node {name: 'John'}) DELETE n\")\n```\n\n#### Running migrations <a name=\"running-migrations\"></a>\n\nTo run the migrations, you can use the `up` or `down` commands. The `up` command will run all migrations that have not been run yet, while the `down` command will run all migrations in reverse order.\n\nBoth commands support a `--up-count` or `--down-count` argument, which can be used to limit the number of migrations to run. By default, the `up` command will run `all pending migration` and the `down` command will roll back the `last migration`.\n\n```bash\npoetry run pyneo4j_ogm up --up-count 3\npoetry run pyneo4j_ogm down --down-count 2\n```\n\n#### Listing migrations <a name=\"listing-migrations\"></a>\n\nThe current state of all migrations can be viewed anytime using the `status` command. This will show you all migrations that have been run and all migrations that are pending.\n\n```bash\npoetry run pyneo4j_ogm status\n\n# Output\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Migration                               \u2502 Applied At          \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 20160608155948-my_awesome_migration     \u2502 2022-03-04 15:40:22 \u2502\n\u2502 20160608155948-my_fixed_migration       \u2502 2022-03-04 15:41:13 \u2502\n\u2502 20160608155948-final_fix_i_swear        \u2502 PENDING             \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n#### Programmatic usage <a name=\"migrations-programmatic-usage\"></a>\n\nThe migration tool can also be used programmatically. This can be useful if you want to run migrations inside your application or if you want to integrate the migration tool into your own CLI.\n\n```python\nimport asyncio\nfrom pyneo4j_ogm.migrations import create, down, init, status, up\n\n# Call with same arguments as you would with cli\ninit(migration_dir=\"./my/custom/migration/path\")\n\ncreate(\"my_first_migration\")\nasyncio.run(up())\n```\n\n### Logging <a name=\"logging\"></a>\n\nYou 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.\n\n### Running the test suite <a name=\"running-the-test-suite\"></a>\n\nTo run the test suite, you have to install the development dependencies and run the tests using `pytest`. The tests are located in the `tests` directory. Any tests located in the `tests/integration` directory will require you to have a Neo4j instance running on `localhost:7687` with the credentials (`neo4j:password`). This can easily be done using the provided `docker-compose.yml` file.\n\n```bash\npoetry run pytest tests --asyncio-mode=auto -W ignore::DeprecationWarning\n```\n\n> **Note:** The `-W ignore::DeprecationWarning` can be omitted but will result in a lot of deprication warnings by Neo4j itself about the usage of the now deprecated `ID`.\n\nAs for running the tests with a different pydantic version, you can just run the following command locally:\n\n```bash\npoetry add pydantic@1.10\n```\n\n",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "Asynchronous Python OGM for Neo4j",
    "version": "0.5.2",
    "project_urls": {
        "Documentation": "https://github.com/groc-prog/pyneo4j-ogm#readme",
        "Homepage": "https://github.com/groc-prog/pyneo4j-ogm",
        "Repository": "https://github.com/groc-prog/pyneo4j-ogm"
    },
    "split_keywords": [
        "neo4j",
        "python",
        "orm",
        "ogm",
        "async",
        "asynchronous",
        "database",
        "graph-database",
        "pydantic"
    ],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "b068a3389efdee6daeca441b9264522bbed07364a6cb587dd1fe09184ee99895",
                "md5": "f2d9fbe8e0549e423b318a49451ecf7b",
                "sha256": "92cbebe73ee6180e3bcb518839cd2799a825cc532d3e3c691e1338af6d3a9f4c"
            },
            "downloads": -1,
            "filename": "pyneo4j_ogm-0.5.2-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "f2d9fbe8e0549e423b318a49451ecf7b",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.10,<4.0",
            "size": 84052,
            "upload_time": "2024-02-22T18:16:07",
            "upload_time_iso_8601": "2024-02-22T18:16:07.483863Z",
            "url": "https://files.pythonhosted.org/packages/b0/68/a3389efdee6daeca441b9264522bbed07364a6cb587dd1fe09184ee99895/pyneo4j_ogm-0.5.2-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "0240cb4e205cd6857ada4ba621339a84b95fd8a82284b8974292399588bcc6cd",
                "md5": "98de2aa677e193e6c452d390ae60178a",
                "sha256": "5e776e4cf0de29d6a014abef055914d5bc822e4d22557ba8d96bc115b00e1986"
            },
            "downloads": -1,
            "filename": "pyneo4j_ogm-0.5.2.tar.gz",
            "has_sig": false,
            "md5_digest": "98de2aa677e193e6c452d390ae60178a",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.10,<4.0",
            "size": 94858,
            "upload_time": "2024-02-22T18:16:09",
            "upload_time_iso_8601": "2024-02-22T18:16:09.344272Z",
            "url": "https://files.pythonhosted.org/packages/02/40/cb4e205cd6857ada4ba621339a84b95fd8a82284b8974292399588bcc6cd/pyneo4j_ogm-0.5.2.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2024-02-22 18:16:09",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "groc-prog",
    "github_project": "pyneo4j-ogm",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": true,
    "lcname": "pyneo4j-ogm"
}
        
Elapsed time: 0.18889s