kaspernj/acts_as_customized_attributes

View on GitHub
lib/acts_as_customized_attributes/class_methods.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# frozen_string_literal: true
module ActsAsCustomizedAttributes::ClassMethods
  def acts_as_customized_attributes(_args = {})
    $aaca_class_name_key = "#{name}DataKey"
    $aaca_class_name_data = "#{name}Data"
    $original_class_name = name

    class_data_key = Class.new(ActiveRecord::Base) do
      if respond_to?(:set_table_name)
        set_table_name $aaca_class_name_key.tableize.tr("/", "_")
      else
        self.table_name = $aaca_class_name_key.tableize.tr("/", "_")
      end

      after_create :add_to_cache
      after_update :add_to_cache
      after_destroy :remove_from_cache
      before_update :remove_from_cache

      validates_presence_of :name
      validate :validate_unique_name

      has_many :data, class_name: $aaca_class_name_data.to_s, table_name: $aaca_class_name_data.tableize.to_s, foreign_key: "data_key_id", dependent: :destroy

      def self.id_for_name(name)
        cache_name_to_id.fetch(name.to_s)
      end

      def self.name_for_id(id)
        cache_name_to_id.key(id) || raise(KeyError, "No such ID: #{id}")
      end

      def self.cache_name_to_id
        reset_cache_name_to_id if Rails.env.test? # Always reset when testing to avoid caching problems
        update_cache_name_to_id unless @cache_name_to_id
        @_cache_name_to_id
      end

      def self.reset_cache_name_to_id
        @_cache_name_to_id = nil
      end

      # Initializes / reloads the cache.
      def self.update_cache_name_to_id
        @_cache_name_to_id = {}
        find_each do |data_key|
          @_cache_name_to_id[data_key.name] = data_key.id
        end
      end

                     private

      def add_to_cache
        self.class.cache_name_to_id[name.to_s] = id
      end

      def remove_from_cache
        self.class.cache_name_to_id.delete(name_was)
      end

      def validate_unique_name
        id_exists = self.class.id_for_name(name)

        errors.add :name, :taken if id_exists != id
      rescue KeyError
        # No problem.
      end
    end

    class_data = Class.new(ActiveRecord::Base) do
      if respond_to?(:set_table_name)
        set_table_name $aaca_class_name_data.tableize.tr("/", "_")
      else
        self.table_name = $aaca_class_name_data.tableize.tr("/", "_")
      end

      belongs_to :resource, class_name: $original_class_name.to_s
      belongs_to :data_key, class_name: $aaca_class_name_key.to_s

      validate :associated_resource_and_data_key

      def self.key_class=(key_class)
        @key_class = key_class
      end

      def self.key_class
        @key_class
      end

                 private

      def associated_resource_and_data_key
        return errors.add :data_key_id, "not valid" unless data_key_id?

        if !resource_id? || !resource
          return errors.add :resource_id, "not associated"
        end

        begin
          self.class.key_class.name_for_id(data_key_id)
        rescue KeyError
          errors.add :data_key_id, "doesn't exist"
        end
      end
    end

    # Set the constants
    constant_to_set_on = Object
    constant_name_parts = $aaca_class_name_key.split("::")
    constant_name_parts.each_with_index do |constant_name_part, index|
      next if index >= (constant_name_parts.length - 1)
      constant_to_set_on = constant_to_set_on.const_get(constant_name_part)
    end

    data_constant_name = $aaca_class_name_data.split("::").last

    constant_to_set_on.const_set(constant_name_parts.last, class_data_key)
    constant_to_set_on.const_set(data_constant_name, class_data)

    class_data.key_class = Object.const_get($aaca_class_name_key)
    # End of setting constants

    include ActsAsCustomizedAttributes::InstanceMethods

    has_many :data, class_name: "#{name}Data", foreign_key: "resource_id", dependent: :destroy

    scope :join_customized_attribute, lambda { |key|
      key_id = aaca_key_class.id_for_name(key)
      join_name = "customized_attribute_#{key}"
      joins("LEFT JOIN #{aaca_data_class.table_name} AS #{join_name} ON resource_id = #{table_name}.id AND #{join_name}.data_key_id = '#{key_id}'")
    }

    scope :where_customized_attribute, lambda { |key, value|
      join_name = "customized_attribute_#{key}"
      join_customized_attribute(key).where("#{join_name}.value = ?", value)
    }
  end

  def create_customized_attributes!
    migration_class.new.migrate(:up)
  end

  def drop_customized_attributes!
    migration_class.new.migrate(:down)
  end

  def aaca_key_class
    @aaca_key_class ||= Object.const_get("#{name}DataKey")
  end

  def aaca_data_class
    @aaca_data_class ||= Object.const_get("#{name}Data")
  end

private

  def migration_class
    $acts_as_customized_attributes_keys_table_name = "#{name.tableize.singularize.tr("/", "_")}_data_keys"
    $acts_as_customized_attributes_table_name = "#{name.tableize.singularize.tr("/", "_")}_data"
    $table_name = table_name

    Class.new(ActiveRecord::Migration) do
      def up
        create_table $acts_as_customized_attributes_keys_table_name do |t|
          t.string :name
          t.string :title
          t.timestamps
        end

        add_index $acts_as_customized_attributes_keys_table_name, :name, unique: true

        create_table $acts_as_customized_attributes_table_name do |t|
          t.belongs_to :resource, index: true
          t.belongs_to :data_key, index: true
          t.string :value
          t.timestamps
        end

        add_foreign_key $acts_as_customized_attributes_table_name, $acts_as_customized_attributes_keys_table_name, column: "data_key_id", on_delete: :cascade
        add_foreign_key $acts_as_customized_attributes_table_name, $table_name, column: "resource_id", on_delete: :cascade
        add_index $acts_as_customized_attributes_table_name, [:data_key_id, :resource_id], unique: true
      end

      def down
        drop_table $acts_as_customized_attributes_table_name
        drop_table $acts_as_customized_attributes_keys_table_name
      end
    end
  end
end