Ecto Changeset shows unchanged fields as changes

Hi,

I am having this weird issue where Ecto changeset is showing unchanged fields as changes as well. All the fields that has values in the DB also shows up as changes along with the actual changes.

What could be the reason for this?

Can you provide more information such as a code snippets that illustrates the behavior you describe with values and what you would expect?

I dont’t know if this might help,

    IO.inspect(socket.assigns.review, limit: :infinity)
    IO.inspect("Attrs--------------")
    IO.inspect(attrs, limit: :infinity)
    IO.inspect("Changeset--------------")

Here, assigns.review is the fetch from DB. attrs is the input I get from the form on phx_change. When I call the changeset function and inspect the changeset with changeset.changes, It’s printing out all the fields in the schema, irrespective of whether they were changed or not.

def changeset_review(review = %__MODULE__{}, attrs) do
    review
    |> cast(attrs, [])
    |> cast_embed_list(@optional_fields)
    |> cast_embed_list(@required_fields, required: true)
  end

This is the changeset function

It’d be much more helpful if you also show what this outputs into the logs. And maybe extend the pipeline in your changeset_review/2 function with |> IO.inspect(label: "changeset_review: ") and post its output as well.

1 Like
"Attrs--------------"
%{
 <Removed since its too big>

  "contact_person_review" => %{
    "address_review" => %{
      "apartment_review" => %{
        "answer" => "688",
        "comment" => "need more info",
        "status" => "further_info_requested"
      },
      "city_review" => %{
        "answer" => "Berlin",
        "comment" => "",
        "status" => "pending"
      },
      "street_review" => %{
        "answer" => "Londin",
        "comment" => "okay",
        "status" => "approved"
      }
    },
    "name_review" => %{
      "answer" => "Manuka",
      "comment" => "looks good",
      "status" => "approved"
    }
  },
  "founding_date_review" => %{
    "answer" => "2023-01-01",
    "comment" => "",
    "status" => "pending"
  },
  "hold_all_assets_review" => %{
    "answer" => "",
    "comment" => "",
    "status" => "pending"
  },
  "legal_entity_address_review" => %{
    "apartment_review" => %{
      "answer" => "",
      "comment" => "",
      "status" => "pending"
    },
    "city_review" => %{"answer" => "", "comment" => "", "status" => "pending"},
    "country_review" => %{
      "answer" => "",
      "comment" => "",
      "status" => "pending"
    },
    "postal_code_review" => %{
      "answer" => "",
      "comment" => "",
      "status" => "pending"
    },
    "province_review" => %{
      "answer" => "",
      "comment" => "",
      "status" => "pending"
    },
    "street_review" => %{"answer" => "", "comment" => "", "status" => "pending"}
  },
<Removed since its too big>
}
DB Fetch
%Review{
  <Removed since its too big>
  legal_entity_address_review: %AddressReview{
    country_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    },
    postal_code_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    },
    province_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    },
    city_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    },
    apartment_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    },
    street_review: %AnswerReviewItem{
      status: "pending",
      comment: nil,
      answer: nil
    }
  },
  legal_entity_name_review: %AnswerReviewItem{
    status: "pending",
    comment: nil,
    answer: nil
  },
  contact_person_review: %ContactPersonReview{
    address_review: %AddressReview{
      country_review: %AnswerReviewItem{
        status: "pending",
        comment: nil,
        answer: "AW"
      },
      postal_code_review: %AnswerReviewItem{
        status: "pending",
        comment: nil,
        answer: "10287"
      },
      province_review: %AnswerReviewItem{
        status: "pending",
        comment: nil,
        answer: "Branden"
      },
      city_review: %AnswerReviewItem{
        status: "pending",
        comment: nil,
        answer: "Berlin"
      },
      apartment_review: %AnswerReviewItem{
        status: "pending",
        comment: nil,
        answer: "688"
      },
      street_review: %AnswerReviewItem{
        status: "approved",
        comment: "okay",
        answer: "Londin"
      }
    },
    name_review: %AnswerReviewItem{
      status: "approved",
      comment: "looks good",
      answer: "Manuka"
    }
  },
  id: "e428a224-a397-4e91-8003-b5a07e3535cb",
  __meta__: #Ecto.Schema.Metadata<:loaded, "dd_reviews">
changeset_review: : #Ecto.Changeset<
  action: nil,
  changes: %{
    asset_review: #Ecto.Changeset<
      action: :insert,
      changes: %{
        description_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #AnswerReviewItem<>,
         valid?: true>
      },
      errors: [],
      data: #PropertyReview<>,
      valid?: true
    >,
    business_model_desc_review: #Ecto.Changeset<action: :insert, changes: %{},
     errors: [], data: #AnswerReviewItem<>, valid?: true>,
    contact_person_review: #Ecto.Changeset<
      action: :insert,
      changes: %{
        address_review: #Ecto.Changeset<
          action: :insert,
          changes: %{
            apartment_review: #Ecto.Changeset<
              action: :insert,
              changes: %{
                answer: "688",
                comment: "need more info",
                status: "further_info_requested"
              },
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >,
            city_review: #Ecto.Changeset<
              action: :insert,
              changes: %{answer: "Berlin"},
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >,
            country_review: #Ecto.Changeset<
              action: :insert,
              changes: %{answer: "AW"},
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >,
            postal_code_review: #Ecto.Changeset<
              action: :insert,
              changes: %{answer: "10287"},
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >,
            province_review: #Ecto.Changeset<
              action: :insert,
              changes: %{answer: "Branden"},
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >,
            street_review: #Ecto.Changeset<
              action: :insert,
              changes: %{answer: "Londin", comment: "okay", status: "approved"},
              errors: [],
              data: #AnswerReviewItem<>,
              valid?: true
            >
          },
          errors: [],
          data: #AddressReview<>,
          valid?: true
        >,
        name_review: #Ecto.Changeset<
          action: :insert,
          changes: %{
            answer: "Manuka",
            comment: "looks good",
            status: "approved"
          },
          errors: [],
          data: #AnswerReviewItem<>,
          valid?: true
        >
      },
      errors: [],
      data: #ContactPersonReview<>,
      valid?: true
    >,
    founding_date_review: #Ecto.Changeset<
      action: :insert,
      changes: %{answer: "2023-01-01"},
      errors: [],
      data: #AnswerReviewItem<>,
      valid?: true
    >,
    hold_all_assets_review: #Ecto.Changeset<action: :insert, changes: %{},
     errors: [], data: #AnswerReviewItem<>, valid?: true>,
    legal_entity_address_review: #Ecto.Changeset<
      action: :insert,
      changes: %{
        apartment_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #AnswerReviewItem<>,
         valid?: true>,
        city_review: #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
         data: #AnswerReviewItem<>, valid?: true>,
        country_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #=AnswerReviewItem<>,
         valid?: true>,
        postal_code_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #AnswerReviewItem<>,
         valid?: true>,
        province_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #AnswerReviewItem<>,
         valid?: true>,
        street_review: #Ecto.Changeset<action: :insert, changes: %{},
         errors: [], data: #AnswerReviewItem<>,
         valid?: true>
      },
      errors: [],
      data: #AddressReview<>,
      valid?: true
    >,
    legal_entity_name_review: #Ecto.Changeset<action: :insert, changes: %{},
     errors: [], data: #AnswerReviewItem<>, valid?: true>,
    sale_reason_review: #Ecto.Changeset<action: :insert, changes: %{},
     errors: [], data: #AnswerReviewItem<>, valid?: true>
  },
  errors: [],
  data: #Review<>,
  valid?: true
>

I have removed and renamed certain things but you should be able to get the gist.

Something that I noted is, the changeset action is always insert, never update

You can see that even the values that exists in the DB fetch is still there as changes in the changeset

At first glance, what jumps out at me is the nesting/embedding of changesets assuming cast_embed_list/3 is a custom function that wraps Ecto.Changeset’s cast_embed/3.

The docs for cast_embed/3 states:

See cast_assoc/3 for an example of working with casts and associations which would also apply for embeds.

And the docs for cast_assoc/3 states:

This function should be used when working with the entire association at once (and not a single element of a many-style association) and receiving data external to the application.

cast_assoc/3 works matching the records extracted from the database and compares it with the parameters received from an external source. Therefore, it is expected that the data in the changeset has explicitly preloaded the association being cast and that all of the IDs exist and are unique.
For example, imagine a user has many addresses relationship where post data is sent as follows

%{"name" => "john doe", "addresses" => [
  %{"street" => "somewhere", "country" => "brazil", "id" => 1},
  %{"street" => "elsewhere", "country" => "poland"},
]}

and then

User
|> Repo.get!(id)
|> Repo.preload(:addresses) # Only required when updating data
|> Ecto.Changeset.cast(params, [])
|> Ecto.Changeset.cast_assoc(:addresses, with: &MyApp.Address.changeset/2)

Note the bit about the expectation of preloading the association such that the nested/embedded IDs exist. That may help explain why it’s trying to insert rather than update.

Hi,

Thanks for the reply. But all these data is from a single table, there is no table association here

I have @primary_key false on all the embedded structs, could this be a factor?

@type t() :: %__MODULE__{
          owner_id: String.t(),
          name_review: AnswerReviewItem.t(),
          address_review: AddressReview.t(),
          id_proof_review: [FileReviewItem.t()]
        }

  @primary_key false
  embedded_schema do
    field :owner_id, :string
    embeds_one :name_review, AnswerReviewItem, on_replace: :delete
    embeds_one :address_review, AddressReview, on_replace: :delete
    embeds_many :id_proof_review, FileReviewItem, on_replace: :delete
  end

Yeah, that looks like the culprit.

The docs for both embeds_many and embeds_one note that when updating embedded schemas without primary keys, Ecto wipes the existing embeds before re-inserting new ones when :on_replace is set to :delete.

With embeds_one, it’s possible to specify :update instead of :delete, but with embeds_many, it seems like a primary key is necessary if you want to update embeds instead of deleting and re-inserting them.

Ecto uses the primary keys to detect if an embed is being updated or not. If a primary is not present and you still want the list of embeds to be updated, :on_replace must be set to :delete, forcing all current embeds to be deleted and replaced by new ones whenever a new list of embeds is set.
embeds_many/3 | Ecto.Schema docs

Ecto uses the primary keys to detect if an embed is being updated or not. If a primary key is not present, :on_replace should be set to either :update or :delete if there is a desire to either update or delete the current embed when a new one is set.
embeds_one/3 | Ecto.Schema docs

I introduced the primary key to the embeds.

Now I am getting the (Ecto.StaleEntryError) attempted to update a stale struct: error.

I know there can be two reasons for this,

  1. Attempting to update a row that does not exist in DB
  2. Fetching a schema from the database and updating it after it has been updated somewhere else in the app.

Can you explain the second reason a bit?

Did you clear out the existing embeds from the DB? Either by clearing the map columns or resetting your DB in development? It might be these old entries without a primary ID that is tripping up Ecto. Just a theory!

Regarding the second reason, I’d suggest looking into concurrency, how it can set the stage for database race conditions/deadlocks, and how to resolve them with locking strategies e.g. optimistic vs pessimistic.

A concrete example in a typical Phoenix app would be collaborative editing of a shared resource such that different users attempt to update the same resource at the same time. Note that more generally, users can also be various concurrent background/async processes.

  1. Fetch Post X (version 1) from DB for User A
  2. Fetch Post X (version 1) from DB for User B
  3. User A updates Post X (version 1) => successful, now Post X (version 2)
  4. User B updates Post X (version 1) => unsuccessful, Post X (version 1) is now a stale entry
1 Like