AshNeo4j

Module VersionHex DocsLicenseREUSE statusAsk DeepWiki

Ash DataLayer for Neo4j, configurable using a simple DSL

Installation

Add to the deps:

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

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 edgelabel 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:

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.

I've used Neo4j community edition 4.4 (bolt 4.4) and 5.28 (bolty limits to bolt 5.4) and any version in between should work.

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 shortname Ash Type Module Elixir Type Module Attribute Value Example Neo4j Node Property Value Cypher Example Cypher Type
:atom Ash.Type.Atom Atom :a "a" STRING
:binary Ash.Type.Binary BitString <<1, 2, 3>> "AQID" STRING
:boolean Ash.Type.Boolean Boolean true true BOOLEAN
:ci_string Ash.Type.CiString Ash.CiString Ash.CiString.new("Hello") "Hello" STRING
:date Ash.Type.Date Date ~D[2025-05-11] 2025-05-11 DATE
:datetime Ash.Type.DateTime DateTime ~U[2025-05-11 07:45:41Z] "2025-05-11T07:45:41Z" STRING
:decimal Ash.Type.Decimal Decimal Decimal.new("4.2") "\"4.2\"". STRING
:duration Ash.Type.Duration Duration %Duration{month: 2} PT2H DURATION
:duration_name Ash.Type.DurationName Atom :day "day" STRING
:integer Ash.Type.Integer Integer 1 1 INTEGER
:float Ash.Type.Float Float 1.23456789 1.23456789 FLOAT
:function Ash.Type.Function Function &AshNeo4j.Neo4jHelper.create_node/2 "&AshNeo4j.Neo4jHelper.create_node/2" STRING
subtype_of: :keyword DogKeyword using Ash.Type.NewType DogKeyword [name: "Henry", age: 8, breed: :groodle] "{\"age\":8,\"breed\":\"groodle\",\"name\":\"Henry\"}" STRING
:map Ash.Type.Map Map %{name: "Henry", age: 8, breed: :groodle} "{\"age\":8,\"breed\":\"groodle\",\"name\":\"Henry\"}" STRING
:module Ash.Type.Module Module AshNeo4j.DataLayer "Elixir.AshNeo4j.DataLayer" STRING
:naive_datetime Ash.Type.NaiveDateTime NaiveDateTime ~N[2025-05-11 07:45:41] 2025-05-11T07:45:41 LOCAL_DATETIME
:string Ash.Type.String BitString "hello" "hello" STRING
subtype_of: :struct DogStruct using Ash.Type.NewType DogStruct %DogStruct{name: "Henry", age: 8, breed: :groodle} "{\"age\":8,\"breed\":\"groodle\",\"name\":\"Henry\"}" STRING
:time Ash.Type.Time Time ~T[07:45:41Z] 07:45:41Z TIME
:time_usec Ash.Type.TimeUsec Time ~T[07:45:41.429903Z] 07:45:41.429903000Z TIME
subtype_of: :tuple DogTuple using Ash.Type.NewType Tuple {"Henry", 8, :groodle} "{\"age\":8,\"breed\":\"groodle\",\"name\":\"Henry\"}" STRING
:subtype_of :struct DogTypedStruct using Ash.TypedStruct DogTypedStruct %DogTypedStruct{name: "Henry", age: 8, breed: :groodle} "{\"age\":8,\"breed\":\"groodle\",\"name\":\"Henry\"}" STRING
:union Ash.Type.Union Ash.Union %Ash.Union{type: :typed_struct, value: %Dog{age: 8}} "{\"type\":\"typed_struct\",\"value\":{\"age\":8}}" STRING
:url_encoded_binary Ash.Type.UrlEncodedBinary BitString <<1, 2, 3>> "AQID" STRING
:utc_datetime Ash.Type.UtcDatetime DateTime ~U[2025-05-11 07:45:41Z] "2025-05-11T07:45:41Z" STRING
:utc_datetime_usec Ash.Type.UtcDatetimeUsec DateTime ~U[2025-05-11 07:45:41.429903Z] "2025-05-11T07:45:41.429903000Z" STRING
:uuid Ash.Type.UUID BitString "0274972c-161c-4dc9-882f-6851704c2af9" "0274972c-161c-4dc9-882f-6851704c2af9" STRING
:uuid7 Ash.Type.UUIDv7 BitString "019d85f7-8450-7695-9426-4ede74026140" "019d85f7-8450-7695-9426-4ede74026140" STRING

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. Neo4j doesn't store the timezone, just the offset so timezone information is lost.

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

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

Ash.Type.File, Ash.Type.Term and Ash.Type.Vector are not supported.

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.

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.

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.

Limitations and Future Work

Ash Neo4j has support for Ash create, update, read, destroy actions. 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).

Future work may include: calculations, aggregates, vectors/semantic search, transactions, 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.

Links

Diffo.devNeo4j Deployment Centre.