>_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).
| Option | Meaning |
|---|---|
uri | Bolt URI — neo4j://, neo4j+s://, bolt:// (default 127.0.0.1:7687) |
user | username (default neo4j) |
password | password |
database | target 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
| Group | Functions |
|---|---|
| Liveness | version, ping, server_info |
| Query | query, query_one, scalar, query_values, query_column |
| Write | run, batch (transaction) |
| Graph helpers | create_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 |
| Schema | create_index, create_constraint, drop_index, drop_constraint |
| Introspection | labels, relationship_types, property_keys, indexes, constraints, databases |
| Planning | explain, profile |
| Cypher helpers | escape, quote_literal, quote_ident, valid_identifier, format_value |
| URL helpers | parse_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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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)
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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)
| Function | Returns / 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)
| Function | Returns / 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
| Symptom | Cause / 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() errors | SHOW DATABASES needs a server that supports multi-database administration; single-database editions reject it. |
| Stale connection after a server restart | The pool whose call errored is evicted automatically; the next call reconnects. No manual reset needed. |
Live t/ tests are skipped | The 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:
- stryke-arrow — Apache Arrow / Parquet / Feather
- stryke-aws — S3, DynamoDB, SQS, Lambda, STS
- stryke-azure — Blob, Queues, Cosmos DB, Key Vault, Entra
- stryke-clickhouse — ClickHouse
- stryke-demo — live demos for every connector
- stryke-docker — Docker daemon API
- stryke-duckdb — embedded DuckDB SQL engine
- stryke-email — email + campaign client
- stryke-fleet — parallel expect/PTY automation
- stryke-gcp — Cloud Storage, Pub/Sub, Secret Manager, BigQuery, Firestore
- stryke-grpc — reflection-based gRPC client
- stryke-gui — mouse / keyboard / screen / clipboard automation
- stryke-k8s — Kubernetes
- stryke-kafka — Apache Kafka
- stryke-mcpd — MCP servers without a runtime
- stryke-mongo — MongoDB
- stryke-mssql — Microsoft SQL Server
- stryke-mysql — MySQL / MariaDB
- stryke-office — Office docs / PDF / images
- stryke-parquet — Parquet file inspector
- stryke-polars — Polars + ndarray + linalg + FFT
- stryke-postgres — PostgreSQL
- stryke-redis — Redis / Valkey
- stryke-scrape — web scraping / crawling
- stryke-scylla — ScyllaDB / Cassandra
- stryke-search — Elasticsearch / OpenSearch
- stryke-selenium — browser automation (WebDriver)
- stryke-spark — Spark Connect (no JVM)
- stryke-utils — pure-stryke utility belt
- stryke-zmq — ZeroMQ messaging