PostgreSQL Data Layer Minimal Setup
The fastest path to a minimal Ash + AshPostgres setup is igniter. It
scaffolds the app, pulls the right deps, wires up App.Repo, creates
config/config.exs with the ash_domains entry, and generates the
Postgres config for every environment.
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash,ash_postgres --yes
$ cd app
| Looking for an Ash + Phoenix Framework setup instead? Jump to PostgreSQL Data Layer Ash + Phoenix Framework Setup. |
What Igniter gave you
The installer creates the AshPostgres.Repo wrapper:
defmodule App.Repo do
use AshPostgres.Repo, otp_app: :app
end
A supervised application tree that starts the repo on boot:
defmodule App.Application do
use Application
@impl true
def start(_type, _args) do
children = [App.Repo]
opts = [strategy: :one_for_one, name: App.Supervisor]
Supervisor.start_link(children, opts)
end
end
And the config files, with the ecto_repos entry already in place:
import Config
config :app, :ash_domains, [App.Shop] (1)
config :app, ecto_repos: [App.Repo]
import_config "#{config_env()}.exs"
| 1 | You add this line yourself once the App.Shop domain exists, see
below. |
import Config
config :app, App.Repo,
username: "postgres", (1)
password: "postgres",
hostname: "localhost",
database: "app_dev", (2)
port: 5432,
show_sensitive_data_on_connection_error: true,
pool_size: 10
| 1 | Most developers use postgres:postgres for local development.
Adjust to whatever your PostgreSQL is expecting. |
| 2 | Pick a database name that matches your app. |
import Config
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") || (1)
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
config :app, App.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end
| 1 | Most production environments use environment variables to
configure the database connection. Heroku, Fly, and similar cloud
providers all default to DATABASE_URL. |
import Config
config :app, App.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "app_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
Add AshPostgres to a Resource
As an example we add a minimal Product resource to our application.
The resource is more or less empty. We 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. |
And the domain that owns it, 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
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
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 through 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
takes a migration name as an argument so the generated file is easy
to read in a pull request.
We created the database but it is still empty. It is time to use
mix ash.codegen to create a migration for the Product resource:
$ mix ash.codegen create_products
Running codegen for AshPostgres.DataLayer...
Extension Migrations:
No extensions to install
Generating Tenant Migrations:
Generating Migrations:
* creating priv/repo/migrations/20260418183000_create_products.exs
It is not a bad habit to check the generated migration file before running the migration. In our case it looks like this:
[...]
def up do
create table(:products, primary_key: false) do (1)
add :id, :uuid, null: false, primary_key: true (2)
end
end
def down do
drop table(:products)
end
[...]
| 1 | Create a table named products. |
| 2 | Add a primary key column named id of type uuid. |
mix ash_postgres.migrate
Now it is time to run the migration:
$ mix ash_postgres.migrate
[info] == Running 20260418183000 App.Repo.Migrations.CreateProducts.up/0 forward
[info] create table products
[info] == Migrated 20260418183000 in 0.0s
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 |
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 20260418183000 App.Repo.Migrations.CreateProducts.down/0 forward
[info] drop table products
[info] == Migrated 20260418183000 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, plus a matching
get_product_by_name code interface:
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. |
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 :get_product_by_name, action: :read, get_by: :name (1)
define :update_product, action: :update
define :destroy_product, action: :destroy
end
end
end
| 1 | A get_product_by_name helper that looks up a product by its
:name. |
Start mix ash.codegen again:
$ mix ash.codegen add_product_attributes
Running codegen for AshPostgres.DataLayer...
Extension Migrations:
No extensions to install
Generating Tenant Migrations:
Generating Migrations:
* creating priv/repo/migrations/20260418184000_add_product_attributes.exs (1)
$ mix ash_postgres.migrate (2)
[info] == Running 20260418184000 App.Repo.Migrations.AddProductAttributes.up/0 forward
[info] alter table products
[info] == Migrated 20260418184000 in 0.0s
| 1 | mix ash.codegen created a new migration file which includes the
new attributes. |
| 2 | mix ash_postgres.migrate runs the migration. |
Because we are curious we 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 |
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=12.5ms idle=795.4ms
begin []
[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)
[debug] QUERY OK db=0.8ms
commit []
%App.Shop.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: "4d7e383b-ce7b-44d0-818c-290eaa8b0532",
name: "Banana",
price: Decimal.new("0.1")
}
iex(2)> App.Shop.create_product!(%{name: "Pineapple", price: Decimal.new("0.50")})
[debug] QUERY OK db=0.3ms idle=935.2ms
begin []
[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")]
[debug] QUERY OK db=0.6ms
commit []
%App.Shop.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "products">,
id: "e854a911-4cda-4693-bd49-db200b675ded",
name: "Pineapple",
price: Decimal.new("0.5")
}
iex(3)>
| 1 | In development mode you see these SQL debugging messages. |
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 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.
|