Skip to content

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 default

user-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, for CAST($list AS jsonb) to read. A json or jsonb column comes back as a map, so SELECT jsonb_build_object(…) hydrates with .transform(MyView::class.java). A function body in dollar quotes, $fn$ … $fn$, is left as it is.
  • A CypherStatement is 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 in pg_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 with enable_seqscan, enable_hashjoin, enable_mergejoin and enable_material off, set back after it to what they were (#609, #631). Postgres kept a plan made while the table was empty, or took a CALL’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 an ANALYZE just after a DELETE leaves them, each row was compared with every row of the table: a FOREACH over 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() and avg() out in exact decimal (numeric), not as doubles, so it is more accurate: 0.1 + 0.2 is 0.3, where Neo4j gives 0.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".
  • jsonb holds 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 takes toFloat of 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), where numeric fails, and toFloat('NaN') is NaN. Such a statement’s SQL is longer (RETURN n.x / n.y about three times) and slower: over 20,000 nodes, n.x / n.y of 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 with division by zero, rather than store that number, and toFloat of 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 / 0 is an infinity.
  • A statement that writes and returns rows is one with RETURNING, or a data-modifying WITH: one statement, which writes nothing when it fails.
  • A raw whereClause is a SQL predicate. A property of the root may be written as Cypher writes it (issue.state = 'open'); anything more is SQL over issue.props, issue.labels and issue.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>_write carries 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.
  • saveAll saves fragments 500 to a statement, and each view by a statement of its own, in one transaction; patchAll writes 500 nodes to a statement.
  • A delete is one statement, and can cascade with DELETE_ALL and DELETE_ORPHAN.
  • A compiled MATCH that 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}), or WHERE 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, since numeric keeps the scale a product makes (0.25 * 10 is stored as 2.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 not 7, 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, 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 compares and sorts text by its code points, as Cypher does (B, a, b), not by the database’s collation: in the C collation, whose byte order is code point order in a UTF-8 database. In a list the statement makes, such as ORDER 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.
  • matches takes 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. loadNearest goes 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.
  • loadMatching reads 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 (k1 1.2, b 0.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 prefix* adds the same to every node that holds it, however often and however rare, as Lucene’s prefix query does: in kiw* melon, a node with kiwi and no melon scores 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 of pg_trgm trigrams 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. An analyzer is recorded with the index and not applied. A dotted name, as org.example.models, is words here, split at each dot, as it is on Oracle and SQLite: example models finds 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 and example models doesn’t; FullTextIndexSpec’s analyzer picks another there.
  • The deprecated GraphObjectManager.save and saveAll, 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 = $id
RETURN { claim: claim {.*},
companies: [(claim)-[:MENTIONS]->(companies:Company) | companies {.*}],
people: [(claim)-[:MENTIONS]->(people:Human) | people {.*}] } AS result

Postgres 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 result
FROM 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

Terminal window
./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).