mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 13:33:20 +12:00
5967ed3a48
* improvement!: use `%Ash.NotSelected{}` for unselected values * improvement!: default `require_atomic?` to `true` * improvement!: raise errors on unknown generic action arguments * improvement!: default bulk strategy to `:atomic` * improvement!: warnings on `require_atomic?` `true` actions improvement!: revise `Ash.NotSelected` to `Ash.NotLoaded` improvement!: errors on unknown action inputs across the board * doc: clarify wording in notifiers.md closes #889 * improvement!: default `api.authorization.authorize` to `:by_default` * improvement!: require the api when constructing changesets this commit also fixes some work from prior commits around the default value for the `authorize` option * improvement!: code_interface.define_for -> code_interface.api `code_interface.define_for` is now `code_interface.api`. Additionally, it is set automatically if the `api` option is specified on `use Ash.Resource`. * improvement!: remove registries * improvement!: pubsub notifier default to `previous_values?: false` improvement!: requires_original_data? callback defaults to false * improvement!: rename Ash.Calculation -> Ash.Resource.Calculation improvement!: improve `Ash.Query.Calculation.new` signature improvement!: anonymous function calculations now take lists and return lists improvement!: make callback contexts into structs improvement!: pass context to builtin lifecycle hook changes improvement!: calculation arguments are now in the `arguments` key of the context * chore: fix build * improvement!: remove `aggregates` and `calculations` from `Filter.parse` and `Filter.parse_input` * improvement: update spark to 2.0 * improvement!: make picosat_elixir optional with `simple_sat` * improvement!: rename api to domain * docs: add more info to upgrading guide * docs: tweak docs formatting * improvement!: remove `Ash.Changeset.new!` * docs: update docs for `Ash.Changeset.new/1` * improvement!: deprecate `private?: false` in favor of `public?: true` * doc: add upgrade guide for private -> public * improvement: update reactor to 3.0 * improvement!: default `default_accept` is now `[]` * improvement!: `Ash.CiString.new/1` returns `nil` on `nil` input * improvement!(Ash.Reactor): Improve integration with Ash 3.0 changes. * improvement!: clean up and reorganize `Ash` functions this is in preparation of deprecating the functions that are defined on the api improvement!: remove context-based functionality * chore: update docs references from `Ash.Domain` to `Ash` * chore: fix bad merge * chore: fix context access in atomic changes * improvement!: Deprecate calling functions on (domain) api in favor of `Ash` * improvement!: add `attribute_public?` and update `attribute_writable?` behavior * improvement!: update atomic behaviors, default to invalid * chore: update downcase docs * improvement!: changeset.filters -> changeset.filter * improvement!: remove deprecated functions * improvement!: remove and simplify `Ash.Filter.TemplateHelpers` * improvement: import Ash.Expr in modules where it is used improvement: require Ash.QUery in modules where it makes sense * fix!: keyword lists are no longer special cased in ash expressions * improvement: add structs for more context implementations * chore: small tweaks, finish `:all` -> `:*` conversion * chore: update DSL docs for multitenancy.global? * improvement: ensure selects are applied on destroys chore: remove TODOs * chore: some docs changes * improvement!: introduce strict mode to calculations * chore: update tests * improvement: support custom expressions * docs: document custom expressions * chore: fix and test custom expressions and function fragments docs: update relevant docs w/ the changes * improvement!: reverse order of before action & before transaction hooks * improvement!: default read actions are now paginatable * improvement!: require explicit accept lists in default actions * chore: update docs * improvement!: remove Ash.Flow and Ash.Engine * chore: unlock unused deps * chore: don't use unused variable * chore: include ash flow change in upgrade guide * improvement!: standardize various exception keys and names * improvement!: use `Splode` for errors * improvement: update upgrade guide to include Splode * feat: code interface on the domain * improvement: only require primary key if resource has actions or fields improvement: only build schema if resource has actions or fields improvement: verify primary key in its own verifier * improvement: add `resource/1` builtin check * improvement!: move simple_notifiers to an option instead of a DSL builder improvement!: update spark for better autocomplete, configure autocomplete for key functions docs: replace `an domain` with `a domain` * improvement: better code interface documentation * fix: set tenant on query so that root calles to Api.aggreagte work as expected (#929) * chore: fixes from previous improvements * chore: update splode * chore: update splode * improvement!: swap position of sort order and arguments in calculation sorting * improvement!: add `include_nil?` aggregate option, and default it to `false` * improvement: support notifiers within actions * improvement: support specifying multiple filters * improvement: add `sortable?` flags to all fields improvement: support multiple filters on relationships * improvement: support sensitive? on calculations and arguments * improvement: validate resources in inputs to code interface * chore: don't require explicit accept lists when using `default_accept :*` * chore: update spark * chore: update public attribute handling per 3.0 * improvement: update reactor and tests * chore: better error message * chore: fix rebase issue * chore: handle merge issues improvement: don't require domain on relationships if destination has domain * improvement!: errors on unknown inputs for calculations * improvement: always choose to cast atomic * improvement: support casting some embeds atomically * improvement: various 3.0 updates, documented in upgrade.md * chore: Add failing tests for loads with with explicit domains. (#948) Co-authored-by: James Harton <james@harton.nz> * improvement: ensure non-static dynamic domains works * improvement: add Ash.ToTenant protocol * chore: add docs for no ToTenant option * fix: properly construct new query in `build/3` * chore: update simple_sat dependency * chore: don't reselect when missing primary keys * chore: remove IO.inspect * chore: update spark * chore: update spark * improvement: use `Keyword.put_new` in `Ash.Context.to_opts` (#953) * improvement: support bulk and atomic operations in code interfaces --------- Co-authored-by: James Harton <james@harton.nz> Co-authored-by: WIGGLES <55168935+WIGGLES-dev@users.noreply.github.com> Co-authored-by: Dmitry Maganov <vonagam@gmail.com>
560 lines
14 KiB
Elixir
560 lines
14 KiB
Elixir
defmodule Ash.Helpers do
|
|
@moduledoc false
|
|
|
|
@dialyzer {:nowarn_function, {:unwrap_or_raise!, 2}}
|
|
|
|
@spec try_compile(term) :: :ok
|
|
def try_compile(module) when is_atom(module) do
|
|
try do
|
|
# This is to get the compiler to ensure that the resource is compiled
|
|
# For some very strange reason, `Code.ensure_compiled/1` isn't enough
|
|
module.ash_dsl_config()
|
|
rescue
|
|
_ ->
|
|
:ok
|
|
end
|
|
|
|
Code.ensure_compiled!(module)
|
|
:ok
|
|
end
|
|
|
|
def try_compile(_), do: :ok
|
|
|
|
def flatten_preserving_keywords(list) do
|
|
if Keyword.keyword?(list) do
|
|
[list]
|
|
else
|
|
Enum.flat_map(list, fn item ->
|
|
cond do
|
|
Keyword.keyword?(item) ->
|
|
[item]
|
|
|
|
is_list(item) ->
|
|
flatten_preserving_keywords(item)
|
|
|
|
true ->
|
|
[item]
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defmacro expect_resource!(resource) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true, bind_quoted: [resource: resource, formatted: formatted] do
|
|
if !Ash.Resource.Info.resource?(resource) do
|
|
raise ArgumentError,
|
|
"Expected an `Ash.Resource` in #{formatted}, got: #{inspect(resource)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_resource_or_query!(resource_or_query) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true,
|
|
bind_quoted: [resource_or_query: resource_or_query, formatted: formatted] do
|
|
case resource_or_query do
|
|
%Ash.Query{} ->
|
|
:ok
|
|
|
|
other ->
|
|
if !Ash.Resource.Info.resource?(other) do
|
|
raise ArgumentError,
|
|
"Expected an `%Ash.Query{}` or an `Ash.Resource` in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_query!(query) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true, bind_quoted: [query: query, formatted: formatted] do
|
|
case query do
|
|
%Ash.Query{} ->
|
|
:ok
|
|
|
|
other ->
|
|
raise ArgumentError,
|
|
"Expected an `%Ash.Query{}` in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_changeset!(changeset) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true, bind_quoted: [changeset: changeset, formatted: formatted] do
|
|
case changeset do
|
|
%Ash.Changeset{} ->
|
|
:ok
|
|
|
|
other ->
|
|
raise ArgumentError,
|
|
"Expected an `%Ash.Changeset{}` in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_resource_or_record!(resource) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true, bind_quoted: [resource: resource, formatted: formatted] do
|
|
case resource do
|
|
%resource{} = record ->
|
|
if !Ash.Resource.Info.resource?(resource) do
|
|
raise ArgumentError,
|
|
"Expected an `Ash.Resource` or a record in #{formatted}, got: #{inspect(record)}"
|
|
end
|
|
|
|
other ->
|
|
if !Ash.Resource.Info.resource?(resource) do
|
|
raise ArgumentError,
|
|
"Expected an `Ash.Resource` or a record in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_changeset_or_record!(changeset_or_record) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true,
|
|
bind_quoted: [changeset_or_record: changeset_or_record, formatted: formatted] do
|
|
case changeset_or_record do
|
|
%Ash.Changeset{} ->
|
|
:ok
|
|
|
|
%resource{} = record ->
|
|
if !Ash.Resource.Info.resource?(resource) do
|
|
raise ArgumentError,
|
|
"Expected an `Ash.Resource` or a record in #{formatted}, got: #{inspect(record)}"
|
|
end
|
|
|
|
other ->
|
|
raise ArgumentError,
|
|
"Expected an `Ash.Resource` or a record in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_record!(record) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote bind_quoted: [record: record, formatted: formatted] do
|
|
case record do
|
|
%resource{} = record ->
|
|
if !Ash.Resource.Info.resource?(resource) do
|
|
raise ArgumentError,
|
|
"Expected a record in #{formatted}, got: #{inspect(record)}"
|
|
end
|
|
|
|
other ->
|
|
raise ArgumentError,
|
|
"Expected a record in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
defmacro expect_options!(options) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true, bind_quoted: [options: options, formatted: formatted] do
|
|
case options do
|
|
[] ->
|
|
:ok
|
|
|
|
[{atom, _} | _] when is_atom(atom) ->
|
|
:ok
|
|
|
|
other ->
|
|
raise ArgumentError,
|
|
"Expected a keyword list in #{formatted}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
def resource_from_query_or_stream(domain, query_or_stream, opts) do
|
|
resource =
|
|
opts[:resource] ||
|
|
case query_or_stream do
|
|
[%resource{} | _] ->
|
|
resource
|
|
|
|
%Ash.Query{resource: resource} ->
|
|
resource
|
|
|
|
resource when is_atom(resource) ->
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
resource
|
|
end
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
|
|
if !resource do
|
|
raise ArgumentError,
|
|
"Could not determine resource for bulk action. Please provide the `resource` option if providing a stream of inputs."
|
|
end
|
|
|
|
Ash.Domain.Info.resource(domain, resource)
|
|
end
|
|
|
|
def get_action(resource, params, type, preset \\ nil) do
|
|
case Keyword.fetch(params, :action) do
|
|
{:ok, %_{} = action} ->
|
|
{:ok, action}
|
|
|
|
{:ok, nil} ->
|
|
if preset do
|
|
get_action(resource, Keyword.put(params, :action, preset), type)
|
|
else
|
|
get_action(resource, Keyword.delete(params, :action), type)
|
|
end
|
|
|
|
{:ok, action} ->
|
|
case Ash.Resource.Info.action(resource, action, type) do
|
|
nil ->
|
|
{:error,
|
|
Ash.Error.Invalid.NoSuchAction.exception(
|
|
resource: resource,
|
|
action: action,
|
|
type: type
|
|
)}
|
|
|
|
action ->
|
|
{:ok, action}
|
|
end
|
|
|
|
:error ->
|
|
if preset do
|
|
get_action(resource, Keyword.put(params, :action, preset), type)
|
|
else
|
|
case Ash.Resource.Info.primary_action(resource, type) do
|
|
nil ->
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
{:error,
|
|
Ash.Error.Invalid.NoPrimaryAction.exception(resource: resource, type: type)}
|
|
else
|
|
{:error, Ash.Error.Invalid.NoSuchResource.exception(resource: resource)}
|
|
end
|
|
|
|
action ->
|
|
{:ok, action}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def pagination_check(action, resource, opts) do
|
|
if Keyword.get(opts, :page) && Keyword.get(opts, :page) != [] && !Map.get(action, :pagination) do
|
|
{:error,
|
|
Ash.Error.to_error_class(
|
|
Ash.Error.Invalid.ActionRequiresPagination.exception(resource: resource, action: action)
|
|
)}
|
|
else
|
|
{:ok, action}
|
|
end
|
|
end
|
|
|
|
def unwrap_one({:error, error}) do
|
|
{:error, error}
|
|
end
|
|
|
|
def unwrap_one({:ok, result, query}) do
|
|
case unwrap_one({:ok, result}) do
|
|
{:ok, result} ->
|
|
{:ok, result, query}
|
|
|
|
{:error, error} ->
|
|
{:error, Ash.Error.to_ash_error(error, query: query)}
|
|
end
|
|
end
|
|
|
|
def unwrap_one({:ok, result}) do
|
|
case unwrap_one(result) do
|
|
{:ok, result} ->
|
|
{:ok, result}
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
def unwrap_one(%{results: results}) do
|
|
unwrap_one(results)
|
|
end
|
|
|
|
def unwrap_one([]), do: {:ok, nil}
|
|
def unwrap_one([result]), do: {:ok, result}
|
|
|
|
def unwrap_one([_ | _] = results) do
|
|
error =
|
|
Ash.Error.Invalid.MultipleResults.exception(
|
|
count: Enum.count(results),
|
|
at_least?: true
|
|
)
|
|
|
|
{:error, error}
|
|
end
|
|
|
|
def resource_from_data!(data, query, opts) do
|
|
if opts[:resource] do
|
|
opts[:resource]
|
|
else
|
|
case query do
|
|
%Ash.Query{resource: resource} -> resource
|
|
_ -> do_resource_from_data!(data)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp do_resource_from_data!(%struct{rerun: {%Ash.Query{resource: resource}, _}})
|
|
when struct in [Ash.Page.Offset, Ash.Page.Keyset] do
|
|
resource
|
|
end
|
|
|
|
defp do_resource_from_data!(%struct{results: [%resource{} | _]} = data)
|
|
when struct in [Ash.Page.Offset, Ash.Page.Keyset] do
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
resource
|
|
else
|
|
raise_no_resource_error!(data)
|
|
end
|
|
end
|
|
|
|
defp do_resource_from_data!(%resource{} = data) do
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
resource
|
|
else
|
|
raise_no_resource_error!(data)
|
|
end
|
|
end
|
|
|
|
defp do_resource_from_data!([%resource{} | _] = data) do
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
resource
|
|
else
|
|
raise_no_resource_error!(data)
|
|
end
|
|
end
|
|
|
|
defp do_resource_from_data!(data) do
|
|
raise_no_resource_error!(data)
|
|
end
|
|
|
|
defp raise_no_resource_error!(data) do
|
|
raise ArgumentError,
|
|
message: """
|
|
Could not determine a resource from the provided input:
|
|
|
|
#{inspect(data)}
|
|
"""
|
|
end
|
|
|
|
defmacro domain!(
|
|
subject,
|
|
opts,
|
|
instructions \\ "Please specify the `:domain` option, or adjust the input."
|
|
) do
|
|
formatted = format_caller(__CALLER__)
|
|
|
|
quote generated: true,
|
|
bind_quoted: [
|
|
subject: subject,
|
|
opts: opts,
|
|
formatted: formatted,
|
|
instructions: instructions
|
|
] do
|
|
domain = Ash.Helpers.get_domain(subject, opts)
|
|
|
|
expanded =
|
|
if not is_nil(subject) do
|
|
"\n\n#{inspect(subject)}"
|
|
end
|
|
|
|
if !domain do
|
|
raise(
|
|
ArgumentError,
|
|
"""
|
|
Could not determine domain for input in #{formatted}. #{instructions}#{expanded}
|
|
"""
|
|
)
|
|
end
|
|
|
|
domain
|
|
end
|
|
end
|
|
|
|
defp format_caller(caller) do
|
|
mod = caller.module
|
|
{func, arity} = caller.function
|
|
"`#{inspect(mod)}.#{func}/#{arity}`"
|
|
end
|
|
|
|
def get_domain(nil, nil) do
|
|
nil
|
|
end
|
|
|
|
def get_domain(%input_struct{domain: domain}, _opts)
|
|
when input_struct in [Ash.Query, Ash.Changeset, Ash.ActionInput] and not is_nil(domain) do
|
|
domain
|
|
end
|
|
|
|
def get_domain([record | _], opts) do
|
|
get_domain(record, opts)
|
|
end
|
|
|
|
def get_domain({resource, _}, opts) when is_atom(resource) do
|
|
get_domain(resource, opts)
|
|
end
|
|
|
|
def get_domain(%page_struct{rerun: {query, _}}, opts)
|
|
when page_struct in [Ash.Page.Offset, Ash.Page.Keyset] do
|
|
get_domain(query, opts)
|
|
end
|
|
|
|
def get_domain(%page_struct{results: results}, opts)
|
|
when page_struct in [Ash.Page.Offset, Ash.Page.Keyset] do
|
|
get_domain(results, opts)
|
|
end
|
|
|
|
def get_domain(%{resource: resource}, opts) when not is_nil(resource) do
|
|
get_domain(resource, opts)
|
|
end
|
|
|
|
def get_domain(nil, opts) do
|
|
cond do
|
|
domain = opts[:domain] ->
|
|
domain
|
|
|
|
resource = opts[:resource] ->
|
|
get_domain(resource, Keyword.delete(opts, :resource))
|
|
|
|
true ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
def get_domain(%resource{}, opts) do
|
|
if Ash.Resource.Info.resource?(resource) do
|
|
get_domain(resource, opts)
|
|
else
|
|
get_domain(nil, opts)
|
|
end
|
|
end
|
|
|
|
def get_domain(resource, opts) do
|
|
opts[:domain] || domain_from_resource(resource)
|
|
end
|
|
|
|
defp domain_from_resource(resource) do
|
|
if Ash.Resource.Info.resource?(resource) || (is_map(resource) and not is_struct(resource)) do
|
|
Ash.Resource.Info.domain(resource)
|
|
end
|
|
end
|
|
|
|
def unwrap_or_raise!(first, destroy? \\ false)
|
|
def unwrap_or_raise!(%Ash.BulkResult{} = bulk_result, _), do: bulk_result
|
|
def unwrap_or_raise!(:ok, _), do: :ok
|
|
def unwrap_or_raise!({:ok, result}, false), do: result
|
|
def unwrap_or_raise!({:ok, _result}, true), do: :ok
|
|
def unwrap_or_raise!({:ok, result, other}, _), do: {result, other}
|
|
|
|
def unwrap_or_raise!({:error, error}, destroy?) when is_list(error) do
|
|
unwrap_or_raise!({:error, Ash.Error.to_error_class(error)}, destroy?)
|
|
end
|
|
|
|
def unwrap_or_raise!({:error, error}, _) do
|
|
exception = Ash.Error.to_error_class(error)
|
|
|
|
case exception do
|
|
%{stacktrace: %{stacktrace: stacktrace}} = exception ->
|
|
reraise exception, stacktrace
|
|
|
|
_ ->
|
|
raise exception
|
|
end
|
|
end
|
|
|
|
def implements_behaviour?(module, behaviour) do
|
|
:attributes
|
|
|> module.module_info()
|
|
|> Enum.flat_map(fn
|
|
{:behaviour, value} -> List.wrap(value)
|
|
_ -> []
|
|
end)
|
|
|> Enum.any?(&(&1 == behaviour))
|
|
rescue
|
|
_ ->
|
|
false
|
|
end
|
|
|
|
# sobelow_skip ["Misc.BinToTerm"]
|
|
def non_executable_binary_to_term(binary, opts \\ []) when is_binary(binary) do
|
|
term = :erlang.binary_to_term(binary, opts)
|
|
non_executable_terms(term)
|
|
term
|
|
end
|
|
|
|
defp non_executable_terms(list) when is_list(list) do
|
|
non_executable_list(list)
|
|
end
|
|
|
|
defp non_executable_terms(tuple) when is_tuple(tuple) do
|
|
non_executable_tuple(tuple, tuple_size(tuple))
|
|
end
|
|
|
|
defp non_executable_terms(map) when is_map(map) do
|
|
folder = fn key, value, acc ->
|
|
non_executable_terms(key)
|
|
non_executable_terms(value)
|
|
acc
|
|
end
|
|
|
|
:maps.fold(folder, map, map)
|
|
end
|
|
|
|
defp non_executable_terms(other)
|
|
when is_atom(other) or is_number(other) or is_bitstring(other) or is_pid(other) or
|
|
is_reference(other) do
|
|
other
|
|
end
|
|
|
|
defp non_executable_terms(other) do
|
|
raise ArgumentError,
|
|
"cannot deserialize #{inspect(other)}, the term is not safe for deserialization"
|
|
end
|
|
|
|
defp non_executable_list([]), do: :ok
|
|
|
|
defp non_executable_list([h | t]) when is_list(t) do
|
|
non_executable_terms(h)
|
|
non_executable_list(t)
|
|
end
|
|
|
|
defp non_executable_list([h | t]) do
|
|
non_executable_terms(h)
|
|
non_executable_terms(t)
|
|
end
|
|
|
|
defp non_executable_tuple(_tuple, 0), do: :ok
|
|
|
|
defp non_executable_tuple(tuple, n) do
|
|
non_executable_terms(:erlang.element(n, tuple))
|
|
non_executable_tuple(tuple, n - 1)
|
|
end
|
|
|
|
@doc false
|
|
def deep_merge_maps(left, right)
|
|
when is_map(left) and is_map(right) and not is_struct(left) and not is_struct(right) do
|
|
Map.merge(left, right, fn _, left, right ->
|
|
deep_merge_maps(left, right)
|
|
end)
|
|
end
|
|
|
|
def deep_merge_maps(_left, right), do: right
|
|
end
|