Powered by AppSignal & Oban Pro

Managing Neo4j Schema

managing_schema.livemd

Managing Neo4j Schema

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 constraintsAshNeo4j.Constraint (#20, #32)
  • vector indexesAshNeo4j.Vector (#74)
  • (spatial POINT indexes — AshNeo4j.Spatial — follow the same shape; see usage-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
end

Identities → 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)}
end

That’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.