Skip to content

Instantly share code, notes, and snippets.

@zachdaniel
Last active April 29, 2024 13:29
Show Gist options
  • Save zachdaniel/79042f1cc546535e495d7e599ca9f21b to your computer and use it in GitHub Desktop.
Save zachdaniel/79042f1cc546535e495d7e599ca9f21b to your computer and use it in GitHub Desktop.

Comparing usage of Ash & Ecto

# https://gist.github.com/Gazler/b4e92e9ab7527c7e326f19856f8a974a

Application.put_env(:phoenix, :json_library, Jason)

Application.put_env(:sample, SamplePhoenix.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  secret_key_base: String.duplicate("a", 64)
)

Application.put_env(:ash, :validate_domain_config_inclusion?, false)

Mix.install(
  [
    {:plug_cowboy, "~> 2.5"},
    {:jason, "~> 1.0"},
    {:phoenix, "~> 1.7.0"},
    {:ecto_sql, "~> 3.10"},
    {:postgrex, ">= 0.0.0"},
    {:ash, "~> 3.0.0-rc"},
    {:ash_postgres, "~> 2.0.0-rc"}
  ]
)

Application.put_env(:sample, Repo, database: "mix_install_examples")

defmodule Repo do
  use AshPostgres.Repo,
    otp_app: :sample

  def installed_extensions, do: ["ash-functions"]
end

defmodule Migration0 do
  use Ecto.Migration

  def change do
    create table("posts") do
      add(:title, :string)
      timestamps(type: :utc_datetime_usec)
    end

    create table("comments") do
      add(:content, :string)
      add(:post_id, references(:posts, on_delete: :delete_all), null: false)
    end
  end
end

# This file is autogenerated by ash_postgres, not something you need to define yourself
defmodule Migration1 do
  use Ecto.Migration

  def up do
    execute("""
    CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
    AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
    LANGUAGE SQL
    IMMUTABLE;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
    AS $$ SELECT COALESCE($1, $2) $$
    LANGUAGE SQL
    IMMUTABLE;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
      SELECT CASE
        WHEN $1 IS TRUE THEN $2
        ELSE $1
      END $$
    LANGUAGE SQL
    IMMUTABLE;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
      SELECT CASE
        WHEN $1 IS NOT NULL THEN $2
        ELSE $1
      END $$
    LANGUAGE SQL
    IMMUTABLE;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
    RETURNS text[] AS $$
    DECLARE
        start_index INT = 1;
        end_index INT = array_length(arr, 1);
    BEGIN
        WHILE start_index <= end_index AND arr[start_index] = '' LOOP
            start_index := start_index + 1;
        END LOOP;

        WHILE end_index >= start_index AND arr[end_index] = '' LOOP
            end_index := end_index - 1;
        END LOOP;

        IF start_index > end_index THEN
            RETURN ARRAY[]::text[];
        ELSE
            RETURN arr[start_index : end_index];
        END IF;
    END; $$
    LANGUAGE plpgsql
    IMMUTABLE;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
    RETURNS BOOLEAN AS $$
    BEGIN
        -- Raise an error with the provided JSON data.
        -- The JSON object is converted to text for inclusion in the error message.
        RAISE EXCEPTION 'ash_error: %', json_data::text;
        RETURN NULL;
    END;
    $$ LANGUAGE plpgsql;
    """)

    execute("""
    CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
    RETURNS ANYCOMPATIBLE AS $$
    BEGIN
        -- Raise an error with the provided JSON data.
        -- The JSON object is converted to text for inclusion in the error message.
        RAISE EXCEPTION 'ash_error: %', json_data::text;
        RETURN NULL;
    END;
    $$ LANGUAGE plpgsql;
    """)
  end

  def down do
    execute(
      "DROP FUNCTION IF EXISTS ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
    )
  end
end

try do
  Repo.stop()
catch
  :exit, _ ->
    :ok
end

Repo.start_link()

Repo.__adapter__().storage_down(Repo.config())
Repo.__adapter__().storage_up(Repo.config())

Ecto.Migrator.run(Repo, [{0, Migration0}, {1, Migration1}], :up,
  all: true,
  log_migrations_sql: :debug
)

Foreword

These are simple examples. The goal here is not to say in some way that Ecto is limited. It is not limited. It is, however, a library for interacting with databases. Its primitives are around data. Ash's primitives are around application-level concepts, which may or may not map to a data layer.

This document is intentionally not titled "Ash vs Ecto". Ash (for some data layers) actually sits on top of ecto. Comparing them direclty is an apples and oranges comparison.

They are very different things, even though what you're about to see are a bunch of examples of them doing the same thing. Ash extends far beyond the examples shown here.

I want to reiterate that Ash is not a "data wrapper". While what you see here may seem very "CRUD-y", you are strongly encouraged to define your actions as "domain-relevant events", i.e you :publish a Post, or :revoke a License.

I went off of my memory of what building things with Phoenix Contexts and Ecto are like, if I got something wrong, it is ignorance, not malice 😅

Boilerplate

Reducing the amount of code that you write is not a goal of Ash Framework. It is, however, a natural effect of our design patterns. The goals of Ash framework include:

  • Building maintainable, stable applications. We care about day 1, but we also care about year 5
  • Increasing flexibility and code reuse.
  • Adding new capabilities that, without a smart framework like Ash are practical impossibilities otherwise. Atomics & bulk actions are an example of this. note practical impossibilities. You could design all your operations to be fully concurrency safe and run in batches. But you would almost certainly not expend that effort. With Ash, that is very easy to accomplish.
  • Derive layers from your application definition. We derive APIs and new behavior directly from your resources. This maximizes flexibiltiy, saves time, and enables the tooling to do really smart things for you. No need to write absinthe resolvers or data loaders. No need to write an OpenAPI schema. Ash does it all for you.

Ecto Schemas & a context

defmodule Ecto.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field(:title, :string)
    timestamps(type: :utc_datetime_usec)
    has_many(:comments, Ecto.Comment)
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title])
    |> validate_required([:title])
  end
end

defmodule Ecto.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "comments" do
    field(:content, :string)
    belongs_to(:post, Ecto.Post)
  end

  def changeset(comment, attrs) do
    comment
    |> cast(attrs, [:content, :post_id])
    |> validate_required([:content, :post_id])
  end
end

defmodule Ecto.Blog do
  require Ecto.Query

  def list_posts do
    Repo.all(Ecto.Post, preload: :comments)
  end

  def get_post(id) do
    Ecto.Post |> Repo.get!(id) |> Repo.preload(:comments)
  end

  def create_post(title) do
    %Ecto.Post{}
    |> Ecto.Post.changeset(%{title: title})
    |> Repo.insert!()
  end

  def update_post(%Ecto.Post{} = post, changes) do
    post
    |> Ecto.Post.changeset(changes)
    |> Repo.update!()
    |> Repo.preload(:comments)
  end

  def delete_post(%Ecto.Post{} = post) do
    Repo.delete!(post)
  end

  def add_comment(post_id, content) do
    %Ecto.Comment{}
    |> Ecto.Comment.changeset(%{content: content, post_id: post_id})
    |> Repo.insert!()
  end

  def remove_comment(%Ecto.Comment{} = comment) do
    Repo.delete!(comment)
  end
end

Ash Resources

defmodule Ash.Post do
  use Ash.Resource,
    domain: Ash.Blog,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "posts"
    repo Repo
  end

  actions do
    defaults [:read, :destroy, create: [:title], update: [:title]]
  end

  attributes do
    integer_primary_key :id
    attribute :title, :string, allow_nil?: false
    create_timestamp :inserted_at
    update_timestamp :updated_at
  end

  relationships do
    has_many :comments, Ash.Comment
  end
end

defmodule Ash.Comment do
  use Ash.Resource,
    domain: Ash.Blog,
    data_layer: AshPostgres.DataLayer

  actions do
    defaults [:read, :destroy, create: [:content, :post_id], update: [:content]]
  end

  postgres do
    table "comments"
    repo Repo
  end

  attributes do
    integer_primary_key :id
    attribute :content, :string, allow_nil?: false
  end

  relationships do
    belongs_to :post, Ash.Post do
      attribute_type :integer
      allow_nil? false
    end
  end
end

defmodule Ash.Blog do
  use Ash.Domain

  resources do
    resource Ash.Post do
      define :list_posts, action: :read
      define :create_post, action: :create
      define :get_post, action: :read, get_by: [:id]
      define :update_post, action: :update
      define :delete_post, action: :destroy
    end

    resource Ash.Comment do
      define :add_comment, action: :create, args: [:post_id, :content]
      define :remove_comment, action: :destroy
    end
  end
end

Now lets use them

Creating Posts

# Creating posts
Ecto.Blog.create_post("ecto_title")
Ash.Blog.create_post!(%{title: "ash_title"})

# Bulk creating posts
# Ecto: you can do it iteratively, i.e # for input <- [....]
# but you need to write new code to do a "bulk create" in ecto using insert_all

Ash.Blog.create_post!([%{title: "ash_title1"}, %{title: "ash_title2"}])

Listing Posts

# Listing Posts
Ecto.Blog.list_posts()
Ash.Blog.list_posts!()
Ecto.Blog.get_post(1)
Ash.Blog.get_post!(1)

# Modifying the query

# Ecto: need to add arguments to your context functions for ecto
require Ash.Query
Ash.Blog.list_posts!(query: Ash.Query.filter(Ash.Post, contains(title, "ash")))
Ash.Blog.list_posts!(query: Ash.Query.sort(Ash.Post, title: :asc))
Ash.Blog.list_posts!(query: Ash.Query.limit(Ash.Post, 1))

# loading data
# Ecto: need to put it in your context function, or accept an arg
# note: you can put this in the action if you like, just showing that you can do it here
Ash.Blog.list_posts!(load: :comments)

# Pagination

# Ecto: need to bring in a pagination library

# Ash: can use offset or keyset(A.K.A cursor) pagination
%{results: [first | _]} = Ash.Blog.list_posts!(page: [limit: 5, offset: 1])
Ash.Blog.list_posts(page: [after: first.__metadata__.keyset, limit: 1])

Updating Posts

ecto_post = Ecto.Blog.get_post(1)
Ecto.Blog.update_post(ecto_post, %{title: "new_title"})

ash_post = Ash.Blog.get_post!(1)
Ash.Blog.update_post!(ash_post, %{title: "new_title2"})

# update many posts
# Ecto: need to write something new, maybe multiple somethings new if you want to use a 
# list input or a query input

require Ash.Query

# update a query
Ash.Post
|> Ash.Query.filter(contains(title, "ecto"))
|> Ash.Blog.update_post!(%{title: "gotcha"})

# update records in batches
[Ash.Blog.get_post!(1), Ash.Blog.get_post!(2)]
|> Ash.Blog.update_post!(%{title: "gotcha again"})

# stream-capable

[%{title: "title1"}, %{title: "title2"}]
|> Ash.Blog.create_post!(bulk_options: [return_stream?: true, return_records?: true])
|> Ash.Blog.update_post!(%{title: "updated"},
  bulk_options: [return_stream?: true, return_records?: true]
)
|> Enum.to_list()

And a whole lot more

Multitenancy built in

Attribute multi tenancy works across any data layer. data layers can provide multitenancy features. For example, ash_postgres can manage schemas for each tenants.

Authorization built in

  • Add policies using Ash.Policy.Authorizer.

Authentication

  • Authentication with AshAuthentication and AshAuthenticationPhoenix

Add APIs in literally minutes

  • Full featured JSON:API with AshJsonApi (filter, sort, pagination, data inclusion, OpenAPI etc.)
  • Full featured GraphQL with AshGraphql (filter, sort, pagination, relay, etc.)

Easily Extensible

  • Add declarative changes/validations, like plugs for your changesets. Use them to support to batch and atomic (i.e update_all)
  • Override action behavior with the manual option.
  • Or use "generic actions", which
    • benefit from being typed
    • can be placed in your APIs using the api extensions (AshJsonApi, not yet but soon)
    • honor policy authorization

Advanced tools you won't find anywhere else

Calculations

Define expression calculations which can be run in Elixir or in the data layer

calculate :full_name, :string, expr(first_name <> " " <> last_name)

Use fragments, custom expressions and more to extend this syntax.

Atomics

Encapsulate logic for your changesets that covers "regular", atomic and batch cases

defmodule Increment do
  use Ash.Resource.Change

  @impl true
  def change(changeset, opts, _) do
    field = opts[:field]
    amount = opts[:amount] || 1
    value = Map.get(changeset.data, field) || 0
    Ash.Changeset.change_attribute(changeset, field, value + amount)
  end

  @impl true
  def atomic(_, opts, _) do
    field = opts[:field]
    amount = opts[:amount] || 1
    {:atomic, %{field => expr(^ref(field) + ^amount)}}
  end

  # @impl true
  # def batch_change(changesets, _, _) do
  # we don't need this, so we don't define it and the single change is used
  # end
end
update :game_won do
  accept []
  change {Increment, field: :total_games_won}
  change {Increment, field: :score, amount: 100}
end

Auto filtering policies

policies do
  policy action_type(:read) do
    authorize_if expr(owner_id == ^actor(:id))
    forbid_if expr(content_hidden == true)    
    authorize_if expr(public == true)
  end
end

# Generates a query like the following
%Ash.Query{filter: #Ash.Filter<owner_id == ^id or (not(content_hidden == true) and public == true)>}

Packages

Ash has a rich package ecosystem that continues to grow. These packages are more than just utilities or libraries. They are powerful extensions that are resource-aware. Here are just the core packages

Data Layers

API Extensions

Web

Finance

Resource Utilities

  • AshOban | Background jobs and scheduled jobs for Ash, backed by Oban

  • AshArchival | Archive resources instead of deleting them

  • AshStateMachine | Create state machines for resources

  • AshPaperTrail | Keep a history of changes to resources

  • AshCloak | Encrypt attributes of a resource

Admin & Monitoring

Testing

  • Smokestack | Declarative test factories for Ash resources

So much more

I could go on, but a lot of things make more sense when you see how they all play together vs explaining them without the necessary context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment