Type-safe Cypher queries in F#. Plain records as schema, computation expressions as queries, parameterized by default.
type Person = { Name: string; Age: int }
type Movie = { Title: string; Released: int }
type ActedIn = { Roles: string list }
let findActors = cypher {
for p in node<Person> do
for m in node<Movie> do
matchRel (p -- edge<ActedIn> --> m)
where (p.Age > 30)
orderBy m.Released
select (p.Name, m.Title)
}
// MATCH (p:Person) MATCH (m:Movie) MATCH (p)-[:ACTED_IN]->(m)
// WHERE p.age > $p0 ORDER BY m.released RETURN p.name, m.title- Zero boilerplate schema -- F# records are your graph schema. No attributes, no base classes, no code generation.
- Compile-time safety -- quotation-based CE catches errors before runtime.
- Parameterized by default -- every value becomes
$p0,$p1. No string interpolation, no injection. - Multi-backend -- same query runs on Neo4j and Apache AGE (PostgreSQL).
- Zero dependencies -- core library depends only on
FSharp.Core. - Fast -- sub-microsecond compilation, ~1us parse, ~3us for complex queries (benchmarks).
dotnet add package Fyper # Core query builder + compiler
dotnet add package Fyper.Parser # Cypher string parser (zero deps)
dotnet add package Fyper.Neo4j # Neo4j Bolt driver
dotnet add package Fyper.Age # Apache AGE (PostgreSQL) driverPlain F# records. No attributes required.
type Person = { Name: string; Age: int }
type Movie = { Title: string; Released: int }
type ActedIn = { Roles: string list }open Fyper
let findActors = cypher {
for p in node<Person> do
for m in node<Movie> do
matchRel (p -- edge<ActedIn> --> m)
where (p.Age > 30 && m.Released >= 2000)
orderBy m.Released
select (p.Name, m.Title)
}let cypherString, parameters = findActors |> Cypher.toCypher
// MATCH (p:Person) MATCH (m:Movie) MATCH (p)-[:ACTED_IN]->(m)
// WHERE (p.age > $p0) AND (m.released >= $p1)
// ORDER BY m.released RETURN p.name AS name, m.title AS titleopen Fyper.Neo4j
let driver = new Neo4jDriver(
Neo4j.Driver.GraphDatabase.Driver(
"bolt://localhost:7687",
Neo4j.Driver.AuthTokens.Basic("neo4j", "password")))
task {
let! results = findActors |> Cypher.executeAsync driver
for (name, title) in results do
printfn "%s acted in %s" name title
}Same query, different backend:
open Fyper.Age
open Npgsql
let ds = NpgsqlDataSource.Create("Host=localhost;Database=mydb;Username=user;Password=pass")
let driver = new AgeDriver(ds, graphName = "movies")
task {
let! results = findActors |> Cypher.executeAsync driver
// identical typed results
}// Match relationship (extracts type: ActedIn -> ACTED_IN)
let q = cypher {
for p in node<Person> do
for m in node<Movie> do
matchRel (p -- edge<ActedIn> --> m)
select (p.Name, m.Title)
}
// OPTIONAL MATCH
let q = cypher {
for p in node<Person> do
for m in optionalNode<Movie> do
matchRel (p -- edge<ActedIn> --> m)
select (p, m)
}
// Variable-length paths
let q = cypher {
for p in node<Person> do
for q in node<Person> do
matchPath (p -- edge<ActedIn> --> q) (Between(1, 5))
select (p, q)
}
// -> MATCH (p)-[:ACTED_IN*1..5]->(q)
// Create relationship between existing nodes
let q = cypher {
for p in node<Person> do
for m in node<Movie> do
where (p.Name = "Tom")
createRel (p -- edge<ActedIn> --> m)
}// CREATE node
let q = cypher {
for _p in node<Person> do
create { Name = "Alice"; Age = 30 }
}
// SET with record update syntax
let q = cypher {
for p in node<Person> do
where (p.Name = "Alice")
set (fun p -> { p with Age = p.Age + 1 })
}
// -> SET p.age = (p.age + $p0)
// DETACH DELETE
let q = cypher {
for p in node<Person> do
where (p.Name = "Bob")
detachDelete p
}
// MERGE with ON MATCH / ON CREATE
let q = cypher {
for p in node<Person> do
merge { Name = "Tom"; Age = 0 }
onMatch (fun p -> { p with Age = 50 })
onCreate (fun p -> { p with Age = 25 })
}task {
let! result = Cypher.inTransaction driver (fun tx -> task {
let! _ = cypher { for _p in node<Person> do; create { Name = "A"; Age = 1 } }
|> Cypher.executeWriteAsync tx
let! _ = cypher { for _p in node<Person> do; create { Name = "B"; Age = 2 } }
|> Cypher.executeWriteAsync tx
return 2
})
// Both committed atomically, or both rolled back on exception
}// Aggregation functions
let q = cypher { for p in node<Person> do; select (count()) }
let q = cypher { for p in node<Person> do; select (sum(p.Age)) }
let q = cypher { for p in node<Person> do; select (collect(p.Name)) }
// Anonymous record projection
let q = cypher { for p in node<Person> do; select {| Age = p.Age; Count = count() |} }
// RETURN DISTINCT
let q = cypher { for p in node<Person> do; selectDistinct p.Name }
// UNWIND
let names = ["Tom"; "Alice"]
let q = cypher { for _p in node<Person> do; unwind names "name"; select _p }
// WITH clause
let q = cypher { for p in node<Person> do; withClause p }
// CASE expression
let q = cypher {
for p in node<Person> do
select (caseWhen (p.Age > 18) p.Name "minor")
}
// String operators
let q = cypher {
for p in node<Person> do
where (p.Name.Contains("Tom") || p.Name.StartsWith("A") || p.Name.EndsWith("son"))
select p
}
// REMOVE property / label
let q = cypher { for p in node<Person> do; removeProperty p.Name }
let q = cypher { for p in node<Person> do; removeLabel p "Admin" }
// CREATE relationship with properties
let q = cypher {
for p in node<Person> do
for m in node<Movie> do
createRelWith (p -- edge<ActedIn> --> m) { Roles = ["Neo"] }
}
// EXISTS subquery in WHERE
let q = cypher {
for p in node<Person> do
for m in node<Movie> do
where (existsRel (p -- edge<ActedIn> --> m))
select p
}
// CALL procedure
let q = cypher { for _p in node<Person> do; callProc "db.labels" ["label"] }
// Incoming / undirected relationships
let q = cypher {
for p in node<Person> do
for m in node<Movie> do
matchRel (p -- edgeIn<ActedIn> --> m) // (p)<-[:ACTED_IN]-(m)
select p
}
// Raw Cypher (escape hatch)
let! records = Cypher.rawAsync driver "MATCH (n) RETURN count(n) AS cnt" Map.emptyParse Cypher strings into the typed AST -- useful for query analysis, transformation, and validation. Zero dependencies beyond Fyper core.
open Fyper.Parser
// Parse any Cypher string
let parsed = CypherParser.parse
"MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE p.age > 30 RETURN p.name, m.title"
// parsed.Clauses = [Match(RelPattern(...)); Where(BinOp(...)); Return(...)]
// Roundtrip: parse -> compile
let compiled = Fyper.CypherCompiler.compile parsed
printfn "%s" compiled.Cypher
// Supports full Cypher:
// MATCH, OPTIONAL MATCH, WHERE, RETURN, WITH, CREATE, MERGE (ON MATCH/ON CREATE),
// DELETE, DETACH DELETE, SET, REMOVE, ORDER BY, SKIP, LIMIT, UNWIND, UNION, CALL,
// CASE WHEN, EXISTS subqueries, variable-length paths, IS NULL, CONTAINS, etc.Each driver declares supported Cypher features. Unsupported features are rejected at query construction time, not at the database:
| Feature | Neo4j | Apache AGE |
|---|---|---|
| MATCH / WHERE / RETURN | yes | yes |
| CREATE / DELETE / SET | yes | yes |
| OPTIONAL MATCH | yes | no |
| MERGE + ON MATCH/CREATE | yes | no |
| UNWIND | yes | no |
| CASE expressions | yes | no |
| Variable-length paths | yes | yes |
| ORDER BY / SKIP / LIMIT | yes | yes |
| Named paths | yes | no |
| CALL procedures | yes | no |
Override conventions when needed:
[<Label "PERSON">]
type Person = { Name: string; [<CypherName "birth_year">] BirthYear: int }Default conventions:
- Type name = node label (
Person->:Person) - PascalCase field = camelCase property (
FirstName->firstName) - Relationship type = UPPER_SNAKE_CASE (
ActedIn->ACTED_IN)
Benchmarked on Apple M1 Pro, .NET 10.0:
| Operation | Mean | Allocated |
|---|---|---|
| Compile simple query | 890 ns | 2.3 KB |
| Compile complex query (8 clauses) | 3.2 us | 9.2 KB |
| Lex simple Cypher string | 744 ns | 1.5 KB |
| Parse simple Cypher string | 1.2 us | 2.6 KB |
| Parse complex Cypher (rel + WHERE + ORDER BY) | 3.5 us | 7.7 KB |
| Full roundtrip: parse -> compile | 2.0 us | 4.9 KB |
| Schema: toCypherName | 22 ns | 104 B |
| Schema: getMeta (cached) | 24 ns | 64 B |
| ResultMapper: record | 6.8 us | 4.3 KB |
| ResultMapper: tuple | 790 ns | 664 B |
Run benchmarks: dotnet run --project tests/Fyper.Benchmarks/ -c Release
- AGE dialect limitations -- Apache AGE does not support OPTIONAL MATCH, MERGE, UNWIND, CASE. Fyper rejects these at query construction time.
- Multi-field SET --
set (fun p -> { p with Name = "X"; Age = 30 })changes both fields. Only the changed fields generate SET clauses, but both changes are in one SET (no separate SET per field).
| Package | Description | Dependencies |
|---|---|---|
Fyper |
Core query builder + compiler | FSharp.Core only |
Fyper.Parser |
Cypher string parser | Fyper only |
Fyper.Neo4j |
Neo4j Bolt driver | Neo4j.Driver |
Fyper.Age |
Apache AGE (PostgreSQL) driver | Npgsql |
# Run all 239 tests (unit + property-based + parser)
dotnet test tests/Fyper.Tests/
# Run integration tests (requires Docker)
docker compose up -d
dotnet test tests/Fyper.Integration.Tests/
docker compose down
# Run benchmarks
dotnet run --project tests/Fyper.Benchmarks/ -c Release
# Run sample app (10 examples, no database needed)
dotnet run --project samples/Fyper.Sample/