Cypher Query Support¶
Provisa translates a subset of openCypher to SQL via the provisa/cypher/ module. Queries are parsed by a custom recursive-descent parser (no external Cypher library), schema-resolved against the model, and emitted as SQL, then routed to the target execution engine.
Implemented Features¶
Clauses¶
| Clause | Status | Notes |
|---|---|---|
MATCH (n:Label) |
✓ | Node patterns with labels, variables, inline properties |
OPTIONAL MATCH |
✓ | Emits LEFT JOIN |
WHERE |
✓ | Full expression support; applied after MATCH |
RETURN |
✓ | Star, property access, expressions, aliases |
RETURN DISTINCT |
✓ | Emits SELECT DISTINCT |
WITH |
✓ | Emits a named CTE (_w0, _w1, …); supports WITH … WHERE |
ORDER BY |
✓ | ASC / DESC |
SKIP / LIMIT |
✓ | Maps to SQL OFFSET / LIMIT |
UNION / UNION ALL |
✓ | Recursive union across sub-ASTs |
CALL { … } |
✓ | Top-level call subquery decomposition via cypher_calls_to_sql_list |
CALL { WITH x … } |
✓ | Correlated subquery → CROSS JOIN LATERAL; see §Correlated CALL |
CALL db.labels() |
✓ | Returns node labels from the model; no SQL translation |
CALL db.relationshipTypes() |
✓ | Returns relationship types from the model |
CALL db.propertyKeys() |
✓ | Returns all property key names across all node types |
UNWIND |
✓ | Array-to-rows expansion; first item becomes FROM, subsequent become CROSS JOIN UNNEST |
Match Patterns¶
| Pattern | Status | Notes |
|---|---|---|
(n) — unlabeled node |
✓ | UNION ALL over all known types |
(n:Label) |
✓ | Maps to the registered table for that GraphQL type |
(n:Label {prop: val}) |
✓ | Inline property filter becomes WHERE |
(a)-[:TYPE]->(b) |
✓ | Directed, single hop |
(a)<-[:TYPE]-(b) |
✓ | Backward traversal; join columns reversed |
(a)-[]->(b) |
✓ | Any directed relationship a→b; UNION ALL if multiple types match |
(a)-[]-(b) |
✓ | Bidirectional; expands to UNION ALL of all forward and backward relationships |
(a)-[:TYPE*..N]->(b) |
✓ | Variable-length with upper bound; recursive CTE for self-referential, flat JOIN otherwise |
(a)-[]->(b)-[]->(c) |
✓ | Multi-hop chained JOINs |
(n:DomainLabel) |
✓ | Domain label → UNION ALL subquery over all types in the domain |
(n:A\|B) |
✓ | Label alternation → ad-hoc domain injected into label map; UNION ALL over matching types |
shortestPath(…) |
✓ | Flat JOIN for heterogeneous endpoints; WITH RECURSIVE CTE for same-type/self-referential |
allShortestPaths(…) |
✓ | Same as shortestPath without LIMIT 1 |
Expressions and Predicates¶
| Feature | Status | SQL mapping |
|---|---|---|
Property access n.prop |
✓ | n."prop" |
Parameters $name |
✓ | Positional $N |
Legacy parameters {name} |
✓ | Normalised to $name at parse time |
Comparison =, <>, <, >, <=, >= |
✓ | Direct |
AND, OR, NOT |
✓ | Direct |
IS NULL / IS NOT NULL |
✓ | Direct |
IN [list] |
✓ | SQL IN; Cypher [...] bracket syntax rewritten to (...) |
STARTS WITH |
✓ | starts_with(col, val) |
ENDS WITH |
✓ | col LIKE CONCAT('%', val) |
CONTAINS |
✓ | strpos(col, val) > 0 |
=~ regex |
✓ | regexp_like(col, pattern) |
exists(n.prop) |
✓ | (n.prop) IS NOT NULL |
EXISTS { MATCH … } |
✓ | Correlated EXISTS (SELECT 1 FROM …) subquery |
COUNT { MATCH … } |
✓ | Correlated (SELECT count(*) FROM …) subquery |
COLLECT { MATCH … RETURN x } |
✓ | Correlated ARRAY(SELECT x FROM …) subquery |
id(n) |
✓ | Resolved to the node's configured ID column |
labels(n) |
✓ | ARRAY['Label'] |
keys(n) |
✓ | ARRAY['prop1', 'prop2', …] |
type(r) |
✓ | Resolved at compile time to 'REL_TYPE' string literal; no runtime column |
length(p) |
✓ | _t.hops for recursive CTE paths; 1 for flat JOIN paths |
CASE WHEN … THEN … ELSE … END |
✓ | Direct (searched and simple forms) |
| Implicit GROUP BY | ✓ | Non-aggregated RETURN items become GROUP BY keys when any item has an aggregate |
Map Projections¶
| Syntax | SQL mapping |
|---|---|
n { .prop1, .prop2 } |
MAP(ARRAY['prop1','prop2'], ARRAY[n."prop1",n."prop2"]) |
n { .* } |
MAP(ARRAY[all props...], ARRAY[n."col",...]) — expanded from schema |
n { .*, extra: expr } |
All schema props plus named key; combined MAP |
n { key: expr } |
MAP(ARRAY['key'], ARRAY[expr]) |
Object properties. A column holding a declared object (a remote GraphQL type's embedded object, or any column registered with object fields) is a map property: RETURN n.address gives it as a map, whatever form the engine returns it in, and the node n carries it among its properties. A remote GraphQL field pointing at a registered type, one object or a list, is not a property; it is reached over its relationship.
Aggregation Functions¶
| Cypher | SQL |
|---|---|
count(*), count(x) |
direct |
count(DISTINCT x) |
count(DISTINCT x) |
collect(x) |
array_agg(x) |
avg, sum, min, max |
direct |
stDev(x) |
stddev_samp(x) |
stDevP(x) |
stddev_pop(x) |
percentileCont(x, p) |
approx_percentile(x, p) |
percentileDisc(x, p) |
approx_percentile(x, p) |
String Functions¶
| Cypher | SQL |
|---|---|
toLower(x) |
lower(x) |
toUpper(x) |
upper(x) |
ltrim(x), rtrim(x), trim(x) |
direct |
replace(x, a, b) |
direct |
reverse(x) |
direct |
split(x, d) |
direct |
left(x, n) |
left(x, n) |
right(x, n) |
right(x, n) |
substring(x, start, len) |
substr(x, start+1, len) (0→1 index) |
size(string) |
char_length(string) |
size(list) |
cardinality(list) |
Type Conversion Functions¶
| Cypher | SQL |
|---|---|
toString(x) |
CAST(x AS VARCHAR) |
toInteger(x) |
TRY_CAST(x AS BIGINT) |
toFloat(x) |
TRY_CAST(x AS DOUBLE) |
toBoolean(x) |
TRY_CAST(x AS BOOLEAN) |
toStringOrNull, toIntegerOrNull, toFloatOrNull, toBooleanOrNull |
TRY_CAST variants |
Math Functions¶
| Cypher | SQL |
|---|---|
log(x) |
ln(x) (natural log) |
log2(x) |
log2(x) |
range(start, end) |
sequence(start, end) |
abs, sqrt, ceil, floor, round, sign |
passed through |
List Functions¶
| Cypher | SQL |
|---|---|
head(list) |
element_at(list, 1) |
last(list) |
element_at(list, -1) |
tail(list) |
slice(list, 2, cardinality(list)) |
isEmpty(list) |
cardinality(list) = 0 |
List Comprehensions¶
| Syntax | SQL mapping |
|---|---|
[x IN list \| f(x)] |
transform(list, x -> f(x)) |
[x IN list WHERE p(x)] |
filter(list, x -> p(x)) |
[x IN list WHERE p(x) \| f(x)] |
transform(filter(list, x -> p(x)), x -> f(x)) |
any(x IN list WHERE p(x)) |
any_match(list, x -> p(x)) |
all(x IN list WHERE p(x)) |
all_match(list, x -> p(x)) |
none(x IN list WHERE p(x)) |
none_match(list, x -> p(x)) |
single(x IN list WHERE p(x)) |
cardinality(filter(list, x -> p(x))) = 1 |
reduce(acc = init, x IN list \| expr) |
reduce(list, init, (acc, x) -> expr, acc -> acc) |
Pattern Comprehensions¶
| Syntax | SQL mapping |
|---|---|
[(a)-[:R]->(b) \| b.prop] |
ARRAY(SELECT b."prop" FROM ... WHERE a.fk = b.pk) |
[(a)-[]->(b:Label) \| b.prop] |
type-inferred from the model; same ARRAY subquery form |
Correlated CALL Subqueries¶
CALL { WITH x MATCH (x)-[:R]->(n) RETURN n.prop AS alias } translates to CROSS JOIN LATERAL (SELECT n."prop" AS alias FROM ... WHERE x."pk" = n."fk"). Rules:
- The outer-scope variable (
x) must appear inWITH - Multiple imported vars (
WITH a, b) are supported - The first relationship in the inner MATCH whose source is a lateral-bound var determines the inner
FROMand join condition - Non-correlated top-level
CALL { ... }blocks (withoutWITH) are handled bycypher_calls_to_sql_list
Writes¶
Cypher supports three write patterns through the /data/cypher endpoint, executed by provisa/cypher/write_translator.py.
| Cypher | SQL |
|---|---|
CREATE (n:Label {props}) |
INSERT INTO catalog.schema.table (cols) VALUES (vals) |
MATCH (n:Label) WHERE … DELETE n |
DELETE FROM catalog.schema.table WHERE … |
MATCH (n:Label) WHERE … SET n.prop = val, … |
UPDATE catalog.schema.table SET col = val, … WHERE … |
Property names map to columns via domain-prefix stripping and alias resolution; Cypher scalar values are coerced to the target column type. The response body carries an affected_rows count.
A node's property is its column's published name: the column's alias when it has one, else the naming convention's name for it -- the name GraphQL and SQL read it by -- in MATCH, RETURN, CREATE, SET and MERGE alike.
A name that is no property of the label is null wherever it is read (WHERE, RETURN, ORDER BY, any expression), as a missing property is in Neo4j. A column the label does not publish (a governed object column) or the role may not see reads the same way: null, with no sign that it exists. A write is never silent: CREATE or SET of such a name is refused with <Label> has no property <name>.
Every node has one built-in property, _id: its key. It is the key column's value, or for a compound key the node's identity, Label|v1|v2 (each key column in key order, a | or \ in a value escaped by a \). _id works in WHERE, RETURN and ORDER BY, and every returned node carries it among its properties. Where a source's own _id column is the key, as a MongoDB collection's is, the built-in reads the same value.
Rules:
- The label must resolve to exactly one registered table. Ambiguous or unknown labels are hard errors; no fuzzy matching. New labels or types cannot be created through Cypher.
- Every write is gated on the target table's write grants: the role needs the
writeright and a grant for the operation, and the columns written must be in the grant. A role without them is rejected at compile time. - The backing source connector must support DML. Read-only sources (Trino-federated, Iceberg without a Delta connector) reject writes at translation time.
- Relationships cannot be written: they are derived from the model's declared joins, not stored edges. Targeting a relationship is a hard error. A junction-backed edge is no exception: the associative table behind it is itself a registered table, and rows are written to that table, not to the edge.
- Writes run through the full write pipeline: RLS injection and post-mutation hooks (response-cache invalidation, materialized-view stale marking, Kafka change events, hot-table reload).
MERGE,DETACH DELETE, andREMOVEare unsupported and rejected at parse time.
Protocol Access¶
Cypher reaches the same governed pipeline over two transports:
- HTTP —
POST /data/cypherwith a JSON body ({"query": "...", "params": {...}}). Returns typed rows, oraffected_rowsfor writes. Graph variables in theRETURNclause serialize as JSON: nodes carryid,label,tableLabel, andproperties; edges carryidentity,start,end,type,properties,startNode, andendNode; paths carrynodes,edges, andlength/hops. Registered commands are also callable here viaCALL fn(args) YIELD col1, col2, and positional args map to the command's declared argument names in order. - Bolt — a Neo4j-compatible binary protocol server (PackStream codec, chunked framing) that lets Neo4j Browser, Bloom, and Bolt drivers run Cypher over the federated graph. It starts when
PROVISA_BOLT_PORTis set to a non-zero value and is disabled by default; setPROVISA_BOLT_CERT/PROVISA_BOLT_KEYfor TLS. Bolt auth maps principal to user and database to role:SHOW DATABASESlists one entry per (view × role) pair, namedprovisa_<role>(business domains) orprovisa_ops_<role>(with system/meta/ops domains);:useselects the active role and view. Relationships receive durable integer IDs via arel_idstable, mirroring thenode_idsdesign. Registered commands are callable withCALL command(args), and positional args map to declared argument names in order;CALL dbms.*/CALL db.*procedures take precedence.
Graph Analytics¶
POST /data/graph-analytics runs a Cypher query, builds an in-memory NetworkX graph from the resulting nodes and edges, executes a named algorithm, and merges a _analytics dict into each node and edge before returning them as JSON with an elapsed_ms field. The _analytics keys vary by algorithm: centrality yields score; community detection yields cluster; k-core yields core_number; degree centrality adds in_degree and out_degree. The endpoint rejects graphs above a configurable size (default 10,000 nodes / 50,000 edges) with HTTP 413; Girvan-Newman is capped at 500 nodes unless the caller passes force=true.
Limitations¶
Design constraints¶
-
Writes are limited to
CREATE,SET, andDELETE. These execute as direct table writes through the same pipeline as GraphQL and SQL mutations. See §Writes above.MERGE,DETACH DELETE, andREMOVEare rejected at parse time. APOC procedures are also rejected. -
Relationship properties exist only on junction-backed edges. An edge declared over a foreign-key column pair exists only as join metadata in the model and stores no attributes, so
WHERE r.since > 2020orRETURN r.weightmeans nothing on it. An edge declared over a junction table does carry them. The associative table's remaining columns are the relationship's properties, andRETURN rreturns them. AWHEREon one compiles to a predicate on the junction alias, so it restricts the traversal itself instead of filtering rows after assembly. The junction table is dropped from the node side of the graph schema: it's an edge here and a table everywhere else. -
Bidirectional traversal
(a)-[]-(b)rewrites to the forward and backward UNION ALL of every matching directed relationship in the model. Every relationship in the model has a direction, and the bidirectional syntax is sugar that expands to both. The extra branches are emitted at the outermost query level. A later MATCH pattern in the same query isn't duplicated across the branches, which limits bidirectional patterns in a query with several MATCH clauses. -
Recursive paths require a bound. Variable-length patterns (
[*]) must include an upper bound (e.g.[*..10]). Unbounded traversal is rejected at parse time to prevent runaway recursive CTEs.
Behaviour notes¶
-
shortestPathon non-self-referential paths uses a flat JOIN with no hops ordering. When the start and end types differ and the schema has no self-referential relationship, the translator emits a flat JOIN chain along the shortest schema path. That code path doesn't track hops, so it emits noORDER BY hops. The result is the structurally shortest schema path. It may differ from the shortest path through the data across multiple rows. -
Multiple schema paths produce
UNION ALL. When two schema paths of equal hop count connect the same start and end types (e.g.Person -[WORKS_AT]-> CompanyandPerson -[MANAGES]-> Company), both are emitted asUNION ALLbranches. Deduplication of rows that appear in both branches is not performed. -
One
RelationshipMappingper source→target pair and rel_type combination. If two GraphQL fields on the same source type produce the samerel_typestring (after uppercasing) to the same target type, the second registration overwrites the first inCypherLabelMap.relationships. The relationship key includes source and target type names, so distinct source/target pairs with the same type name each get their own entry and are not affected. -
WITHclause CTEs are named_w0,_w1, … Names are assigned positionally within a single translation call. Composing multiple translated queries (e.g. in a batch) can produce colliding CTE names if they are concatenated naively.
Expression and pattern coverage¶
Cypher expressions are parsed into an AST and lowered node-to-node to SQL (provisa/cypher/expr_parser.py, provisa/gql/expr_visitor.py). The grammar follows the openCypher oC_Expression precedence tower. Supported: literals, parameters, property access, n.prop, index and slice, arithmetic (+ - * / % ^), comparison, IN, STARTS WITH / ENDS WITH / CONTAINS / =~, IS [NOT] NULL, boolean AND / OR / XOR / NOT, CASE, list and map literals, list and pattern comprehensions (including the p = (…) path binding), map projection, reduce, the all / any / none / single quantifiers, existential subqueries, and function calls.
-
Labels are fixed; you cannot create object types through Cypher. A label resolves to a known domain, a known object type or a qualified
domain:object_type, which is the closed set the registered schema defines. Cypher never introduces a new label or type. You can create instances only of types already defined in a writable data source:CREATEwrites rows into such a table (see §Writes) and can't define a new label or type. Both label forms are accepted and mean the same test, the postfixn:Labeland the verbosen IS :Label(with its negationn IS NOT :Label). A qualified label is writtenn:domain:object_type. -
shortestPathandallShortestPathswork only insideMATCH. In a pattern (MATCH p = shortestPath((a:Person)-[:KNOWS*..5]->(b:Person))) they translate to aWITH RECURSIVECTE and need labeled source and target nodes. They aren't supported in expression position, for exampleRETURN shortestPath((a)-[*]->(b))orWHERE length(shortestPath((a)-[*]->(b))) < 5. The recursive rewrite is driven off theMATCHclause, and there's no correlated subquery to carry it. -
List comprehensions,
REDUCEand quantifiers run against list values; pattern comprehensions traverse.reduce(...),all/any/none/single(...)and the list comprehension[x IN list | …]work over a list expression and lower to the engine's higher-order list functions. They don't walk the graph themselves. The pattern comprehension[(a)-[:R]->(b) WHERE p | e]does traverse: its graph pattern is addressed as a correlated subquery, so its source is a traversal. To feed traversal results into the list forms, usenodes(p)/relationships(p)/collect(...), or use a pattern comprehension directly.