Skip to content

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.

Terminal window
DATABASE_TYPE=ORACLE
DATABASE_HOST=localhost
DATABASE_PORT=1521 # the default
DATABASE_NAME=FREEPDB1 # the service: required
DATABASE_USER=app
DATABASE_PASSWORD=secret
DATABASE_DEFAULT_GRAPH_PATH=drivine # names the property graph and its tables; this is the default

The 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 with DBMS_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, labels and stamp. A property of the root may be written as Cypher writes it, n.name = $name (or n.`a name` ), and is read as that property, as text; a property compared with true or false is 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 of stamp for 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.
  • matches takes 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, \1 to \8; \9 is 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. loadNearest is 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. loadMatching reads words, a "phrase", +must, -not and a prefix*, a word as on Postgres. In a phrase a * is text, as on Neo4j: "of sou*" is the phrase of 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’s stmtCacheSize), 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.