Status: in development. The spike is complete, and its findings are recorded in the design notes. Core dialect wiring and the renderer are being implemented. FalkorDB is not part of shacl2cypher 0.2.0: --dialect falkordb and the connection flags described below are planned, not released. Follow the add-falkordb-dialect change in the repository for progress.
Why FalkorDB
FalkorDB is a property graph database that runs as a Redis module. It is popular for low-latency graph workloads and for GraphRAG, where a knowledge graph backs a language model. Its data model is the closest of any engine to Neo4j's: schemaless, multi-label nodes, untyped properties, and Cypher as the query language. Teams running it have had no way to validate their graphs against SHACL.
Why the Neo4j queries can't be reused
The Neo4j renderer leans on Neo4j 5 constructs that FalkorDB either lacks or implements differently. The spike checked each one against falkordb/falkordb:v4.20.4:
| Construct | Neo4j 5 | FalkorDB 4.20.4 |
|---|---|---|
EXISTS { MATCH … }, COUNT { … } | Used everywhere | Parse errors |
| Type predicates | v IS :: STRING NOT NULL | No IS ::; typeOf(v) returns 'String', 'Integer', … |
| Regex | =~, anchored Java regex | No =~; string.matchRegEx (Oniguruma) searches substrings |
| Label unions | n:A|B | Parse error; WHERE n:A OR n:B works |
| Element ids | elementId(n) | toString(id(n)) |
| Unlimited detail rows | LIMIT coalesce($limit, …) | Rejected; LIMIT $limit accepts up to i64::MAX |
| Temporal constructors | datetime(), time() | Absent; localdatetime drops offsets and fractional seconds |
| Schema introspection | db.schema.* procedures | Only db.labels(), db.relationshipTypes() and n[k] |
Missing features are the easy part: the database rejects the query and you notice. The hard part is what came next.
Spike first, because some answers are silently wrong
Before writing any renderer code, the spikes/falkordb project ran nine probe scripts through redis-cli inside the container, plus a probe of the Rust client. The scripts and their outputs are committed side by side, so a version bump can rerun them. Three findings justified the whole exercise, because each one returns a plausible, wrong result with no error:
- Pattern predicates inside
CASEare always true.(n)-[:T]->()is correct directly inWHERE, but inside aCASEexpression it never filters anything. - Pattern comprehensions anchored at quantifier variables are wrong. A comprehension anchored at a variable bound by
all(x IN …), a list comprehension,reduceor aUNIONin the sameCALLreturns wrong results. Nested shapes, where a test on a neighbour traverses further, need exactly that. MATCHcollapses parallel relationships. TwoWORKS_FORrelationships between the same pair of nodes produce one row unless the relationship variable is referenced, for example withWHERE id(r) >= 0. Ash:maxCountcheck would under-count.
Two more findings shape every value test. CASE evaluates all of its branches, so a guard like CASE WHEN typeOf(v) = 'String' THEN size(v) … END still raises on a number. And size, string.matchRegEx, keys, labels and property access all raise on the wrong type. Only toStringOrNull is total.
The renderer: anchor every traversal at a row
The one construct that was reliable in every probe was the correlated subquery: CALL { WITH x OPTIONAL MATCH … RETURN <aggregate> }. It returns exactly one row per outer row, even with no matches, and gives correct results nested at least three levels deep. The FalkorDB renderer is built on it:
- Every relationship route becomes a correlated
CALLreturning an aggregate column: a count, a boolean fromall(…)over collected test results, or a distinct list for pair constraints. - Every relationship variable is referenced (
id(r) >= 0), so parallel relationships are counted. Variable-length hops bind a path variable. - Alternative paths become
UNIONbranches of an innerCALL. A test that traverses further nests anotherCALL, so query depth follows shape depth. - The rule condition combines the columns in
WITH … WHERE NOT (…). The renderer never emits pattern predicates or pattern comprehensions, and a lint test enforces that.
For sh:path ex:worksFor ; sh:maxCount 1 the shape of a query is roughly the following. This is a sketch of the design, not compiler output:
MATCH (v0:Person)
CALL {
WITH v0
OPTIONAL MATCH (v0)-[r1:WORKS_FOR]->(y1)
WHERE id(r1) >= 0
RETURN count(DISTINCT y1) AS c1
}
WITH v0, c1
WHERE NOT (c1 <= 1)
RETURN … , {label: 'Person', key: 'id', keyValue: toStringOrNull(v0.id), elementId: toString(id(v0))} AS focus
LIMIT $limit
Total value tests
Because no expression may raise for any value type, datatype tests use typeOf, integer ranges are coalesce-wrapped comparisons, and length and pattern tests run on toStringOrNull(v). Node-only functions are applied only where the IR proves the value is a node.
Regex
sh:pattern renders as size(string.matchRegEx(toStringOrNull(v), '<flags><pattern>')) > 0. Oniguruma already searches substrings, so no wrapper is needed. It supports back-references (allowed, as on Neo4j) but silently never matches \x{…}. The renderer therefore rewrites those escapes to \uHHHH, or to the literal character outside the Basic Multilingual Plane.
Temporal constants: refuse rather than approximate
date('2020-02-30') rolls over to March 1. localdatetime drops time zones and fractions. Durations compare as total seconds, so P1Y equals P365D. A constraint like sh:minInclusive "2024-01-01T00:00:00+02:00"^^xsd:dateTime cannot be rendered faithfully, so it becomes a compile error naming FalkorDB:
xsd:duration constant P1Y is not supported on FalkorDB, which compares durations as total
seconds (P1Y equals P365D) and cannot hold negative or fractional ones
Checks on the type of temporal values, with sh:datatype, still work.
The runner: read-only over Redis
- Only
GRAPH.RO_QUERY. It refuses writes.GRAPH.QUERYwould silently create a missing graph, so the runner never sends it. It also checks that the graph exists before running anything. - Timeouts are server-side. Without a
TIMEOUTargument FalkorDB applies a 1000 ms default, which would fail real validations. The runner sends--timeoutin milliseconds, or0(no limit) when none is given. The client's own response timeout doesn't interrupt a running query, so the server timeout is the only enforcement. - Client quirks are handled explicitly. The Rust
falkordbcrate (0.10.3, pinned together withredis1.2.2 to build on Rust 1.87) drops the first word of server errors. Timeouts are therefore recognized by the message suffix. Values are converted to JSON explicitly, including local times, which the crate decodes as seconds since 1900. - Schema dump by sampling. Labels and relationship types come from
db.labels()anddb.relationshipTypes(). Property types come fromtypeOfover the data, mapped to the neutral snapshot types. The snapshot stays optional, as on Neo4j, because FalkorDB properties are untyped.
Planned usage
The design adds a dialect name and two connection flags. These do not work in 0.2.0:
# planned
shacl2cypher compile shapes.ttl --dialect falkordb --node-key id -o out/
shacl2cypher validate shapes.ttl --falkordb redis://localhost:6379 --graph social --node-key id
shacl2cypher schema dump --falkordb redis://user:pass@localhost:6379 --graph social -o schema.json
Credentials can also come from FALKORDB_USERNAME and FALKORDB_PASSWORD. --neo4j-labels applies to FalkorDB too, since both engines have multi-label nodes. Report formats and exit codes don't change.
How it will be tested
- Every conformance and regex fixture loads into a fresh FalkorDB graph and must match the same expected violations as Neo4j and LadybugDB. Data FalkorDB cannot store, such as nulls inside lists, is restricted per fixture. Confirmed engine bugs, such as known variable-length path issues, become documented
known_differenceswith issue links, never silent passes. - Literal round-trip fuzzing extends to FalkorDB. Identifiers containing a backtick are excluded: they cannot be written in FalkorDB and are a compile error.
- CI pins
falkordb/falkordb:v4.20.4. The newer Rust-rewrite engine behaves differently for regex and aggregation, and will get its own follow-up.
Out of scope for the first release
- TLS (
rediss://), Redis Cluster and Sentinel. Arediss://URL is a clear setup error. - Python and Node.js database handles for FalkorDB.
compile(dialect="falkordb")will work through the shared core, but opening a FalkorDB connection from the bindings comes later. - Performance parity with Neo4j. One correlated
CALLper traversal per focus row costs more than Neo4j'sCOUNT {}. Correctness comes first, and cost classes stay the same.
The lesson
"It speaks Cypher" is a statement about syntax, not semantics. Three constructs in a mature engine returned wrong answers with no error, and a validator built on them would have reported clean data that was not clean. Probing first, committing the probes, and designing only on constructs that proved reliable is how shacl2cypher keeps its main promise: if it says your data conforms, it does.