shioyama/mobility

View on GitHub
lib/mobility/backends/active_record/table.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen-string-literal: true
require "mobility/backends/active_record"
require "mobility/backends/table"

module Mobility
  module Backends
=begin

Implements the {Mobility::Backends::Table} backend for ActiveRecord models.

To generate a translation table for a model +Post+, you can use the included
+mobility:translations+ generator:

  rails generate mobility:translations post title:string content:text

This will create a migration which can be run to create the translation table.
If the translation table already exists, it will create a migration adding
columns to that table.

@example Model with table backend
  class Post < ApplicationRecord
    extend Mobility
    translates :title, backend: :table
  end

  post = Post.create(title: "foo")
  #<Post:0x00... id: 1>

  post.title
  #=> "foo"

  post.translations
  #=> [#<Post::Translation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  title: "foo">]

  Post::Translation.first
  #=> #<Post::Translation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  title: "foo">

@example Model with multiple translation tables
  class Post < ActiveRecord::Base
    extend Mobility
    translates :title,   backend: :table, table_name: :post_title_translations,   association_name: :title_translations
    translates :content, backend: :table, table_name: :post_content_translations, association_name: :content_translations
  end

  post = Post.create(title: "foo", content: "bar")
  #<Post:0x00... id: 1>

  post.title
  #=> "foo"

  post.content
  #=> "bar"

  post.title_translations
  #=> [#<Post::TitleTranslation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  title: "foo">]

  post.content_translations
  #=> [#<Post::ContentTranslation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  content: "bar">]

  Post::TitleTranslation.first
  #=> #<Post::TitleTranslation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  title: "foo">

  Post::ContentTranslation.first
  #=> #<Post::ContentTranslation:0x00...
  #  id: 1,
  #  locale: "en",
  #  post_id: 1,
  #  title: "bar">
=end
    class ActiveRecord::Table
      include ActiveRecord
      include Table

      class << self
        # @!group Backend Configuration
        # @option options [Symbol] association_name (:translations)
        #   Name of association method
        # @option options [Symbol] table_name Name of translation table
        # @option options [Symbol] foreign_key Name of foreign key
        # @option options [Symbol] subclass_name (:Translation) Name of subclass
        #   to append to model class to generate translation class
        def configure(options)
          table_name = model_class.table_name
          options[:table_name]  ||= "#{table_name.singularize}_translations"
          options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key
          if (association_name = options[:association_name]).present?
            options[:subclass_name] ||= association_name.to_s.singularize.camelize.freeze
          else
            options[:association_name] = :translations
            options[:subclass_name] ||= :Translation
          end
          %i[foreign_key association_name subclass_name table_name].each { |key|
          if options[key].is_a?(Enumerable)
              options[key] = options[key].map!(&:to_sym)
            else
              options[key] = options[key].to_sym
            end
          }
        end
        # @!endgroup

        # @param [String] attr Attribute name
        # @param [Symbol] _locale Locale
        # @return [Mobility::Plugins::Arel::Attribute] Arel node for column on translation table
        def build_node(attr, locale)
          aliased_table = model_class.const_get(subclass_name).arel_table.alias(table_alias(locale))
          Plugins::Arel::Attribute.new(aliased_table, attr, locale, self)
        end

        # Joins translations using either INNER/OUTER join appropriate to the
        # query.
        # @param [ActiveRecord::Relation] relation Relation to scope
        # @param [Object] predicate Arel predicate
        # @param [Symbol] locale (Mobility.locale) Locale
        # @option [Boolean] invert
        # @return [ActiveRecord::Relation] relation Relation with joins applied (if needed)
        def apply_scope(relation, predicate, locale = Mobility.locale, invert: false)
          visitor = Visitor.new(self, locale)
          if join_type = visitor.accept(predicate)
            join_type &&= Visitor::INNER_JOIN if invert
            join_translations(relation, locale, join_type)
          else
            relation
          end
        end

        private

        def join_translations(relation, locale, join_type)
          return relation if already_joined?(relation, locale, join_type)
          m = model_class.arel_table
          t = model_class.const_get(subclass_name).arel_table.alias(table_alias(locale))
          relation.joins(m.join(t, join_type).
                         on(t[foreign_key].eq(m[model_class.primary_key] || m[:id]).
                            and(t[:locale].eq(locale))).join_sources)
        end

        def already_joined?(relation, locale, join_type)
          if join = get_join(relation, locale)
            return true if (join_type == Visitor::OUTER_JOIN) || (Visitor::INNER_JOIN === join)
            relation.joins_values -= [join]
          end
          false
        end

        def get_join(relation, locale)
          relation.joins_values.find { |v| (::Arel::Nodes::Join === v) && (v.left.name == table_alias(locale).to_s) }
        end
      end

      # Internal class used to visit all nodes in a predicate clause and
      # return a single join type required for the predicate, or nil if no
      # join is required. (Similar to the KeyValue Visitor class.)
      #
      # Example:
      #
      #   class Post < ApplicationRecord
      #     extend Mobility
      #     translates :title, :content, backend: :table
      #   end
      #
      #   backend_class = Post.mobility_backend_class(:title)
      #   visitor = Mobility::Backends::ActiveRecord::Table::Visitor.new(backend_class, :en)
      #
      #   title   = backend_class.build_node("title", :en)   # arel node for title
      #   content = backend_class.build_node("content", :en) # arel node for content
      #
      #   visitor.accept(title.eq(nil).and(content.eq(nil)))
      #   #=> Arel::Nodes::OuterJoin
      #
      #   visitor.accept(title.eq("foo").and(content.eq(nil)))
      #   #=> Arel::Nodes::InnerJoin
      #
      # In the first case, both attributes are matched against nil values, so
      # we need an OUTER JOIN. In the second case, one attribute is matched
      # against a non-nil value, so we can use an INNER JOIN.
      #
      class Visitor < Plugins::Arel::Visitor
        private

        def visit_Arel_Nodes_Equality(object)
          nils, nodes = [object.left, object.right].partition(&:nil?)
          if nodes.any?(&method(:visit))
            nils.empty? ? INNER_JOIN : OUTER_JOIN
          end
        end

        def visit_collection(objects)
          objects.map { |obj|
            visit(obj).tap { |visited| return visited if visited == INNER_JOIN }
          }.compact.first
        end
        alias :visit_Array :visit_collection

        # If either left or right is an OUTER JOIN (predicate with a NULL
        # argument) OR we are combining this with anything other than a
        # column on the same translation table, we need to OUTER JOIN
        # here. The *only* case where we can use an INNER JOIN is when we
        # have predicates like this:
        #
        #   table.attribute1 = 'something' OR table.attribute2 = 'somethingelse'
        #
        # Here, both columns are on the same table, and both are non-nil, so
        # we can safely INNER JOIN. This is pretty subtle, think about it.
        #
        def visit_Arel_Nodes_Or(object)
          visited = [object.left, object.right].map(&method(:visit))
          if visited.all? { |v| INNER_JOIN == v }
            INNER_JOIN
          elsif visited.any?
            OUTER_JOIN
          end
        end

        def visit_Mobility_Plugins_Arel_Attribute(object)
          # We compare table names here to ensure that attributes defined on
          # different backends but the same table will correctly get an OUTER
          # join when required. Use options[:table_name] here since we don't
          # know if the other backend has a +table_name+ option accessor.
          (backend_class.table_name == object.backend_class.options[:table_name]) &&
            (locale == object.locale) && OUTER_JOIN || nil
        end
      end

      setup do |_attributes, options|
        association_name = options[:association_name]
        subclass_name    = options[:subclass_name]

        translation_class =
          if self.const_defined?(subclass_name, false)
            const_get(subclass_name, false)
          else
            const_set(subclass_name, Class.new(Translation))
          end

        translation_class.table_name = options[:table_name]

        has_many association_name,
          class_name:  translation_class.name,
          foreign_key: options[:foreign_key],
          dependent:   :destroy,
          autosave:    true,
          inverse_of:  :translated_model,
          extend:      TranslationsHasManyExtension

        translation_class.belongs_to :translated_model,
          class_name:  name,
          foreign_key: options[:foreign_key],
          inverse_of:  association_name,
          touch: true

        before_save do
          required_attributes = translation_class.attribute_names.select { |name| self.class.mobility_attribute?(name) }
          send(association_name).destroy_empty_translations(required_attributes)
        end

        module_name = "MobilityArTable#{association_name.to_s.camelcase}"
        unless const_defined?(module_name)
          dupable = Module.new do
            define_method :initialize_dup do |source|
              super(source)
              self.send("#{association_name}=", source.send(association_name).map(&:dup))

              # for cache
              # FIXME: do this better
              if options[:cache]
                instance_variable_set(:"@__mobility_#{association_name}_cache", {})
              end
            end
          end
          include const_set(module_name, dupable)
        end
      end

      # Returns translation for a given locale, or builds one if none is present.
      # @param [Symbol] locale
      def translation_for(locale, **)
        translation = translations.in_locale(locale)
        translation ||= translations.build(locale: locale)
        translation
      end

      module TranslationsHasManyExtension
        # Returns translation in a given locale, or nil if none exist
        # @param [Symbol, String] locale
        def in_locale(locale)
          locale = locale.to_s
          find { |t| t.locale == locale }
        end

        # Destroys translations with all empty values
        def destroy_empty_translations(required_attributes)
          empty_translations = select{ |t| required_attributes.map(&t.method(:send)).none? }
          destroy(empty_translations) if empty_translations.any?
        end
      end

      # Subclassed dynamically to generate translation class.
      class Translation < ::ActiveRecord::Base
        self.abstract_class = true
        validates :locale, presence: true
      end
    end

    register_backend(:active_record_table, ActiveRecord::Table)
  end
end