Setup
ash_age is an Ash data layer for
Apache AGE: your Ash resources become labelled graph
vertices, relationships become real graph edges, and traversals run inside the
PostgreSQL you already operate.
This notebook needs a running PostgreSQL + Apache AGE instance. The quickest way is Docker:
docker run -d --name ash_age \
-p 5462:5432 \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=ash_age_test \
apache/age:release_PG16_1.6.0The container publishes Postgres on host port 5462, not the default 5432 —
a local Postgres almost always already owns 5432, and a clash there gives you a
misleading connection not available / pool-timeout error (the notebook ends up
talking to your local Postgres, which has no AGE extension).
The notebook connects to postgres://postgres:postgres@localhost:5462/ash_age_test
by default. Want a different port? Change the -p <host-port>:5432 mapping in
the docker run above and the matching port in the "Start a Repo" cell below — or,
to override the whole URL without editing a cell, set the AGE_DATABASE_URL
environment variable before launching Livebook. If a cell fails with a connection
error, AGE isn't reachable at that URL — check the container is up and the ports
line up.
Re-running a cell reuses the already-started database process. If you change the connection settings, reconnect the runtime (Runtime → Reconnect) so the new settings take effect.
The setup cells (repo start,
CREATE EXTENSION, migration) are safe to re-run. The data-creation cells are not — each insert adds a new row (there is no upsert), so re-running them duplicatesAlice/Boband a later single-result match will fail. To start over, reconnect the runtime and run all cells top to bottom.
Install
Mix.install([{:ash_age, "~> 1.0"}])Register Postgrex types for AGE's agtype
Postgrex.Types.define/3 defines the module named in its first argument, so it is
called at the top level (not inside a defmodule).
Postgrex.Types.define(
GettingStarted.PostgrexTypes,
[AshAge.Postgrex.AgtypeExtension] ++ Ecto.Adapters.Postgres.extensions(),
[]
)Start a Repo
The AGE session hook sets the search path and loads the AGE extension on every
connection. The case makes the cell safe to re-run — a second run reuses the
already-started process.
defmodule GettingStarted.Repo do
use Ecto.Repo, otp_app: :getting_started, adapter: Ecto.Adapters.Postgres
end
# Connection URL. Host port 5462 matches the `docker run -p 5462:5432` above (5432 is
# avoided because a local Postgres usually owns it). Change the port here AND in the
# docker `-p` mapping to use another, or set AGE_DATABASE_URL to override the whole URL.
case GettingStarted.Repo.start_link(
url:
System.get_env(
"AGE_DATABASE_URL",
"postgres://postgres:postgres@localhost:5462/ash_age_test"
),
types: GettingStarted.PostgrexTypes,
after_connect: {AshAge.Session, :setup, []},
pool_size: 2
) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
endCreate the AGE extension
AGE ships as a PostgreSQL extension. The apache/age image auto-creates it in its
POSTGRES_DB, so this is usually a no-op — but running it makes the notebook work
against any AGE-enabled PostgreSQL. It needs a superuser connection (the dev
container's postgres role is one).
Ecto.Adapters.SQL.query!(GettingStarted.Repo, "CREATE EXTENSION IF NOT EXISTS age", [])Provision the graph with a migration
In a real app this migration lives in priv/repo/migrations/ (generate one with
mix ash_age.gen.migration). Here we define and run it inline. It is idempotent —
Ecto.Migrator records the version, so re-running this cell is a no-op.
defmodule GettingStarted.Migrations.CreateGraph do
use Ecto.Migration
import AshAge.Migration
def up do
create_age_graph("getting_started")
create_vertex_label("getting_started", "Person")
create_edge_label("getting_started", "FRIEND")
end
def down, do: drop_age_graph("getting_started")
end
Ecto.Migrator.up(
GettingStarted.Repo,
20_260_703_000_001,
GettingStarted.Migrations.CreateGraph,
all: true
)Define a resource
A Person is a :Person vertex. The :friend edge is a :FRIEND graph edge to
another Person, and :friends_of is a bounded traversal over those edges.
defmodule GettingStarted.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
allow_unregistered?(true)
end
end
defmodule GettingStarted.Person do
use Ash.Resource,
domain: GettingStarted.Domain,
validate_domain_inclusion?: false,
data_layer: AshAge.DataLayer
age do
graph(:getting_started)
repo(GettingStarted.Repo)
label(:Person)
edge :friend do
label(:FRIEND)
destination(__MODULE__)
properties([:since])
end
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string, public?: true, allow_nil?: false)
end
relationships do
has_many :friends_of, __MODULE__ do
manual(
{AshAge.ManualRelationships.Traverse,
edge_label: :FRIEND, direction: :outgoing, max_depth: 2}
)
end
end
actions do
defaults([:read, :destroy])
create :create do
accept([:name])
end
update :add_friend do
require_atomic?(false)
argument(:friend_id, :uuid)
argument(:since, :string)
change({AshAge.Changes.CreateEdge, edge: :friend, to: :friend_id})
end
end
endCreate and read
require Ash.Query brings the filter macro into scope.
require Ash.Query
{:ok, alice} =
GettingStarted.Person |> Ash.Changeset.for_create(:create, %{name: "Alice"}) |> Ash.create()
{:ok, bob} =
GettingStarted.Person |> Ash.Changeset.for_create(:create, %{name: "Bob"}) |> Ash.create()
{:ok, everyone} = GettingStarted.Person |> Ash.Query.for_read(:read) |> Ash.read()
{:ok, [only_alice]} = GettingStarted.Person |> Ash.Query.filter(name == "Alice") |> Ash.read()
{length(everyone), only_alice.name}Create an edge
{:ok, _} =
alice
|> Ash.Changeset.for_update(:add_friend, %{friend_id: bob.id, since: "2020"})
|> Ash.update()
:edge_createdTraverse
Loading :friends_of walks :FRIEND edges out from Alice, up to depth 2.
{:ok, alice_loaded} = Ash.load(alice, :friends_of)
Enum.map(alice_loaded.friends_of, & &1.name)Optional: multitenancy (:attribute strategy)
ash_age supports Ash's :attribute multitenancy — one graph, filtered by a tenant
discriminator. This section is optional and independent of the core walkthrough
above.
This is app-layer tenant filtering (Ash scopes every query by the tenant). DB-enforced Row-Level Security is a separate opt-in that requires a non-superuser role and is out of scope for this guide — see
usage-rules.md.
defmodule GettingStarted.Migrations.CreateTenantGraph do
use Ecto.Migration
import AshAge.Migration
def up do
create_age_graph("getting_started_tenants")
create_vertex_label("getting_started_tenants", "Note")
end
def down, do: drop_age_graph("getting_started_tenants")
end
Ecto.Migrator.up(
GettingStarted.Repo,
20_260_703_000_002,
GettingStarted.Migrations.CreateTenantGraph,
all: true
)defmodule GettingStarted.Note do
use Ash.Resource,
domain: GettingStarted.Domain,
validate_domain_inclusion?: false,
data_layer: AshAge.DataLayer
age do
graph(:getting_started_tenants)
repo(GettingStarted.Repo)
label(:Note)
end
multitenancy do
strategy(:attribute)
attribute(:org_id)
end
attributes do
uuid_primary_key(:id)
attribute(:org_id, :uuid, allow_nil?: false, public?: true)
attribute(:body, :string, public?: true)
end
actions do
defaults([:read, :destroy])
create :create do
accept([:body])
end
end
endorg_a = Ash.UUID.generate()
org_b = Ash.UUID.generate()
{:ok, _} =
GettingStarted.Note
|> Ash.Changeset.for_create(:create, %{body: "from org A"}, tenant: org_a)
|> Ash.create()
{:ok, _} =
GettingStarted.Note
|> Ash.Changeset.for_create(:create, %{body: "from org B"}, tenant: org_b)
|> Ash.create()
{:ok, org_a_notes} = GettingStarted.Note |> Ash.Query.for_read(:read) |> Ash.read(tenant: org_a)
Enum.map(org_a_notes, & &1.body)