Ex4j
An Ecto-inspired Cypher DSL and Neo4j driver for Elixir.
Ex4j lets you build parameterized Cypher queries using Elixir macros, protocols, and pipe-based composition — no raw strings required. All values become query parameters ($p0, $p1, …) to prevent injection and enable Neo4j query plan caching.
Powered by Boltx (Bolt 5.0–5.4, Neo4j 5.x) and Ecto for schema validation.
Installation
Add ex4j to your list of dependencies in mix.exs:
def deps do
[
{:ex4j, "~> 0.2.0"}
]
endConfigure the Neo4j connection:
# config/config.exs
config :ex4j, Boltx,
url: "bolt://localhost:7687",
basic_auth: [username: "neo4j", password: "your_password"],
pool_size: 10
# For Neo4j Aura (cloud), use neo4j+s:// and specify the database name:
config :ex4j, Boltx,
url: "neo4j+s://your-instance-id.databases.neo4j.io",
basic_auth: [username: "your_username", password: "your_password"],
database: "your-database-name",
pool_size: 5Define a Repo module for executing queries:
defmodule MyApp.Repo do
use Ex4j.Repo, otp_app: :my_app
endAdd the Repo config:
# config/config.exs
config :my_app, MyApp.Repo, []The docs can be found at https://hexdocs.pm/ex4j.
Defining Schemas
Nodes
defmodule MyApp.User do
use Ex4j.Schema
node "User" do
field(:name, :string)
field(:age, :integer)
field(:email, :string)
end
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :age, :email])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_inclusion(:age, 18..100)
end
endComments
defmodule MyApp.Comment do
use Ex4j.Schema
node "Comment" do
field(:content, :string)
end
endRelationships
defmodule MyApp.HasComment do
use Ex4j.Schema
relationship "HAS_COMMENT" do
from(MyApp.User)
to(MyApp.Comment)
field(:created_at, :utc_datetime)
end
endMulti-Label Nodes
defmodule MyApp.Admin do
use Ex4j.Schema
node ["Person", "Admin"] do
field(:name, :string)
field(:role, :string)
end
endAll schemas automatically get:
-
Ecto
embedded_schemawith a:uuidprimary key Ecto.Changesetfunctions importednew/1andnew/2constructors for creating structs from maps__schema__/1introspection callbacks for query building
Building Queries
Import the query API to access all macros:
import Ex4j.Query.APISimple Match + Return
User
|> match(as: :u)
|> return([:u])
|> MyApp.Repo.all()
# => {:ok, [%{"u" => %MyApp.User{name: "Tiago", age: 38, ...}}]}Generated Cypher:
MATCH (u:User)
RETURN uWhere with Macro Expressions
No more raw strings! Write Elixir expressions and they compile to parameterized Cypher:
User
|> match(as: :u)
|> where([u], u.age > 18 and u.name == "Tiago")
|> return([:u])
|> limit(10)
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
WHERE (u.age > $p0 AND u.name = $p1)
RETURN u
LIMIT 10
-- params: %{"p0" => 18, "p1" => "Tiago"}Pin Operator for Runtime Values
Use ^ to inject runtime variables as parameters (just like Ecto):
name = "Tiago"
min_age = 18
User
|> match(as: :u)
|> where([u], u.name == ^name and u.age >= ^min_age)
|> return([u], [:name, :age])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
WHERE (u.name = $p0 AND u.age >= $p1)
RETURN u.name, u.age
-- params: %{"p0" => "Tiago", "p1" => 18}Relationship Traversal
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> where([u], u.name == ^user_name)
|> where([c], c.content =~ "Article")
|> return([:u, :c])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)-[r:HAS_COMMENT]->(c:Comment)
WHERE (u.name = $p0 AND c.content CONTAINS $p1)
RETURN u, cRelationship Directions
# Outgoing: ->
edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
# (u)-[r:HAS_COMMENT]->(c)
# Incoming: <-
edge(HasComment, as: :r, from: :u, to: :c, direction: :in)
# (u)<-[r:HAS_COMMENT]-(c)
# Any direction: -
edge(HasComment, as: :r, from: :u, to: :c, direction: :any)
# (u)-[r:HAS_COMMENT]-(c)Variable-Length Relationships
query()
|> match(User, as: :u)
|> match(User, as: :friend)
|> edge(:KNOWS, as: :r, from: :u, to: :friend, direction: :out, length: 1..3)
|> return([:u, :friend])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)-[r:KNOWS*1..3]->(friend:User)
RETURN u, friendWhere Operators
The where macro supports all common comparison and logical operators:
| Elixir Expression | Cypher Output |
|---|---|
u.age > 18 | u.age > $p0 |
u.age >= 18 | u.age >= $p0 |
u.age < 65 | u.age < $p0 |
u.age <= 65 | u.age <= $p0 |
u.name == "Tiago" | u.name = $p0 |
u.name != "Admin" | u.name <> $p0 |
u.age in [18, 25, 30] | u.age IN $p0 |
u.name =~ "pattern" | u.name CONTAINS $p0 |
starts_with(u.name, "T") | u.name STARTS WITH $p0 |
ends_with(u.name, "go") | u.name ENDS WITH $p0 |
is_nil(u.email) | u.email IS NULL |
not is_nil(u.email) | NOT u.email IS NULL |
expr1 and expr2 | expr1 AND expr2 |
expr1 or expr2 | expr1 OR expr2 |
^variable | $pN (runtime parameter) |
Multiple Where Clauses
Multiple where calls are combined with AND:
User
|> match(as: :u)
|> where([u], u.age > 18)
|> where([u], u.name == "Tiago")
|> return([:u])Generated Cypher:
MATCH (u:User)
WHERE u.age > $p0 AND u.name = $p1
RETURN uWrite Operations
CREATE
query()
|> create(User, as: :u, set: %{name: "Alice", age: 30, email: "alice@example.com"})
|> return([:u])
|> MyApp.Repo.run()Generated Cypher:
CREATE (u:User {name: $p0, age: $p1, email: $p2})
RETURN uCREATE Relationship
query()
|> match(User, as: :u, where: %{email: "alice@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c, set: %{created_at: "2025-06-01T10:00:00Z"})
|> return([:r])
|> MyApp.Repo.run()Generated Cypher:
MATCH (u:User {email: $p0}), (c:Comment {content: $p1})
CREATE (u)-[r:HAS_COMMENT {created_at: $p2}]->(c)
RETURN rYou can also create relationships without properties:
query()
|> match(User, as: :u, where: %{email: "bob@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c)
|> return([:r])
|> MyApp.Repo.run()Or with a specific direction:
# Incoming relationship
query()
|> match(User, as: :u, where: %{email: "alice@example.com"})
|> match(Comment, as: :c, where: %{content: "Great article!"})
|> create(HasComment, as: :r, from: :u, to: :c, direction: :in)
|> return([:r])
|> MyApp.Repo.run()MERGE
query()
|> merge(User, as: :u, match: %{email: "alice@example.com"})
|> return([:u])
|> MyApp.Repo.run()Generated Cypher:
MERGE (u:User {email: $p0})
RETURN uSET
query()
|> match(User, as: :u)
|> where([u], u.email == "alice@example.com")
|> set(:u, :name, "Alice Updated")
|> set(:u, :age, 31)
|> return([:u])
|> MyApp.Repo.run()Generated Cypher:
MATCH (u:User)
WHERE u.email = $p0
SET u.name = $p1, u.age = $p2
RETURN uDELETE
# Simple delete
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> delete(:u)
|> MyApp.Repo.run()
# Detach delete (removes all relationships first)
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> delete(:u, detach: true)
|> MyApp.Repo.run()Generated Cypher:
MATCH (u:User)
WHERE u.name = $p0
DETACH DELETE uREMOVE
query()
|> match(User, as: :u)
|> where([u], u.name == "Alice")
|> remove(:u, :email)
|> return([:u])
|> MyApp.Repo.run()Generated Cypher:
MATCH (u:User)
WHERE u.name = $p0
REMOVE u.email
RETURN uAdvanced Features
OPTIONAL MATCH
query()
|> match(User, as: :u)
|> optional_match(Comment, as: :c)
|> return([:u, :c])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
OPTIONAL MATCH (c:Comment)
RETURN u, cORDER BY, SKIP, LIMIT
User
|> match(as: :u)
|> return([:u])
|> order_by([u], asc: :name, desc: :age)
|> skip(10)
|> limit(25)
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
RETURN u
ORDER BY u.name, u.age DESC
SKIP 10
LIMIT 25WITH (Query Chaining)
query()
|> match(User, as: :u)
|> with_query([:u])
|> return([:u])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
WITH u
RETURN uUNWIND
query()
|> unwind([1, 2, 3], as: :x)
|> return([:x])
|> MyApp.Repo.all()Generated Cypher:
UNWIND $p0 AS x
RETURN x
-- params: %{"p0" => [1, 2, 3]}UNION
q1 =
query()
|> match(User, as: :u)
|> where([u], u.age > 30)
|> return(:u, [:name])
q2 =
query()
|> match(User, as: :u)
|> where([u], u.age < 20)
|> return(:u, [:name])
union(q1, q2) # UNION (distinct)
union(q1, q2, :all) # UNION ALL
|> MyApp.Repo.all()CALL Subqueries
subquery =
query()
|> match(Comment, as: :c)
|> return([:c])
query()
|> match(User, as: :u)
|> call(subquery)
|> return([:u, :c])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
CALL {
MATCH (c:Comment)
RETURN c
}
RETURN u, cDynamic Queries
Build queries at runtime based on user input or conditions:
import Ex4j.Query.API
min_age = 18
name = "Tiago"
dyn = dynamic([u], u.age > ^min_age and u.name == ^name)
User
|> match(as: :u)
|> where(^dyn)
|> return([:u])
|> MyApp.Repo.all()Fragments
For Cypher syntax not covered by the DSL, use fragment/1+ to embed raw Cypher with safe parameter binding:
User
|> match(as: :u)
|> where([u], fragment("u.score > duration(?)", "P1Y"))
|> return([:u])
|> MyApp.Repo.all()Generated Cypher:
MATCH (u:User)
WHERE u.score > duration($p0)
RETURN u
-- params: %{"p0" => "P1Y"}
Each ? placeholder becomes a parameterized value. Never interpolate user input directly — always use ? placeholders.
Raw Cypher Queries
For full control, pass a raw Cypher string directly:
MyApp.Repo.query("MATCH (n:User) RETURN n LIMIT 25")
# With parameters
MyApp.Repo.query("MATCH (n:User) WHERE n.age > $age RETURN n", %{"age" => 18})Transactions
MyApp.Repo.transaction(fn ->
MyApp.Repo.run(create_user_query)
MyApp.Repo.run(create_relationship_query)
end)Debugging Queries
Inspect the generated Cypher and parameters without executing:
import Ex4j.Query.API
{cypher, params} =
User
|> match(as: :u)
|> where([u], u.age > 18)
|> return([:u])
|> to_cypher()
IO.puts(cypher)
# MATCH (u:User)
# WHERE u.age > $p0
# RETURN u
IO.inspect(params)
# %{"p0" => 18}Or get just the Cypher string:
cypher_string =
User
|> match(as: :u)
|> return([:u])
|> cypher()
# "MATCH (u:User)\nRETURN u"Changeset Validation
Schemas integrate with Ecto changesets for validation:
changeset = User.changeset(%User{}, %{"name" => "Tiago", "email" => "tiago@test.com"})
if changeset.valid? do
user = Ecto.Changeset.apply_action!(changeset, :create)
# proceed with creating the node...
end
Graph-specific validations are available via Ex4j.Changeset:
changeset
|> Ex4j.Changeset.validate_node_label()
|> Ex4j.Changeset.validate_neo4j_type(:age)Cypher 25 Support
Ex4j includes a comprehensive Cypher functions registry supporting Cypher 25 additions:
- Aggregation:
count,sum,avg,min,max,collect,percentile_cont,percentile_disc - Scalar:
coalesce,head,last,size,length,keys,labels,type,id,element_id - String:
trim,to_lower,to_upper,replace,substring,split - Math:
abs,ceil,floor,round,sqrt,log,rand - Temporal:
date,datetime,duration,time,timestamp - Spatial:
point,distance - Cypher 25 Vectors:
vector,vector_dimension_count,vector_distance,vector_norm
Sample Data for Testing
A complete seed script using the User, Comment, and HasComment schemas.
Paste this into priv/repo/seeds.exs or run it in iex -S mix.
import Ex4j.Query.API
alias MyApp.{User, Comment, HasComment}
# --- Indexes & Constraints ---------------------------------------------------
MyApp.Repo.query("CREATE CONSTRAINT user_email_unique IF NOT EXISTS FOR (u:User) REQUIRE u.email IS UNIQUE")
MyApp.Repo.query("CREATE INDEX user_name_index IF NOT EXISTS FOR (u:User) ON (u.name)")
MyApp.Repo.query("CREATE INDEX comment_content_index IF NOT EXISTS FOR (c:Comment) ON (c.content)")
# --- Nodes: Users ------------------------------------------------------------
users = [
%{name: "Tiago", age: 38, email: "tiago@example.com"},
%{name: "Alice", age: 30, email: "alice@example.com"},
%{name: "Bob", age: 25, email: "bob@example.com"}
]
for attrs <- users do
query()
|> create(User, as: :u, set: attrs)
|> return([:u])
|> MyApp.Repo.run()
end
# --- Nodes: Comments ----------------------------------------------------------
comments = [
%{content: "Great article on Elixir!"},
%{content: "Neo4j is awesome for graph data"},
%{content: "Loving the Ex4j DSL"}
]
for attrs <- comments do
query()
|> create(Comment, as: :c, set: attrs)
|> return([:c])
|> MyApp.Repo.run()
end
# --- Edges: HAS_COMMENT (with optional properties) ---------------------------
edges = [
{"tiago@example.com", "Great article on Elixir!", %{created_at: "2025-06-01T10:00:00Z"}},
{"tiago@example.com", "Neo4j is awesome for graph data", %{created_at: "2025-06-02T14:30:00Z"}},
{"alice@example.com", "Loving the Ex4j DSL", %{created_at: "2025-06-03T09:15:00Z"}},
{"bob@example.com", "Great article on Elixir!", %{}} # no properties
]
for {email, content, props} <- edges do
query()
|> match(User, as: :u, where: %{email: email})
|> match(Comment, as: :c, where: %{content: content})
|> create(HasComment, as: :r, from: :u, to: :c, set: props)
|> return([:r])
|> MyApp.Repo.run()
end
# --- Verify ------------------------------------------------------------------
# All users with their comments
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> return([:u, :r, :c])
|> MyApp.Repo.all()
# Users older than 25
User
|> match(as: :u)
|> where([u], u.age > 25)
|> return([u], [:name, :email])
|> order_by([u], asc: :name)
|> MyApp.Repo.all()
# Bob's comments (should be 1, no properties on the edge)
query()
|> match(User, as: :u)
|> match(Comment, as: :c)
|> edge(HasComment, as: :r, from: :u, to: :c, direction: :out)
|> where([u], u.name == "Bob")
|> return([:c, :r])
|> MyApp.Repo.all()Cleanup
MyApp.Repo.query("MATCH (n) DETACH DELETE n")
MyApp.Repo.query("DROP CONSTRAINT user_email_unique IF EXISTS")
MyApp.Repo.query("DROP INDEX user_name_index IF EXISTS")
MyApp.Repo.query("DROP INDEX comment_content_index IF EXISTS")Architecture
| Module | Responsibility |
|---|---|
Ex4j.Schema | Define node and relationship schemas with labels, fields, and Ecto validation |
Ex4j.Query | Immutable query struct that accumulates clauses via pipe composition |
Ex4j.Query.API |
Public macro DSL (match, where, return, create, edge, etc.) |
Ex4j.Query.Compiler | Compiles Elixir AST into parameterized expression structs |
Ex4j.Cypher |
Generates {cypher_string, params_map} from query structs |
Ex4j.Cypher.Fragment |
Handles raw Cypher fragments with ? parameter binding |
Ex4j.Queryable | Protocol allowing schemas and queries to be used interchangeably |
Ex4j.Repo |
Execution interface (like Ecto.Repo) with all, one, run, query, transaction |
Ex4j.Bolt | Boltx adapter for Neo4j communication |
Ex4j.Changeset | Graph-aware validation extensions |
License
Ex4j source code is released under Apache License 2.0.