Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cookbook: From Zero to Graph Analytics

This chapter is a single end-to-end walk-through: load → enforce → query → analyze → export → integrate, ending with the versioning and provenance features that make patinaDB distinct. Every command here runs against a live patinaDB server (Installation, Quick Start) over its REST endpoint (curl) or Bolt endpoint (a Neo4j driver / cypher-shell) — pick whichever fits your workflow; both run the identical Cypher.

We’ll build a tiny social graph: Person nodes connected by FOLLOWS relationships. REST examples assume a node reachable at localhost:21001 with Basic auth neo4j:secret (see Quick Start).

1. Load data

For a one-off graph, just CREATE nodes with a query:

curl -s -u neo4j:secret -X POST localhost:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query": "CREATE (u1:Person {name:\"Ada\", age:36, city:\"London\"})"}'

For a real dataset, use LOAD CSV — a streaming CSV row source in the reading pipeline, just like UNWIND but from a file. Point it at a file the server can read (see file-I/O sandboxing below):

LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CREATE (:Person {
  name: row.name, age: toInteger(row.age), city: row.city
})
# people.csv
name,age,city
Ada,36,London
Grace,40,New York
Linus,29,Helsinki
Margaret,33,Boston
LOAD CSV WITH HEADERS FROM 'file:///data/follows.csv' AS row
MATCH (a:Person {name: row.from}), (b:Person {name: row.to})
CREATE (a)-[:FOLLOWS {since: toInteger(row.since)}]->(b)
  • WITH HEADERS makes each row a map keyed by header (row.name); without it each row is a list of string cells (row[0]).
  • Cells are strings — coerce with toInteger / toFloat / toBoolean. A coercion may sit directly in a CREATE/MERGE pattern property, as above.
  • Only file:// and bare/relative paths are read; http(s):// is rejected.

Loading online, in batches — a lot of rows at once

A bare LOAD CSV … CREATE buffers all resolved writes into one transaction (one Raft entry) before committing — fine for a small file, but an O(rows) memory cliff for a large one. Wrap the write in CALL { … } IN TRANSACTIONS OF n ROWS to chunk it into many small, independently-committed transactions instead:

LOAD CSV WITH HEADERS FROM 'file:///data/accounts.csv' AS row
CALL {
  WITH row
  CREATE (:Account {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS

Each batch of 1000 rows commits as its own engram (memory stays O(batch), not O(file)) and, on a cluster, as its own Raft entry. ON ERROR {CONTINUE|BREAK|FAIL} controls what happens to a failing batch. See Bulk Loading & Import.

Loading a lot of rows — bulk upsert

For loading (or reconciling) a large dataset from a file already sitting on the server’s disk, POST /mgmt/upsert (admin-only) resolves and applies a whole columnar file as one match-or-create changeset, index-accelerated and chunkable — see Bulk Upsert.

2. Enforce schema

Add constraints so bad data is rejected at write time. patinaDB supports IS UNIQUE, IS NOT NULL, IS NODE KEY, and IS :: <TYPE> on nodes, plus the same set on relationships — see Constraints for the full list:

CREATE CONSTRAINT person_name FOR (p:Person) REQUIRE p.name IS UNIQUE
status: 'Constraint created on :Person(name) IS UNIQUE'

Creating a constraint over already-populated data fails loudly if the data already violates it. Once in place, a violating write is rejected:

CREATE (p:Person {name: 'Ada'})
error: Unique constraint violation on :Person(name): value already exists

SHOW CONSTRAINTS lists them. Constraint DDL replicates to every node and rides Raft snapshots. See the Cypher chapter for the full DDL grammar.

3. Query

Ordinary Cypher — MATCH, WHERE, ORDER BY, aggregation. List people by age:

MATCH (p:Person) RETURN p.name AS name, p.age AS age ORDER BY p.age DESC

Who has the most followers? A traversal plus a count(*) group-by:

MATCH (:Person)-[:FOLLOWS]->(t:Person)
RETURN t.name AS name, count(*) AS followers
ORDER BY followers DESC, name

Over REST, the response’s scalars block is the clean column view:

"scalars": {
  "name": ["Ada", "Linus", "Grace"],
  "followers": [2, 2, 1]
}

patinaDB passes ~97% of the openCypher TCK; see Cypher Support for the exact coverage and the Limitations chapter for the gaps.

4. Analyze

Built-in, read-only graph algorithms run over an in-memory snapshot of the current graph and yield real nodes plus a per-node result. Rank the influential accounts with PageRank:

CALL patinadb.algo.pageRank('Person','FOLLOWS') YIELD node, score
RETURN node.name AS name, round(score*1000)/1000 AS score
ORDER BY score DESC
"scalars": {
  "name":  ["Ada", "Linus", "Grace", "Margaret"],
  "score": [0.387, 0.374, 0.202, 0.038]
}

patinadb.algo.wcc (weakly-connected components), patinadb.algo.degree (degree centrality), patinadb.algo.betweenness, patinadb.algo.closeness, patinadb.algo.triangleCount, and patinadb.algo.labelPropagation round out the set (seven algorithms in all), with gds.*.stream aliases for tooling. See Procedures.

To understand the shape of your data before writing queries, inspect the planner’s statistics catalog — per-property count, distinct values (NDV), and min/max:

CALL patinadb.stats('Person') YIELD label, property, count, ndv, min, max
RETURN property, count, ndv, min, max ORDER BY ndv
"scalars": {
  "property": ["age", "city", "name", null],
  "count":    [4, 4, 4, 4],
  "ndv":      [4, 4, 4, null],
  "min":      [29, "Boston", "Ada", null],
  "max":      [40, "New York", "Margaret", null]
}

The same catalog feeds cost-based entry-point selection and join ordering — it only ever changes which plan runs, never the result. See Procedures → Statistics catalog.

5. Export

Read the whole graph back out, read-only, into import-compatible files — one node file per label, one relationship file per type. GET /mgmt/export streams a tar archive:

curl -s -u neo4j:secret 'localhost:21001/mgmt/export?db=default&format=csv' \
  -o dump.tar
tar xf dump.tar
$ ls
nodes_Person.csv  rels_FOLLOWS.csv  _schema.json

$ cat nodes_Person.csv
:ID(uuid),age:int,city,name,:LABEL
1ac09593-6a05-5203-b12a-91294189c587,40,New York,Grace,Person
...

?format=parquet / ?format=arrow write the columnar equivalents. Selective exports are also available from within a query: CALL patinadb.export.csv(label, path) or CALL patinadb.export.query(query, file) write a file on the server — see Procedures → CSV export and the file-I/O sandbox note below.

Server file-I/O sandbox

LOAD CSV FROM 'file://…' and the export procedures both touch the server’s own filesystem — they read/write a file on the machine running the query, not on your client. Both are deny-by-default: an operator must explicitly whitelist directories with --allow-csv-dir/--allow-export-dir, and any query doing file I/O requires the global Admin role. See Authentication & TLS → Cypher-driven file I/O.

6. Integrate

  • The server speaks Bolt, so the official Neo4j drivers and the Neo4j Browser point straight at patinaDB.
  • Change Streams (CDC) (GET /changes) let an external system react to graph changes as they happen — cache invalidation, search-index sync, ETL.
  • The full REST API and management endpoints (/mgmt/*) cover backup/restore, cluster administration, and metrics.

7. Time-travel & provenance — the differentiators

Every write is recorded as an engram — a git-like commit. Build up some history:

CREATE (a:Person {name:'Ada', team:'core'})
CREATE (g:Person {name:'Grace', team:'core'})
MATCH (a:Person {name:'Ada'}) SET a.team = 'infra'

List the history (oldest first) with CALL patinadb.engrams(), then view a git-style diff of any single change:

CALL patinadb.diff('<set-engram-id>')
engram ae2eae1a-3fcc-5c26-aad9-b11c4949b5c3
parent:    54f17ad5-bc1d-5686-867a-9891bc906d92
timestamp: 1783448468

~ (7530d903:Person).team: 'core' -> 'infra'

Query the graph as it was at any past engram — no audit table required. Prefix the read with USE <db> AS OF '<engram-id>' (or pass an at field over REST):

USE default AS OF '<prev-engram-id>'
MATCH (a:Person {name:'Ada'}) RETURN a.team AS team
-- team: 'core'   (it is 'infra' at HEAD)

See Time Travel, Diffs, and Engrams.

Finally, Anamnesis auto-projects that change stream into a queryable W3C-PROV graph in a companion <db>__anamnesis database — who, what, when, and optional source / confidence / derived_from — enriched straight from Neo4j transaction metadata. It is opt-in per database (CALL patinadb.anamnesis.enable()). Where other databases leave you to assemble provenance from triggers or CDC, here it ships in the engine.

Where to go next