Skip to content

Commit

Permalink
improvement: Add default code interface options (#1681)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Zach Daniel <[email protected]>
  • Loading branch information
binarypaladin and zachdaniel authored Jan 1, 2025
1 parent 89e062f commit eb47aca
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 73 deletions.
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ spark_locals_without_parens = [
default_context: 1,
default_domain: 1,
default_limit: 1,
default_options: 1,
defaults: 1,
define: 1,
define: 2,
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-Ash.Domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ define :get_user_by_id, action: :get_by_id, args: [:id], get?: true
| [`get?`](#resources-resource-define-get?){: #resources-resource-define-get? } | `boolean` | `false` | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. |
| [`get_by`](#resources-resource-define-get_by){: #resources-resource-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`get_by_identity`](#resources-resource-define-get_by_identity){: #resources-resource-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`default_options`](#resources-resource-define-default_options){: #resources-resource-define-default_options } | `keyword` | `[]` | Default options to be merged with client-provided options. These can override domain or action defaults. |



Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-Ash.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,7 @@ define :get_user_by_id, action: :get_by_id, args: [:id], get?: true
| [`get?`](#code_interface-define-get?){: #code_interface-define-get? } | `boolean` | `false` | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. |
| [`get_by`](#code_interface-define-get_by){: #code_interface-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`get_by_identity`](#code_interface-define-get_by_identity){: #code_interface-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`default_options`](#code_interface-define-default_options){: #code_interface-define-default_options } | `keyword` | `[]` | Default options to be merged with client-provided options. These can override domain or action defaults. |



Expand Down
237 changes: 165 additions & 72 deletions lib/ash/code_interface.ex
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,138 @@ defmodule Ash.CodeInterface do
end
end

@doc """
A common pattern is for a function to have both optional parameters and
optional options. This usually comes in the form of two defaults:
* An empty map for params.
* An empty list for options.
With those two defaults in mind, this function will decipher, from two inputs,
which should be parameters and which should be options.
Parameters can take one of two primary forms:
1. A map.
2. A list of maps for bulk operations.
Additionally, if options are set explicitly (i.e. at least one option has
been set), a keyword list will be converted to a map.
## Examples
iex> params_and_opts(%{key: :param}, [key: :opt])
{%{key: :param}, [key: :opt]}
iex> params_and_opts([key: :opt], [])
{%{}, [key: :opt]}
iex> params_and_opts([], [])
{[], []}
iex> params_and_opts([%{key: :param}], [])
{[%{key: :param}], []}
iex> params_and_opts([key: :param], [key: :opt])
{%{key: :param}, [key: :opt]}
"""
@spec params_and_opts(params_or_opts :: map() | [map()] | keyword(), keyword()) ::
{params :: map() | [map()], opts :: keyword()}
def params_and_opts(%{} = params, opts), do: {params, opts}

def params_and_opts([], opts), do: {[], opts}

def params_and_opts([%{} | _] = params_list, opts), do: {params_list, opts}

def params_and_opts(opts, []), do: {%{}, opts}

def params_and_opts(params_or_list, opts) do
params =
if Keyword.keyword?(params_or_list),
do: Map.new(params_or_list),
else: params_or_list

{params, opts}
end

@doc """
See `params_and_opts/2`.
Merges default options if provided (see `merge_default_opts/2`) and dds a
post process function that can takes the opts and can further process,
validate, or transform them.
"""
@spec params_and_opts(
params_or_opts :: map() | [map()] | keyword(),
keyword(),
keyword(),
(keyword() -> keyword())
) ::
{params :: map() | [map()], opts :: keyword()}
def params_and_opts(params_or_opts, maybe_opts, default_opts \\ [], post_process_opts_fn)
when is_function(post_process_opts_fn, 1) do
params_or_opts
|> params_and_opts(maybe_opts)
|> then(fn {params, opts} ->
{params,
opts
|> merge_default_opts(default_opts)
|> post_process_opts_fn.()}
end)
end

@deep_merge_keys [:bulk_options, :page]
@doc """
Selectively merges default opts into client-provided opts. For most keys,
the value in opts will be used instead of the default if provided. However,
certain options have special behavior:
* #{@deep_merge_keys |> Enum.map(&"`:#{&1}`") |> Enum.join(", ")} - These
options are deep merged, so if the default is a keyword list and the
client value is a keyword list, they'll be merged.
* `:load` - The default value and the client value will be combined in this
case.
## Examples
iex> merge_default_opts([key1: 1], key2: 2)
[key2: 2, key1: 1]
iex> merge_default_opts([key2: :client], key1: :default, key2: :default)
[key2: :client, key1: :default]
iex> merge_default_opts([page: false], page: [limit: 100])
[page: false]
iex> merge_default_opts([page: [offset: 2]], page: [limit: 100])
[page: [limit: 100, offset: 2]]
iex> merge_default_opts([load: [:calc2, :rel4]], load: [:calc1, rel1: [:rel2, :rel3]])
[load: [:calc1, {:rel1, [:rel2, :rel3]}, :calc2, :rel4]]
"""
@spec merge_default_opts(keyword(), keyword()) :: keyword()
def merge_default_opts(opts, default_opts) do
Enum.reduce(default_opts, opts, fn {k, default}, opts ->
opts
|> Keyword.fetch(k)
|> case do
:error -> default
{:ok, value} -> merge_default_opt(k, default, value)
end
|> then(&Keyword.put(opts, k, &1))
end)
end

defp merge_default_opt(:load, default, value) do
List.wrap(default) ++ List.wrap(value)
end

defp merge_default_opt(key, default, value)
when key in @deep_merge_keys and is_list(default) and is_list(value),
do: Keyword.merge(default, value)

defp merge_default_opt(_key, _default, value), do: value

@doc """
Defines the code interface for a given resource + domain combination in the current module. For example:
Expand Down Expand Up @@ -449,60 +581,29 @@ defmodule Ash.CodeInterface do

interface_options = Ash.Resource.Interface.interface_options(action.type, interface)

resolve_opts_params =
resolve_params_and_opts =
quote do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
unquote(interface_options).validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
unquote(interface_options).validate!(opts)
|> unquote(interface_options).to_options()}
Ash.CodeInterface.params_and_opts(
params_or_opts,
opts,
unquote(Macro.escape(interface.default_options)),
fn opts ->
opts
|> unquote(interface_options).validate!()
|> unquote(interface_options).to_options()
end
)

params =
arg_params =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params, fn {key, value}, params ->
Map.put(params, key, value)
end)
end

resolve_bang_opts_params =
quote do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{if(params_or_opts != [], do: %{}, else: []),
unquote(interface_options).validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{if(Keyword.keyword?(params_or_opts),
do: Map.new(params_or_opts),
else: params_or_opts
),
unquote(interface_options).validate!(opts)
|> unquote(interface_options).to_options()}
|> Map.new()

params =
if is_list(params) do
to_merge =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Map.new()

Enum.map(params, fn params ->
Map.merge(params, to_merge)
end)
else
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params, fn {key, value}, params ->
Map.put(params, key, value)
end)
end
if is_list(params),
do: Enum.map(params, &Map.merge(&1, arg_params)),
else: Map.merge(params, arg_params)
end

{subject, subject_args, resolve_subject, act, act!} =
Expand Down Expand Up @@ -1265,7 +1366,7 @@ defmodule Ash.CodeInterface do
{first_opts_location + 1, interface_options.schema()}
]
def unquote(interface.name)(unquote_splicing(common_args)) do
unquote(resolve_opts_params)
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(act)
end
Expand All @@ -1289,7 +1390,7 @@ defmodule Ash.CodeInterface do
{first_opts_location + 1, interface_options.schema()}
]
def unquote(:"#{interface.name}!")(unquote_splicing(common_args)) do
unquote(resolve_bang_opts_params)
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(act!)
end
Expand Down Expand Up @@ -1327,7 +1428,7 @@ defmodule Ash.CodeInterface do
{first_opts_location + 1, subject_opts}
]
def unquote(:"#{subject_name}_to_#{interface.name}")(unquote_splicing(common_args)) do
unquote(resolve_opts_params)
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(subject)
end
Expand All @@ -1351,17 +1452,13 @@ defmodule Ash.CodeInterface do
]
def unquote(:"can_#{interface.name}")(actor, unquote_splicing(common_args)) do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
Ash.Resource.Interface.CanOpts.validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
Ash.Resource.Interface.CanOpts.validate!(opts)
|> unquote(interface_options).to_options()}

opts = Keyword.put(opts, :actor, actor)
Ash.CodeInterface.params_and_opts(params_or_opts, opts, fn opts ->
opts
|> Ash.Resource.Interface.CanOpts.validate!()
|> unquote(interface_options).to_options()
|> Keyword.put(:actor, actor)
end)

unquote(resolve_subject)

case unquote(subject) do
Expand Down Expand Up @@ -1397,17 +1494,13 @@ defmodule Ash.CodeInterface do
|> Ash.CodeInterface.trim_double_newlines()
def unquote(:"can_#{interface.name}?")(actor, unquote_splicing(common_args)) do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
Ash.Resource.Interface.CanQuestionMarkOpts.validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
Ash.Resource.Interface.CanQuestionMarkOpts.validate!(opts)
|> unquote(interface_options).to_options()}

opts = Keyword.put(opts, :actor, actor)
Ash.CodeInterface.params_and_opts(params_or_opts, opts, fn opts ->
opts
|> Ash.Resource.Interface.CanOpts.validate!()
|> unquote(interface_options).to_options()
|> Keyword.put(:actor, actor)
end)

unquote(resolve_subject)

case unquote(subject) do
Expand Down
7 changes: 7 additions & 0 deletions lib/ash/resource/interface.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Ash.Resource.Interface do
:get_by,
:get_by_identity,
:not_found_error?,
default_options: [],
require_reference?: true
]

Expand Down Expand Up @@ -245,6 +246,12 @@ defmodule Ash.Resource.Interface do
doc: """
Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument.
"""
],
default_options: [
type: :keyword_list,
default: [],
doc:
"Default options to be merged with client-provided options. These can override domain or action defaults. `:load`, `:bulk_options`, and `:page` options will be deep merged."
]
]

Expand Down
19 changes: 18 additions & 1 deletion test/code_interface_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ defmodule Ash.Test.CodeInterfaceTest do
@moduledoc false
use ExUnit.Case, async: true

doctest Ash.CodeInterface, import: true

alias Ash.Test.Domain, as: Domain

defmodule Notifier do
Expand All @@ -27,6 +29,7 @@ defmodule Ash.Test.CodeInterfaceTest do
define :get_by_id, action: :by_id, get?: true, args: [:id]
define :create, args: [{:optional, :first_name}]
define :hello, args: [:name]
define :hello_actor, default_options: [actor: %{name: "William Shatner"}]
define :create_with_map, args: [:map]
define :update_with_map, args: [:map]

Expand Down Expand Up @@ -84,6 +87,12 @@ defmodule Ash.Test.CodeInterfaceTest do
{:ok, "Hello #{input.arguments.name}"}
end)
end

action :hello_actor, :string do
run(fn input, _ ->
{:ok, "Hello, #{input.context.private.actor.name}."}
end)
end
end

calculations do
Expand Down Expand Up @@ -388,6 +397,14 @@ defmodule Ash.Test.CodeInterfaceTest do
end

test "it handles keyword inputs properly" do
assert User.create!("fred", [last_name: "weasley"], actor: nil)
assert {:ok, %{last_name: "weasley"}} =
User.create("fred", [last_name: "weasley"], actor: nil)

assert %{last_name: "weasley"} = User.create!("fred", [last_name: "weasley"], actor: nil)
end

test "default options" do
assert "Hello, Leonard Nimoy." = User.hello_actor!(actor: %{name: "Leonard Nimoy"})
assert "Hello, William Shatner." = User.hello_actor!()
end
end

0 comments on commit eb47aca

Please sign in to comment.