// STRYKE-NEO4J — NEO4J GRAPH CLIENT FOR STRYKE // CYPHER + PARAMS + RUN + SCHEMA INTROSPECTION

stryke package · namespace Neo4j · cdylib libstryke_neo4j · opt-in (kept out of stryke core)

Report GitHub Issues

>_STRYKE-NEO4J

Graphs, one stryke pipe away. Neo4j graph client for stryke — parametrized Cypher query and run, scalar/row helpers, and schema introspection over the Bolt protocol. An opt-in package, kept out of the stryke core binary so the daily-driver install stays slim. The Bolt driver pulls in tokio — too heavy to bake into stryke core.

It speaks Bolt directly via neo4rs, the pure-Rust driver — no JVM, no official-driver FFI — against any Neo4j 4.x or 5.x server. Every Neo4j::* function is a thin .stk wrapper over a JSON-in/JSON-out cdylib export; the Neo4j namespace exposes 44 functions across liveness, query, write, graph helpers, schema management, introspection, query planning, and pure Cypher/URL helpers.

Contents

Install

# build the cdylib, install as a stryke package
cd ~/projects/stryke-neo4j
cargo build --release
s pkg install -g .

# one-liner
make install

The release build produces target/release/libstryke_neo4j.{dylib,so}; s pkg install -g . places it in ~/.stryke/store/neo4j@<ver>/. The cdylib is dlopened in-process on first use Neo4j.

Usage

use Neo4j

var %conn = ( uri => "neo4j://localhost:7687", user => "neo4j", password => $ENV{NEO4J_PASS} )

# write with named params ($name placeholders, never string-interpolated)
Neo4j::run("CREATE (p:Person {id: \$id, name: \$name})",
    params => { id => 1, name => "Ada" }, %conn)

# query — each row is a hashref keyed by the RETURN aliases
val @people = Neo4j::query("MATCH (p:Person) WHERE p.id = \$id RETURN p.name AS name",
    params => { id => 1 }, %conn)
p $people[0]{name}        # Ada

# scalar + schema introspection
p Neo4j::scalar("MATCH (p:Person) RETURN count(p) AS n", %conn)
p Neo4j::labels(%conn)    # [ { label => "Person" }, ... ]

The convenience helpers cover the common single-node / single-relationship CRUD shape without hand-writing Cypher. They take scalar properties only; for list or map properties, or anything more elaborate, fall back to run / query with explicit Cypher.

# node CRUD by (label, key, value) — scalar props only
val $node = Neo4j::create_node("Person", { id => 1, name => "Ada" }, %conn)
Neo4j::merge_node("Person", "id", 1, props => { name => "Ada Lovelace" }, %conn)  # upsert
Neo4j::set_props("Person", "id", 1, props => { active => \1 }, %conn)            # update-only
p Neo4j::get_node("Person", "id", 1, %conn){name}                                # Ada Lovelace
p Neo4j::exists_node("Person", "id", 1, %conn)                                   # true

# typed relationships between two matched nodes
Neo4j::create_rel(
    from_label => "Person", from_key => "id", from_value => 2,
    to_label   => "Person", to_key   => "id", to_value   => 1,
    type       => "KNOWS", props => { since => 2020 }, %conn)
Neo4j::merge_rel(                                  # idempotent edge
    from_label => "Person", from_key => "id", from_value => 2,
    to_label   => "Person", to_key   => "id", to_value   => 1,
    type       => "KNOWS", %conn)

# counts + degree (portable across 4.x / 5.x)
p Neo4j::node_count(label => "Person", %conn)                       # 2
p Neo4j::relationship_count(type => "KNOWS", %conn)                 # 1
p Neo4j::degree("Person", "id", 2, direction => "out", type => "KNOWS", %conn)  # 1

# pluck a flat list, group-by a property
val @names = Neo4j::query_column("MATCH (p:Person) RETURN p.name AS nm", "nm", %conn)
val @dist  = Neo4j::count_property_values("Person", "name", %conn)  # [ { value, count }, ... ]

# remove
Neo4j::delete_node("Person", "id", 1, %conn)                        # count removed
Neo4j::delete_nodes("Person", match => { active => \0 }, %conn)     # DETACH DELETE matched

Connection options

Each call takes a trailing %opts hash carrying the Bolt endpoint and credentials (or $NEO4J_URL as a fallback). A Graph connection pool is cached per (uri, user, database).

OptionMeaning
uriBolt URI — neo4j://, neo4j+s://, bolt:// (default 127.0.0.1:7687)
userusername (default neo4j)
passwordpassword
databasetarget database on a multi-database server

When no uri is passed and $NEO4J_URL is set in the environment, that Bolt URI is used as the fallback endpoint. Credentials default to user neo4j with an empty password and the server's default database when unspecified. The pool is keyed on (uri, user, password, database); a pool whose call errors is dropped from the cache so the next call reconnects cleanly.

API surface

GroupFunctions
Livenessversion, ping, server_info
Queryquery, query_one, scalar, query_values, query_column
Writerun, batch (transaction)
Graph helperscreate_node, merge_node, set_props, get_node, exists_node, create_rel, merge_rel, delete_rel, delete_node, delete_nodes, degree, node_count, relationship_count, count_property_values
Schemacreate_index, create_constraint, drop_index, drop_constraint
Introspectionlabels, relationship_types, property_keys, indexes, constraints, databases
Planningexplain, profile
Cypher helpersescape, quote_literal, quote_ident, valid_identifier, format_value
URL helpersparse_url, redact_url, build_url

Function reference

Every function takes a trailing %opts hash carrying connection params (see above). The signatures below list the leading positional / named arguments; %opts is omitted for brevity. Return shapes are exactly what the .stk wrappers hand back.

Liveness

FunctionReturns / behaviour
version()Package version string (from CARGO_PKG_VERSION). Connection-free — also a load-probe for the cdylib.
ping()Connectivity probe via RETURN 1. True on success.
server_info()Rows from CALL dbms.components() — name, versions, edition.

Query

FunctionReturns / behaviour
query($cypher)List of row hashrefs, each keyed by the RETURN aliases. Nodes / relationships deserialize to their property maps.
query_one($cypher)The first row hashref, or undef.
scalar($cypher)The first value of the first row (a scalar), or undef.
query_values($cypher)Flat list of the first column's value across every row.
query_column($cypher, $column)Flat list of one named RETURN alias across every row. Generalizes query_values to any column.

All four bind $name placeholders from %opts{params} (a hashref) — values are sent as Bolt parameters, never interpolated into the query text.

Write

FunctionReturns / behaviour
run($cypher)Execute a write / DDL statement that returns no rows (CREATE / MERGE / SET / DELETE / CREATE INDEX …). Binds %opts{params}. True on success.
batch($steps)Run a list of { cypher, params => {...} } steps in one transaction — commit on success, rollback if any step errors. Returns the number of committed steps.

Graph helpers (scalar properties)

FunctionReturns / behaviour
create_node($label, $props)CREATE a node with a scalar-property hashref. Returns the node.
merge_node($label, $key, $value)MERGE on $key = $value, then SET n += %opts{props} (upsert). Returns the node.
set_props($label, $key, $value)MATCH on $key = $value and SET n += %opts{props} — update-only, never creates. Returns the updated node, or undef on no match.
get_node($label, $key, $value)Read-only point lookup of a single node by $key = $value. Returns the node, or undef.
exists_node($label, $key, $value)True when at least one matching node exists.
create_rel(%opts)CREATE a typed relationship between two matched nodes. %opts: from_label, from_key, from_value, to_label, to_key, to_value, type, props. Returns the rel.
merge_rel(%opts)MERGE a typed relationship (idempotent); props applied via SET r += {...}. Same %opts as create_rel. Returns the rel.
delete_rel(%opts)DELETE a typed relationship between two matched nodes. Same %opts minus props. Returns the count removed.
delete_node($label, $key, $value)DETACH DELETE a single node matched by $key = $value. Returns the count removed (0 on no match).
delete_nodes($label)DETACH DELETE all $label nodes, optionally narrowed by %opts{match} (a scalar-property map).
degree($label, $key, $value)Relationship degree of one node. %opts{direction} is out / in / both (default both); %opts{type} narrows to one rel type.
node_count()Count nodes, optionally of a single %opts{label}.
relationship_count()Count relationships, optionally of a single %opts{type}. Portable count(r) — no size() / COUNT{} divergence.
count_property_values($label, $property)Group-by count of a property's distinct values: rows of { value, count } ordered by descending count.

Labels, relationship types, and property names cannot be parametrized in Cypher, so these helpers backtick-quote them (internal backticks doubled) — injection-safe. Property values are passed as bound parameters. A non-scalar property value (list / map) is rejected with an error; use run with hand-written Cypher for those.

Schema management

FunctionReturns / behaviour
create_index($label, $property)CREATE INDEX … IF NOT EXISTS FOR (n:Label) ON (n.prop). Optional %opts{name}.
create_constraint($label, $property)CREATE CONSTRAINT … IF NOT EXISTS … REQUIRE n.prop IS UNIQUE (uniqueness). Optional %opts{name}.
drop_index($name)DROP INDEX name IF EXISTS.
drop_constraint($name)DROP CONSTRAINT name IF EXISTS.

Introspection

FunctionReturns / behaviour
labels()Node labels via CALL db.labels(), ordered.
relationship_types()Relationship types via CALL db.relationshipTypes(), ordered.
property_keys()Property keys via CALL db.propertyKeys(), ordered.
indexes()Rows from SHOW INDEXES.
constraints()Rows from SHOW CONSTRAINTS.
databases()Rows from SHOW DATABASES — needs a server with multi-database administration.

Query planning

FunctionReturns / behaviour
explain($cypher)EXPLAIN — the plan rows, without running the query.
profile($cypher)PROFILE — run the query and return the plan with runtime stats.

Cypher helpers (pure, connection-free)

FunctionReturns / behaviour
escape($value)Escape \ and ' for a single-quoted Cypher string literal.
quote_literal($value)Wrap a string as a single-quoted Cypher literal (calls escape internally).
quote_ident($value)Backtick-quote a Cypher identifier (label / type / property / index name), doubling internal backticks.
format_value($value)Format any value as a Cypher literal — string→'…', number as-is, bool→true/false, null→null, list→[…], map→{`k`: v}.
valid_identifier($value)True when the string is a valid unquoted Cypher identifier ([A-Za-z_][A-Za-z0-9_]*).

URL helpers (pure, connection-free)

FunctionReturns / behaviour
parse_url($url)Decompose a Bolt URI into a hashref: scheme, host, port, username, password (redacted to ***), tls (true for +s / +ssc).
redact_url($url)Return the URI with the password replaced by ***.
build_url($parts)Build a Bolt URI from a parts hashref (inverse of parse_url) — keys scheme (default neo4j), host (default localhost), port, username, password.

Cypher safety & identifier quoting

Cypher can parametrize values but not structural identifiers — labels, relationship types, and property names. The graph helpers interpolate those into the query text, so they backtick-quote each one (doubling any internal backtick) to keep interpolation injection-safe. The pure helpers expose the same machinery for hand-written Cypher:

p Neo4j::quote_ident("Person")              # `Person`
p Neo4j::quote_ident("weird`label")         # `weird``label`
p Neo4j::escape("O'Brien")                  # O\'Brien
p Neo4j::quote_literal("O'Brien")           # 'O\'Brien'
p Neo4j::format_value({ a => 1 })           # {`a`: 1}
p Neo4j::format_value([1, 2])               # [1, 2]
p Neo4j::valid_identifier("n1")             # true
p Neo4j::valid_identifier("1n")             # false

For values inside query / run, always prefer params over building literals by hand — the bound-parameter path never touches the query text at all.

Transactions & batch

batch runs a list of steps inside a single Bolt transaction. Every step commits together; if any step errors the whole transaction rolls back and the call dies with the failing step's error.

val $n = Neo4j::batch([
    { cypher => "CREATE (:Person {id: \$id})", params => { id => 1 } },
    { cypher => "CREATE (:Person {id: \$id})", params => { id => 2 } },
], %conn)
p $n                       # 2  (committed step count)

Query planning

explain returns the planner's chosen plan without executing the query; profile runs it and returns the plan annotated with runtime stats (db hits, rows). Both bind params like query.

val @plan = Neo4j::explain("MATCH (p:Person) WHERE p.id = \$id RETURN p",
    params => { id => 1 }, %conn)
val @prof = Neo4j::profile("MATCH (p:Person) RETURN count(p) AS n", %conn)

URL helpers

Bolt-URI parsing, password redaction, and reconstruction — all pure, no connection required. Useful for logging a connection string without leaking the password, or normalizing endpoints.

val $p = Neo4j::parse_url("neo4j+s://alice:secret\@graph.example.com:7687")
p $p->{host}        # graph.example.com
p $p->{port}        # 7687
p $p->{username}    # alice
p $p->{password}    # ***   (always redacted)
p $p->{tls}         # true  (neo4j+s implies TLS)

p Neo4j::redact_url("neo4j://alice:secret\@host:7687")   # neo4j://alice:***@host:7687
p Neo4j::build_url({ scheme => "neo4j", host => "db", port => 7687,
                     username => "neo4j", password => "pw" })   # neo4j://neo4j:pw@db:7687

Why a package, not a builtin

The Bolt driver (neo4rs) pulls in tokio and a TLS stack. That dependency tree ships once, on demand, as an opt-in package — stryke core is never linked against it.

The stryke side is a thin .stk wrapper; the heavy code lives in the libstryke_neo4j cdylib and is dlopened on first use Neo4j. The cdylib holds one shared tokio runtime and one Graph connection pool per (uri, user, db) — no per-call fork, no per-call reconnect.

FFI contract

Each export is a #[no_mangle] extern "C" fn neo4j__* taking a JSON request string and returning a JSON response string. stryke's FFI bridge resolves these symbols at load. Handler errors and panics are caught and surfaced as {"error": "..."} — a panic never crosses the C ABI into the host shell.

# the .stk wrapper decodes the JSON and dies on an {error} payload
fn Neo4j::_decode ($name, $json) {
    val $o = from_json $json
    if (ref $o eq "HASH" && exists $o->{error}) {
        die "Neo4j::$name: $o->{error}"
    }
    $o
}

Troubleshooting

SymptomCause / fix
Neo4j::<op>: connect …The Bolt endpoint is unreachable or credentials are wrong. Check uri / user / password (or $NEO4J_URL), and that the server's Bolt connector is listening.
Neo4j::<op>: missing cypher (and friends)A required argument was omitted — the handler validates each request field and surfaces the missing key as an error.
… must be a scalar …A graph helper got a list or map as a property / key value. Helpers take scalars only; use run with hand-written Cypher for list / map properties.
databases() errorsSHOW DATABASES needs a server that supports multi-database administration; single-database editions reject it.
Stale connection after a server restartThe pool whose call errored is evicted automatically; the next call reconnects. No manual reset needed.
Live t/ tests are skippedThe graph asserts short-circuit unless $NEO4J_URL is set. Point it (plus $NEO4J_USER / $NEO4J_PASS) at a throwaway Neo4j to exercise the query path.

Layout

stryke-neo4j/
├── Cargo.toml             # cdylib crate (publish = false)
├── src/
│   └── lib.rs            # neo4j__* FFI exports + Bolt ops
├── lib/                   # stryke .stk wrapper (Neo4j package)
├── stryke.toml            # stryke package manifest + ffi exports
├── t/                     # offline surface + live tests
├── Makefile               # `make install` builds + installs
└── docs/                  # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: