diff --git a/.formatter.exs b/.formatter.exs index 0616cbbe..3f867a5a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -19,7 +19,9 @@ locals_without_parens = [ has_many: 2, has_many: 3, many_to_many: 2, - many_to_many: 3 + many_to_many: 3, + default_page_size: 1, + resources: 1 ] [ diff --git a/README.md b/README.md index c6517953..573ed10e 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,10 @@ * Validate rules at creation * Maybe fix the crappy parts of optimal and bring it in for opts validation? * The ecto internals that live on structs are going to cause problems w/ pluggability of backends, like the `%Ecto.Association.NotLoaded{}`. That backend may need to scrub the ecto specifics off of those structs. -* Add a mixin compatibility checker framework, to allow for mixins to declare what features they do/don't support. +* Add a mixin compatibility checker framework, to allow for mix_ins to declare what features they do/don't support. * Make `Ash.Type` that is a superset of things like `Ecto.Type`. If we bring in ecto database-less(looking like more and more of a good idea to me) that kind of thing gets easier and we can potentially lean on ecto for type validations well. * use a process to hold constructed DSL state, and then coalesce it all at the end. This can clean things up, and also allow us to potentially eliminate the registry. This will probably go hand in hand w/ the "capabilities" layer, where the DSL confirms that your data layer is capable of performing everything that your DSL declares * make ets dep optional -go through all the remaining endpoints and make them use all the goodness that get and index have now. -start on the work that will make it “well featured” like supporting all the different field types and whatnot + diff --git a/lib/ash.ex b/lib/ash.ex index beae8901..a9876feb 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -12,8 +12,8 @@ defmodule Ash do @type sort :: Keyword.t() @type side_loads :: Keyword.t() - def resources() do - Application.get_env(:ash, :resources) || [] + def resources(api) do + api.resources() end def primary_key(resource) do @@ -33,6 +33,10 @@ defmodule Ash do resource.relationships() end + def side_load_config(api) do + api.side_load_config() + end + def primary_action(resource, type) do resource |> actions() @@ -75,49 +79,6 @@ defmodule Ash do resource.data_layer() end - def get(resource, id, params \\ %{}) do - # TODO: Figure out this interface - params_with_filter = - params - |> Map.put_new(:filter, %{}) - |> Map.update!(:filter, &Map.put(&1, :id, id)) - |> Map.put(:page, %{limit: 2}) - - case read(resource, params_with_filter) do - {:ok, %{results: [single_result]}} -> - {:ok, single_result} - - {:ok, %{results: []}} -> - {:ok, nil} - - {:error, error} -> - {:error, error} - - {:ok, %{results: results}} when is_list(results) -> - {:error, :too_many_results} - end - end - - def read(resource, params \\ %{}) do - case Map.get(params, :action) || primary_action(resource, :read) do - nil -> - {:error, "no action provided, and no primary action found"} - - action -> - Ash.DataLayer.Actions.run_read_action(resource, action, params) - end - end - - def create(resource, params) do - case Map.get(params, :action) || primary_action(resource, :create) do - nil -> - {:error, "no action provided, and no primary action found"} - - action -> - Ash.DataLayer.Actions.run_create_action(resource, action, params) - end - end - # # TODO: auth # def create(resource, attributes, relationships, params \\ %{}) do # action = Map.get(params, :action) || primary_action(resource, :create) @@ -136,9 +97,5 @@ defmodule Ash do # Ash.DataLayer.Actions.run_destroy_action(record, action, params) # end - # TODO: Implement a to_resource protocol, like ecto's to query logic - def to_resource(%resource{}), do: resource - def to_resource(resource) when is_atom(resource), do: resource - ## Datalayer shit TODO move this elsewhere end diff --git a/lib/ash/api/api.ex b/lib/ash/api/api.ex new file mode 100644 index 00000000..b60dea87 --- /dev/null +++ b/lib/ash/api/api.ex @@ -0,0 +1,97 @@ +defmodule Ash.Api do + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + import Ash.Api, only: [api: 1] + @before_compile Ash.Api + + @default_page_size nil + @no_interface !!opts[:no_interface?] + @side_load_type :simple + @side_load_config [] + + Module.register_attribute(__MODULE__, :mix_ins, accumulate: true) + Module.register_attribute(__MODULE__, :resources, accumulate: true) + Module.register_attribute(__MODULE__, :named_resources, accumulate: true) + end + end + + defmacro api(do: block) do + quote do + import Ash.Api, + only: [ + default_page_size: 1, + resources: 1, + side_load: 2, + side_load: 1 + ] + + unquote(block) + + import Ash.Api, only: [] + end + end + + defmacro resources(resources) do + quote do + Enum.map(unquote(resources), fn resource -> + case resource do + {name, resource} -> + @resources resource + @named_resources {name, resource} + + resource -> + @resources resource + end + end) + end + end + + defmacro side_load(type, config \\ []) do + quote bind_quoted: [type: type, config: config] do + unless type in [:parallel, :simple] do + raise "side_load type must be one if `:parallel` or `:simple`" + end + + case type do + :simple -> + @side_load_type :simple + + :parallel -> + @side_load_type :parallel + # TODO: validate no extra keys + raise "`:supervisor` option must be set." + + @side_load_config [ + supervisor: config[:supervisor], + max_concurrency: config[:max_concurrency], + timeout: opts[:timeout], + shutdown: opts[:shutdown] + ] + end + end + end + + defmacro default_page_size(value) do + quote do + @default_page_size unquote(value) + end + end + + defmacro __before_compile__(env) do + quote do + def default_page_size(), do: @default_page_size + def mix_ins(), do: @mix_ins + def resources(), do: @resources + def side_load_config(), do: {@side_load_type, @side_load_config} + + unless @no_interface do + use Ash.Api.Interface + end + + Enum.map(@mix_ins || [], fn hook_module -> + code = hook_module.before_compile_hook(unquote(Macro.escape(env))) + Module.eval_quoted(__MODULE__, code) + end) + end + end +end diff --git a/lib/ash/api/interface.ex b/lib/ash/api/interface.ex new file mode 100644 index 00000000..80b536e3 --- /dev/null +++ b/lib/ash/api/interface.ex @@ -0,0 +1,75 @@ +defmodule Ash.Api.Interface do + defmacro __using__(_) do + quote do + def get(resource, id, params \\ %{}) do + Ash.Api.Interface.get(__MODULE__, resource, id, params) + end + + def read(resource, params) do + Ash.Api.Interface.read(__MODULE__, resource, params) + end + + def create(resource, params) do + Ash.Api.Interface.create(__MODULE__, resource, params) + end + end + end + + def get(api, resource, id, params \\ %{}) do + # TODO: Figure out this interface + params_with_filter = + params + |> Map.put_new(:filter, %{}) + |> Map.update!(:filter, &Map.put(&1, :id, id)) + |> Map.put(:page, %{limit: 2}) + + case read(api, resource, params_with_filter) do + {:ok, %{results: [single_result]}} -> + {:ok, single_result} + + {:ok, %{results: []}} -> + {:ok, nil} + + {:error, error} -> + {:error, error} + + {:ok, %{results: results}} when is_list(results) -> + {:error, :too_many_results} + end + end + + def read(api, resource, params \\ %{}) do + params = add_default_page_size(api, params) + + case Map.get(params, :action) || Ash.primary_action(resource, :read) do + nil -> + {:error, "no action provided, and no primary action found"} + + action -> + Ash.DataLayer.Actions.run_read_action(resource, action, api, params) + end + end + + def create(api, resource, params) do + case Map.get(params, :action) || Ash.primary_action(resource, :create) do + nil -> + {:error, "no action provided, and no primary action found"} + + action -> + Ash.DataLayer.Actions.run_create_action(resource, action, api, params) + end + end + + defp add_default_page_size(_api, %{page: %{limit: value}} = params) when is_integer(value) do + params + end + + defp add_default_page_size(api, params) do + params + |> Map.update( + :page, + %{limit: api.default_page_size}, + &Map.put(&1, :limit, api.default_page_size) + ) + end +end diff --git a/lib/ash/data_layer/actions.ex b/lib/ash/data_layer/actions.ex index bfad199d..d5cc52e8 100644 --- a/lib/ash/data_layer/actions.ex +++ b/lib/ash/data_layer/actions.ex @@ -23,7 +23,7 @@ defmodule Ash.DataLayer.Actions do # Ash.Data.delete(record, action, params) # end - def run_read_action(resource, action, params) do + def run_read_action(resource, action, api, params) do auth_context = %{ resource: resource, action: action, @@ -49,6 +49,7 @@ defmodule Ash.DataLayer.Actions do resource, found, Map.get(instructions, :side_load, []), + api, Map.take(params, [:authorize?, :user]) ), :allow <- @@ -65,6 +66,7 @@ defmodule Ash.DataLayer.Actions do resource, side_loaded_for_auth, Map.get(params, :side_load, []), + api, Map.take(params, [:authorize?, :user]) ) do {:ok, %{paginator | results: side_loaded}} @@ -82,7 +84,7 @@ defmodule Ash.DataLayer.Actions do end end - def run_create_action(resource, action, params) do + def run_create_action(resource, action, params, api) do auth_context = %{ resource: resource, action: action, @@ -113,6 +115,7 @@ defmodule Ash.DataLayer.Actions do resource, created, Map.get(params, :side_load, []), + api, Map.take(params, [:authorize?, :user]) ) do {:ok, side_loaded} diff --git a/lib/ash/data_layer/side_loader.ex b/lib/ash/data_layer/side_loader.ex index a18d7a96..41eaefae 100644 --- a/lib/ash/data_layer/side_loader.ex +++ b/lib/ash/data_layer/side_loader.ex @@ -1,19 +1,20 @@ defmodule Ash.DataLayer.SideLoader do - def side_load(resource, record, keyword, global_params \\ %{}) + def side_load(resource, record, keyword, api, global_params \\ %{}) - def side_load(_resource, record_or_records, [], _global_params), do: {:ok, record_or_records} + def side_load(_resource, record_or_records, [], _api, _global_params), + do: {:ok, record_or_records} - def side_load(resource, record, side_loads, global_params) when not is_list(record) do - case side_load(resource, [record], side_loads, global_params) do + def side_load(resource, record, side_loads, api, global_params) when not is_list(record) do + case side_load(resource, [record], side_loads, api, global_params) do {:ok, [side_loaded]} -> side_loaded {:error, error} -> {:error, error} end end - def side_load(resource, records, side_loads, global_params) do + def side_load(resource, records, side_loads, api, global_params) do # TODO: No global config! - config = Application.get_env(:ash, :side_loader) - parallel_supervisor = config[:parallel_supervisor] + {side_load_type, config} = Ash.side_load_config(resource) + async? = side_load_type == :parallel side_loads = Enum.map(side_loads, fn side_load_part -> @@ -26,7 +27,7 @@ defmodule Ash.DataLayer.SideLoader do side_loaded = side_loads - |> maybe_async_stream(config, parallel_supervisor, fn relationship_name, further -> + |> maybe_async_stream(config, async?, fn relationship_name, further -> relationship = Ash.relationship(resource, relationship_name) # Combining filters, and handling boolean filters is @@ -41,7 +42,7 @@ defmodule Ash.DataLayer.SideLoader do }) |> Map.put_new(:paginate?, false) - with {:ok, related_records} <- Ash.read(relationship.destination, action_params), + with {:ok, related_records} <- api.read(relationship.destination, action_params), {:ok, %{results: side_loaded_related}} <- side_load(relationship.destination, related_records, further, global_params) do keyed_by_id = @@ -87,13 +88,13 @@ defmodule Ash.DataLayer.SideLoader do first_error || {:ok, Enum.map(side_loaded, &elem(&1, 1))} end - defp maybe_async_stream(preloads, _opts, nil, function) do + defp maybe_async_stream(preloads, _opts, false, function) do Enum.map(preloads, fn {association, further} -> function.(association, further) end) end - defp maybe_async_stream(preloads, opts, supervisor, function) do + defp maybe_async_stream(preloads, opts, true, function) do # We could theoretically do one of them outside of a task whlie we wait for the rest # Not worth implementing to start, IMO. opts = [ @@ -104,7 +105,7 @@ defmodule Ash.DataLayer.SideLoader do shutdown: opts[:shutdown] || :timer.seconds(5) ] - supervisor + opts[:supervisor] |> Task.Supervisor.async_stream_nolink( preloads, fn {key, further} -> function.(key, further) end, diff --git a/lib/ash/resource.ex b/lib/ash/resource.ex index e031498b..6d018358 100644 --- a/lib/ash/resource.ex +++ b/lib/ash/resource.ex @@ -40,10 +40,6 @@ defmodule Ash.Resource do defmacro __before_compile__(env) do quote location: :keep do - if __MODULE__ not in Ash.resources() do - raise "Your module (#{inspect(__MODULE__)}) must be in config, :ash, resources: [...]" - end - @sanitized_actions Ash.Resource.mark_primaries(@actions) @ash_primary_key Ash.Resource.primary_key(@attributes) diff --git a/lib/ash/resource/relationships/many_to_many.ex b/lib/ash/resource/relationships/many_to_many.ex index c2a47d9d..3ca177fa 100644 --- a/lib/ash/resource/relationships/many_to_many.ex +++ b/lib/ash/resource/relationships/many_to_many.ex @@ -67,10 +67,6 @@ defmodule Ash.Resource.Relationships.ManyToMany do defp through!(opts, _source_field_on_join_table, _destination_field_on_join_table) do case opts[:through] do through when is_atom(through) -> - unless through in Ash.resources() do - raise "Got an atom/module for `through`, but it was not a resource." - end - through # TODO: do this check at runtime. When done at compilation, it forces the modules