shioyama/mobility

View on GitHub
lib/mobility/plugins/active_model/dirty.rb

Summary

Maintainability
A
45 mins
Test Coverage
# frozen-string-literal: true

module Mobility
  module Plugins
    module ActiveModel
=begin

Dirty tracking for models which include the +ActiveModel::Dirty+ module.

Assuming we have an attribute +title+, this module will add support for the
following methods:
- +title_changed?+
- +title_change+
- +title_was+
- +title_will_change!+
- +title_previously_changed?+
- +title_previous_change+
- +restore_title!+

The following methods are also patched to work with translated attributes:
- +changed_attributes+
- +changes+
- +changed+
- +changed?+
- +previous_changes+
- +clear_attribute_changes+
- +restore_attributes+

In addition, the following ActiveModel attribute handler methods are also
patched to work with translated attributes:
- +attribute_changed?+
- +attribute_previously_changed?+
- +attribute_was+

(When using these methods, you must pass the attribute name along with its
locale suffix, so +title_en+, +title_pt_br+, etc.)

Other methods are also included for ActiveRecord models, see documentation on
the ActiveRecord dirty plugin for more information.

@see http://api.rubyonrails.org/classes/ActiveModel/Dirty.html Rails documentation for Active Model Dirty module

=end
      module Dirty
        extend Plugin

        requires :dirty, include: false

        initialize_hook do
          if options[:dirty]
            define_dirty_methods(names)
            include dirty_handler_methods
          end
        end

        included_hook do |klass, backend_class|
          raise TypeError, "#{name} should include ActiveModel::Dirty to use the active_model plugin" unless active_model_dirty_class?(klass)

          if options[:dirty]
            private_methods = InstanceMethods.instance_methods & klass.private_instance_methods
            klass.include InstanceMethods
            klass.class_eval { private(*private_methods) }

            backend_class.include BackendMethods
          end
        end

        private

        # Overridden in AR::Dirty plugin to define a different HandlerMethods module
        def dirty_handler_methods
          HandlerMethods
        end

        def active_model_dirty_class?(klass)
          klass.ancestors.include?(::ActiveModel::Dirty)
        end

        def define_dirty_methods(attribute_names)
          attribute_names.each do |name|
            dirty_handler_methods.each_pattern(name) do |method_name, attribute_method|
              define_method(method_name) do |*args|
                # for %s_changed?(from:, to:) pattern
                if (kwargs = args.last).is_a?(Hash)
                  mutations_from_mobility.send(attribute_method, Dirty.append_locale(name), *args[0,-1], **kwargs)
                else
                  mutations_from_mobility.send(attribute_method, Dirty.append_locale(name), *args)
                end
              end
            end

            define_method "restore_#{name}!" do
              locale_accessor = Dirty.append_locale(name)
              if mutations_from_mobility.attribute_changed?(locale_accessor)
                __send__("#{name}=", mutations_from_mobility.attribute_was(locale_accessor))
                mutations_from_mobility.restore_attribute!(locale_accessor)
              end
            end
          end

          # This private method override is necessary to make
          # +restore_attributes+ (which is public) work with translated
          # attributes.
          define_method :restore_attribute! do |attr|
            attribute_names.include?(attr.to_s) ? send("restore_#{attr}!") : super(attr)
          end
          private :restore_attribute!
        end

        def self.append_locale(attr_name)
          Mobility.normalize_locale_accessor(attr_name)
        end

        # Module builder which mimics dirty method handlers on a given dirty class.
        # Used to mimic ActiveModel::Dirty and ActiveRecord::Dirty, which have
        # similar but slightly different sets of handler methods. Doing it this
        # way with introspection allows us to support basically all AR/AM
        # versions without changes here.
        class HandlerMethodsBuilder < Module
          attr_reader :klass

          PATTERNS_WITH_KWARGS =
            %w[
              %s_changed?
              %s_previously_changed?
              will_save_change_to_%s?
              saved_change_to_%s?
            ].freeze

          # @param [Class] klass Dirty class to mimic
          def initialize(klass)
            @klass = klass
            define_handler_methods
          end

          def each_pattern(attr_name)
            patterns.each do |pattern|
              yield pattern % attr_name, pattern % 'attribute'
            end
          end

          def define_handler_methods
            public_patterns.each do |pattern|
              method_name = pattern % 'attribute'

              kwargs = PATTERNS_WITH_KWARGS.include?(pattern) ? ', **kwargs' : ''
              module_eval <<-EOM, __FILE__, __LINE__ + 1
              def #{method_name}(attr_name, *rest#{kwargs})
                if (mutations_from_mobility.attribute_changed?(attr_name) ||
                    mutations_from_mobility.attribute_previously_changed?(attr_name))
                  mutations_from_mobility.send(#{method_name.inspect}, attr_name, *rest#{kwargs})
                else
                  super
                end
              end
              EOM
            end
          end

          # Get method suffixes. Creating an object just to get the list of
          # suffixes is simplest given they change from Rails version to version.
          def patterns
            @patterns ||=
              begin
                # Method name changes in Rails 7.1
                attribute_method_patterns = klass.respond_to?(:attribute_method_patterns) ?
                  klass.attribute_method_patterns :
                  klass.attribute_method_matchers
                attribute_method_patterns.map { |p| "#{p.prefix}%s#{p.suffix}" } - excluded_patterns
              end
          end

          private

          def public_patterns
            @public_patterns ||= patterns.select do |p|
              klass.public_method_defined?(p % 'attribute')
            end
          end

          def excluded_patterns
            ['%s', 'restore_%s!']
          end
        end

        # Module which defines generic handler methods like
        # +attribute_changed?+ that are patched to work with translated
        # attributes.
        HandlerMethods = HandlerMethodsBuilder.new(Class.new { include ::ActiveModel::Dirty })

        module InstanceMethods
          def changed_attributes
            super.merge(mutations_from_mobility.changed_attributes)
          end

          def changes_applied
            super
            mutations_from_mobility.finalize_changes
          end

          def changes
            super.merge(mutations_from_mobility.changes)
          end

          def changed
            # uniq is required for Rails < 6.0
            (super + mutations_from_mobility.changed).uniq
          end

          def changed?
            super || mutations_from_mobility.changed?
          end

          def previous_changes
            super.merge(mutations_from_mobility.previous_changes)
          end

          def clear_changes_information
            @mutations_from_mobility = nil
            super
          end

          def clear_attribute_changes(attr_names)
            attr_names.each { |attr_name| mutations_from_mobility.restore_attribute!(attr_name) }
            super
          end

          private

          def mutations_from_mobility
            @mutations_from_mobility ||= MobilityMutationTracker.new(self)
          end
        end

        # @note Seriously, I really don't want to reproduce all of
        #   ActiveModel::Dirty here, but having fought with upstream changes
        #   many many times I finally decided it's more future-proof to just
        #   re-implement the stuff we need here, to avoid weird breakage.
        #
        #   Although this is somewhat ugly, at least it's explicit and since
        #   it's self-defined (rather than hooking into fickle private methods
        #   in Rails), it won't break all of a sudden. We just need to ensure
        #   that specs are up-to-date with the latest weird dirty method
        #   pattern Rails has decided to support.
        class MobilityMutationTracker
          OPTION_NOT_GIVEN = Object.new

          attr_reader :previous_changes

          def initialize(model)
            @model = model
            @current_changes = {}.with_indifferent_access
            @previous_changes = {}.with_indifferent_access
          end

          def finalize_changes
            @previous_changes = changes
            @current_changes = {}.with_indifferent_access
          end

          def changed
            attr_names.select { |attr_name| attribute_changed?(attr_name) }
          end

          def changed_attributes
            attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
              if attribute_changed?(attr_name)
                result[attr_name] = attribute_was(attr_name)
              end
            end
          end

          def changes
            attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
              if change = attribute_change(attr_name)
                result.merge!(attr_name => change)
              end
            end
          end

          def changed?
            attr_names.any? { |attr| attribute_changed?(attr) }
          end

          def attribute_change(attr_name)
            if attribute_changed?(attr_name)
              [attribute_was(attr_name), fetch_value(attr_name)]
            end
          end

          def attribute_previous_change(attr_name)
            previous_changes[attr_name]
          end

          def attribute_previously_was(attr_name)
            if attribute_previously_changed?(attr_name)
              # Calling +first+ here fetches the value before change from the
              # hash.
              previous_changes[attr_name].first
            end
          end

          def attribute_changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
            current_changes.include?(attr_name) &&
              (OPTION_NOT_GIVEN == from || attribute_was(attr_name) == from) &&
              (OPTION_NOT_GIVEN == to || fetch_value(attr_name) == to)
          end

          def attribute_previously_changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
            previous_changes.include?(attr_name) &&
              (OPTION_NOT_GIVEN == from || attribute_previous_change(attr_name).first == from) &&
              (OPTION_NOT_GIVEN == to || attribute_previous_change(attr_name).second == to)
          end

          def attribute_was(attr_name)
            if attribute_changed?(attr_name)
              current_changes[attr_name]
            else
              fetch_value(attr_name)
            end
          end

          def attribute_will_change!(attr_name)
            current_changes[attr_name] = fetch_value(attr_name) unless current_changes.include?(attr_name)
          end

          def restore_attribute!(attr_name)
            current_changes.delete(attr_name)
          end

          # These are for ActiveRecord, but we'll define them here.
          alias_method :saved_change_to_attribute?,     :attribute_previously_changed?
          alias_method :saved_change_to_attribute,      :attribute_previous_change
          alias_method :attribute_before_last_save,     :attribute_previously_was
          alias_method :will_save_change_to_attribute?, :attribute_changed?
          alias_method :attribute_change_to_be_saved,   :attribute_change
          alias_method :attribute_in_database,          :attribute_was

          private
          attr_reader :model, :current_changes

          def attr_names
            current_changes.keys
          end

          def fetch_value(attr_name)
            model.__send__(attr_name)
          end
        end

        module BackendMethods
          # @!group Backend Accessors
          # @!macro backend_writer
          # @param [Hash] options
          def write(locale, value, **options)
            locale_accessor = Mobility.normalize_locale_accessor(attribute, locale)
            if model.changed_attributes.has_key?(locale_accessor) && model.changed_attributes[locale_accessor] == value
              mutations_from_mobility.restore_attribute!(locale_accessor)
            elsif read(locale, **options.merge(locale: true)) != value
              mutations_from_mobility.attribute_will_change!(locale_accessor)
            end
            super
          end
          # @!endgroup

          private

          def mutations_from_mobility
            model.send(:mutations_from_mobility)
          end
        end
      end
    end

    register_plugin(:active_model_dirty, ActiveModel::Dirty)
  end
end