AshAge (AshAge v1.0.0)

Copy Markdown View Source

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.PostgrexTypes

2. 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.PostgrexTypes

3. 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
end

4. 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
end

Mix Tasks

Modules

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

cypher(repo, graph, cypher, params \\ %{}, return_types)

@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); the cypher body is yours to write. The graph name is validate_identifier!-checked; a $$ break-out in the body is rejected.
  • Return: {:ok, [row_map]} where each row_map is %{column_name => decoded} keyed by the atoms in return_types. Each cell decodes to a AshAge.Type.Vertex/Edge/Path or a scalar; a bare agtype aggregate (collect(n), {k: v}) is returned as its raw agtype string (aggregate decoding is out of scope — use Cypher UNWIND for collections).
  • Tenancy is explicit: the graph you pass IS the isolation boundary (:context). This opens no transaction of its own; for :attribute + RLS defense-in-depth, wrap the call in AshAge.with_tenant_rls/4, which sets the tenant GUC (set_config) on the same connection.

tenant_graph(resource, tenant)

@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.

with_tenant_rls(repo, guc, tenant, fun)

@spec with_tenant_rls(module(), String.t(), term(), (-> result)) :: result
when result: var

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 tenant does NOT fail closed. It is set_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 unlike cypher/5 itself (which wraps DB and encode failures in a redacted QueryFailed). The caller owns error handling for this wrapper's own set_config queries.