deseretbook/hashformer

View on GitHub
lib/hashformer/generate.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Hashformer transformation generators
# Created July 2014 by Mike Bourgeous, DeseretBook.com
# Copyright (C)2014 Deseret Book
# See LICENSE and README.md for details.

require 'classy_hash'

module Hashformer
  # This module contains simple methods for generating complex transformations
  # for Hashformer.
  module Generate
    # Internal representation of a mapping transformation.  Do not instantiate
    # directly; call Hashformer::Generate.map (or HF::G.map) instead.
    class Map
      def initialize(*keys_or_callables, &block)
        @keys = keys_or_callables
        @block = block
      end

      # Called to process the map on the given +input_hash+
      def call(input_hash)
        values = @keys.map{|k| Hashformer.get_value(input_hash, k)}
        values = @block.call(*values) if @block
        values
      end

      # TODO: Make maps chainable?  Or at least add a #reduce method.
    end

    # Internal representation of a path to a nested key/value.  Do not
    # instantiate directly; call Hashformer::Generate.path (or HF::G.path)
    # instead.
    class Path
      def initialize
        @pathlist = []
      end

      # Called to dereference the path on the given +input_hash+.
      def call(input_hash)
        begin
          value = input_hash
          @pathlist.each do |path_item|
            value = value && value[path_item]
          end
          value
        rescue => e
          raise "Error dereferencing path #{self}: #{e}"
        end
      end

      # Adds a path item to the end of the saved path.
      def [](path_item)
        @pathlist << path_item
        self
      end

      def to_s
        @pathlist.map{|p| "[#{p.inspect}]"}.join
      end
    end

    # Internal representation of a method call and array lookup chainer.  Do
    # not use this directly; instead use HF::G.chain().
    class Chain
      # Base module that defines methods included by BasicReceiver and
      # DebuggableReceiver.
      module ReceiverMethods
        # An oddly named accessor is used instead of #initialize to avoid
        # conflicts with any methods that might be chained.
        attr_accessor :__chain

        # Adds a method call or array dereference to the list of calls to
        # apply.  Does nothing if #__end has been called.  Returns self for
        # more chaining.
        def method_missing(name, *args, &block)
          @__ended ||= false
          @__chain << {name: name, args: args, block: block} unless @__ended
          self
        end

        # Adds a call to the given +block+ to the chain like Object#tap, but
        # returns the result of the block instead of the original object.  Any
        # arguments given will be passed to the +block+ after the current
        # value.  Does nothing if #__end has been called.  Returns self for
        # more chaining.
        #
        # This is similar in spirit (but not implementation) to
        # http://stackoverflow.com/a/12849214
        def __as(*args, &block)
          ::Kernel.raise 'No block given to #__as' unless ::Kernel.block_given?
          @__ended ||= false
          @__chain << {args: args, block: block} unless @__ended
          self
        end

        # Disables further chaining.  Any future method calls will just return
        # the existing chain without modifying it.
        def __end
          @__ended = true
          self
        end
      end

      # Receiver for chaining calls that has no methods of its own except
      # initialize.  This allows methods like :call to be chained.
      #
      # IMPORTANT: No methods other than #__chain, #__as, or #__end should be
      # called on this object, because they will be chained!  Instead, use ===
      # to detect the object's type, for example.
      class BasicReceiver < BasicObject
        include ReceiverMethods

        undef !=
        undef ==
        undef !
        undef instance_exec
        undef instance_eval
        undef equal?
        undef singleton_method_added
        undef singleton_method_removed
        undef singleton_method_undefined
      end

      # Debuggable chain receiver that inherits from Object.  This will break a
      # lot of chains (e.g. any chain using #to_s or #inspect), but will allow
      # some debugging tools to operate without crashing.  See
      # Hashformer::Generate::Chain.enable_debugging.
      class DebuggableReceiver
        include ReceiverMethods

        # Overrides ReceiverMethods#method_missing to print out methods as they
        # are added to the chain.
        def method_missing(name, *args, &block)
          __dbg_msg(name, args, block)
          super
        end

        # Overrides ReceiverMethods#__as to print out blocks as they are added
        # to the chain.
        def __as(*args, &block)
          __dbg_msg('__as', args, block)
          super
        end

        # Overrides ReceiverMethods#__end to print a message when a chain is
        # ended.
        def __end
          $stdout.puts "Ending chain #{__id__}"
          super
        end

        private

        # Prints a debugging message for the addition of the given method
        # +name+, +args+, and +block+.  Prints "Adding..." for active chains,
        # "Ignoring..." for ended chains.
        def __dbg_msg(name, args, block)
          $stdout.puts "#{@__ended ? 'Ignoring' : 'Adding'} " \
            "#{name.inspect}(#{args.map(&:inspect).join(', ')}){#{block}} " \
            "to chain #{__id__}"
        end
      end

      class << self
        # The chaining receiver class that will be used by newly created chains
        # (must include ReceiverMethods).
        def receiver_class
          @receiver_class ||= BasicReceiver
        end

        # Switches Receiver to an Object (DebuggableReceiver) for debugging.
        # debugging tools to introspect Receiver without crashing.
        def enable_debugging
          @receiver_class = DebuggableReceiver
        end

        # Switches Receiver back to a BasicObject (BasicReceiver).
        def disable_debugging
          @receiver_class = BasicReceiver
        end
      end


      # Returns the call chaining receiver for this chain.
      attr_reader :receiver

      # Initializes an empty chain.
      def initialize
        @calls = []
        @receiver = self.class.receiver_class.new
        @receiver.__chain = self
      end

      # Applies the methods stored by #method_missing
      def call(input_hash)
        value = input_hash
        @calls.each do |c|
          if c[:name]
            value = value.send(c[:name], *c[:args], &c[:block])
          else
            # Support #__as
            value = c[:block].call(value, *c[:args])
          end
        end
        value
      end

      # Adds the given call info (used by ReceiverMethods).
      def <<(info)
        @calls << info
        self
      end

      # Returns a String with the class name and a list of chained methods.
      def to_s
        "#{self.class.name}: #{@calls.map{|c| c[:name]}}"
      end
      alias inspect to_s
    end

    # Internal representation of a constant output value.  Do not instantiate
    # directly; call Hashformer::Generate.const (or HF::G.const) instead.
    class Constant
      attr_reader :value

      def initialize(value)
        @value = value
      end
    end


    # Generates a transformation that always returns a constant value.
    #
    # Examples:
    #   HF::G.const(5)
    def self.const(value)
      Constant.new(value)
    end

    # Generates a transformation that passes one or more values from the input
    # Hash (denoted by key names or paths (see Hashformer::Generate.path) to
    # the block.  If the block is not given, then the values are placed in an
    # array in the order in which their keys were given as parameters.
    #
    # You can also pass a Hashformer transformation Hash as one or more keys.
    #
    # Examples:
    #   HF::G.map(:first, :last) do |f, l| "#{f} #{l}".strip end
    #   HF::G.map(:a1, :a2) # Turns {a1: 1, a2: 2} into [1, 2]
    #   HF::G.map(HF::G.path[:address][:line1], HF::G.path[:address][:line2])
    def self.map(*keys_or_paths, &block)
      Map.new(*keys_or_paths, &block)
    end

    # Generates a path reference (via Path#[]) that grabs a nested value for
    # use directly or in other transformations.  If no path is specified, the
    # transformation will use the input hash.
    #
    # When the path is dereferenced, if any of the parent elements referred to
    # by the path are nil, then nil will be returned.  If any path elements do
    # not respond to [], or otherwise raise an exception, then an exception
    # will be raised by the transformation.
    #
    # The major difference between .path and .chain is that .path will return
    # nil if a nonexistent key is referenced (even multiple times), while
    # .chain will raise an exception.
    #
    # Examples:
    #   HF::G.path[:user][:address][:line1]
    #   HF::G.path[:lines][5]
    def self.path
      Path.new
    end

    # Generates a method call chain to apply to the input hash given to a
    # transformation.  This allows path references (as with HF::G.path) and
    # method calls to be stored and applied later.
    #
    # See Hashformer::Generate::Chain.enable_debugging if you run into issues.
    #
    # Example:
    #   data = { in1: { in2: [1, 2, 3, [4, 5, 6, 7]] } }
    #   xform = { out1: HF::G.chain[:in1][:in2][3].reduce(&:+) }
    #   Hashformer.transform(data, xform) # Returns { out1: 22 }
    def self.chain
      Chain.new.receiver
    end
  end

  # Shortcut to Hashformer::Generate
  G = Generate

  # Convenience method for calling HF::G.chain() to generate a path reference
  # and/or method call chain.  If the initial +path_item+ is not given, then
  # the method chain will start with the input hash.  Chaining methods that
  # have side effects or modify the underlying data is not recommended.
  #
  # Example:
  #   data = { in1: { in2: ['a', 'b', 'c', 'd'] } }
  #   xform = { out1: HF[:in1][:in2][3], out2: HF[].count }
  #   Hashformer.transform(data, xform) # Returns { out1: 'd', out2: 1 }
  def self.[](path_item = :__hashformer_not_given)
    path_item == :__hashformer_not_given ? HF::G.chain : HF::G.chain[path_item]
  end
end