Skip to content

Instantly share code, notes, and snippets.

@sudoremo
Last active April 9, 2026 01:42
Show Gist options
  • Select an option

  • Save sudoremo/4204e399e547ff7e3afdd0d89a5aaf3e to your computer and use it in GitHub Desktop.

Select an option

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
@jox
Copy link
Copy Markdown

jox commented May 25, 2020

Thanks! I needed to change line 37 from return super to return 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).

@sudoremo
Copy link
Copy Markdown
Author

Thanks! I needed to change line 37 from return super to return 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!

@jox
Copy link
Copy Markdown

jox commented Mar 31, 2026

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 # 3

With already persisted records there is no problem:

u = User.first
u.group_ids = [1, 2, 3]
u.save!
u.groups.count # 3

@sudoremo
Copy link
Copy Markdown
Author

Thanks @jox for your heads up, I will fix this shortly.

@sudoremo
Copy link
Copy Markdown
Author

sudoremo commented Apr 1, 2026

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:

  • _ids reader override: group_ids now returns the pending (lazy) IDs when set, instead of always hitting the database. An eager_group_ids alias is provided to bypass this and read directly from the DB.
  • eager_* aliases for the reader: The original group_ids is preserved as eager_group_ids, in addition to the already existing eager_group_ids= for the writer.
  • changed_for_autosave? override: When lazy IDs are pending, changed_for_autosave? returns true. 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_blank on 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 in unless method_defined?(:eager_changed_for_autosave?), so calling lazy_ids for multiple associations on the same model doesn't create a recursive alias chain that results in a SystemStackError.

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.

@jox
Copy link
Copy Markdown

jox commented Apr 1, 2026

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

@sudoremo
Copy link
Copy Markdown
Author

sudoremo commented Apr 1, 2026

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
end

A 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:

  1. Your exact Rails version (e.g. 7.1.3.4)?
  2. Your database adapter (PostgreSQL, MySQL, SQLite)?
  3. Whether your model has any additional callbacks, autosave: true on the association, accepts_nested_attributes_for, or gems like paper_trail/audited that hook into save callbacks?

That would help me reproduce and fully debug the issue. Thanks!

@jox
Copy link
Copy Markdown

jox commented Apr 1, 2026

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
end

and 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
end

So 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment