A computation expression that very closely matches the standard Cypher syntax and allows typed Cypher queries in F#.
0.1.0-alpha package available on NuGet
open FSharp.Data.Cypher
open Neo4j.Driver
let driver = GraphDatabase.Driver("bolt://localhost:7687", AuthTokens.None)
cypher {
for movie in Graph.Movie do
for person in Graph.Person do
for actedIn in Graph.ActedIn do
MATCH (Node(person, person.Label) -- Rel(actedIn.Label) --> Node(movie, movie.Label))
WHERE (movie.released < 1984 && person.born < Some 1960)
RETURN (movie, person.name)
LIMIT 1L
}
|> Cypher.run driver
|> QueryResult.results
// val it : (Movie * string) [] =
// [|({ title = "One Flew Over the Cuckoo's Nest"
// tagline = Some "If he's crazy, what does that make you?"
// released = 1975 }, "Danny DeVito")|]
MATCH (person:Person)-[:ACTED_IN]->(movie:Movie)
WHERE movie.released < 1984 AND person.born < 1960
RETURN movie, person.name
LIMIT 1
Inspired by this great article.
Most clauses are available, working, and have tests. The way IFSNode<'N>
/ Node<'N>
& IFSRel<'R>
/ Rel<'R>
work together; and the abstract member requirements on the interfaces are being explored and so could change.
- Differences with Cypher
- OPTIONAL MATCH and null
- FOREACH Clause
- Supported Types
- Parameterization
- Running a Query
- Not yet supported
- Type Provider
- Setup
- Examples
The intent is to stay a close as possible to the cypher syntax: if you can write cypher you are pretty much set. Its a fairly flexible as to how you define your types to work with the builder, but a good starting point is:
- Impliment
IFSNode<'N>
orIFSRel<'R>
on your graph types - Define a graph type of static members of
Node<'N>
where'N :> IFSNode<'N>
&Rel<'R>
where'R :> IFSRel<'R>
. - Use a
for entity in Graph.Entity do
to unwrap the graph and use in the query
For the example above that can look like:
type Movie =
{ title : string
tagline : string option
released : int }
member _.Label = NodeLabel "Movie"
interface IFSNode<Movie> with
member this.Labels = Some [ this.Label ]
type Person =
{ born : int option
name : string }
member _.Label = NodeLabel "Person"
interface IFSNode<Person> with
member this.Labels = Some [ this.Label ]
type ActedIn =
{ roles : string [] }
member _.Label = RelLabel "ACTED_IN"
interface IFSRel<ActedIn> with
member this.Label = this.Label
// Define the complete graph if you so wish
// Futher down the track a script that can generate all the graph types, or typeprovider could be plugged in
type Graph =
static member Movie = Node<Movie>()
static member Person = Node<Person>()
cypher {
for movie in Graph.Movie do
for person in Graph.Person do
for actedIn in Node<ActedIn>() do // Or Simply wrap the type in the builder
()
}
In cypher a node (..)
consists of 3 optional parts: the binding name
, a label
or list of labels
, and property values
:
(n:Label1 {property1: value1, property2: value2})
In the F# builder match these 3 parts as parameters to a Node()
constructor:
Node(n, Label1, { n with { property1 = value1; property2 = value2 })
In cypher a relationship [..]
consists of 3 optional parts: the binding name
, a label
(or officially Type
), and property values
:
[r:Label1 {property1: value1, property2: value2}]
In the F# builder match these 3 parts as parameters to a Rel()
constructor:
Rel(n, Label1, { n with { property1 = value1; property2 = value2 })
Not currently implemented as I'm considering the best approach: its complicated by the return of a IPath
which contains lists of INode
& IRelationship
.
Just double the number of dashes:
Cypher | FSharp |
---|---|
- |
-- |
-> |
--> |
<- |
<-- |
()--() |
Node() ---- Node() |
()-[]->() |
Node() -- Rel() --> Node() |
To use name aliases (required for aggregating and other functions) there is the AS<'T>()
type with a member to AS : AS<'T> -> 'T
. The variable is defined up front then passed into the AS
member:
cypher {
let totalPeople = AS<int64>() // Define the variable
for person in Graph.Person do
MATCH (Node person)
RETURN (count(person) .AS totalPeople) // Pass it to the AS method on the function
}
For nodes labels are defined by the type NodeLabel
and for relationships the type RelLabel
(Neo4j calls a relationship label a type, however in the F# world the word type has a different meaning). In cypher relationship types can be or'd with |
, for F# use /
:
let labelChoice : RelLabel = RelLabel "Label1" / RelLabel "Label2" // :Label1|:Label2
Setting & removing node labels can be done by making a tuple of (IFSNode<'N>(), NodeLabel)
and passing to the appropiate clause
cypher {
for movie in Graph.Movie do
MATCH (Node(movie, movie.Label, { movie with released = 1975 }))
SET ((movie, NodeLabel "WATCHED"), (movie, NodeLabel "LIKE"))
RETURN movie
}
cypher {
for movie in Graph.Movie do
MATCH (Node(movie, movie.Label, { movie with released = 1975 }))
REMOVE (movie, NodeLabel "WATCHED")
RETURN movie
}
Unfortunately from an F# perspective null is '...is used to represent missing or undefined values...' and this has the potential to all null into your program. With core types (string, int, float etc...
):
- If null is encountered an
ArgumentNullException
will be thrown i.e. you won't be able to create arecord
with a null field, or return a core type of null - If your
record
field or return type is an'T option
,null
will happily becomeNone
OPTIONAL MATCH currently throws a spanner in the works since it will happily return null
in place of node or relationship. When this is returned from the database the deserilizer will still be expecting a node, sees null
and will intentionally throw ArgumentNullException
. There is a nice solution coming for this where the OPTIONAL MATCH query will only work with a record option... coming soon.
Some issues using =
, <>
, None
& null
also need to be resolved. For example the expectations may be different:
// In Neo4j
"" <> null = null
// In F#
"" <> null = true
// This needs to be translated and sent to neo4j as
"" IS NOT NULL = true
FOREACH is a special type of clause in cypher... as such it is implemented as its own builder of type Foreach
. It is then used inside the cypher
builder and ForEach
's can be nested within other ForEach
's
cypher {
for person in Graph.Person do
let people = AS<Person list>()
MATCH (Node(person, person.Label))
WITH (collect(person) .AS people, person)
FOREACH { for p in people do SET (p, NodeLabel "ForEach") }
RETURN person
}
Currently you can only use F# record types, parameterless classes, or single case fieldless DUs. These complex types can be built from the following core types:
string, int32, int64, float, bool
option, seq, list, array, Set
of the above
Records should be the default since they allow the update syntax which is used when you want to bind to a node / relationship with the required properties. Parameterless classes & single case fieldless DUs are for when you have Node or Relationship with no properties:
type Follows =
| NA
member _.Label = RelLabel "FOLLOWS"
interface IFSRel<Follows> with
member this.Label = this.Label
type Produced() =
member _.Label = RelLabel "PRODUCED"
interface IFSRel<Produced> with
member this.Label = this.Label
Some support for DUs will be implemented at a later date... classes are harder still. The serialization / deserialization code has some improvements to go, but its pretty robust for now.
All queries are parameterized by default and sent as a multiline of text (easier to troubleshoot when Neo4j doesn't like the query). You are able to access both the raw and parameterized query by calling the Query
member on CypherBuilder
. For the example at the top:
MATCH (person:Person)-[:ACTED_IN]->(movie:Movie)
WHERE movie.released < 1984 AND person.born < 1960
RETURN movie, person.name
LIMIT 1
Is sent to the database as
MATCH (person:Person)-[:ACTED_IN]->(movie:Movie)
WHERE movie.released < $p01 AND person.born < $p02
RETURN movie, person.name
LIMIT $p00
with a dictionary
of [("p02", 1960); ("p01", 1984); ("p00", 1L)]
. If Option.None
is used that will be sent to the database as null
To run a query you need a IDriver
instance from the standard Neo4j.Driver
driver. Queries come in two flavours see the docs for more info.
These are automatically committed to the database
let driver = GraphDatabase.Driver( ... )
let result =
cypher {
for person in Node<Person>() do
MATCH (Node person)
RETURN (person)
}
|> Cypher.run driver
let results : Person [] = QueryResult.results result
let summary : IResultSummary = QueryResult.summary result
The query is sent to the database where it is run and returns the results - however it is not committed e.g. the database is not updated. The TransactionResult
should then be either committed or rolled back to the database manually: this will need to happen before any subsequent queries.
let transactionResult =
cypher {
for person in Node<Person>() do
MATCH (Node person)
SET (person.name = "NewName")
RETURN (person)
}
|> Cypher.Explicit.run driver
// Results available and have name = "NewName", but are not committed to database
// i.e. they would all still have their original names if you were to query them now
let results : Person [] = TransactionResult.results transactionResult
// Commit the results -> all names in database now set.
let commit : QueryResult<Person> = TransactionResult.commit transactionResult
// Rollback -> there is no change to the database
let rollBack : unit = TransactionResult.rollback transactionResult
e.g. MATCH (n)-[r:DIRECTED]-(m) SET n = $allProperties
. Easy to do... just not had time.
Constraints, indexes, case expressions, stored proceedures.
No support for Paths
, though I have something in mind.
Lots... not much done here.
There are some other core types allowed with Neo4j e.g. date and times. Also add something for just returning a dictionary
+ labels
.
Mathematical, null, XOR, string matching, regex.
Nothing done yet
It could be possible to have typed access to the graph using a Type Provider... but no time for that just yet. A starting point may be a script that could be run to generate all the types based of a graph.
The test project expects a local graph to be running on:
bolt://localhost:7687
- No authentication
AuthTokens.None
- The
Movie Graph
loaded:play movie-graph
TODO: Match the Cypher Ref Card