Skip to content

Instantly share code, notes, and snippets.

@malomalo
Created May 29, 2024 15:24
Show Gist options
  • Save malomalo/269bda0439b1402e432acdb1fcabd910 to your computer and use it in GitHub Desktop.
Save malomalo/269bda0439b1402e432acdb1fcabd910 to your computer and use it in GitHub Desktop.
# ActiveRecord Autosave will save and validate all autosave records in
# every permutation possible. This patch allows Rails to save / valid
# in once significantly speeding up saving and validating of recrods.
# TODO: Remove when https://github.com/rails/rails/pull/46438 is accepted
require 'active_record/base'
require 'active_record/validations'
require 'active_record/autosave_association'
module ActiveRecord
module Validations
def save(**options)
if perform_validations(options)
begin
memory_was = @memory
@memory = options[:memory] || {}
@memory["saved#{self.object_id}"] = true
@memory[self.object_id] = false
super
ensure
@memory = memory_was
end
else
false
end
end
def save!(**options)
if perform_validations(memory: (options[:memory] || @memory), **options)
begin
memory_was = @memory
@memory = options[:memory] || {}
@memory["saved#{self.object_id}"] = true
@memory[self.object_id] = false
super
ensure
@memory = memory_was
end
else
raise_validation_error
end
end
def valid?(context = nil, memory = nil)
context ||= default_validation_context
memory_was = @memory
@memory = memory || {}
@memory["valid#{self.object_id}"] = true
@memory[self.object_id] = false
output = super(context)
errors.empty? && output
ensure
@memory = memory_was
end
end
module AutosaveAssociation
# Returns whether or not this record has been changed in any way (including whether
# any of its nested autosave associations are likewise changed)
def changed_for_autosave?(memory)
if new_record? || has_changes_to_save? || marked_for_destruction?
memory[self.object_id] = true
elsif memory.has_key?(self.object_id)
memory[self.object_id]
else
memory[self.object_id] = nested_records_changed_for_autosave?(memory)
end
end
private
# Returns the record for an association collection that should be validated
# or saved. If +autosave+ is +false+ only new records will be returned,
# unless the parent is/was a new record itself.
def associated_records_to_validate_or_save(association, new_record, autosave, memory)
if new_record || custom_validation_context?
association && association.target
elsif autosave
association.target.find_all { |r| r.changed_for_autosave?(memory) }
else
association.target.find_all(&:new_record?)
end
end
# Go through nested autosave associations that are loaded in memory (without loading
# any new ones), and return true if any are changed for autosave.
# Returns false if already called to prevent an infinite loop.
def nested_records_changed_for_autosave?(memory)
@_nested_records_changed_for_autosave_already_called ||= false
return false if @_nested_records_changed_for_autosave_already_called
begin
@_nested_records_changed_for_autosave_already_called = true
self.class._reflections.values.any? do |reflection|
if reflection.options[:autosave]
association = association_instance_get(reflection.name)
association && Array.wrap(association.target).any? {|r| r.changed_for_autosave?(memory) }
end
end
ensure
@_nested_records_changed_for_autosave_already_called = false
end
end
# Validate the association if <tt>:validate</tt> or <tt>:autosave</tt> is
# turned on for the association.
def validate_single_association(reflection)
association = association_instance_get(reflection.name)
record = association && association.reader
association_valid?(reflection, record, @memory) if record && (record.changed_for_autosave?(@memory) || custom_validation_context?)
end
# Validate the associated records if <tt>:validate</tt> or
# <tt>:autosave</tt> is turned on for the association specified by
# +reflection+.
def validate_collection_association(reflection)
if association = association_instance_get(reflection.name)
if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave], @memory)
records.each_with_index { |record, index| association_valid?(reflection, record, index, @memory) }
end
end
end
# Returns whether or not the association is valid and applies any errors to
# the parent, <tt>self</tt>, if it wasn't. Skips any <tt>:autosave</tt>
# enabled records if they're marked_for_destruction? or destroyed.
def association_valid?(reflection, record, index = nil, memory)
return true if record.destroyed? || (reflection.options[:autosave] && record.marked_for_destruction?)
context = validation_context if custom_validation_context?
if !memory.has_key?("valid#{record.object_id}")
memory["valid#{record.object_id}"] = true
valid = record.valid?(context, memory)
unless valid
if reflection.options[:autosave]
indexed_attribute = !index.nil? && (reflection.options[:index_errors] || ActiveRecord.index_nested_attribute_errors)
record.errors.group_by_attribute.each { |attribute, errors|
attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
errors.each { |error|
self.errors.import(
error,
attribute: attribute
)
}
}
else
errors.add(reflection.name)
end
end
memory["valid#{record.object_id}"] = valid
valid
end
end
# Saves any new associated records, or all loaded autosave associations if
# <tt>:autosave</tt> is enabled on the association.
#
# In addition, it destroys all children that were marked for destruction
# with #mark_for_destruction.
#
# This all happens inside a transaction, _if_ the Transactions module is included into
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default.
def save_collection_association(reflection)
if association = association_instance_get(reflection.name)
autosave = reflection.options[:autosave]
# By saving the instance variable in a local variable,
# we make the whole callback re-entrant.
new_record_before_save = @new_record_before_save
# reconstruct the scope now that we know the owner's id
association.reset_scope
if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave, @memory)
if autosave
records_to_destroy = records.select(&:marked_for_destruction?)
records_to_destroy.each { |record| association.destroy(record) }
records -= records_to_destroy
end
records.each do |record|
if !@memory.has_key?("saved#{record.object_id}")
@memory["saved#{record.object_id}"] = true
end
end
records.each do |record|
next if record.destroyed?
saved = true
if autosave != false && (new_record_before_save || record.new_record?)
association.set_inverse_instance(record)
if autosave
saved = association.insert_record(record, false, false, @memory)
elsif !reflection.nested?
association_saved = association.insert_record(record, true, false, @memory)
if reflection.validate?
errors.add(reflection.name) unless association_saved
saved = association_saved
end
end
elsif autosave
saved = record.save(validate: false, memory: @memory)
end
raise(RecordInvalid.new(association.owner)) unless saved
end
end
end
end
# Saves the associated record if it's new or <tt>:autosave</tt> is enabled
# on the association.
#
# In addition, it will destroy the association if it was marked for
# destruction with #mark_for_destruction.
#
# This all happens inside a transaction, _if_ the Transactions module is included into
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default.
def save_has_one_association(reflection)
association = association_instance_get(reflection.name)
record = association && association.load_target
if record && !record.destroyed?
autosave = reflection.options[:autosave]
if autosave && record.marked_for_destruction?
record.destroy
elsif autosave != false
primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
primary_key_value = primary_key.map { |key| _read_attribute(key) }
if (autosave && record.changed_for_autosave?(@memory)) || _record_changed?(reflection, record, primary_key_value)
unless reflection.through_reflection
foreign_key = Array(reflection.foreign_key)
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
record[foreign_key] = _read_attribute(primary_key)
end
association.set_inverse_instance(record)
end
saved = if @memory.has_key?("saved#{record.object_id}")
@memory["saved#{record.object_id}"]
else
record.save(validate: !autosave, memory: @memory)
end
raise ActiveRecord::Rollback if !saved && autosave
saved
end
end
end
end
# Saves the associated record if it's new or <tt>:autosave</tt> is enabled.
#
# In addition, it will destroy the association if it was marked for destruction.
def save_belongs_to_association(reflection)
association = association_instance_get(reflection.name)
return unless association && association.loaded? && !association.stale_target?
record = association.load_target
if record && !record.destroyed?
autosave = reflection.options[:autosave]
if autosave && record.marked_for_destruction?
foreign_key = Array(reflection.foreign_key)
foreign_key.each { |key| self[key] = nil }
record.destroy
elsif autosave != false
saved = if @memory.has_key?("saved#{record.object_id}")
@memory["saved#{record.object_id}"]
elsif record.new_record? || (autosave && record.changed_for_autosave?(@memory))
record.save(validate: !autosave, memory: @memory)
end
if association.updated?
primary_key = Array(compute_primary_key(reflection, record)).map(&:to_s)
foreign_key = Array(reflection.foreign_key)
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
association_id = record._read_attribute(primary_key)
self[foreign_key] = association_id unless self[foreign_key] == association_id
end
association.loaded!
end
saved if autosave
end
end
end
end
end
require 'active_record/associations/collection_association'
require 'active_record/associations/has_many_association'
require 'active_record/associations/has_many_through_association'
module ActiveRecord
module Associations
class CollectionAssociation
def insert_record(record, validate = true, raise = false, memory = nil, &block)
if raise
record.save!(validate: validate, memory: memory, &block)
else
record.save(validate: validate, memory: memory, &block)
end
end
end
class HasManyAssociation
def insert_record(record, validate = true, raise = false, memory = nil)
set_owner_attributes(record)
super
end
end
class HasManyThroughAssociation
def insert_record(record, validate = true, raise = false, memory = nil)
ensure_not_nested
if record.new_record? || record.has_changes_to_save?
return unless super
end
save_through_record(record)
record
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment