# 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
)
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 😅
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.
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
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
# 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
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])
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()
Attribute multi tenancy works across any data layer. data layers can
provide multitenancy features. For example, ash_postgres
can manage
schemas for each tenants.
- Add policies using
Ash.Policy.Authorizer
.
- Authentication with
AshAuthentication
andAshAuthenticationPhoenix
- Full featured JSON:API with
AshJsonApi
(filter, sort, pagination, data inclusion, OpenAPI etc.) - Full featured GraphQL with
AshGraphql
(filter, sort, pagination, relay, etc.)
- 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
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.
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
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)>}
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
-
AshPostgres | PostgreSQL data layer
-
AshSqlite | SQLite data layer
-
AshCsv | CSV data layer
-
AshCubdb | CubDB data layer
-
AshJsonApi | JSON:API builder
-
AshGraphql | GraphQL builder
-
AshPhoenix | Phoenix integrations
-
AshAuthentication | Authenticate users with password, OAuth, and more
-
AshAuthenticationPhoenix | Integrations for AshAuthentication and Phoenix
-
AshMoney | A money data type for Ash
-
AshDoubleEntry | A double entry system backed by Ash Resources
-
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
-
AshAdmin | A push-button admin interface
-
AshAppsignal | Monitor your Ash resources with AppSignal
- Smokestack | Declarative test factories for Ash resources
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.
Run this in Livebook: https://livebook.dev/run?url=https%3A%2F%2Fgist.github.com%2Fzachdaniel%2F79042f1cc546535e495d7e599ca9f21b