Inspired by Kotlin Exposed, this library aims to provide a type-safe cypher DSL for interacting with Neo4j.
- User defined graph schemas
- Cypher CRUD operations
- Allows you to construct complex queries
- Returned data is automatically deserialized into the implied type
val findBestRatedMovies = query {
val (movie, review) = match(::Movie `←-o` ::Reviewed `←-o` ::User)
many(movie.title, avg(review.rating), count(review))
}.with { (title, averageRating, numberOfReviews) ->
where(numberOfReviews greaterThan 100)
orderByDesc(averageRating)
limit(25)
many(title, averageRating)
}.build()
graph.findBestRatedMovies().forEach { println(it) /* it: Pair<String, Double> */ }
You will need a Neo4j database setup. For instructions on installing Neo4j desktop see here.
Note: Some features will only be available for the enterprise edition.
Step 1. Add the JitPack repository to your build file
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
Step 2. Add the dependency
dependencies {
implementation 'com.github.mnbjhu:neo4k:0.0.2-alpha'
}
Step 1. Add the JitPack repository to your build file
allprojects {
repositories {
...
maven("https://jitpack.io")
}
}
Step 2. Add the dependency
dependencies {
implementation("com.github.mnbjhu:neo4k:0.0.2-alpha")
}
To connect to your database, you create an instance of 'Graph':
val graph = Graph(
name = "neo4j",
username = "Username",
password = "Password123!",
host = "localhost"
)
To take advantage of Kotlin's type safety, neo4k requires you to build a schema.
You can define nodes by extending either the UnitNode or the Node class and by defining its attributes as values in the primary constructor. These values should all be instances of ReturnValue:
class Movie(
val title: StringReturn,
val releaseYear: LongReturn
): UnitNode()
A UnitNode is just a node which can't be returned directly from a query.
data class MovieData(val title: String, val releaseYear: Long)
class Movie(
val title: StringReturn,
val releaseYear: LongReturn
): Node<MovieData>(){
override fun ReturnScope.decode() = MovieData(
::title.result(),
::releaseYear.result()
)
}
Currently supported types are:
Name | Class | Type Descriptor | Return Type |
---|---|---|---|
Long | LongReturn | ::LongReturn | kotlin.Long |
Boolean | BooleanReturn | ::BooleanReturn | kotlin.Boolean |
String | StringReturn | ::StringReturn | kotlin.String |
Double | DoubleReturn | ::DoubleReturn | kotlin.Double |
Nullable | NullableReturn<T, U: ReturnValue<T>> | nullable{ /* Inner */ } | T? |
Arrays | ArrayReturn<T, U: ReturnValue<T>> | array{ /* Inner */ } | List<T> |
Structs | StructReturn<T, U: ReturnValue<T>> | ::MyStruct (see StructReturn) | T |
You can create relationships by extending the DirectionalRelationship or NonDirectionalRelationship classes or their Unit equivalent.
Directional relationships take two type arguments: the 'from' type and the 'to' type.
class ActedIn(val role: StringReturn): UnitDirectionalRelationship<Actor, Movie>()
On the other hand non-directional only require one type argument
class FriendsWith: UnitNonDirectionalRelationship<User>()
Once you have built a schema it can be used to query your graph. You can describe your nodes and relationships using their constructor reference:
With no constraints:
::Movie
,::ActedIn
With constrains:
import uk.gibby.neo4k.core.invoke // THIS WILL NOT IMPORT AUTOMATICALLY
::Movie{ it[title] = "Pulp Fiction" }
You can then use the arrow functions: `o-→`, `←-o` `-o-` ('o' was added for autocomplete) to describe paths:
::Actor `o-→` ::ActedIn `o-→` ::Movie{ it[title] = "Pulp Fiction" }
Path are currently supported upto a length of ten (not including ranged relation matches).
Queries are written within a lambda with the QueryScope class as the receiver. This provides you with a set of functions which correspond to the clauses from neo4j. The currently supported clauses are:
- CREATE
- DELETE
- LIMIT
- MATCH
- ORDER BY
- SET
- SKIP
- UNWIND
- WHERE
- WITH (See below)
Queries can either be executed directly by calling the 'query' function:
graph.query { // this: QueryScope
val movie = create(::Movie{ it[title] = "Star Wars: Episode V - The Empire Strikes Back"; it[releaseYear] = 1980 })
create(::Actor{ it[name] = "Mark Hamill" } `o-→` ::ActedIn{ it[role] = "Luke Skywalker" } `o-→` movie)
create(::Actor{ it[name] = "Carrie Fisher" } `o-→` ::ActedIn{ it[role] = "Princess Leia" } `o-→` movie)
create(::Actor{ it[name] = "Harrison Ford" } `o-→` ::ActedIn{ it[role] = "Han Solo" } `o-→` movie)
}
or by first constructing a query and then executing it on the graph:
val findActorsInMovie = query(::StringReturn) { searchString ->
val (actor, _, movie) = match(::Actor `o-→` ::ActedIn `o-→` ::Movie)
where(movie.name contains searchString)
actor.name
}.build()
val actorNames = graph.findActorsInMovie("Star Wars")
The latter creates the query string and the relevant serializers when 'build' is called: making the query much more efficient. In this example we have a parameter of 'searchString'. Parameterizing queries (rather than constructing them at execution time) makes it easier for neo4j to cache the execution plan, which also makes it faster 🔥.
val findBestRatedMovies = query {
val (movie, review) = match(::Movie `←-o` ::Reviewed `←-o` ::User)
many(movie.title, avg(review.rating), count(review))
}.with { (title, averageRating, numberOfReviews) ->
where(numberOfReviews greaterThan 100)
orderByDesc(averageRating)
limit(25)
many(title, averageRating)
}.build()
graph.findBestRatedMovies() // List<Pair<String, Double>>
You can chain queries together using the 'with' function which passes on the return of the previous query.