Metadata-Version: 2.5
Name: neoloom
Version: 0.2.0
Summary: A lightweight Data Mapper OGM for Neo4j, with Unit of Work, Repository and a Cypher query builder.
Project-URL: Homepage, https://github.com/amirfaghihi/neoloom
Project-URL: Repository, https://github.com/amirfaghihi/neoloom
Project-URL: Issues, https://github.com/amirfaghihi/neoloom/issues
Project-URL: Changelog, https://github.com/amirfaghihi/neoloom/blob/main/CHANGELOG.md
Author-email: Amirhossein Faghihifar <a.faghihifar@gmail.com>
Maintainer-email: Amirhossein Faghihifar <a.faghihifar@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cypher,data-mapper,graph,graph-database,neo4j,ogm,orm,repository-pattern,unit-of-work
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Database :: Front-Ends
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: neo4j>=5.0
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Description-Content-Type: text/markdown

# neoloom

[![CI](https://github.com/amirfaghihi/neoloom/actions/workflows/ci.yml/badge.svg)](https://github.com/amirfaghihi/neoloom/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/neoloom.svg)](https://pypi.org/project/neoloom/)
[![Python versions](https://img.shields.io/pypi/pyversions/neoloom.svg)](https://pypi.org/project/neoloom/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

A lightweight **Data Mapper** OGM for [Neo4j](https://neo4j.com/). Model your graph
as Python classes, and let neoloom handle the Cypher.

Unlike Active Record OGMs, your model classes stay free of persistence logic: a
`Session` decides when writes happen, and a `Repository` gives you a typed,
collection-like view of each label.

```python
from neoloom import Node, Relationship, Session, StringField, IntegerField


class Team(Node):
    name = StringField(unique=True, nullable=False)


class Person(Node):
    email = StringField(unique=True, nullable=False)
    age = IntegerField()
    teams = Relationship(Team, rel_type="MEMBER_OF", direction="->")


with Session(uri="bolt://localhost:7687", user="neo4j", password="secret") as session:
    people = session.repository(Person)

    people.add(Person(email="ana@example.com", age=30))
    session.commit()

    for person in people.query().filter(age__gte=18).order_by("-age").limit(10):
        print(person.email, [team.name for team in person.teams])
```

## Contents

- [Why neoloom](#why-neoloom)
- [Install](#install)
- [Defining models](#defining-models)
- [Sessions and repositories](#sessions-and-repositories)
- [Querying](#querying)
- [Relationships](#relationships)
- [The query builder](#the-query-builder)
- [Error handling](#error-handling)
- [API reference](#api-reference)
- [Development](#development)

## Why neoloom

- **Data Mapper, not Active Record.** Models describe your domain; the session
  persists it. Nothing is written until you commit.
- **Unit of Work.** Inserts, updates and deletes are batched and applied in order.
- **Identity Map.** One Python object per graph node, per session.
- **Lazy relationships.** Traversals run on first access, then cache.
- **Safe by construction.** Every value is sent as a query parameter, never
  interpolated into the statement.
- **Small.** One dependency: the official `neo4j` driver.

## Install

```bash
pip install neoloom
```

Requirements:

- Python 3.9 or newer
- Neo4j 5.x, reachable over Bolt

## Defining models

A model subclasses `Node`. Its class name becomes the node label, and its field
descriptors become node properties.

```python
from datetime import datetime
from neoloom import (
    Node,
    StringField,
    IntegerField,
    FloatField,
    BooleanField,
    DateTimeField,
)


class Product(Node):
    sku = StringField(unique=True, nullable=False)
    title = StringField(nullable=False, max_length=200)
    price = FloatField(nullable=False)
    stock = IntegerField(default=0, min_value=0)
    active = BooleanField(default=True)
    created_at = DateTimeField(default=datetime.now)
```

### Field types

| Field | Python type | Extra options |
| --- | --- | --- |
| `StringField` | `str` | `max_length` |
| `IntegerField` | `int` | `min_value`, `max_value` |
| `FloatField` | `float` | integers are widened automatically |
| `BooleanField` | `bool` | |
| `DateTimeField` | `datetime` | ISO 8601 strings are parsed on assignment |

Every field accepts `default`, `unique` and `nullable`.

### Validation

Types and constraints are checked **on assignment**, so mistakes surface at the
point of the bug rather than at commit time:

```python
product = Product(sku="A1", title="Anvil", price=9.99)
product.stock = -1  # ValidationError: 'stock' is -1, below min_value 0
product.price = "free"  # ValidationError: FloatField 'price' expects float, got str
```

`nullable=False` is enforced when the node is saved, not on assignment — a node
read back from the graph with a missing property stays loadable.

A `default` may be a callable, in which case it is invoked per instance.
`DateTimeField(default=datetime.now)` gives each node its own timestamp.

### Defaults, uniqueness and identity

- `unique=True` marks the field as an identity key. neoloom refuses to create a
  second node with the same value, and uses these fields to locate a node on
  update and delete.
- Declare at least one unique field on any model you intend to update.
- Fields are inherited by subclasses.

## Sessions and repositories

A `Session` owns the connection, the Unit of Work and the Identity Map. Use it as
a context manager so the driver is always released.

```python
from neoloom import Session

with Session(uri="bolt://localhost:7687", user="neo4j", password="secret") as session:
    products = session.repository(Product)
    ...
```

To reuse a driver you already manage — a connection pool shared across your app,
say — pass it in. neoloom will not close a driver it did not create:

```python
from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "secret"))
session = Session(driver=driver, database="analytics")
```

### Writing

Repository writes are **deferred**; nothing reaches the graph until `commit()`.

```python
products = session.repository(Product)

# Create
products.add(Product(sku="A1", title="Anvil", price=9.99))

# Update
anvil = products.get(sku="A1")
anvil.price = 12.50
products.update(anvil)

# Delete
products.delete(products.get(sku="B2"))

session.commit()  # inserts, then updates, then deletes
```

`session.rollback()` discards everything pending. If a commit fails partway, the
Unit of Work is left intact so you can inspect or retry it.

### Reading

Reads happen immediately.

```python
products.get(sku="A1")  # first match, or None
products.find(active=True)  # list of matches
products.all()  # every Product
products.count(active=True)
```

## Querying

`repository.query()` returns a lazy `QuerySet`. Nothing is sent until you iterate
it or call `all()`, `first()`, `count()` or `exists()`.

```python
expensive = (
    products.query()
    .filter(price__gte=100, active=True)
    .order_by("-price", "title")
    .limit(20)
)

for product in expensive:  # the query runs here
    print(product.sku)
```

Each call returns a **new** `QuerySet`, so a partially built query is safe to
reuse as a base:

```python
in_stock = products.query().filter(stock__gt=0)
cheap = in_stock.filter(price__lt=10)  # in_stock is unchanged
```

### Lookups

| Lookup | Cypher |
| --- | --- |
| `field=value` | `=` |
| `field__ne` | `<>` |
| `field__gt`, `field__gte`, `field__lt`, `field__lte` | `>`, `>=`, `<`, `<=` |
| `field__in` | `IN` |
| `field__contains` | `CONTAINS` |
| `field__startswith`, `field__endswith` | `STARTS WITH`, `ENDS WITH` |
| `field__isnull` | `IS NULL` / `NOT … IS NULL` |

A list or tuple value implies `IN`, and `None` implies `IS NULL`, so
`filter(sku=["A1", "A2"])` and `filter(stock=None)` do what you would expect.

An unrecognised lookup raises `QueryError` rather than being silently ignored.

### Q objects

Use `Q` to build reusable filters and to combine them with `&`, `|` and `~`:

```python
from neoloom import Q

adults = Q(age__gte=18)
dutch = Q(country="NL") | Q(country="BE")

people.query().filter(adults & dutch).all()
people.query().filter(~Q(status="archived")).all()
```

Combining builds a tree, so grouping is preserved. The first query above renders
as `age >= 18 AND (country = 'NL' OR country = 'BE')` — the `OR` keeps its
parentheses instead of being flattened into the surrounding `AND`.

### Excluding

`exclude()` is the negation of `filter()`:

```python
products.query().exclude(active=False).all()
products.query().exclude(Q(stock=0) | Q(price__gt=1000)).all()
```

### Pagination

```python
page_two = products.query().order_by("sku").offset(20).limit(20).all()
```

`SKIP` and `LIMIT` are emitted in the order Cypher requires, whichever order you
call them in.

## Relationships

neoloom has two relationship layers. Use the descriptor for navigation, and a
relationship model when the edge carries properties of its own.

### Relationship descriptors

```python
from neoloom import Node, Relationship, StringField


class Team(Node):
    name = StringField(unique=True, nullable=False)


class Person(Node):
    email = StringField(unique=True, nullable=False)
    teams = Relationship(Team, rel_type="MEMBER_OF", direction="->")
```

`direction` is `"->"` (outgoing), `"<-"` (incoming) or `"--"` (either).

Assign a node or a list of nodes, then commit to write the edges:

```python
person = Person(email="ana@example.com")
person.teams = [platform, infra]
people.add(person)
session.commit()  # writes both MEMBER_OF edges
```

Reading a relationship traverses the graph on first access and caches the result:

```python
person = people.get(email="ana@example.com")
for team in person.teams:  # one query, here
    print(team.name)

len(person.teams)  # no second query
person.teams.refresh()  # forces a reload
person.teams.first()
```

A node must be attached to a session for traversal to work. Nodes returned by a
repository or `QuerySet` are attached for you; a node you constructed yourself is
not, and its relationships read as empty.

### Forward references

A model cannot reference itself, or a class defined later, by name. Pass a string
instead:

```python
class Person(Node):
    email = StringField(unique=True, nullable=False)
    mentors = Relationship("Person", rel_type="MENTORED_BY", direction="<-")
```

The name is resolved the first time the relationship is used.

### Relationship models

When an edge needs properties, declare it as a relationship model. The type is
derived from the class name — `MemberOf` becomes `MEMBER_OF` — unless you set
`rel_type`.

```python
from neoloom import RelationshipTo, StringField


class MemberOf(RelationshipTo):
    since = StringField()


MemberOf.create(session.ogm(), from_node=person, to_node=team, since="2026-01-01")
MemberOf.delete(session.ogm(), from_node=person, to_node=team)
```

`RelationshipTo` and `RelationshipFrom` set the direction. `RelationshipBoth` is
for matching only: creating or deleting through it raises `QueryError`, since
there is no direction to write.

## The query builder

For anything the model layer does not cover, use the builder directly. Methods
chain and the query is sent on `execute()`.

```python
ogm = session.ogm()

rows = (
    ogm.match_node(alias="p", label="Person", new=True)
    .match_relation(alias="r", rel_type="MEMBER_OF", direction="to")
    .match_node(alias="t", label="Team")
    .where(alias="p", field="age", operator="gte", value=18)
    .return_("p{.*}", "t{.*}")
    .order_by("p", "age", ascending=False)
    .limit(25)
    .execute()
)
```

Inspect a query without running it:

```python
ogm.match_node(alias="p", label="Person", new=True).return_("p{.*}").sync()
print(ogm.get_cypher(), ogm.get_parameters())
```

Raw Cypher, when you want full control:

```python
rows = ogm.execute_raw_cypher(
    "MATCH (p:Person) WHERE p.age > $min RETURN p.email AS email",
    {"min": 18},
)
```

The builder resets after every `execute()`, including when the query raises, so
one instance can be reused safely.

### APOC batch operations

`call_apoc_periodic_iterate()` wraps the clauses built so far: the MATCH/WHERE/
RETURN parts become the driving query, and the MERGE/CREATE/SET/DELETE parts the
write query run per batch.

```python
(
    ogm.match_node(alias="p", label="Person", new=True)
    .return_("p")
    .set_properties(alias="p", properties={"migrated": True})
    .call_apoc_periodic_iterate({"batchSize": 1000})
    .execute()
)
```

This requires the [APOC](https://neo4j.com/docs/apoc/current/) plugin to be
installed on the server; without it Neo4j raises `ProcedureNotFound`.

## Error handling

Every error derives from `NeoloomError`, so one `except` catches the library.

| Exception | Raised when |
| --- | --- |
| `ConfigurationError` | No URI or driver given; an unknown forward reference |
| `ValidationError` | A field value or a required field fails validation |
| `UniquenessError` | A `unique=True` value already exists |
| `NotFoundError` | An update matched no node |
| `QueryError` | A query cannot be built — unknown operator, unfilterable type |

```python
from neoloom import NeoloomError, ValidationError

try:
    session.commit()
except ValidationError as exc:
    ...
except NeoloomError as exc:
    ...
```

`ValidationError` also subclasses the built-in `ValueError`.

## API reference

Everything below is importable directly from `neoloom`.

**Models** — `Node` (alias `BaseNode`)

**Fields** — `StringField`, `IntegerField`, `FloatField`, `BooleanField`,
`DateTimeField`, `BaseField`

**Relationships** — `Relationship`, `RelationshipTo`, `RelationshipFrom`,
`RelationshipBoth`, `BaseRelationship`, `LazyRelationshipProxy`

**Persistence** — `Session`, `Repository`, `NodeMapper`, `UnitOfWork`,
`IdentityMap`

**Querying** — `Q`, `QuerySet`, `QueryBuilder` (alias `Neo4jOGM`)

**Errors** — `NeoloomError`, `ConfigurationError`, `ValidationError`,
`UniquenessError`, `NotFoundError`, `QueryError`

## Development

```bash
git clone https://github.com/amirfaghihi/neoloom
cd neoloom
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

make test      # unit tests (no database needed)
make lint      # ruff
make typecheck # mypy
make check     # all of the above
```

Integration tests need a live Neo4j and are skipped by default:

```bash
make integration   # starts Neo4j in Docker, runs them, tears it down
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.

## License

MIT — see [LICENSE](LICENSE).
