improvement: update to support new atomics & bulk actions

This commit is contained in:
Zach Daniel 2024-04-29 15:40:06 -04:00
parent 1a0819d1c5
commit c47c548a78
9 changed files with 181 additions and 107 deletions

View file

@ -20,6 +20,9 @@ if config_env() == :test do
config :ash, :disable_async?, true
end
config :ash, :known_types, [AshMoney.Types.Money]
config :logger, level: :error
config :spark, :formatter,
remove_parens?: true,
"Ash.Resource": []

View file

@ -22,6 +22,7 @@ An extension for creating a double entry ledger balance. See the getting started
| [`account_resource`](#balance-account_resource){: #balance-account_resource .spark-required} | `module` | | The resource used for accounts |
| [`pre_check_identities_with`](#balance-pre_check_identities_with){: #balance-pre_check_identities_with } | `module` | | A domain to use to precheck generated identities. Required by certain data layers. |
| [`money_composite_type?`](#balance-money_composite_type?){: #balance-money_composite_type? } | `boolean` | `true` | Whether the balance is stored as a composite type. |
| [`data_layer_can_add_money?`](#balance-data_layer_can_add_money?){: #balance-data_layer_can_add_money? } | `boolean` | `true` | Whether or not the data layer supports adding money. |

View file

@ -24,6 +24,11 @@ defmodule AshDoubleEntry.Balance do
type: :boolean,
doc: "Whether the balance is stored as a composite type.",
default: true
],
data_layer_can_add_money?: [
type: :boolean,
doc: "Whether or not the data layer supports adding money.",
default: true
]
]
}

View file

@ -0,0 +1,45 @@
defmodule AshDoubleEntry.Balance.Changes.AdjustBalance do
@moduledoc false
use Ash.Resource.Change
def change(changeset, _, _) do
amount_delta = changeset.arguments.delta
new_balance =
if changeset.data.account_id == changeset.arguments.from_account_id do
Money.sub!(changeset.data.balance, amount_delta)
else
Money.add!(changeset.data.balance, amount_delta)
end
Ash.Changeset.force_change_attribute(changeset, :balance, new_balance)
end
def atomic(changeset, opts, _) do
amount_delta = changeset.arguments.delta
if Ash.Expr.expr?(amount_delta) do
raise """
Amount delta is dynamic. The balance adjustment logic does not support this.
Expected a literal money value, got an expression: #{inspect(amount_delta)}
"""
end
if opts[:can_add_money?] do
{:atomic,
%{
balance:
expr(
if account_id == ^changeset.arguments.from_account_id do
^atomic_ref(:balance) - ^amount_delta
else
^atomic_ref(:balance) + ^amount_delta
end
)
}}
else
{:not_atomic, "Data layer cannot add money, so balance cannot be adjusted atomically"}
end
end
end

View file

@ -3,6 +3,7 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do
@moduledoc false
use Spark.Dsl.Transformer
import Spark.Dsl.Builder
import Ash.Expr
def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true
def before?(Ash.Resource.Transformers.BelongsToAttribute), do: true
@ -51,6 +52,32 @@ defmodule AshDoubleEntry.Balance.Transformers.AddStructure do
upsert?: true,
upsert_identity: :unique_references
)
|> Ash.Resource.Builder.add_action(:update, :adjust_balance,
changes: [
Ash.Resource.Builder.build_action_change(
{Ash.Resource.Change.Filter,
filter:
expr(
account_id in [^arg(:from_account_id), ^arg(:to_account_id)] and
transfer_id > ^arg(:transfer_id)
)}
),
Ash.Resource.Builder.build_action_change(
{AshDoubleEntry.Balance.Changes.AdjustBalance,
can_add_money?: AshDoubleEntry.Balance.Info.balance_data_layer_can_add_money?(dsl)}
)
],
arguments: [
Ash.Resource.Builder.build_action_argument(:from_account_id, :uuid, allow_nil?: false),
Ash.Resource.Builder.build_action_argument(:to_account_id, :uuid, allow_nil?: false),
Ash.Resource.Builder.build_action_argument(:delta, AshMoney.Types.Money,
allow_nil?: false
),
Ash.Resource.Builder.build_action_argument(:transfer_id, AshDoubleEntry.ULID,
allow_nil?: false
)
]
)
|> Ash.Resource.Builder.add_identity(:unique_references, [:account_id, :transfer_id],
pre_check_with: pre_check_with(dsl)
)

View file

@ -80,77 +80,55 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do
amount_delta
)
unless changeset.action.type == :destroy do
changeset.resource
|> AshDoubleEntry.Transfer.Info.transfer_balance_resource!()
|> Ash.Changeset.for_create(
:upsert_balance,
%{
account_id: from_account.id,
transfer_id: result.id,
balance: new_from_account_balance,
account: from_account
},
Ash.Context.to_opts(context,
domain: changeset.domain,
skip_unknown_inputs: [:account_id, :transfer_id, :balance, :account]
)
)
|> Ash.create!()
balance_resource =
AshDoubleEntry.Transfer.Info.transfer_balance_resource!(changeset.resource)
changeset.resource
|> AshDoubleEntry.Transfer.Info.transfer_balance_resource!()
|> Ash.Changeset.for_create(
unless changeset.action.type == :destroy do
Ash.bulk_create!(
[
%{
account_id: from_account.id,
transfer_id: result.id,
balance: new_from_account_balance
},
%{
account_id: to_account.id,
transfer_id: result.id,
balance: new_to_account_balance
}
],
balance_resource,
:upsert_balance,
%{
account_id: to_account.id,
transfer_id: result.id,
balance: new_to_account_balance
},
Ash.Context.to_opts(context,
domain: changeset.domain,
skip_unknown_inputs: [:account_id, :transfer_id, :balance]
upsert_fields: [:balance],
return_errors?: true,
stop_on_error?: true
)
)
|> Ash.create!()
end
# Turn this into a bulk update when we support it in Ash core
changeset.resource
|> AshDoubleEntry.Transfer.Info.transfer_balance_resource!()
|> Ash.Query.filter(account_id in ^[from_account.id, to_account.id])
|> Ash.Query.filter(transfer_id > ^result.id)
|> Ash.stream!(Ash.Context.to_opts(context, domain: changeset.domain))
|> Stream.map(fn balance ->
amount_delta =
if changeset.action.type == :destroy do
Money.mult!(amount_delta, -1)
else
amount_delta
end
if balance.account_id == from_account.id do
%{
account_id: balance.account_id,
transfer_id: balance.transfer_id,
balance: Money.sub!(balance.balance, amount_delta)
}
amount_delta =
if changeset.action.type == :destroy do
Money.mult!(amount_delta, -1)
else
%{
account_id: balance.account_id,
transfer_id: balance.transfer_id,
balance: Money.add!(balance.balance, amount_delta)
}
amount_delta
end
end)
|> Ash.bulk_create!(
AshDoubleEntry.Transfer.Info.transfer_balance_resource!(changeset.resource),
:upsert_balance,
Ash.bulk_update!(
balance_resource,
:adjust_balance,
%{
from_account_id: from_account.id,
to_account_id: to_account.id,
transfer_id: result.id,
delta: amount_delta
},
Ash.Context.to_opts(context,
domain: changeset.domain,
strategy: [:atomic, :stream, :atomic_batches],
return_errors?: true,
stop_on_error?: true,
upsert_fields: [:balance]
stop_on_error?: true
)
)
@ -174,16 +152,15 @@ defmodule AshDoubleEntry.Transfer.Changes.VerifyTransfer do
Ash.Changeset.before_action(changeset, fn changeset ->
balance_resource
|> Ash.Query.filter(transfer_id == ^changeset.data.id)
|> Ash.stream!(Ash.Context.to_opts(context, authorize?: false, domain: changeset.domain))
|> Enum.each(fn balance ->
balance
|> Ash.Changeset.for_destroy(
destroy_action,
%{},
Ash.Context.to_opts(context, authorize?: false, domain: changeset.domain)
|> Ash.bulk_destroy!(
destroy_action,
%{},
Ash.Context.to_opts(context,
authorize?: false,
domain: changeset.domain,
strategy: [:stream, :atomic, :atomic_batches]
)
|> Ash.destroy!()
end)
)
changeset
end)

View file

@ -116,8 +116,8 @@ defmodule AshDoubleEntry.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ash, ash_version("~> 3.0.0-rc.7")},
{:ash_money, "~> 0.1.6-rc.0"},
{:ash, ash_version("~> 3.0.0-rc")},
{:ash_money, path: "../ash_money"},
{:ex_money_sql, "~> 1.10"},
# dev/test dependencies
{:git_ops, "~> 2.5", only: [:dev, :test]},

View file

@ -15,11 +15,11 @@
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"},
"ex_cldr": {:hex, :ex_cldr, "2.37.5", "9da6d97334035b961d2c2de167dc6af8cd3e09859301a5b8f49f90bd8b034593", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "74ad5ddff791112ce4156382e171a5f5d3766af9d5c4675e0571f081fe136479"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.15.1", "e92ba17c41e7405b7784e0e65f406b5f17cfe313e0e70de9befd653e12854822", [:mix], [{:ex_cldr, "~> 2.34", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "31df8bd37688340f8819bdd770eb17d659652078d34db632b85d4a32864d6a25"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.32.4", "5562148dfc631b04712983975093d2aac29df30b3bf2f7257e0c94b85b72e91b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.37", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "6fd5a82f0785418fa8b698c0be2b1845dff92b77f1b3172c763d37868fb503d2"},
"ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "a99e02654fb6910660921df72838a05e44f2411a", []},
"ex_money": {:hex, :ex_money, "5.15.4", "9c933cd65e943d9f48cb9c6878d3eaa535baade94ffc8be7630e619c99658d95", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.31", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "196a28df6b9195d566a4ba0860d2f87b45738032b9d6f87d1ca067987a4de5bb"},
"ex_cldr": {:hex, :ex_cldr, "2.38.0", "80399ccbfd996ea02f245394db83d7ce6113ed88e1e50e4695238cd9a0c258d5", [:mix], [{:cldr_utils, "~> 2.25", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "8758000c97bdf4b2583c3fedd7cfa35896567a7f8351248b2faa33ba73841cc7"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.16.1", "29317f533cb5ec046d04523256cca4090291e9157028f28731395149b06ff8b2", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "095d5e973bf0ee066dd1153990d10cb6fa6d8ff0e028295bdce7a7821c70a0e4"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.33.1", "49dc6e77e6d9ad22660aaa2480a7408ad3aedfbe517e4e83e5fe3a7bf5345770", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.16", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "c003bfaa3fdee6bab5195f128b94038c2ce1cf4879a759eef431dd075d9a5dac"},
"ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "f8075a37b44b406bc489ee9cb1374f6e3d3bb8ee", []},
"ex_money": {:hex, :ex_money, "5.16.1", "99c07552948ab437ce8c7a43c80d59571145ba7c42a466e4c8ce687c71fa0f57", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.33", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "c4c894a0a1423df31b3f0442692b1cb8d51ba826ff25778382803a059a04ae4f"},
"ex_money_sql": {:hex, :ex_money_sql, "1.11.0", "1b9b2f920d5d9220fa6dd4d8aae258daf562deaed2fb037b38b1f7ba4d0a344c", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_money, "~> 5.7", [hex: :ex_money, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.15", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "629e0541ae9f87122d34650f8c8febbc7349bbc6f881cf7a51b4d0779886107d"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},

View file

@ -1,14 +1,47 @@
defmodule AshDoubleEntryTest do
use ExUnit.Case
import ExUnit.CaptureLog
require Ash.Query
defmodule RequiresPositiveBalance do
use Ash.Resource.Validation
def validate(changeset, _, _) do
account_id = Ash.Changeset.get_attribute(changeset, :account_id)
if is_nil(account_id) do
:ok
else
account = Ash.get!(AshDoubleEntryTest.Account, account_id, authorize?: false)
if account.allow_zero_balance do
{:error, "Account must require positive balance"}
else
:ok
end
end
end
def atomic(_changeset, _, _) do
{:atomic, [:account], expr(account.allow_zero_balance == false),
expr(
error(Ash.Error.Changes.InvalidRelationship,
relationship: :account,
message: "Account must require positive balance"
)
)}
end
end
defmodule Account do
use Ash.Resource,
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Mnesia,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Account]
ets do
private? true
end
account do
pre_check_identities_with AshDoubleEntryTest.Domain
transfer_resource AshDoubleEntryTest.Transfer
@ -26,9 +59,13 @@ defmodule AshDoubleEntryTest do
defmodule Transfer do
use Ash.Resource,
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Mnesia,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Transfer]
ets do
private? true
end
transfer do
account_resource Account
balance_resource AshDoubleEntryTest.Balance
@ -42,9 +79,13 @@ defmodule AshDoubleEntryTest do
defmodule Balance do
use Ash.Resource,
domain: AshDoubleEntryTest.Domain,
data_layer: Ash.DataLayer.Mnesia,
data_layer: Ash.DataLayer.Ets,
extensions: [AshDoubleEntry.Balance]
ets do
private? true
end
balance do
pre_check_identities_with AshDoubleEntryTest.Domain
transfer_resource Transfer
@ -55,24 +96,10 @@ defmodule AshDoubleEntryTest do
defaults [:destroy]
end
changes do
change after_action(&validate_balance/3)
end
defp validate_balance(_changeset, result, _context) do
account = result |> Ash.load!(:account) |> Map.get(:account)
if account.allow_zero_balance == false &&
Money.negative?(result.balance) do
{:error,
Ash.Error.Changes.InvalidAttribute.exception(
value: result.balance,
field: :balance,
message: "balance cannot be negative"
)}
else
{:ok, result}
end
validations do
validate compare(:balance, greater_than_or_equal_to: 0),
where: [RequiresPositiveBalance],
message: "balance cannot be negative"
end
end
@ -86,17 +113,6 @@ defmodule AshDoubleEntryTest do
end
end
setup do
Ash.DataLayer.Mnesia.start(Domain)
on_exit(fn ->
capture_log(fn ->
:mnesia.stop()
:mnesia.delete_schema([node()])
end)
end)
end
describe "opening accounts" do
test "an account can be opened" do
assert %{identifier: "account_one"} =