PostgreSQL Data Layer Ash + Phoenix Framework Setup
The recommended path is Igniter. One command scaffolds a Phoenix 1.8
project with ash, ash_postgres, and ash_phoenix pre-installed
and configured:
$ mix archive.install hex igniter_new
$ mix igniter.new app --with phx.new --install ash,ash_postgres,ash_phoenix --yes
$ cd app
Igniter generates the App.Repo wrapper, the application supervision
tree, the config files, and the .formatter.exs imports for you. The
files you end up with are the ones covered in
PostgreSQL Data Layer Minimal Setup
with these Phoenix-specific additions:
-
App.Repois added to the application’s child list alongsideAppWeb.Telemetry,Phoenix.PubSub,Finch, andAppWeb.Endpoint. -
.formatter.exsimports[:phoenix, :ash, :ash_postgres, :ash_phoenix]. -
config/dev.exs,config/test.exs, andconfig/runtime.exscarry the usual Postgres credentials (see the minimal setup for the full listings).
Add AshPostgres to a Resource
As an example we add a minimal Product resource to our application.
We will add more attributes later.
defmodule App.Shop.Product do
use Ash.Resource,
data_layer: AshPostgres.DataLayer, (1)
domain: App.Shop,
otp_app: :app
postgres do
table "products" (2)
repo App.Repo
end
attributes do
uuid_primary_key :id (3)
end
actions do
defaults [:create, :read, :update, :destroy]
end
end
| 1 | Tells Ash to use the AshPostgres.DataLayer for this resource. |
| 2 | Sets the name of the table in the database. |
| 3 | An AshPostgres resource always has to have at least one primary key attribute. |
The matching domain with the code interfaces we will call from IEx:
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product do
define :create_product, action: :create
define :list_products, action: :read
define :get_product_by_id, action: :read, get_by: :id
define :update_product, action: :update
define :destroy_product, action: :destroy
end
end
end
Add the domain to config/config.exs:
# [...]
config :app, :ash_domains, [App.Shop]
# [...]
The products table is not yet created. Not even the database is
created. We do that in the next step.
Create the Database
Assuming that you have PostgreSQL
installed and running on your system, you can now create the database
with the mix ash_postgres.create command:
$ mix ash_postgres.create
The database for App.Repo has been created
Now is the first time you could actually start the Phoenix
server with mix phx.server without getting an error.
|
Drop the Database
In case you need to drop (delete) the database you can use the
mix ash_postgres.drop command:
$ mix ash_postgres.drop
The database for App.Repo has been dropped
Please re-run mix ash_postgres.create now in case you ran the
drop command by accident while working this tutorial.
|
mix ash.codegen
mix ash.codegen scans your application for resources, keeps track of
them and generates migrations if things (e.g. attributes) change. It
now takes a migration name as a positional argument:
$ mix ash.codegen create_products
Running codegen for AshPostgres.DataLayer...
* creating priv/resource_snapshots/repo/extensions.json
* creating priv/repo/migrations/20260418183000_create_products_extensions_1.exs
* creating priv/repo/migrations/20260418183001_create_products.exs
* creating priv/resource_snapshots/repo/products/20260418183002.json
mix ash_postgres.migrate
Now run the migration:
$ mix ash_postgres.migrate
[info] == Running 20260418183000 App.Repo.Migrations.CreateProductsExtensions1.up/0 forward
[info] execute "CREATE OR REPLACE FUNCTION ash_elixir_or..."
[info] == Migrated 20260418183000 in 0.0s
[info] == Running 20260418183001 App.Repo.Migrations.CreateProducts.up/0 forward
[info] create table products
[info] == Migrated 20260418183001 in 0.0s
The first migration installs Ash’s SQL helper functions (ash_elixir_or,
ash_required, uuid_generate_v7, and so on) via the ash-functions
extension Igniter registers in App.Repo. The second one is the
migration for our Product resource.
If you want to you can check the table with psql:
$ psql -h localhost -U postgres -d app_dev -c "\d products"
Table "public.products"
Column | Type | Collation | Nullable | Default
--------+------+-----------+----------+--------------------------
id | uuid | | not null | gen_random_uuid()
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
mix ash_postgres.rollback
Sometimes you want to undo a migration. You can do that with
mix ash_postgres.rollback:
$ mix ash_postgres.rollback
[info] == Running 20260418183001 App.Repo.Migrations.CreateProducts.down/0 forward
[info] drop table products
[info] == Migrated 20260418183001 in 0.0s
In case you just did a rollback in this example you want to
migrate again with mix ash_postgres.migrate before you continue.
|
Add Attributes to a Resource
Let’s add two attributes to the Product resource:
defmodule App.Shop.Product do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
domain: App.Shop,
otp_app: :app
postgres do
table "products"
repo App.Repo
end
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true (1)
attribute :price, :decimal, public?: true (2)
end
actions do
default_accept [:name, :price]
defaults [:create, :read, :update, :destroy]
end
end
| 1 | A :name attribute of type :string. |
| 2 | A :price attribute of type :decimal. Prefer :decimal over
:float for money so rounding errors do not creep in. |
Also add a matching get_product_by_name code interface:
# [...]
resource App.Shop.Product do
define :create_product, action: :create
define :list_products, action: :read
define :get_product_by_id, action: :read, get_by: :id
define :get_product_by_name, action: :read, get_by: :name (1)
define :update_product, action: :update
define :destroy_product, action: :destroy
end
# [...]
| 1 | A get_product_by_name helper that looks up a product by its
:name. |
Generate and run the migration:
$ mix ash.codegen add_product_attributes
Running codegen for AshPostgres.DataLayer...
* creating priv/repo/migrations/20260418184000_add_product_attributes.exs
* creating priv/resource_snapshots/repo/products/20260418184001.json
$ mix ash_postgres.migrate
[info] == Running 20260418184000 App.Repo.Migrations.AddProductAttributes.up/0 forward
[info] alter table products
[info] == Migrated 20260418184000 in 0.0s
Check the table again:
$ psql -h localhost -U postgres -d app_dev -c "\d products"
Table "public.products"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+--------------------------
id | uuid | | not null | gen_random_uuid()
name | text | | |
price | numeric | | |
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
Time to add two entries into the products table (a Banana and a Pineapple):
$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana", price: Decimal.new("0.10")})
[debug] QUERY OK db=3.9ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id"
["4d7e383b-ce7b-44d0-818c-290eaa8b0532", "Banana", Decimal.new("0.1")] (1)
%App.Shop.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: "4d7e383b-ce7b-44d0-818c-290eaa8b0532",
name: "Banana",
price: Decimal.new("0.10")
}
iex(2)> App.Shop.create_product!(%{name: "Pineapple", price: Decimal.new("0.50")})
[debug] QUERY OK db=0.6ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id"
["e854a911-4cda-4693-bd49-db200b675ded", "Pineapple", Decimal.new("0.5")]
%App.Shop.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: "e854a911-4cda-4693-bd49-db200b675ded",
name: "Pineapple",
price: Decimal.new("0.50")
}
iex(3)>
| 1 | In development mode you see these SQL debugging messages.
Logger.configure(level: :info) at the top of the session hides them. |
After pressing Ctrl-C two times to exit the iex session we can
check the table again:
$ psql -h localhost -U postgres -d app_dev -c "select * from products"
id | name | price
--------------------------------------+-----------+-------
4d7e383b-ce7b-44d0-818c-290eaa8b0532 | Banana | 0.10
e854a911-4cda-4693-bd49-db200b675ded | Pineapple | 0.50
(2 rows)
Congratulations! You just created your first Ash + Phoenix application with a PostgreSQL database.
Never forget to run mix ash.codegen <migration_name> and
mix ash_postgres.migrate after you change a resource. Otherwise
the changes will not be reflected in the database.
|
Test Setup
Igniter wires up the test database helper for you. The generated
test/test_helper.exs already calls
Ecto.Adapters.SQL.Sandbox.mode(App.Repo, :manual), and the
mix test alias runs ash_postgres.create --quiet and
ash_postgres.migrate --quiet before each test run. Run:
$ mix test
.....
Finished in 0.06 seconds (0.02s async, 0.04s sync)
5 tests, 0 failures
If you need a shared App.DataCase for tests that exercise the
repo, copy the one Phoenix generates in stock projects (or regenerate
with mix phx.gen.schema in a throwaway scratch app and lift the
file).
Building the Phoenix CRUD by hand
A full phx.gen.html-style generator for Ash does not ship with the
framework, but the controller pattern is short once ash_phoenix is
installed. Given how much this piece changes between ash_phoenix
releases, we keep the detailed example in the
ash_phoenix documentation rather
than pin it here. The short version:
-
AshPhoenix.Form.for_create/3andAshPhoenix.Form.for_update/3build forms compatible with the<.simple_form>Phoenix component, usingdomain: App.Shop(the oldapi:keyword is no longer valid in Ash 3). -
AshPhoenix.Form.submit/1returns{:ok, record}or{:error, form}and is what you call from yourcreate/2andupdate/2actions. -
A one-line
resources "/products", ProductControllerinlib/app_web/router.exwires up the seven RESTful routes.
When in doubt, prefer a LiveView: mix ash_phoenix.live (from the
ash_phoenix archive) generates the same UI as a LiveView and is the
direction the Ash team is moving in.