Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions documentation/dsls/DSL-Ash.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,7 @@ end
| [`constraints`](#actions-action-constraints){: #actions-action-constraints } | `keyword` | | Constraints for the return type. See `Ash.Type` for more. |
| [`allow_nil?`](#actions-action-allow_nil?){: #actions-action-allow_nil? } | `boolean` | `false` | Whether or not the action can return nil. Unlike attributes & arguments, this defaults to `false`. |
| [`run`](#actions-action-run){: #actions-action-run } | `(any, any -> any) \| module \| module` | | Module may be an `Ash.Resource.Actions.Implementation` or `Reactor`. |
| [`extends`](#actions-action-extends){: #actions-action-extends } | `atom` | | The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable. |
| [`primary?`](#actions-action-primary?){: #actions-action-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
| [`description`](#actions-action-description){: #actions-action-description } | `String.t` | | An optional description for the action |
| [`transaction?`](#actions-action-transaction?){: #actions-action-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
Expand Down Expand Up @@ -1037,7 +1038,7 @@ prepare build(sort: [:foo, :bar])

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`on`](#actions-action-prepare-on){: #actions-action-prepare-on } | `:read \| :action \| list(:read \| :action)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`on`](#actions-action-prepare-on){: #actions-action-prepare-on } | `:read \| :action \| :create \| :update \| :destroy \| list(:read \| :action \| :create \| :update \| :destroy)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`where`](#actions-action-prepare-where){: #actions-action-prepare-where } | `(any, any -> any) \| module \| list((any, any -> any) \| module)` | `[]` | Validations that should pass in order for this preparation to apply. Any of these validations failing will result in this preparation being ignored. |
| [`only_when_valid?`](#actions-action-prepare-only_when_valid?){: #actions-action-prepare-only_when_valid? } | `boolean` | `false` | If the preparation should only run on valid queries. |

Expand Down Expand Up @@ -1145,6 +1146,7 @@ end
| [`upsert_condition`](#actions-create-upsert_condition){: #actions-create-upsert_condition } | `any` | | An expression to check if the record should be updated when there's a conflict. |
| [`return_skipped_upsert?`](#actions-create-return_skipped_upsert?){: #actions-create-return_skipped_upsert? } | `boolean` | | Returns the record that would have been upserted against but was skipped due to a filter or no fields being changed. How this works depends on the data layer. Keep in mind that read policies *are not applied* to the read of the record in question. |
| [`multitenancy`](#actions-create-multitenancy){: #actions-create-multitenancy } | `:enforce \| :allow_global \| :bypass \| :bypass_all` | `:enforce` | This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set, `:bypass_all` like `:bypass` but also bypasses the tenancy requirement for the nested resources. |
| [`extends`](#actions-create-extends){: #actions-create-extends } | `atom` | | The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable. |
| [`primary?`](#actions-create-primary?){: #actions-create-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
| [`description`](#actions-create-description){: #actions-create-description } | `String.t` | | An optional description for the action |
| [`transaction?`](#actions-create-transaction?){: #actions-create-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
Expand Down Expand Up @@ -1394,6 +1396,7 @@ end
| [`timeout`](#actions-read-timeout){: #actions-read-timeout } | `pos_integer` | | The maximum amount of time, in milliseconds, that the action is allowed to run for. Ignored if the data layer doesn't support transactions *and* async is disabled. |
| [`multitenancy`](#actions-read-multitenancy){: #actions-read-multitenancy } | `:enforce \| :allow_global \| :bypass \| :bypass_all` | `:enforce` | This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set, `:bypass_all` like `:bypass` but also bypasses the tenancy requirement for the nested resources. This is useful to change the behaviour of selected read action without the need of marking the whole resource with `global? true`. |
| [`skip_global_validations?`](#actions-read-skip_global_validations?){: #actions-read-skip_global_validations? } | `boolean` | `false` | If true, global validations will be skipped. Useful for manual actions. |
| [`extends`](#actions-read-extends){: #actions-read-extends } | `atom` | | The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable. |
| [`primary?`](#actions-read-primary?){: #actions-read-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
| [`description`](#actions-read-description){: #actions-read-description } | `String.t` | | An optional description for the action |
| [`transaction?`](#actions-read-transaction?){: #actions-read-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
Expand Down Expand Up @@ -1473,7 +1476,7 @@ prepare build(sort: [:foo, :bar])

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`on`](#actions-read-prepare-on){: #actions-read-prepare-on } | `:read \| :action \| list(:read \| :action)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`on`](#actions-read-prepare-on){: #actions-read-prepare-on } | `:read \| :action \| :create \| :update \| :destroy \| list(:read \| :action \| :create \| :update \| :destroy)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`where`](#actions-read-prepare-where){: #actions-read-prepare-where } | `(any, any -> any) \| module \| list((any, any -> any) \| module)` | `[]` | Validations that should pass in order for this preparation to apply. Any of these validations failing will result in this preparation being ignored. |
| [`only_when_valid?`](#actions-read-prepare-only_when_valid?){: #actions-read-prepare-only_when_valid? } | `boolean` | `false` | If the preparation should only run on valid queries. |

Expand Down Expand Up @@ -1689,6 +1692,7 @@ update :flag_for_review, primary?: true
| [`atomic_upgrade?`](#actions-update-atomic_upgrade?){: #actions-update-atomic_upgrade? } | `boolean` | `false` | If set to `true`, atomic upgrades will be performed. Ignored if `required_atomic?` is `true`. See the update actions guide for more. |
| [`atomic_upgrade_with`](#actions-update-atomic_upgrade_with){: #actions-update-atomic_upgrade_with } | `atom \| nil` | | Configure the read action used when performing atomic upgrades. Defaults to the primary read action. |
| [`multitenancy`](#actions-update-multitenancy){: #actions-update-multitenancy } | `:enforce \| :allow_global \| :bypass \| :bypass_all` | `:enforce` | This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set, `:bypass_all` like `:bypass` but also bypasses the tenancy requirement for the nested resources. |
| [`extends`](#actions-update-extends){: #actions-update-extends } | `atom` | | The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable. |
| [`primary?`](#actions-update-primary?){: #actions-update-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
| [`description`](#actions-update-description){: #actions-update-description } | `String.t` | | An optional description for the action |
| [`transaction?`](#actions-update-transaction?){: #actions-update-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
Expand Down Expand Up @@ -1937,6 +1941,7 @@ end
| [`require_atomic?`](#actions-destroy-require_atomic?){: #actions-destroy-require_atomic? } | `boolean` | `true` | Require that the update be atomic. Only relevant if `soft?` is set to `true`. This means that all changes and validations implement the `atomic` callback. See the guide on atomic updates for more. |
| [`atomic_upgrade?`](#actions-destroy-atomic_upgrade?){: #actions-destroy-atomic_upgrade? } | `boolean` | `false` | If set to `true`, atomic upgrades will be performed. See the update actions guide for more. |
| [`atomic_upgrade_with`](#actions-destroy-atomic_upgrade_with){: #actions-destroy-atomic_upgrade_with } | `atom \| nil` | | Configure the read action used when performing atomic upgrades. Defaults to the primary read action. |
| [`extends`](#actions-destroy-extends){: #actions-destroy-extends } | `atom` | | The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable. |
| [`primary?`](#actions-destroy-primary?){: #actions-destroy-primary? } | `boolean` | `false` | Whether or not this action should be used when no action is specified by the caller. |
| [`description`](#actions-destroy-description){: #actions-destroy-description } | `String.t` | | An optional description for the action |
| [`transaction?`](#actions-destroy-transaction?){: #actions-destroy-transaction? } | `boolean` | | Whether or not the action should be run in transactions. Reads default to false, while create/update/destroy actions default to `true`. |
Expand Down Expand Up @@ -2699,7 +2704,7 @@ prepare build(sort: [:foo, :bar])

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`on`](#preparations-prepare-on){: #preparations-prepare-on } | `:read \| :action \| list(:read \| :action)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`on`](#preparations-prepare-on){: #preparations-prepare-on } | `:read \| :action \| :create \| :update \| :destroy \| list(:read \| :action \| :create \| :update \| :destroy)` | `[:read]` | The action types the preparation should run on. By default, preparations only run on read actions. Use `:action` to run on generic actions. |
| [`where`](#preparations-prepare-where){: #preparations-prepare-where } | `(any, any -> any) \| module \| list((any, any -> any) \| module)` | `[]` | Validations that should pass in order for this preparation to apply. Any of these validations failing will result in this preparation being ignored. |
| [`only_when_valid?`](#preparations-prepare-only_when_valid?){: #preparations-prepare-only_when_valid? } | `boolean` | `false` | If the preparation should only run on valid queries. |

Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/actions/action/action.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Ash.Resource.Actions.Action do
:description,
:returns,
:run,
extends: nil,
constraints: [],
touches_resources: [],
skip_unknown_inputs: [],
Comment on lines 10 to 16
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extends was added to the struct, but the @type t spec below doesn’t include the new extends field. Please update the type spec to match the struct fields.

Copilot uses AI. Check for mistakes.
Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/actions/create.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Ash.Resource.Actions.Create do
:description,
:error_handler,
:multitenancy,
extends: nil,
accept: nil,
require_attributes: [],
allow_nil_input: [],
Comment on lines 11 to 16
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extends was added to the struct, but the @type t spec below doesn’t include the new extends field. Please update the type spec to match the struct fields.

Copilot uses AI. Check for mistakes.
Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/actions/destroy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule Ash.Resource.Actions.Destroy do
:description,
:error_handler,
:multitenancy,
extends: nil,
manual: nil,
require_atomic?: Application.compile_env(:ash, :require_atomic_by_default?, true),
skip_unknown_inputs: [],
Comment on lines 13 to 18
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extends was added to the struct, but the @type t spec below doesn’t include the new extends field. Please update the type spec to match the struct fields.

Copilot uses AI. Check for mistakes.
Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/actions/read.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Ash.Resource.Actions.Read do

defstruct arguments: [],
description: nil,
extends: nil,
filter: nil,
Comment on lines 8 to 11
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extends was added to the struct, but the @type t spec below doesn’t include the new extends field. Please update the type spec to match the struct fields.

Copilot uses AI. Check for mistakes.
filters: [],
get_by: nil,
Expand Down
5 changes: 5 additions & 0 deletions lib/ash/resource/actions/shared_options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ defmodule Ash.Resource.Actions.SharedOptions do
required: true,
doc: "The name of the action"
],
extends: [
type: :atom,
doc:
"The name of an action of the same type to extend. All configuration is inherited, with list fields concatenated (base first, then extending action) and scalar fields overridable."
],
primary?: [
type: :boolean,
default: false,
Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/actions/update.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Ash.Resource.Actions.Update do
:description,
:error_handler,
:multitenancy,
extends: nil,
accept: nil,
require_attributes: [],
allow_nil_input: [],
Comment on lines 12 to 17
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extends was added to the struct, but the @type t spec below doesn’t include the new extends field. Please update the type spec to match the struct fields.

Copilot uses AI. Check for mistakes.
Expand Down
1 change: 1 addition & 0 deletions lib/ash/resource/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,7 @@ defmodule Ash.Resource.Dsl do
]

@transformers [
Ash.Resource.Transformers.ExtendActions,
Ash.Resource.Transformers.RequireUniqueActionNames,
Ash.Resource.Transformers.SetRelationshipSource,
Ash.Resource.Transformers.BelongsToAttribute,
Expand Down
123 changes: 123 additions & 0 deletions lib/ash/resource/transformers/extend_actions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
defmodule Ash.Resource.Transformers.ExtendActions do
@moduledoc "Resolves `extends` option on actions by merging configuration from the base action."
use Spark.Dsl.Transformer

alias Spark.Dsl.Transformer
alias Spark.Error.DslError

def before?(Ash.Resource.Transformers.RequireUniqueActionNames), do: true
def before?(Ash.Resource.Transformers.SetPrimaryActions), do: true
def before?(Ash.Resource.Transformers.DefaultAccept), do: true
def before?(Ash.Resource.Transformers.GetByReadActions), do: true
def before?(_), do: false

@list_fields %{
create:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs)a,
read: ~w(arguments preparations filters metadata touches_resources skip_unknown_inputs)a,
update:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs atomics)a,
destroy:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs)a,
Comment on lines +16 to +21
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action_select is a list option for create/update/destroy actions (see Ash.Resource.Actions.SharedOptions), but it’s not included in @list_fields, so it will be treated as a scalar (override/inherit) rather than concatenated. Either add it to the appropriate list fields or clarify the docs that some list-typed options are not concatenated.

Suggested change
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs)a,
read: ~w(arguments preparations filters metadata touches_resources skip_unknown_inputs)a,
update:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs atomics)a,
destroy:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs)a,
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs action_select)a,
read: ~w(arguments preparations filters metadata touches_resources skip_unknown_inputs)a,
update:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs atomics action_select)a,
destroy:
~w(arguments changes metadata reject require_attributes allow_nil_input notifiers touches_resources skip_unknown_inputs action_select)a,

Copilot uses AI. Check for mistakes.
action: ~w(arguments preparations touches_resources skip_unknown_inputs)a
}

@excluded_fields [:name, :primary?, :type, :extends, :__spark_metadata__]

def transform(dsl_state) do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.filter(& &1.extends)
|> Enum.reduce_while({:ok, dsl_state}, fn action, {:ok, dsl_state} ->
all_actions = Transformer.get_entities(dsl_state, [:actions])
base = Enum.find(all_actions, &(&1.name == action.extends))

Comment on lines +32 to +34
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base = Enum.find(all_actions, &(&1.name == action.extends)) can resolve to the action itself when extends points to its own name (or can participate in a cycle across multiple actions). That will silently duplicate list fields and produce unpredictable merges. Please add an explicit guard that disallows self-extension and detect/raise on cycles (e.g., by tracking a visited set while resolving bases).

Copilot uses AI. Check for mistakes.
cond do
is_nil(base) ->
{:halt,
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` extends `#{action.extends}`, but no action named `#{action.extends}` exists."
)}}

base.type != action.type ->
{:halt,
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` (#{action.type}) extends `#{action.extends}` (#{base.type}), but they must be the same type."
)}}
Comment on lines +39 to +54
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raised DslError here doesn’t include location, and the path is only [:actions, action.type], which makes it hard to pinpoint the offending extends option. Consider using the action entity’s :extends property anno for location and a more specific path like [:actions, action.name, :extends] (consistent with other action-related transformers).

Copilot uses AI. Check for mistakes.

true ->
merged = merge_action(action, base)

new_state =
Transformer.replace_entity(
dsl_state,
[:actions],
merged,
&(&1.name == action.name && &1.type == action.type)
)

{:cont, {:ok, new_state}}
end
end)
Comment on lines +28 to +69
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extends resolution is order-dependent for multi-level inheritance: if action A extends B and B extends C, and A is processed before B, A will merge against the unresolved B and won’t inherit C’s config. Consider resolving the full extends chain recursively/topologically (with cycle detection) before merging, so results don’t depend on action declaration order.

Suggested change
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.filter(& &1.extends)
|> Enum.reduce_while({:ok, dsl_state}, fn action, {:ok, dsl_state} ->
all_actions = Transformer.get_entities(dsl_state, [:actions])
base = Enum.find(all_actions, &(&1.name == action.extends))
cond do
is_nil(base) ->
{:halt,
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` extends `#{action.extends}`, but no action named `#{action.extends}` exists."
)}}
base.type != action.type ->
{:halt,
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` (#{action.type}) extends `#{action.extends}` (#{base.type}), but they must be the same type."
)}}
true ->
merged = merge_action(action, base)
new_state =
Transformer.replace_entity(
dsl_state,
[:actions],
merged,
&(&1.name == action.name && &1.type == action.type)
)
{:cont, {:ok, new_state}}
end
end)
actions = Transformer.get_entities(dsl_state, [:actions])
actions_by_key =
Map.new(actions, fn action ->
{{action.name, action.type}, action}
end)
actions
|> Enum.filter(& &1.extends)
|> Enum.reduce_while({:ok, dsl_state, %{}}, fn action, {:ok, dsl_state, resolved} ->
case resolve_action(action, dsl_state, actions_by_key, resolved, MapSet.new()) do
{:ok, new_state, new_resolved} ->
{:cont, {:ok, new_state, new_resolved}}
{:error, _} = error ->
{:halt, error}
end
end)
|> case do
{:ok, dsl_state, _resolved} ->
{:ok, dsl_state}
{:error, _} = error ->
error
end
end
defp resolve_action(action, dsl_state, actions_by_key, resolved, visiting) do
key = {action.name, action.type}
cond do
Map.has_key?(resolved, key) ->
{:ok, dsl_state, resolved}
MapSet.member?(visiting, key) ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Cyclic action inheritance detected starting at `#{action.name}` for type `#{action.type}`."
)}
is_nil(action.extends) ->
{:ok, dsl_state, Map.put(resolved, key, true)}
true ->
base_key = {action.extends, action.type}
base = Map.get(actions_by_key, base_key)
cond do
is_nil(base) ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` extends `#{action.extends}`, but no action named `#{action.extends}` exists."
)}
base.type != action.type ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:actions, action.type],
message:
"Action `#{action.name}` (#{action.type}) extends `#{action.extends}` (#{base.type}), but they must be the same type."
)}
true ->
visiting = MapSet.put(visiting, key)
case resolve_action(base, dsl_state, actions_by_key, resolved, visiting) do
{:ok, dsl_state, resolved} ->
all_actions = Transformer.get_entities(dsl_state, [:actions])
resolved_base =
Enum.find(all_actions, fn candidate ->
candidate.name == action.extends && candidate.type == action.type
end)
merged = merge_action(action, resolved_base)
new_state =
Transformer.replace_entity(
dsl_state,
[:actions],
merged,
&(&1.name == action.name && &1.type == action.type)
)
{:ok, new_state, Map.put(resolved, key, true)}
{:error, _} = error ->
error
end
end
end

Copilot uses AI. Check for mistakes.
end

defp merge_action(extending, base) do
list_fields = Map.get(@list_fields, extending.type, [])
explicitly_set = explicitly_set_fields(extending)

extending
|> Map.keys()
|> Enum.reject(&(&1 in @excluded_fields))
|> Enum.reduce(extending, fn field, acc ->
if field in list_fields do
Map.put(acc, field, Map.get(base, field, []) ++ Map.get(acc, field, []))
else
if field in explicitly_set do
acc
else
Map.put(acc, field, Map.get(base, field))
end
end
end)
end

defp explicitly_set_fields(entity) do
case entity do
%{__spark_metadata__: %{properties_anno: props}} when props != %{} ->
props
|> Map.keys()
|> Enum.reject(&(&1 in @excluded_fields))

_ ->
defaults = effective_defaults(entity)

entity
|> Map.keys()
|> Enum.reject(&(&1 in @excluded_fields))
|> Enum.filter(fn field ->
Map.get(entity, field) != Map.get(defaults, field)
end)
end
end

defp effective_defaults(entity) do
module = entity.__struct__
struct_defaults = module.__struct__()
opt_schema = module.opt_schema()

opt_schema_defaults =
opt_schema
|> Enum.filter(fn {_key, opts} -> Keyword.has_key?(opts, :default) end)
|> Map.new(fn {key, opts} -> {key, opts[:default]} end)

Map.merge(struct_defaults, opt_schema_defaults)
end
end
Loading
Loading