Postgres
Postgres 14 or later (tested on 17), over JDBC: plain SQL, with no extension beyond pgvector, which
vector search uses where the server has it. Drivine keeps a graph in the same tables as on Oracle, so
a node carries any number of labels (a label is data, not a table), and models, the query DSL and
StatelessGraphObjectManager are the same as on the graph engines. Add the driver yourself:
org.postgresql:postgresql.
database: datasources: graph: type: POSTGRES host: localhost port: 5432 database-name: app # the database to connect to user-name: app password: secret default-graph-path: drivine # names the graph's tables; this is the defaultuser-name and database-name are required, and port is 5432 unless given. A cypher-dialect or a
protocol means nothing here and is ignored with a warning. 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, so Contracts and contracts are one graph. The pool is as on Oracle (HikariCP,
pool-max, connection-timeout, idle-timeout), and sends a string without a type, so a statement
casts it as it needs (stringtype=unspecified). To use a DataSource the application already has:
registry.register(JdbcConnectionProvider("graph", DatabaseType.POSTGRES, dataSource, graphName = "drivine")).
Drivine does not configure a DataSource it is given: set stringtype=unspecified on it yourself, as
statements written by hand and the test kit’s fixtures bind JSON text where a column is jsonb. For a
pool of Drivine’s own without the YAML:
val provider = JdbcConnectionProvider.pooled( "graph", DatabaseType.POSTGRES, JdbcConnectionProvider.postgresUrl("localhost", 5432, "app"), "app", "secret", graphName = "drivine",)How a graph is stored. The tables Oracle keeps, made, where they are not there yet, before the
datasource’s first connection:
<graph>_node (nid, labels, props, stamp) and <graph>_edge (eid, src, dst, type, props), with
labels (a JSON array) and props as jsonb, and <graph>_schema for what schema management made.
A GIN index over labels answers whether a node carries a label. Beside them are three functions,
<graph>_write, <graph>_find and <graph>_owns, which a save is carried out by. A store that an
earlier version of Drivine made is brought up to date on first use, and keeps its rows. A node’s stamp
is the stamp column, returned by a load as __drivine.stamp. Deleting a node’s row deletes its
relationships with it.
Notes:
- A statement written by hand is SQL. Parameters are named
$name; a collection is bound as JSON, forCAST($list AS jsonb)to read. Ajsonorjsonbcolumn comes back as a map, soSELECT jsonb_build_object(…)hydrates with.transform(MyView::class.java). A function body in dollar quotes,$fn$ … $fn$, is left as it is. - A
CypherStatementis compiled to SQL over the graph’s tables, as on Oracle: a read is a query, and a write a function made for the statement inpg_temp, applied whole or not at all. See Cypher on a relational database. - 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 (#609, #631). 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 (#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. - Temporal values are their ISO 8601 text, and a year before 1 is numbered, as on Oracle.
- Compiled Cypher works float arithmetic,
sum()andavg()out in exact decimal (numeric), not as doubles, so it is more accurate:0.1 + 0.2is0.3, where Neo4j gives0.30000000000000004. A result differs from a graph engine’s only around its 16th significant digit, and still comes back as a float. It shows only where a result is compared for exact equality with one worked out in doubles, which is fragile with floats anyway. A float’s text carries no trailing zeros:toString(4.0 / 2)is"2.0". jsonbholds no NaN or infinity. A statement given one as a parameter writes it as a number past any double’s, and works with it as the float it is, as Neo4j does; the rows give it back as that float. A statement that writes and is given one is refused, rather than store that number.- A statement that reads and may work NaN or an infinity out is compiled the same way, decided as it is compiled: one
that divides or takes
%by anything but a number other than 0 written in it or given, or takestoFloatof anything but a number, or a text that names neither, written in it or given. So a number divided by a float zero is NaN or an infinity, as on Neo4j (0.0 / 0.0,$x / 0.0), wherenumericfails, andtoFloat('NaN')is NaN. Such a statement’s SQL is longer (RETURN n.x / n.yabout three times) and slower: over 20,000 nodes,n.x / n.yof the 2,000 a filter keeps takes about 1.8 times as long (18 ms against 10), and three values worked out from each node about 3 times (226 ms against 75). Any other statement compiles exactly as before. A statement that writes and divides a float by zero fails withdivision by zero, rather than store that number, andtoFloatof a text that names NaN or an infinity is null in it. A number divided by an integer zero fails, as on Neo4j, but where both are written in the statement, which Neo4j works out before it runs, as Java does:1.0 / 0is an infinity. - A statement that writes and returns rows is one with
RETURNING, or a data-modifyingWITH: one statement, which writes nothing when it fails. - A raw
whereClauseis a SQL predicate. A property of the root may be written as Cypher writes it (issue.state = 'open'); anything more is SQL overissue.props,issue.labelsandissue.stamp(issue.props ->> 'state'). Postgres’s?operators cannot be used: JDBC takes a?for a parameter. - A save is a plan of steps, the same plan Oracle’s block carries out, which
<graph>_writecarries out in one statement: applied whole or not at all, and stamped and checked as on the graph engines. A node is found by its id and its labels, then locked by its key and looked at again. Another writer that changes a node in SQL should give it a new stamp, as on Oracle. - A save the database turns away for a serialization failure (
40001) or a deadlock (40P01) is run again, as on Neo4j, Memgraph and Oracle. saveAllsaves fragments 500 to a statement, and each view by a statement of its own, in one transaction;patchAllwrites 500 nodes to a statement.- A delete is one statement, and can cascade with
DELETE_ALLandDELETE_ORPHAN. - 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) (#617). This holds where the rows are known to hold no list or map 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 number is compared and ordered as a number only where one is stored: the text
'7'is not7, as on the graph engines. Postgres keeps an empty string apart from null, so""is a value. - 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 compares and sorts text by its code points, as Cypher does (
B, a, b), not by the database’s collation: in theCcollation, whose byte order is code point order in a UTF-8 database. In a list the statement makes, such asORDER BY [n.last, n.first], a text is ordered by its code points over its first 500 characters, and past them by the database’s collation. A list read from a property, and a map, still order the texts in them by the database’s collation (ORDER BY n.tags). - Index the id of each node type you save,
constraints.ensure(UniquenessConstraintSpec("Person", "id")), or with a range index: without one a save reads the label’s nodes to find the one it writes. matchestakes a regular expression in Postgres’s dialect, matched against the whole value. A leading(?i)ignores case.- A vector index is a partial HNSW index of pgvector over the embeddings of the label’s nodes that
are of its size; a node written with another size is left out, not refused.
loadNearestgoes through it and is approximate; with no index made, it measures every node of the label whose embedding is of the vector’s size, and is exact. pgvector is made with the graph where the server has it and the user may: without it, everything else works. loadMatchingreads the full-text syntax Oracle reads, and counts each term in the text as Oracle does without Oracle Text. 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 Drivine’s own docs, the top hit was Neo4j’s for 6 of 21 queries, and is for 17. A full-text index is a GIN index ofpg_trgmtrigrams over the text, which narrows a search to the nodes that hold a term’s words. Every node that holds a term is among them, so how rare each term is comes out the same; 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 search sees its own transaction’s writes. Ananalyzeris recorded with the index and not applied. 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;FullTextIndexSpec’sanalyzerpicks another there.- The deprecated
GraphObjectManager.saveandsaveAll, and the Cypher migration tools, do not run on Postgres, as on Oracle; that manager’s loads, queries and deletes work.
What a read looks like. A load is one statement, written to be read beside the Cypher it stands
for. A row is called what the model calls it, with an underscore so that a field may be called user
or order. A relationship field is a subquery over the nodes reached from the root’s row: a path is
joins over the edge table, a step to a depth is a recursive query anchored at the node it starts from,
and each label is tested on its own, so that a partial index over the nodes of one is used. Where
Cypher loads a view with
MATCH (claim:Claim) WHERE claim.id = $idRETURN { claim: claim {.*}, companies: [(claim)-[:MENTIONS]->(companies:Company) | companies {.*}], people: [(claim)-[:MENTIONS]->(people:Human) | people {.*}] } AS resultPostgres is sent
SELECT jsonb_build_object( 'claim', (claim_.props || jsonb_build_object('labels', claim_.labels) || jsonb_strip_nulls(jsonb_build_object('__drivine.stamp', claim_.stamp))), 'companies', COALESCE(( SELECT jsonb_agg((companies_.props || jsonb_build_object('labels', companies_.labels) || jsonb_strip_nulls(jsonb_build_object('__drivine.stamp', companies_.stamp))) ORDER BY companies_.eid) FROM ( SELECT e.src AS src, e.eid, e.props AS eprops, companies_.nid, companies_.labels, companies_.props, companies_.stamp FROM drivine_edge e JOIN drivine_node companies_ ON companies_.nid = e.dst WHERE e.src = claim_.nid AND e.type = 'MENTIONS' AND companies_.labels @> '["Company"]' ) companies_), '[]'::jsonb), 'people', COALESCE(( SELECT jsonb_agg((people_.props || jsonb_build_object('labels', people_.labels) || jsonb_strip_nulls(jsonb_build_object('__drivine.stamp', people_.stamp))) ORDER BY people_.eid) FROM ( SELECT e.src AS src, e.eid, e.props AS eprops, people_.nid, people_.labels, people_.props, people_.stamp FROM drivine_edge e JOIN drivine_node people_ ON people_.nid = e.dst WHERE e.src = claim_.nid AND e.type = 'MENTIONS' AND people_.labels @> '["Human"]' ) people_), '[]'::jsonb)) AS resultFROM drivine_node claim_WHERE claim_.labels @> '["Claim"]' AND (claim_.props ->> 'id') = CAST($id AS text)Testing. PostgresTestContainer starts Postgres with pgvector (pgvector/pgvector:pg17; another
with POSTGRES_TEST_IMAGE or -Dtest.postgres.image) and gives a user, drivine, and a database,
drivine. @EnableDrivineTestConfig uses it for a datasource of type POSTGRES, or a local
instance with USE_LOCAL_POSTGRES=true (see TEST_CONTAINERS.md). Add the JDBC
driver to the test classpath. A test that seeds its graph in Cypher keeps its fixtures with
SqlCypherFixtures, as on Oracle, and TestCleanup.beforeEach works on Postgres too. Without Spring:
val provider = JdbcConnectionProvider.pooled( "graph", DatabaseType.POSTGRES, PostgresTestContainer.getJdbcUrl(), PostgresTestContainer.getConnectionUsername(), PostgresTestContainer.getConnectionPassword(),)Drivine’s own Postgres tests run with
./gradlew :test --tests '*Postgres*'They run by default, in a plain ./gradlew test. POSTGRES_TESTS=false skips them. CI runs them in the
main job. A job of its own (test (Postgres)) runs the sample app’s stateless tests and the root
module’s stateless Spring tests on a Postgres datasource
(DATABASE_DATASOURCES_GRAPH_TYPE=POSTGRES, DATABASE_DATASOURCES_NEO_TYPE=POSTGRES).

