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=NEO4JDATABASE_HOST=localhostDATABASE_PORT=7687DATABASE_USER=neo4jDATABASE_PASSWORD=…A second database takes a prefix, ANALYTICS_DATABASE_TYPE and so on. Then register them:
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).
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, Oracle and SQLite). 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).
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). 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 three functions: <graph>_nulls_equal, by which a compiled Cypher
statement compares two lists or maps that may hold a null (see
Cypher on Postgres, Oracle and SQLite), and <graph>_era and
<graph>_era_date, by which it renumbers a year before 1 between Cypher’s numbering and Oracle’s as it reads and writes
a date or a moment. drivine4j and Drivine for TypeScript make them byte for byte alike. Making them takes
CREATE PROCEDURE, which the RESOURCE role grants, as well as the CREATE TABLE the tables take. A store an earlier
version made gains those it lacks 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 them, and Drivine logs a warning once for the graph when a statement
would call one, and looks for it again a minute later. Until then, without <graph>_nulls_equal, 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. Without the era
functions, a statement gives the same rows, renumbering a year inline, but Oracle takes longer to plan one of temporal
arithmetic: seconds, and over a minute for a moment two CALLs deep.
On Postgres, DATABASE_NAME is the database, and DATABASE_DEFAULT_GRAPH_PATH names the graph, drivine by default:
see Postgres. On either, Cypher you write is compiled to SQL: see
Cypher on a relational database.
Or in code:
registry.builder().database_type(DatabaseType.MEMGRAPH).host("localhost").register("graph")Postgres
Section titled “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.
DATABASE_TYPE=POSTGRESDATABASE_HOST=localhostDATABASE_PORT=5432DATABASE_NAME=appDATABASE_USER=appDATABASE_PASSWORD=…DATABASE_DEFAULT_GRAPH_PATH=drivine # names the graph's tables; this is the defaultThe 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, Oracle and SQLite). 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, ajsonbcolumn as Python values. - A raw
where, for a load or a delete, is SQL over the root’s row: itsprops,labelsandstamp, say(n.props ->> 'name') = $name. A property of the root may be written as Cypher writes it,n.name = $name(orn.`a name`), and is read as that property, as text; a property compared withtrueorfalseis compared with the text a JSON boolean is read as. - A number is compared as a number only where one is stored:
'7'is not7, 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, Brather thanB, 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, Oracle and SQLite). - 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.
matchestakes 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_nearestis 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_matchingscores the same with it and without it, and reads words, a"phrase",+must,-notand aprefix*. A word is a run of letters as Unicode’s Alphabetic holds them (with the vowel signs of Indic scripts), decimal digits and underscores, as the text’s[[:alnum:]_]reads it:m²xis the phrasem x. In a phrase a*is text, as on Neo4j:"of sou*"is the phraseof sou. It scores by BM25, as Lucene and so Neo4j’s full-text index do (k11.2,b0.75): a term weighs as it is rare among the label’s nodes, and a text’s counts against its length, in characters, beside the label’s average. A term written again at once,kiwi kiwi kiwi, is counted each time. Aprefix*adds the same to every node that holds it, however often and however rare, as Lucene’s prefix query does: inkiw* melon, a node withkiwiand nomelonscores about 0.54 of one with both, as on Neo4j. Oracle without Oracle Text scores the same way. A dotted name, asorg.example.models, is words here, split at each dot, as it is on Oracle and SQLite:example modelsfinds it, and the name itself is the phrase of its words. Neo4j’s default analyzer keeps such a name one token, which the whole name finds andexample modelsdoesn’t. drivine4j’sFullTextIndexSpecpicks another analyzer there; drivine-python doesn’t take one yet. - An index or a uniqueness constraint, a vector index’s HNSW index among them, is built with no parallel workers
(
max_parallel_maintenance_workers0), and the session’s own setting is put back after, whether the build passed or failed. A session that builds an index in parallel keeps a shared memory segment in/dev/shmfor as long as it lives, and the pool keeps it alive: in a container’s 64 MB, Docker’s default, enough of them leave too little for a later HNSW build, which asks for nearly all ofmaintenance_work_memat once and fails withcould not resize shared memory segment ... No space left on device. A large build takes longer for it. drivine4j closes the session after the build instead, and keeps its parallel workers; psycopg_pool would log a warning for each connection given back closed.
SQLite
Section titled “SQLite”SQLite keeps a graph in a file, with no server: drivine4j’s store, in the same tables as Postgres, so a file one library
wrote, drivine4j, Drivine for TypeScript or this one, the others read and write as it is. pip install "drivine[sqlite]"
installs what it takes: apsw, whose wheels bundle a SQLite of their own (3.53.2 or
later). So the store runs on the same SQLite whatever SQLite Python was built
with, as drivine4j’s and Drivine for TypeScript’s run on the one their drivers bundle. Models, the query DSL and the
object manager are the same as on the other stores; the persistence manager runs plain SQL, with cursors and
transactions, and Cypher you write is compiled to SQLite’s SQL, as on Postgres and Oracle: see
Cypher on Postgres, Oracle and SQLite. SQLite 3.53.2 writes a
float as text to 17 significant digits (3.53.0 and 3.53.1 still write 15), so a float compiled Cypher gives is the one a graph engine gives, and it runs
a CALL { … } that groups by a variable it imports. A SQLite older than 3.53.2, as an apsw built against an older
SQLite of the system runs, is refused as a connection opens.
Breaking change. SQLite now goes through apsw and the SQLite it bundles: pip install "drivine[sqlite]" brings
it, and pip install drivine alone no longer opens a SQLite store. Python’s own sqlite3 module and pysqlite3 are no
longer used, and Drivine no longer warns that floats may lose their last digits, as no SQLite it runs on does. A
failure inside a function Drivine registers, such as a regular expression that doesn’t compile, is raised as itself,
and SQLite’s own failures are apsw’s (apsw.Error, apsw.ConstraintError, apsw.BusyError …), not sqlite3.Error.
A named parameter given no value, or a text of more than one statement, is refused with DrivineError, not
sqlite3.ProgrammingError. The SQLite file is the same: one written before reads and writes as it did (#376).
DATABASE_TYPE=SQLITEDATABASE_NAME=/var/lib/app/graph.db # the file's path: there is no host, port, user or passwordDATABASE_DEFAULT_GRAPH_PATH=drivine # names the graph's tables; this is the defaultOr in code, registry.builder().database_type(DatabaseType.SQLITE).database_name("graph.db").register("graph"). The
file is made if it isn’t there, and the graph’s tables on the first connection, as on Postgres. :memory: is refused:
each pooled connection would open a database of its own. In the async API each connection’s calls run on a thread of
the connection’s own, so one waiting for the file’s lock holds up neither the event loop nor another connection.
How a graph is stored. <graph>_node (nid INTEGER PRIMARY KEY AUTOINCREMENT, labels TEXT, props TEXT, stamp TEXT), <graph>_edge (eid INTEGER PRIMARY KEY AUTOINCREMENT, src, dst REFERENCES <graph>_node ON DELETE CASCADE, type, props TEXT) and <graph>_schema, the indexes and constraints Drivine made. Labels and properties are JSON text, not
SQLite’s binary jsonb, and labels are written sorted, with nothing escaped that JSON does not require: a label is
found by its text. The stamp is a column. Moments compare and order to the nanosecond, whatever their offset.
The id of a node or relationship that is deleted is never given to another, within a statement, a transaction or after
it, as on Oracle and Postgres: an element id an application holds names the element it was read from or nothing.
SQLite keeps the greatest id each table has given in its own table, sqlite_sequence; deleting every row, as a test’s
cleanup does, leaves it, so ids go on from there. A file whose graph tables an earlier Drivine made, with INTEGER PRIMARY KEY alone, which gives a new row the id of the last one deleted, is refused on the provider’s first
connection and on every one after it, with a ReusedIdsError, from drivine and drivine.sync, that names the
graph and the file. Its graph is the graph’s name, as the provider was given it, and its path the file, None for a
database in memory or in a temporary file. There is no migration: make a new file and save the graph into it again.
- A save is the plan of steps Postgres’s write function carries out, carried out by a function Drivine registers on
each connection,
drivine_write, under a savepoint: applied whole or not at all, in a transaction or not. The other functions a statement calls are registered too:drivine_delete,drivine_epoch,drivine_lower(Unicode’s lower case, where SQLite’s lowers ASCII),drivine_similarity,drivine_regexp_countandREGEXP. A statement of yours may call them; a connection you open yourself has none of them. - A statement Drivine writes that gives back documents opens with
/* drivine:json */, and its text values are read as JSON. One you write is read as it is: a row is a dict of its columns, andlabelsandpropsare text. - A raw
whereis SQL over the root’s row, as on Postgres; a property written as Cypher writes it,n.name, is read as that property, as text. - A range index or a uniqueness constraint is a partial index over the property as a statement reads it, for the
label’s nodes. A vector or a full-text index is recorded and nothing is made for it:
load_nearestandload_matchingmeasure every node of the label, andload_matchingscores by BM25, as Postgres does. matchestakes a regular expression in Java’s dialect, as on drivine4j, matched against the whole value: aREGEXPreads it as Java does, and refuses what Python can’t read so, such as a script (\p{IsLatin}) or a look-behind of more than one length.
What a transaction gives. Each connection writes ahead to a log, enforces foreign keys and waits for a locked file for up to its busy timeout, 30 seconds by default.
- There is one writer at a time. A transaction takes the file’s write lock as it begins (
BEGIN IMMEDIATE), so even a transaction that only reads waits for the lock, for up to the busy timeout. One that holds it longer than 5 seconds is logged as a warning when it ends, committed, rolled back or released. - Reads outside a transaction don’t wait for a writer: each reads the file as it last was.
- Saves, patches and deletes outside a transaction are run again when SQLite turns them away as contended
(
SQLITE_BUSY). - Each statement Drivine writes applies whole or not at all.
- A connection you open yourself, with
sqlite3.connect, has none of this: no foreign keys, no write lock taken as a transaction begins, no warning, Python’s wait of 5 seconds for a locked file rather than 30, and none of Drivine’s functions. It does write ahead to the log, as the file keeps that mode once Drivine has set it.
Drivine reads os.environ, and doesn’t load .env files itself: use python-dotenv, pydantic-settings, or your
platform’s way.

