Oracle
Oracle Database 23ai or later, over JDBC. Oracle does not speak Cypher: Drivine reads a graph with
SQL/PGQ (GRAPH_TABLE) and writes it as rows, since Oracle refuses data manipulation through a
property graph. Models, the query DSL and StatelessGraphObjectManager are the same as on the graph
engines. Add the driver yourself: com.oracle.database.jdbc:ojdbc11.
database: datasources: graph: type: ORACLE host: localhost port: 1521 database-name: FREEPDB1 # the service to connect to user-name: app password: secret default-graph-path: drivine # names the property graph and its tables; this is the defaultuser-name and database-name are required, and port is 1521 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 114 characters.
Drivine makes a connection pool (HikariCP) for the datasource: pool-max connections at most (40
unless given), connection-timeout milliseconds to wait for one (5,000 unless given), and
idle-timeout milliseconds before an idle one is closed (the pool’s own default unless given). The
pool asks the database for a connection as it is made, so a service that cannot be reached or a user
that is turned away fails at startup. Each connection keeps 64 statements prepared
(oracle.jdbc.implicitStatementCacheSize): a save is the same block each time, and is neither sent
nor parsed again.
To use a DataSource the application already has, register a provider in code:
registry.register(JdbcConnectionProvider("graph", DatabaseType.ORACLE, dataSource, graphName = "drivine")).
Drivine does not close a DataSource it was given, and does not configure it: turn the driver’s
statement cache on there yourself. For a pool of Drivine’s own (HikariCP) without the YAML:
val provider = JdbcConnectionProvider.pooled( "graph", DatabaseType.ORACLE, JdbcConnectionProvider.oracleUrl("localhost", 1521, "FREEPDB1"), "app", "secret", graphName = "drivine",)How a graph is stored. Two tables and the property graph over them, made when a manager first
speaks to the graph: <graph>_node (nid, labels, props, stamp) and
<graph>_edge (eid, src, dst, type, props), with labels and props as JSON. Labels, property
names and relationship types are data, so @NodeLabels, @PropertyBag and edges.relate work as
they do elsewhere. To SQL written by hand the graph has one vertex label, node, and one edge label,
edge; a Cypher label is a test on labels and a relationship type a test on type. A node’s stamp is
the stamp column, and a load returns it as __drivine.stamp. Deleting a node’s row deletes its
relationships with it. A third table, <graph>_schema, records the indexes and constraints Drivine
made: see Schema Management.
Notes:
- A statement written by hand is SQL. Parameters are named
$nameas in Cypher. A collection is bound as JSON, forJSON_TABLEto read. A column of the JSON type comes back as a map, soSELECT JSON_OBJECT(… RETURNING JSON)hydrates with.transform(MyView::class.java). - A statement that writes and returns rows is a PL/SQL block that hands them back with
DBMS_SQL.RETURN_RESULT. A block is one statement: one that fails writes nothing. - 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. - A save is one statement and checks the stamp, as on the graph engines. Another writer that changes a
node in SQL should give it a new stamp too: the first sixteen characters of
stampfor its properties or labels, the last sixteen for its relationships.Stamps.setClauseandStamps.linksClauseare Cypher and do not apply. - A save the database turns away because another writer held a row it wanted, a deadlock (
ORA-00060) or a serialization failure (ORA-08177), is run again, as on Neo4j and Memgraph. saveAllsaves fragments 500 to a statement, and each view by a statement of its own. The batch is one transaction.patchAllwrites 500 nodes to a statement, and 100 where a@VectorIndexfield is written, in one transaction.patchis one block. Its predicate is run inside the block as a statement of its own, since Oracle Free 23.26 takes no parameter inside aGRAPH_TABLEof a block’s own SQL, which is how a predicate on a relationship is written.- A delete can cascade with
DELETE_ALLand withDELETE_ORPHAN. - Index the id of each node type you save:
constraints.ensure(UniquenessConstraintSpec("Person", "id"))makes a unique index and the index a node is found by. Without one a save reads the table. - A range index reads a property as text. A comparison or an order by a number or a moment is correct and is not served by that index, so an ordered or keyset-paged load is given no index advice.
- A range index is two indexes on Oracle: one over the property whatever the label, which a save and
a load by id find a node by, and one over the property of the label’s nodes alone. A
loadAll, acount, adeleteAllor agroupswhosewherecompares an indexed property with a text (eq,gt,gte,lt,lte) reads the second, and does not test the label on its own: the one index answers both. What else thewhereholds is tested on the nodes it gives.IN,neq, a test of part of a text, a comparison inside anor, and one with a number are read as before. A uniqueness constraint over one property already has an index over the label’s nodes, its unique index, which reads the same value: a filter on that property is read through it, and no second one is made. A range index made by an earlier version is found, listed and dropped as it is, and the nextensureof it adds the index over the label’s nodes. The backend reads which there are from<graph>_schemaon a thread of its own, at most once a minute and again once this process has made or dropped an index or a constraint. A read never waits for it: until the first is done, and while another is under way, statements are written with what was read last. - Oracle reads an empty string as null, so a stamp of
''is no stamp. A comparison with""in the query DSL is written to find a property stored empty all the same; SQL of yours that comparesJSON_VALUE(…)with''finds nothing. matchestakes 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 vector index (
indexes.ensure(VectorIndexSpec(...)), or@VectorIndexthrough schema management) is a table of embeddings beside the nodes, kept in step by a trigger, with an Oracle vector index over it by neighbour partitions.loadNearestgoes through it and is approximate, as on the graph engines. With no index made, it measures every node of the label and is exact. - A full-text index is a table of the indexed text beside the nodes, with an Oracle Text index over
it.
loadMatchingreads words, a"phrase",+must,-notand aprefix*. A backslash before punctuation or a space makes that character text:prefix\*is the word and\-notis not taken away. A backslash before a letter or a digit is a gap between two words, where Lucene drops it:foo\baris the phrasefoo barhere and the wordfoobaron Neo4j. The index holds what has been committed, so a search does not see its own transaction’s writes. Oracle Text is in the regular Free image and not the slim one: without it, or with no index made, a search counts the words in the text of every node of the label, and does see them. Ananalyzeris recorded with the index and not applied. - A property over 4,000 characters is stored and loaded whole, and compares as absent in a predicate.
- The deprecated
GraphObjectManager.saveandsaveAllthrowUnsupportedOperationExceptionon Oracle; that manager’s loads, queries and deletes work. Cypher statements written by hand do not run, and neither do the migration toolsRelationshipDirectionRepairandPathRelationshipReport, which are Cypher: they repair what saves before 0.1.0 wrote, and no graph on Oracle is that old.
What a read looks like. A load is one SQL/PGQ 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, and the node of a MATCH has its row’s name. IS node and IS edge stand where
Cypher has a label and a type, since both are data: a label is a test of labels, a type of type.
doc is the node as the map a load returns. Where Cypher loads a view with
MATCH (claim:Claim) WHERE claim.id = $idRETURN { claim: claim {.*}, people: [(claim)-[:MENTIONS]->(people:Human) | people {.*}] } AS resultOracle is sent
SELECT JSON_OBJECT( 'claim' VALUE claim_.doc, 'people' VALUE COALESCE(( SELECT JSON_ARRAYAGG(people_.doc ORDER BY people_.eid RETURNING JSON) FROM GRAPH_TABLE (drivine MATCH (claim_ IS node) -[e IS edge WHERE e.type = 'MENTIONS']-> (people_ IS node WHERE INSTR(JSON_SERIALIZE(people_.labels), '"Human"') > 0) COLUMNS (claim_.nid AS src, e.eid, people_.doc)) people_ WHERE people_.src = claim_.nid), JSON('[]')) RETURNING JSON) AS resultFROM GRAPH_TABLE (drivine MATCH (claim_ IS node WHERE INSTR(JSON_SERIALIZE(claim_.labels), '"Claim"') > 0) COLUMNS (claim_.nid, claim_.props, claim_.doc)) claim_WHERE JSON_VALUE(claim_.props, '$.id') = $idA relationship field is a query of its own, under the field it fills: Oracle does not let a
GRAPH_TABLE name the row it is nested in, so each returns the node it starts from as src and is
joined to that row outside. COLUMNS names only what the statement reads of a row, and every column
of the root once a whereClause written by hand is in it. A second row for
one field is numbered (people2_), as when a condition on people is tested beside the field itself.
A label is tested with JSON_EXISTS(… '$[*]?(@ == "Claim")') where every node of the label is read,
which the index over labels answers, and with INSTR where the node is found by its id or at the end
of a relationship. Where a range index on the label answers the where, the property is read as that
index reads it and the label is not tested on its own:
SELECT n_.doc AS resultFROM GRAPH_TABLE (drivine MATCH (n_ IS node) COLUMNS (n_.labels, n_.props, n_.doc)) n_WHERE CASE WHEN JSON_EXISTS(n_.labels, '$[*]?(@ == "Issue")') THEN JSON_VALUE(n_.props, '$.repoKey') END = $_p0 AND JSON_VALUE(n_.props, '$.state') = $_p1A @GraphPath field, and a list read to a depth, is the path’s own rows grouped by the node reached,
so each node is there once however many ways lead to it:
'employers' VALUE COALESCE(( SELECT JSON_ARRAYAGG(employers_.doc ORDER BY employers_.nid RETURNING JSON) FROM ( SELECT reached_.src, reached_.nid, ANY_VALUE(reached_.doc) AS doc FROM GRAPH_TABLE (drivine MATCH (claim_ IS node) -[e1 IS edge WHERE e1.type = 'MENTIONS']-> (m1 IS node WHERE INSTR(JSON_SERIALIZE(m1.labels), '"Human"') > 0) -[e2 IS edge WHERE e2.type = 'WORKS_AT']-> (reached_ IS node WHERE INSTR(JSON_SERIALIZE(reached_.labels), '"Company"') > 0) COLUMNS (claim_.nid AS src, reached_.nid, reached_.doc)) reached_ GROUP BY reached_.src, reached_.nid ) employers_ WHERE employers_.src = claim_.nid), JSON('[]'))Testing. OracleTestContainer starts Oracle Free (gvenzl/oracle-free:23-faststart; another with
ORACLE_TEST_IMAGE or -Dtest.oracle.image) and gives a user, drivine, in the service FREEPDB1.
@EnableDrivineTestConfig uses it for a datasource of type ORACLE, or a local instance with
USE_LOCAL_ORACLE=true (see TEST_CONTAINERS.md). Add the JDBC driver to the test
classpath. Without Spring:
val provider = JdbcConnectionProvider.pooled( "graph", DatabaseType.ORACLE, OracleTestContainer.getJdbcUrl(), OracleTestContainer.getConnectionUsername(), OracleTestContainer.getConnectionPassword(),)A test written for the graph engines usually seeds its graph and reads it back in Cypher, which Oracle
does not run. OracleCypherFixtures works such a statement out over the graph instead, so the test’s
fixtures stay as they are:
val fixtures = OracleCypherFixtures.over(persistenceManager) // Cypher fixtures here, everything else to Oraclefixtures.execute(QuerySpecification.withStatement("CREATE (:Person {id: 'ada'})-[:KNOWS]->(:Person {id: 'bob'})"))
OracleCypherFixtures.run(persistenceManager, "MATCH (p:Person) RETURN p.id ORDER BY p.id") // ["ada", "bob"]OracleCypherFixtures.clear(persistenceManager) // every node and relationship- It reads the Cypher fixtures use and no more:
CREATE,MATCHandMERGEpatterns with literal or bound properties,WHERE(comparisons joined byAND),SET,REMOVE,DETACH DELETE,UNWIND,WITH, and aRETURNof one expression or acount, withORDER BY. Anything else is refused:OR,ON CREATE SET,SKIP,LIMIT, aRETURNof several columns. - Keywords are upper case. A statement is taken for a fixture when it opens with
MATCH (,CREATE (,MERGE (orUNWIND; any other, one that opens withWITHincluded, goes to Oracle as SQL. - Each statement reads the whole graph into memory and writes back what it changed, a row at a time. It is for a test’s handful of nodes and nothing else.
- The manager must name its graph (
graphName), as one over aJdbcConnectionProviderdoes. TestCleanup.beforeEach(persistenceManager, "my-test")deletes the nodes whosecreatedByis the tag, in SQL on Oracle, as it does in Cypher elsewhere.
Drivine’s own Oracle tests are left out of a plain ./gradlew test: the image is large and the
database wants two gigabytes. Run them with
ORACLE_TESTS=true ./gradlew :test --tests '*Oracle*'CI runs the same command, in a job of its own (test (Oracle)).

