Last active
April 9, 2026 01:42
-
-
Save sudoremo/4204e399e547ff7e3afdd0d89a5aaf3e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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_createhook 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:and you do this:
It will not only save the
stateattribute to the db but do a full save of the whole record including groups resulting inu.persisted? == true.Now when doing this in an
after_createhook it will result in the unexpected duplicate association records in Rails 7.1. Example to fully reproduce:So the solution in my case is to consider this my bug and remove
user.pending!fromafter_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. :-)