Ash stopped working with ExMachina. Ash 2.5.10

Myrmyr
2023-02-09

Myrmyr:

Hello, I’ve been using Ash with ExMachina successfully up to this point. Recently I’ve wanted to upgrade Ash from 2.5.9 to 2.6.0, alongside AshPostgres from 1.3.3 to 1.3.8. I’ve tested combinations of different Ash and AshPostgres combinations to narrow down the issue. It appears to be caused by Ash 2.5.10. Specifically this commit https://github.com/ash-project/ash/commit/2787b5074b8b339057af6f0bc4ee3db5abf7c60d

Our factory:

defmodule MyApp.Factory do
  @moduledoc false
  use ExMachina.Ecto, repo: MyApp.Repo

 def resource_factory do
    %MyApp.Resource{
      id: Ecto.UUID.generate(),
      name: sequence(:name, &"Resource #{&1}"),
      ...
    }
  end

When I try to do insert(:resource, some_field: "some_value) I get the following error

** (UndefinedFunctionError) function Ash.NotLoaded.__schema__/0 is undefined or private
    (ash 2.6.0) Ash.NotLoaded.__schema__()
    iex:1: (file)

Is there any possibility to bring back the support for ExMachina?

ZachDaniel:

🤔 I don’t think that we can undo that change realistically.

ZachDaniel:

but hopefully we can find some other way to get compatibility

ZachDaniel:

Is there a stacktrace for that error?

Myrmyr:

Yeah, sorry.

** (UndefinedFunctionError) function Ash.NotLoaded.__schema__/1 is undefined or private
     code: insert(:resource,
     stacktrace:
       (ash 2.6.0) Ash.NotLoaded.__schema__(:fields)
       (ex_machina 2.7.0) lib/ex_machina/ecto_strategy.ex:141: ExMachina.EctoStrategy.schema_fields/1
       (ex_machina 2.7.0) lib/ex_machina/ecto_strategy.ex:56: ExMachina.EctoStrategy.cast_all_fields/1
       (ex_machina 2.7.0) lib/ex_machina/ecto_strategy.ex:49: ExMachina.EctoStrategy.cast/1
       (ex_machina 2.7.0) lib/ex_machina/ecto_strategy.ex:109: anonymous fn/2 in ExMachina.EctoStrategy.cast_all_assocs/1
       (elixir 1.14.1) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
       (ex_machina 2.7.0) lib/ex_machina/ecto_strategy.ex:25: ExMachina.EctoStrategy.handle_insert/2
       test/my_app/resources/resource_test.exs:xxx: (test)

ZachDaniel:

I think we may need to clone the EctoStrategy and make an AshStrategy , or make a PR to the ecto strategy to give %Ash.NotLoaded{} the same treatment that Ecto.NotLoaded gets

Myrmyr:

Yeah, that was my first step, but that kinda seems like a lot of unnecessary work to just handle that case.

My second thought was to allow developer to opt-out of adding Ecto Relationship by wrapping the added case statement in an unless . Something like

            unless Ash.Resource.Info.disable_ecto_relationship?(__MODULE__) do
              case relationship do
                %{no_attributes?: true} ->
                  :ok
  
                %{manual?: true} ->
                  :ok
  
                %{manual: manual} when not is_nil(manual) ->
                  :ok
  
                %{type: :belongs_to} ->
                  belongs_to relationship.name, relationship.destination,
                    define_field: false,
                    references: relationship.destination_attribute,
                    foreign_key: relationship.source_attribute
  
                %{type: :has_many} ->
                  has_many relationship.name, relationship.destination,
                    foreign_key: relationship.destination_attribute,
                    references: relationship.source_attribute
  
                %{type: :has_one} ->
                  has_one relationship.name, relationship.destination,
                    foreign_key: relationship.destination_attribute,
                    references: relationship.source_attribute
  
                %{type: :many_to_many} ->
                  many_to_many relationship.name, relationship.destination,
                    join_through: relationship.through,
                    join_keys: [
                      {relationship.source_attribute_on_join_resource,
                       relationship.source_attribute},
                      {relationship.destination_attribute_on_join_resource,
                       relationship.destination_attribute}
                    ]
              end
            end

And then either in the resource itself or by config file pass disable_ecto_relationship: true

Myrmyr:

But I lack the knowledge of Ash internals to be sure if it’s a viable solution

ZachDaniel:

🤔 you might also be able to do this

ZachDaniel:

its a bit of a hack, but might work

ZachDaniel:

AshPostgres.DataLayer.to_ecto(%YourStuff{})

ZachDaniel:

in the factory, I mean

ZachDaniel:

Ash also has some seeding tools FWIW

Ash HQ Bot:

Found 1 Code results in all libraries:

Myrmyr:

The AshPostgres.DataLayer.to_ecto(%YourStuff{}) works. 🎉 Thanks!

I’ve written simple ExMachina strategy to do that on every insert. But I do not know to which repo(if at all) should it go.

defmodule Ash.ExMachina do
  @moduledoc false

  defmacro __using__(opts) do
    quote do
      use ExMachina.Ecto

      # We want all the usefull functions that `ExMachina.Ecto` provides, but have to override inserts
      defoverridable insert: 1
      defoverridable insert: 2
      defoverridable insert: 3
      defoverridable insert_pair: 1
      defoverridable insert_pair: 2
      defoverridable insert_pair: 3
      defoverridable insert_list: 2
      defoverridable insert_list: 3
      defoverridable insert_list: 4

      use Ash.ExMachina.InsertStrategy,
        repo: unquote(Keyword.get(opts, :repo))
    end
  end
end

defmodule Ash.ExMachina.InsertStrategy do
  @moduledoc false

  use ExMachina.Strategy, function_name: :insert

  def handle_insert(record, opts) do
    record
    |> AshPostgres.DataLayer.to_ecto()
    |> ExMachina.EctoStrategy.handle_insert(opts)
    |> AshPostgres.DataLayer.from_ecto()
  end
end

EDIT: Added from_ecto() at the end of handle_insert as Zach suggested

ZachDaniel:

You might want to add a from_ecto/1 call at the end also so it will definitely play nicely with the rest of Ash stuff.

ZachDaniel:

I’m also not sure where that should go 😆 Where does the Ecto related stuff go fro ex_machina ? Do they just have it all in one repo? For now, I might just keep it in your app, this forum thread will be searchable for others with similar issues 🙂

Myrmyr:

ExMachina has built-in Ecto support so it’s in the main library, but I don’t think we will manage to add Ash support there 😆 Yeah I think it’s kinda too specific to add it to Ash. But if there’s a need for that later I think it could go into ash_postgres

ZachDaniel:

Yeah, thats a good point. We can conditionally compile it if ExMachina is compiled too

alex88:

Hi Myrmyr, I have the same issue, where do you put this file? Do you just use Ash.ExMachina instead of use ExMachina.Ecto ?

Myrmyr:

I actually put these modules in a separate files in support folder of test directory. Then, just as you’ve described, in factory I do use Ash.ExMachina, repo: MyApp.Repo instead of ExMachina.Ecto, repo: MyApp.Repo

Terryble:

Not sure if anything changed recently, but I tried this solution and it doesn’t work for some reason. None of the records I try to create are persisted in the database.

ZachDaniel:

they should only live for the life of each test, is that what you’re seeing?

brittonjb:

You have to do a bit more with the ExMachina.Ecto and ExMachina.EctoStrategy based on when I took a swing at this a little over a month ago.

I’ll try to do a writeup in the next week, but if you’re not already leveraging ExMachina throughout your codebase you may better off giving Ash.Seed a shot.

ZachDaniel:

👆

Terryble:

I didn’t know Ash.Seed exists. I’ll check this out. Thanks!

Terryble:

It doesn’t exist even inside the test. As in the struct says it’s loaded, but there is no id , inserted_at , and updated_at which implies the record didn’t get created.

ZachDaniel:

🤔 Yeah, hard to say whats up there. For my piece, I’d like to just improve Ash.Seed until there is no compelling reason to use ex_machina.

Terryble:

I tried Ash.Seed and it looks like it’s enough for me. I wish I’d known about that module a lot earlier lol