moneta-rb/moneta

View on GitHub
lib/moneta/transformer.rb

Summary

Maintainability
C
1 day
Test Coverage
module Moneta
  # Transforms keys and values (Marshal, YAML, JSON, Base64, MD5, ...).
  # You can bypass the transformer (e.g. serialization) by using the `:raw` option.
  #
  # @example Add `Moneta::Transformer` to proxy stack
  #   Moneta.build do
  #     transformer key: [:marshal, :escape], value: [:marshal]
  #     adapter :File, dir: 'data'
  #   end
  #
  # @example Bypass serialization
  #   store.store('key', 'value', raw: true)
  #   store['key'] # raises an Exception
  #   store.load('key', raw: true) # returns 'value'
  #
  #   store['key'] = 'value'
  #   store.load('key', raw: true) # returns "\x04\bI\"\nvalue\x06:\x06ET"
  #
  # @api public
  class Transformer < Proxy
    class << self
      alias original_new new

      # @param [Moneta store] adapter The underlying store
      # @param [Hash] options
      # @return [Transformer] new Moneta transformer
      # @option options [Array] :key List of key transformers in the order in which they should be applied
      # @option options [Array] :value List of value transformers in the order in which they should be applied
      # @option options [String] :prefix Prefix string for key namespacing (Used by the :prefix key transformer)
      # @option options [String] :secret HMAC secret to verify values (Used by the :hmac value transformer)
      # @option options [Integer] :maxlen Maximum key length (Used by the :truncate key transformer)
      def new(adapter, options = {})
        keys = [options[:key]].flatten.compact
        values = [options[:value]].flatten.compact
        raise ArgumentError, 'Option :key or :value is required' if keys.empty? && values.empty?
        options[:prefix] ||= '' if keys.include?(:prefix)
        name = class_name(keys, values)
        const_set(name, compile(keys, values)) unless const_defined?(name)
        const_get(name).original_new(adapter, options)
      end

      private

      def compile(keys, values)
        @key_validator ||= compile_validator(KEY_TRANSFORMER)
        @load_key_validator ||= compile_validator(LOAD_KEY_TRANSFORMER)
        @test_key_validator ||= compile_validator(TEST_KEY_TRANSFORMER)
        @value_validator ||= compile_validator(VALUE_TRANSFORMER)

        raise ArgumentError, 'Invalid key transformer chain' if @key_validator !~ keys.map(&:inspect).join
        raise ArgumentError, 'Invalid value transformer chain' if @value_validator !~ values.map(&:inspect).join

        klass = Class.new(self)
        compile_each_key_support_clause(klass, keys)
        klass.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
          def initialize(adapter, options = {})
            super
            #{compile_initializer('key', keys)}
            #{compile_initializer('value', values)}
          end
        END_EVAL

        key, key_opts = compile_transformer(keys, 'key')
        key_load, key_load_opts = compile_transformer(keys.reverse, 'key', 1) if @load_key_validator =~ keys.map(&:inspect).join
        key_test, key_test_opts = compile_transformer(keys.reverse, 'key', 4) if @test_key_validator =~ keys.map(&:inspect).join
        dump, dump_opts = compile_transformer(values, 'value')
        load, load_opts = compile_transformer(values.reverse, 'value', 1)

        if values.empty?
          compile_key_transformer(klass, key, key_opts, key_load, key_load_opts, key_test, key_test_opts)
        elsif keys.empty?
          compile_value_transformer(klass, load, load_opts, dump, dump_opts)
        else
          compile_key_value_transformer(klass, key, key_opts, key_load, key_load_opts, key_test, key_test_opts, load, load_opts, dump, dump_opts)
        end

        klass
      end

      def without(*options)
        options = options.flatten.uniq
        options.empty? ? 'options' : "Utils.without(options, #{options.map(&:to_sym).map(&:inspect).join(', ')})"
      end

      def compile_each_key_support_clause(klass, keys)
        klass.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
          #{'not_supports :each_key' if @load_key_validator !~ keys.map(&:inspect).join}
        END_EVAL
      end

      def compile_key_transformer(klass, key, key_opts, key_load, key_load_opts, key_test, key_test_opts)
        if_key_test = key_load && key_test ? "if #{key_test}" : ''

        klass.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
          def key?(key, options = {})
            @adapter.key?(#{key}, #{without key_opts})
          end
          def each_key(&block)
            raise NotImplementedError, "each_key is not supported on this transformer" \
              unless supports? :each_key

            return enum_for(:each_key) unless block_given?
            @adapter.each_key.lazy.map{ |key| #{key_load} #{if_key_test} }.reject(&:nil?).each(&block)

            self
          end
          def increment(key, amount = 1, options = {})
            @adapter.increment(#{key}, amount, #{without key_opts})
          end
          def load(key, options = {})
            @adapter.load(#{key}, #{without :raw, key_opts})
          end
          def store(key, value, options = {})
            @adapter.store(#{key}, value, #{without :raw, key_opts})
          end
          def delete(key, options = {})
            @adapter.delete(#{key}, #{without :raw, key_opts})
          end
          def create(key, value, options = {})
            @adapter.create(#{key}, value, #{without :raw, key_opts})
          end
          def values_at(*keys, **options)
            t_keys = keys.map { |key| #{key} }
            @adapter.values_at(*t_keys, **#{without :raw, key_opts})
          end
          def fetch_values(*keys, **options)
            t_keys = keys.map { |key| #{key} }

            block = if block_given?
                      key_lookup = Hash[t_keys.zip(keys)]
                      lambda { |t_key| yield key_lookup[t_key] }
                    end
            @adapter.fetch_values(*t_keys, **#{without :raw, key_opts}, &block)
          end
          def slice(*keys, **options)
            t_keys = keys.map { |key| #{key} }
            key_lookup = Hash[t_keys.zip(keys)]
            @adapter.slice(*t_keys, **#{without :raw, key_opts}).map do |key, value|
              [key_lookup[key], value]
            end
          end
          def merge!(pairs, options = {})
            keys, values = pairs.to_a.transpose
            t_keys = keys.map { |key| #{key} }
            block = if block_given?
                      key_lookup = Hash[t_keys.zip(keys)]
                      lambda { |k, old, new| yield(key_lookup[k], old, new) }
                    end
            @adapter.merge!(t_keys.zip(values), #{without :raw, key_opts}, &block)
            self
          end
        END_EVAL
      end

      def compile_value_transformer(klass, load, load_opts, dump, dump_opts)
        klass.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
          def load(key, options = {})
            value = @adapter.load(key, #{without :raw, load_opts})
            value && !options[:raw] ? #{load} : value
          end
          def store(key, value, options = {})
            @adapter.store(key, options[:raw] ? value : #{dump}, #{without :raw, dump_opts})
            value
          end
          def delete(key, options = {})
            value = @adapter.delete(key, #{without :raw, load_opts})
            value && !options[:raw] ? #{load} : value
          end
          def create(key, value, options = {})
            @adapter.create(key, options[:raw] ? value : #{dump}, #{without :raw, dump_opts})
          end
          def values_at(*keys, **options)
            values = @adapter.values_at(*keys, **#{without :raw, load_opts})
            values.map do |value|
              value && !options[:raw] ? #{load} : value
            end
          end
          def fetch_values(*keys, **options, &orig_block)
            substituted = {}
            block = if block_given?
                      lambda { |key| substituted[key] = true; yield key }
                    end

            values = @adapter.fetch_values(*keys, **#{without :raw, load_opts}, &block)
            if options[:raw]
              values
            else
              keys.map(&substituted.method(:key?)).zip(values).map do |substituted, value|
                if substituted || !value
                  value
                else
                  #{load}
                end
              end
            end
          end
          def slice(*keys, **options)
            @adapter.slice(*keys, **#{without :raw, load_opts}).map do |key, value|
              [key, value && !options[:raw] ? #{load} : value]
            end
          end
          def merge!(pairs, options = {}, &orig_block)
            block = if block_given?
                      if options[:raw]
                        orig_block
                      else
                        lambda do |k, old_val, new_val|
                          value = old_val; old_val = #{load}
                          value = new_val; new_val = #{load}
                          value = yield(k, old_val, new_val)
                          #{dump}
                        end
                      end
                    end

            t_pairs = options[:raw] ? pairs : pairs.map { |key, value| [key, #{dump}] }
            @adapter.merge!(t_pairs, #{without :raw, dump_opts}, &block)
            self
          end
        END_EVAL
      end

      def compile_key_value_transformer(klass, key, key_opts, key_load, key_load_opts, key_test, key_test_opts, load, load_opts, dump, dump_opts)
        if_key_test = key_load && key_test ? "if #{key_test}" : ''

        klass.class_eval <<-END_EVAL, __FILE__, __LINE__ + 1
          def key?(key, options = {})
            @adapter.key?(#{key}, #{without key_opts})
          end
          def each_key(&block)
            raise NotImplementedError, "each_key is not supported on this transformer" \
              unless supports? :each_key

            return enum_for(:each_key) { @adapter.each_key.size } unless block_given?
            @adapter.each_key.lazy.map{ |key| #{key_load} #{if_key_test} }.reject(&:nil?).each(&block)

            self
          end
          def increment(key, amount = 1, options = {})
            @adapter.increment(#{key}, amount, #{without key_opts})
          end
          def load(key, options = {})
            value = @adapter.load(#{key}, #{without :raw, key_opts, load_opts})
            value && !options[:raw] ? #{load} : value
          end
          def store(key, value, options = {})
            @adapter.store(#{key}, options[:raw] ? value : #{dump}, #{without :raw, key_opts, dump_opts})
            value
          end
          def delete(key, options = {})
            value = @adapter.delete(#{key}, #{without :raw, key_opts, load_opts})
            value && !options[:raw] ? #{load} : value
          end
          def create(key, value, options = {})
            @adapter.create(#{key}, options[:raw] ? value : #{dump}, #{without :raw, key_opts, dump_opts})
          end
          def values_at(*keys, **options)
            t_keys = keys.map { |key| #{key} }
            values = @adapter.values_at(*t_keys, **#{without :raw, key_opts, load_opts})
            values.map do |value|
              value && !options[:raw] ? #{load} : value
            end
          end
          def fetch_values(*keys, **options)
            t_keys = keys.map { |key| #{key} }
            key_lookup = Hash[t_keys.zip(keys)]
            substituted = {}
            block = if block_given?
                      lambda do |t_key|
                        key = key_lookup[t_key]
                        substituted[key] = true
                        yield key
                      end
                    end

            values = @adapter.fetch_values(*t_keys, **#{without :raw, key_opts, load_opts}, &block)

            if options[:raw]
              values
            else
              keys.map(&substituted.method(:key?)).zip(values).map do |substituted, value|
                if substituted || !value
                  value
                else
                  #{load}
                end
              end
            end
          end
          def slice(*keys, **options)
            t_keys = keys.map { |key| #{key} }
            key_lookup = Hash[t_keys.zip(keys)]
            @adapter.slice(*t_keys, **#{without :raw, key_opts, load_opts}).map do |key, value|
              [key_lookup[key], value && !options[:raw] ? #{load} : value]
            end
          end
          def merge!(pairs, options = {})
            keys, values = pairs.to_a.transpose
            t_keys = keys.map { |key| #{key} }
            key_lookup = Hash[t_keys.zip(keys)]

            block = if block_given?
                      if options[:raw]
                        lambda do |k, old_val, new_val|
                          yield(key_lookup[k], old_val, new_val)
                        end
                      else
                        lambda do |k, old_val, new_val|
                          value = old_val; old_val = #{load}
                          value = new_val; new_val = #{load}
                          value = yield(key_lookup[k], old_val, new_val)
                          #{dump}
                        end
                      end
                    end
            t_pairs = if options[:raw]
                        t_keys.zip(values)
                      else
                        t_keys.zip(values.map { |value| #{dump} })
                      end
            @adapter.merge!(t_pairs, #{without :raw, key_opts, dump_opts}, &block)
            self
          end
        END_EVAL
      end

      # Compile option initializer
      def compile_initializer(type, transformers)
        transformers.map do |name|
          t = TRANSFORMER[name]
          (t[1].to_s + t[2].to_s).scan(/@\w+/).uniq.map do |opt|
            "raise ArgumentError, \"Option #{opt[1..-1]} is required for #{name} #{type} transformer\" unless #{opt} = options[:#{opt[1..-1]}]\n"
          end
        end.join("\n")
      end

      def compile_validator(str)
        Regexp.new('\A' +
                   str.gsub(/\w+/) do
                     '(' + TRANSFORMER.select { |_, v| v.first.to_s == $& }.map { |v| ":#{v.first}" }.join('|') + ')'
                   end.gsub(/\s+/, '') +
                   '\Z')
      end

      # Returned compiled transformer code string
      def compile_transformer(transformer, var, idx = 2)
        value, options = var, []
        transformer.each do |name|
          raise ArgumentError, "Unknown transformer #{name}" unless t = TRANSFORMER[name]
          require t[3] if t[3]
          code = t[idx]
          code ||= compile_prefix(name: name, transformer: t, value: value) if idx == 4 && var == 'key'

          raise "Undefined command for transformer #{name}" unless code

          options += code.scan(/options\[:(\w+)\]/).flatten
          value =
            if t[0] == :serialize && var == 'key' && idx == 4
              "(tmp = #{value}; (false === tmp || '' === tmp) ? false : #{code % 'tmp'})"
            elsif t[0] == :serialize && var == 'key'
              "(tmp = #{value}; String === tmp ? tmp : #{code % 'tmp'})"
            else
              code % value
            end
        end
        [value, options]
      end

      def class_name(keys, values)
        camel_case = lambda { |sym| sym.to_s.split('_').map(&:capitalize).join }
        (keys.empty? ? '' : keys.map(&camel_case).join + 'Key') +
          (values.empty? ? '' : values.map(&camel_case).join + 'Value')
      end

      def compile_prefix(name:, transformer:, value:)
        return unless [:encode, :serialize].include?(transformer[0])

        load_val, = compile_transformer([name], value, 1)
        "(#{load_val} rescue '')"
      end
    end
  end
end

require 'moneta/transformer/helper'
require 'moneta/transformer/config'