From cad7a65fae384e3cb2faeef2f392cb478abd3981 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 27 Apr 2023 22:07:05 -0400 Subject: [PATCH] improvement: initial feature set --- README.md | 12 +- documentation/get-started-with-ash-oban.md | 62 +++++ lib/ash_oban.ex | 203 ++++++++++++++- lib/changes/builtin_changes.ex | 7 + lib/changes/run_oban_trigger.ex | 20 ++ lib/checks/ash_oban_interaction.ex | 28 ++ lib/info.ex | 10 + lib/transformers/define_schedulers.ex | 289 +++++++++++++++++++++ lib/transformers/set_defaults.ex | 62 +++++ lib/verifiers/define_schedulers.ex | 14 - mix.exs | 26 +- mix.lock | 23 +- test/ash_oban_test.exs | 18 +- 13 files changed, 718 insertions(+), 56 deletions(-) create mode 100644 documentation/get-started-with-ash-oban.md create mode 100644 lib/changes/builtin_changes.ex create mode 100644 lib/changes/run_oban_trigger.ex create mode 100644 lib/checks/ash_oban_interaction.ex create mode 100644 lib/transformers/define_schedulers.ex create mode 100644 lib/transformers/set_defaults.ex delete mode 100644 lib/verifiers/define_schedulers.ex diff --git a/README.md b/README.md index 70d955a..91ab88b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ -# AshOban +# AshStateMachine -**TODO: Add description** +An oban extension for `Ash.Resource` ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `ash_oban` to your list of dependencies in `mix.exs`: - ```elixir def deps do [ @@ -15,7 +12,6 @@ def deps do 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 . +## Get Started +Check out the [getting started guide](/documentation/tutorials/get-started-with-ash-oban.md). diff --git a/documentation/get-started-with-ash-oban.md b/documentation/get-started-with-ash-oban.md new file mode 100644 index 0000000..ceacf00 --- /dev/null +++ b/documentation/get-started-with-ash-oban.md @@ -0,0 +1,62 @@ +# Get Started With Ash Oban + +AshOban will likely grow to provide many more oban-related features, but for now the primary focus is on "triggers". + +A trigger describes an action that is run periodically. + +This guide will need to be expanded, this is primarily a placeholder with an example. + +## Setup + +First, follow the oban setup guide. + +If you are using Oban Pro, set the following configuration: + +```elixir +config :ash_oban, :pro?, true +``` + +Next, allow AshOban to alter your configuration + +```elixir +# in your application +{Oban, AshOban.config([YourApi, YourOtherApi], your_oban_config)} +``` + +## Usage + +Finally, configure your triggers in your resources. + +Add the `AshOban` extension: + +```elixir +use Ash.Resource, extensions: [AshOban] +``` + +For example: + +```elixir +oban do + triggers do + api YourApi + + # add a triggere called `:process` + trigger :process do + # this trigger calls the `process` action + action :process + # for any record that has `processed != true` + where expr(processed != true) + # checking for matches every minute + scheduler_cron "* * * * *" + end + end +end +``` + +See the DSL documentation for more: `AshOban` + +## Changing Triggers + +To remove or disable triggers, *do not just remove them from your resource*. Due to the way that oban implements cron jobs, if you just remove them from your resource, the cron will attempt to continue scheduling jobs. Instead, set `paused true` or `delete true` on the trigger. See the oban docs for more: https://getoban.pro/docs/pro/0.14.1/Oban.Pro.Plugins.DynamicCron.html#module-using-and-configuring + + diff --git a/lib/ash_oban.ex b/lib/ash_oban.ex index 4621ba0..4deeb6e 100644 --- a/lib/ash_oban.ex +++ b/lib/ash_oban.ex @@ -1,26 +1,96 @@ defmodule AshOban do - @moduledoc """ - Documentation for `AshOban`. - """ - defmodule Trigger do @type t :: %__MODULE__{ + name: atom, action: atom, - where: Ash.Expr.t() + read_action: atom, + queue: atom, + scheduler_cron: String.t(), + scheduler_queue: atom, + max_attempts: pos_integer(), + max_scheduler_attempts: pos_integer(), + where: Ash.Expr.t(), + scheduler: module, + worker: module, + __identifier__: atom } - defstruct [:action, :where] + defstruct [ + :name, + :action, + :read_action, + :queue, + :scheduler_cron, + :scheduler_queue, + :max_attempts, + :max_scheduler_attempts, + :where, + :scheduler, + :worker, + :__identifier__ + ] end @trigger %Spark.Dsl.Entity{ name: :trigger, target: Trigger, - args: [:action], + args: [:name], + identifier: :name, imports: [Ash.Filter.TemplateHelpers], schema: [ + name: [ + type: :atom, + doc: "A unique identifier for this trigger." + ], + scheduler_queue: [ + type: :atom, + doc: + "The queue to place the scheduler job in. The trigger name plus \"_scheduler\" is used by default." + ], + scheduler_cron: [ + type: :string, + default: "* * * * *", + doc: + "A crontab configuration for when the job should run. Defaults to once per minute (\"* * * * *\")." + ], + queue: [ + type: :atom, + doc: "The queue to place the worker job in. The trigger name is used by default." + ], + max_scheduler_attempts: [ + type: :pos_integer, + default: 1, + doc: "How many times to attempt scheduling of the triggered action." + ], + max_attempts: [ + type: :pos_integer, + default: 1, + doc: "How many times to attempt the job." + ], + state: [ + type: {:one_of, [:active, :paused, :deleted]}, + doc: """ + Describes the state of the cron job. + + See the getting started guide for an explanation on the need for this field. + The most important thing is that you *do not remove a trigger from a resource*. + Oban's cron jobs are persisted, meaning you will get repeated errors whenever the cron + job tries to fire. + """ + ], + read_action: [ + type: :atom, + required: true, + doc: """ + The read action to use when querying records. Defaults to the primary read. + + This action *must* support keyset pagination. + """ + ], action: [ type: :atom, - doc: "The action to be triggered" + doc: + "The action to be triggered. Defaults to the identifier of the resource plus the name of the trigger" ], where: [ type: :any, @@ -36,13 +106,124 @@ defmodule AshOban do @oban %Spark.Dsl.Section{ name: :oban, + schema: [ + api: [ + type: {:behaviour, Ash.Api}, + doc: "The Api module to use when calling actions on this resource", + required: true + ] + ], sections: [@triggers] } + @sections [@oban] + + @moduledoc """ + Dsl documentation for AshOban + + + ## DSL Documentation + + ### Index + + #{Spark.Dsl.Extension.doc_index(@sections)} + + ### Docs + + #{Spark.Dsl.Extension.doc(@sections)} + + """ + use Spark.Dsl.Extension, sections: [@oban], - verifiers: [ - # This is a bit dumb, a verifier probably shouldn't have side effects - AshOban.Verifiers.DefineSchedulers + imports: [AshOban.Changes.BuiltinChanges], + transformers: [ + AshOban.Transformers.SetDefaults, + AshOban.Transformers.DefineSchedulers ] + + def config(apis, base \\ %{}) do + pro? = AshOban.Info.pro?() + + cron_plugin = + if pro? do + Oban.Pro.Plugins.DynamicCron + else + Oban.Pro.Plugins.Cron + end + + if pro? && base[:engine] != Oban.Pro.Queue.SmartEngine do + raise """ + Expected oban engine to be Oban.Pro.Queue.SmartEngine, but got #{inspect(base[:engine])}. + This expectation is because you've set `config :ash_oban, pro?: true`. + """ + end + + require_cron!(base, cron_plugin) + + apis + |> Enum.flat_map(fn api -> + api + |> Ash.Api.Info.resources() + end) + |> Enum.uniq() + |> Enum.flat_map(fn resource -> + resource + |> AshOban.Info.oban_triggers() + |> Enum.map(&{resource, &1}) + end) + |> Enum.reduce(base, fn {resource, trigger}, config -> + require_queues!(config, resource, trigger) + add_job(config, cron_plugin, resource, trigger) + end) + end + + defp add_job(config, cron_plugin, _resource, trigger) do + Keyword.update!(config, :plugins, fn plugins -> + Keyword.update!(plugins, cron_plugin, fn plugins -> + opts = + case trigger.state do + :paused -> + [paused: true] + + :deleted -> + [delete: true] + + _ -> + [] + end + + if(trigger.state = cron = {trigger.scheduler_cron, trigger.scheduler, []}) + Keyword.update(plugins, :crontab, [cron], &[cron | &1]) + end) + end) + end + + defp require_queues!(config, resource, trigger) do + unless config[:queues][trigger.queue] do + raise """ + Must configure the queue `:#{trigger.queue}`, requied for + the trigger `:#{trigger.name}` on #{inspect(resource)} + """ + end + + unless config[:queues][trigger.scheduler_queue] do + raise """ + Must configure the queue `:#{trigger.queue}`, required for + the scheduler of the trigger `:#{trigger.name}` on #{inspect(resource)} + """ + end + end + + defp require_cron!(config, name) do + unless config[:plugins][name] do + raise """ + Must configure cron plugin #{name}. + + See oban's documentation for more. AshOban will + add cron jobs to the configuration, but will not + add the basic configuration for you. + """ + end + end end diff --git a/lib/changes/builtin_changes.ex b/lib/changes/builtin_changes.ex new file mode 100644 index 0000000..4e9eda0 --- /dev/null +++ b/lib/changes/builtin_changes.ex @@ -0,0 +1,7 @@ +defmodule AshOban.Changes.BuiltinChanges do + @moduledoc "Builtin changes for `AshOban`" + + def run_oban_trigger(trigger_name) do + {AshOban.Changes.RunObanTrigger, trigger: trigger_name} + end +end diff --git a/lib/changes/run_oban_trigger.ex b/lib/changes/run_oban_trigger.ex new file mode 100644 index 0000000..d475f66 --- /dev/null +++ b/lib/changes/run_oban_trigger.ex @@ -0,0 +1,20 @@ +defmodule AshOban.Changes.RunObanTrigger do + use Ash.Resource.Change + + def change(changeset, opts, _context) do + trigger = AshOban.Info.oban_trigger(changeset.resource, opts[:trigger]) + primary_key = Ash.Resource.Info.primary_key(changeset.resource) + + if !trigger do + raise "No such trigger #{opts[:trigger]} for resource #{inspect(changeset.resource)}" + end + + Ash.Changeset.after_action(changeset, fn _changeset, result -> + %{primary_key: Map.take(result, primary_key)} + |> trigger.worker.new() + |> Oban.insert!() + + {:ok, result} + end) + end +end diff --git a/lib/checks/ash_oban_interaction.ex b/lib/checks/ash_oban_interaction.ex new file mode 100644 index 0000000..9f0ac4f --- /dev/null +++ b/lib/checks/ash_oban_interaction.ex @@ -0,0 +1,28 @@ +defmodule AshOban.Checks.AshObanInteraction do + @moduledoc """ + This check is true if the context `private.ash_oban?` is set to true. + + This context will only ever be set in code that is called internally by + `ash_oban`, allowing you to create a bypass in your policies on your + user/user_token resources. + + ```elixir + policies do + bypass AshObanInteraction do + authorize_if always() + end + end + ``` + """ + use Ash.Policy.SimpleCheck + + @impl Ash.Policy.Check + def describe(_) do + "AshOban is performing this interaction" + end + + @impl Ash.Policy.SimpleCheck + def match?(_, %{query: %{context: %{private: %{ash_oban?: true}}}}, _), do: true + def match?(_, %{changeset: %{context: %{private: %{ash_oban?: true}}}}, _), do: true + def match?(_, _, _), do: false +end diff --git a/lib/info.ex b/lib/info.ex index 4daa972..60862fe 100644 --- a/lib/info.ex +++ b/lib/info.ex @@ -1,3 +1,13 @@ defmodule AshOban.Info do use Spark.InfoGenerator, extension: AshOban, sections: [:oban] + @pro Application.compile_env(:ash_oban, :pro?) || false + + def pro?, do: @pro + + @spec oban_trigger(Ash.Resource.t() | Spark.Dsl.t(), atom) :: nil | AshOban.Trigger.t() + def oban_trigger(resource, name) do + resource + |> oban_triggers() + |> Enum.find(&(&1.name == name)) + end end diff --git a/lib/transformers/define_schedulers.ex b/lib/transformers/define_schedulers.ex new file mode 100644 index 0000000..54dffe2 --- /dev/null +++ b/lib/transformers/define_schedulers.ex @@ -0,0 +1,289 @@ +defmodule AshOban.Transformers.DefineSchedulers do + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + def after?(AshOban.Transformers.SetDefaults), do: true + def after?(_), do: false + + def transform(dsl) do + module = Transformer.get_persisted(dsl, :module) + + dsl + |> AshOban.Info.oban_triggers() + |> Enum.reduce(dsl, fn trigger, dsl -> + scheduler_module_name = module_name(module, trigger, "Scheduler") + worker_module_name = module_name(module, trigger, "Worker") + + dsl + |> Transformer.replace_entity([:oban, :triggers], %{ + trigger + | scheduler: scheduler_module_name, + worker: worker_module_name + }) + |> Transformer.async_compile(fn -> + define_worker(module, worker_module_name, trigger, dsl) + end) + |> Transformer.async_compile(fn -> + define_scheduler(module, scheduler_module_name, worker_module_name, trigger, dsl) + end) + end) + |> then(&{:ok, &1}) + end + + defp module_name(module, trigger, type) do + module + |> List.wrap() + |> Enum.concat(["AshOban", type]) + |> Enum.concat([Macro.camelize(to_string(trigger.name))]) + |> Module.concat() + end + + defp define_scheduler(resource, scheduler_module_name, worker_module_name, trigger, dsl) do + api = AshOban.Info.oban_api!(dsl) + primary_key = Ash.Resource.Info.primary_key(dsl) + pro? = AshOban.Info.pro?() + + filter = + if not is_nil(trigger.where) do + quote do + def filter(query) do + Ash.Query.do_filter(query, unquote(Macro.escape(trigger.where))) + end + end + end + + stream = + if is_nil(trigger.where) do + quote do + def stream(resource) do + resource + |> Ash.Query.set_context(%{private: %{ash_oban?: true}}) + |> Ash.Query.select(unquote(primary_key)) + |> Ash.Query.for_read(unquote(trigger.read_action)) + |> unquote(api).stream!() + end + end + else + quote do + def stream(resource) do + resource + |> Ash.Query.set_context(%{private: %{ash_oban?: true}}) + |> Ash.Query.select(unquote(primary_key)) + |> Ash.Query.for_read(unquote(trigger.read_action)) + |> filter() + |> unquote(api).stream!() + end + end + end + + insert = + if pro? do + quote do + def insert(stream) do + stream + |> Stream.chunk_every(100) + |> Stream.each(&Oban.insert_all!/1) + |> Stream.run() + end + end + else + quote do + def insert(stream) do + stream + |> Stream.each(&Oban.insert!()) + |> Stream.run() + end + end + end + + worker = + if pro? do + Oban.Pro.Worker + else + Oban.Worker + end + + function_name = + if pro? do + :process + else + :perform + end + + quoted = + quote do + use unquote(worker), + queue: unquote(trigger.scheduler_queue), + unique: [ + period: :infinity, + states: [ + :available, + :retryable, + :scheduled + ] + ], + max_attempts: unquote(trigger.max_scheduler_attempts) + + require Logger + + @impl unquote(worker) + if unquote(trigger.state != :active) do + def unquote(function_name)(%Oban.Job{}) do + {:discard, unquote(trigger.state)} + end + else + def unquote(function_name)(%Oban.Job{}) do + unquote(resource) + |> stream() + |> Stream.map(fn record -> + unquote(worker_module_name).new(%{ + primary_key: Map.take(record, unquote(primary_key)) + }) + end) + |> insert() + end + end + + unquote(stream) + unquote(filter) + unquote(insert) + end + + Module.create(scheduler_module_name, quoted, Macro.Env.location(__ENV__)) + end + + defp define_worker(resource, worker_module_name, trigger, dsl) do + api = AshOban.Info.oban_api!(dsl) + pro? = AshOban.Info.pro?() + + worker = + if pro? do + Oban.Pro.Worker + else + Oban.Worker + end + + function_name = + if pro? do + :process + else + :perform + end + + query = + if is_nil(trigger.where) do + quote do + def query do + unquote(resource) + end + end + else + quote do + def query do + Ash.Query.do_filter(unquote(resource), unquote(Macro.escape(trigger.where))) + end + end + end + + Module.create( + worker_module_name, + quote do + use unquote(worker), + max_attempts: 3, + queue: unquote(trigger.queue), + unique: [ + period: :infinity, + states: [ + :available, + :retryable, + :scheduled + ] + ] + + require Logger + + @impl unquote(worker) + if unquote(trigger.state != :active) do + def unquote(function_name)(_) do + {:discard, unquote(trigger.state)} + end + else + def unquote(function_name)(%Oban.Job{args: %{"primary_key" => primary_key}}) do + if Ash.DataLayer.data_layer_can?(unquote(resource), :transact) do + Ash.DataLayer.transaction( + unquote(resource), + fn -> + opts = [ + action: unquote(trigger.read_action), + context: %{private: %{ash_oban?: true}} + ] + + opts = + if Ash.DataLayer.data_layer_can?(unquote(resource), {:lock, :for_update}) do + opts + else + Keyword.put(opts, :lock, :for_update) + end + + query() + |> unquote(api).read_one(primary_key, opts) + |> case do + {:ok, nil} -> + {:discard, :trigger_no_longer_applies} + + {:ok, record} -> + nil + end + |> Ash.Changeset.new() + |> Ash.Changeset.set_context(%{private: %{ash_oban?: true}}) + |> Ash.Changeset.for_update(unquote(trigger.action), %{}) + |> unquote(api).update!() + end, + nil, + %{ + type: :ash_oban_trigger, + metadata: %{ + resource: unquote(resource), + trigger: unquote(trigger.name) + } + } + ) + |> case do + {:ok, {:discard, reason}} -> + {:discard, reason} + + {:ok, _} -> + :ok + + other -> + other + end + else + opts = [ + action: unquote(trigger.read_action), + context: %{private: %{ash_oban?: true}} + ] + + query() + |> unquote(api).read_one(primary_key, opts) + |> case do + {:ok, nil} -> + {:discard, :trigger_no_longer_applies} + + {:ok, record} -> + nil + end + |> Ash.Changeset.new() + |> Ash.Changeset.set_context(%{private: %{ash_oban?: true}}) + |> Ash.Changeset.for_update(unquote(trigger.action), %{}) + |> unquote(api).update() + end + end + end + + unquote(query) + end, + Macro.Env.location(__ENV__) + ) + end +end diff --git a/lib/transformers/set_defaults.ex b/lib/transformers/set_defaults.ex new file mode 100644 index 0000000..3d50099 --- /dev/null +++ b/lib/transformers/set_defaults.ex @@ -0,0 +1,62 @@ +defmodule AshOban.Transformers.SetDefaults do + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + def after?(AshOban.Transformers.DefineSchedulers), do: false + def after?(_), do: true + def before?(AshOban.Transformers.DefineSchedulers), do: true + def before?(_), do: false + + def transform(dsl) do + module = Transformer.get_persisted(dsl, :module) + + {:ok, + dsl + |> Transformer.get_entities([:oban, :triggers]) + |> Enum.reduce(dsl, fn trigger, dsl -> + read_action = + case trigger.read_action do + nil -> + Ash.Resource.Info.primary_action(dsl, :read) || + raise Spark.Error.DslError, + path: [ + :oban, + :triggers, + trigger.name, + :read_action + ], + module: module, + message: """ + No read action was configured for this trigger, and no primary read action exists + """ + + read_action -> + Ash.Resource.Info.action(dsl, read_action) + end + + unless read_action.pagination && read_action.pagination.keyset? do + raise Spark.Error.DslError, + path: [:oban, :triggers, trigger.name, :read_action], + module: module, + message: """ + The read action `:#{read_action.name}` must support keyset pagination in order to be + used by an AshOban trigger. + """ + end + + queue = trigger.queue || default_queue_name(dsl, trigger) + + Transformer.replace_entity(dsl, [:oban, :triggers], %{ + trigger + | read_action: read_action.name, + queue: queue, + scheduler_queue: trigger.scheduler_queue || :"#{queue}_scheduler", + action: trigger.action || trigger.name + }) + end)} + end + + defp default_queue_name(dsl, trigger) do + :"#{Ash.Resource.Info.short_name(dsl)}_#{trigger.name}" + end +end diff --git a/lib/verifiers/define_schedulers.ex b/lib/verifiers/define_schedulers.ex deleted file mode 100644 index 1e95bba..0000000 --- a/lib/verifiers/define_schedulers.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule AshOban.Verifiers.DefineSchedulers do - use Spark.Dsl.Verifier - - def verify(dsl_state) do - # TODO - # dsl_state - # |> AshOban.Info.oban_triggers() - # |> Enum.each(fn trigger -> - # IO.inspect(trigger) - # end) - - :ok - end -end diff --git a/mix.exs b/mix.exs index 6cf9916..debd1ed 100644 --- a/mix.exs +++ b/mix.exs @@ -97,29 +97,13 @@ defmodule AshOban.MixProject do module: AshOban, name: "AshOban", target: "Ash.Resource", - type: "StateMachine Resource" - }, - %{ - module: AshGraphql.Api, - name: "AshGraphql Api", - target: "Ash.Api", - type: "GraphQL Api" + type: "AshOban Resource" } ] ], extras: extras(), groups_for_extras: groups_for_extras(), groups_for_modules: [ - AshGraphql: [ - AshGraphql - ], - Introspection: [ - AshGraphql.Resource.Info, - AshGraphql.Api.Info - ], - Miscellaneous: [ - AshGraphql.Resource.Helpers - ], Internals: ~r/.*/ ] ] @@ -135,8 +119,12 @@ defmodule AshOban.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, "~> 2.7"}, - {:spark, ">= 1.0.9"} + {:ash, github: "ash-project/ash"}, + {:spark, ">= 1.1.3"}, + {:oban, "~> 2.15"}, + {:oban_pro, "~> 0.14", repo: "oban"}, + {:oban_web, "~> 2.9", repo: "oban"} + # {:oban, path: "../oban"} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ] diff --git a/mix.lock b/mix.lock index 8d62384..85a4b57 100644 --- a/mix.lock +++ b/mix.lock @@ -1,16 +1,33 @@ %{ - "ash": {:hex, :ash, "2.7.1", "e8915f4ebb4dbf8e8f689020430adf30f68ac2f4bd2613e416e6f69fd8f4dc69", [: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.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", "9b6e708fac305147f99d292823782bf8f7d2897efae7fe4fdf688e3c7cad777f"}, + "ash": {:git, "https://github.com/ash-project/ash.git", "fbc341b3a00d1d0798a8966d831443f88f412390", []}, + "castore": {:hex, :castore, "1.0.1", "240b9edb4e9e94f8f56ab39d8d2d0a57f49e46c56aced8f873892df8ff64ff5a", [:mix], [], "hexpm", "b4951de93c224d44fac71614beabd88b71932d0b1dea80d2f80fb9044e01bbb3"}, "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, - "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "ecto": {:hex, :ecto, "3.10.1", "c6757101880e90acc6125b095853176a02da8f1afe056f91f1f90b80c9389822", [: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", "d2ac4255f1601bdf7ac74c0ed971102c6829dc158719b94bd30041bbad77f87a"}, + "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, "elixir_make": {:hex, :elixir_make, "0.7.6", "67716309dc5d43e16b5abbd00c01b8df6a0c2ab54a8f595468035a50189f9169", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5a0569756b0f7873a77687800c164cca6dfc03a09418e6fcf853d78991f49940"}, "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, + "oban": {:hex, :oban, "2.15.0", "27b9c2845cdff30c98c8060b11a64318e79bbc1bd32b8dc95fa59a1580a8d90c", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "22e181c540335d1dd5c995be00435927075519207d62b3de32477d95dbf9dfd3"}, + "oban_pro": {:hex, :oban_pro, "0.14.1", "729394796e1e79633bb1083633cb9db5c5ddf2a13ab1cbc081593978348f285f", [:mix], [{:ecto_sql, "~> 3.8", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.13", [hex: :libgraph, repo: "hexpm", optional: true]}, {:oban, "~> 2.15.0", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}], "oban", "ef5e448ec98faa7f07924548904718d3b7a5c1e3eddd9a5a0c11bf90b9af75c2"}, + "oban_web": {:hex, :oban_web, "2.9.6", "40a77a41f13e9a1a712101eec0972616dc2a82ae5befe4020592e885977d41da", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.11", [hex: :oban, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.17.4 and < 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "oban", "be7917e91555050ff82f29921168ff7f4dd5d80692daaa987d78286de4b8b297"}, + "phoenix": {:hex, :phoenix, "1.7.2", "c375ffb482beb4e3d20894f84dd7920442884f5f5b70b9f4528cbe0cedefec63", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1ebca94b32b4d0e097ab2444a9742ed8ff3361acad17365e4e6b2e79b4792159"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.18", "1f38fbd7c363723f19aad1a04b5490ff3a178e37daaf6999594d5f34796c47fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5810d0472f3189ede6d2a95bda7f31c6113156b91784a3426cb0ab6a6d85214"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, + "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, + "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "sourceror": {:hex, :sourceror, "0.12.2", "2ae55efd149193572e0eb723df7c7a1bda9ab33c43373c82642931dbb2f4e428", [:mix], [], "hexpm", "7ad74ade6fb079c71f29fae10c34bcf2323542d8c51ee1bcd77a546cfa89d59c"}, - "spark": {:hex, :spark, "1.0.9", "6a98f4d0183a30a241de06d27604d5baa48a99b0fe99afc8a5bd82d4067a501e", [: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", "4e64cc73fb43d7d64e985e80b5e6300e94114782c59464c4476d272b0bdb9df1"}, + "spark": {:hex, :spark, "1.1.3", "5577146f14f7af85c9c56da283d633377428377952cdafae862e55aabdbd5133", [: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", "4512bd2b9b9b20cc47450c83420d7e7e223fd08c4ac75ce0846d0f522d82e50b"}, "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"}, + "websock": {:hex, :websock, "0.5.1", "c496036ce95bc26d08ba086b2a827b212c67e7cabaa1c06473cd26b40ed8cf10", [:mix], [], "hexpm", "b9f785108b81cd457b06e5f5dabe5f65453d86a99118b2c0a515e1e296dc2d2c"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.1", "292e6c56724e3457e808e525af0e9bcfa088cc7b9c798218e78658c7f9b85066", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8e2e1544bfde5f9d0442f9cec2f5235398b224f75c9e06b60557debf64248ec1"}, } diff --git a/test/ash_oban_test.exs b/test/ash_oban_test.exs index cbdd28f..776bb6c 100644 --- a/test/ash_oban_test.exs +++ b/test/ash_oban_test.exs @@ -2,6 +2,14 @@ defmodule AshObanTest do use ExUnit.Case doctest AshOban + defmodule Api do + use Ash.Api + + resources do + allow_unregistered? true + end + end + defmodule Triggered do use Ash.Resource, data_layer: Ash.DataLayer.Ets, @@ -9,14 +17,22 @@ defmodule AshObanTest do oban do triggers do + api Api + trigger :process do + action :process where expr(processed != true) end end end actions do - defaults [:read, :create] + defaults [:create] + + read :read do + primary? true + pagination keyset?: true + end update :process do change set_attribute(:processed, true)