require 'active_support/core_ext/enumerable' # For Enumerable#index_by class GlobalID module Locator class InvalidModelIdError < StandardError; end class << self # Takes either a GlobalID or a string that can be turned into a GlobalID # # Options: # * :includes - A Symbol, Array, Hash or combination of them. # The same structure you would pass into a +includes+ method of Active Record. # If present, locate will load all the relationships specified here. # See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations. # * :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 = {}) gid = GlobalID.parse(gid) return unless gid && find_allowed?(gid.model_class, options[:only]) locator = locator_for(gid) if locator.method(:locate).arity == 1 GlobalID.deprecator.warn "It seems your locator is defining the `locate` method only with one argument. Please make sure your locator is receiving the options argument as well, like `locate(gid, options = {})`." locator.locate(gid) else locator.locate(gid, options.except(: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: # * :includes - A Symbol, Array, Hash or combination of them # The same structure you would pass into a includes method of Active Record. # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations # If present, locate_many will load all the relationships specified here. # Note: It only works if all the gids models have that relationships. # * :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: # * :includes - A Symbol, Array, Hash or combination of them # The same structure you would pass into a includes method of Active Record. # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations # If present, locate_signed will load all the relationships specified here. # * :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: # * :includes - A Symbol, Array, Hash or combination of them # The same structure you would pass into a includes method of Active Record. # @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations # If present, locate_many_signed will load all the relationships specified here. # Note: It only works if all the gids models have that relationships. # * :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, options| # 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, options = {}) # @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, options = {}) return unless model_id_is_valid?(gid) model_class = gid.model_class model_class = model_class.includes(options[:includes]) if options[:includes] model_class.find gid.model_id end def locate_many(gids, options = {}) ids_by_model = Hash.new { |hash, key| hash[key] = [] } gids.each do |gid| next unless model_id_is_valid?(gid) ids_by_model[gid.model_class] << gid.model_id end records_by_model_name_and_id = {} ids_by_model.each do |model, ids| records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes]) records_by_id = records.index_by do |record| record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s end records_by_model_name_and_id[model.name] = records_by_id end gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] } end private def find_records(model_class, ids, options) model_class = model_class.includes(options[:includes]) if options[:includes] if options[:ignore_missing] model_class.where(model_class.primary_key => ids) else model_class.find(ids) end end def model_id_is_valid?(gid) Array(gid.model_id).size == Array(gid.model_class.primary_key).size end end class UnscopedLocator < BaseLocator def locate(gid, options = {}) 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, options = {}) @locator.call(gid, options) end def locate_many(gids, options = {}) gids.map { |gid| locate(gid, options) } end end end end