Ash Framework DataLayer for Apache AGE graph database.
Setup
1. Register Postgrex Types
Create a Postgrex types module so AGE's agtype is understood by Ecto:
# `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(),
[]
)Then reference it in your Repo config:
config :my_app, MyApp.Repo,
types: MyApp.PostgrexTypes2. Configure the Repo
Add the AGE session hook so each connection sets the search path and loads AGE:
config :my_app, MyApp.Repo,
after_connect: {AshAge.Session, :setup, []},
types: MyApp.PostgrexTypes3. Create an AGE Migration
Generate a migration with mix ash_age.gen.migration, or write one manually:
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
end4. Define Ash Resources
defmodule MyApp.Entity 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 :name, :string, allow_nil?: false
attribute :properties, :map, default: %{}
end
actions do
defaults [:read, :create, :update, :destroy]
end
endMix Tasks
mix ash_age.install— Print setup instructionsmix ash_age.gen.migration— Generate an AGE migrationmix ash_age.verify— Verify AGE database configuration
Modules
AshAge.DataLayer— The Ash DataLayer implementationAshAge.Session— Connection session setupAshAge.Migration— Migration helpersAshAge.Graph— Graph management utilities
Summary
Functions
Runs arbitrary parameterized Cypher against graph on repo, returning decoded
results — the escape hatch for graph queries Ash's DSL cannot express.
Resolves the AGE graph name for a :context-multitenant resource and tenant.
Runs fun inside a transaction with the RLS tenant GUC set (set_config(guc, tenant, true)), so raw cypher/5 calls inside fun are RLS-scoped on the same
connection. The one auditable way to tenant-scope the raw hatch — do not hand-roll
set_config. guc/tenant reach Postgres only as bound parameters.
Functions
@spec cypher(module(), atom() | String.t(), String.t(), map(), keyword()) :: {:ok, [map()]} | {:error, Exception.t()}
Runs arbitrary parameterized Cypher against graph on repo, returning decoded
results — the escape hatch for graph queries Ash's DSL cannot express.
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{...}}, ...]}Contract
- Values reach AGE only as
$parameters (params); thecypherbody is yours to write. Thegraphname isvalidate_identifier!-checked; a$$break-out in the body is rejected. - Return:
{:ok, [row_map]}where eachrow_mapis%{column_name => decoded}keyed by the atoms inreturn_types. Each cell decodes to aAshAge.Type.Vertex/Edge/Pathor a scalar; a bare agtype aggregate (collect(n),{k: v}) is returned as its raw agtype string (aggregate decoding is out of scope — use CypherUNWINDfor collections). - Tenancy is explicit: the
graphyou pass IS the isolation boundary (:context). This opens no transaction of its own; for:attribute+ RLS defense-in-depth, wrap the call inAshAge.with_tenant_rls/4, which sets the tenant GUC (set_config) on the same connection.
@spec tenant_graph(Ash.Resource.t(), term()) :: String.t()
Resolves the AGE graph name for a :context-multitenant resource and tenant.
Host applications call this to derive the graph name to provision (via
AshAge.Migration.provision_tenant/3), guaranteeing the provisioned name matches
the one ash_age resolves at query time. Delegates to AshAge.Multitenancy.graph_name/2.
Runs fun inside a transaction with the RLS tenant GUC set (set_config(guc, tenant, true)), so raw cypher/5 calls inside fun are RLS-scoped on the same
connection. The one auditable way to tenant-scope the raw hatch — do not hand-roll
set_config. guc/tenant reach Postgres only as bound parameters.
Diverges from the data layer's internal RLS path in two ways a caller must know:
- Blank/nil
tenantdoes NOT fail closed. It isset_config'd as-is, so the RLS policy's blank-GUC guard yields "no rows visible" rather than an error — a silent empty result, not a raised:rls_tenant_required. Pass a real tenant. - Errors propagate raw (this uses
SQL.query!); it does not redact DB errors, unlike the data layer's internal path and unlikecypher/5itself (which wraps DB and encode failures in a redactedQueryFailed). The caller owns error handling for this wrapper's ownset_configqueries.