Documentation

Querying with Cypher

Querying the Graph — Cypher

JetGraph uses Cypher, the standard graph query language originally developed for Neo4j and now governed by the openCypher specification. If you know SQL, Cypher will feel natural — it uses a similar declarative style but describes patterns in the graph rather than joins between tables.

📖
This section covers the essentials with fraud-detection examples. For the complete language reference — every clause, function, procedure, engine limit, and JetGraph-specific behaviour — see the JetGraph Cypher Manual.

Basic Patterns

Cypher uses ASCII-art notation to express graph patterns:

CREATE — Insert a Node

cypher
// Create a CARD node with properties CREATE (c:CARD {external_id: "card-001", country: "US"}) RETURN c.external_id AS id

MATCH — Query Nodes

cypher
// Find a specific card MATCH (c:CARD {external_id: "card-001"}) RETURN c.external_id AS id, c.country AS country // Find all cards (with limit — always paginate large result sets) MATCH (c:CARD) RETURN c.external_id AS id LIMIT 100

CREATE — Insert a Relationship

cypher
// Connect a CARD to a MERCHANT MATCH (c:CARD {external_id: "card-001"}), (m:MERCHANT {external_id: "merchant-42"}) CREATE (c)-[:TRANSACTS_AT {amount: 49.99, ts: 1712345678}]->(m) RETURN true AS created

Traversal — Multi-hop Queries

cypher
// Find all merchants this card has visited MATCH (c:CARD {external_id: "card-001"})-[:TRANSACTS_AT]->(m:MERCHANT) RETURN m.external_id AS merchant // Two-hop: other cards that share a device with this card MATCH (c:CARD {external_id: "card-001"})-[:USES_DEVICE]->(d:DEVICE) <-[:USES_DEVICE]-(other:CARD) WHERE other.external_id <> "card-001" RETURN other.external_id AS related_card, d.fingerprint AS shared_device

Filtering with WHERE

cypher
MATCH (c:CARD)-[r:TRANSACTS_AT]->(m:MERCHANT) WHERE r.amount > 500 AND m.country = "US" RETURN c.external_id AS card, m.external_id AS merchant, r.amount ORDER BY r.amount DESC LIMIT 20

Parameterized Queries

Always use parameters (prefixed with $) instead of string interpolation to avoid injection and improve query plan reuse:

bash
curl -sS -X POST http://localhost:8080/cypher \ -H 'Content-Type: application/json' \ -d '{ "query": "MATCH (c:CARD {external_id: $card_id})-[:TRANSACTS_AT]->(m:MERCHANT) RETURN m.external_id AS merchant", "parameters": {"card_id": "card-001"} }'

Aggregations

cypher
// Count transactions per merchant MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) RETURN m.external_id AS merchant, COUNT(c) AS card_count ORDER BY card_count DESC LIMIT 10 // Sum transaction amounts per card MATCH (c:CARD)-[r:TRANSACTS_AT]->(m:MERCHANT) RETURN c.external_id AS card, SUM(r.amount) AS total_spend

MERGE — Upsert Nodes and Relationships

MERGE matches an existing pattern or creates it if it does not exist. Use ON CREATE SET and ON MATCH SET to set properties conditionally:

cypher
// Upsert a node — create if absent, update timestamp if it exists MERGE (c:CARD {external_id: $card_id}) ON CREATE SET c.created_at = $ts, c.country = $country ON MATCH SET c.last_seen = $ts RETURN c.external_id, c.created_at // Upsert a relationship between two existing nodes MATCH (c:CARD {external_id: $card_id}), (d:DEVICE {external_id: $device_id}) MERGE (c)-[:USES_DEVICE]->(d) RETURN true AS linked

OPTIONAL MATCH

OPTIONAL MATCH works like a left outer join — if the pattern does not exist, the variables are bound to null rather than excluding the row:

cypher
// Return card with its device fingerprint, even if no device is linked MATCH (c:CARD {external_id: $card_id}) OPTIONAL MATCH (c)-[:USES_DEVICE]->(d:DEVICE) RETURN c.external_id AS card, d.external_id AS device

WITH — Pipeline and Filter Mid-Query

WITH passes results from one query stage to the next, allowing intermediate filtering, aggregation, and variable re-binding:

cypher
// Find cards with more than 5 distinct merchants, then get their devices MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WITH c, COUNT(DISTINCT m) AS merchant_count WHERE merchant_count > 5 MATCH (c)-[:USES_DEVICE]->(d:DEVICE) RETURN c.external_id AS card, merchant_count, d.external_id AS device LIMIT 50

UNWIND — Expand a List

UNWIND turns a list into individual rows, which is useful for batch operations driven by a parameter array:

cypher
// Create multiple nodes from a list parameter in one round-trip UNWIND $card_ids AS cid CREATE (c:CARD {external_id: cid}) RETURN c.external_id AS created // Parameters: {"card_ids": ["card-001", "card-002", "card-003"]}

Data Manipulation

Insert Data

Use CREATE to insert new nodes and relationships. Both the source and destination nodes must already exist before creating a relationship.

bash — create node
curl -sS -X POST http://localhost:8080/cypher \ -H 'Content-Type: application/json' \ -d '{"query":"CREATE (m:MERCHANT {external_id: $id, mcc: $mcc}) RETURN m.external_id","parameters":{"id":"merchant-42","mcc":"5411"}}'

Update Node Properties

Use SET to update or add properties on an existing node:

cypher
MATCH (c:CARD {external_id: "card-001"}) SET c.risk_score = 0.85, c.flagged = true RETURN c.external_id, c.risk_score

Delete a Node

A node must have no relationships before it can be deleted. Use DETACH DELETE to remove both the node and all its relationships in one step:

cypher
// Delete node and all its relationships MATCH (c:CARD {external_id: "card-001"}) DETACH DELETE c

Delete a Relationship

cypher
MATCH (c:CARD {external_id: "card-001"})-[r:TRANSACTS_AT]->(m:MERCHANT) DELETE r

Bulk Inserts

For bulk data loading, fire multiple POST /cypher requests in parallel. Each request is independent and thread-safe. For maximum throughput from Rust, use the jetgraph-client crate which batches requests over a persistent gRPC connection.

ℹ️
JetGraph can ingest up to 35,000 events per second via the streaming ingestion pipeline. For high-volume onboarding, use the bulk import tooling rather than individual /cypher POST requests.

Cypher Best Practices

These rules are derived from direct analysis of the JetGraph query planner logs. Each one maps to a specific engine optimisation — or the absence of one — that has measurable impact at scale. Follow them to keep every query O(1) or close to it.

1. Always label every node in MATCH

The pushdown optimizer converts a WHERE node.external_id = $value filter into an O(1) IndexLookup only when it knows the node type. Without a label the engine produces NodeScan { node_type: None } — a full scan across every node in the graph, repeated once per edge type registered in the schema.

cypher — ✗ Avoid
// No label on (s) → full graph scan × number of edge types MATCH (s)-[r]->(d) WHERE s.external_id = $id RETURN s, r, d
cypher — ✓ Prefer
// Typed nodes → pushdown rewrites NodeScan to O(1) IndexLookup MATCH (s:CARD)-[r:TRANSACTS_AT]->(d:MERCHANT) WHERE s.external_id = $id RETURN s.external_id, d.external_id

2. Always use parameters — never embed literal values

Literal values baked into the query string each create a unique plan cache key. Every new literal triggers a full parse + plan cycle regardless of how many times a similar query has been run before. Parameters collapse every variation of a query into a single cached plan that is reused for every card ID, merchant ID, device fingerprint, or IP address.

cypher — ✗ Avoid
// Each card number is a separate cache key — replanning on every call MATCH (c:CARD {external_id: "9792487647826207"})-[:TRANSACTS_AT]->(m:MERCHANT) RETURN m.external_id AS merchant
cypher — ✓ Prefer
// One plan, cached forever — value supplied at runtime via parameters MATCH (c:CARD {external_id: $card_id})-[:TRANSACTS_AT]->(m:MERCHANT) RETURN m.external_id AS merchant
💡
This applies equally to device fingerprints, IP addresses, customer IDs, and every other lookup value. Any hardcoded string in the query string is a separate cache entry.

3. Filter on external_id equality on the source node

The pushdown optimizer walks the plan tree and converts Filter(src.external_id = value) → NodeScan into an IndexLookup. This rewrite only fires when the filter is an equality on external_id and applies to the source variable of the expand. Filters on destination properties or on any other property remain as post-expansion filters (slower).

cypher — ✗ Avoid
// No anchor on the source — engine scans all CARDs then filters merchants MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE m.name CONTAINS 'Airline' RETURN c.external_id, m.name
cypher — ✓ Prefer
// Anchor on source external_id → pushdown fires → IndexLookup for the card MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id AND m.name CONTAINS 'Airline' RETURN c.external_id, m.name // If you need to start from the merchant side, flip the traversal direction MATCH (m:MERCHANT)<-[:TRANSACTS_AT]-(c:CARD) WHERE m.external_id = $merchant_id RETURN c.external_id

4. Always type every relationship

An untyped relationship -[r]-> causes the engine to fan out the expand across every edge type registered in the schema — one full expand per type. With six edge types registered, a single untyped MATCH compiles into six separate query plans. Always name the relationship type explicitly.

cypher — ✗ Avoid
// Untyped [r] → engine fans out over EdgeTypeId(0), (1), (2) … for every type MATCH (c:CARD)-[r]->(n) WHERE c.external_id = $card_id RETURN type(r), n.external_id
cypher — ✓ Prefer
// Single explicit edge type → one targeted expand, one IndexLookup MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id RETURN m.external_id // Need multiple edge types? Use UNION ALL — each branch is individually optimized MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id RETURN 'merchant' AS kind, m.external_id AS neighbor UNION ALL MATCH (c:CARD)-[:USES_DEVICE]->(d:DEVICE) WHERE c.external_id = $card_id RETURN 'device' AS kind, d.external_id AS neighbor UNION ALL MATCH (c:CARD)-[:USES_IP]->(ip:IP) WHERE c.external_id = $card_id RETURN 'ip' AS kind, ip.external_id AS neighbor

5. Canonical fraud context — the recommended multi-hop template

This is the recommended pattern for pulling the full risk context of a card in a single round-trip: typed nodes, typed relationships, parameterized, one plan cached for every card. Each UNION ALL branch is compiled and optimized independently.

cypher — ✓ Canonical fraud context query
MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id RETURN 'merchant' AS kind, m.external_id AS id, m.name AS label UNION ALL MATCH (c:CARD)-[:USES_DEVICE]->(d:DEVICE) WHERE c.external_id = $card_id RETURN 'device' AS kind, d.external_id AS id, '' AS label UNION ALL MATCH (c:CARD)-[:USES_IP]->(ip:IP) WHERE c.external_id = $card_id RETURN 'ip' AS kind, ip.external_id AS id, '' AS label

6. Anchor ring / co-occurrence queries on the known node

Shared-entity ring queries (cards sharing a device or IP) must start from a known, typed anchor. An unanchored ring causes a full scan of all devices before expanding to cards. Start from the entity you know.

cypher — ✗ Avoid
// No anchor → scans all DEVICE nodes, expands to all cards, then filters MATCH (d:DEVICE)<-[:USES_DEVICE]-(c:CARD) WHERE c.country = 'US' RETURN d.external_id, c.external_id
cypher — ✓ Prefer
// Start from a known card → IndexLookup → expand to its devices → expand to sibling cards MATCH (c1:CARD)-[:USES_DEVICE]->(d:DEVICE)<-[:USES_DEVICE]-(c2:CARD) WHERE c1.external_id = $card_id AND c2.external_id <> $card_id RETURN DISTINCT c2.external_id AS linked_card, d.external_id AS shared_device LIMIT 50 // Or start from a known device MATCH (d:DEVICE)<-[:USES_DEVICE]-(c:CARD) WHERE d.external_id = $device_id RETURN c.external_id AS linked_card LIMIT 50

7. Always add LIMIT to traversals and scans

The engine propagates LIMIT hints down into NodeScan and VariableExpand nodes so BFS stops as soon as enough rows are found. Without a limit, traversals materialise the full result set before returning anything. This is especially important for variable-length paths.

cypher — ✓ Prefer
// LIMIT is pushed into the expand — BFS stops after finding 50 merchants MATCH (c:CARD)-[:TRANSACTS_AT]->(m:MERCHANT) WHERE c.external_id = $card_id RETURN m.external_id, m.name LIMIT 50 // Variable-length traversal: cap both hop count and result rows MATCH (c:CARD)-[:TRANSACTS_AT*1..3]->(m:MERCHANT) WHERE c.external_id = $card_id RETURN DISTINCT m.external_id LIMIT 100

8. Use db.nodeStats() for counts, not MATCH (n:TYPE)

A bare MATCH (n:CARD) RETURN count(n) materialises every node in that type before counting. db.nodeStats() reads a pre-maintained O(1) counter with no scan.

cypher — ✗ Avoid
// Materialises all nodes in the type before counting MATCH (n:CARD) RETURN count(n) AS total
cypher — ✓ Prefer
// O(1) pre-computed counter — no scan CALL db.nodeStats() YIELD type, count WHERE type = 'CARD' RETURN count // If you must scan for exploration, always cap with LIMIT MATCH (n:CARD) RETURN n.external_id LIMIT 25

9. Batch writes with graph.ingest()

Individual CREATE statements are planned and executed one at a time. For loading multiple nodes and edges in a single operation, use graph.ingest() — one round-trip, one plan, one atomic batch write regardless of how many entities are included.

cypher — ✓ Prefer for batch loads
CALL graph.ingest( [ { node_type: 'CARD', externalId: $card_id }, { node_type: 'MERCHANT', externalId: $merchant_id } ], [ { edge_type: 'TRANSACTS_AT', src: $card_id, dst: $merchant_id } ] ) YIELD ok, nodes_created, edges_created RETURN ok, nodes_created, edges_created

10. Record transaction events with graph.upsertEdge()

For high-throughput edge upserts — recording payment events — use graph.upsertEdge() rather than MERGE-based patterns. It is a single O(1) procedure call that increments counters and updates the activity bitmap atomically, with no planner involved.

cypher — ✓ Prefer for event recording
CALL graph.upsertEdge( $edge_type, $src_typed_id, $dst_typed_id ) YIELD created_new, tx_count, approx_sum RETURN created_new, tx_count, approx_sum // Parameters example: // { "edge_type": "TRANSACTS_AT", // "src_typed_id": "CARD:9792487647826207", // "dst_typed_id": "MERCHANT:M2_0000803" }

Quick Reference

#RuleEngine impact avoided
1 Label every node: (c:CARD) not (c) Eliminates full-graph NodeScan { node_type: None }
2 Use $parameters, never inline literals One cached plan per query shape instead of one per value
3 Filter src.external_id = $value on the source node Triggers pushdown → O(1) IndexLookup
4 Type every relationship: [:TRANSACTS_AT] not [r] Prevents N-way fan-out (one expand per edge type)
5 Use the UNION ALL fraud context template One cached plan, all neighbours, one round-trip
6 Anchor ring queries on the known node Avoids full DEVICE / IP scan in co-occurrence queries
7 Always add LIMIT to traversals BFS stops early; limit is pushed into the expand node
8 Use db.nodeStats() for counts O(1) counter vs full node materialisation
9 Batch writes with graph.ingest() Single round-trip; bypasses per-statement planning
10 Edge events with graph.upsertEdge() O(1) atomic counter update; no planner involved