shioyama/mobility

View on GitHub
lib/mobility/backends/sequel/key_value.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen-string-literal: true
require "set"
require "mobility/util"
require "mobility/backends/sequel"
require "mobility/backends/key_value"

module Mobility
  module Backends
=begin

Implements the {Mobility::Backends::KeyValue} backend for Sequel models.

@note This backend requires the cache to be enabled in order to track
  and store changed translations, since Sequel does not support +build+-type
  methods on associations like ActiveRecord.

=end
    class Sequel::KeyValue
      include Sequel
      include KeyValue
      include Util

      class << self
        # @!group Backend Configuration
        # @option (see Mobility::Backends::KeyValue::ClassMethods#configure)
        # @raise (see Mobility::Backends::KeyValue::ClassMethods#configure)
        # @raise [CacheRequired] if cache is disabled
        def configure(options)
          raise CacheRequired, "Cache required for Sequel::KeyValue backend" if options[:cache] == false
          super
          if type = options[:type]
            options[:association_name] ||= :"#{options[:type]}_translations"
            options[:class_name]       ||= const_get("#{type.capitalize}Translation")
          end
        rescue NameError
          raise ArgumentError, "You must define a Mobility::Sequel::#{type.capitalize}Translation class."
        end
        # @!endgroup

        def build_op(attr, locale)
          QualifiedIdentifier.new(table_alias(attr, locale), value_column, locale, self, attr)
        end

        # @param [Sequel::Dataset] dataset Dataset to prepare
        # @param [Object] predicate Predicate
        # @param [Symbol] locale Locale
        # @return [Sequel::Dataset] Prepared dataset
        def prepare_dataset(dataset, predicate, locale)
          visit(predicate, locale).inject(dataset) do |ds, (attr, join_type)|
            join_translations(ds, attr, locale, join_type)
          end
        end

        # Called from setup block. Can be overridden to customize behaviour.
        def define_one_to_many_association(klass, attributes)
          belongs_to_id     = :"#{belongs_to}_id"
          belongs_to_type   = :"#{belongs_to}_type"

          # Track all attributes for this association, so that we can limit the scope
          # of keys for the association to only these attributes. We need to track the
          # attributes assigned to the association in case this setup code is called
          # multiple times, so we don't "forget" earlier attributes.
          #
          attrs_method_name = :"#{association_name}_attributes"
          association_attributes = (klass.instance_variable_get(:"@#{attrs_method_name}") || []) + attributes
          klass.instance_variable_set(:"@#{attrs_method_name}", association_attributes)

          klass.one_to_many association_name,
            reciprocal:      belongs_to,
            key:             belongs_to_id,
            reciprocal_type: :one_to_many,
            conditions:      { belongs_to_type => klass.to_s, key_column => association_attributes },
            adder:           proc { |translation| translation.update(belongs_to_id => pk, belongs_to_type => self.class.to_s) },
            remover:         proc { |translation| translation.update(belongs_to_id => nil, belongs_to_type => nil) },
            clearer:         proc { send_(:"#{association_name}_dataset").update(belongs_to_id => nil, belongs_to_type => nil) },
            class:           class_name
        end

        # Called from setup block. Can be overridden to customize behaviour.
        def define_save_callbacks(klass, attributes)
          b = self
          callback_methods = Module.new do
            define_method :before_save do
              super()
              send(b.association_name).select { |t| attributes.include?(t.__send__(b.key_column)) && Util.blank?(t.__send__(b.value_column)) }.each(&:destroy)
            end
            define_method :after_save do
              super()
              attributes.each { |attribute| mobility_backends[attribute].save_translations }
            end
          end
          klass.include callback_methods
        end

        # Called from setup block. Can be overridden to customize behaviour.
        def define_after_destroy_callback(klass)
          # Clean up *all* leftover translations of this model, only once.
          b = self
          translation_classes = [class_name, *Mobility::Backends::Sequel::KeyValue::Translation.descendants].uniq
          klass.define_method :after_destroy do
            super()

            @mobility_after_destroy_translation_classes = [] unless defined?(@mobility_after_destroy_translation_classes)
            (translation_classes - @mobility_after_destroy_translation_classes).each do |translation_class|
              translation_class.where(:"#{b.belongs_to}_id" => id, :"#{b.belongs_to}_type" => self.class.name).destroy
            end
            @mobility_after_destroy_translation_classes += translation_classes
          end
        end

        private

        def join_translations(dataset, attr, locale, join_type)
          dataset.join_table(join_type,
                             class_name.table_name,
                             {
                               key_column => attr.to_s,
                               :locale => locale.to_s,
                               :"#{belongs_to}_type" => model_class.name,
                               :"#{belongs_to}_id" => ::Sequel[:"#{model_class.table_name}"][:id]
                             },
                             table_alias: table_alias(attr, locale))
        end

        # @return [Hash] Hash of attribute/join_type pairs
        def visit(predicate, locale)
          case predicate
          when Array
            visit_collection(predicate, locale)
          when QualifiedIdentifier
            visit_sql_identifier(predicate, locale)
          when ::Sequel::SQL::BooleanExpression
            visit_boolean(predicate, locale)
          when ::Sequel::SQL::ComplexExpression
            visit(predicate.args, locale)
          else
            {}
          end
        end

        def visit_boolean(boolean, locale)
          if boolean.op == :IS
            nils, ops = boolean.args.partition(&:nil?)
            if hash = visit(ops, locale)
              join_type = nils.empty? ? :inner : :left_outer
              # TODO: simplify to hash.transform_values { join_type } when
              #   support for Ruby 2.3 is deprecated
              ::Hash[hash.keys.map { |key| [key, join_type] }]
            else
              {}
            end
          elsif boolean.op == :'='
            hash = visit(boolean.args, locale)
            # TODO: simplify to hash.transform_values { :inner } when
            #   support for Ruby 2.3 is deprecated
            ::Hash[hash.keys.map { |key| [key, :inner] }]
          elsif boolean.op == :OR
            hash = boolean.args.map { |op| visit(op, locale) }.
              compact.inject(:merge)
            # TODO: simplify to hash.transform_values { :left_outer } when
            #   support for Ruby 2.3 is deprecated
            ::Hash[hash.keys.map { |key| [key, :left_outer] }]
          else
            visit(boolean.args, locale)
          end
        end

        def visit_collection(collection, locale)
          collection.map { |p| visit(p, locale) }.compact.inject do |hash, visited|
            visited.merge(hash) { |_, old, new| old == :inner ? old : new }
          end
        end

        def visit_sql_identifier(identifier, locale)
          if identifier.backend_class == self && identifier.locale == locale
            { identifier.attribute_name => :left_outer }
          else
            {}
          end
        end
      end

      setup do |attributes, _options, backend_class|
        backend_class.define_one_to_many_association(self, attributes)
        backend_class.define_save_callbacks(self, attributes)
        backend_class.define_after_destroy_callback(self)

        include(mod = Module.new)
        backend_class.define_column_changes(mod, attributes)
      end

      # Returns translation for a given locale, or initializes one if none is present.
      # @param [Symbol] locale
      # @return [Mobility::Backends::Sequel::KeyValue::TextTranslation,Mobility::Backends::Sequel::KeyValue::StringTranslation]
      def translation_for(locale, **)
        translation = model.send(association_name).find { |t| t.__send__(key_column) == attribute && t.locale == locale.to_s }
        translation ||= class_name.new(locale: locale, key_column => attribute)
        translation
      end

      # Saves translation which have been built and which have non-blank values.
      def save_translations
        cache.each_value do |translation|
          next unless present?(translation.__send__ value_column)
          translation.id ? translation.save : model.send("add_#{singularize(association_name)}", translation)
        end
      end

      class CacheRequired < ::StandardError; end

      module Cache
        include KeyValue::Cache

        private

        def translations
          (model.send(association_name) + cache.values).uniq
        end
      end

      class QualifiedIdentifier < ::Sequel::SQL::QualifiedIdentifier
        attr_reader :backend_class, :locale, :attribute_name

        def initialize(table, column, locale, backend_class, attribute_name)
          @backend_class = backend_class
          @locale = locale
          @attribute_name = attribute_name || column
          super(table, column)
        end
      end

      class Translatable < Module
        attr_reader :key_column, :value_column, :belongs_to, :id_column, :type_column

        def initialize(key_column, value_column, belongs_to)
          @key_column = key_column
          @value_column = value_column
          @belongs_to = belongs_to
          @id_column = :"#{belongs_to}_id"
          @type_column = :"#{belongs_to}_type"
        end

        # Strictly these are not "descendants", but to keep terminology
        # consistent with ActiveRecord KeyValue backend.
        def descendants
          @descendants ||= Set.new
        end

        def included(base)
          @descendants ||= Set.new
          @descendants << base

          mod = self
          key_column = mod.key_column
          id_column = mod.id_column
          type_column = mod.type_column

          base.class_eval do
            plugin :validation_helpers

            # Paraphased from sequel_polymorphic gem
            #
            model = underscore(self.to_s)
            plural_model = pluralize(model)
            many_to_one mod.belongs_to,
              reciprocal: plural_model.to_sym,
              reciprocal_type: :many_to_one,
              setter: (proc do |able_instance|
                self[id_column]   = (able_instance.pk if able_instance)
                self[type_column] = (able_instance.class.name if able_instance)
              end),
              dataset: (proc do
                translatable_type = send type_column
                translatable_id   = send id_column
                return if translatable_type.nil? || translatable_id.nil?
                klass = self.class.send(:constantize, translatable_type)
                klass.where(klass.primary_key => translatable_id)
              end),
              eager_loader: (proc do |eo|
                id_map = {}
                eo[:rows].each do |model|
                  model_able_type = model.send type_column
                  model_able_id = model.send id_column
                  model.associations[belongs_to] = nil
                  ((id_map[model_able_type] ||= {})[model_able_id] ||= []) << model if !model_able_type.nil? && !model_able_id.nil?
                end
                id_map.each do |klass_name, id_map|
                  klass = constantize(camelize(klass_name))
                  klass.where(klass.primary_key=>id_map.keys).all do |related_obj|
                    id_map[related_obj.pk].each do |model|
                      model.associations[belongs_to] = related_obj
                    end
                  end
                end
              end)

            define_method :validate do
              super()
              validates_presence [:locale, key_column, id_column, type_column]
              validates_unique   [:locale, key_column, id_column, type_column]
            end
          end
        end
      end
      Translation = Translatable.new(:key, :value, :translatable)

      class TextTranslation < ::Sequel::Model(:mobility_text_translations)
        include Translation
      end

      class StringTranslation < ::Sequel::Model(:mobility_string_translations)
        include Translation
      end
    end

    register_backend(:sequel_key_value, Sequel::KeyValue)
  end
end