jdantonio/functional-ruby

View on GitHub
lib/functional/record.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'functional/abstract_struct'
require 'functional/protocol'
require 'functional/type_check'

module Functional

  # An immutable data structure with multiple data fields. A `Record` is a
  # convenient way to bundle a number of field attributes together,
  # using accessor methods, without having to write an explicit class.
  # The `Record` module generates new `AbstractStruct` subclasses that hold a
  # set of fields with a reader method for each field.
  #
  # A `Record` is very similar to a Ruby `Struct` and shares many of its behaviors
  # and attributes. Unlike a # Ruby `Struct`, a `Record` is immutable: its values
  # are set at construction and can never be changed. Divergence between the two
  # classes derive from this core difference.
  #
  # {include:file:doc/record.md}
  #
  # @see Functional::Union
  # @see Functional::Protocol
  # @see Functional::TypeCheck
  #
  # @!macro thread_safe_immutable_object
  module Record
    extend self

    # Create a new record class with the given fields.
    #
    # @return [Functional::AbstractStruct] the new record subclass
    # @raise [ArgumentError] no fields specified or an invalid type
    #   specification is given
    def new(*fields, &block)
      raise ArgumentError.new('no fields provided') if fields.empty?

      name = nil
      types = nil

      # check if a name for registration is given
      if fields.first.is_a?(String)
        name = fields.first
        fields = fields[1..fields.length-1]
      end

      # check for a set of type/protocol specifications
      if fields.size == 1 && fields.first.respond_to?(:to_h)
        types = fields.first
        fields = fields.first.keys
        check_types!(types)
      end

      build(name, fields, types, &block)
    rescue
      raise ArgumentError.new('invalid specification')
    end

    private

    # @!visibility private
    #
    # A set of restrictions governing the creation of a new record.
    class Restrictions
      include Protocol
      include TypeCheck

      # Create a new restrictions object by processing the given
      # block. The block should be the DSL for defining a record class.
      #
      # @param [Hash] types a hash of fields and the associated type/protocol
      #   when type/protocol checking is among the restrictions
      # @param [Proc] block A DSL definition of a new record.
      # @yield A DSL definition of a new record.
      def initialize(types = nil, &block)
        @types = types
        @required = []
        @defaults = {}
        instance_eval(&block) if block_given?
        @required.freeze
        @defaults.freeze
        self.freeze
      end

      # DSL method for declaring one or more fields to be mandatory.
      #
      # @param [Symbol] fields zero or more mandatory fields
      def mandatory(*fields)
        @required.concat(fields.collect{|field| field.to_sym})
      end

      # DSL method for declaring a default value for a field
      #
      # @param [Symbol] field the field to be given a default value
      # @param [Object] value the default value of the field
      def default(field, value)
        @defaults[field] = value
      end

      # Clone a default value if it is cloneable. Else just return
      # the value.
      #
      # @param [Symbol] field the name of the field from which the
      #   default value is to be cloned.
      # @return [Object] a clone of the value or the value if uncloneable
      def clone_default(field)
        value = @defaults[field]
        value = value.clone unless uncloneable?(value)
      rescue TypeError
        # can't be cloned
      ensure
        return value
      end

      # Validate the record data against this set of restrictions.
      #
      # @param [Hash] data the data hash
      # @raise [ArgumentError] when the data does not match the restrictions
      def validate!(data)
        validate_mandatory!(data)
        validate_types!(data)
      end

      private

      # Check the given data hash to see if it contains non-nil values for
      # all mandatory fields.
      #
      # @param [Hash] data the data hash
      # @raise [ArgumentError] if any mandatory fields are missing
      def validate_mandatory!(data)
        if data.any?{|k,v| @required.include?(k) && v.nil? }
          raise ArgumentError.new('mandatory fields must not be nil')
        end
      end

      # Validate the record data against a type/protocol specification.
      #
      # @param [Hash] data the data hash
      # @raise [ArgumentError] when the data does not match the specification
      def validate_types!(data)
        return if @types.nil?
        @types.each do |field, type|
          value = data[field]
          next if value.nil?
          if type.is_a? Module
            raise ArgumentError.new("'#{field}' must be of type #{type}") unless Type?(value, type)
          else
            raise ArgumentError.new("'#{field}' must stasify the protocol :#{type}") unless Satisfy?(value, type)
          end
        end
      end

      # Is the given object uncloneable?
      #
      # @param [Object] object the object to check
      # @return [Boolean] true if the object cannot be cloned else false
      def uncloneable?(object)
        Type? object, NilClass, TrueClass, FalseClass, Fixnum, Bignum, Float
      end
    end
    private_constant :Restrictions

    # Validate the given type/protocol specification.
    #
    # @param [Hash] types the type specification
    # @raise [ArgumentError] when the specification is not valid
    def check_types!(types)
      return if types.nil?
      unless types.all?{|k,v| v.is_a?(Module) || v.is_a?(Symbol) }
        raise ArgumentError.new('invalid specification')
      end
    end

    # Use the given `AbstractStruct` class and build the methods necessary
    # to support the given data fields.
    #
    # @param [String] name the name under which to register the record when given
    # @param [Array] fields the list of symbolic names for all data fields
    # @return [Functional::AbstractStruct] the record class
    def build(name, fields, types, &block)
      fields = [name].concat(fields) unless name.nil?
      record, fields = AbstractStruct.define_class(self, :record, fields)
      record.class_variable_set(:@@restrictions, Restrictions.new(types, &block))
      define_initializer(record)
      fields.each do |field|
        define_reader(record, field)
      end
      record
    end

    # Define an initializer method on the given record class.
    #
    # @param [Functional::AbstractStruct] record the new record class
    # @return [Functional::AbstractStruct] the record class
    def define_initializer(record)
      record.send(:define_method, :initialize) do |data = {}|
        super()
        restrictions = record.class_variable_get(:@@restrictions)
        data = record.fields.reduce({}) do |memo, field|
          memo[field] = data.fetch(field, restrictions.clone_default(field))
          memo
        end
        restrictions.validate!(data)
        set_data_hash(data)
        set_values_array(data.values)
        ensure_ivar_visibility!
        self.freeze
      end
      record
    end

    # Define a reader method on the given record class for the given data field.
    #
    # @param [Functional::AbstractStruct] record the new record class
    # @param [Symbol] field symbolic name of the current data field
    # @return [Functional::AbstractStruct] the record class
    def define_reader(record, field)
      record.send(:define_method, field) do
        to_h[field]
      end
      record
    end
  end
end