PikachuEXE/contracted_value

View on GitHub
lib/contracted_value/core.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require "contracts"
require "ice_nine"

module ContractedValue
  module RefrigerationMode
    module Enum
      DEEP    = :deep
      SHALLOW = :shallow
      NONE    = :none

      def self.all
        [
          DEEP,
          SHALLOW,
          NONE,
        ].freeze
      end
    end
  end

  module Errors
    class DuplicateAttributeDeclaration < ArgumentError
      def initialize(key)
        super("Attribute :#{key} has already been declared")
      end
    end

    class InvalidRefrigerationMode < ArgumentError
      def initialize(val)
        valid_values = RefrigerationMode::Enum.all

        super(<<~MSG)
          option `refrigeration_mode` received <#{val.inspect}> but expected:
          #{valid_values.to_a.map(&:inspect).join(", ")}
        MSG
      end
    end

    class InvalidInputType < ArgumentError
      def initialize(input_val)
        super(
          <<~MSG
            Input must be a Hash, but got: <#{input_val.inspect}>
          MSG
        )
      end
    end

    class MissingAttributeInput < ArgumentError
      def initialize(key)
        super(
          <<~MSG
            Attribute :#{key} missing from input
          MSG
        )
      end
    end

    class InvalidAttributeValue < ArgumentError
      def initialize(key, val)
        super(
          <<~MSG
            Attribute :#{key} received invalid value:
            #{val.inspect}
          MSG
        )
      end
    end

    class InvalidAttributeDefaultValue < ArgumentError
      def initialize(key, val)
        super(
          <<~MSG
            Attribute :#{key} is declared with invalid default value:
            #{val.inspect}
          MSG
        )
      end
    end
  end

  module Private
    # No 2 procs are ever the same
    ATTR_DEFAULT_VALUE_ABSENT_VAL = -> {}
  end
  private_constant :Private

  class AttributeSet
    def self.new(*)
      ::IceNine.deep_freeze(super)
    end

    def initialize(attributes_hash = {})
      @attributes_hash = attributes_hash
    end

    def merge(other_attr_set)
      self.class.new(attributes_hash.merge(other_attr_set.attributes_hash))
    end

    def add(attr)
      merge!(self.class.new(attr.name => attr))
    end

    def each_attribute
      return to_enum(:each_attribute) unless block_given?

      attributes_hash.each_value do |v|
        yield(v)
      end
    end

    protected

    def merge!(other_attr_set)
      shared_keys = attr_names & other_attr_set.attr_names
      if shared_keys.any?
        raise(Errors::DuplicateAttributeDeclaration, shared_keys.first)
      end

      self.class.new(attributes_hash.merge(other_attr_set.attributes_hash))
    end

    def attr_names
      @attributes_hash.keys
    end

    attr_reader :attributes_hash
  end

  class Attribute
    def self.new(...)
      ::IceNine.deep_freeze(super)
    end

    def initialize(
      name:, contract:, refrigeration_mode:, default_value:
    )

      @name = name
      @contract = contract
      @refrigeration_mode = refrigeration_mode
      @default_value = default_value

      raise_error_if_inputs_invalid
    end

    attr_reader :name
    attr_reader :contract
    attr_reader :refrigeration_mode

    def extract_value(hash)
      if hash.key?(name)
        attr_value = hash.fetch(name)

        unless Contract.valid?(attr_value, contract)
          raise(
            Errors::InvalidAttributeValue.new(name, attr_value),
          )
        end

        return attr_value
      end

      # Data missing from input
      # Use default value if present
      # Raise error otherwise

      return default_value if default_value_present?

      raise(
        Errors::MissingAttributeInput.new(
          name,
        ),
      )
    end

    private

    attr_reader :default_value

    def raise_error_if_inputs_invalid
      raise_error_if_name_invalid
      raise_error_if_refrigeration_mode_invalid
      raise_error_if_default_value_invalid
    end

    def raise_error_if_name_invalid
      return if name.is_a?(Symbol)

      raise NotImplementedError, "Internal error: name is not a symbol (#{name.class.name})"
    end

    def raise_error_if_refrigeration_mode_invalid
      return if RefrigerationMode::Enum.all.include?(refrigeration_mode)

      raise Errors::InvalidRefrigerationMode.new(
        refrigeration_mode,
      )
    end

    def raise_error_if_default_value_invalid
      return unless default_value_present?
      return if Contract.valid?(default_value, contract)

      raise(
        Errors::InvalidAttributeDefaultValue.new(
          name,
          default_value,
        ),
      )
    end

    def default_value_present?
      # The default value of default value (ATTR_DEFAULT_VALUE_ABSENT_VAL)
      # only represents the absence of default value
      default_value != Private::ATTR_DEFAULT_VALUE_ABSENT_VAL
    end
  end

  class Value
    # rubocop:disable Metrics/CyclomaticComplexity
    # rubocop:disable Metrics/AbcSize
    def initialize(input_attr_values = {})
      input_attr_values_hash =
        case input_attr_values
        when ::Hash
          input_attr_values
        when Value
          input_attr_values.to_h
        else
          raise(
            Errors::InvalidInputType.new(
              input_attr_values,
            ),
          )
        end

      self.class.send(:attribute_set).each_attribute do |attribute|
        attr_value = attribute.extract_value(input_attr_values_hash)

        sometimes_frozen_attr_value =
          case attribute.refrigeration_mode
          when RefrigerationMode::Enum::DEEP
            # Use ice_nine for deep freezing
            ::IceNine.deep_freeze(attr_value)
          when RefrigerationMode::Enum::SHALLOW
            # No need to re-freeze
            attr_value.frozen? ? attr_value : attr_value.freeze
          when RefrigerationMode::Enum::NONE
            # No freezing
            attr_value
          else
            raise Errors::InvalidRefrigerationMode.new(
              refrigeration_mode,
            )
          end

        # Using symbol since attribute names are limited in number
        # An alternative would be using frozen string
        instance_variable_set(
          :"@#{attribute.name}",
          sometimes_frozen_attr_value,
        )
      end

      freeze
    end
    # rubocop:enable Metrics/AbcSize
    # rubocop:enable Metrics/CyclomaticComplexity

    def to_h
      self.class.send(:attribute_set).
        each_attribute.each_with_object({}) do |attribute, hash|
          hash[attribute.name] = instance_variable_get(:"@#{attribute.name}")
        end
    end

    # == Class interface == #
    class << self
      def inherited(klass)
        super

        klass.instance_variable_set(:@attribute_set, AttributeSet.new)
      end

      private

      # @api
      def attribute(
        name,
        contract: ::Contracts::Builtin::Any,
        refrigeration_mode: RefrigerationMode::Enum::DEEP,
        default_value: Private::ATTR_DEFAULT_VALUE_ABSENT_VAL
      )
        # Using symbol since attribute names are limited in number
        # An alternative would be using frozen string
        name_in_sym = name.to_sym

        attr = Attribute.new(
          name: name_in_sym,
          contract: contract,
          refrigeration_mode: refrigeration_mode,
          default_value: default_value,
        )
        @attribute_set = @attribute_set.add(attr)

        attr_reader(name_in_sym)
      end

      # @api private
      def super_attribute_set
        unless superclass.respond_to?(:attribute_set, true)
          return AttributeSet.new
        end

        superclass.send(:attribute_set)
      end

      # @api private
      def attribute_set
        # When the chain comes back to original class
        # (ContractedValue::Value)
        # @attribute_set would be nil
        super_attribute_set.merge(@attribute_set || AttributeSet.new)
      end
    end
    # == Class interface == #
  end
end