Skip to content

Keep unknown keys when update_only updates a nested model during a cast - #241

Open
imacomber wants to merge 1 commit into
DmitryTsepelev:masterfrom
imacomber:fix/nested-update-only-unknown-attributes
Open

imacomber wants to merge 1 commit into
DmitryTsepelev:masterfrom
imacomber:fix/nested-update-only-unknown-attributes

Conversation

@imacomber

Copy link
Copy Markdown
Contributor

Follow-up to #240.

With update_only: true, *_attributes= updates the existing nested model with assign_attributes, which is strict, so an unknown key in the nested payload made the whole cast raise:

Settings.to_type.cast_value(section: { title: "a" }, section_attributes: { title: "b", qq: 1 })
# => ActiveModel::UnknownAttributeError: unknown attribute 'qq' for Section

Now, when the parent is being built by a StoreModel type, the nested model it updates in place collects unknown keys the same way. Assigning to the nested model directly, or from an ActiveRecord parent, is still strict.

🤖 Generated with Claude Code

With update_only, `*_attributes=` updates the existing nested model with
a strict `assign_attributes`, so one unknown key in the nested payload
made the whole cast raise. The parent now passes its leniency on to the
model it updates in place; direct assignment and ActiveRecord parents
stay strict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@DmitryTsepelev DmitryTsepelev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick follow-up. The cast case from #240 works now, and CI is green.

I'd like one change in behaviour before merging, explained inline. Right now the same *_attributes= call keeps unknown keys in one situation and raises in another. The other inline comments are small.

Please also add a line for this PR under ## master in CHANGELOG.md. Your author link is already at the bottom of the file from #240.

# A parent collecting unknown keys during a cast (see UnknownAttributeAssignment)
# lets the nested model it updates in place collect them too.
def update_nested_model(model, *args, **kwargs)
return model.assign_attributes(*args, **kwargs) unless @collecting_unknown_attributes

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With update_only, the same assignment now behaves differently depending on whether the nested model already exists.

When there is no supplier yet, *_attributes= goes through send("supplier=", ...), which casts through the type. The type keeps unknown keys. When a supplier exists, this method only keeps them if the parent is being built by a type cast at that moment. In every other case the same call raises:

class Settings
  include StoreModel::Model
  attribute :supplier, Supplier.to_type
  accepts_nested_attributes_for :supplier, update_only: true
end

Settings.new(supplier_attributes: { title: "a", extra: 1 })
# keeps "extra" in supplier.unknown_attributes

settings = Settings.new(supplier: { title: "a" })
settings.supplier_attributes = { extra: 1 }
# => ActiveModel::UnknownAttributeError: unknown attribute 'extra' for Supplier

Master behaves the same way here, so this isn't new. But this PR is the place to make the two paths agree.

I'd make the in-place update always keep unknown keys, the same way the replacement path does. That also means this method no longer needs to read the parent's @collecting_unknown_attributes. That variable belongs to UnknownAttributeAssignment, and NestedAttributes is also mixed into ActiveRecord models, which never set it. So for an ActiveRecord parent the existing-model case is always strict today, and this change would fix that too.

One thing to watch: an ActiveRecord parent can't call a protected method on the nested model, so the nested model will need a public entry point for this. Marking it :nodoc: would keep it out of the docs.

expect(configuration.supplier.unknown_attributes).to eq("extra" => 1)
end

it "raises for unknown keys assigned directly to an existing model" do

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec records the split described in the other comment as the intended behaviour. The same assign_attributes(supplier_attributes: { extra: 1 }) succeeds when supplier is nil, and this spec only covers the case where it raises. With the change I suggested, it would become "keeps unknown keys assigned to an existing model", and it would be worth adding an ActiveRecord-parent example next to it.


# A parent collecting unknown keys during a cast (see UnknownAttributeAssignment)
# lets the nested model it updates in place collect them too.
def update_nested_model(model, *args, **kwargs)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method ends up public, because it comes after the class-level private and there's no instance-level private above it. That adds update_nested_model to the public API of every StoreModel and every ActiveRecord model that includes NestedAttributes, and it could clash with a method of the same name there. Could you make it private?


protected

def collecting_unknown_attributes

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small naming point. model.collecting_unknown_attributes { ... } reads like a getter, because it has the same name as the instance variable it changes. Something like collect_unknown_attributes { ... } would show it takes a block, and it would match UnknownAttributeAssignment.collect.

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

Successfully merging this pull request may close these issues.

2 participants