diff --git a/lib/ash/generator/generator.ex b/lib/ash/generator/generator.ex index be8a28343a..6cbc06e225 100644 --- a/lib/ash/generator/generator.ex +++ b/lib/ash/generator/generator.ex @@ -401,6 +401,10 @@ defmodule Ash.Generator do * `:context` - Passed through to the changeset * `:after_action` - A one argument function that takes the result and returns a new result to run after the record is created. + * `:generate_rest?` - If `false`, only generates values for attributes explicitly provided + in the attributes map (and overrides). Unlisted attributes receive their resource-level + default or `nil` (if `allow_nil?`). Raises if a required attribute with no default is + missing. Only applies to the tuple form `{Resource, attrs}`. Defaults to `true`. """ @spec seed_generator( Ash.Resource.record() @@ -447,21 +451,28 @@ defmodule Ash.Generator do |> StreamData.fixed_map() {resource, attributes} -> - resource - |> Ash.Resource.Info.attributes() - |> Enum.reject(&(!&1.writable? && &1.generated?)) - |> generate_attributes( - to_generators( - Map.put( - Map.merge(attributes, Map.new(opts[:overrides] || %{})), - :__will_be_struct__, - resource - ) - ), - false, - :create, - [] - ) + merged = Map.merge(attributes, Map.new(opts[:overrides] || %{})) + + if Keyword.get(opts, :generate_rest?, true) do + resource + |> Ash.Resource.Info.attributes() + |> Enum.reject(&(!&1.writable? && &1.generated?)) + |> generate_attributes( + to_generators( + Map.put(merged, :__will_be_struct__, resource) + ), + false, + :create, + [] + ) + else + validate_required_attributes(resource, merged, :create) + + merged + |> to_generators() + |> Map.put(:__will_be_struct__, resource) + |> StreamData.fixed_map() + end end end) |> StreamData.map(fn keys -> @@ -502,15 +513,22 @@ defmodule Ash.Generator do |> StreamData.fixed_map() {_resource, attributes} -> - resource - |> Ash.Resource.Info.attributes() - |> Enum.reject(&(!&1.writable? && &1.generated?)) - |> generate_attributes( - to_generators(Map.merge(attributes, Map.new(opts[:overrides] || %{}))), - false, - :create, - [] - ) + merged = Map.merge(attributes, Map.new(opts[:overrides] || %{})) + + if Keyword.get(opts, :generate_rest?, true) do + resource + |> Ash.Resource.Info.attributes() + |> Enum.reject(&(!&1.writable? && &1.generated?)) + |> generate_attributes( + to_generators(merged), + false, + :create, + [] + ) + else + validate_required_attributes(resource, merged, :create) + merged |> to_generators() |> StreamData.fixed_map() + end end |> StreamData.map(fn keys -> Ash.Resource.set_metadata( @@ -1123,6 +1141,36 @@ defmodule Ash.Generator do end) end + defp validate_required_attributes(resource, generators, action_type) do + action = Ash.Resource.Info.primary_action(resource, action_type) + allow_nil_input = (action && Map.get(action, :allow_nil_input)) || [] + require_attributes = (action && Map.get(action, :require_attributes)) || [] + + resource + |> Ash.Resource.Info.attributes() + |> Enum.reject(&(!&1.writable? && &1.generated?)) + |> Enum.each(fn attribute -> + default = + if action_type == :create, do: attribute.default, else: attribute.update_default + + required? = + cond do + attribute.name in allow_nil_input -> false + attribute.name in require_attributes -> true + attribute.allow_nil? -> false + !is_nil(default) -> false + true -> true + end + + if required? && !Map.has_key?(generators, attribute.name) do + raise ArgumentError, + "Attribute #{inspect(attribute.name)} on #{inspect(resource)} is required " <> + "and has no default, but no generator was provided. " <> + "Add a generator for this attribute or set generate_rest?: true." + end + end) + end + defp to_generators(generators) do Map.new(generators, fn {key, value} -> case value do diff --git a/test/generator/generator_test.exs b/test/generator/generator_test.exs index 028c21f229..00dad9b001 100644 --- a/test/generator/generator_test.exs +++ b/test/generator/generator_test.exs @@ -234,6 +234,46 @@ defmodule Ash.Test.GeneratorTest do end end + defmodule ActionOverrides do + @moduledoc false + use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + default_accept :* + + create :create do + primary? true + allow_nil_input [:required_attr] + require_attributes [:defaulted_attr] + end + + defaults [:read, :destroy, update: :*] + end + + attributes do + uuid_primary_key :id + + attribute :required_attr, :string do + public?(true) + allow_nil? false + end + + attribute :defaulted_attr, :string do + public?(true) + default "default value" + end + + attribute :regular_required, :string do + public?(true) + allow_nil? false + end + end + end + defmodule Generator do use Ash.Generator @@ -680,6 +720,124 @@ defmodule Ash.Test.GeneratorTest do end end + describe "seed_generator generate_rest?" do + test "default behavior still generates all attributes" do + result = + Ash.Generator.seed_generator({Author, %{meta: %{}}}) + |> Ash.Generator.generate() + + assert %Author{} = result + end + + test "generate_rest?: true generates all attributes" do + result = + Ash.Generator.seed_generator({Author, %{meta: %{}}}, generate_rest?: true) + |> Ash.Generator.generate() + + assert %Author{} = result + end + + test "generate_rest?: false only generates listed attributes" do + result = + Ash.Generator.seed_generator( + {Author, %{meta: %{key: "value"}}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + + assert %Author{} = result + assert result.meta == %{key: "value"} + assert result.metadata == nil + end + + test "generate_rest?: false applies resource defaults" do + result = + Ash.Generator.seed_generator( + {Author, %{meta: %{}}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + + assert %Author{} = result + assert result.name == "Fred" + end + + test "generate_rest?: false raises for missing required attributes" do + assert_raise ArgumentError, + ~r/Attribute :meta .* is required/, + fn -> + Ash.Generator.seed_generator( + {Author, %{}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + end + end + + test "generate_rest?: false works with overrides" do + result = + Ash.Generator.seed_generator( + {Author, %{meta: %{}}}, + generate_rest?: false, + overrides: %{name: "Custom Name"} + ) + |> Ash.Generator.generate() + + assert %Author{} = result + assert result.name == "Custom Name" + end + + test "generate_rest?: false works with uses" do + result = + Ash.Generator.seed_generator( + fn uses -> {Author, %{meta: %{}, name: uses.name}} end, + generate_rest?: false, + uses: %{name: StreamData.constant("Generated Name")} + ) + |> Ash.Generator.generate() + + assert %Author{} = result + assert result.name == "Generated Name" + assert result.metadata == nil + end + + test "generate_rest?: false respects primary action's allow_nil_input" do + result = + Ash.Generator.seed_generator( + {ActionOverrides, %{defaulted_attr: "x", regular_required: "y"}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + + assert %ActionOverrides{} = result + assert result.required_attr == nil + end + + test "generate_rest?: false respects primary action's require_attributes" do + assert_raise ArgumentError, + ~r/Attribute :defaulted_attr .* is required/, + fn -> + Ash.Generator.seed_generator( + {ActionOverrides, %{regular_required: "y"}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + end + end + + test "generate_rest?: false still raises for resource-level required attrs not in overrides" do + assert_raise ArgumentError, + ~r/Attribute :regular_required .* is required/, + fn -> + Ash.Generator.seed_generator( + {ActionOverrides, %{defaulted_attr: "x"}}, + generate_rest?: false + ) + |> Ash.Generator.generate() + end + end + end + describe "changeset_generator" do test "it correctly seeds a record" do assert %Author{} =