Problem with nested attributes
Opened this issue ยท 13 comments
Given:
class Widget < ActiveRecord::Base
accept_nested_attributes_for :cars, allow_destroy: true
has_drafts ignore: [:slug]
end
class WidgetsController < InheritedResources::Base
def update
@widget.attributes = widget_params # Update nested attributes already here
if @widget.draft_update
# ok
else
update! # Inherited resources update without draft, raising exception if destroyed or create duplicates because they were created in above code
end
end
end
the problem - nested object are created or destroyed twice(raising not found on second destroy).
Does anyone faced with similar problem ?
@alexey I really hate to say this, but I would avoid nested attributes on models drafted with Draftsman.
If you're interested, I could share a form model that I created to handle this sort of thing. (I think I created a form model back in the day that mocked the nested attributes functionality while tricking the form in the view template into thinking accepts_nested_attributes_for
was in use.)
Hi @chrisdpeters, thanks for response, i thought about this solution but my currect form has many nested forms in it, it may be problem.
I have idea how to make it on controller level, will try and post result on that
@alexey I would like to see support for nested attributes added, but I must admit that I do not have the need for it personally. The app that I'm working on uses Draftsman, but it is an API.
If you would like to work out the solution and submit a pull request, I'd be glad to integrate it as a new feature!
Hey @chrisdpeters, thanks for this gem. It's perfect for my needs so far.
My case also uses nested attributes so I'd like your suggestion. I'm still trying to understand how Draftman works with 2-level associations, i.e.
Store has_many categories
Category has_many products
I did notice that "The largest risk at this time is functionality that assists with publishing or reverting dependencies through associations" so I realize that my usecase may not be fully supported.
I really don't see it being viable to add nested attribute support to Draftsman.
These would be the 2 possible solutions that I can think of. (Maybe someone can propose something better):
- Override/monkey-patch ActiveRecord's nested attributes implementation to call
save_draft
instead ofsave
under the hood. - Implement a separate Draftsman-specific implementation of
accepts_nested_attributes_for
.
Both solutions are super complex.
The first solution would cause all sorts of problems. For example, which version of ActiveRecord's nested attributes implementation should we start with when doing the monkey-patching? What if we implement AR 5, but someone wants to use Draftsman with AR 4? Did anything significant change from 4 to 5?
I did promise @alexey to share an example form model where I mock accepts_nested_attributes_for
-style functionality using a class that extends ActiveModel
. I'll try my best to draft up a blog post sometime this coming week. (Sorry for letting this slip through the cracks!)
Seriously, there could be a better solution out there than these 2 scenarios that I've proposed, so I am all ears if anyone has any better ideas.
I think I can make the ux work without nested attributes as long as child and grandchildren can also be saved with the draft and are publishable
I think I have a solution to the nested attributes problem:
I have a module:
# frozen_string_literal: true
# To use this, define on any model that already has `has_drafts`
# ```
# class Blog < ApplicationRecord
# include DeeplyPublishable
#
# has_drafts
# associations_to_publish :posts, :pages
# ...
# end
# ```
#
# With the above definition, there are two publicly available
# instance methods provided:
# - deep_publish!
# - deep_discard!
#
# Both of these will traverse the associations as defined by
# associations_to_publish, either publishing or discarding drafts
# for any draft object encountered.
#
# NOTE: The draft becomes the real version when published.
# Until publish, the data for the actual model in the model's table
# is the same as it was before the draft was created.
module DeeplyPublishable
extend ActiveSupport::Concern
included do
# Array of Symbols, representing association names
cattr_accessor :publishable_associations
class << self
# can be called multiple times.
# each call appends to publishable_associations
def associations_to_publish(*association_list)
self.publishable_associations ||= []
self.publishable_associations.concat(Array[*association_list])
self.publishable_associations.uniq!
end
end
end
def deep_publish!
ActiveRecord::Base.transaction { _dangerous_deep_publish }
end
def deep_discard!
ActiveRecord::Base.transaction { _dangerous_deep_discard }
end
def deep_save_draft!
ActiveRecord::Base.transaction { _dangerous_deep_save }
end
def deep_trash!
ActiveRecord::Base.transaction { _dangerous_deep_trash }
end
# Use instead of destroy
def _dangerous_deep_trash
draft_destruction
_invoke_on_publishable_associations(:_dangerous_deep_trash)
end
# Use instead of save/update
def _dangerous_deep_save
save_draft
_invoke_on_publishable_associations(:_dangerous_deep_save)
end
def _dangerous_deep_publish
draft&.publish!
_invoke_on_publishable_associations(:_dangerous_deep_publish)
end
def _dangerous_deep_discard
draft&.revert!
_invoke_on_publishable_associations(:_dangerous_deep_discard)
end
def _invoke_on_publishable_associations(method)
return unless publishable_associations.present?
publishable_associations.map do |association|
# superclasses may not respond_to, but subclasses might
next unless respond_to?(association)
relation = send(association)
_invoke_on_relation(relation, method)
end.flatten
end
# A relation may be the result of a has_many
# or a belongs_to relationship.
#
# @param [Symbol] method
def _invoke_on_relation(relation, method)
# has_many / collection of records
return relation.each(&method) if relation.respond_to?(:each)
# belongs_to / singular record
relation.send(method)
end
end
Tests:
require 'rails_helper'
describe 'Versioning' do
describe 'Drafting' do
describe 'Nested Attributes' do
context 'Updating' do
it 'saves a nested model as a draft' do
l = create(:question)
s = create(:question_option, question: l)
l.attributes = {
name: 'updated parent',
question_options_attributes: [
{
id: s.id,
text: 'child updated'
}
]
}
expect { l.deep_save_draft! }
.to change(Versioning::DraftMetadata, :count).by(2)
end
it 'marks the nested model as trashed' do
l = create(:question)
s = create(:question_option, question: l)
l.attributes = {
name: 'updated parent',
question_options_attributes: [
{
id: s.id,
text: 'child updated'
}
]
}
expect { l.deep_trash! }
.to change(Versioning::DraftMetadata, :count).by(2)
.and change(Question.trashed, :count).by(1)
.and change(QuestionOption.trashed, :count).by(1)
end
end
end
end
end
Setup:
class Question < ApplicationRecord
include DeeplyPublishable
has_drafts
associations_to_publish :question_options
has_many :question_options
accepts_nested_attributes_for :question_options, allow_destroy: true
end
class QuestionOption < ApplicationRecord
include DeeplyPublishable
has_drafts
belongs_to :question
# ... more stuff
There is all or nothing at the moment.
I currently can't delete a nested object while updating the parent.
actually, _destroy
is stored on the models, so I just need to change _invoke_on_relation
, and then I'll have all the use cases for nested_attributes
Ok, I have deleting children while updating the parent working.
Here is the final module:
# frozen_string_literal: true
# To use this, define on any model that already has `has_drafts`
# ```
# class Blog < ApplicationRecord
# include DeeplyPublishable
#
# has_drafts
# associations_to_publish :posts, :pages
# ...
# end
# ```
#
# With the above definition, there are two publicly available
# instance methods provided:
# - deep_publish!
# - deep_discard!
# - deep_save_draft!
# - deep_trash!
#
# Both of these will traverse the associations as defined by
# associations_to_publish, either publishing or discarding drafts
# for any draft object encountered.
#
# NOTE: The draft becomes the real version when published.
# Until publish, the data for the actual model in the model's table
# is the same as it was before the draft was created.
module DeeplyPublishable
extend ActiveSupport::Concern
included do
# Array of Symbols, representing association names
cattr_accessor :publishable_associations
class << self
# can be called multiple times.
# each call appends to publishable_associations
def associations_to_publish(*association_list)
self.publishable_associations ||= []
self.publishable_associations.concat(Array[*association_list])
self.publishable_associations.uniq!
end
end
end
def deep_publish!
ActiveRecord::Base.transaction { _dangerous_deep_publish }
end
def deep_discard!
ActiveRecord::Base.transaction { _dangerous_deep_discard }
end
def deep_save_draft!
ActiveRecord::Base.transaction { _dangerous_deep_save }
end
def deep_trash!
ActiveRecord::Base.transaction { _dangerous_deep_trash }
end
# Use instead of destroy
def _dangerous_deep_trash
draft_destruction
_invoke_on_publishable_associations(:_dangerous_deep_trash)
end
# Use instead of save/update
def _dangerous_deep_save
# _destroy will be true when using accepts_nested_attributes_for
# and a nested model has been selected for deletion while
# updating a parent model
_destroy ? draft_destruction : save_draft
_invoke_on_publishable_associations(:_dangerous_deep_save)
end
def _dangerous_deep_publish
draft&.publish!
_invoke_on_publishable_associations(:_dangerous_deep_publish)
end
def _dangerous_deep_discard
draft&.revert!
_invoke_on_publishable_associations(:_dangerous_deep_discard)
end
def _invoke_on_publishable_associations(method)
return unless publishable_associations.present?
publishable_associations.map do |association|
# superclasses may not respond_to, but subclasses might
next unless respond_to?(association)
relation = send(association)
_invoke_on_relation(relation, method)
end.flatten
end
# A relation may be the result of a has_many
# or a belongs_to relationship.
#
# @param [Symbol] method
def _invoke_on_relation(relation, method)
# has_many / collection of records
return relation.each(&method) if relation.respond_to?(:each)
# belongs_to / singular record
relation.send(method)
end
end
@NullVoxPopuli Looks great. My favorite part of running an open source project like this is being schooled by contributors. :)
I'll sit on this one little bit and think about its implications for the design of the overall API. Maybe there's a way to mix this logic into the base #save_draft
, #draft_destruction
and #publish!
if, for example, .has_drafts
can have an option similar to your proposed .associations_to_publish
initializer?
This is a great workaround in the meantime. Also a reminder that I still need to convert this project into a more modular system based on the newer ActiveSupport::Concern
style of doing things.
Thanks! :-)
well, a huge downside is this has to touch every node / leaf in your tree. :-(
No way to just magically know what all the nodes are and if they have drafts ahead of time.
I strongly recommend people use https://github.com/salsify/goldiloader for implicit eager loading.
@NullVoxPopuli I was unable to get this module to work as I expected. As far as I can tell, calling save_draft
on the parent resource (called indirectly by deep_save_draft!
) will actually commit the changes specified in the nested_attributes to the child resource in addition to creating a draft.
So, with your Question/QuestionOption example in the test file, I'm seeing that a draft exists for QuestionOption as expected, but also that the name attribute for QuestionOption (in the question_options table) is set to "child updated" even without calling deep_publish!
Are you seeing this same behavior?
UPDATE: I see the expected behavior if I update an attribute of the parent model in addition to an attribute of the child model (via nested attributes)
In my case, this works:
@size.attributes = {dimensions_attributes: [{id: 596, value: "54"}], name: "XS (Updated)}
@size.deeply_save_draft!
But this does not (dimension has a draft, but also has a value of 54):
@size.attributes = {dimensions_attributes: [{id: 596, value: "54"}]}
@size.deeply_save_draft!
Here is my module to make it work with nested attributes:
module Draftable
extend ActiveSupport::Concern
included do
attr_accessor :_save_draft
attr_accessor :_draft_destruction
before_save if: :_save_draft do
self._save_draft = false # the callback will be called recursively without this line
save_draft
end
before_save if: :_draft_destruction do
self._draft_destruction = false
draft_destruction
end
end
end
The idea here is that draft actions can be invoked implicitly using model attributes.
Here is the usage:
class Course < ActiveRecord::Base
include Draftable
has_drafts
has_many :units
accepts_nested_attributes_for :units
end
class Unit < ActiveRecord::Base
include Draftable
has_drafts
belongs_to :course
end
course = Course.create(title: 'Test Course', _save_draft: true, units_attributes: [{ title: 'Test Unit', _save_draft: true }])
course.draft? # true
course.units.first.draft? # true