Oracle
Oracle Database 23.9 or later, through oracledb in thin mode. Oracle doesn’t speak Cypher: Drivine reads the graph
with SQL/PGQ (GRAPH_TABLE) and writes it as rows. Models, the DSL and the object manager are the same as on the graph
engines.
Drivine needs 23.9 because Cypher’s randomUUID() is Oracle’s UUID(), a random version 4 UUID, which Oracle added
in 23.9. A provider reads the version the driver found on its first connection, which needs no grant. On an older
Oracle, plain SQL still runs, and a Cypher statement is refused with the version found.
DATABASE_TYPE=ORACLEDATABASE_HOST=localhostDATABASE_PORT=1521 # the defaultDATABASE_NAME=FREEPDB1 # the service: requiredDATABASE_USER=appDATABASE_PASSWORD=secretDATABASE_DEFAULT_GRAPH_PATH=drivine # names the property graph and its 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 114
characters. In code, it is defaultGraphPath on the connection’s properties, or on the builder:
DatabaseRegistry.getInstance().builder().withProperties(properties).defaultGraphPath('tenant_a').register('TENANT_A').
Two databases over one Oracle service, each naming its own graph, keep apart: each manager reads, writes and indexes
its own graph’s tables alone.
How a graph is stored. Two tables and a 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 work as
they do elsewhere. A node’s stamp is the stamp column. A third table, <graph>_schema, records the indexes and
constraints Drivine made, as drivine4j records them. The layout is drivine4j’s, so the JVM, Node and Python read one
store, and each sees and drops the indexes the others made, and makes none beside them.
Beside the tables the 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 the notes in
Cypher on Oracle and Postgres), and <graph>_era and <graph>_era_date, which
renumber a year before 1 between Cypher’s numbering and Oracle’s as a date or a moment is read and written. 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 connection to the graph first connects. 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. 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 ([null] = [1]), 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.
Notes:
- A statement you write yourself is SQL. Parameters are named
$name, as in Cypher. A statement that writes and returns rows is a PL/SQL block that hands them back withDBMS_SQL.RETURN_RESULT. - 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 predicate you write yourself, for a load or a delete, is SQL over the root’s row: its
props,labelsandstamp. 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 save is one block and checks the stamp, as on the graph engines. A save turned away by a deadlock (
ORA-00060) or a serialization failure (ORA-08177) is run again. Another writer that changes a node in SQL should give it a new stamp: the first sixteen characters ofstampfor its properties or labels, the last sixteen for its relationships. - Index the id of each class you save:
uniquenessConstraint('Person', 'id')makes the unique index a node is found by. Without one, a save reads the table. - 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.
- Oracle reads an empty string as null, so a comparison with
''in the DSL is written to find a property stored empty. 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 backreference names the group the expression numbers it by,\1to\8;\9is 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, kept in step by a trigger, with an Oracle vector index over
it by neighbour partitions. The table holds the embeddings of the index’s size; a node written with another size is
left out, not refused.
loadNearestis 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. - 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 word as on Postgres. In a phrase a*is text, as on Neo4j:"of sou*"is the phraseof sou. The index holds what has been committed, so a search doesn’t see its own transaction’s writes. Oracle Text is in the regular Free image, 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 scores them by BM25, as Postgres does. - Each session Drivine opens makes no adaptive plan (
OPTIMIZER_ADAPTIVE_PLANS = FALSE). On tables without statistics, Oracle’s adaptive join plan gave no rows for a read of a graph just written, where the same plan, fixed, gave them all (#331). A statement of yours that relies on an adaptive plan can ask for one with the hint/*+ OPT_PARAM('optimizer_adaptive_plans' 'true') */. - Each connection keeps the statements it runs prepared, 30 of them (
oracledb’sstmtCacheSize), 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, drivine#396). 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.

