Slide 1

Slide 1 text

Stefan Wintermeyer GitHub/Twitter/Misc: @wintermeyer Ash Framework Do I need it? 54 snackable slides to answer this question.

Slide 2

Slide 2 text

⬅ Zach Daniel Creator of the Ash Framework ➡ Me Stefan Wintermeyer

Slide 3

Slide 3 text

„… 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

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

Show me Code Examples! Source code: https://elixir-phoenix-ash.com Slides: https://speakerdeck.com/wintermeyer

Slide 7

Slide 7 text

The Resource

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

$ 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

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

$ 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

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

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: %{}, ... >

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

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: %{}, ... > ]}

Slide 17

Slide 17 text

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: %{}, ... >

Slide 18

Slide 18 text

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", ... > ]

Slide 19

Slide 19 text

Relationships https://elixir-phoenix-ash.com/ash/relationships/

Slide 20

Slide 20 text

+----------+ +-------------+ | Category | | Product | +----------+ +-------------+ | id |<-----| category_id | | name | | id | | | | name | | | | price | +----------+ +-------------+ belongs_to

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

$ 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", ...

Slide 24

Slide 24 text

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", ... >

Slide 25

Slide 25 text

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", ... >}

Slide 26

Slide 26 text

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", ... >

Slide 27

Slide 27 text

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"

Slide 28

Slide 28 text

belongs_to

Slide 29

Slide 29 text

•has_many •many_to_many •has_one Other Relationships

Slide 30

Slide 30 text

Web Interface Scaffold

Slide 31

Slide 31 text

$ 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

Slide 32

Slide 32 text

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 # …

Slide 33

Slide 33 text

<.header> Task <%= @task.id %> <:subtitle>This is a task record from your database. <:actions> <.link href={~p"/tasks/#{@task}/edit"}> <.button>Edit task <.list> <:item title="Content"><%= @task.content %> <:item title="Position"><%= @task.position %> <.back navigate={~p"/tasks"}>Back to tasks

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

No content

Slide 37

Slide 37 text

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 […]

Slide 38

Slide 38 text

JSON API {:ash_json_api, "~> 0.34.2"}

Slide 39

Slide 39 text

scope "/api/json" do pipe_through(:api) forward "/", AppWeb.Api.Router end

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

$ 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"}}

Slide 43

Slide 43 text

GraphQL API {:ash_graphql, "~> 0.27.0"}

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

AshAdmin {:ash_admin, "~> 0.10.6“}

Slide 46

Slide 46 text

https://www.youtube.com/watch?v=aFMLz3cpQ8c

Slide 47

Slide 47 text

Calculations https://ash-hq.org/docs/guides/ash/latest/topics/calculations

Slide 48

Slide 48 text

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 # …

Slide 49

Slide 49 text

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: %{},

Slide 50

Slide 50 text

Policies https://ash-hq.org/docs/guides/ash/latest/topics/policies

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

Policies work for the Web GUI and the external APIs (e.g. JSON-API).

Slide 53

Slide 53 text

The Ash Framework covers a lot more. This talk was just to make you curious.

Slide 54

Slide 54 text

Stefan Wintermeyer GitHub/Twitter/Misc: @wintermeyer https://elixir-phoenix-ash.com https://speakerdeck.com/wintermeyer