Files
prism/lib/prism/orm/associable.rb
T

497 lines
23 KiB
Ruby

module Prism
module Orm
module Associable
DEPENDENT_OPTIONS = [nil, :destroy, :nullify].freeze
def self.included(base)
base.extend(ClassMethods)
end
# Normalizes a has_many/has_one/belongs_to result into a flat, deduped
# (by id) array of records, walking each `through` record's `source`
# association. Shared by the lazy reader in ClassMethods and by
# Relation's eager-load preloader.
def self.collect_through(through_records, source_name)
seen = {}
as_array(through_records).each_with_object([]) do |through_record, list|
as_array(through_record.send(source_name)).each do |target|
next if target.nil? || seen[target.id]
seen[target.id] = true
list << target
end
end
end
def self.as_array(value)
case value
when nil then []
when Enumerable then value.to_a
else [value]
end
end
# Resolves the counter_cache column name against `klass.name` lazily,
# at hook-execution time rather than when `belongs_to` is declared -
# an anonymous `Class.new(Model) { belongs_to ..., counter_cache: true }`
# (the pattern this test suite uses throughout, constant-set only
# after the class body runs) would otherwise capture `klass.name ==
# nil` forever, silently producing a bogus "_count" column.
def self.counter_cache_column(klass, counter_cache)
counter_cache == true ? "#{Inflector.tableize(klass.name)}_count" : counter_cache.to_s
end
module ClassMethods
def associations
@associations ||= {}
end
def belongs_to(name, class_name: nil, foreign_key: nil, polymorphic: false, counter_cache: false, touch: false)
if polymorphic
return define_belongs_to_polymorphic(name, foreign_key: foreign_key, counter_cache: counter_cache,
touch: touch)
end
target = class_name || Inflector.camelize(name.to_s)
key = (foreign_key || "#{name}_id").to_s
associations[name.to_sym] = { type: :belongs_to, class_name: target, foreign_key: key }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
value = self[key]
cache[name] = value.nil? ? nil : Object.const_get(target).find(value)
end
define_method("#{name}=") do |record|
self[key] = record&.id
association_cache[name] = record
end
define_counter_cache(target, key, counter_cache) if counter_cache
define_touch(target, key, touch) if touch
end
def has_one(name, class_name: nil, foreign_key: nil, dependent: nil, through: nil, source: nil, as: nil)
return define_has_one_through(name, through: through, source: source, dependent: dependent) if through
return define_has_one_polymorphic(name, class_name: class_name, as: as, dependent: dependent) if as
validate_dependent!(dependent)
target = class_name || Inflector.camelize(name.to_s)
key = (foreign_key || "#{Inflector.underscore(self.name)}_id").to_s
associations[name.to_sym] = { type: :has_one, class_name: target, foreign_key: key, dependent: dependent }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
cache[name] = Object.const_get(target).find_by(key => id)
end
define_method("#{name}=") do |record|
if record
record[key] = id
record.save
end
association_cache[name] = record
end
end
def has_many(name, class_name: nil, foreign_key: nil, dependent: nil, through: nil, source: nil, as: nil)
return define_has_many_through(name, through: through, source: source, dependent: dependent) if through
return define_has_many_polymorphic(name, class_name: class_name, as: as, dependent: dependent) if as
validate_dependent!(dependent)
target = class_name || Inflector.classify(name.to_s)
key = (foreign_key || "#{Inflector.underscore(self.name)}_id").to_s
associations[name.to_sym] = { type: :has_many, class_name: target, foreign_key: key, dependent: dependent }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
Object.const_get(target).where(key => id)
end
end
def has_and_belongs_to_many(name, class_name: nil, join_table: nil, foreign_key: nil,
association_foreign_key: nil)
target = class_name || Inflector.classify(name.to_s)
jt = (join_table || habtm_join_table_name(self.name, target)).to_s
left_key = (foreign_key || "#{Inflector.underscore(self.name)}_id").to_s
right_key = (association_foreign_key || "#{Inflector.underscore(target)}_id").to_s
associations[name.to_sym] = {
type: :habtm, class_name: target, join_table: jt, left_key: left_key, right_key: right_key
}
define_method(name) do
HabtmCollection.new(self, target: Object.const_get(target), join_table: jt,
left_key: left_key, right_key: right_key)
end
define_method("#{name}=") do |records|
send(name).replace(records)
end
end
def dependent_associations
associations.select { |_, meta| meta[:dependent] }
end
private
# has_many :through - derives its collection by walking an existing
# association (`through`) and reading another association (`source`)
# off each of those records, e.g. `has_many :commenters, through:
# :comments, source: :commenter`. Unlike plain has_many this holds no
# foreign key of its own, so `dependent` is not supported.
def define_has_many_through(name, through:, source:, dependent: nil)
raise ArgumentError, 'has_many :through requires a through: association' unless through
if dependent
raise ArgumentError, "dependent is not supported with has_many :through (#{name.inspect})"
end
source_name = (source || Inflector.singularize(name.to_s)).to_sym
associations[name.to_sym] = { type: :has_many_through, through: through.to_sym, source: source_name }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
cache[name] = Associable.collect_through(send(through), source_name)
end
end
# has_one :through - single-record counterpart to has_many :through:
# walks an existing has_one/belongs_to association (`through`) and
# reads another single-record association (`source`) off it, e.g.
# `has_one :account_history, through: :account`. Holds no foreign key
# of its own, so `dependent` is not supported.
def define_has_one_through(name, through:, source:, dependent: nil)
raise ArgumentError, 'has_one :through requires a through: association' unless through
if dependent
raise ArgumentError, "dependent is not supported with has_one :through (#{name.inspect})"
end
source_name = (source || name).to_sym
associations[name.to_sym] = { type: :has_one_through, through: through.to_sym, source: source_name }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
through_record = send(through)
cache[name] = through_record && through_record.send(source_name)
end
end
# belongs_to :commentable, polymorphic: true - the owner holds both
# the foreign key (commentable_id) and a type column (commentable_type,
# the target's class name as a string), so the same foreign key can
# point at rows in different tables depending on the record.
#
# counter_cache:/touch: both work here too, exactly as on a plain
# belongs_to, except which owner CLASS to reach for is read off
# type_key per record/per saved-change instead of being the single
# fixed class_name a plain belongs_to always has - see the type_key:
# branches in define_counter_cache/define_touch below.
def define_belongs_to_polymorphic(name, foreign_key:, counter_cache: false, touch: false)
key = (foreign_key || "#{name}_id").to_s
type_key = "#{name}_type"
associations[name.to_sym] = { type: :belongs_to, polymorphic: true, foreign_key: key, type_key: type_key }
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
target_id = self[key]
target_type = self[type_key]
cache[name] = (target_id.nil? || target_type.nil?) ? nil : Object.const_get(target_type).find(target_id)
end
define_method("#{name}=") do |record|
self[key] = record&.id
self[type_key] = record&.class&.name
association_cache[name] = record
end
define_counter_cache(nil, key, counter_cache, type_key: type_key) if counter_cache
define_touch(nil, key, touch, type_key: type_key) if touch
end
# has_many :comments, as: :commentable - the inverse side of a
# polymorphic belongs_to: scopes the child table by both the foreign
# key and the type column so rows belonging to a different owner
# class with the same id never leak in.
def define_has_many_polymorphic(name, class_name:, as:, dependent: nil)
validate_dependent!(dependent)
target = class_name || Inflector.classify(name.to_s)
key = "#{as}_id"
type_key = "#{as}_type"
associations[name.to_sym] = {
type: :has_many, class_name: target, foreign_key: key, type_key: type_key, dependent: dependent
}
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
Object.const_get(target).where(key => id, type_key => self.class.name)
end
end
# has_one :preview_image, as: :imageable - single-record counterpart
# to define_has_many_polymorphic.
def define_has_one_polymorphic(name, class_name:, as:, dependent: nil)
validate_dependent!(dependent)
target = class_name || Inflector.camelize(name.to_s)
key = "#{as}_id"
type_key = "#{as}_type"
associations[name.to_sym] = {
type: :has_one, class_name: target, foreign_key: key, type_key: type_key, dependent: dependent
}
define_method(name) do
cache = association_cache
return cache[name] if cache.key?(name)
NPlusOneDetector.record_lazy_load(self, name)
cache[name] = Object.const_get(target).find_by(key => id, type_key => self.class.name)
end
define_method("#{name}=") do |record|
if record
record[key] = id
record[type_key] = self.class.name
record.save
end
association_cache[name] = record
end
end
# belongs_to ..., counter_cache: true - keeps a denormalized count of
# this class's own rows on the parent row pointed at by the foreign
# key, so `post.comments.count` becomes a plain column read
# (`post.comments_count`) instead of a query, mirroring Rails'
# `belongs_to :post, counter_cache: true` on the child (Comment)
# side. `counter_cache: true` defaults the column to this class's
# own table name pluralized-as-a-suffix (Comment -> comments_count);
# `counter_cache: :custom_column` names it explicitly. The three
# hooks run inside the same transaction as the triggering
# create/update/destroy (Store#increment_counter is reentrant), so a
# rolled-back child write rolls the counter bump back with it.
#
# type_key: is set by define_belongs_to_polymorphic - a polymorphic
# owner's class varies per row (read off that column), unlike a
# plain belongs_to's single fixed target_class_name, so id and type
# are tracked as a PAIR everywhere below instead of an id alone.
# Either half of that pair can in principle change independently of
# the other on update, so old/new are resolved separately for each.
def define_counter_cache(target_class_name, foreign_key, counter_cache, type_key: nil)
after_create do
owner_id = self[foreign_key]
owner_type = type_key ? self[type_key] : target_class_name
next unless owner_id && owner_type
column = Associable.counter_cache_column(self.class, counter_cache)
Object.const_get(owner_type).increment_counter(column, owner_id)
end
after_destroy do
owner_id = self[foreign_key]
owner_type = type_key ? self[type_key] : target_class_name
next unless owner_id && owner_type
column = Associable.counter_cache_column(self.class, counter_cache)
Object.const_get(owner_type).decrement_counter(column, owner_id)
end
# A restore (Paranoia#restore) never goes through after_create -
# it un-stamps and updates an existing row - so without this a
# counter_cache'd child coming back from the trash would leave
# its parent's count permanently short by one.
after_restore do
owner_id = self[foreign_key]
owner_type = type_key ? self[type_key] : target_class_name
next unless owner_id && owner_type
column = Associable.counter_cache_column(self.class, counter_cache)
Object.const_get(owner_type).increment_counter(column, owner_id)
end
after_update do
fk_changed = saved_change_to_attribute?(foreign_key)
type_changed = type_key && saved_change_to_attribute?(type_key)
next unless fk_changed || type_changed
unchanged_id = [self[foreign_key], self[foreign_key]]
old_id, new_id = fk_changed ? saved_change_to_attribute(foreign_key) : unchanged_id
current_type = type_key ? self[type_key] : target_class_name
unchanged_type = [current_type, current_type]
old_type, new_type = type_changed ? saved_change_to_attribute(type_key) : unchanged_type
column = Associable.counter_cache_column(self.class, counter_cache)
Object.const_get(old_type).decrement_counter(column, old_id) if old_id && old_type
Object.const_get(new_type).increment_counter(column, new_id) if new_id && new_type
end
end
# belongs_to ..., touch: true - Model#touch's the owner (updated_at,
# plus `touch: :column` for an explicit extra one) whenever this
# child is saved or destroyed. Unlike counter_cache this fires on
# EVERY save, not only when the foreign key itself changes - editing
# a comment's body with no change to its post_id still needs to bust
# the post's #cache_key. If the foreign key did change, the OLD
# owner is touched too (mirrors Rails: both sides of a reassignment
# see their cache invalidated), same before/after split as
# counter_cache's after_update above. A parent that no longer
# exists (already destroyed elsewhere) is left alone rather than
# raising - the child's own save/destroy already succeeded and
# should not fail retroactively over stale cache bookkeeping.
#
# after_destroy skips the touch entirely when
# destroyed_by_association_foreign_key names this same foreign key
# - i.e. when this child is being destroyed because ITS OWNER is
# cascading `has_many ..., dependent: :destroy` into it (see
# apply_dependent above). Without this guard, a paranoid,
# optimistically-locked owner running that cascade would have each
# cascaded child's after_destroy re-`find` the owner and bump its
# lock_version - so by the time the owner's own soft/hard delete
# finally writes using the lock_version it captured once at the top
# of its #destroy, that captured value no longer matches what the
# cascade already advanced it to, and the owner's own delete raises
# StaleObjectError over a write it never asked for. An owner that's
# disappearing in this same operation was never going to have
# anyone observe the bumped-then-discarded timestamp anyway - only
# the exception would have been visible. Mirrors Rails'
# ActiveRecord::Associations `destroyed_by_association` guard on
# its own touch callback, for exactly this reason.
#
# type_key: is set by define_belongs_to_polymorphic - which class to
# Object.const_get and #find on varies per row (read off that
# column) instead of being the single fixed target_class_name a
# plain belongs_to always touches, so id and type travel together as
# a pair everywhere below, same as in define_counter_cache above.
def define_touch(target_class_name, foreign_key, touch, type_key: nil)
extra_column = touch == true ? nil : touch.to_s
perform_touch = lambda do |owner_type, owner_id|
next unless owner_id && owner_type
begin
record = Object.const_get(owner_type).find(owner_id)
extra_column ? record.touch(extra_column) : record.touch
rescue RecordNotFound, StaleObjectError
nil
end
end
after_save do
current_type = type_key ? self[type_key] : target_class_name
perform_touch.call(current_type, self[foreign_key])
fk_changed = saved_change_to_attribute?(foreign_key)
type_changed = type_key && saved_change_to_attribute?(type_key)
if fk_changed || type_changed
old_id = fk_changed ? saved_change_to_attribute(foreign_key)[0] : self[foreign_key]
old_type = type_changed ? saved_change_to_attribute(type_key)[0] : current_type
perform_touch.call(old_type, old_id)
end
end
after_destroy do
unless destroyed_by_association_foreign_key == foreign_key
owner_type = type_key ? self[type_key] : target_class_name
perform_touch.call(owner_type, self[foreign_key])
end
end
end
def validate_dependent!(dependent)
return if DEPENDENT_OPTIONS.include?(dependent)
raise ArgumentError, "unsupported dependent: #{dependent.inspect} (use :destroy or :nullify)"
end
def habtm_join_table_name(a, b)
[Inflector.tableize(a), Inflector.tableize(b)].sort.join('_')
end
end
def association_cache
@association_cache ||= {}
end
# Set on a child by apply_dependent right before it destroys that
# child as part of its owner's own dependent: :destroy cascade,
# naming the foreign key that made it a dependent. belongs_to ...,
# touch: true's after_destroy hook (see define_touch) checks this to
# skip touching an owner that is disappearing in this very same
# operation - not just to dodge a redundant write, but because the
# owner's own destroy already captured its lock_version before the
# cascade started, and cascade-triggered touches bumping it further
# would make that captured version stale by the time the owner's own
# soft/hard delete finally writes (see the long comment on
# define_touch). Mirrors Rails' own ActiveRecord::Associations
# `destroyed_by_association` for exactly this reason.
attr_accessor :destroyed_by_association_foreign_key
private
def cascade_dependent_associations
self.class.dependent_associations.each do |name, meta|
cascade_one_association(meta)
end
end
def cascade_one_association(meta)
target = Object.const_get(meta[:class_name])
condition = { meta[:foreign_key] => id }
condition[meta[:type_key]] = self.class.name if meta[:type_key]
case meta[:type]
when :has_many
apply_dependent(target.where(condition).to_a, meta)
when :has_one
record = target.find_by(condition)
apply_dependent(record ? [record] : [], meta)
end
end
def apply_dependent(records, meta)
case meta[:dependent]
when :destroy
records.each do |record|
record.destroyed_by_association_foreign_key = meta[:foreign_key]
record.destroy
end
when :nullify
updates = { meta[:foreign_key] => nil }
updates[meta[:type_key]] = nil if meta[:type_key]
records.each { |record| record.update(updates) }
end
end
# Mirror of cascade_dependent_associations for Paranoia#restore(recursive:
# true): only :destroy associations can have anything to bring back
# (:nullify never removed a row, it just detached one), and only a
# paranoid target could possibly have a soft-deleted row sitting in
# only_deleted - a plain model's dependent: :destroy children were
# already removed for real when the parent was trashed, so there is
# nothing left to restore. Recurses into each restored child with
# recursive: true itself, so restoring a Post brings its Comments back
# AND each Comment's own paranoid dependents (its Likes), if any.
def cascade_restore_dependent_associations
self.class.dependent_associations.each do |name, meta|
next unless meta[:dependent] == :destroy
restore_one_dependent_association(meta)
end
end
def restore_one_dependent_association(meta)
target = Object.const_get(meta[:class_name])
return unless target.respond_to?(:paranoid?) && target.paranoid?
condition = { meta[:foreign_key] => id }
condition[meta[:type_key]] = self.class.name if meta[:type_key]
case meta[:type]
when :has_many
target.only_deleted.where(condition).to_a.each { |record| record.restore(recursive: true) }
when :has_one
record = target.only_deleted.find_by(condition)
record.restore(recursive: true) if record
end
end
end
end
end