Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ecto.Changeset.put_assoc cannot handle abstract table inserts #3471

Closed
ErinGreenhalgh opened this issue Nov 6, 2020 · 2 comments
Closed

Comments

@ErinGreenhalgh
Copy link

Environment

  • Elixir version (elixir -v): 1.10.2
  • Database and version (PostgreSQL 9.4, MongoDB 3.2, etc.): PostgreSQL 12.3
  • Ecto version (mix deps): 3.4
  • Database adapter and version (mix deps): postgrex: 0.15.2, ecto_sql: 3.4.5
  • Operating system: Mac OS 10.14.6 (Mojave)

Current behavior

Summary: Changesets created with put_assoc do not support writes when associations are an abstract table.

Context:

Across my application, I want to track state changes for many types of records, eg orders, line items, etc. I want to add a state event record associated with a parent record each time that parent is created or updated. And I want to be able to use the same function in both the create and udpate scenarios, in order to isolate logic and lessen the burden for future devs who may be adding state events to new models.

Implementation:

The state event model is a good candidate for an abstract table because it is a canonical polymorphic association.

Abstract Schema:

defmodule StateEvent do
  use Ecto.Schema

  schema("abstract table: state_events") do
    field(:state, :string)
    field(:parent_id, :integer)

    belongs_to(:user, User)

    timestamps(type: :utc_datetime_usec)
  end

  def changeset(state_event, attrs) do
    state_event
    |> Ecto.Changeset.cast(attrs, [:state, :user_id])
  end
end

Associated to a line item parent:

defmodule LineItem do
  alias StateEvent
  
  has_many(:state_events, {"line_item_state_events", StateEvent},
      foreign_key: :parent_id
    )
end

With the corresponding concrete table:

defmodule Repo.Migrations.LineItemStateEvents do
  use Ecto.Migration

  def change do
    create table("line_item_state_events") do
      add :state, :string
      add :parent_id, references(:line_items)
      add :user_id, references(:users)

      timestamps()
    end
  end
end

Creating state events on line item create and update:

Because line items are created through nested forms, we manage the creation of the line item and its associations through cast_assoc. So while the Ecto docs advocate for creating child records from the abstract table like this: Repo.insert!(build_assoc(post, :comments)), I prefer not to create state-events in this one-off fashion and instead hook into the changeset for the line item.

I could leverage either put_assoc or cast_assoc to do this. put_assoc is perhaps preferred because we are not taking in state event information as external params. However, put_assoc does not play nicely with the abstract state events table.

put_assoc vs cast_assoc:

Both generate changesets that look identical, but Postgrex throws an error on trying to insert the record using the changeset generated by put_assoc.

(Note: the examples here are for an insert case, but the same happens for an update to an existing line item.)

Using put_assoc cannot handle the abstract schema:

put_assoc_changeset = %LineItem{}
|> Ecto.Changeset.cast(%{state: "Draft"}, [:state])
|> Ecto.Changeset.put_assoc(changeset, :state_events, [%{state: "Draft"}])
#Ecto.Changeset<
  action: nil,
  changes: %{
    state: "Draft",
    state_events: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{state: "Draft"},
        errors: [],
        data: #StateEvent<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: #LineItem<>,
  valid?: true
>
Repo.insert!(put_assoc_changeset)
** (Postgrex.Error) ERROR 42P01 (undefined_table) relation "abstract table: state_events" does not exist

    query: INSERT INTO "abstract table: state_events" ("parent_id","state","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id"

but cast_assoc can handle the abstract table:

cast_assoc_changeset = %LineItem{} 
|> Ecto.Changeset.cast(%{state: "Draft", state_events: [%{state: "Draft"}]}, [:state]) 
|> Ecto.Changeset.cast_assoc(:state_events)
#Ecto.Changeset<
  action: nil,
  changes: %{
    state: "Draft",
    state_events: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{state: "Draft"},
        errors: [],
        data: #FastRadius.StateEvents.StateEvent<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: #FastRadius.Costing.CostingRequestLineItem<>,
  valid?: true
>

Calling Repo.insert!() with the cast_assoc_changeset successfully creates the line item and the associated state event.

Expected behavior

Changesets created by put_assoc should be able to support writes when the association is an abstract table, in the same way that cast_assoc can.

@josevalim
Copy link
Member

Hi @ErinGreenhalgh, thanks for the well detailed write up!

To be 100% sure, the issue is that, even though you declare this:

has_many(:state_events, {"line_item_state_events", StateEvent},
foreign_key: :parent_id
)

cast_assoc and put_assoc are ignoring the "line_item_state_events" and using the abstract table, correct?

@ErinGreenhalgh
Copy link
Author

ErinGreenhalgh commented Nov 6, 2020

yes, that's the issue, but only for put_assoc. cast_assoc correctly inserts into the "line_item_state_events" table

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants