shacl2cypher
← Articles

Bringing SHACL validation to FalkorDB

FalkorDB speaks Cypher, so reusing the Neo4j queries looks like it should work. It doesn't: a probe spike found constructs that return wrong answers without raising any error. This article covers what the spike found and the renderer being built around it.

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:

ConstructNeo4j 5FalkorDB 4.20.4
EXISTS { MATCH … }, COUNT { … }Used everywhereParse errors
Type predicatesv IS :: STRING NOT NULLNo IS ::; typeOf(v) returns 'String', 'Integer', …
Regex=~, anchored Java regexNo =~; string.matchRegEx (Oniguruma) searches substrings
Label unionsn:A|BParse error; WHERE n:A OR n:B works
Element idselementId(n)toString(id(n))
Unlimited detail rowsLIMIT coalesce($limit, …)Rejected; LIMIT $limit accepts up to i64::MAX
Temporal constructorsdatetime(), time()Absent; localdatetime drops offsets and fractional seconds
Schema introspectiondb.schema.* proceduresOnly 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:

  1. Pattern predicates inside CASE are always true. (n)-[:T]->() is correct directly in WHERE, but inside a CASE expression it never filters anything.
  2. Pattern comprehensions anchored at quantifier variables are wrong. A comprehension anchored at a variable bound by all(x IN …), a list comprehension, reduce or a UNION in the same CALL returns wrong results. Nested shapes, where a test on a neighbour traverses further, need exactly that.
  3. MATCH collapses parallel relationships. Two WORKS_FOR relationships between the same pair of nodes produce one row unless the relationship variable is referenced, for example with WHERE id(r) >= 0. A sh:maxCount check 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 CALL returning an aggregate column: a count, a boolean from all(…) 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 UNION branches of an inner CALL. A test that traverses further nests another CALL, 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.QUERY would 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 TIMEOUT argument FalkorDB applies a 1000 ms default, which would fail real validations. The runner sends --timeout in milliseconds, or 0 (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 falkordb crate (0.10.3, pinned together with redis 1.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() and db.relationshipTypes(). Property types come from typeOf over 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_differences with 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. A rediss:// 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 CALL per traversal per focus row costs more than Neo4j's COUNT {}. 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.

Sources: openspec/changes/add-falkordb-dialect/ (proposal, design, tasks), spikes/falkordb/probes/, and the FalkorDB section of lat.md/dialects.md.