Share
Embed
Ash Framework - Elixir Kenya February 2024 Webinar
A very quick introduction into the basics of the Ash Framework.
Stefan Wintermeyer
February 23, 2024
Transcript
54 slides
-
Stefan Wintermeyer <[email protected]> GitHub/Twitter/Misc: @wintermeyer Ash Framework Do I need
it? 54 snackable slides to answer this question. -
⬅ Zach Daniel Creator of the Ash Framework ➡ Me
Stefan Wintermeyer -
„… when the various components of your system can have
consistent expectations of how the other components around them work, you can ultimately do a significant amount more, with less.“ https://ash-hq.org/docs/guides/ash/latest/tutorials/why-ash -
defmodule App.ToDoList.Task do use Ash.Resource, data_layer: AshPostgres.DataLayer postgres do table
"tasks" repo App.Repo end attributes do uuid_primary_key :id attribute :content, :string end end -
$ mix ash_postgres.create The database for App.Repo has been created
$ mix ash_postgres.migrate Compiling 1 file (.ex) 09:51:39.227 [info] == Running 20240221085028 App.Repo.Migrations.MigrateResources1.up/0 forward 09:51:39.228 [info] create table tasks 09:51:39.232 [info] == Migrated 20240221085028 in 0.0s -
defmodule App.ToDoList.Task do use Ash.Resource, data_layer: AshPostgres.DataLayer postgres do table
"tasks" repo App.Repo end attributes do uuid_primary_key :id attribute :content, :string, allow_nil?: false attribute :position, :integer, allow_nil?: false end end -
$ mix ash_postgres.generate_migrations --name add_position Compiling 1 file (.ex) Extension
Migrations: No extensions to install Generating Tenant Migrations: Generating Migrations: * creating priv/repo/migrations/20240221113258_add_position.exs $ mix ash_postgres.migrate 12:33:29.451 [info] == Running 20240221113258 App.Repo.Migrations.AddPosition.up/0 forward 12:33:29.452 [info] alter table tasks 12:33:29.455 [info] == Migrated 20240221113258 in 0.0s -
iex(1)> App.ToDoList.Task.create!(%{content: "Mow the lawn", position: 1}) [debug] QUERY OK
db=2.4ms idle=129.0ms begin [] ↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/ changeset.ex:2404 [debug] QUERY OK source="tasks" db=2.2ms INSERT INTO "tasks" ("id","position","content") VALUES ($1,$2,$3) RETURNING "position","content","id" ["09380ab1-a761-4281-bf24-41e11973fdd2", 1, "Mow the lawn"] ↳ AshPostgres.DataLayer.bulk_create/3, at: lib/data_layer.ex:1582 [debug] QUERY OK db=1.1ms commit [] ↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/ changeset.ex:2404 #App.ToDoList.Task< __meta__: #Ecto.Schema.Metadata<:loaded, "tasks">, id: "09380ab1-a761-4281-bf24-41e11973fdd2", content: "Mow the lawn", position: 1, aggregates: %{}, calculations: %{}, ... > -
iex(2)> App.ToDoList.Task.read [debug] QUERY OK source="tasks" db=0.7ms queue=1.0ms idle=1138.9ms SELECT
t0."id", t0."content", t0."position" FROM "tasks" AS t0 [] ↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/ data_layer.ex:686 {:ok, [ #App.ToDoList.Task< __meta__: #Ecto.Schema.Metadata<:loaded, "tasks">, id: "09380ab1-a761-4281-bf24-41e11973fdd2", content: "Mow the lawn", position: 1, aggregates: %{}, calculations: %{}, ... > ]} -
iex(3)> App.ToDoList.Task.by_id!("09380ab1-a761-4281- bf24-41e11973fdd2") [debug] QUERY OK source="tasks" db=0.6ms queue=0.8ms idle=1350.3ms
SELECT t0."id", t0."content", t0."position" FROM "tasks" AS t0 WHERE (t0."id"::uuid = $1::uuid) ["09380ab1-a761-4281- bf24-41e11973fdd2"] ↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/ data_layer.ex:686 #App.ToDoList.Task< __meta__: #Ecto.Schema.Metadata<:loaded, "tasks">, id: "09380ab1-a761-4281-bf24-41e11973fdd2", content: "Mow the lawn", position: 1, aggregates: %{}, calculations: %{}, ... > -
iex(4)> {:ok, tasks} = App.ToDoList.Task.read {:ok, [ #App.ToDoList.Task< __meta__: #Ecto.Schema.Metadata<:loaded,
"tasks">, id: "09380ab1-a761-4281-bf24-41e11973fdd2", content: "Mow the lawn", ... > ]} iex(5)> tasks = App.ToDoList.Task.read! [ #App.ToDoList.Task< __meta__: #Ecto.Schema.Metadata<:loaded, "tasks">, id: "09380ab1-a761-4281-bf24-41e11973fdd2", content: "Mow the lawn", ... > ] -
+----------+ +-------------+ | Category | | Product | +----------+ +-------------+
| id |<-----| category_id | | name | | id | | | | name | | | | price | +----------+ +-------------+ belongs_to -
defmodule App.Shop.Category do use Ash.Resource, data_layer: Ash.DataLayer.Ets attributes do uuid_primary_key
:id attribute :name, :string end actions do defaults [:create, :read] end code_interface do define_for App.Shop define :create define :read end end -
defmodule App.Shop.Product do use Ash.Resource, data_layer: Ash.DataLayer.Ets attributes do uuid_primary_key
:id attribute :name, :string attribute :price, :decimal end relationships do belongs_to :category, App.Shop.Category do attribute_writable? true end end # ... -
$ iex -S mix iex(1)> alias App.Shop.Product App.Shop.Product iex(2)> alias
App.Shop.Category App.Shop.Category iex(3)> fruits = Category.create!(%{name: "Fruits"}) #App.Shop.Category< __meta__: #Ecto.Schema.Metadata<:loaded>, id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", name: "Fruits", ... -
iex(4)> orange = Product.create!(%{ name: "Orange", price: 0.15, category_id: fruits.id
}) #App.Shop.Product< category: #Ash.NotLoaded<:relationship>, __meta__: #Ecto.Schema.Metadata<:loaded>, id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0", name: "Orange", price: Decimal.new("0.15"), category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", ... > -
iex(5)> App.Shop.load(orange, :category) {:ok, #App.Shop.Product< category: #App.Shop.Category< __meta__: #Ecto.Schema.Metadata<:loaded>, id:
"91cb42d8-45c2-451d-8261-72ae4d94a3c6", name: "Fruits", ... >, __meta__: #Ecto.Schema.Metadata<:loaded>, id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0", name: "Orange", price: Decimal.new("0.15"), category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", ... >} -
iex(6)> orange2 = Product.by_name!("Orange", load: [:category]) #App.Shop.Product< category: #App.Shop.Category< __meta__:
#Ecto.Schema.Metadata<:loaded>, id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", name: "Fruits", ... >, __meta__: #Ecto.Schema.Metadata<:loaded>, id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0", name: "Orange", price: Decimal.new("0.15"), category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", ... > -
iex(7)> orange2.category #App.Shop.Category< __meta__: #Ecto.Schema.Metadata<:loaded>, id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6", name: "Fruits", ...
> iex(8)> orange2.category.name "Fruits" -
$ mix ash_phoenix.gen.html App.ToDoList App.ToDoList.Task * creating lib/app_web/controllers/task_controller.ex * creating
lib/app_web/controllers/task_html/edit.html.heex * creating lib/app_web/controllers/task_html.ex * creating lib/app_web/controllers/task_html/index.html.heex * creating lib/app_web/controllers/task_html/new.html.heex * creating lib/app_web/controllers/task_html/ task_form.html.heex * creating lib/app_web/controllers/task_html/show.html.heex Add the resource to your browser scope in lib/task_web/ router.ex: resources "/tasks", TaskController -
defmodule AppWeb.TaskController do use AppWeb, :controller alias App.ToDoList.Task def index(conn,
_params) do tasks = Task.read!() render(conn, :index, tasks: tasks) end def new(conn, _params) do render(conn, :new, form: create_form()) end # … -
<.header> Task <%= @task.id %> <:subtitle>This is a task record
from your database.</:subtitle> <:actions> <.link href={~p"/tasks/#{@task}/edit"}> <.button>Edit task</.button> </.link> </:actions> </.header> <.list> <:item title="Content"><%= @task.content %></:item> <:item title="Position"><%= @task.position %></:item> </.list> <.back navigate={~p"/tasks"}>Back to tasks</.back> -
iex(4)> App.ToDoList.Task.create!(%{}) ** (Ash.Error.Invalid) Input Invalid * attribute content is
required (elixir 1.16.0) lib/process.ex:860: Process.info/2 (ash 2.19.3) lib/ash/error/exception.ex:59: Ash.Error.Changes.Required.exception/1 (ash 2.19.3) lib/ash/changeset/changeset.ex:2199: anonymous fn/2 in Ash.Changeset.require_values/4 […] -
defmodule AppWeb.Api.Router do use AshJsonApi.Api.Router, # The api modules you
want to serve apis: [App.ToDoList], # optionally a json_schema route json_schema: "/json_schema", # optionally an open_api route open_api: "/open_api" end -
defmodule App.ToDoList.Task do use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [AshJsonApi.Resource] #
... json_api do type "task" routes do base("/tasks") get(:read) index(:read) post(:create) patch(:update) end end end -
$ curl http://localhost:4000/api/json/tasks {"data":[{"attributes":{"position":1,"content":"Mow the lawn"},"id":"09380ab1-a761-4281-bf24-41e11973fdd2","links": {},"meta":{},"type":"task","relationships":{}}],"links": {"self":"http://localhost:4000"},"meta":{},"jsonapi": {"version":"1.0"}} $
curl http://localhost:4000/api/json/tasks/09380ab1- a761-4281-bf24-41e11973fdd2 {"data":{"attributes":{"position":1,"content":"Mow the lawn"},"id":"09380ab1-a761-4281-bf24-41e11973fdd2","links": {},"meta":{},"type":"task","relationships":{}},"links": {"self":"http://localhost:4000"},"jsonapi":{"version":"1.0"}} -
graphql do type :task queries do get :get_task, :read list
:list_tasks, :read end mutations do create :create_task, :create update :update_task, :update destroy :destroy_task, :destroy end end -
defmodule App.ToDoList.User do use Ash.Resource, data_layer: AshPostgres.DataLayer attributes do uuid_primary_key
:id attribute :first_name, :string, allow_nil?: false attribute :last_name, :string, allow_nil?: false end calculations do calculate :full_name, :string, expr(first_name <> " " <> last_name) end preparations do prepare build(load: [:full_name]) end # … -
iex(4)> App.ToDoList.User.read! [debug] QUERY OK source="users" db=0.6ms queue=0.9ms idle=1655.9ms SELECT
u0."id", u0."first_name", u0."last_name", (u0."first_name"::text::varchar || ($1::varchar || u0."last_name"::text::varchar)::varchar)::text::text FROM "users" AS u0 [" "] ↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/ data_layer.ex:686 [ #App.ToDoList.User< full_name: "Stefan Wintermeyer", __meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: "2290b296-6181-4197-99f9-e8b40c60ce67", first_name: "Stefan", last_name: "Wintermeyer", aggregates: %{}, calculations: %{}, -
policies do bypass actor_attribute_equals(:super_user, true) do authorize_if always() end policy
action_type(:read) do # unless the actor is an active user, forbid forbid_unless actor_attribute_equals(:active, true) # if the record is marked as public, authorize authorize_if attribute(:public, true) # if the actor is related to the data via that data's `owner` relationship, authorize authorize_if relates_to_actor_via(:owner) end end -
Policies work for the Web GUI and the external APIs
(e.g. JSON-API). -
The Ash Framework covers a lot more. This talk was
just to make you curious.