ash/lib/ash/helpers.ex
Zach Daniel 5967ed3a48 improvement!: 3.0 (#955)
* 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>
2024-03-27 16:31:59 -04:00

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