backend/app/model/mixins/relationships.rb
# A relationship is a one-to-one/one-to-many/many-to-many link between two
# records, where the link can have properties of its own.
#
# Each relationship generates a dynamic model class that represents the
# relationship and stores its properties in the database. This code takes care
# of managing those relationship instances. It:
#
# * Generates classes in response to a 'define_relationship' definition
#
# * Creates relationship instances for incoming JSON records (and linking up
# the related objects
#
# * Turns those relationships back into JSON when sequel_to_json is called
#
# Some bits of terminology used here:
#
# * Referents -- the objects that an instance of a relationship refers to.
# For example, if the relationship is "linked agent", the two referents will
# be agent records.
#
# Note that a relationship can refer to two objects of the same type. This
# leads to some awkwardness when trying to distinguish between the two. As
# a result, there's a common pattern of using "other_referent_than(obj)" to
# mean "the referred record that isn't this one".
#
# * Reference columns -- In the DB, each relationship is a row in a table.
# The row contains columns for the relationship's properties, plus several
# "reference columns". These columns are foreign key references to records
# in other tables.
#
# Relationships between records of the same type create some awkwardness
# here too, since links between two resource records (for example) require
# two foreign key columns like 'resource_id_0' and 'resource_id_1'. Now to
# answer the question "Which resources involve the resource whose ID is 5?"
# we need to check in both reference columns.
#
# So, you'll see the "reference_columns_for(someclass)" helper used here.
# This returns a list of the columns that might contain references to a
# given record type.
#
# We'll create a concrete instance of this class for each defined relationship.
AbstractRelationship = Class.new(Sequel::Model) do
include ObjectGraph
# Create a relationship instance between two objects with a defined set of properties.
def self.relate(obj1, obj2, properties)
columns = if obj1.class == obj2.class
# If our two related objects are of the same type, we'll get back multiple
# columns here anyway
raise ReferenceError.new("Can't relate an object to itself") if obj1.id == obj2.id
self.reference_columns_for(obj1.class)
else
[self.reference_columns_for(obj1.class).first, self.reference_columns_for(obj2.class).first]
end
if columns.include?(nil)
raise ("One of the relationship columns for #{obj1} and #{obj2} couldn't be found." +
" (Have you created the '#{table_name}' table?)")
end
values = Hash[columns.zip([obj1.id, obj2.id])].merge(properties)
if [obj1, obj2].any? {|obj| obj.class.suppressible? && obj.suppressed == 1}
# Suppress this new relationship if it points to a suppressed record
values[:suppressed] = 1
end
# some objects ( like events? ) seem to leak their ids into the mix.
values.reject! { |key| key == :id or key == "id" }
if ( obj1.is_a?(Location) or obj2.is_a?(Location) )
values.reject! { |key| key == :jsonmodel_type or key == "jsonmodel_type" }
end
self.create(values)
end
# True if this relationship relates to obj
def relates_to?(obj)
self.class.reference_columns_for(obj.class).any? {|col|
self[col] == obj.id
}
end
# Find any relationship instances that reference 'victims' and modify them to
# refer to 'target' instead.
def self.transfer(target, victims)
target_columns = self.reference_columns_for(target.class)
victims_by_model = victims.reject {|v| (v.class == target.class) && (v.id == target.id)}.group_by(&:class)
# We're going to have to handle container profiles separately since victim
# cp relationships have to be compared against one another prior to merging.
# We'll just use this method to store container profile relationships then
# actually handle container profile relationships in the separate
# `cleanup_container_profile_relationships` method below
victim_container_profiles = []
victims_by_model.each do |victim_model, vics|
confirm_accepts_target(victim_model, vics)
victim_columns = self.reference_columns_for(victim_model)
victim_columns.each do |victim_col|
vics.each do |victim|
who_participates_with(victim).each do |parent|
parent_col = reference_columns_for(parent.class).first
# Find any relationship where the current column contains a reference to
# our victim
self.exclude(parent_col => nil).filter(victim_col => vics.map(&:id)).each do |relationship|
target_pre = find_by_participant(target)
# Remove this relationship's reference to the victim
relationship[victim_col] = nil
# When merging top containers, you also have to deal with the fact
# that subcontainers and instances may also need to be deleted. This
# array stores records that will be deleted in cleanup_duplicates.
dups = []
# Now add a new reference to the target (which, if the victim and
# target are of different types, might require updating a different
# column to the one we just set to NULL)
target_columns.each do |target_col|
if relationship[target_col]
# This column is already used to reference the other record in our
# relationship so we'll skip over it. But while we're here, make
# sure we're not about to create a circular relationship.
if relationship[target_col] == target.id
raise "Transfer would create a circular relationship!"
end
elsif relationship.is_a?(Relationships::SubContainerTopContainerLink) && !find_by_participant(target).empty?
identify_duplicate_containers(target, relationship, target_col, dups)
elsif relationship.is_a?(Relationships::ContainerProfileTopContainerProfile)
victim_container_profiles << relationship
else
target_pre.each do |pre|
if pre[parent_col] == relationship[parent_col]
dups << relationship
end
end
transfer_relationship_to_target(relationship, target_col, target, true)
break
end
end
relationship[:system_mtime] = Time.now
relationship[:user_mtime] = Time.now
relationship.save
cleanup_duplicates(dups)
end
end
end
end
end
cleanup_container_profile_relationships(victim_container_profiles, target)
# Finally, reindex the target record for good measure (and, in the case of
# top containers, to update the associated collections)
target[:system_mtime] = Time.now
target[:user_mtime] = Time.now
target.save
end
# If we're merging a record of type A with relationship R into a record of
# type B, type B must also support that relationship type. If it doesn't,
# we risk losing data through the merge and should abort.
def self.confirm_accepts_target(victim_model, vics)
unless participating_models.include?(victim_model)
found = self.find_by_participant_ids(victim_model, vics.map(&:id))
unless found.empty?
raise ReferenceError.new("#{victim_model} to be merged has data for relationship #{self}, but target record doesn't support it.")
end
end
end
def self.transfer_relationship_to_target(relationship, target_col, target, skip_refresh=false)
relationship[target_col] = target.id
unless skip_refresh
relationship[:system_mtime] = Time.now
relationship[:user_mtime] = Time.now
relationship.save
end
end
# ANW-952: After merging objects together you may be left with two
# relationships that link to the same post-merge record. This deletes
# those duplicated relationships after the merge process is complete.
def self.cleanup_duplicates(dups)
if !dups.empty?
dups.each {|d| d.delete}
end
end
def self.identify_duplicate_containers(target, relationship, target_col, dups)
find_by_participant(target).each do |target_relationship|
subcontainer = SubContainer[relationship[:sub_container_id]]
target_subcontainer = SubContainer[target_relationship[:sub_container_id]]
# Only proceed if the subcontainer record is empty
if [:type_2_id, :indicator_2, :type_3_id, :indicator_3].map {|k| subcontainer[k]}.compact.empty?
instance = Instance[subcontainer[:instance_id]]
target_instance = Instance[target_subcontainer[:instance_id]]
[:accession_id, :archival_object_id, :resource_id].each do |p|
next if instance[p].nil?
# If subcontainer is empty and if the subcontainer's instance
# record links to the same parent record (ao, accession, or
# resource), delete the subcontainer and the instance.
if instance[p] == target_instance[p]
dups << subcontainer
dups << instance
break
else
transfer_relationship_to_target(relationship, target_col, target, true)
break
end
end
else
transfer_relationship_to_target(relationship, target_col, target, true)
break
end
end
end
# When merging top containers there is the possibility that multiple container
# profiles might be relinked to the surviving top container, despite the fact
# that top containers should only ever have on linked container profile.
# While future work should actually make db-level/schema-level changes to
# prohibit linking multiple container profiles to a single top container, in the
# interim, this method ensures that container profile relationships are handled
# separately from other linked record transfers and ensures only one container
# profile (or, conditionally, no container profiles) survives the merge process.
def self.cleanup_container_profile_relationships(victim_container_profiles, target)
target_relationship = nil
find_by_participant(target).each do |target_rlshp|
if !target_rlshp.nil? && target_rlshp.is_a?(Relationships::ContainerProfileTopContainerProfile)
target_relationship = target_rlshp
end
end
victim_container_profiles_unique = victim_container_profiles.map {|v| v[:container_profile_id]}
# If the target has a linked container profile already, delete all victim
# container profile relationships
if !target_relationship.nil?
cleanup_duplicates(victim_container_profiles)
else
# If the array of victims only has one container profile relationship
# transfer that relationship over to the merge target
if victim_container_profiles.count == 1
victim_container_profile = victim_container_profiles.first
transfer_relationship_to_target(victim_container_profile, :top_container_id, target)
elsif victim_container_profiles.count > 1
# If the array of victims has multiple container profile relationships
# but they all link to the same container profile, transfer the first
# relationship to the merge target and delete the rest
if victim_container_profiles_unique.uniq.count == 1
victim_container_profile = victim_container_profiles.first
transfer_relationship_to_target(victim_container_profile, :top_container_id, target)
victim_container_profiles.shift
cleanup_duplicates(victim_container_profiles)
# If the array of victims has multiple container profile relationships
# and they link to different container profiles, delete all victim
# container profile relationships
else
cleanup_duplicates(victim_container_profiles)
end
end
end
end
# Return the value of 'property' for any relationship involving 'obj'.
def self.values_for_property(obj, property)
result = []
self.reference_columns_for(obj.class).each do |col|
self.filter(col => obj.id).select(property).distinct.each do |relationship|
result << relationship[property]
end
end
result
end
def self.to_s
"<#Relationship #{table_name}>"
end
# Methods for defining relationships
def self.set_json_property(property); @json_property = property; end
def self.json_property; @json_property; end
def self.set_participating_models(models); @participating_models = models; end
def self.participating_models; @participating_models or raise "No participating models set"; end
def self.set_wants_array(val); @wants_array = val; end
def self.wants_array?; @wants_array; end
# Return a list of the relationship instances that refer to 'obj'.
def self.find_by_participant(obj)
# Find all columns in our relationship's table that are named after obj's table
# These will contain references to instances of obj's class
reference_columns = self.reference_columns_for(obj.class)
filters = reference_columns.map {|col| { col => obj.id }}
return [] if filters.empty?
matching_relationships = self.filter(Sequel.|(*filters)).all
our_columns = participating_models.map {|m| reference_columns_for(m)}.flatten(1)
# Reject any relationship that links to obj.id but not another model we're interested in.
matching_relationships.reject! {|relationship|
!our_columns.any? {|c|
relationship[c] && (!reference_columns_for(obj.class).include?(c) || relationship[c] != obj.id)
}
}
matching_relationships.sort_by {|relationship| relationship[:aspace_relationship_position]}
end
# Return the list of relationships involving any of the records named in
# 'participant_ids'
def self.find_by_participant_ids(participant_model, participant_ids)
result = []
return result if participant_ids.empty?
reference_columns = self.reference_columns_for(participant_model)
filters = reference_columns.map {|col| { col => participant_ids }}
return [] if filters.empty?
self.filter(Sequel.|(*filters)).each do |relationship|
result << relationship
end
result
end
# Return a mapping of records and the relationships they participate in.
# Input is a list like:
#
# [rec1, rec2, ...]
#
# and the result is:
#
# { rec1 => [relationship1, relationship2, ...],
# rec2 => [relationship3, ...],
# ...}
#
def self.find_by_participants(objs)
result = {}
return result if objs.empty?
reference_columns = self.reference_columns_for(objs.first.class)
objects_by_id = objs.group_by {|obj| obj.id}
reference_columns.each do |col|
self.eager(self.associations).filter(col => objects_by_id.keys).all.each do |relationship|
obj = objects_by_id[relationship[col]].first
result[obj] ||= []
result[obj] << relationship
end
end
result.each do |obj, relationships|
relationships.sort_by! {|relationship| relationship[:aspace_relationship_position]}
end
result
end
# Return a list of the objects that are related to 'obj' via one of our
# relationships
def self.who_participates_with(obj)
# Find all relationships involving obj
relationships = self.find_by_participant(obj)
relationships.map {|relationship|
relationship.other_referent_than(obj)
}
end
# A list of all DB columns that might contain a foreign key reference to a
# record of type 'model'.
MODEL_COLUMNS_CACHE = java.util.concurrent.ConcurrentHashMap.new(128)
def self.reference_columns_for(model)
key = [self, model]
if columns = MODEL_COLUMNS_CACHE.get(key)
columns
else
MODEL_COLUMNS_CACHE.put(key,
self.db_schema.keys.select { |column_name|
[
model.table_name.downcase.to_s + "_id",
model.table_name.downcase.to_s + "_id_0",
model.table_name.downcase.to_s + "_id_1",
].include?(column_name.to_s.downcase)
})
MODEL_COLUMNS_CACHE.get(key)
end
end
def self.handle_suppressed(ids, val)
ASModel.update_suppressed_flag(self.filter(:id => ids), val)
end
def self.handle_delete(ids)
self.filter(:id => ids).delete
end
def self.my_jsonmodel(ok_if_missing = false)
raise("No corresponding JSONModel set for model #{self.inspect}") unless ok_if_missing
end
def self.publishable?
self.columns.include?(:publish)
end
# The properties for this relationship instance
def properties
self.values
end
# The record referred to by the current relationship that isn't 'obj'.
def other_referent_than(obj)
self.class.participating_models.each {|model|
self.class.reference_columns_for(model).each {|column|
if self[column] && (model != obj.class || self[column] != obj.id)
return model.respond_to?(:any_repo) ? model.any_repo[self[column]] : model[self[column]]
end
}
}
nil
end
# The URI of the record referred to by the current relationship that isn't
# 'obj'.
def uri_for_other_referent_than(obj)
self.class.participating_models.each {|model|
self.class.reference_columns_for(model).each {|column|
if self[column] && (model != obj.class || self[column] != obj.id)
return model.my_jsonmodel.uri_for(self[column],
:repo_id => RequestContext.get(:repo_id))
end
}
}
raise "Failed to find a URI for other referent in #{self}: #{obj.id}"
end
def self.is_relationship?
true
end
end
module Relationships
def self.included(base)
base.instance_eval do
@relationships ||= {}
@relationship_dependencies ||= {}
end
base.extend(ClassMethods)
end
def update_from_json(json, opts = {}, apply_nested_records = true)
obj = super
# Call this before and after the change since relationships might have been
# removed and the previously linked objects might need reindexing.
trigger_reindex_of_dependants
self.class.apply_relationships(obj, json, opts)
trigger_reindex_of_dependants
obj
end
# Store a list of the relationships that this object participates in. Saves
# looking up the DB for each one.
attr_reader :cached_relationships
def cache_relationships(relationship_defn, relationship_objects)
@cached_relationships ||= {}
@cached_relationships[relationship_defn] = relationship_objects
end
def trigger_reindex_of_dependants
# Update the mtime of any record with a relationship to this one. This
# encourages the indexer to reindex records when, say, a subject is renamed.
#
# Once we have our list of unique models, inform each of them that our
# instance has been updated (using a class method defined below).
self.class.dependent_models.each do |model|
model.touch_mtime_of_anyone_related_to(self)
end
end
# Added to the mixed in class itself: return a list of the relationship
# instances involving this object
def my_relationships(name)
self.class.find_relationship(name).find_by_participant(self)
end
# Return all object instances that are related to the current record by the
# relationship named by 'name'.
def related_records(name)
relationship = self.class.find_relationship(name)
records = relationship.who_participates_with(self)
relationship.wants_array? ? records : records.first
end
# Find all relationships involving the records in 'victims' and rewrite them
# to refer to us instead.
def assimilate(victims)
victims = victims.reject {|v| (v.class == self.class) && (v.id == self.id)}
self.class.relationship_dependencies.each do |relationship, models|
models.each do |model|
model.transfer(relationship, self, victims)
end
end
DB.attempt {
victims.each(&:delete)
}.and_if_constraint_fails {
raise MergeRequestFailed.new("Can't complete merge: record still in use")
}
trigger_reindex_of_dependants
end
def transfer_to_repository(repository, transfer_group = [])
if transfer_group.empty?
do_id = self.class == DigitalObject ? self[:id] : 0
else
do_id = transfer_group.first.class == DigitalObject ? transfer_group.first[:id] : 0
end
unless do_id == 0
return unless do_transferable?(do_id)
end
# When a record is being transferred to another repository, any
# relationships it has to records within the current repository must be
# cleared.
predicate = proc {|relationship|
referent = relationship.other_referent_than(self)
# Delete the relationship if we're repository-scoped and the referent is
# in the old repository. Don't worry about relationships to any of the
# records that are going to be transferred along with us (listed in
# transfer_group)
(referent.class.model_scope == :repository &&
referent.repo_id != repository.id &&
!transfer_group.any? {|obj| obj.id == referent.id && obj.model == referent.model})
}
([self.class] + self.class.dependent_models).each do |model|
model.delete_existing_relationships(self, false, false, predicate)
end
super
end
def do_transferable?(do_id)
# ANW-151: Digital objects should not be transferable if they have instance links to other repository-scoped record types. If not transferrable, we throw an error and abort the transfer.
do_relationship = DigitalObject.find_relationship(:instance_do_link)
instances = do_relationship
.select(:instance_id).filter(:digital_object_id => do_id)
.map {|row| row[:instance_id]}
if instances.empty?
true
else
do_has_link_error(instances)
false
end
end
def do_has_link_error(instances)
# Abort the transfer and provide the list of top-level records that are preventing it from completing.
exception = TransferConstraintError.new
ASModel.all_models.each do |model|
next unless model.associations.include?(:instance)
model
.eager_graph(:instance)
.filter(:instance__id => instances)
.select(Sequel.qualify(model.table_name, :id))
.each do |row|
exception.add_conflict(model.my_jsonmodel.uri_for(row[:id], :repo_id => self.class.active_repository),
{:json_property => 'instances',
:message => "DIGITAL_OBJECT_HAS_LINK"})
end
end
raise exception
return
end
module ClassMethods
def calculate_object_graph(object_graph, opts = {})
# For each relationship involving a resource
self.relationships.each do |relationship_defn|
# Find any relationship of this type involving any record mentioned in
# object graph
object_graph.each do |model, id_list|
next unless relationship_defn.participating_models.include?(model)
linked_relationships = relationship_defn.find_by_participant_ids(model, id_list).map {|row|
row[:id]
}
object_graph.add_objects(relationship_defn, linked_relationships)
end
end
super
end
# Reset relationship definitions for the current class
def clear_relationships
@relationships = {}
end
def relationships
@relationships.values
end
def relationship_dependencies
@relationship_dependencies
end
def dependent_models
@relationship_dependencies.values.flatten.uniq
end
def find_relationship(name, noerror = false)
@relationships[name] or (noerror ? nil : raise("Couldn't find #{name} in #{@relationships.inspect}"))
end
# Define a new relationship.
def define_relationship(opts)
[:name, :contains_references_to_types].each do |p|
opts[p] or raise "No #{p} given"
end
base = self
ArchivesSpaceService.loaded_hook do
# We hold off actually setting anything up until all models have been
# loaded, since our relationships may need to reference a model that
# hasn't been loaded yet.
#
# This is also why the :contains_references_to_types property is a proc
# instead of a regular array--we don't want to blow up with a NameError
# if the model hasn't been loaded yet.
related_models = opts[:contains_references_to_types].call
clz = Class.new(AbstractRelationship) do
table = "#{opts[:name]}_rlshp".intern
set_dataset(table)
set_primary_key(:id)
if !self.db.table_exists?(self.table_name)
Log.warn("Table doesn't exist: #{self.table_name}")
end
set_participating_models([base, *related_models].uniq)
set_json_property(opts[:json_property])
set_wants_array(opts[:is_array].nil? || opts[:is_array])
end
opts[:class_callback].call(clz) if opts[:class_callback]
@relationships[opts[:name]] = clz
related_models.each do |model|
model.include(Relationships)
model.add_relationship_dependency(opts[:name], base)
end
# Give the new relationship class a name to help with debugging
# Example: Relationships::ResourceSubject
Relationships.const_set(self.name + opts[:name].to_s.camelize, clz)
end
end
# Delete all existing relationships for 'obj'.
def delete_existing_relationships(obj, bump_lock_version_on_referent = false, force = false, predicate = nil)
relationships.each do |relationship_defn|
next if (!relationship_defn.json_property && !force)
if (relationship_defn.json_property &&
(!self.my_jsonmodel.schema['properties'][relationship_defn.json_property] ||
self.my_jsonmodel.schema['properties'][relationship_defn.json_property]['readonly'] === 'true'))
# Don't delete instances of relationships that are read-only in this direction.
next
end
relationship_defn.find_by_participant(obj).each do |relationship|
# If our predicate says to spare this relationship, leave it alone
next if predicate && !predicate.call(relationship)
# If we're deleting a relationship without replacing it, bump the lock
# version on the referent object so it doesn't accidentally get
# re-added.
#
# This will also encourage the indexer to pick up changes on deletion
# (e.g. a subject gets deleted and we want to reindex the records that
# reference it)
if bump_lock_version_on_referent
referent = relationship.other_referent_than(obj)
DB.increase_lock_version_or_fail(referent) if referent
end
relationship.delete
end
end
end
# Create set of relationships for a given update
def apply_relationships(obj, json, opts, new_record = false)
delete_existing_relationships(obj) if !new_record
@relationships.each do |relationship_name, relationship_defn|
property_name = relationship_defn.json_property
# If there's no property name, the relationship is just read-only
next if !property_name
# For each record reference in our JSON data
ASUtils.as_array(json[property_name]).each_with_index do |reference, idx|
record_type = parse_reference(reference['ref'], opts)
referent_model = relationship_defn.participating_models.find {|model|
model.my_jsonmodel.record_type == record_type[:type]
} or raise "Couldn't find model for #{record_type[:type]}"
referent = referent_model[record_type[:id]]
if !referent
raise ReferenceError.new("Can't relate to non-existent record: #{reference['ref']}")
end
# Create a new relationship instance linking us and them together, and
# add the properties from the JSON request to the relationship
properties = reference.clone.tap do |properties|
properties.delete('ref')
end
properties[:aspace_relationship_position] = idx
properties[:system_mtime] = Time.now
properties[:user_mtime] = Time.now
relationship_defn.relate(obj, referent, properties)
# If this is a reciprocal relationship (defined on both participating
# models), update the referent's lock version to ensure that a
# concurrent update to that object won't clobber our changes.
if referent_model.find_relationship(relationship_name, true) && !opts[:system_generated]
DB.increase_lock_version_or_fail(referent)
end
end
end
end
# Find all of the relationships involving 'objects' and tell each object to
# cache its relationships. This is an optimisation: avoids the need for one
# SELECT for every relationship lookup by pulling back all relationships at
# once.
def eager_load_relationships(objects, relationships_to_load = nil)
relationships_to_load = relationships unless relationships_to_load
relationships_to_load.each do |relationship_defn|
# For each defined relationship
relationships_map = relationship_defn.find_by_participants(objects)
objects.each do |obj|
obj.cache_relationships(relationship_defn, relationships_map[obj])
end
end
end
def create_from_json(json, opts = {})
obj = super
apply_relationships(obj, json, opts, true)
obj
end
def sequel_to_jsonmodel(objs, opts = {})
jsons = super
return jsons if opts[:skip_relationships]
eager_load_relationships(objs, relationships.select {|relationship_defn| relationship_defn.json_property})
jsons.zip(objs).each do |json, obj|
relationships.each do |relationship_defn|
property_name = relationship_defn.json_property
# If we don't need this property in our return JSON, skip it.
next unless property_name
# For each defined relationship
relationships = if obj.cached_relationships
# Use the eagerly fetched relationships if we have them
Array(obj.cached_relationships[relationship_defn])
else
relationship_defn.find_by_participant(obj)
end
json[property_name] = relationships.map {|relationship|
next if RequestContext.get(:enforce_suppression) && relationship.suppressed == 1
# Return the relationship properties, plus the URI reference of the
# related object
values = ASUtils.keys_as_strings(relationship.properties)
values['ref'] = relationship.uri_for_other_referent_than(obj)
values
}
if !relationship_defn.wants_array?
json[property_name] = json[property_name].first
end
end
end
jsons
end
# Find all instances of the referring class that have a relationship with 'obj'
# Spans all defined relationships.
def instances_relating_to(obj)
relationships.map {|relationship_defn|
relationship_defn.who_participates_with(obj)
}.flatten
end
def add_relationship_dependency(relationship_name, clz)
@relationship_dependencies[relationship_name] ||= []
@relationship_dependencies[relationship_name] << clz
end
def transfer(relationship_name, target, victims)
relationship = find_relationship(relationship_name)
relationship.transfer(target, victims)
end
# This notifies the current model that an instance of a related model has
# been changed. We respond by finding any of our own instances that refer
# to the updated instance and update their mtime.
def touch_mtime_of_anyone_related_to(obj)
now = Time.now
relationships.map do |relationship_defn|
models = relationship_defn.participating_models
# If this relationship doesn't link to records of type `obj`, we're not
# interested.
next unless models.include?(obj.class)
their_ref_columns = relationship_defn.reference_columns_for(obj.class)
my_ref_columns = relationship_defn.reference_columns_for(self)
their_ref_columns.each do |their_col|
my_ref_columns.each do |my_col|
# This one type of relationship (between the software agent and
# anything else) was a particular hotspot when analyzing real-world
# performance.
#
# Terrible to have to do this, but the MySQL optimizer refuses
# to use the primary key on agent_software because it (often)
# only has one row.
#
if DB.supports_join_updates? &&
self.table_name == :agent_software &&
relationship_defn.table_name == :linked_agents_rlshp
DB.open do |db|
id_str = Integer(obj.id).to_s
db.run("UPDATE `agent_software` FORCE INDEX (PRIMARY) " +
" INNER JOIN `linked_agents_rlshp` " +
"ON (`linked_agents_rlshp`.`agent_software_id` = `agent_software`.`id`) " +
"SET `agent_software`.`system_mtime` = NOW() " +
"WHERE (`linked_agents_rlshp`.`archival_object_id` = #{id_str})")
end
return
end
# Example: if we're updating a subject record and want to update
# the timestamps of any linked archival object records:
#
# * self = ArchivalObject
# * relationship_defn is subject_rlshp
# * obj = #<Subject instance that was updated>
# * their_col = subject_rlshp.subject_id
# * my_col = subject_rlshp.archival_object_id
# Join our model class table to the relationship that links it to `obj`
#
# For example: join ArchivalObject to subject_rlshp
# join Instance to instance_do_link_rlshp
base_ds = self.join(relationship_defn.table_name,
Sequel.qualify(relationship_defn.table_name, my_col) =>
Sequel.qualify(self.table_name, :id))
# Limit only to the object of interest--we only care about records
# involved in a relationship with the record that was updated (obj)
base_ds = base_ds.filter(Sequel.qualify(relationship_defn.table_name, their_col) => obj.id)
# Now update the mtime of any top-level record that links to that
# relationship.
self.update_toplevel_mtimes(base_ds, now)
end
end
end
end
# Given a `dataset` that links the current record type to some relationship
# type, set the modification time of the nearest top-level record to
# `new_mtime`.
#
# If the current record type links directly to the relationship (such as an
# Archival Object linking to a Subject), then this is easy: we just update
# the modification time of the Archival Object.
#
# If the current record is a nested record (such as an Instance linked to a
# Digital Object), we want to continue up the chain, linking the Instance
# nested record to its Accession/Resource/Archival Object parent record, and
# then update the modification time of that parent.
#
# And if the nested record has a nested record has a nested record has a
# relationship... well, you get the idea. We handle the recursive case too!
#
def update_toplevel_mtimes(dataset, new_mtime)
if self.enclosing_associations.empty?
# If we're not enclosed by anything else, we're a top-level record. Do the final update.
if DB.supports_join_updates?
# Fast path! Use a join update.
dataset.update(Sequel.qualify(self.table_name, :system_mtime) => new_mtime)
else
# Slow path. Subselect.
ids_to_touch = dataset.select(Sequel.qualify(self.table_name, :id))
self.filter(:id => ids_to_touch).update(:system_mtime => new_mtime)
end
else
# Otherwise, we're a nested record
self.enclosing_associations.each do |association|
parent_model = association[:model]
# Link the parent into the current dataset
parent_ds = dataset.join(parent_model.table_name,
Sequel.qualify(self.table_name, association[:key]) =>
Sequel.qualify(parent_model.table_name, :id))
# and tell it to continue!
parent_model.update_toplevel_mtimes(parent_ds, new_mtime)
end
end
end
end
end