Mix.install(
[
{:ash_neo4j, "~> 0.10"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false
)What this guide covers
AshNeo4j has three manual DDL surfaces and a deliberate no-migrations-on-boot stance:
- identity uniqueness constraints and primary-key constraints —
AshNeo4j.Constraint(#20, #32) - vector indexes —
AshNeo4j.Vector(#74) - (spatial POINT indexes —
AshNeo4j.Spatial— follow the same shape; seeusage-rules/spatial.md)
This guide is a cohesive, runnable walk-through of creating and maintaining them.
Why manual
AshNeo4j runs no migrations on boot. It never silently mutates your database schema on application start. Instead you call these helpers yourself — typically from a start-up or release task — so schema changes are explicit, reviewable, and under your control.
Every statement uses IF NOT EXISTS / IF EXISTS, so the helpers are idempotent and safe to re-run. The dry-run functions (constraint_statements/1, index_statements/3) return the exact Cypher without touching the database — so the constraint/index sections below are runnable for review even before you connect.
Connecting
Update the configuration for your local Neo4j and evaluate. (Only the live "create / drop / SHOW" cells need a connection — the dry-run cells do not.)
config = [
uri: "bolt://localhost:7687",
auth: [username: "neo4j", password: "password"],
user_agent: "ashNeo4jSchemaHowTo/1",
pool_size: 5,
prefix: :default,
name: Bolt,
versions: [6.0, {5, 6..8}, {5, 0..4}],
log: false
]
AshNeo4j.BoltyHelper.start(config)
AshNeo4j.BoltyHelper.is_connected()Example resources
A small domain with three constraint shapes: a single-attribute identity, a composite primary key, and a vector attribute.
defmodule Schema.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
allow_unregistered? true
end
end
defmodule Schema.Product do
use Ash.Resource, domain: Schema.Domain, data_layer: AshNeo4j.DataLayer
neo4j do
label :Product
end
attributes do
uuid_primary_key :id
attribute :sku, :string, allow_nil?: false, public?: true
attribute :name, :string, public?: true
end
identities do
# a single-attribute identity → one uniqueness constraint
identity :unique_sku, [:sku]
end
end
defmodule Schema.Listing do
use Ash.Resource, domain: Schema.Domain, data_layer: AshNeo4j.DataLayer
neo4j do
label :Listing
end
attributes do
# a composite primary key → one composite IS UNIQUE constraint
attribute :marketplace, :string, allow_nil?: false, primary_key?: true, public?: true
attribute :sku, :string, allow_nil?: false, primary_key?: true, public?: true
attribute :price, :integer, public?: true
end
end
defmodule Schema.Note do
use Ash.Resource, domain: Schema.Domain, data_layer: AshNeo4j.DataLayer
neo4j do
label :Note
end
attributes do
uuid_primary_key :id
attribute :body, :string, public?: true
# a vector attribute → a VECTOR index (Cypher 25 / Neo4j ≥ 2025.06)
attribute :embedding, AshNeo4j.Type.Vector,
constraints: [element_type: :float32, dimensions: 1536],
public?: true
end
endIdentities → uniqueness constraints (#20)
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. A conflicting create surfaces as Ash's own Ash.Error.Changes.InvalidAttribute ("has already been taken").
Inspect the Cypher first (no database needed):
AshNeo4j.Constraint.constraint_statements(Schema.Product)
#=> {:ok, ["CREATE CONSTRAINT product_pk IF NOT EXISTS FOR (n:Product) REQUIRE n.uuid IS UNIQUE",
# "CREATE CONSTRAINT product_unique_sku IF NOT EXISTS FOR (n:Product) REQUIRE n.sku IS UNIQUE"]}Then create them (this one touches the database):
AshNeo4j.Constraint.create_constraints(Schema.Product)
#=> {:ok, [%Bolty.Response{}, ...]} # one per constraint; safe to re-run (IF NOT EXISTS)Constraint names are derived: <label_lower>_<identity_name> for an identity (product_unique_sku), and <label_lower>_pk for the primary key (product_pk).
Composite primary keys (#32)
The resource's primary key also gets a uniqueness constraint — composite keys included, on Neo4j Community Edition. Schema.Listing has a composite [:marketplace, :sku] primary key:
AshNeo4j.Constraint.constraint_statements(Schema.Listing)
#=> {:ok, ["CREATE CONSTRAINT listing_pk IF NOT EXISTS FOR (n:Listing) REQUIRE (n.marketplace, n.sku) IS UNIQUE"]}The primary-key constraint is deduped: if an identity already constrains the same attribute set, no redundant _pk constraint is created. Primary-key attributes are always required, so the constraint is always enforceable.
AshNeo4j.Constraint.create_constraints(Schema.Listing)What's refused (not silently skipped)
Some identities cannot be enforced as a Neo4j uniqueness constraint:
nils_distinct?: false— Neo4j treats nulls as always-distinct in a uniqueness constraint- a filtered identity (
where:) — a constraint can't carry a predicate
Rather than silently leave such an identity unenforced (and permit the duplicates the constraint exists to prevent), AshNeo4j refuses — returning {:error, %AshNeo4j.Error.UnsupportedIdentity{}} and creating nothing for that resource (all-or-nothing). The same cases are rejected at compile time by AshNeo4j.Verifiers.VerifyIdentities, so you find out when you define the resource, not in production.
defmodule Schema.Loose do
use Ash.Resource, domain: Schema.Domain, data_layer: AshNeo4j.DataLayer
neo4j do
label :Loose
end
attributes do
uuid_primary_key :id
attribute :email, :string, public?: true
end
identities do
identity :unique_email, [:email], nils_distinct?: false
end
end
AshNeo4j.Constraint.constraint_statements(Schema.Loose)
#=> {:error, %AshNeo4j.Error.UnsupportedIdentity{reason: :nils_not_distinct, ...}}Vector indexes (#74)
A vector attribute is searchable without an index (a full scan), but an HNSW VECTOR index is what makes similarity search scale. Like constraints, you create it yourself. It requires Cypher 25 (Neo4j ≥ 2025.06).
Dimensions and element type come from the attribute's constraints; the index options control naming, recreation, and the similarity function.
AshNeo4j.Vector.index_statements(Schema.Note, :embedding)
#=> {:ok, "CREATE VECTOR INDEX note_embedding_vector IF NOT EXISTS FOR (n:Note) ON (n.embedding) " <>
# "OPTIONS {indexConfig: {`vector.dimensions`: 1536, `vector.similarity_function`: 'cosine'}}"}# create it (Cypher 25 required)
AshNeo4j.Vector.create_index(Schema.Note, :embedding)
# changed :dimensions or :similarity_function? recreate drops then re-creates:
AshNeo4j.Vector.create_index(Schema.Note, :embedding, recreate: true, similarity_function: :euclidean)Maintaining
Inspect what's actually on the server with native Cypher via the data layer's pool:
{:ok, constraints} = AshNeo4j.Cypher.run("SHOW CONSTRAINTS")
{:ok, indexes} = AshNeo4j.Cypher.run("SHOW INDEXES")
{constraints, indexes}Drop when a resource's schema changes (both use IF EXISTS, so they're no-ops when absent):
AshNeo4j.Constraint.drop_constraints(Schema.Product)
AshNeo4j.Vector.drop_index(Schema.Note, :embedding)A typical release task creates the schema for every resource on deploy — idempotent, so it's safe on every release:
for resource <- [Schema.Product, Schema.Listing] do
{resource, AshNeo4j.Constraint.create_constraints(resource)}
endThat's the whole schema surface: declare constraints and indexes in code, inspect the Cypher with the dry-run helpers, create them from a release task, and re-run freely. No migrations on boot, nothing implicit.