Skip to content

Commit 9ea296c

Browse files
RylandBangerter85zachdaniel
authored andcommitted
feat: add batch_validate callback to Ash.Resource.Validation (#2650)
Validations now support batch processing during bulk operations via batch_validate/3, mirroring the existing pattern on Ash.Resource.Change. This allows validations to process entire batches at once instead of per-changeset, enabling optimizations like batched external API calls or cross-record checks.
1 parent 4eb049e commit 9ea296c

File tree

5 files changed

+519
-102
lines changed

5 files changed

+519
-102
lines changed

lib/ash/actions/create/bulk.ex

Lines changed: 89 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,98 +1856,114 @@ defmodule Ash.Actions.Create.Bulk do
18561856
%{must_return_records?: false, re_sort?: false, batch: batch, changes: %{}},
18571857
fn
18581858
{%{validation: {module, opts}} = validation, _change_index}, %{batch: batch} = state ->
1859+
validation_context =
1860+
struct(
1861+
Ash.Resource.Validation.Context,
1862+
Map.put(context, :message, validation.message)
1863+
)
1864+
18591865
batch =
1860-
Enum.map(batch, fn changeset ->
1861-
cond do
1862-
!module.has_validate?() ->
1863-
Ash.Changeset.add_error(
1864-
changeset,
1865-
Ash.Error.Framework.CanNotBeAtomic.exception(
1866-
resource: changeset.resource,
1867-
change: module,
1868-
reason: "Create actions cannot be made atomic"
1866+
if module.has_batch_validate?() &&
1867+
module.batch_callbacks?(batch, opts, validation_context) do
1868+
Ash.Actions.Helpers.Bulk.run_batch_validation(
1869+
validation,
1870+
batch,
1871+
validation_context,
1872+
actor
1873+
)
1874+
else
1875+
Enum.map(batch, fn changeset ->
1876+
cond do
1877+
!module.has_validate?() ->
1878+
Ash.Changeset.add_error(
1879+
changeset,
1880+
Ash.Error.Framework.CanNotBeAtomic.exception(
1881+
resource: changeset.resource,
1882+
change: module,
1883+
reason: "Create actions cannot be made atomic"
1884+
)
18691885
)
1870-
)
18711886

1872-
invalid =
1873-
Enum.find(validation.where, fn {where_mod, _} ->
1874-
!where_mod.has_validate?()
1875-
end) ->
1876-
{invalid_mod, _} = invalid
1877-
1878-
Ash.Changeset.add_error(
1879-
changeset,
1880-
Ash.Error.Framework.CanNotBeAtomic.exception(
1881-
resource: changeset.resource,
1882-
change: invalid_mod,
1883-
reason: "Create actions cannot be made atomic"
1887+
invalid =
1888+
Enum.find(validation.where, fn {where_mod, _} ->
1889+
!where_mod.has_validate?()
1890+
end) ->
1891+
{invalid_mod, _} = invalid
1892+
1893+
Ash.Changeset.add_error(
1894+
changeset,
1895+
Ash.Error.Framework.CanNotBeAtomic.exception(
1896+
resource: changeset.resource,
1897+
change: invalid_mod,
1898+
reason: "Create actions cannot be made atomic"
1899+
)
18841900
)
1885-
)
18861901

1887-
validation.only_when_valid? && !changeset.valid? ->
1888-
changeset
1902+
validation.only_when_valid? && !changeset.valid? ->
1903+
changeset
1904+
1905+
Enum.all?(validation.where || [], fn {where_mod, where_opts} ->
1906+
where_opts =
1907+
templated_opts(
1908+
where_opts,
1909+
actor,
1910+
changeset.to_tenant,
1911+
changeset.arguments,
1912+
changeset.context,
1913+
changeset
1914+
)
18891915

1890-
Enum.all?(validation.where || [], fn {where_mod, where_opts} ->
1891-
where_opts =
1916+
{:ok, where_opts} = Ash.Resource.Validation.init(where_mod, where_opts)
1917+
1918+
Ash.Resource.Validation.validate(
1919+
where_mod,
1920+
changeset,
1921+
where_opts,
1922+
struct(Ash.Resource.Validation.Context, context)
1923+
) == :ok
1924+
end) ->
1925+
opts =
18921926
templated_opts(
1893-
where_opts,
1927+
opts,
18941928
actor,
18951929
changeset.to_tenant,
18961930
changeset.arguments,
18971931
changeset.context,
18981932
changeset
18991933
)
19001934

1901-
{:ok, where_opts} = Ash.Resource.Validation.init(where_mod, where_opts)
1902-
1903-
Ash.Resource.Validation.validate(
1904-
where_mod,
1905-
changeset,
1906-
where_opts,
1907-
struct(Ash.Resource.Validation.Context, context)
1908-
) == :ok
1909-
end) ->
1910-
opts =
1911-
templated_opts(
1912-
opts,
1913-
actor,
1914-
changeset.to_tenant,
1915-
changeset.arguments,
1916-
changeset.context,
1917-
changeset
1918-
)
1919-
1920-
{:ok, opts} = Ash.Resource.Validation.init(module, opts)
1935+
{:ok, opts} = Ash.Resource.Validation.init(module, opts)
19211936

1922-
case Ash.Resource.Validation.validate(
1923-
module,
1924-
changeset,
1925-
opts,
1926-
struct(
1927-
Ash.Resource.Validation.Context,
1928-
Map.put(context, :message, validation.message)
1929-
)
1930-
) do
1931-
:ok ->
1932-
changeset
1937+
case Ash.Resource.Validation.validate(
1938+
module,
1939+
changeset,
1940+
opts,
1941+
struct(
1942+
Ash.Resource.Validation.Context,
1943+
Map.put(context, :message, validation.message)
1944+
)
1945+
) do
1946+
:ok ->
1947+
changeset
19331948

1934-
{:error, error} ->
1935-
error = Ash.Error.to_ash_error(error)
1949+
{:error, error} ->
1950+
error = Ash.Error.to_ash_error(error)
19361951

1937-
if validation.message do
1938-
error =
1939-
Ash.Error.override_validation_message(error, validation.message)
1952+
if validation.message do
1953+
error =
1954+
Ash.Error.override_validation_message(error, validation.message)
19401955

1941-
Ash.Changeset.add_error(changeset, error)
1942-
else
1943-
Ash.Changeset.add_error(changeset, error)
1944-
end
1945-
end
1956+
Ash.Changeset.add_error(changeset, error)
1957+
else
1958+
Ash.Changeset.add_error(changeset, error)
1959+
end
1960+
end
19461961

1947-
true ->
1948-
changeset
1949-
end
1950-
end)
1962+
true ->
1963+
changeset
1964+
end
1965+
end)
1966+
end
19511967

19521968
%{
19531969
state

lib/ash/actions/helpers/bulk.ex

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,4 +496,74 @@ defmodule Ash.Actions.Helpers.Bulk do
496496
end
497497

498498
def clean_changeset_id_metadata(other), do: other
499+
500+
@doc """
501+
Runs batch validation for a validation that implements `batch_validate/3`.
502+
503+
Splits the batch into matching/non-matching changesets based on `where` and
504+
`only_when_valid?` conditions, runs `batch_validate` on the matches, and
505+
merges the results back together.
506+
"""
507+
def run_batch_validation(
508+
%{validation: {module, opts}} = validation,
509+
batch,
510+
validation_context,
511+
actor
512+
) do
513+
{matches, non_matches} =
514+
if Enum.empty?(validation.where) && !validation.only_when_valid? do
515+
{batch, []}
516+
else
517+
Enum.split_with(batch, fn changeset ->
518+
applies_from_only_when_valid? =
519+
if validation.only_when_valid?, do: changeset.valid?, else: true
520+
521+
applies_from_where? =
522+
Enum.all?(validation.where || [], fn {where_module, where_opts} ->
523+
where_opts =
524+
Ash.Actions.Helpers.templated_opts(
525+
where_opts,
526+
actor,
527+
changeset.to_tenant,
528+
changeset.arguments,
529+
changeset.context,
530+
changeset
531+
)
532+
533+
{:ok, where_opts} = where_module.init(where_opts)
534+
535+
Ash.Resource.Validation.validate(
536+
where_module,
537+
changeset,
538+
where_opts,
539+
validation_context
540+
) == :ok
541+
end)
542+
543+
applies_from_where? and applies_from_only_when_valid?
544+
end)
545+
end
546+
547+
if Enum.empty?(matches) do
548+
non_matches
549+
else
550+
opts =
551+
case opts do
552+
{:templated, opts} -> opts
553+
opts -> opts
554+
end
555+
556+
{:ok, opts} = module.init(opts)
557+
558+
validated =
559+
module.batch_validate(
560+
matches,
561+
opts,
562+
struct(validation_context, bulk?: true)
563+
)
564+
|> Enum.to_list()
565+
566+
Enum.concat(validated, non_matches)
567+
end
568+
end
499569
end

lib/ash/actions/update/bulk.ex

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3545,44 +3545,55 @@ defmodule Ash.Actions.Update.Bulk do
35453545
all_changes,
35463546
%{must_return_records?: false, re_sort?: false, batch: batch, changes: %{}},
35473547
fn
3548-
{%{validation: {module, _opts}} = validation, _change_index}, %{batch: batch} = state ->
3548+
{%{validation: {module, validation_opts}} = validation, _change_index},
3549+
%{batch: batch} = state ->
35493550
validation_context =
35503551
struct(
35513552
Ash.Resource.Validation.Context,
35523553
Map.put(context, :message, validation.message)
35533554
)
35543555

35553556
batch =
3556-
if module.has_validate?() &&
3557-
Enum.all?(validation.where, fn {module, _opts} ->
3558-
module.has_validate?()
3559-
end) do
3560-
if validation.before_action? do
3561-
Enum.map(batch, fn changeset ->
3562-
Ash.Changeset.before_action(changeset, fn changeset ->
3563-
[changeset] =
3564-
validate_batch_non_atomically(
3565-
validation,
3566-
[changeset],
3567-
validation_context,
3568-
actor
3569-
)
3557+
if module.has_batch_validate?() &&
3558+
module.batch_callbacks?(batch, validation_opts, validation_context) do
3559+
Ash.Actions.Helpers.Bulk.run_batch_validation(
3560+
validation,
3561+
batch,
3562+
validation_context,
3563+
actor
3564+
)
3565+
else
3566+
if module.has_validate?() &&
3567+
Enum.all?(validation.where, fn {module, _opts} ->
3568+
module.has_validate?()
3569+
end) do
3570+
if validation.before_action? do
3571+
Enum.map(batch, fn changeset ->
3572+
Ash.Changeset.before_action(changeset, fn changeset ->
3573+
[changeset] =
3574+
validate_batch_non_atomically(
3575+
validation,
3576+
[changeset],
3577+
validation_context,
3578+
actor
3579+
)
35703580

3571-
changeset
3581+
changeset
3582+
end)
35723583
end)
3573-
end)
3574-
else
3575-
validate_batch_non_atomically(validation, batch, validation_context, actor)
3576-
end
3577-
else
3578-
if module.atomic?() do
3579-
validate_batch_atomically(validation, batch, validation_context, context, actor)
3584+
else
3585+
validate_batch_non_atomically(validation, batch, validation_context, actor)
3586+
end
35803587
else
3581-
raise """
3582-
Cannot use a non-atomic validation with an atomic condition.
3588+
if module.atomic?() do
3589+
validate_batch_atomically(validation, batch, validation_context, context, actor)
3590+
else
3591+
raise """
3592+
Cannot use a non-atomic validation with an atomic condition.
35833593
3584-
Attempting to run action: `#{inspect(resource)}.#{action.name}`
3585-
"""
3594+
Attempting to run action: `#{inspect(resource)}.#{action.name}`
3595+
"""
3596+
end
35863597
end
35873598
end
35883599

0 commit comments

Comments
 (0)