# Comparing usage of Ash & Ecto
```elixir
# 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
```elixir
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
```elixir
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
```elixir
# 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
```elixir
# 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
```elixir
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
<!-- livebook:{"force_markdown":true} -->
```elixir
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
<!-- livebook:{"force_markdown":true} -->
```elixir
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
```
<!-- livebook:{"force_markdown":true} -->
```elixir
update :game_won do
accept []
change {Increment, field: :total_games_won}
change {Increment, field: :score, amount: 100}
end
```
#### Auto filtering policies
<!-- livebook:{"force_markdown":true} -->
```elixir
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
* [AshPostgres](https://hexdocs.pm/ash_postgres) | PostgreSQL data layer
* [AshSqlite](https://hexdocs.pm/ash_sqlite) | SQLite data layer
* [AshCsv](https://hexdocs.pm/ash_csv) | CSV data layer
* [AshCubdb](https://hexdocs.pm/ash_cubdb) | CubDB data layer
#### API Extensions
* [AshJsonApi](https://hexdocs.pm/ash_json_api) | JSON:API builder
* [AshGraphql](https://hexdocs.pm/ash_graphql) | GraphQL builder
#### Web
* [AshPhoenix](https://hexdocs.pm/ash_phoenix) | Phoenix integrations
* [AshAuthentication](https://hexdocs.pm/ash_authentication) | Authenticate users with password, OAuth, and more
* [AshAuthenticationPhoenix](https://hexdocs.pm/ash_authentication_phoenix) | Integrations for AshAuthentication and Phoenix
#### Finance
* [AshMoney](https://hexdocs.pm/ash_money) | A money data type for Ash
* [AshDoubleEntry](https://hexdocs.pm/ash_double_entry) | A double entry system backed by Ash Resources
#### Resource Utilities
* [AshOban](https://hexdocs.pm/ash_oban) | Background jobs and scheduled jobs for Ash, backed by Oban
* [AshArchival](https://hexdocs.pm/ash_archival) | Archive resources instead of deleting them
* [AshStateMachine](https://hexdocs.pm/ash_state_machine) | Create state machines for resources
* [AshPaperTrail](https://hexdocs.pm/ash_paper_trail) | Keep a history of changes to resources
* [AshCloak](https://hexdocs.pm/ash_cloak) | Encrypt attributes of a resource
#### Admin & Monitoring
* [AshAdmin](https://hexdocs.pm/ash_admin) | A push-button admin interface
* [AshAppsignal](https://hexdocs.pm/ash_appsignal) | Monitor your Ash resources with AppSignal
#### Testing
* [Smokestack](https://hexdocs.pm/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.