commit 1fe4a43ff8093761e2cfc6db5c9f131174ab2ff0 Author: Zach Daniel Date: Fri Sep 29 15:38:27 2023 -0400 WIP diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a72ba7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +ash_event_source-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd9e6df --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# AshEventSource + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `ash_event_source` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:ash_event_source, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..7f4576b --- /dev/null +++ b/config/config.exs @@ -0,0 +1,6 @@ +import Config + +if Mix.env() == :test do + config :ash, :validate_api_resource_inclusion?, false + config :ash, :validate_api_config_inclusion?, false +end diff --git a/lib/ash_event_source.ex b/lib/ash_event_source.ex new file mode 100644 index 0000000..eb98504 --- /dev/null +++ b/lib/ash_event_source.ex @@ -0,0 +1,20 @@ +defmodule AshEventSource do + @event_source %Spark.Dsl.Section{ + name: :event_source, + schema: [ + event_resource: [ + type: {:behaviour, Ash.Resource}, + required: true, + doc: "The resource to use to store events." + ] + ] + } + + @sections [@event_source] + + @transformers [ + AshEventSource.Transformers.RewriteActions + ] + + use Spark.Dsl.Extension, transformers: @transformers, sections: @sections +end diff --git a/lib/info.ex b/lib/info.ex new file mode 100644 index 0000000..38ff356 --- /dev/null +++ b/lib/info.ex @@ -0,0 +1,3 @@ +defmodule AshEventSource.Info do + use Spark.InfoGenerator, extension: AshEventSource, sections: [:event_source] +end diff --git a/lib/persist_event.ex b/lib/persist_event.ex new file mode 100644 index 0000000..d5e3ac3 --- /dev/null +++ b/lib/persist_event.ex @@ -0,0 +1,44 @@ +defmodule AshEventSource.PersistEvent do + def run(input, opts, context) do + event = + opts[:event_resource] + |> Ash.Changeset.for_create(:create, %{input: input.params, resource: input.resource, action: opts[:action]}, actor: Ash.context_to_opts(context)) + |> input.api.create!() + + event.resource + |> Ash.Changeset.for_create(event.action, event.input) + |> input.api.create!() + + event = + event + |> Ash.Changeset.for_update(:process) + |> input.api.update!() + + + {:ok, :success} + end + + # use Ash.Resource.ManualCreate + # use Ash.Resource.ManualUpdate + + # @impl Ash.Resource.ManualCreate + # def create(changeset, opts, context) do + # input = changeset.input + + + # # This is a hack, need to figure this out + # Ash.Changeset.apply_attributes(changeset, force?: true) + # end + + # @impl Ash.Resource.ManualUpdate + # def update(changeset, opts, context) do + # input = changeset.input + + # opts[:resource] + # |> Ash.Changeset.for_create(:create, %{input: input}, actor: Ash.context_to_opts(context)) + # |> changeset.api.create!() + + # # This is a hack, need to figure this out + # Ash.Changeset.apply_attributes(changeset, force?: true) + # end +end diff --git a/lib/transformers/rewrite_actions.ex b/lib/transformers/rewrite_actions.ex new file mode 100644 index 0000000..b823b8f --- /dev/null +++ b/lib/transformers/rewrite_actions.ex @@ -0,0 +1,54 @@ +defmodule AshEventSource.Transformers.RewriteActions do + @moduledoc "Rewrite each create, update and destroy action into event sourced actions" + use Spark.Dsl.Transformer + + def after?(_), do: true + + def transform(dsl) do + event_resource = AshEventSource.Info.event_source_event_resource!(dsl) + + dsl + |> Ash.Resource.Info.actions() + |> Enum.filter(&(&1.type in [:create, :update, :destroy])) + |> Enum.reduce({:ok, dsl}, fn action, {:ok, dsl} -> + new_name = :"#{action.name}_implementation" + copied_action = %{action | name: new_name} + + arguments = + action.accept + |> Enum.map(fn attr_name -> + attr = Ash.Resource.Info.attribute(dsl, attr_name) + + %Ash.Resource.Actions.Argument{ + name: attr.name, + allow_nil?: attr.allow_nil?, + type: attr.type, + constraints: attr.constraints, + default: attr.default, + description: attr.description, + sensitive?: attr.sensitive? + } + end) + + arguments = + action.arguments + |> Enum.concat(arguments) + |> Enum.uniq_by(&(&1.name)) + + action = %Ash.Resource.Actions.Action{ + name: action.name, + description: action.description, + returns: :atom, + constraints: [one_of: [:success, :failure]], + arguments: arguments, + run: {AshEventSource.PersistEvent, [event_resource: event_resource, action: new_name]}, + transaction?: true + } + + {:ok, + dsl + |> Spark.Dsl.Transformer.replace_entity([:actions], action, &(&1.name == action.name)) + |> Spark.Dsl.Transformer.add_entity([:actions], copied_action)} + end) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1c26aca --- /dev/null +++ b/mix.exs @@ -0,0 +1,29 @@ +defmodule AshEventSource.MixProject do + use Mix.Project + + def project do + [ + app: :ash_event_source, + version: "0.1.0", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ash, "~> 2.14"} + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..0a3da42 --- /dev/null +++ b/mix.lock @@ -0,0 +1,16 @@ +%{ + "ash": {:hex, :ash, "2.14.12", "bb2e3dbe82b49407f07854560ba3174a2c479508da8f33eb296764c6445477fb", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 1.1.20 and < 2.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "799edb6326bd539ede0582b3e62f015a74062d3291e7b2c21ce4b0a2f9ebe88c"}, + "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, + "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, + "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, + "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, + "sourceror": {:hex, :sourceror, "0.13.0", "c6ecc96ee3ae0e042e9082a9550a1989ea40182492dc29024a8d9d2b136e5014", [:mix], [], "hexpm", "d0a819491061cd26bfa4450d1c84301a410c19c1782a6577ce15853fc0e7e4e1"}, + "spark": {:hex, :spark, "1.1.25", "9a4836520b71a485f5dedfdab1909071c01375b0409eb4cc2e9cfa8cc28c0398", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "506dfb6e8a851362ed5bb056644f9f852ec7513df77c65c09ef8f71518a91640"}, + "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, +} diff --git a/test/ash_event_source_test.exs b/test/ash_event_source_test.exs new file mode 100644 index 0000000..084d2b6 --- /dev/null +++ b/test/ash_event_source_test.exs @@ -0,0 +1,153 @@ +defmodule AshEventSourceTest do + use ExUnit.Case + + require Ash.Query + + defmodule Api do + use Ash.Api + + resources do + allow_unregistered?(true) + end + end + + defmodule Event do + use Ash.Resource, + data_layer: Ash.DataLayer.Ets + + actions do + defaults [:read, :create, :update, :destroy] + + update :process do + change set_attribute(:processed, true) + end + end + + ets do + private?(true) + end + + attributes do + uuid_primary_key(:id) + attribute :input, :map, allow_nil?: false + attribute :resource, :atom, allow_nil?: false + attribute :action, :atom, allow_nil?: false + attribute :processed, :boolean, allow_nil?: false, default: false + attribute :timestamp, :utc_datetime_usec do + default &DateTime.utc_now/0 + allow_nil? false + writable? false + end + end + end + + defmodule Profile do + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshEventSource] + + event_source do + event_resource(Event) + end + + actions do + defaults [:create, :read, :update, :destroy] + end + + ets do + private?(true) + end + + attributes do + uuid_primary_key :id + attribute :bio, :string + end + + relationships do + belongs_to :user, AshEventSourceTest.User do + allow_nil? false + attribute_writable? true + end + end + + code_interface do + define_for Api + define :create + end + end + + defmodule User do + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshEventSource] + + event_source do + event_resource(Event) + end + + ets do + private?(true) + end + + attributes do + uuid_primary_key(:id) + attribute(:username, :string, allow_nil?: false) + end + + actions do + defaults([:read, :update, :destroy]) + create :create do + primary? true + + change after_action(fn _changeset, result -> + Profile.create!(%{user_id: result.id, bio: "Initial Bio!"}) + + {:ok, result} + end) + end + end + + code_interface do + define_for(Api) + define(:create) + end + + relationships do + has_one :profile, Profile + end + end + + test "test" do + User.create!(%{username: "fred"}) + # IO.inspect("FIRST EVENT CREATED") + Api.read!(Event) |> IO.inspect() + Api.read!(User) |> IO.inspect() + Api.read!(Profile) |> IO.inspect() + # process() + # IO.inspect("FIRST EVENT PROCESSED") + # Api.read!(Event) |> IO.inspect() + # Api.read!(User) |> IO.inspect() + # Api.read!(Profile) |> IO.inspect() + # process() + # IO.inspect("SECOND EVENT PROCESSED") + # Api.read!(Event) |> IO.inspect() + # Api.read!(User) |> IO.inspect() + # Api.read!(Profile) |> IO.inspect() + end + + defp process() do + Event + |> Ash.Query.sort(timestamp: :asc, id: :desc) + |> Ash.Query.filter(processed == false) + |> Api.read!() + |> Enum.each(fn event -> + event.resource + |> Ash.Changeset.for_create(event.action, event.input) + |> Api.create!() + + event + |> Ash.Changeset.for_update(:process) + |> Api.update!() + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()