Postgres
Postgres 14 or later (tested on 16), as drivine4j states. Postgres 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, and pg_trgm,
which a full-text index is made with (it ships with Postgres’s contrib modules). Models, the DSL
and the object manager are the same as on the graph engines; PersistenceManager runs plain SQL, with cursors and
transactions, as it always has.
DATABASE_TYPE=POSTGRESDATABASE_HOST=localhostDATABASE_PORT=5432DATABASE_NAME=appDATABASE_USER=appDATABASE_PASSWORD=secretDATABASE_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 are not there, as on
Oracle, whichever manager is first: a database you connect to through Drivine gets the graph’s tables, even if you send
it only plain SQL. A user who may only read connects to a graph that is already there and up to date. 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: to keep a database you use only for plain SQL free of them, narrow the catalog with .forDatabase(...).
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.
Notes:
- A statement you write yourself is SQL. Parameters are named
$name, as in Cypher, and a statement is bound only the ones it names. - A Cypher statement,
cypherStatement('MATCH …'), is compiled to SQL over the graph’s tables and gives the rows a graph engine gives: see Cypher on a relational database to start, and Cypher on Oracle and Postgres for how it differs. - A write’s loop over its rows that reads the node or edge table for each row, where it adds to that table or joins it to
the rows of a
CALL { … }, runs withenable_seqscan,enable_hashjoin,enable_mergejoinandenable_materialoff, set back after it to what they were (#628, #646, drivine4j#609). Postgres kept a plan made while the table was empty, or took aCALL’s one row for 100, and read the whole table for each row:MATCH (n) MERGE (n)-[:R]->(n)took 28 s over 20,000 nodes where it now takes under 1 s. The settings are set for the transaction (set_config(…, true)), so a statement that fails leaves them as its transaction or savepoint had them. - A statement’s rows, a JSON list, read each row’s node or relationship back from its table by its id through a probe
of the table’s key,
LEFT JOIN LATERAL (SELECT * FROM … WHERE nid = … OFFSET 0) … ON TRUE, not a plain join (#655, drivine4j#644). A plain join is planned by the table’s statistics, and where they said the table was empty while it still had pages, as anANALYZEjust after aDELETEleaves them, each row was compared with every row of the table: aFOREACHover 20,000 nodes took 19 s where it now takes under 1 s. The probe is a scan of the key’s index whatever the statistics say. - A predicate you write yourself, for a load or a delete, is SQL over the root’s row: its
props,labelsandstamp, e.g.(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. To compare one with a number, read it as a number in SQL. - A number is compared as a number only where one is stored:
'7'is not7, as on the graph engines. - A compiled
MATCHthat compares a property by=with a value a row holds (UNWIND $rows AS row MATCH (n:Person {id: row.id}),UNWIND $ids AS id MATCH (n:Person {id: id}), orWHERE n.id = row.id) is read through the range index or the uniqueness constraint over the property, as one compared with a text given is: beside the comparison stands a test of the property’s text among the texts the value can be stored as, a number’s with no zeros after its point and with each count of them up to 16, sincenumerickeeps the scale a product makes (0.25 * 10is stored as2.50) (#636, drivine4j#617). Compared as JSON alone, inside aCASE, each row read every node of the label: in drivine4j, APOC’s export replay took 1.77 s for each statement of 20 rows. This holds where the rows are known to hold no list or map, and no null, at the key: a parameter’s, or a list written of literals, maps, lists and parameters, as APOC’s export writes its rows. A list’s numbers may be stored at any scale, so a row that may hold a list is compared as JSON alone, as before. A statement is compiled for the rows it is given each time it runs, so the same statement given a list later finds it. - 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 Oracle and Postgres). - 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;
loadNearestis 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;loadMatchingscores 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. 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. Scored by the counts alone, a common word outweighed the word a query was about, and a long text won by holding more of everything: over drivine4j’s docs, the top hit was Neo4j’s for 6 of 21 queries, and is for 17. How rare a term is comes out the same narrowed; only the label’s count of nodes and their average length are read from every node of it, and no term is matched in that read. 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 for TypeScript doesn’t take one yet.

