require 'active_support' require 'active_support/core_ext/enumerable' # For Enumerable#index_by class GlobalID module Locator class << self # Takes either a GlobalID or a string that can be turned into a GlobalID # # Options: # * :only - A class, module or Array of classes and/or modules that are # allowed to be located. Passing one or more classes limits instances of returned # classes to those classes or their subclasses. Passing one or more modules in limits # instances of returned classes to those including that module. If no classes or # modules match, +nil+ is returned. def locate(gid, options = {}) if gid = GlobalID.parse(gid) locator_for(gid).locate gid if find_allowed?(gid.model_class, options[:only]) end end # Takes an array of GlobalIDs or strings that can be turned into a GlobalIDs. # All GlobalIDs must belong to the same app, as they will be located using # the same locator using its locate_many method. # # By default the GlobalIDs will be located using Model.find(array_of_ids), so the # models must respond to that finder signature. # # This approach will efficiently call only one #find (or #where(id: id), when using ignore_missing) # per model class, but still interpolate the results to match the order in which the gids were passed. # # Options: # * :only - A class, module or Array of classes and/or modules that are # allowed to be located. Passing one or more classes limits instances of returned # classes to those classes or their subclasses. Passing one or more modules in limits # instances of returned classes to those including that module. If no classes or # modules match, +nil+ is returned. # * :ignore_missing - By default, locate_many will call #find on the model to locate the # ids extracted from the GIDs. In Active Record (and other data stores following the same pattern), # #find will raise an exception if a named ID can't be found. When you set this option to true, # we will use #where(id: ids) instead, which does not raise on missing records. def locate_many(gids, options = {}) if (allowed_gids = parse_allowed(gids, options[:only])).any? locator = locator_for(allowed_gids.first) locator.locate_many(allowed_gids, options) else [] end end # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID # # Options: # * :only - A class, module or Array of classes and/or modules that are # allowed to be located. Passing one or more classes limits instances of returned # classes to those classes or their subclasses. Passing one or more modules in limits # instances of returned classes to those including that module. If no classes or # modules match, +nil+ is returned. def locate_signed(sgid, options = {}) SignedGlobalID.find sgid, options end # Takes an array of SignedGlobalIDs or strings that can be turned into a SignedGlobalIDs. # The SignedGlobalIDs are located using Model.find(array_of_ids), so the models must respond to # that finder signature. # # This approach will efficiently call only one #find per model class, but still interpolate # the results to match the order in which the gids were passed. # # Options: # * :only - A class, module or Array of classes and/or modules that are # allowed to be located. Passing one or more classes limits instances of returned # classes to those classes or their subclasses. Passing one or more modules in limits # instances of returned classes to those including that module. If no classes or # modules match, +nil+ is returned. def locate_many_signed(sgids, options = {}) locate_many sgids.collect { |sgid| SignedGlobalID.parse(sgid, options.slice(:for)) }.compact, options end # Tie a locator to an app. # Useful when different apps collaborate and reference each others' Global IDs. # # The locator can be either a block or a class. # # Using a block: # # GlobalID::Locator.use :foo do |gid| # FooRemote.const_get(gid.model_name).find(gid.model_id) # end # # Using a class: # # GlobalID::Locator.use :bar, BarLocator.new # # class BarLocator # def locate(gid) # @search_client.search name: gid.model_name, id: gid.model_id # end # end def use(app, locator = nil, &locator_block) raise ArgumentError, 'No locator provided. Pass a block or an object that responds to #locate.' unless locator || block_given? URI::GID.validate_app(app) @locators[normalize_app(app)] = locator || BlockLocator.new(locator_block) end private def locator_for(gid) @locators.fetch(normalize_app(gid.app)) { DEFAULT_LOCATOR } end def find_allowed?(model_class, only = nil) only ? Array(only).any? { |c| model_class <= c } : true end def parse_allowed(gids, only = nil) gids.collect { |gid| GlobalID.parse(gid) }.compact.select { |gid| find_allowed?(gid.model_class, only) } end def normalize_app(app) app.to_s.downcase end end private @locators = {} class BaseLocator def locate(gid) gid.model_class.find gid.model_id end def locate_many(gids, options = {}) models_and_ids = gids.collect { |gid| [ gid.model_class, gid.model_id ] } ids_by_model = models_and_ids.group_by(&:first) loaded_by_model = Hash[ids_by_model.map { |model, ids| [ model, find_records(model, ids.map(&:last), ignore_missing: options[:ignore_missing]).index_by { |record| record.id.to_s } ] }] models_and_ids.collect { |(model, id)| loaded_by_model[model][id] }.compact end private def find_records(model_class, ids, options) if options[:ignore_missing] model_class.where(id: ids) else model_class.find(ids) end end end class UnscopedLocator < BaseLocator def locate(gid) unscoped(gid.model_class) { super } end private def find_records(model_class, ids, options) unscoped(model_class) { super } end def unscoped(model_class) if model_class.respond_to?(:unscoped) model_class.unscoped { yield } else yield end end end DEFAULT_LOCATOR = UnscopedLocator.new class BlockLocator def initialize(block) @locator = block end def locate(gid) @locator.call(gid) end def locate_many(gids, options = {}) gids.map { |gid| locate(gid) } end end end end