ash_age

Run in Livebook

An Ash Framework data layer for Apache AGE — model your Ash resources as a property graph (vertices and edges) inside the PostgreSQL you already run.

Apache AGE adds openCypher graph queries to PostgreSQL. ash_age maps Ash resources onto it: a resource becomes a labeled vertex, its relationships become real graph edges, and reachability questions become bounded traversals — all behind the full Ash stack (actions, changes, policies, multitenancy, code interfaces) and running on your existing Ecto.Repo. There is no separate graph database to deploy or operate.

Why ash_age

Capabilities

AreaWhat you get
CRUD & bulkResources as labeled vertices; Ash.bulk_create via UNWIND; single and composite primary keys
EdgesCreate/destroy graph edges from actions, edge properties, incoming / outgoing / undirected
TraversalBounded variable-length traversal as an Ash manual relationship — per-source dedup, cardinality-aware
MultitenancyBoth Ash strategies — :attribute (one tenant-filtered graph) and :context (graph-per-tenant) — plus opt-in DB-enforced Row-Level Security
Sensitive dataFail-closed classification verifiers; deterministic-encryption equality search on ciphertext
Filteringeq / not_eq / in / is_nil, ranges, boolean and nested expressions, sort, limit, offset
Raw CypherAshAge.cypher/5 parameterized escape hatch for queries the DSL can't express
TelemetryA :telemetry span on every operation, with metadata guaranteed free of values and secrets

1.0 — the public API is stable and follows semantic versioning. Upgrading from 0.2.x? See the CHANGELOG "Upgrading from 0.2.x" notes.

Installation

Add to your mix.exs:

def deps do
[
{:ash_age, "~> 1.0"}
]
end

Compatibility

Tested against Apache AGE 1.6.0 on PostgreSQL 16 (the apache/age:release_PG16_1.6.0 image, pinned by digest in CI). Other PostgreSQL majors with a matching AGE build are expected to work but are not covered by CI.

Usage

The Quick Start below gets a resource reading and writing to a graph. For the complete reference, see the DSL reference, usage-rules.md (the full behavioral contract), and the troubleshooting guide.

Quick Start

  1. Ensure Apache AGE extension is installed in PostgreSQL

  2. Register Postgrex types for AGE's agtype:

# Postgrex.Types.define/3 defines the module itself — call it at the top level
# of the file (no `defmodule` wrapper of the same name).
Postgrex.Types.define(
MyApp.PostgrexTypes,
[AshAge.Postgrex.AgtypeExtension] ++ Ecto.Adapters.Postgres.extensions(),
[]
)
  1. Configure your Repo with the AGE session hook and types module:
config :my_app, MyApp.Repo,
after_connect: {AshAge.Session, :setup, []},
types: MyApp.PostgrexTypes

This sets search_path to public, ag_catalog, "$user" and loads the AGE extension on each connection. (public must be first to prevent shadowing Ecto's schema_migrations table.)

  1. Create an AGE graph via migration:
defmodule MyApp.Repo.Migrations.CreateAgeGraph do
use Ecto.Migration
import AshAge.Migration
def up do
create_age_graph("my_graph")
create_vertex_label("my_graph", "Entity")
end
def down do
drop_age_graph("my_graph")
end
end
  1. Define Ash resources using AshAge.DataLayer:
defmodule MyApp.MyEntity do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshAge.DataLayer
age do
graph :my_graph
repo MyApp.Repo
label :Entity
end
attributes do
uuid_primary_key :id
attribute :tenant_id, :uuid, allow_nil?: false
attribute :label, :string, allow_nil?: false
attribute :properties, :map, default: %{}
end
actions do
defaults [:read, :create, :update, :destroy]
end
end

Edges

Graph edges connect vertices. Define them in the age block:

age do
graph :my_graph
repo MyApp.Repo
edge :related_to do
label :RELATES_TO
direction :outgoing
destination MyApp.RelatedEntity
properties [:weight]
end
end

Create and destroy edges via changes:

actions do
create :create_with_relation do
argument :related_id, :uuid
change {AshAge.Changes.CreateEdge, edge: :related_to, to: :related_id}
end
destroy :remove_relation do
argument :related_id, :uuid
change {AshAge.Changes.DestroyEdge, edge: :related_to, to: :related_id}
end
end

Edges are atomic with their source vertex write, tenant-isolated, and fail closed on endpoint not found. Edge property values come from same-named action arguments. See usage-rules.md for constraints (single-PK destinations) and direction semantics.

Traversal

Bounded variable-length graph traversal is an Ash manual relationship via AshAge.ManualRelationships.Traverse:

has_many :descendants, MyApp.Node do
manual {AshAge.ManualRelationships.Traverse,
edge_label: :LINK, direction: :outgoing, min_depth: 1, max_depth: 3}
end

direction may be :outgoing, :incoming, or :both (undirected). max_depth is required and bounded (unbounded * is forbidden). Loading yields a source-PK-keyed map of destination records, deduped per source and cardinality-aware, with single or composite primary keys. Tenancy is fail-closed: :context scopes to the per-tenant graph, and :attribute scopes every node on the path by the tenant discriminator (via a fixed-length UNION expansion, since this AGE build lacks ALL(nodes(p))). See usage-rules.md for options and telemetry.

Raw Cypher

For queries the DSL can't express, AshAge.cypher/5 runs parameterized Cypher and decodes each cell:

AshAge.cypher(MyApp.Repo, "my_graph",
"MATCH (n:Person)-[:KNOWS*1..2]->(m) WHERE n.id = $id RETURN m",
%{"id" => person_id},
[{:m, :agtype}])
#=> {:ok, [%{m: %AshAge.Type.Vertex{...}}, ...]}

Values reach AGE only as $ params; the graph name is identifier-checked. Each cell decodes to a %Vertex{}/Edge{}/Path{} or a scalar; a bare aggregate (collect(n)) returns as its raw agtype string (use UNWIND). The graph you pass is the tenant isolation boundary. See usage-rules.md for the full contract.

Bulk Create

Ash.bulk_create is now supported via UNWIND grouping. Rows are grouped by their key-set so sparse rows don't null-fill to match others. Record order is preserved, and failures are atomic per batch. See usage-rules.md for transaction semantics and tenant handling.

Multitenancy

Both Ash strategies are supported. :attribute (one graph, tenant-filtered) works through Ash core — just declare multitenancy do strategy :attribute; attribute :org_id end (don't list the attribute in age do skip or an action's accept). :context gives graph-per-tenant physical isolation: declare strategy :context, then provision each tenant's graph up front —

graph = AshAge.tenant_graph(MyApp.Entity, tenant)
AshAge.Migration.provision_tenant(MyApp.Repo, graph, vlabels: ["Entity"])

Tenant/policy filters are enforced on update/destroy (not just reads). See usage-rules.md for the graph-name encoder, the tenant_graph MFA override, and strategy trade-offs.

For :attribute resources, an opt-in rls_guc option adds PostgreSQL Row-Level Security as a DB-enforced read-confidentiality backstop beneath the app-layer tenant filter (enabled via AshAge.Migration.enable_tenant_rls/2). It is read/target-side only — AGE cypher() CREATE bypasses WITH CHECK — and requires the app's DB role to be a non-superuser without BYPASSRLS. See the "Multitenancy — DB-enforced RLS" section of usage-rules.md for the full contract.

Sensitive Data

Classify attributes whose plaintext must never reach the graph; store app-side-encrypted bytes (AshCloak/Cloak) in :binary attributes:

age do
graph :my_graph
repo MyApp.Repo
sensitive [:ssn] # verifier-enforced: binary-storage-typed or skipped
end

Deterministic ciphertext is equality-searchable (eq/not_eq/in — ash_age encodes filter values to the same encoded form it stores); range filters and sort on binary attributes are rejected rather than silently wrong. Erasure is DETACH DELETE; crypto-shred means destroying the app-side key. Verifier errors surface as compiler diagnostics (Spark-wide behavior) — build with --warnings-as-errors to make them blocking. See usage-rules.md "Sensitive Data" for the full guidance (AshPaperTrail, maps, migration notes).

Telemetry

Every data-layer operation emits a :telemetry.span:

[:ash_age, :read | :create | :bulk_create | :update | :destroy | :create_edge | :destroy_edge | :traverse | :cypher, :start | :stop | :exception]
:telemetry.attach(
"ash-age",
[:ash_age, :create, :stop],
fn _event, %{duration: d}, meta, _ -> IO.inspect({d, meta.result}) end,
nil
)

Metadata is value-free — schema identifiers, counts, booleans, and DSL enums only (resource, multitenancy, tenant?, result, row_count, direction, …); never a PK/property value, error reason, Cypher, or the tenant-derived graph name. See usage-rules.md for the full per-op metadata catalog.

Mix Tasks

Development

cd ash_age
mix deps.get
mix test
mix format
mix credo --strict

Documentation

License

MIT