Skip to content

A asynchronous Object-Graph-Mapper for Neo4j 5+ and Python 3.10+

License

Notifications You must be signed in to change notification settings

groc-prog/pyneo4j-ogm

Repository files navigation

pyneo4j-ogm

PyPI PyPI - Python Version PyPI - License PyPI - Downloads

pyneo4j-ogm is a asynchronous Object-Graph-Mapper for Neo4j 5+ and Python 3.10+. It is inspired by beanie and build on top of proven technologies like Pydantic 1.10+ and 2+ and the Neo4j Python Driver. It saves you from writing ever-repeating boilerplate queries and allows you to focus on the stuff that actually matters. It is designed to be simple and easy to use, but also flexible and powerful.

🎯 Features

pyneo4j-ogm has a lot to offer, including:

  • Fully typed: pyneo4j-ogm is fully typed out of the box.
  • Powerful validation: Since we use Pydantic under the hood, you can use it's powerful validation and serialization features without any issues.
  • 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.
  • Build-in migration tooling: Shipped with simple, yet flexible migration tooling.
  • Fully asynchronous: Completely asynchronous code, thanks to the Neo4j Python Driver.
  • Supports Neo4j 5+: pyneo4j-ogm supports Neo4j 5+ and is tested against the latest version of Neo4j.
  • 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.

πŸ“¦ Installation

Using pip:

pip install pyneo4j-ogm

or when using Poetry:

poetry add pyneo4j-ogm

πŸš€ Quickstart

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:

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.
  • The WithOptions function has been used to define constraints and indexes (more about them here) 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:

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

# 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

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

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

Running the test suite

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. Some tests 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.

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 install a different pydantic version with the following command:

poetry add pydantic@<version>