Getting "function AshPhoenix.Form.fetch/2 is undefined" when following Form documentation

I’m creating my first form with Ash and cannot get past this error. Here’s my quick setup.

I have a Project resource in a Projects domain. The domain is defined:

defmodule Crochet.Projects do
  use Ash.Domain,
    extensions: [AshPhoenix]

  resources do
    resource Crochet.Projects.Project do
      define :create_project, action: :create, args: [:name, :user_id]
      # other stuff
    end
  end
end

Following along in the AshPhoenix.Form documentation it shows a way to render a form. When I try to follow along with those examples, I get a function AshPhoenix.Form.fetch/2 is undefined error:

function AshPhoenix.Form.fetch/2 is undefined (AshPhoenix.Form does not implement the Access behaviour

You can use the "struct.field" syntax to access struct fields. You can also use Access.key!/1 to access struct fields dynamically inside get_in/put_in/update_in)

Here is my code that attempts to render the form:

defmodule CrochetWeb.ProjectsLive.Form do
  use CrochetWeb, :live_component

  alias Crochet.Projects

  def mount(socket) do
    socket = assign(socket, :form, Projects.form_to_create_project())
    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <div>
      <.form for={@form} phx-change="validate">
        <.input field={@form[:name]} />
      </.form>
    </div>
    """
  end
end

I feel like that is pretty basic and matches really closely to the documentation examples. Specifically, it errors on @form[:name]. What am I doing wrong?

Full stacktrace

UndefinedFunctionError at GET /projects

Exception:

** (UndefinedFunctionError) function AshPhoenix.Form.fetch/2 is undefined (AshPhoenix.Form does not implement the Access behaviour

You can use the "struct.field" syntax to access struct fields. You can also use Access.key!/1 to access struct fields dynamically inside get_in/put_in/update_in)
    (ash_phoenix 2.1.12) AshPhoenix.Form.fetch(#AshPhoenix.Form&lt;resource: Crochet.Projects.Project, action: :create, type: :create, params: %{}, source: #Ash.Changeset&lt;domain: Crochet.Projects, action_type: :create, action: :create, attributes: %{}, relationships: %{}, errors: [%Ash.Error.Changes.Required{field: :name, type: :attribute, resource: Crochet.Projects.Project, splode: nil, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace&lt;&gt;, class: :invalid}], data: #Crochet.Projects.Project&lt;user: #Ash.NotLoaded&lt;:relationship, field: :user&gt;, __meta__: #Ecto.Schema.Metadata&lt;:built, "projects"&gt;, id: nil, name: nil, inserted_at: nil, updated_at: nil, user_id: nil, aggregates: %{}, calculations: %{}, ...&gt;, valid?: false&gt;, name: "form", data: nil, form_keys: [], forms: %{}, domain: Crochet.Projects, method: "post", submit_errors: nil, id: "form", transform_errors: nil, original_data: nil, transform_params: nil, prepare_params: nil, prepare_source: nil, raw_params: %{}, warn_on_unhandled_errors?: true, any_removed?: false, added?: false, changed?: false, touched_forms: MapSet.new([]), valid?: false, errors: nil, submitted_once?: false, just_submitted?: false, ...&gt;, :name)
    (elixir 1.17.0) lib/access.ex:322: Access.get/3
    (crochet 0.1.0) lib/crochet_web/live/projects/form.ex:15: anonymous fn/2 in CrochetWeb.ProjectsLive.Form.render/1
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:414: Phoenix.LiveView.Diff.traverse/7
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:555: anonymous fn/4 in Phoenix.LiveView.Diff.traverse_dynamic/7
    (elixir 1.17.0) lib/enum.ex:2531: Enum."-reduce/3-lists^foldl/2-0-"/3
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:412: Phoenix.LiveView.Diff.traverse/7
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:555: anonymous fn/4 in Phoenix.LiveView.Diff.traverse_dynamic/7
    (elixir 1.17.0) lib/enum.ex:2531: Enum."-reduce/3-lists^foldl/2-0-"/3
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:412: Phoenix.LiveView.Diff.traverse/7
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:782: Phoenix.LiveView.Diff.render_component/8
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:720: Phoenix.LiveView.Diff.zip_components/5
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:703: anonymous fn/4 in Phoenix.LiveView.Diff.render_pending_components/6
    (stdlib 6.2) maps.erl:860: :maps.fold_1/4
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:648: Phoenix.LiveView.Diff.render_pending_components/6
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/diff.ex:143: Phoenix.LiveView.Diff.render/3
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/static.ex:288: Phoenix.LiveView.Static.to_rendered_content_tag/4
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/static.ex:171: Phoenix.LiveView.Static.do_render/4
    (phoenix_live_view 1.0.1) lib/phoenix_live_view/controller.ex:39: Phoenix.LiveView.Controller.live_render/3
    (phoenix 1.7.18) lib/phoenix/router.ex:484: Phoenix.Router.__call__/5

Code:

nofile

No code available.

Called with 2 arguments

  • #AshPhoenix.Form&lt;resource: Crochet.Projects.Project, action: :create, type: :create, params: %{}, source: #Ash.Changeset&lt;domain: Crochet.Projects, action_type: :create, action: :create, attributes: %{}, relationships: %{}, errors: [%Ash.Error.Changes.Required{field: :name, type: :attribute, resource: Crochet.Projects.Project, splode: nil, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace&lt;&gt;, class: :invalid}], data: #Crochet.Projects.Project&lt;user: #Ash.NotLoaded&lt;:relationship, field: :user&gt;, __meta__: #Ecto.Schema.Metadata&lt;:built, "projects"&gt;, id: nil, name: nil, inserted_at: nil, updated_at: nil, user_id: nil, aggregates: %{}, calculations: %{}, ...&gt;, valid?: false&gt;, name: "form", data: nil, form_keys: [], forms: %{}, domain: Crochet.Projects, method: "post", submit_errors: nil, id: "form", transform_errors: nil, original_data: nil, transform_params: nil, prepare_params: nil, prepare_source: nil, raw_params: %{}, warn_on_unhandled_errors?: true, any_removed?: false, added?: false, changed?: false, touched_forms: MapSet.new([]), valid?: false, errors: nil, submitted_once?: false, just_submitted?: false, ...&gt;
  • :name

lib/access.ex

No code available.

lib/crochet_web/live/projects/form.ex

10   
11     def render(assigns) do
12       ~H"""
13       &lt;div&gt;
14         &lt;.form for={@form} phx-change="validate"&gt;
15&gt;          &lt;.input field={@form[:name]} /&gt;
16         &lt;/.form&gt;
17       &lt;/div&gt;
18       """
19     end
20   end

lib/phoenix_live_view/diff.ex

409            changed?
410          ) do
411       {_counter, diff, children, pending, components, template} =
412         traverse_dynamic(
413           socket,
414&gt;          invoke_dynamic(rendered, false),
415           %{},
416           pending,
417           components,
418           template,
419           changed?

lib/phoenix_live_view/diff.ex

550       Enum.reduce(dynamic, {0, %{}, children, pending, components, template}, fn
551         entry, {counter, diff, children, pending, components, template} -&gt;
552           child = Map.get(children, counter)
553   
554           {serialized, child_fingerprint, pending, components, template} =
555&gt;            traverse(socket, entry, child, pending, components, template, changed?)
556   
557           # If serialized is nil, it means no changes.
558           # If it is an empty map, then it means it is a rendered struct
559           # that did not change, so we don't have to emit it either.
560           diff =

lib/enum.ex

No code available.

lib/phoenix_live_view/diff.ex

407            components,
408            template,
409            changed?
410          ) do
411       {_counter, diff, children, pending, components, template} =
412&gt;        traverse_dynamic(
413           socket,
414           invoke_dynamic(rendered, false),
415           %{},
416           pending,
417           components,

lib/phoenix_live_view/diff.ex

550       Enum.reduce(dynamic, {0, %{}, children, pending, components, template}, fn
551         entry, {counter, diff, children, pending, components, template} -&gt;
552           child = Map.get(children, counter)
553   
554           {serialized, child_fingerprint, pending, components, template} =
555&gt;            traverse(socket, entry, child, pending, components, template, changed?)
556   
557           # If serialized is nil, it means no changes.
558           # If it is an empty map, then it means it is a rendered struct
559           # that did not change, so we don't have to emit it either.
560           diff =

lib/enum.ex

No code available.

lib/phoenix_live_view/diff.ex

407            components,
408            template,
409            changed?
410          ) do
411       {_counter, diff, children, pending, components, template} =
412&gt;        traverse_dynamic(
413           socket,
414           invoke_dynamic(rendered, false),
415           %{},
416           pending,
417           components,

lib/phoenix_live_view/diff.ex

777   
778           {changed?, linked_cid, prints} =
779             maybe_reuse_static(rendered, socket, component, cids, components)
780   
781           {diff, component_prints, pending, components, nil} =
782&gt;            traverse(socket, rendered, prints, %{}, components, nil, changed?)
783   
784           children_cids =
785             for {_component, list} &lt;- pending,
786                 entry &lt;- list,
787                 do: elem(entry, 0)

lib/phoenix_live_view/diff.ex

715            {pending, diffs, components}
716          ) do
717       diffs = maybe_put_events(diffs, socket)
718   
719       {new_pending, diffs, components} =
720&gt;        render_component(socket, component, id, cid, new?, cids, diffs, components)
721   
722       pending = Map.merge(pending, new_pending, fn _, v1, v2 -&gt; v2 ++ v1 end)
723       zip_components(sockets, metadata, component, cids, {pending, diffs, components})
724     end
725   

lib/phoenix_live_view/diff.ex

698   
699               {sockets, Map.put(telemetry_metadata, :sockets, sockets)}
700             end)
701   
702           metadata = Enum.reverse(metadata)
703&gt;          triplet = zip_components(sockets, metadata, component, cids, {pending, diffs, components})
704           {triplet, seen_ids}
705         end)
706   
707       render_pending_components(socket, pending, seen_ids, cids, diffs, components)
708     end

maps.erl

No code available.

lib/phoenix_live_view/diff.ex

643   
644     defp render_pending_components(socket, pending, seen_ids, cids, diffs, components) do
645       acc = {{%{}, diffs, components}, seen_ids}
646   
647       {{pending, diffs, components}, seen_ids} =
648&gt;        Enum.reduce(pending, acc, fn {component, entries}, acc -&gt;
649           {{pending, diffs, components}, seen_ids} = acc
650           update_many? = function_exported?(component, :update_many, 1)
651           entries = maybe_preload_components(component, Enum.reverse(entries))
652   
653           {assigns_sockets, metadata, components, seen_ids} =

lib/phoenix_live_view/diff.ex

138       # cid_to_component is used by maybe_reuse_static and it must be a copy before changes.
139       # However, given traverse does not change cid_to_component, we can read it now.
140       {cid_to_component, _, _} = components
141   
142       {cdiffs, components} =
143&gt;        render_pending_components(socket, pending, cid_to_component, %{}, components)
144   
145       socket = %{socket | fingerprints: prints}
146       diff = maybe_put_title(diff, socket)
147       {diff, cdiffs} = extract_events({diff, cdiffs})
148       {socket, maybe_put_cdiffs(diff, cdiffs), components}

lib/phoenix_live_view/static.ex

283       content_tag(tag, attrs, "")
284     end
285   
286     defp to_rendered_content_tag(socket, tag, view, attrs) do
287       rendered = Phoenix.LiveView.Renderer.to_rendered(socket, view)
288&gt;      {_, diff, _} = Diff.render(socket, rendered, Diff.new_components())
289       content_tag(tag, attrs, Diff.to_iodata(diff))
290     end
291   
292     defp content_tag(tag, attrs, content) do
293       tag = to_string(tag)

lib/phoenix_live_view/static.ex

166             {:data, data_attrs}
167             | extended_attrs
168           ]
169   
170           try do
171&gt;            {:ok, to_rendered_content_tag(socket, tag, view, attrs), socket.assigns}
172           catch
173             :throw, {:phoenix, :child_redirect, redirected, flash} -&gt;
174               {:stop, Utils.replace_flash(%{socket | redirected: redirected}, flash)}
175           end
176   

lib/phoenix_live_view/controller.ex

34           end
35         end
36   
37     """
38     def live_render(%Plug.Conn{} = conn, view, opts \\ []) do
39&gt;      case LiveView.Static.render(conn, view, opts) do
40         {:ok, content, socket_assigns} -&gt;
41           conn
42           |&gt; Plug.Conn.fetch_query_params()
43           |&gt; ensure_format()
44           |&gt; Phoenix.Controller.put_view(LiveView.Static)

lib/phoenix/router.ex

479           :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
480           halted_conn
481   
482         %Plug.Conn{} = piped_conn -&gt;
483           try do
484&gt;            plug.call(piped_conn, plug.init(opts))
485           else
486             conn -&gt;
487               measurements = %{duration: System.monotonic_time() - start}
488               metadata = %{metadata | conn: conn}
489               :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)

Connection details

Params

%{}

Request info

Headers

  • accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7
  • accept-encoding: gzip, deflate, br, zstd
  • accept-language: en-US,en;q=0.9
  • cache-control: max-age=0
  • connection: keep-alive
  • cookie: my-cookie
  • dnt: 1
  • host: localhost:4000
  • referer: http://localhost:4000/projects
  • sec-ch-ua: “Not A(Brand”;v=“8”, “Chromium”;v=“132”
  • sec-ch-ua-mobile: ?0
  • sec-ch-ua-platform: “macOS”
  • sec-fetch-dest: document
  • sec-fetch-mode: navigate
  • sec-fetch-site: same-origin
  • upgrade-insecure-requests: 1
  • user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36

Session

%{"_csrf_token" =&gt; "mV3lhtFCZtwVmZF-Bd-N4ERi", "user" =&gt; "user?id=f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5"}

Sorry, this was missed when I added those new docs! You have to call |> to_form() on the result of that function.

That turns the ash form into a phoenix form :sweat_smile:. Can you open an issue or PR to update it?

socket = assign(socket, :form, Projects.form_to_create_project() |> to_form())
1 Like

Yep, that did it. I mistakenly assumed that form_to_* would do what it needed to do to get it into a state that can be used in a form. Is there a reason to keep that separate and keep requiring the to_form()?

Hmmm…it’s a good question. Most AshPhoenix.Form functions work on either the phoenix form or the ash form… :thinking: lemme think on it

So I could see a use case for doing this in a major release, but ultimately the form you get back is an ash form, which has to be turned into a phoenix form to be used in templates etc and the ship has sailed on changing that interface :laughing:

I’ve fixed the docs

1 Like

:sweat_smile: OK, no problem. As long as the docs are correct, I can do the extra to_form() step.

I do have another question about the form_to_* functions though. This might be its own post, but I’m going to hijack my own post for now.

I got past the creation form and am now working on an edit form. I have a new function of :update_name. I would expect Projects.form_to_update_name(project) to give me a form ready to update, including any fields that are already filled and ready to be edited (specifically :name in this case). Unfortunately, it does not prefill the fields.

Here is what that function gives me

iex(10)> Crochet.Projects.form_to_update_name(proj)
#AshPhoenix.Form<
  resource: Crochet.Projects.Project,
  action: :update_name,
  type: :update,
  params: %{},
  source: #Ash.Changeset<
    domain: Crochet.Projects,
    action_type: :update,
    action: :update_name,
    attributes: %{},
    relationships: %{},
    errors: [],
    data: #Crochet.Projects.Project<
      user: #Ash.NotLoaded<:relationship, field: :user>,
      __meta__: #Ecto.Schema.Metadata<:built, "projects">,
      id: nil,
      name: nil,
      inserted_at: nil,
      updated_at: nil,
      user_id: nil,
      aggregates: %{},
      calculations: %{},
      ...
    >,
    valid?: true
  >,
  name: "form",
  data: #Crochet.Projects.Project<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:built, "projects">,
    id: nil,
    name: nil,
    inserted_at: nil,
    updated_at: nil,
    user_id: nil,
    aggregates: %{},
    calculations: %{},
    ...
  >,
  form_keys: [],
  forms: %{},
  domain: Crochet.Projects,
  method: "put",
  submit_errors: nil,
  id: "form",
  transform_errors: nil,
  original_data: #Crochet.Projects.Project<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:built, "projects">,
    id: nil,
    name: nil,
    inserted_at: nil,
    updated_at: nil,
    user_id: nil,
    aggregates: %{},
    calculations: %{},
    ...
  >,
  transform_params: nil,
  prepare_params: nil,
  prepare_source: nil,
  raw_params: %{},
  warn_on_unhandled_errors?: true,
  any_removed?: false,
  added?: false,
  changed?: false,
  touched_forms: MapSet.new([]),
  valid?: true,
  errors: nil,
  submitted_once?: false,
  just_submitted?: false,
  ...
>

However, if I use AshPhoenix.Form.for_update/2, the fields are prefilled.

iex(11)> AshPhoenix.Form.for_update(proj, :update_name)
#AshPhoenix.Form<
  resource: Crochet.Projects.Project,
  action: :update_name,
  type: :update,
  params: %{},
  source: #Ash.Changeset<
    domain: Crochet.Projects,
    action_type: :update,
    action: :update_name,
    attributes: %{},
    relationships: %{},
    errors: [],
    data: #Crochet.Projects.Project<
      user: #Ash.NotLoaded<:relationship, field: :user>,
      __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
      id: "2cee0ea7-1cf7-4bbf-ad9e-bb4d39a56105",
      name: "My first project!",
      inserted_at: ~U[2025-01-07 03:22:27.488668Z],
      updated_at: ~U[2025-01-07 03:22:27.488668Z],
      user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
      aggregates: %{},
      calculations: %{},
      ...
    >,
    valid?: true
  >,
  name: "form",
  data: #Crochet.Projects.Project<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
    id: "2cee0ea7-1cf7-4bbf-ad9e-bb4d39a56105",
    name: "My first project!",
    inserted_at: ~U[2025-01-07 03:22:27.488668Z],
    updated_at: ~U[2025-01-07 03:22:27.488668Z],
    user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
    aggregates: %{},
    calculations: %{},
    ...
  >,
  form_keys: [],
  forms: %{},
  domain: Crochet.Projects,
  method: "put",
  submit_errors: nil,
  id: "form",
  transform_errors: nil,
  original_data: #Crochet.Projects.Project<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
    id: "2cee0ea7-1cf7-4bbf-ad9e-bb4d39a56105",
    name: "My first project!",
    inserted_at: ~U[2025-01-07 03:22:27.488668Z],
    updated_at: ~U[2025-01-07 03:22:27.488668Z],
    user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
    aggregates: %{},
    calculations: %{},
    ...
  >,
  transform_params: nil,
  prepare_params: nil,
  prepare_source: nil,
  raw_params: %{},
  warn_on_unhandled_errors?: true,
  any_removed?: false,
  added?: false,
  changed?: false,
  touched_forms: MapSet.new([]),
  valid?: true,
  errors: nil,
  submitted_once?: false,
  just_submitted?: false,
  ...
>

Am I correct or incorrect in my assumption that the form_to_* functions should prefill those fields for me?

That does look like a bug. Are you on the latest version of ash_phoenix?

1 Like

As a matter of fact, I was not. I was on 2.1.12. Upgrading to 2.1.14 did indeed fix it in that the data already saved on the record was included. Thanks.

I’m going to hijack the thread again (at least they are all regarding forms! :laughing:).

When I submit the form using AshPhoenix.Form.submit, I keep getting back an error (included below). Here is my code handling the submission (mostly using code from the documentation examples):

  def handle_event("submit", %{"form" => params}, socket) do
    params = Map.put(params, "user_id", socket.assigns.current_user.id)

    case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
      {:ok, project} ->
        socket =
          socket
          |> push_navigate(to: ~p"/projects/#{project}")

        {:noreply, socket}

      {:error, form} ->
        socket =
          socket
          |> put_flash(:error, "Something went wrong")
          |> assign(:form, form)

        {:noreply, socket}
    end
  end

When I examine the form in {:error, form}, it gives me this:

%Phoenix.HTML.Form{
  source: #AshPhoenix.Form<
    resource: Crochet.Projects.Project,
    action: :update_name,
    type: :update,
    params: %{
      "name" => "My adsf project!",
      "user_id" => "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5"
    },
    source: #Ash.Changeset<
      domain: Crochet.Projects,
      action_type: :update,
      action: :update_name,
      attributes: %{},
      atomics: [name: "My adsf project!", updated_at: now()],
      relationships: %{},
      errors: [
        %Ash.Error.Query.Required{
          field: :user_id,
          type: :argument,
          resource: Crochet.Projects.Project,
          splode: Ash.Error,
          bread_crumbs: ["Returned from bulk query update: Crochet.Projects.Project.update_name"],
          vars: [],
          path: [],
          stacktrace: #Splode.Stacktrace<>,
          class: :invalid
        },
        %Ash.Error.Query.Required{
          field: :id,
          type: :argument,
          resource: Crochet.Projects.Project,
          splode: Ash.Error,
          bread_crumbs: ["Returned from bulk query update: Crochet.Projects.Project.update_name"],
          vars: [],
          path: [],
          stacktrace: #Splode.Stacktrace<>,
          class: :invalid
        }
      ],
      data: #Crochet.Projects.Project<
        user: #Ash.NotLoaded<:relationship, field: :user>,
        __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
        id: "ae4f1bbf-3ff1-4a19-81be-5da504cf429e",
        name: "My second project!",
        inserted_at: ~U[2025-01-07 03:39:37.002716Z],
        updated_at: ~U[2025-01-07 03:39:37.002716Z],
        user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
        aggregates: %{},
        calculations: %{},
        ...
      >,
      context: %{data_layer: %{use_atomic_update_data?: true}},
      valid?: true
    >,
    name: "form",
    data: #Crochet.Projects.Project<
      user: #Ash.NotLoaded<:relationship, field: :user>,
      __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
      id: "ae4f1bbf-3ff1-4a19-81be-5da504cf429e",
      name: "My second project!",
      inserted_at: ~U[2025-01-07 03:39:37.002716Z],
      updated_at: ~U[2025-01-07 03:39:37.002716Z],
      user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
      aggregates: %{},
      calculations: %{},
      ...
    >,
    form_keys: [],
    forms: %{},
    domain: Crochet.Projects,
    method: "put",
    submit_errors: [user_id: {"is required", []}, id: {"is required", []}],
    id: "form",
    transform_errors: nil,
    original_data: #Crochet.Projects.Project<
      user: #Ash.NotLoaded<:relationship, field: :user>,
      __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
      id: "ae4f1bbf-3ff1-4a19-81be-5da504cf429e",
      name: "My second project!",
      inserted_at: ~U[2025-01-07 03:39:37.002716Z],
      updated_at: ~U[2025-01-07 03:39:37.002716Z],
      user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
      aggregates: %{},
      calculations: %{},
      ...
    >,
    transform_params: nil,
    prepare_params: nil,
    prepare_source: nil,
    raw_params: %{
      "name" => "My adsf project!",
      "user_id" => "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5"
    },
    warn_on_unhandled_errors?: true,
    any_removed?: false,
    added?: false,
    changed?: true,
    touched_forms: MapSet.new(["name", "user_id"]),
    valid?: false,
    errors: true,
    submitted_once?: true,
    just_submitted?: true,
    ...
  >,
  impl: Phoenix.HTML.FormData.AshPhoenix.Form,
  id: "form",
  name: "form",
  data: #Crochet.Projects.Project<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:loaded, "projects">,
    id: "ae4f1bbf-3ff1-4a19-81be-5da504cf429e",
    name: "My second project!",
    inserted_at: ~U[2025-01-07 03:39:37.002716Z],
    updated_at: ~U[2025-01-07 03:39:37.002716Z],
    user_id: "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5",
    aggregates: %{},
    calculations: %{},
    ...
  >,
  action: nil,
  hidden: [
    _touched: "name,user_id",
    _form_type: "update",
    id: "ae4f1bbf-3ff1-4a19-81be-5da504cf429e"
  ],
  params: %{
    "name" => "My adsf project!",
    "user_id" => "f2063d7f-fe65-42e4-88f8-3b3bfd12a7e5"
  },
  errors: [user_id: {"is required", []}, id: {"is required", []}],
  options: [method: "put"],
  index: nil
}

Based on that errors value, it makes it sound like I did not provide a user_id or id. I don’t want to change either of those (or, in fact, allow them to change at all in :update_name) and I assume the current values would have been picked up in the submit action. Am I doing something wrong?

Again, for reference, my domain:

defmodule Crochet.Projects do
  use Ash.Domain, extensions: [AshPhoenix]

  resources do
    resource Crochet.Projects.Project do
      # other stuff
      define :update_name, action: :update_name, args: [:name]
    end
  end
end

and my resource

defmodule Crochet.Projects.Project do
  use Ash.Resource,
    domain: Crochet.Projects,
    data_layer: AshPostgres.DataLayer

  postgres do ... end

  actions do
    # other actions
    update :update_name do
      accept [:name]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
    end

    timestamps()
  end

  relationships do
    belongs_to :user, Crochet.Accounts.User, allow_nil?: false
  end
end

…that is certainly strange. I know that update actions work w/ forms in general, so not sure why you’d be getting that error…can you try the previous released version of ash? It is possibly a regression.

Does this happen when you use the code interface directly? I haven’t seen anything like that :thinking:

Does this happen when you use the code interface directly?

I’m assuming you mean by calling it like this? If so, yes, it does happen (with ash 3.4.55). It also happens in 3.4.48 which is as low as I could go without also backing out other deps. Finally, I upgraded to 3.4.56 and it still happens just a below.

iex(8)> Crochet.Projects.update_name(proj, "first")
{:error,
 %Ash.Error.Invalid{
   bread_crumbs: ["Returned from bulk query update: Crochet.Projects.Project.update_name"],
   changeset: "#Changeset<>",
   errors: [
     %Ash.Error.Query.Required{
       field: :user_id,
       type: :argument,
       resource: Crochet.Projects.Project,
       splode: Ash.Error,
       bread_crumbs: ["Returned from bulk query update: Crochet.Projects.Project.update_name"],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     },
     %Ash.Error.Query.Required{
       field: :id,
       type: :argument,
       resource: Crochet.Projects.Project,
       splode: Ash.Error,
       bread_crumbs: ["Returned from bulk query update: Crochet.Projects.Project.update_name"],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     }
   ]
 }}

Looking at the error actually I don’t think it’s a bug. Those are listed as arguments. My suspicion is that your primary read action has required arguments. This is a common issue and I really need to add a warning against this. Updates may get an “atomic upgrade” which means we do them atomically and that constitutes a combo of a read and an update at the same time. I will add a warning to this effect today or tomorrow. Your primary read action should not have any required arguments, and typically shouldn’t do much at all actually.

Yes, that was it. I had a :get action that was set as primary and it required an id and user_id.

    read :get do
      primary? true
      get? true
      argument :id, :uuid, allow_nil?: false
      argument :user_id, :uuid, allow_nil?: false
      filter expr(id == ^arg(:id) and user_id == ^arg(:user_id))
    end

(Out of curiosity, is this the idiomatic way to get a resource that belongs to another?)

I removed that primary? true call from there and added a defaults [:read] in the actions do ... end block and that seems to have taken care of it. I’m now able to successfully call it in IEx (and the web form).

That is one way to do it, but you don’t necessarily need to define a custom action for it. You can use Ash.get!(Resource, %{user_id: user_id, id: id}) which goes directly to the primary action and adds the filter.

You can also do it with code interfaces with the get_by option.Code Interface — ash v3.4.56

Great, thank your for all the help!

1 Like