Ferrosa's native graph query layer. Run Cypher queries against your CQL tables — no separate graph database, no data duplication, no ETL pipelines.
Ferrosa lets you run graph queries against your existing CQL tables without a separate graph database. The workflow is:
ALTER TABLE ... WITH extensionsFerrosa automatically maintains an adjacency index for efficient multi-hop traversals. Your CQL tables remain the source of truth — the graph layer is a query interface, not a separate data store.
| Method | Path | Description |
|---|---|---|
| POST | /graph/query | Execute a Cypher query |
| POST | /graph/explain | Show query execution plan |
| GET | /graph/schema | List vertex and edge labels |
| GET | /graph/health | Health check (no auth required) |
All endpoints except /graph/health require HTTP Basic authentication using the same credentials as CQL (default: cassandra:cassandra). Disable with FERROSA_AUTH_DISABLED=true for development.
Queries are sent as JSON with a query string and an optional keyspace:
// POST /graph/query { "query": "MATCH (n:Person) RETURN n", "keyspace": "social" }
Responses are JSON with a columns array and a rows array:
{
"columns": ["n.name", "n.email"],
"rows": [
["Alice", "alice@example.com"],
["Bob", "bob@example.com"]
]
}
The graph HTTP API listens on port 7474 by default. Configure with FERROSA_GRAPH_PORT.
Ferrosa's graph layer works on top of existing CQL tables. You annotate tables as vertex or edge types using the extensions table property:
-- Mark a table as a graph vertex type ALTER TABLE social.users WITH extensions = {'graph.type': 'vertex', 'graph.label': 'Person'};
The graph.label becomes the node label used in Cypher patterns (e.g., (:Person)). The table's primary key columns become the vertex identifier.
-- Mark a table as a graph edge type ALTER TABLE social.follows WITH extensions = { 'graph.type': 'edge', 'graph.label': 'FOLLOWS', 'graph.source': 'Person', 'graph.target': 'Person' };
Edge tables require graph.source and graph.target to specify which vertex labels the edge connects. Source and target can be different labels (e.g., Person to Company).
-- Create the keyspace and tables CREATE KEYSPACE social WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': 3 }; CREATE TABLE social.users ( user_id uuid, name text, email text, age int, PRIMARY KEY (user_id) ); CREATE TABLE social.follows ( follower_id uuid, followed_id uuid, since timestamp, PRIMARY KEY (follower_id, followed_id) ); -- Annotate as graph types ALTER TABLE social.users WITH extensions = {'graph.type': 'vertex', 'graph.label': 'Person'}; ALTER TABLE social.follows WITH extensions = { 'graph.type': 'edge', 'graph.label': 'FOLLOWS', 'graph.source': 'Person', 'graph.target': 'Person' };
MATCH is the primary read statement in Cypher. It describes a pattern to find in the graph and returns matching results.
-- Simple node match MATCH (n:Person) RETURN n -- Traversal with filter MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE a.age > 30 RETURN a.name, b.name ORDER BY a.age DESC LIMIT 10 -- Multi-hop traversal MATCH (a:Person)-[:FOLLOWS]->(b:Person)-[:FOLLOWS]->(c:Person) WHERE a.name = 'Alice' RETURN c.name
Patterns are compiled into a query plan that resolves vertex lookups via the CQL tables and edge traversals via the adjacency index.
Nodes are enclosed in parentheses. A node pattern can include a variable name, a label, and inline property filters:
| Pattern | Description |
|---|---|
| (n) | Any node, bound to variable n |
| (n:Person) | Node with label Person |
| (:Person) | Node with label, no variable binding |
| (n:Person {name: 'Alice'}) | Node with inline property filter |
Properties specified inside {} are equality checks applied during pattern matching:
-- Find a specific person and their followers MATCH (n:Person {name: 'Alice'})-[:FOLLOWS]->(f:Person) RETURN f.name, f.email
Relationships (edges) connect two nodes with an arrow indicating direction:
| Pattern | Description |
|---|---|
| (a)-[r]->(b) | Directed relationship from a to b |
| (a)<-[r]-(b) | Directed relationship from b to a |
| (a)-[r]-(b) | Undirected — matches either direction |
| (a)-[:FOLLOWS]->(b) | Relationship with type FOLLOWS |
| (a)-[r:FOLLOWS]->(b) | Typed relationship bound to variable r |
-- Directed: who does Alice follow? MATCH (a:Person {name: 'Alice'})-[:FOLLOWS]->(b:Person) RETURN b.name -- Reverse direction: who follows Alice? MATCH (a:Person {name: 'Alice'})<-[:FOLLOWS]-(b:Person) RETURN b.name -- Undirected: all connections regardless of direction MATCH (a:Person)-[:WORKS_AT]-(b:Company) RETURN a, b
WHERE filters the results of a MATCH pattern. It supports comparison operators, boolean logic, and null checks.
| Operator | Description |
|---|---|
| = | Equals |
| <> | Not equals |
| < | Less than |
| > | Greater than |
| <= | Less than or equal |
| >= | Greater than or equal |
| AND | Logical AND |
| OR | Logical OR |
| NOT | Logical negation |
| IS NULL | Check for null value |
| IS NOT NULL | Check for non-null value |
-- Comparison operators MATCH (n:Person) WHERE n.age >= 21 AND n.age < 65 RETURN n.name, n.age -- Boolean logic MATCH (n:Person) WHERE n.city = 'NYC' OR n.city = 'SF' RETURN n.name -- NOT and null checks MATCH (n:Person) WHERE NOT n.email IS NULL RETURN n.name, n.email
RETURN specifies which variables and properties to include in the result set. It supports aliases, DISTINCT, ORDER BY, and LIMIT.
-- Return specific properties MATCH (n:Person) RETURN n.name, n.email -- Aliases with AS MATCH (n:Person) RETURN n.name AS person_name -- DISTINCT to remove duplicates MATCH (a:Person)-[:FOLLOWS]->(b:Person) RETURN DISTINCT b.name -- ORDER BY and LIMIT MATCH (n:Person) RETURN n.name, n.age ORDER BY n.age DESC LIMIT 25 -- Return entire node MATCH (n:Person) RETURN n
-- Create a vertex CREATE (n:Person {name: 'Alice', age: 30}) -- Create a relationship MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) CREATE (a)-[:FOLLOWS]->(b)
-- Update a property MATCH (n:Person {name: 'Alice'}) SET n.age = 31
-- Delete a node (must have no relationships) MATCH (n) WHERE n.status = 'inactive' DELETE n -- Delete a node and all its relationships MATCH (n) WHERE n.status = 'inactive' DETACH DELETE n
SUBSCRIBE supports two modes for real-time graph change streaming:
| Mode | Syntax | Behavior |
|---|---|---|
| EVERY | EVERY 5s | Re-executes the graph query at the given interval and pushes results |
| DELTA | DELTA | Push-on-write — only sends changes as they occur via the commit log |
-- Poll for changes every 5 seconds SUBSCRIBE MATCH (n:Person) RETURN n EVERY 5s -- Push-on-write for relationship changes SUBSCRIBE MATCH (a)-[:FOLLOWS]->(b) RETURN a, b DELTA -- Subscribe to a filtered traversal SUBSCRIBE MATCH (a:Person {name: 'Alice'})-[:FOLLOWS]->(b:Person) RETURN b.name, b.email EVERY 10s
Ferrosa enforces resource limits on graph queries to prevent unbounded traversals from impacting cluster stability:
| Limit | Default | Description |
|---|---|---|
| Query timeout | 30 seconds | Maximum wall-clock time for a single query |
| Max result rows | 10,000 | Maximum rows returned in a single response |
| Max fan-out per hop | 10,000 | Maximum edges traversed at each hop in a multi-hop pattern |
These defaults are tunable per query or globally via configuration. When a limit is reached, the query returns a partial result set with a truncated: true flag in the response.
When you annotate an edge table with graph.type: 'edge', Ferrosa automatically creates and maintains a system adjacency table for efficient graph traversals:
-- Automatically created by Ferrosa: -- system_graph_<keyspace>.adjacency -- -- Schema (internal): -- source_label text -- source_id blob -- direction text (OUT or IN) -- edge_label text -- target_label text -- target_id blob
graph.type: 'edge' to a table and dropped when you remove itsystem_graph_* keyspace and is invisible to normal CQL queriesThis design means that MATCH traversals resolve edge lookups via efficient partition-key reads on the adjacency table, rather than scanning the edge table.
Ferrosa's graph endpoint is a standard HTTP/JSON API — any language with an HTTP client works. No special driver required.
curl -X POST http://localhost:7474/graph/query \ -u cassandra:cassandra \ -H 'Content-Type: application/json' \ -d '{"query": "MATCH (n:Person) RETURN n", "keyspace": "social"}'
import requests r = requests.post('http://localhost:7474/graph/query', auth=('cassandra', 'cassandra'), json={'query': 'MATCH (n:Person) RETURN n', 'keyspace': 'social'}) data = r.json() for row in data['rows']: print(row)
const resp = await fetch('http://localhost:7474/graph/query', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + btoa('cassandra:cassandra') }, body: JSON.stringify({ query: 'MATCH (n:Person) RETURN n', keyspace: 'social' }) }); const data = await resp.json();
curl -X POST http://localhost:7474/graph/explain \ -u cassandra:cassandra \ -H 'Content-Type: application/json' \ -d '{"query": "MATCH (a:Person)-[:FOLLOWS]->(b:Person) RETURN b.name", "keyspace": "social"}'
The following Cypher features are planned for future releases:
| Feature | Status |
|---|---|
| Variable-length paths (-[*]->) | Planned |
| Aggregation functions (COUNT, SUM, AVG, MIN, MAX) | Planned |
| UNION | Planned |
| WITH (query chaining) | Planned |
| CASE expressions | Planned |
| Full property retrieval on RETURN n | Planned — Phase 1 returns vertex IDs |
| Subqueries | Planned |
| MERGE (upsert) | Planned |
| OPTIONAL MATCH | Planned |
| Path expressions | Planned |