AshNeo4j

Module VersionHex DocsLicenseREUSE statusAsk DeepWiki

Ash DataLayer for Neo4j, configurable using a simple DSL

Installation

mix igniter.install ash_neo4j

This automatically configures the formatter, adds Bolty connection config to config/runtime.exs, and wires Bolty into your supervision tree.

Manual

Add to deps in mix.exs:

def deps do
[
{:ash_neo4j, "~> 0.9"},
]
end

Then follow the Bolty configuration steps below.

AI Coding Assistants

AshNeo4j ships usage rules for AI coding assistants. If your project uses usage_rules, add ash_neo4j to your :usage_rules config and run mix usage_rules.sync to merge the rules into your AGENTS.md (or CLAUDE.md).

Tutorial

To get started you need a running instance of Livebook

Run in Livebook

Usage

Configure AshNeo4j.DataLayer as data_layer: within use Ash.Resource options:

use Ash.Resource,
data_layer: AshNeo4j.DataLayer

Configuration

Each Ash.Resource allows configuration of its AshNeo4j.DataLayer. An example Comment resource is given below, it can belong to a Post resource. The neo4j configuration block below is actually unnecessary as written.

defmodule Blog.Comment do
use Ash.Resource,
data_layer: AshNeo4j.DataLayer
neo4j do
label :Comment
relate [{:post, :BELONGS_TO, :outgoing, :Post}]
end
actions do
default_accept :*
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :title, :string, public?: true
attribute :date_created, :date, source: :dateCreated
end
relationships do
belongs_to :post, Post, public?: true
end
end

Label

The DSL may be used to label the Ash Resource's underlying graph node. If omitted the Ash Resource's short module name will be used.

neo4j do
label :Comment
end

Relate

The DSL may be used to specifically direct any relationship, in the form {relationship_name, edge_label, edge_direction, destination_label}. An entry can be provided for any relationship to override the default values created by AshNeo4j.

neo4j do
relate [{:post, :BELONGS_TO, :outgoing, :Post}]
end

Default relate clauses are always :outgoing from the source resource, and the edge_label is derived from the Ash relationship type. Relate clauses, whether specific or default must be unique {_, edge_label, edge_direction, destination_label} for a given source_label to allow determination of the source relationship.

Guard

The DSL may be used to guard destroy actions, in the form {edge_label, edge_direction, destination_label}. By default incoming allow_nil? false belongs_to are guarded against deletion while relationships exist. Guards can be created independently of explicit relationships.

neo4j do
guard [{:WRITTEN_BY, :outgoing, :Post}]
end

Guard is useful where the resource has no explicit relationships, but other resources expect the resource to exist while they are related. Guard can also be used where the underlying node has other edges which should prevent resource destruction.

Skip

The DSL may be used to skip storing attributes as node properties. This can be useful for 'transient' attributes, or attributes you want to default using the resource but not store explicitly.

neo4j do
skip [:other_id]
end

Translate

Translation of resource attributes to/from Neo4j node properties is done without explicit Ash Neo4j DSL.

For convenience Ash Neo4j translates attributes with underscores to camelCase Neo4j properties. Neo4j uses the node property 'id' internally, so Ash Neo4j will translate the 'id' attribute using the camelCased short name of the type, e.g. an 'id' attribute of :uuid type is translated to the 'uuid' node property.

Ash Neo4j also supports the source field in Ash.Resource.Attribute DSL - if present this will be used for the node property.

Verifiers

The DSL is verified against misconfiguration and violation of accepted neo4j conventions providing compile time errors:

Testing

AshNeo4j.Sandbox provides test isolation analogous to Ecto.Adapters.SQL.Sandbox. Each test that calls checkout/0 gets a dedicated Neo4j connection with an open transaction. All queries from that test run inside the transaction, which is rolled back automatically when the test process exits. Nothing is ever committed, so there is no data to clean up and tests can safely run in parallel.

Setup

Replace any Neo4jHelper.delete_all() or Neo4jHelper.delete_nodes/1 teardown with a sandbox checkout:

setup_all do
AshNeo4j.BoltyHelper.start()
end
setup do
AshNeo4j.Sandbox.checkout()
on_exit(&AshNeo4j.Sandbox.rollback/0)
end

The on_exit call is optional — the transaction is rolled back automatically when the test process exits — but is recommended for clarity.

Placing a node in a world

A node's label set is what AshNeo4j.worlds/1 resolves to a (domain, resource) world, so polymorphic / open-world tests sometimes need a node whose labels differ from what an Ash create produces. AshNeo4j.Neo4jHelper.update_node_labels/4 adds and/or removes labels on an existing node — create the node normally via Ash, then mutate its labels:

place = Ash.create!(Place, %{name: "Sydney"})
# strip the domain label so worlds/1 can no longer resolve this node to a world
AshNeo4j.Neo4jHelper.update_node_labels(:Place, %{name: "Sydney"}, [], [:SRM])

This is a test/maintenance helper — the data layer sets labels at create time and never mutates them on the normal CRUD path — so it lives in Neo4jHelper alongside the other raw-Cypher helpers (create_node/2, relate_nodes/6, …) rather than on a resource action.

Parallel tests

Because each test's writes are confined to an uncommitted transaction, tests can run concurrently without interfering:

use ExUnit.Case, async: true
setup do
AshNeo4j.Sandbox.checkout()
on_exit(&AshNeo4j.Sandbox.rollback/0)
end

Targeting a second Neo4j (pool routing)

The data layer talks to a configurable Bolty pool — AshNeo4j.BoltyHelper.current_pool/0, defaulting to Bolt. Override it per-process with with_pool/2 (or Process.put(:ash_neo4j_pool, Pool)) to route a test's queries — and the cypher25?/1 / policy/1 capability checks — to a different server. AshNeo4j's own suite uses this to run Cypher-25 vector tests against a Neo4j 2026.05 pool (Bolt6) while the rest of the suite stays on a 5.x pool; those tests are tagged :cypher25 and excluded by default. Start a long-lived pool from test_helper.exs (not a per-test setup) — Bolty.start_link/1 links the pool to the calling process, so starting it inside a test ties the pool's lifetime to that one test. See usage-rules/vectors.md.

Running the suite

The suite needs a Neo4j at bolt://localhost:7687 (neo4j / password, the Bolt block in config/test.exs). The :cypher25 / :bolt6 tests — excluded by default — additionally need a Neo4j ≥ 2025.06 at bolt://localhost:7689 (the Bolt6 block), and the :apoc test needs a Neo4j + APOC at bolt://localhost:7691 (the BoltApoc block). The bundled docker-compose.yml brings all three up (community edition is deliberate — it runs everything the suite needs, including vector search and Cypher 25; APOC is on its own opt-in server since it's not part of the default surface):

docker compose up -d --wait # bolt5 → 7687, bolt6 → 7689, apoc → 7691
mix test # default suite (7687 only)
mix test --include cypher25 --include bolt6 --include apoc # full suite
docker compose down # tear down

Elixir/Erlang versions are pinned in .tool-versions (read by mise or asdf): mise install (or asdf install).

Installing Neo4j and Configuring Bolty

ash_neo4j uses neo4j which must be installed and running.

ash_neo4j uses bolty, a reluctant fork of boltx

Your Ash application needs to configure, start and supervise bolty see bolty documentation. Make sure to configure any required authorisation.

Tested against Neo4j 5.26.x community (Bolt 5.x) and the calendar-versioned Neo4j 2026.05 community (Bolt 6.0), as well as DozerDB 5.26.x with multi-database. bolty ~> 0.1.0 negotiates Bolt 5.6–6.0 and drops the older Bolt 1–4.x protocols; Neo4j 4.x / Bolt 4.x are not supported.

Cypher 25 and Cypher 5

Neo4j 2025.06 introduced versioned Cypher: the long-standing language is now Cypher 5 (the default on Neo4j 5.x and on 2025.x servers), and Cypher 25 is the new calendar-versioned language available from Neo4j 2025.06 onward. The two coexist on a 2025.06+ server and are selected per-query with a leading CYPHER 5 / CYPHER 25 clause.

AshNeo4j detects the connected server version (from Bolty.connection_info/1's server_version) and, on Neo4j ≥ 2025.06, automatically prepends CYPHER 25 to every query so it runs against the Cypher 25 language. On older servers no prefix is emitted and queries run against the server default (Cypher 5). The result is cached per pool; AshNeo4j.BoltyHelper.cypher25?/0 reports it.

This is distinct from the Bolt protocol version (5.6–6.0) — the Bolt version is how the driver talks to the server, while Cypher 5 / 25 is the query language version. Some features require Cypher 25 regardless of Bolt version: for example vector similarity search (see usage-rules/vectors.md) needs Neo4j ≥ 2025.06 but works over Bolt 5.8. A feature that requires it calls AshNeo4j.Cypher.require_cypher25!/0, which raises AshNeo4j.Error.RequiresCypher25 on an older server.

Until bolty#47 adds a cypher25 indicator to Bolty.Policy, AshNeo4j derives this from the server_version string ("Neo4j/YYYY.MM.*"2025.06).

Elixir, Ash and Neo4j Types

We've made some decisions around how Ash/Elixir types are used to persist attributes as Neo4j properties. Where possible we've used Ash.Type.dump_to_native/cast_stored and 'native' Neo4j types, in many cases encoding to ISO8601, JSON or Base64 strings.

Ash Type shortnameAsh Type ModuleElixir Type ModuleAttribute Value ExampleNeo4j Node Property Value Cypher ExampleCypher Type
:atomAsh.Type.AtomAtom:a"a"STRING
:binaryAsh.Type.BinaryBitString<<1, 2, 3>>"AQID"STRING
:booleanAsh.Type.BooleanBooleantruetrueBOOLEAN
:ci_stringAsh.Type.CiStringAsh.CiStringAsh.CiString.new("Hello")"Hello"STRING
:dateAsh.Type.DateDate~D[2025-05-11]2025-05-11DATE
:datetimeAsh.Type.DateTimeDateTime~U[2025-05-11 07:45:41Z]2025-05-11T07:45:41ZDATETIME
:decimalAsh.Type.DecimalDecimalDecimal.new("4.2")""4.2""STRING
:durationAsh.Type.DurationDuration%Duration{month: 2}PT2HDURATION
:duration_nameAsh.Type.DurationNameAtom:day"day"STRING
:integerAsh.Type.IntegerInteger11INTEGER
:floatAsh.Type.FloatFloat1.234567891.23456789FLOAT
:functionAsh.Type.FunctionFunction&AshNeo4j.Neo4jHelper.create_node/2"&AshNeo4j.Neo4jHelper.create_node/2"STRING
subtype_of: :keywordDogKeyword using Ash.Type.NewTypeDogKeyword[name: "Henry", age: 8, breed: :groodle]"{"age":8,"breed":"groodle","name":"Henry"}"STRING
:mapAsh.Type.MapMap%{name: "Henry", age: 8, breed: :groodle}"{"age":8,"breed":"groodle","name":"Henry"}"STRING
:moduleAsh.Type.ModuleModuleAshNeo4j.DataLayer"Elixir.AshNeo4j.DataLayer"STRING
:naive_datetimeAsh.Type.NaiveDateTimeNaiveDateTime~N[2025-05-11 07:45:41]2025-05-11T07:45:41LOCAL_DATETIME
:stringAsh.Type.StringBitString"hello""hello"STRING
subtype_of: :structDogStruct using Ash.Type.NewTypeDogStruct%DogStruct{name: "Henry", age: 8, breed: :groodle}"{"age":8,"breed":"groodle","name":"Henry"}"STRING
:timeAsh.Type.TimeTime~T[07:45:41Z]07:45:41ZTIME
:time_usecAsh.Type.TimeUsecTime~T[07:45:41.429903Z]07:45:41.429903000ZTIME
subtype_of: :tupleDogTuple using Ash.Type.NewTypeTuple{"Henry", 8, :groodle}"{"age":8,"breed":"groodle","name":"Henry"}"STRING
:subtype_of :structDogTypedStruct using Ash.TypedStructDogTypedStruct%DogTypedStruct{name: "Henry", age: 8, breed: :groodle}"{"age":8,"breed":"groodle","name":"Henry"}"STRING
:unionAsh.Type.UnionAsh.Union%Ash.Union{type: :typed_struct, value: %Dog{age: 8}}"{"type":"typed_struct","value":{"age":8}}"STRING
:url_encoded_binaryAsh.Type.UrlEncodedBinaryBitString<<1, 2, 3>>"AQID"STRING
:utc_datetimeAsh.Type.UtcDatetimeDateTime~U[2025-05-11 07:45:41Z]2025-05-11T07:45:41ZDATETIME
:utc_datetime_usecAsh.Type.UtcDatetimeUsecDateTime~U[2025-05-11 07:45:41.429903Z]2025-05-11T07:45:41.429903000Z.DATETIME
:uuidAsh.Type.UUIDBitString"0274972c-161c-4dc9-882f-6851704c2af9""0274972c-161c-4dc9-882f-6851704c2af9"STRING
:uuid7Ash.Type.UUIDv7BitString"019d85f7-8450-7695-9426-4ede74026140""019d85f7-8450-7695-9426-4ede74026140"STRING
(vector embedding)AshNeo4j.Type.VectorList[0.12, -0.04, 0.98][0.12, -0.04, 0.98]LIST

Ash :date, :datetime, :time and :naive_datetime are second precision, whereas :utc_datetime_usec and :time_usec are microsecond precision. Neo4j is capable of nanoseconds however Ash/Elixir is not.

Struct is supported, however must implement Ash.Type. Ash arrays are supported as arrays in neo4j.

Ash.Type.NewType including Ash.TypedStruct are supported, as are embedded resources.

Ash.Type.File and Ash.Type.Term are not supported. The built-in Ash.Type.Vector is also not supported — AshNeo4j ships its own AshNeo4j.Type.Vector for embeddings (stored as a Neo4j LIST<FLOAT>), with vector_similarity / vector_cosine_distance search expressions. See usage-rules/vectors.md.

Storage Types

Generally AshNeo4j uses Ash.Type.dump_to_native and Ash.Type.cast_stored. Post/prior to this we may encode/decode either as JSON or Base64.

Ash.Type.Keyword, Ash.Type.Map, Ash.Type.Struct, Ash.Type.Tuple and Ash.Type.Union are stored as JSON. Ash.Type that have storage type map and aren't built in are also stored as JSON. This covers TypedStruct, embedded resources and Ash.Type.NewType you create subtype_of keyword, map, struct, tuple or union.

JSON types are stored as maps. We encode with AshNeo4j.Util.json_encode, which erases Struct's and orders keys. It deliberately avoids using Jason.Encoder on structs other than those it has converted to Jason.OrderedObject. This means you are free to use Jason.Encoder (possibly via ash_jason) for other concerns such as presentation or communications.

Interestingly many Ash.Types have identical JSON representations (e.g. Map, Struct, Tuple, Keyword). Neo4j lists are used for arrays since JSON and Base64 are strings.

A few things to note:

Keys

We've generally used :uuid_primary_key, which Ash creates. While it may be possible to use other types for primary keys, we haven't done so yet.

Identities

An Ash identity is enforced at the database level with a Neo4j uniqueness constraint, so you don't need pre_check? (and its race window). Create the constraints yourself — like vector indexes, AshNeo4j runs no migrations on boot — with AshNeo4j.Constraint.create_constraints/1 (single and composite identities are both supported on Community Edition). A conflicting create surfaces as Ash's own Ash.Error.Changes.InvalidAttribute ("has already been taken"), in Ash terms. Identities Neo4j can't enforce (nils_distinct?: false, or a filtered where:) are refused — at compile time and by the helper — rather than silently left unenforced. See usage-rules/identities.md.

Elixir nil and Neo4j Null

Generally attributes with nil value are not persisted, rather they are simply not created or removed on update to nil.

Other Notable

Transactions are supported.

Aggregates

AshNeo4j supports Ash aggregates. Declare them in the standard Ash aggregates block:

aggregates do
count :comment_count, :comments
exists :has_comments, :comments
sum :total_score, :comments, field: :score
avg :avg_score, :comments, field: :score
min :min_score, :comments, field: :score
max :max_score, :comments, field: :score
first :first_comment_title, :comments, field: :title
list :comment_titles, :comments, field: :title
end

Supported kinds: :count, :exists, :sum, :avg, :min, :max, :first, :list. The :custom kind is not supported.

Aggregates are computed in Cypher via OPTIONAL MATCH traversal. Single-hop and multi-hop relationship paths are both supported.

Embedded struct and JSON-type fields are supported. When field: refers to an attribute stored as JSON — Ash.TypedStruct, Ash.Type.NewType with map storage, embedded resources, Ash.Type.Map, Ash.Type.Union, etc. — AshNeo4j collects the raw JSON strings from Neo4j and deserializes them in Elixir using Ash.Type.cast_stored/3. :list and :first aggregates return fully deserialized struct values. :sum, :avg, :min, :max work when the deserialized values are directly comparable/numeric. To aggregate a sub-field within a struct, use an expr: aggregate.

aggregates do
list :all_metadata, :related_things, field: :metadata # returns [%MetadataStruct{}, ...]
first :first_metadata, :related_things, field: :metadata # returns %MetadataStruct{}
end
# No elevation needed — navigate into the struct with an expression aggregate:
Ash.aggregate(MyResource, {:total_bandwidth, :sum, [
path: [:characteristics],
expr: Ash.Expr.expr(get_path(value, [:bandwidth])),
expr_type: :integer
]})

For expr: aggregates, AshNeo4j fetches full destination records, evaluates the Ash expression on each in Elixir, and aggregates the results. Any valid Ash expression works — get_path for nested struct navigation, arithmetic, etc. Note: expr: is a programmatic API and is not available in the resource-level aggregates do DSL block.

Calculations

AshNeo4j supports expression calculations — calculations declared with expr(...) in the calculations block. They are evaluated in Elixir after records are loaded from Neo4j.

calculations do
calculate :score_doubled, :integer, expr(score * 2)
calculate :full_name, :string, expr(first_name <> " " <> last_name)
calculate :dog_age, :integer, expr(get_path(dog, [:age]))
end

Calculations can be:

Embedded struct fields work without elevation.get_path(dog, [:age]) navigates into a DogTypedStruct directly — records arrive with embedded types fully deserialized, so any Ash expression that works in-memory works in a calculation.

Only expr(...) calculations are currently supported. Custom :calculate callback modules are not.

Graph Traversal Expressions

traverse(^hop_chain, projection) expresses a multi-hop graph traversal as an Ash.Expr value and pushes it down to a single Cypher path pattern — so a multi-hop reach composes inside a filter instead of being an imperative load-time walk. A relational data layer models relationships as joins and has no notion of a path as an expression value; this is something a graph data layer can offer that the relational ones structurally cannot.

require Ash.Query
import Ash.Expr
chain = [{:forward, :posts}] # {:forward | :reverse, relationship_or_edge}
# reached-node field comparison
Author |> Ash.Query.filter(traverse(^chain, :score) > 50) |> Ash.read!()
# compose with spatial — "services within 5 km of their site", one query
Service |> Ash.Query.filter(st_dwithin(traverse(^chain, :location), ^point, 5_000)) |> Ash.read!()
# membership / cardinality / aggregate over the reached set
Service |> Ash.Query.filter(traverse(^chain, :exists) == true)
Party |> Ash.Query.filter(traverse(^chain, {:max, :population}) <= 5_200_000)

traverse is pushdown-only (it needs the graph) and lands in filter first — sort, calculate/policy and variable-length are tracked on epic #321. To return a reached value rather than filter on it, use AshNeo4j.Calculations.ProjectedTraversal, which late-binds the reached node's type and yields AshNeo4j.Unknown when it can't be determined. See usage-rules/traverse.md.

Atomic and Bulk Writes

AshNeo4j renders Ash atomics straight to Cypher — the new value is computed by the database in a SET, with no read-modify-write round trip. Ash.bulk_update / Ash.bulk_destroy with strategy: :atomic run as a single update_query / destroy_query; a create-or-update keyed on an identity renders an atomic MERGE so concurrent upserts converge on one node. A single filtered (optimistic-lock) update or destroy whose guard no longer holds returns Ash.Error.Changes.StaleRecord rather than a silent no-op, so lost updates are observable. See usage-rules/atomics.md.

Cypher Fragments

fragment(...) is a filter escape hatch — embed a snippet of raw Cypher in a filter for a condition AshNeo4j doesn't push down (e.g. an APOC function), so the read stays a normal Ash query instead of being hand-written as a raw Cypher query (and losing authorization, the resource model and composability). ? arguments are bound safely: an attribute reference renders to s.<property>, a literal to a $param. The fragment must be the whole filter, and arguments must be attribute references or literals — anything else is refused (AshNeo4j.Error.UnsupportedFilterFragment), never silently dropped. (This is the expressionfragment/N, not an Ash resource fragment.) See usage-rules/cypher-fragments.md.

Limitations and Future Work

Ash Neo4j has support for Ash create, update, read, destroy actions (including atomic and bulk writes with optimistic locking), aggregates, expression calculations, graph traversal expressions, spatial types, and vector embeddings. The cypher is now parameterised but is by no means optimised. The DSL is likely to evolve further and this may break back compatibility. Storage formats are subject to infrequent change so upgrade may require data migration (not included).

Vector similarity search is currently a full scan — Neo4j does not use the HNSW vector index for vector.similarity.cosine in a WHERE/ORDER BY. Indexed top-K (via db.index.vector.queryNodes / the Cypher 25 SEARCH clause) is tracked in #297.

Future work may include: cached calculations and aggregates, indexed vector/semantic search (#297), and broader geospatial support.

Collaboration on ash_neo4j welcome via github, please use discussions and/or raise issues as you encounter them. If going straight for a PR, please include explanation and test cases.

Acknowledgements

Thanks to the Ash Core for ash 🚀, including ash_csv which was an exemplar.

Thanks to Sagastume for boltx which was based on bolt_sips by Florin Patrascu.

Thanks to the Neo4j Core for neo4j and pioneering work on graph databases. Thanks to DozerDB for enterprise features on community neo4j.

Diffo.devNeo4j Deployment Centre.