artirix/artirix_data_models

View on GitHub
lib/artirix_data_models/model.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# Responsibilities
# ================
#
# 1. ActiveModel compliant (to_param, valid?, save...)
# 2. Attributes (on initialise, getters and private setters)
# 3. Automatic timestamp attribute attributes definition (_timestamp)
# 4. Definition of Primary Key
# 5. Cache key (calculation of cache key based on minimum information)
# 6. Partial mode (reload, automatic reload when accessing an unavailable attribute)
#   6.1 partial mode - Reload with new data hash
#   6.2 partial mode - Check if in partial mode or in full mode
#   6.3 partial mode - reload using DAO
# 7. Rails Model Param based on Primary Key (for URLs and such)
#
module ArtirixDataModels
  module Model
    extend ActiveSupport::Concern

    included do
      include CompleteModel # by default
    end

    module Errors
      class ReadOnlyModelError < StandardError
      end
    end

    module WithoutDefaultAttributes
      extend ActiveSupport::Concern

      included do
        include ActiveModelCompliant
        include Attributes
        include PrimaryKey
        include WithDAO
        include CacheKey
        include PartialMode
      end
    end

    module CompleteModel
      extend ActiveSupport::Concern

      included do
        include ActiveModelCompliant
        include Attributes
        include PrimaryKey
        include WithDAO
        include CacheKey
        include PartialMode

        # after Attributes and PartialMode
        include Attributes::WithDefaultAttributes
      end
    end

    module OnlyData
      extend ActiveSupport::Concern

      included do
        include ActiveModelCompliant
        include Attributes
        include Attributes::OnlyData
      end
    end

    module PublicWriters
      extend ActiveSupport::Concern

      included do
        include Attributes::PublicWriters
      end
    end

    module WithBaseEntity
      extend ActiveSupport::Concern

      included do
        attr_accessor :base_entity
      end
    end

    module ActiveModelCompliant
      extend ActiveSupport::Concern

      included do
        include ActiveModel::Conversion
        extend ActiveModel::Naming
      end

      def save
        raise Errors::ReadOnlyModelError
      end

      def persisted?
        true
      end

      def valid?
        true
      end

      def new_record?
        false
      end

      def destroyed?
        false
      end

      def errors
        obj = Object.new

        def obj.[](key)
          []
        end

        def obj.full_messages()
          []
        end

        obj
      end

      module ClassMethods
        def active_model_compliant?
          true
        end
      end
    end

    module Attributes
      extend ActiveSupport::Concern

      included do
        include KeywordInit
        include Inspectable
      end

      def self.direct_getter_method_name(attribute)
        "_get_#{attribute}"
      end

      def data_hash
        Hash[self.class.all_defined_attributes.map { |at| [at, send(Attributes.direct_getter_method_name(at))] }]
      end

      def compact_data_hash
        data_hash.reject { |_, v| v.nil? }
      end

      module ClassMethods
        def attribute(*attributes)
          options = attributes.extract_options!
          attributes.each { |attribute| _define_attribute attribute, options }
        end

        def attribute_config
          @attribute_config ||= AttributeConfig.new
        end

        def defined_attributes
          attribute_config.attributes
        end

        # deal with model inheritance
        def all_defined_attributes
          attribute_config.all_attributes
        end

        def inherited(child_class)
          child_class.attribute_config.parent_attribute_config = attribute_config
        end

        def writer_visibility
          @writer_visibility ||= :private
        end

        def writer_visibility=(visibility)
          raise InvalidArgumentError, "Invalid visibility #{visibility.inspect}" unless [:public, :private, :protected].include? visibility
          @writer_visibility = visibility
        end

        private
        def _define_attribute(attribute, options)
          at = attribute.to_sym
          _define_getter(at, options)
          _define_presence(at, options)
          _define_writer(at, options)

          attribute_config.add_attribute at
        end

        def _define_writer(attribute, options)
          skip_option = Array(options.fetch(:skip, []))
          return nil if skip_option.include?(:writer) || skip_option.include?(:setter)

          vis = options.fetch(:writer_visibility, writer_visibility)

          attr_writer attribute
          writer = "#{attribute}="

          if vis == :private
            private writer
          elsif vis == :protected
            protected writer
          end

          writer
        end

        def _define_presence(attribute, options)
          skip_option = Array(options.fetch(:skip, []))
          return nil if skip_option.include?(:presence) || skip_option.include?(:predicate)

          presence_method = "#{attribute}?"
          define_method presence_method do
            send(attribute).present?
          end
        end

        def _define_getter(attribute, options)
          skip_option = Array(options.fetch(:skip, []))
          return nil if skip_option.include?(:reader) || skip_option.include?(:getter)

          variable_name = "@#{attribute}"
          dir_get_name = Attributes.direct_getter_method_name(attribute)
          reader = attribute.to_s

          define_method dir_get_name do
            instance_variable_get variable_name
          end

          define_method(reader) do
            val = send dir_get_name
            if val.nil?
              nil_attribute(attribute)
            else
              val
            end
          end

          vis = options.fetch(:reader_visibility, :public)

          if vis == :private
            private reader
          elsif vis == :protected
            protected reader
          end

        end
      end

      module PublicWriters
        extend ActiveSupport::Concern
        included do
          self.writer_visibility = :public
        end
      end

      module OnlyData
        # return nil as it is, we do not have DAO for an OnlyData model
        def nil_attribute(_)
          nil
        end
      end

      module WithDefaultAttributes
        extend ActiveSupport::Concern

        DEFAULT_ATTRIBUTES = [
          :_timestamp,
          :_score,
          :_type,
          :_index,
          :_id,
        ].freeze

        ATTRIBUTES_ALWAYS_IN_PARTIAL_MODE = [
          :_timestamp,
        ].freeze

        included do
          WithDefaultAttributes.default_attribute_names.each do |at|
            attribute at
          end

          WithDefaultAttributes.default_attributes_always_in_partial_mode.each do |at|
            always_in_partial_mode(at) if respond_to?(:always_in_partial_mode)
          end
        end

        def self.default_attribute_names
          Array(ArtirixDataModels.configuration.try(:default_attributes) || DEFAULT_ATTRIBUTES)
        end

        def self.default_attributes_always_in_partial_mode
          Array(ArtirixDataModels.configuration.try(:attributes_always_in_partial_mode) || ATTRIBUTES_ALWAYS_IN_PARTIAL_MODE)
        end
      end
    end

    module PrimaryKey
      extend ActiveSupport::Concern

      def primary_key
        raise UndefinedPrimaryKeyAttributeError unless self.class.primary_key_attribute.present?
        send(self.class.primary_key_attribute)
      end

      def set_primary_key(value)
        raise UndefinedPrimaryKeyAttributeError unless self.class.primary_key_attribute.present?
        send("#{self.class.primary_key_attribute}=", value)
      end

      PARAM_JOIN_STRING = '/'.freeze

      def to_param
        # for ActiveModel compliant
        if persisted?
          to_key.join PARAM_JOIN_STRING
        else
          nil
        end
      end

      def to_key
        # for ActiveModel compliant
        if persisted?
          [primary_key]
        else
          nil
        end
      end

      module ClassMethods
        attr_accessor :primary_key_attribute
      end

      class UndefinedPrimaryKeyAttributeError < StandardError
      end
    end

    module WithDAO
      extend ActiveSupport::Concern

      included do
        include ArtirixDataModels::WithADMRegistry
      end

      def initialize(adm_registry: nil, adm_registry_loader: nil, **properties)
        set_adm_registry_and_loader adm_registry_loader, adm_registry
        set_properties_for_init properties
      end

      def set_properties_for_init(properties)
        _set_properties properties
      end

      def model_dao_name
        dao.model_name
      end

      def dao
        @dao ||= load_dao
      end

      private

      # private setter => it can be given on object creation as a named argument, or in `_set_properties` method
      attr_writer :dao

      def load_dao
        key = self.class.dao_name
        raise UndefinedDAOError, "`dao_name` not defined for #{self.class}" unless key.present?
        adm_registry.get(key)
      end

      module ClassMethods
        attr_accessor :dao_name
      end

      class UndefinedDAOError < StandardError
      end
    end

    module CacheKey
      extend ActiveSupport::Concern

      EMPTY_TIMESTAMP = 'no_time'.freeze
      SEPARATOR = '/'.freeze

      def cache_key
        # we do not want to force a reload for loading the cache key, it can lead to an infinite loop
        # so if the model is in partial mode, we mark it as full mode, and back as partial later.
        temp_full_mode = false
        if try(:partial_mode?)
          temp_full_mode = true
          try(:mark_full_mode)
        end

        m = try(:model_dao_name) || self.class
        i = try(:primary_key) || try(:id) || try(:object_id)
        t = try(:_timestamp) || try(:updated_at) || EMPTY_TIMESTAMP

        if temp_full_mode
          try(:mark_partial_mode)
        end

        [
          m.to_s.parameterize,
          i.to_s.parameterize,
          t.to_s.parameterize,
        ].join SEPARATOR
      end
    end

    module PartialMode
      extend ActiveSupport::Concern

      def reload_with(new_data)
        set_properties_for_reload new_data
        self
      end

      def set_properties_for_reload(properties)
        _set_properties properties
      end

      def partial_mode?
        !full_mode?
      end

      def full_mode?
        if @_full_mode.nil?
          self.class.default_full_mode?
        else
          @_full_mode
        end
      end

      def mark_full_mode
        @_full_mode = true
      end

      def mark_partial_mode
        @_full_mode = false
      end

      def reload_model!
        dao.reload(self)
        self
      end

      def force_partial_mode_fields(fields)
        @_forced_partial_mode_fields = fields.map &:to_s
      end

      def unforce_partial_mode_fields
        @_forced_partial_mode_fields = nil
      end

      def forced_partial_mode_fields?
        !!@_forced_partial_mode_fields && @_forced_partial_mode_fields.present?
      end

      private
      def in_partial_mode_field?(attribute)
        return true if self.class.is_always_in_partial_mode?(attribute)

        list = forced_partial_mode_fields? ? @_forced_partial_mode_fields : dao.partial_mode_fields
        list.include?(attribute.to_s) || list.include?(attribute.to_sym)
      end

      def nil_attribute(attribute)
        return nil if full_mode? || in_partial_mode_field?(attribute)
        reload_model!
        send(attribute)
      end

      module ClassMethods
        def new_full_mode(*args, &block)
          new(*args, &block).tap { |x| x.mark_full_mode }
        end

        def always_in_partial_mode(attribute)
          attribute_config.always_in_partial_mode(attribute)
        end

        def remove_always_in_partial_mode(attribute)
          attribute_config.remove_always_in_partial_mode(attribute)
        end

        def is_always_in_partial_mode?(attribute)
          attribute_config.is_always_in_partial_mode?(attribute)
        end

        def default_full_mode?
          attribute_config.default_mode == :full
        end

        def mark_full_mode_by_default
          attribute_config.default_mode = :full
        end

        def mark_partial_mode_by_default
          attribute_config.default_mode = :partial
        end

        def restore_default_mode
          attribute_config.default_mode = nil
        end
      end
    end

    class AttributeConfig
      attr_reader :attribute_list, :always_in_partial_mode_list
      attr_accessor :parent_attribute_config
      attr_writer :default_mode

      def initialize
        @attribute_list = Set.new
        @always_in_partial_mode_list = Set.new
        @parent_attribute_config = nil
        @default_mode = nil
      end

      def default_mode
        return @default_mode unless @default_mode.nil?
        parent_attribute_config.try(:default_mode) || initial_default_mode
      end

      def initial_default_mode
        ArtirixDataModels.configuration.try(:default_mode) || :partial
      end

      def attributes
        attribute_list.to_a
      end

      def all_attributes
        Array(parent_attribute_config.try(:attributes)) + attributes
      end

      def add_attribute(attribute)
        attribute_list << attribute
      end

      def always_in_partial_mode(attribute)
        @always_in_partial_mode_list << (attribute.to_s)
      end

      def remove_always_in_partial_mode(attribute)
        @always_in_partial_mode_list.delete attribute.to_s
      end

      def is_always_in_partial_mode?(attribute)
        @always_in_partial_mode_list.include?(attribute.to_s) || parent_attribute_config.try(:is_always_in_partial_mode?, attribute)
      end

    end
  end
end