Skip to main content

documentation/how_to/managing_schema.livemd

<!--
SPDX-FileCopyrightText: 2026 ash_neo4j contributors <https://github.com/diffo-dev/ash_neo4j/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Managing Neo4j Schema

```elixir
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; 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.)

```elixir
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.

```elixir
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):

```elixir
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):

```elixir
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:

```elixir
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.

```elixir
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.

```elixir
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.

```elixir
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'}}"}
```

```elixir
# 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:

```elixir
{: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):

```elixir
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:

```elixir
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.