# frozen_string_literal: true module Rails # :nodoc: module GraphQL # :nodoc: module Helpers # :nodoc: # Helper module allowing leaf values to be collected direct from # ActiveRecord. It also helps AR Adapters to define the necessary # methods and settings to operate with this extractor. # # TODO: Implement ActiveRecord serialization module LeafFromAr def self.extended(other) # Defines which type exactly represents the scalar type on the # ActiveRecord adapter for casting purposes other.class_attribute :ar_adapter_type, instance_writer: false, default: {} # A list of ActiveRecord aliases per adapter to skip casting other.class_attribute :ar_adapter_aliases, instance_writer: false, default: (Hash.new { |h, k| h[k] = Set.new }) end # Identifies the ActiveRecord type (actually it uses the # ActiveModel::Type#type method, but ActiveRecord uses the same # reference) of this object. When mismatching, the query must cast the # value. def ar_type :string end # If a class extend this module, we assume that it can serialize # attributes direct from the query point of view def from_ar?(ar_object, attribute) ar_object&.has_attribute?(attribute) end # Returns an Arel object that represents how this object is serialized # direct from the query # # This happens in 3 parts # # 1. It finds a method to get the arel representation of the # accessor of the given attribute # 2. If the attribute type mismatch the ar type or any of its aliases, # then invoke a adapter-specific cast # 3. If necessary, adapters can describe a specific way to serialize # the arel attribute to ensure equivalency # # ==== Example # # Lets imagine a scenario where the adapter is the +PostgreSQL+, the # attribute is a +data+ field with +enum+ type from a +sample+ table and # the result must be a binary base64 data: # # 1. Sample.arel_attribute(:data) # # => "samples"."data" # 2. Arel::Nodes::NamedFunction.new('CAST', [arel_object, Arel.sql('text')]) # # => CAST("samples"."data" AS text) # 3. Arel::Nodes::NamedFunction.new('ENCODE', [arel_object, Arel.sql("'base64")]) # # => ENCODE(CAST("samples"."data" AS text), 'base64') def from_ar(ar_object, attribute) key = adapter_key(ar_object) method_name = "from_#{key}_adapter" method_name = 'from_abstract_adapter' unless respond_to?(method_name, true) arel_object = send(method_name, ar_object, attribute) return if arel_object.nil? arel_object = try("cast_#{key}_attribute", arel_object, ar_adapter_type[key]) \ unless match_ar_type?(ar_object, attribute, key) return if arel_object.nil? method_name = "#{key}_serialize" respond_to?(method_name, true) ? try(method_name, arel_object) : arel_object end # Helper method that should be used for ActiveRecord adapters in order # to provide the correct methods and settings for retrieving the given # value direct from a query. # # ==== Options # # * :fetch - A specific function to build the arel for fetching the attribute # * :cast - A function to cast an attribute to the correct value # * :type - A symbol that represents the exactly database type that matches # the ActiveRecord type (ie. :varchar for :string) # * :aliases - An array of AR aliases that for the adapter they are # equivalent def define_for(adapter, **settings) adapter = ar_adapters[adapter] if ar_adapters.key?(adapter) raise ArgumentError, <<~MSG.squish unless ar_adapters.values.include?(adapter) The given #{adapter.inspect} adapter is not a valid option. The valid options are: #{ar_adapters.to_a.flatten.map(&:inspect).to_sentence}. MSG define_singleton_method("from_#{adapter}_adapter", &settings[:fetch]) \ if settings.key?(:fetch) define_singleton_method("cast_#{adapter}_attribute", &settings[:cast]) \ if settings.key?(:cast) ar_adapter_type[adapter] = settings[:type] if settings.key?(:type) ar_adapter_aliases[adapter] += Array.wrap(settings[:aliases]) \ if settings.key?(:aliases) end protected # A pretty abstract way to access an attribute from an ActiveRecord # object using arel def from_abstract_adapter(ar_object, attribute) ar_object.arel_attribute(attribute) end # Change the ActiveRecord type of the given object def set_ar_type!(type) redefine_singleton_method(:ar_type) { type } end private # Return the list of defined ActiveRecord Adapters def ar_adapters GraphQL.config.ar_adapters end # Given the ActiveRecord Object, find the key to compound the method # name for the specific attribute accessor def adapter_key(ar_object) ar_adapters[ar_object.connection.adapter_name] end # Check if the GraphQL ar type of this object matches the # ActiveRecord type or any alias for the specific adapter def match_ar_type?(ar_object, attribute, adapter_key) attr_type = ar_object.columns_hash[attribute.to_s].type ar_type.eql?(attr_type) || ar_adapter_aliases[adapter_key].include?(attr_type) end end end end end