-
-
Save sudoremo/4204e399e547ff7e3afdd0d89a5aaf3e to your computer and use it in GitHub Desktop.
| # This is a workaround for the Rails issue described in | |
| # https://github.com/rails/rails/issues/17368. | |
| # | |
| # When assigning `_ids=` on a has_many :through association, Rails immediately | |
| # persists the changes to the join table — even before `save` is called. This | |
| # module defers that persistence to an `after_save` callback, so the IDs are | |
| # only written when the parent record is saved. | |
| # | |
| # If you use this, make sure you understand its code and what it does and what | |
| # it doesn't. | |
| # | |
| # ## Usage | |
| # | |
| # class User < ApplicationRecord | |
| # include LazyIds | |
| # | |
| # has_many :groups_users | |
| # has_many :groups, through: :groups_users | |
| # | |
| # lazy_ids :groups | |
| # end | |
| # | |
| # u = User.first | |
| # u.group_ids = [1, 2, 3] # Does NOT persist yet | |
| # u.groups # Returns the Group records for [1, 2, 3] | |
| # u.group_ids # Returns [1, 2, 3] | |
| # u.save! # Now the join records are persisted | |
| # | |
| # ## Eager (original) accessors | |
| # | |
| # The original `_ids=` and `_ids` methods are preserved as `eager_*` variants | |
| # in case you need to bypass the lazy behavior: | |
| # | |
| # u.eager_group_ids # Reads directly from the database | |
| # u.eager_group_ids = [1] # Persists immediately (original behavior) | |
| # | |
| # ## Validations | |
| # | |
| # Because `changed_for_autosave?` is overridden to return true when lazy IDs | |
| # are pending, standard association validations (e.g. `validates :groups, | |
| # presence: true`) work as expected — they see the pending records. | |
| # | |
| # ## Calling `lazy_ids` multiple times | |
| # | |
| # You can safely call `lazy_ids` for multiple associations on the same model. | |
| # The `changed_for_autosave?` override is only applied once to avoid infinite | |
| # recursion. | |
| module LazyIds | |
| extend ActiveSupport::Concern | |
| included do | |
| after_save :persist_lazy_ids | |
| end | |
| private | |
| # Persists all pending lazy IDs via the original (eager) setters. | |
| def persist_lazy_ids | |
| return unless @_lazy_ids | |
| # Clear @_lazy_ids before calling the eager setters so that | |
| # changed_for_autosave? returns false during the nested save | |
| # operations. This prevents Rails from re-saving the association | |
| # a second time via autosave. | |
| lazy_ids = @_lazy_ids | |
| @_lazy_ids = {} | |
| lazy_ids.each do |association, ids| | |
| send(:"eager_#{association.to_s.singularize}_ids=", ids) | |
| end | |
| end | |
| module ClassMethods | |
| # Declares a has_many :through association whose `_ids=` setter should be | |
| # deferred until `save` is called. | |
| # | |
| # This overrides three methods on the model: | |
| # - `<singular>_ids=` — stores IDs in memory instead of persisting | |
| # - `<singular>_ids` — returns pending IDs if set, otherwise delegates | |
| # - `<association>` — returns matching records for pending IDs if set | |
| # | |
| # The original methods are preserved as `eager_<singular>_ids=` and | |
| # `eager_<singular>_ids`. | |
| def lazy_ids(association) | |
| singular = association.to_s.singularize | |
| # Preserve original _ids= as eager variant, then override with lazy version | |
| alias_method :"eager_#{singular}_ids=", :"#{singular}_ids=" | |
| define_method "#{singular}_ids=" do |ids| | |
| @_lazy_ids ||= {} | |
| @_lazy_ids[association] = ids.compact_blank | |
| end | |
| # Override association reader to return pending records when lazy IDs are set | |
| define_method association.to_s do | |
| if @_lazy_ids&.key?(association) | |
| self.class.reflect_on_association(association).klass.find(@_lazy_ids[association]) | |
| else | |
| super() | |
| end | |
| end | |
| # Preserve original _ids reader as eager variant, then override with lazy version | |
| alias_method :"eager_#{singular}_ids", :"#{singular}_ids" | |
| define_method "#{singular}_ids" do | |
| if @_lazy_ids&.key?(association) | |
| @_lazy_ids[association] | |
| else | |
| super() | |
| end | |
| end | |
| # Override changed_for_autosave? so Rails triggers autosave (and thus | |
| # validations) when lazy IDs are pending. Only alias once to avoid | |
| # infinite recursion when lazy_ids is called for multiple associations. | |
| unless method_defined?(:eager_changed_for_autosave?) | |
| alias_method :eager_changed_for_autosave?, :changed_for_autosave? | |
| define_method 'changed_for_autosave?' do | |
| @_lazy_ids.present? || eager_changed_for_autosave? | |
| end | |
| end | |
| end | |
| end | |
| end |
Thanks! I needed to change line 37 from
return supertoreturn super()due to error "implicit argument passing of super from method defined by define_method() is not supported. Specify all arguments explicitly.". (Ruby 2.5.1, Rails 5.2.4.1).
Thank you, the gist is updated!
This worked perfectly in Rails 5.2. When using Rails 7.1 there is a problem. When adding items to an association of new/unsaved records, it will apply the association twice. If there is a unique constraint it will throw a UniqueViolation error.
For example (without unique constraint):
u = User.new
u.group_ids = [1, 2, 3]
u.save!
u.groups.count # 6
u.group_ids.count # 3 (don't be fooled by this!)or
u = User.first
d = u.dup
d.group_ids = [1, 2, 3]
d.save!
d.groups.count # 6(With SQL logging enabled you can see the duplicate inserts into groups_users.)
A workaround is saving the record before setting the association:
u = User.new
u.save!
u.group_ids = [1, 2, 3]
u.save!
u.groups.count # 3With already persisted records there is no problem:
u = User.first
u.group_ids = [1, 2, 3]
u.save!
u.groups.count # 3Thanks @jox for your heads up, I will fix this shortly.
Thanks @jox for reporting this! I've updated the gist with a significantly improved version. Here's what changed compared to the previously published version:
New features:
_idsreader override:group_idsnow returns the pending (lazy) IDs when set, instead of always hitting the database. Aneager_group_idsalias is provided to bypass this and read directly from the DB.eager_*aliases for the reader: The originalgroup_idsis preserved aseager_group_ids, in addition to the already existingeager_group_ids=for the writer.changed_for_autosave?override: When lazy IDs are pending,changed_for_autosave?returnstrue. This ensures that Rails triggers autosave on the record, which means standard association validations (e.g.validates :groups, presence: true) work correctly with lazy IDs.compact_blankon incoming IDs: The_ids=setter now strips blank/nil values from the array (e.g. from form submissions that include empty strings).
Bug fix:
- Guard against infinite recursion: The
changed_for_autosave?alias is now wrapped inunless method_defined?(:eager_changed_for_autosave?), so callinglazy_idsfor multiple associations on the same model doesn't create a recursive alias chain that results in aSystemStackError.
Regarding the duplicate insert issue you reported: I was unable to reproduce it with this updated version on Rails 7.2. The overridden _ids= setter stores the IDs only in memory (@_lazy_ids) without touching the actual association proxy, so Rails' autosave mechanism finds nothing to persist — only the after_save callback writes to the join table. If you're still seeing duplicates with this version, I'd be curious to hear more about your setup.
Thank you for the update @sudoremo ! Unfortunately a quick test shows that the problem still persists.
Have you been able to reproduce the issue on Rails 7.2 with the previous code?
Important: Writes never happen when ids are set. Only when .save is called it will perform the write two times. As I mentioned, when calling .save once before setting the ids it will perform only one write afterwards as expected.
A little debugging shows that after .save it will call persist_lazy_ids which calls the original setter and then the duplicate write is happening (clearly showing in the SQL logs).
Rails 7.1, Ruby 3.4.8
Thanks for the follow-up @jox! I've pushed another update to the gist that should help with the duplicate write issue.
The fix: In persist_lazy_ids, @_lazy_ids is now cleared before calling the original setter, not after. Previously, when the eager setter ran inside after_save, @_lazy_ids was still populated. This meant that changed_for_autosave? still returned true during the nested save operations triggered by the original _ids= setter, which could cause Rails to re-save the association a second time via autosave.
# Before
def persist_lazy_ids
return unless @_lazy_ids
@_lazy_ids.each do |association, ids|
send(:"eager_#{association.to_s.singularize}_ids=", ids)
end
@_lazy_ids = {} # Too late — autosave may have already fired again
end
# After
def persist_lazy_ids
return unless @_lazy_ids
lazy_ids = @_lazy_ids
@_lazy_ids = {} # Clear first so changed_for_autosave? returns false
lazy_ids.each do |association, ids|
send(:"eager_#{association.to_s.singularize}_ids=", ids)
end
endA note on reproduction: I wasn't able to reproduce the duplicate on Rails 7.2.2 / MySQL — the autosave callbacks run before after_save and the association proxy is empty at that point. If you're still seeing duplicates after this update, could you share:
- Your exact Rails version (e.g.
7.1.3.4)? - Your database adapter (PostgreSQL, MySQL, SQLite)?
- Whether your model has any additional callbacks,
autosave: trueon the association,accepts_nested_attributes_for, or gems likepaper_trail/auditedthat hook into save callbacks?
That would help me reproduce and fully debug the issue. Thanks!
Thanks for your work and your support @sudoremo!
I did some isolated tests and found that the issue did not show up with a minimal setup! We're using a few gems like audited or paranoid. I applied some to narrow down the cause and found it was a custom Concern we are using on the affected model. The Concern has an after_create hook where it is setting an enum value with a bang .enum_value!.
So for example if you add an enum state (integer) to the User example class:
class User < ApplicationRecord
include LazyIds
enum state: %i[pending registered]
has_many :groups_users
has_many :groups, through: :groups_users
lazy_ids :groups
endand you do this:
u = User.new
u.group_ids = [1, 2, 3]
u.pending!It will not only save the state attribute to the db but do a full save of the whole record including groups resulting in u.persisted? == true.
Now when doing this in an after_create hook it will result in the unexpected duplicate association records in Rails 7.1. Example to fully reproduce:
class User < ApplicationRecord
include LazyIds
enum state: %i[pending registered]
after_create do |user|
user.pending!
end
has_many :groups_users
has_many :groups, through: :groups_users
lazy_ids :groups
endSo the solution in my case is to consider this my bug and remove user.pending! from after_create.
Still with Rails 5.2 it behaves more friendly where there is only one clean and coherent save. Not sure whether bug or feature and in which Rails version. :-)
- Rails 7.1.5.1
- PostgreSQL
Thanks! I needed to change line 37 from
return supertoreturn super()due to error "implicit argument passing of super from method defined by define_method() is not supported. Specify all arguments explicitly.". (Ruby 2.5.1, Rails 5.2.4.1).