Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ jobs:
# up to date fails here, rather than slipping other changes into the release PR.
#
# `references` then rewrites the other files that name the version, the sample's model file and poetry.lock and
# the README, as the stubs step and `poetry lock` would, and lists them for the commit. It fails when one of them
# the README and docs/00-introduction.md, as the stubs step and `poetry lock` would, and lists them for the commit. It fails when one of them
# doesn't name the version where it expects, so that the release PR doesn't leave it naming the old one.
- name: Raise the version
env:
Expand Down
1,089 changes: 23 additions & 1,066 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ included. Only a merged version bump or a pushed tag publishes.
stubs step writes, so a model file that names another version fails it.
- `sample/poetry.lock`, whose entry for drivine has the version as PEP 440 normalizes it (`0.3.0rc1`), as
`poetry lock` writes it.
- `README.md`'s "0.3 is an alpha release": its `MAJOR.MINOR`.
- `README.md`'s and `docs/00-introduction.md`'s "0.3 is an alpha release": its `MAJOR.MINOR`.

Each must be found exactly once, or Prepare release fails, saying which: a file moved or a line reworded is then
updated in `REFERENCES`. A unit test checks that the list matches the files and names `pyproject.toml`'s version,
Expand Down
33 changes: 33 additions & 0 deletions docs/00-introduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@

[![PyPI](https://img.shields.io/pypi/v/drivine.svg)](https://pypi.org/project/drivine/)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)

**Docs: [drivine.org](https://drivine.org/py/)**, for Python, TypeScript, and Kotlin & Java.

A graph database client for Python, supporting **Neo4j**, **Memgraph**, **FalkorDB**, **Oracle** (SQL/PGQ) and
**Postgres** as graph stores, plus **Amazon Neptune** through the low-level API. Async first, with a sync API written from it, typed, and fast: one statement per round trip, no session
cache, no lazy loading. It gives you two ways to work with a graph:

1. **PersistenceManager**: a low-level API where you write the queries yourself.
2. **Object manager** (`StatelessGraphObjectManager`): a high-level API that maps Pydantic models, with a query DSL
whose typed references are stubs generated from your models.

Neptune connects for the `PersistenceManager`; the object manager runs on the others. Cypher you write runs on
Postgres and Oracle too, compiled to SQL: see [Cypher on a relational database](03-cypher-on-a-relational-database.md#cypher-on-a-relational-database).
0.3 is an alpha release: the API may still change. See [the design](../docs/design.md) for how it came about.

> **On Node.js or the JVM?** [Drivine](https://github.com/liberation-data/drivine)
> (`npm install @liberation-data/drivine`) and [drivine4j](https://github.com/liberation-data/drivine4j)
> (`org.drivine:drivine4j`) are its TypeScript and Kotlin & Java twins, with the same object manager, query DSL, schema
> management and engines. The three share a storage format: a store written from any of them is read, stamped and
> checked alike by the others.

```
pip install "drivine[neo4j]" # Neo4j, Memgraph, Neptune
pip install "drivine[falkordb]" # FalkorDB
pip install "drivine[oracle]" # Oracle 23ai and 26ai, through python-oracledb in thin mode
pip install "drivine[postgres]" # Postgres 14 or later, through psycopg 3
```

Python 3.11 or newer. [pi-pilgrim](https://github.com/liberation-data/pi-pilgrim), which `DrivineAssembly` is
written with, comes with each; `drivine[pilgrim]` still installs, as it did when pi-pilgrim was an extra.
84 changes: 84 additions & 0 deletions docs/01-quick-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
## Quick start

Describe the database in the environment:

```
DATABASE_TYPE=NEO4J
DATABASE_HOST=localhost
DATABASE_USER=neo4j
DATABASE_PASSWORD=…
```

Declare a fragment for each node a use case reads, and a view that composes them:

```python
from typing import Annotated

from drivine import GraphView, NodeFragment, NodeId, Relationship, RelationshipFragment, Root, TargetNode


class Person(NodeFragment, labels=["Person"]):
uuid: Annotated[str, NodeId()]
name: str
bio: str | None = None


class Organization(NodeFragment, labels=["Organization"]):
uuid: Annotated[str, NodeId()]
name: str


class WorkHistory(RelationshipFragment):
role: str
organization: Annotated[Organization, TargetNode()]


class PersonCareer(GraphView):
person: Annotated[Person, Root()]
employment_history: Annotated[list[WorkHistory], Relationship("WORKS_FOR")] = []
```

Then load and save, each in one statement:

```python
import asyncio

from drivine import DrivineAssembly, refs


async def main() -> None:
async with DrivineAssembly() as drivine:
gom = drivine.graph_object_manager()
await gom.save(
PersonCareer(
person=Person(uuid="p1", name="Ada", bio="Design Lead"),
employment_history=[WorkHistory(role="Lead", organization=Organization(uuid="o1", name="Studio"))],
)
)
q = refs(PersonCareer)
for career in await gom.load_all(PersonCareer, where=q.person.bio.contains("Lead")):
print(career.person.name, [it.organization.name for it in career.employment_history])


asyncio.run(main())
```

`DrivineAssembly` registers the database the environment describes and holds the managers. Entering it starts Drivine,
holding [the schema](07-schema.md#schema) if you declare one, and leaving it closes the drivers and pools, as Spring starts and
closes drivine4j with its context. In an application, [your own assembly holds it](09-with-pi-pilgrim.md#with-pi-pilgrim), and closes it
when it closes.

Or, without the assembly, register the database yourself. Leaving the registry closes it:

```python
from drivine import DatabaseRegistry, PersistenceManagerFactory, StatelessGraphObjectManager

async with DatabaseRegistry() as registry:
registry.build_or_resolve_from_env()
gom = StatelessGraphObjectManager(PersistenceManagerFactory(registry).get())
...
```

From here: [the persistence manager](04-statements.md#statements) for Cypher of your own, [transactions](05-transactions.md#transactions),
[queries](06-fragments-and-views.md#queries) and [typed references](06-fragments-and-views.md#typed-references), [the sync API](08-sync.md#sync), and
[DrivineAssembly](09-with-pi-pilgrim.md#with-pi-pilgrim) to wire it into an application.
146 changes: 146 additions & 0 deletions docs/02-connecting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
## Connecting

Describe a database in the environment, with the same variables as TypeScript's Drivine and drivine4j, so one `.env`
serves an application in any of them:

```
DATABASE_TYPE=NEO4J
DATABASE_HOST=localhost
DATABASE_PORT=7687
DATABASE_USER=neo4j
DATABASE_PASSWORD=…
```

A second database takes a prefix, `ANALYTICS_DATABASE_TYPE` and so on. Then register them:

```python
from drivine import DatabaseRegistry, PersistenceManagerFactory

async with DatabaseRegistry() as registry: # closes the drivers and pools on the way out
registry.build_or_resolve_from_env() # "default"
registry.build_or_resolve_from_env("analytics") # ANALYTICS_DATABASE_*

pm = PersistenceManagerFactory(registry).get() # the default database
...
```

`DrivineAssembly` registers the default database alone; to register others, subclass it and override
`register_databases(registry)` (see [With pi-pilgrim](09-with-pi-pilgrim.md#with-pi-pilgrim)).

On FalkorDB, `DATABASE_NAME` names the graph, `graph` by default.

On Oracle, `DATABASE_NAME` is the service (`FREEPDB1`, say), and `DATABASE_DEFAULT_GRAPH_PATH` names the graph,
`drivine` by default. A graph is drivine4j's tables, `<graph>_node`, `<graph>_edge` and `<graph>_schema`, and a SQL
property graph over them, made on the first connection when they aren't there, so one store serves the JVM, Node and
Python. `<graph>_schema` records the indexes and constraints Drivine made as drivine4j records them, so each library
sees and drops the indexes the others made, and makes none beside them. A range index is two indexes, as drivine4j
makes it: one over the property whatever the label, and one over the label's nodes alone, which a filter is read
through. A uniqueness constraint comes with a range index over its properties, which stays when the constraint is
dropped. The object manager writes SQL for it, and the query DSL, grouped reads, search and schema management work as on
the graph engines. A raw `where` is SQL over the node's row, `n`: its `labels`, `props` (a JSON object keyed by stored
name) and `stamp`, say `JSON_VALUE(n.props, '$.name') = $name`. A property of the node may be written as Cypher writes
it, `n.name = $name` (or ``n.`a name` ``), and is read as that property; a property compared with `true` or `false` is
compared with the text a JSON boolean is read as. Your own statements are SQL too, unless written as
Cypher (see [Cypher on Postgres and Oracle](04-statements.md#cypher-on-postgres-and-oracle)). `matches` takes a regular expression in
Oracle's POSIX dialect, matched against the whole value. A leading `(?i)` ignores case; what Java adds, such as
`(?:…)`, a look-around or `\b`, is not read. A backreference names the group the expression numbers it by,
`\1` to `\8`; `\9` is refused, as the group that anchors the expression would make it the tenth, which POSIX does not
have. A vector index is a table of embeddings beside the nodes, holding those of the index's size: a node written with
another size is left out, not refused. `load_nearest` is approximate through it. With no index made, or one of another
size than the vector's, it measures every node of the label whose embedding is of the vector's size, and is exact.

Each session of Drivine's Oracle pool makes no adaptive plan (`ALTER SESSION SET OPTIMIZER_ADAPTIVE_PLANS = FALSE`, run
as the pool opens it; a session it fails in is dropped from the pool, not given out). Once a graph's tables had grown
and shrunk without statistics, Oracle's adaptive join plan gave no rows for a read that filters a node by a label and a
property, where the same plan, fixed, gave them all ([#146](https://github.com/liberation-data/drivine-python/issues/146)).
A statement of yours that relies on an adaptive plan can ask for one with the hint
`/*+ OPT_PARAM('optimizer_adaptive_plans' 'true') */`. To have the sessions left as Oracle opens them, register a
provider made in code with `sets_no_adaptive_plans=False`: `registry.register(OracleConnectionProvider("default",
"localhost:1521/FREEPDB1", user, password, sets_no_adaptive_plans=False))`, its class from
`drivine.connection.oracle.oracle_connection_provider` (`drivine.sync.connection.oracle.oracle_connection_provider` for
the sync API). It is on by default because, without it, a read can come back with no rows and no
error; if you turn it off, run that statement in each session yourself.

Each session of the pool keeps the statements it runs prepared, 20 of them (python-oracledb's `stmtcachesize`), so a
save, the same block each time, is neither sent nor parsed again. A Cypher statement compiled to SQL is not kept: Oracle
holds a kept statement's memory for as long as the driver keeps it, from about 4 MB for a compiled statement to over
20 MB for one that reads a long text, and a few dozen of them ran the instance out of memory (ORA-04036,
[#218](https://github.com/liberation-data/drivine-python/issues/218)). So it is prepared again each time it runs: about
0.3 ms more for a short statement (Oracle reuses its plan; it doesn't parse it again from scratch). The object manager's
statements are kept, so where the object manager does what you need, it is the faster way to Oracle.

Beside the tables an Oracle graph is made with one function, `<graph>_nulls_equal`, by which a compiled Cypher
statement compares two lists or maps that may hold a null (see
[Cypher on Postgres and Oracle](04-statements.md#cypher-on-postgres-and-oracle)). drivine4j and Drivine for TypeScript make it byte for
byte alike. Making it takes `CREATE PROCEDURE`, which the `RESOURCE` role grants, as well as the `CREATE TABLE` the
tables take. A store an earlier version made gains it the next time a pool first connects to the graph. Where the user
that makes the graph may not make a function, the graph is made without it, and Drivine logs a warning once for the
graph when a statement would call it, and looks for it again a minute later. Until then a statement that writes, or the
step of a `reduce`, compares two lists or maps that hold a null and differ as JSON as false, where Cypher may give null
(`a = b`, of `[null] AS a, [1] AS b`), and a read declares the function itself.

On Postgres, `DATABASE_NAME` is the database, and `DATABASE_DEFAULT_GRAPH_PATH` names the graph, `drivine` by default:
see [Postgres](#postgres). On either, Cypher you write is compiled to SQL: see
[Cypher on a relational database](03-cypher-on-a-relational-database.md#cypher-on-a-relational-database).

Or in code:

```python
registry.builder().database_type(DatabaseType.MEMGRAPH).host("localhost").register("graph")
```

### Postgres

Postgres 14 or later (tested on 16), as drivine4j states, keeps a graph in two tables, as Oracle does, read with joins
and written through a function the store keeps. No extension is needed but pgvector, for vector search, which Drivine
makes where the server has it. Models, the query DSL and the object manager are the same as on the graph engines; the
persistence manager runs plain SQL, with cursors and transactions, and Cypher compiled to SQL: see
[Cypher on a relational database](03-cypher-on-a-relational-database.md#cypher-on-a-relational-database).

```
DATABASE_TYPE=POSTGRES
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=app
DATABASE_USER=app
DATABASE_PASSWORD=…
DATABASE_DEFAULT_GRAPH_PATH=drivine # names the graph's tables; this is the default
```

The graph's name names tables, so it is letters, digits and underscores, begins with a letter, and is at most 49
characters; Postgres folds it to lower case. The tables are made on the first connection when they aren't there, as on
Oracle, whatever is sent first: Cypher or SQL through the persistence manager, a statement of the object manager, or a
schema change. A user who may read the tables but not make them works once they are made. Postgres is a graph store
like the others, so a schema catalog that names no database applies to it too, and makes its indexes at startup.

**How a graph is stored.** drivine4j's store, byte for byte, so the JVM, Node and Python read and write one graph:
`<graph>_node (nid, labels, props, stamp)` and `<graph>_edge (eid, src, dst, type, props)`, with `labels` and `props`
as `jsonb` and a GIN index over the labels, so a node carries any number of them; `<graph>_schema`, the indexes and
constraints Drivine made; and `<graph>_write`, the function a save's steps are carried out by, as one statement that is
applied whole or not at all.

- A statement you write yourself is SQL, unless written as Cypher (see
[Cypher on Postgres and Oracle](04-statements.md#cypher-on-postgres-and-oracle)). Parameters are named `$name`, as in Cypher, and a
statement is bound only the ones it names. A row comes back as a dict of its columns, a `jsonb` column as Python
values.
- A raw `where`, for a load or a delete, is SQL over the root's row: its `props`, `labels` and `stamp`, say
`(n.props ->> 'name') = $name`. A property of the root may be written as Cypher writes it, `n.name = $name` (or
``n.`a name` ``), and is read as that property, as text; a property compared with `true` or `false` is compared with
the text a JSON boolean is read as.
- A number is compared as a number only where one is stored: `'7'` is not `7`, as on the graph engines.
- Text is ordered by the database's collation, so a page of mixed-case text may not be in the order Neo4j gives
(`a, b, B` rather than `B, a, b`). The range indexes are made in that collation, so an order and a seek are read
through them. Compiled Cypher orders text by its code points instead
([Cypher on Postgres and Oracle](04-statements.md#cypher-on-postgres-and-oracle)).
- A save checks the stamp, as on the graph engines. A save turned away by a serialization failure (`40001`) or a
deadlock (`40P01`) is run again.
- Index the id of each class you save: a range index or a uniqueness constraint is a partial index over the label's
nodes, which finds a node by its id.
- `matches` takes a Postgres regular expression, matched against the whole value.
- A vector index is a pgvector HNSW index over the embeddings of the label's nodes of its size; `load_nearest` is
approximate through it, and exact with no index made. A full-text index is a trigram index over the text a search
reads, which narrows the search; `load_matching` scores the same with it and without it, and reads words, a
`"phrase"`, `+must`, `-not` and a `prefix*`.

Drivine reads `os.environ`, and doesn't load `.env` files itself: use python-dotenv, pydantic-settings, or your
platform's way.
Loading
Loading