improvement: better create/update first argument (#1060)

* improvement: better create/update first argument

First argument of Ash.create (with a resource)
and Ash.update (with a record) no longer are in a tuple
with its arguments.

Arguments are moved in the input option.

* improvement: create/update params no more an opts

`Ash.create(User, %{name: "Yasmine"})` 🎉

* improvement: raise if changeset already validated

Raise an argument error for already validated changeset
when params are given.
This commit is contained in:
Pierre Le Gall 2024-04-30 19:02:27 +02:00 committed by GitHub
parent 09238490c4
commit 4ddc91be51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 409 additions and 139 deletions

View file

@ -1981,33 +1981,16 @@ defmodule Ash do
Create a record or raises an error. See `create/2` for more information. Create a record or raises an error. See `create/2` for more information.
""" """
@doc spark_opts: [{1, @create_opts_schema}] @doc spark_opts: [{1, @create_opts_schema}]
@spec create!(Ash.Changeset.t() | resource_with_args(), Keyword.t()) :: @spec create!(
changset_or_resource :: Ash.Changeset.t() | Ash.Changeset.t(),
params_or_opts :: map() | Keyword.t(),
opts :: Keyword.t()
) ::
Ash.Resource.record() Ash.Resource.record()
| {Ash.Resource.record(), list(Ash.Notifier.Notification.t())} | {Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| no_return | no_return
def create!(changeset_or_resource_with_args, opts \\ []) def create!(changeset_or_resource, params \\ %{}, opts \\ []) do
create(changeset_or_resource, params, opts)
def create!({resource, args}, opts) do
Ash.Helpers.expect_resource!(resource)
Ash.Helpers.expect_options!(opts)
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_create_opts()))
create_opts = Keyword.take(opts, Keyword.keys(@create_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(resource, :create).name
args = Enum.into(args, %{})
resource
|> Ash.Changeset.for_create(action, args, changeset_opts)
|> create!(create_opts)
end
def create!(changeset, opts) do
Ash.Helpers.expect_changeset!(changeset)
Ash.Helpers.expect_options!(opts)
changeset
|> create(opts)
|> Ash.Helpers.unwrap_or_raise!() |> Ash.Helpers.unwrap_or_raise!()
end end
@ -2017,32 +2000,42 @@ defmodule Ash do
#{Spark.Options.docs(@create_opts_schema)} #{Spark.Options.docs(@create_opts_schema)}
""" """
@doc spark_opts: [{1, @create_opts_schema}] @doc spark_opts: [{1, @create_opts_schema}]
@spec create(Ash.Changeset.t() | resource_with_args(), Keyword.t()) :: @spec create(
changset_or_resource :: Ash.Changeset.t() | Ash.Changeset.t(),
params_or_opts :: map() | Keyword.t(),
opts :: Keyword.t()
) ::
{:ok, Ash.Resource.record()} {:ok, Ash.Resource.record()}
| {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())} | {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| {:error, term} | {:error, term}
def create(changeset_or_resource_with_argument, opts \\ []) def create(changeset_or_resource, params_or_opts \\ %{}, opts \\ [])
def create({resource, args}, opts) do def create(%Ash.Changeset{} = changeset, params_or_opts, opts) do
Ash.Helpers.expect_resource!(resource) {params, opts} = Ash.Helpers.get_params_and_opts(params_or_opts, opts)
Ash.Helpers.expect_options!(opts)
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_create_opts()))
create_opts = Keyword.take(opts, Keyword.keys(@create_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(resource, :create).name
args = Enum.into(args, %{})
resource
|> Ash.Changeset.for_create(action, args, changeset_opts)
|> create(create_opts)
end
def create(changeset, opts) do
Ash.Helpers.expect_changeset!(changeset)
Ash.Helpers.expect_options!(opts) Ash.Helpers.expect_options!(opts)
domain = Ash.Helpers.domain!(changeset, opts) domain = Ash.Helpers.domain!(changeset, opts)
changeset =
cond do
changeset.__validated_for_action__ && params != %{} ->
raise ArgumentError,
message: """
params should not be provided for a changeset
that has already been validated for an action
"""
is_nil(changeset.__validated_for_action__) ->
action =
opts[:action] ||
Ash.Resource.Info.primary_action!(changeset.resource, :create).name
Ash.Changeset.for_create(changeset, action, params, opts)
true ->
changeset
end
with {:ok, opts} <- Spark.Options.validate(opts, @create_opts_schema), with {:ok, opts} <- Spark.Options.validate(opts, @create_opts_schema),
{:ok, resource} <- Ash.Domain.Info.resource(domain, changeset.resource), {:ok, resource} <- Ash.Domain.Info.resource(domain, changeset.resource),
{:ok, action} <- Ash.Helpers.get_action(resource, opts, :create, changeset.action) do {:ok, action} <- Ash.Helpers.get_action(resource, opts, :create, changeset.action) do
@ -2052,6 +2045,22 @@ defmodule Ash do
end end
end end
def create(resource, params_or_opts, opts) when is_atom(resource) do
{params, opts} = Ash.Helpers.get_params_and_opts(params_or_opts, opts)
Ash.Helpers.expect_resource!(resource)
Ash.Helpers.expect_options!(opts)
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_create_opts()))
create_opts = Keyword.take(opts, Keyword.keys(@create_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(resource, :create).name
resource
|> Ash.Changeset.for_create(action, params, changeset_opts)
|> create(create_opts)
end
@doc """ @doc """
Creates many records, raising any errors that are returned. See `bulk_create/4` for more. Creates many records, raising any errors that are returned. See `bulk_create/4` for more.
""" """
@ -2377,35 +2386,17 @@ defmodule Ash do
@doc """ @doc """
Update a record. See `update/2` for more information. Update a record. See `update/2` for more information.
""" """
@spec update!(Ash.Changeset.t() | record_with_args(), opts :: Keyword.t()) :: @doc spark_opts: [{1, @update_opts_schema}]
@spec update!(
changeset_or_record :: Ash.Changeset.t() | Ash.Resource.record(),
params_or_opts :: map() | Keyword.t(),
opts :: Keyword.t()
) ::
Ash.Resource.record() Ash.Resource.record()
| {Ash.Resource.record(), list(Ash.Notifier.Notification.t())} | {Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| no_return | no_return
def update!(changeset_or_record_with_args, opts \\ []) def update!(changeset_or_record, params_or_opts \\ %{}, opts \\ []) do
update(changeset_or_record, params_or_opts, opts)
def update!({record, args}, opts) do
Ash.Helpers.expect_record!(record)
Ash.Helpers.expect_options!(opts)
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_update_opts()))
update_opts = Keyword.take(opts, Keyword.keys(@update_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(record, :create).name
args = Enum.into(args, %{})
record
|> Ash.Changeset.for_update(action, args, changeset_opts)
|> update!(update_opts)
end
@doc spark_opts: [{1, @update_opts_schema}]
def update!(changeset, opts) do
Ash.Helpers.expect_changeset!(changeset)
Ash.Helpers.expect_options!(opts)
opts = Spark.Options.validate!(opts, @update_opts_schema)
changeset
|> update(opts)
|> Ash.Helpers.unwrap_or_raise!() |> Ash.Helpers.unwrap_or_raise!()
end end
@ -2414,30 +2405,40 @@ defmodule Ash do
#{Spark.Options.docs(@update_opts_schema)} #{Spark.Options.docs(@update_opts_schema)}
""" """
@spec update(Ash.Changeset.t() | record_with_args(), opts :: Keyword.t()) :: @spec update(
changeset_or_record :: Ash.Changeset.t() | Ash.Resource.record(),
params_or_opts :: map() | Keyword.t(),
opts :: Keyword.t()
) ::
{:ok, Ash.Resource.record()} {:ok, Ash.Resource.record()}
| {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())} | {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| {:error, term} | {:error, term}
def update(changeset_or_record_with_args, opts \\ []) def update(changeset_or_record, params_or_opts \\ %{}, opts \\ [])
def update({record, args}, opts) do
Ash.Helpers.expect_record!(record)
Ash.Helpers.expect_options!(opts)
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_update_opts()))
update_opts = Keyword.take(opts, Keyword.keys(@update_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(record, :update).name
args = Enum.into(args, %{})
record
|> Ash.Changeset.for_update(action, args, changeset_opts)
|> update(update_opts)
end
@doc spark_opts: [{1, @update_opts_schema}] @doc spark_opts: [{1, @update_opts_schema}]
def update(changeset, opts) do def update(%Ash.Changeset{} = changeset, params_or_opts, opts) do
Ash.Helpers.expect_changeset!(changeset) {params, opts} = Ash.Helpers.get_params_and_opts(params_or_opts, opts)
changeset =
cond do
changeset.__validated_for_action__ && params != %{} ->
raise ArgumentError,
message: """
params should not be provided for a changeset
that has already been validated for an action
"""
is_nil(changeset.__validated_for_action__) ->
action =
opts[:action] ||
Ash.Resource.Info.primary_action!(changeset.resource, :update).name
Ash.Changeset.for_update(changeset, action, params, opts)
true ->
changeset
end
Ash.Helpers.expect_options!(opts) Ash.Helpers.expect_options!(opts)
domain = Ash.Helpers.domain!(changeset, opts) domain = Ash.Helpers.domain!(changeset, opts)
@ -2455,6 +2456,23 @@ defmodule Ash do
end end
end end
def update(record, params_or_opts, opts) do
{params, opts} = Ash.Helpers.get_params_and_opts(params_or_opts, opts)
Ash.Helpers.expect_record!(record)
Ash.Helpers.expect_options!(opts)
Ash.Helpers.expect_map_or_nil!(opts[:input])
changeset_opts = Keyword.take(opts, Keyword.keys(Ash.Changeset.for_update_opts()))
update_opts = Keyword.take(opts, Keyword.keys(@update_opts_schema))
action = opts[:action] || Ash.Resource.Info.primary_action!(record, :update).name
record
|> Ash.Changeset.for_update(action, params, changeset_opts)
|> update(update_opts)
end
@doc """ @doc """
Destroy a record. See `destroy/2` for more information. Destroy a record. See `destroy/2` for more information.
""" """

View file

@ -178,6 +178,24 @@ defmodule Ash.Helpers do
end end
end end
defmacro expect_map_or_nil!(map_or_nil) do
formatted = format_caller(__CALLER__)
quote generated: true, bind_quoted: [map_or_nil: map_or_nil, formatted: formatted] do
case map_or_nil do
nil ->
:ok
map when is_map(map) ->
: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 def resource_from_query_or_stream(domain, query_or_stream, opts) do
resource = resource =
opts[:resource] || opts[:resource] ||
@ -251,6 +269,17 @@ defmodule Ash.Helpers do
end end
end end
@doc """
Returns {params, opts} from ambigous inputs.
"""
def get_params_and_opts(params_or_opts, opts) do
if opts == [] && Keyword.keyword?(params_or_opts) do
{%{}, params_or_opts}
else
{params_or_opts, opts}
end
end
def pagination_check(action, resource, opts) do def pagination_check(action, resource, opts) do
if Keyword.get(opts, :page) && Keyword.get(opts, :page) != [] && !Map.get(action, :pagination) do if Keyword.get(opts, :page) && Keyword.get(opts, :page) != [] && !Map.get(action, :pagination) do
{:error, {:error,

View file

@ -1,5 +1,6 @@
defmodule Ash.Test.AshTest do defmodule Ash.Test.AshTest do
@moduledoc false @moduledoc false
use ExUnit.Case, async: true use ExUnit.Case, async: true
defmodule Domain do defmodule Domain do
@ -52,102 +53,324 @@ defmodule Ash.Test.AshTest do
end end
describe "create/1" do describe "create/1" do
test "with a {resource, args} as first argument" do test "with a changeset as first argument" do
assert {:ok, %User{name: "John"}} = Ash.create({User, name: "John"}) assert {:ok, %User{name: nil}} =
assert {:ok, %User{name: "John"}} = Ash.create({User, %{name: "John"}}) User
|> Ash.Changeset.new()
|> Ash.create()
end
test "with a resource as first argument" do
assert {:ok, %User{name: nil}} = Ash.create(User)
end end
end end
describe "create/2" do describe "create/2" do
test "with a {record, args} as first argument and explicit action" do test "with a changeset as first argument, with params as second argument" do
assert {:ok, %User{name: "John"}} = assert {:ok, %User{name: "Alice"}} =
Ash.create({User, name: "John"}, action: :create) User
|> Ash.Changeset.new()
|> Ash.create(%{name: "Alice"})
assert {:ok, %User{name: "John"}} = assert_raise ArgumentError, fn ->
Ash.create({User, %{name: "John"}}, action: :create) User
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:create)
|> Ash.create(%{name: "Alice"})
end
end
assert {:ok, %User{name: "John", state: :awake}} = test "with a changeset as first argument, with opts as second argument" do
Ash.create({User, name: "John"}, action: :create_awake) assert {:ok, %User{name: nil}} =
User
|> Ash.Changeset.new()
|> Ash.create(action: :create)
end
assert {:ok, %User{name: "John", state: :awake}} = test "with a resource as first argument, with params as second argument" do
Ash.create({User, %{name: "John"}}, action: :create_awake) assert {:ok, %User{name: "Alice"}} = Ash.create(User, %{name: "Alice"})
end
test "with a resource as first argument, with opts as second argument" do
assert {:ok, %User{name: nil}} = Ash.create(User, action: :create)
end
end
describe "create/3" do
test "with a changeset as first argument, then params and opts" do
assert {:ok, %User{name: "Alice"}} =
User
|> Ash.Changeset.new()
|> Ash.create(%{name: "Alice"})
assert {:ok, %User{name: "Alice", state: :awake}} =
User
|> Ash.Changeset.new()
|> Ash.create(%{name: "Alice"}, action: :create_awake)
assert_raise ArgumentError, fn ->
User
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:create)
|> Ash.create(%{name: "Alice"})
end
end
test "with a changeset as first argument, with params and opts" do
assert {:ok, %User{name: "Alice"}} =
User
|> Ash.Changeset.new()
|> Ash.create(%{name: "Alice"}, action: :create)
end
test "with a record as first argument, then params and opts" do
assert {:ok, %User{name: "Alice"}} =
Ash.create(User, %{name: "Alice"}, action: :create)
assert {:ok, %User{name: "Alice", state: :awake}} =
Ash.create(User, %{name: "Alice"}, action: :create_awake)
end end
end end
describe "create!/1" do describe "create!/1" do
test "with a {resource, args} as first argument" do test "with a changeset as first argument" do
assert %User{name: "John"} = Ash.create!({User, name: "John"}) assert %User{name: nil} =
assert %User{name: "John"} = Ash.create!({User, %{name: "John"}}) User
|> Ash.Changeset.new()
|> Ash.create!()
end
test "with a resource as first argument" do
assert %User{name: nil} = Ash.create!(User)
end end
end end
describe "create!/2" do describe "create!/2" do
test "with a {record, args} as first argument and explicit action" do test "with a changeset as first argument, with params as second argument" do
assert %User{name: "John"} = assert %User{name: "Alice"} =
Ash.create!({User, name: "John"}, action: :create) User
|> Ash.Changeset.new()
|> Ash.create!(%{name: "Alice"})
assert %User{name: "John"} = assert_raise ArgumentError, fn ->
Ash.create!({User, %{name: "John"}}, action: :create) User
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:create)
|> Ash.create!(%{name: "Alice"})
end
end
assert %User{name: "John", state: :awake} = test "with a changeset as first argument, with opts as second argument" do
Ash.create!({User, name: "John"}, action: :create_awake) assert %User{name: nil} =
User
|> Ash.Changeset.new()
|> Ash.create!(action: :create)
end
assert %User{name: "John", state: :awake} = test "with a resource as first argument, with params as second argument" do
Ash.create!({User, %{name: "John"}}, action: :create_awake) assert %User{name: "Alice"} = Ash.create!(User, %{name: "Alice"})
end
test "with a resource as first argument, with opts as second argument" do
assert %User{name: nil} = Ash.create!(User, action: :create)
end
end
describe "create!/3" do
test "with a changeset as first argument, then params and opts" do
assert %User{name: "Alice"} =
User
|> Ash.Changeset.new()
|> Ash.create!(%{name: "Alice"})
assert %User{name: "Alice", state: :awake} =
User
|> Ash.Changeset.new()
|> Ash.create!(%{name: "Alice"}, action: :create_awake)
assert_raise ArgumentError, fn ->
User
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:create)
|> Ash.create!(%{name: "Alice"})
end
end
test "with a record as first argument, then params and opts" do
assert %User{name: "Alice"} =
Ash.create!(User, %{name: "Alice"}, action: :create)
assert %User{name: "Alice", state: :awake} =
Ash.create!(User, %{name: "Alice"}, action: :create_awake)
end end
end end
describe "update/1" do describe "update/1" do
test "with a {record, args} as first argument" do test "with a changeset as first argument" do
user = Ash.create!({User, name: "John"}) user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Jane"}} = Ash.update({user, name: "Jane"}) assert {:ok, %User{name: "Alice"}} =
assert {:ok, %User{name: "Jane"}} = Ash.update({user, %{name: "Jane"}}) user
|> Ash.Changeset.new()
|> Ash.update()
end
test "with a record as first argument" do
user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Alice"}} = Ash.update(user)
end end
end end
describe "update/2" do describe "update/2" do
test "with a {record, args} as first argument and explicit action" do test "with a changeset as first argument, with params as second argument" do
user = Ash.create!({User, name: "John"}) user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Jane"}} = assert {:ok, %User{name: "Bob"}} =
Ash.update({user, name: "Jane"}, action: :update) user
|> Ash.Changeset.new()
|> Ash.update(%{name: "Bob"})
assert {:ok, %User{name: "Jane"}} = assert_raise ArgumentError, fn ->
Ash.update({user, %{name: "Jane"}}, action: :update) user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:update)
|> Ash.update(%{name: "Bob"})
end
end
assert {:ok, %User{name: "John", state: :awake}} = test "with a changeset as first argument, with opts as second argument" do
Ash.update({user, state: :awake}, action: :update_state) user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "John", state: :awake}} = assert %User{name: "Alice"} =
Ash.update({user, %{state: :awake}}, action: :update_state) user
|> Ash.Changeset.new()
|> Ash.update!(action: :update)
end
test "with a record as first argument, with params as second argument" do
user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Bob"}} = Ash.update(user, %{name: "Bob"})
end
test "with a record as first argument, with opts as second argument" do
user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Alice"}} = Ash.update(user, action: :update)
end
end
describe "update/3" do
test "with a changeset as first argument, then params and opts" do
user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Bob"}} =
user
|> Ash.Changeset.new()
|> Ash.update(%{name: "Bob"}, action: :update)
assert {:ok, %User{name: "Alice", state: :awake}} =
user
|> Ash.Changeset.new()
|> Ash.update(%{state: :awake}, action: :update_state)
assert_raise ArgumentError, fn ->
user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:update)
|> Ash.update(%{name: "Bob"})
end
end
test "with a record as first argument, then params and opts" do
user = Ash.create!(User, %{name: "Alice"})
assert {:ok, %User{name: "Bob"}} =
Ash.update(user, %{name: "Bob"}, action: :update)
assert {:ok, %User{name: "Alice", state: :awake}} =
Ash.update(user, %{state: :awake}, action: :update_state)
end end
end end
describe "update!/1" do describe "update!/1" do
test "with a {record, args} as first argument" do test "with a changeset as first argument" do
user = Ash.create!({User, name: "John"}) user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Jane"} = Ash.update!({user, name: "Jane"}) assert %User{name: "Alice"} =
assert %User{name: "Jane"} = Ash.update!({user, %{name: "Jane"}}) user
|> Ash.Changeset.new()
|> Ash.update!()
end
test "with a record as first argument" do
user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Alice"} = Ash.update!(user)
end end
end end
describe "update!/2" do describe "update!/2" do
test "with a {record, args} as first argument and explicit action" do test "with a changeset as first argument, with params as second argument" do
user = Ash.create!({User, name: "John"}) user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Jane"} = assert %User{name: "Bob"} =
Ash.update!({user, name: "Jane"}, action: :update) user
|> Ash.Changeset.new()
|> Ash.update!(%{name: "Bob"})
assert %User{name: "Jane"} = assert_raise ArgumentError, fn ->
Ash.update!({user, %{name: "Jane"}}, action: :update) user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:update)
|> Ash.update!(%{name: "Bob"})
end
end
assert %User{name: "John", state: :awake} = test "with a record as first argument, with params as second argument" do
Ash.update!({user, state: :awake}, action: :update_state) user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "John", state: :awake} = assert %User{name: "Bob"} = Ash.update!(user, %{name: "Bob"})
Ash.update!({user, %{state: :awake}}, action: :update_state) end
test "with a record as first argument, with opts as second argument" do
user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Alice"} = Ash.update!(user, action: :update)
end
end
describe "update!/3" do
test "with a changeset as first argument, then params and opts" do
user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Bob"} =
user
|> Ash.Changeset.new()
|> Ash.update!(%{name: "Bob"}, action: :update)
assert %User{name: "Alice", state: :awake} =
user
|> Ash.Changeset.new()
|> Ash.update!(%{state: :awake}, action: :update_state)
assert_raise ArgumentError, fn ->
user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:update)
|> Ash.update!(%{name: "Bob"})
end
end
test "with a record as first argument and explicit action" do
user = Ash.create!(User, %{name: "Alice"})
assert %User{name: "Bob"} =
Ash.update!(user, %{name: "Bob"}, action: :update)
assert %User{name: "Alice", state: :awake} =
Ash.update!(user, %{state: :awake}, action: :update_state)
end end
end end
end end