# 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