neo4jrb/neo4j

View on GitHub
lib/active_graph/shared/type_converters.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require 'date'
require 'bigdecimal'
require 'bigdecimal/util'
require 'active_support/core_ext/big_decimal/conversions'
require 'active_support/core_ext/string/conversions'

module ActiveGraph::Shared
  class Boolean; end

  module TypeConverters
    CONVERTERS = {}

    class Boolean; end

    class BaseConverter
      class << self
        def converted?(value)
          value.is_a?(db_type)
        end
      end

      def supports_array?
        false
      end
    end

    class IntegerConverter < BaseConverter
      NEO4J_LARGEST_INT = 9223372036854775807
      NEO4J_SMALLEST_INT = -9223372036854775808
      class << self
        def converted?(value)
          false
        end

        def convert_type
          Integer
        end

        def db_type
          Integer
        end

        def to_db(value)
          val = value.to_i
          val > NEO4J_LARGEST_INT || val < NEO4J_SMALLEST_INT ? val.to_s : val
        end

        def to_ruby(value)
          value.to_i
        end
      end
    end

    class FloatConverter < BaseConverter
      class << self
        def convert_type
          Float
        end

        def db_type
          Float
        end

        def to_db(value)
          value.to_f
        end

        alias to_ruby to_db
      end
    end

    class BigDecimalConverter < BaseConverter
      class << self
        def convert_type
          BigDecimal
        end

        def db_type
          String
        end

        def to_db(value)
          case value
          when Rational
            value.to_f.to_d
          when respond_to?(:to_d)
            value.to_d
          else
            BigDecimal(value.to_s)
          end.to_s
        end

        def to_ruby(value)
          value.to_d
        end
      end
    end

    class StringConverter < BaseConverter
      class << self
        def convert_type
          String
        end

        def db_type
          String
        end

        def to_db(value)
          value.to_s
        end

        alias to_ruby to_db
      end
    end

    # Converts Java long types to Date objects. Must be timezone UTC.
    class DateConverter < BaseConverter
      class << self
        def convert_type
          Date
        end

        def db_type
          Date
        end

        def to_ruby(value)
          value.respond_to?(:to_date) ? value.to_date : Time.at(value).utc.to_date
        end

        alias to_db to_ruby
      end
    end

    # Converts DateTime objects to and from Java long types. Must be timezone UTC.
    class DateTimeConverter < BaseConverter
      class << self
        def convert_type
          DateTime
        end

        def db_type
          Integer
        end

        # Converts the given DateTime (UTC) value to an Integer.
        # DateTime values are automatically converted to UTC.
        def to_db(value)
          value = value.new_offset(0) if value.respond_to?(:new_offset)

          args = [value.year, value.month, value.day]
          args += (value.class == Date ? [0, 0, 0] : [value.hour, value.min, value.sec])

          Time.utc(*args).to_i
        end

        def to_ruby(value)
          return value if value.is_a?(DateTime)
          t = case value
              when Time
                return value.to_datetime.utc
              when Integer
                Time.at(value).utc
              when String
                return value.to_datetime
              else
                fail ArgumentError, "Invalid value type for DateType property: #{value.inspect}"
              end

          DateTime.civil(t.year, t.month, t.day, t.hour, t.min, t.sec)
        end
      end
    end

    class TimeConverter < BaseConverter
      class << self
        def convert_type
          Time
        end

        def db_type
          Time
        end

        def to_ruby(value)
          value.respond_to?(:to_time) ? value.to_time : Time.at(value).utc
        end

        alias to_db to_ruby
      end
    end

    class BooleanConverter < BaseConverter
      FALSE_VALUES = %w(n N no No NO false False FALSE off Off OFF f F).to_set

      class << self
        def converted?(value)
          converted_values.include?(value)
        end

        def converted_values
          [true, false]
        end

        def db_type
          ActiveGraph::Shared::Boolean
        end

        alias convert_type db_type

        def to_db(value)
          return false if FALSE_VALUES.include?(value)
          case value
          when TrueClass, FalseClass
            value
          when Numeric, /^\-?[0-9]/
            !value.to_f.zero?
          else
            value.present?
          end
        end

        alias to_ruby to_db
      end
    end

    # Converts hash to/from YAML
    class YAMLConverter < BaseConverter
      class << self
        def convert_type
          Hash
        end

        def db_type
          String
        end

        def to_db(value)
          Psych.dump(value)
        end

        def to_ruby(value)
          value.is_a?(Hash) ? value : Psych.load(value)
        end
      end
    end

    # Converts hash to/from JSON
    class JSONConverter < BaseConverter
      class << self
        def converted?(_value)
          false
        end

        def convert_type
          JSON
        end

        def db_type
          String
        end

        def to_db(value)
          value.to_json
        end

        def to_ruby(value)
          JSON.parse(value, quirks_mode: true)
        end
      end
    end

    class EnumConverter
      def initialize(enum_keys, options)
        @enum_keys = enum_keys
        @options = options

        return unless @options[:case_sensitive].nil?

        @options[:case_sensitive] = ActiveGraph::Config.enums_case_sensitive
      end

      def converted?(value)
        value.is_a?(db_type)
      end

      def supports_array?
        true
      end

      def db_type
        Integer
      end

      def convert_type
        Symbol
      end

      def to_ruby(value)
        @enum_keys.key(value) unless value.nil?
      end

      alias call to_ruby

      def to_db(value)
        if value.is_a?(Array)
          value.map(&method(:to_db))
        elsif @options[:case_sensitive]
          @enum_keys[value.to_s.to_sym] ||
            fail(ActiveGraph::Shared::Enum::InvalidEnumValueError, 'Value passed to an enum property must match one of the enum keys')
        else
          @enum_keys[value.to_s.downcase.to_sym] ||
            fail(ActiveGraph::Shared::Enum::InvalidEnumValueError, 'Case-insensitive (downcased) value passed to an enum property must match one of the enum keys')
        end
      end
    end

    class ObjectConverter < BaseConverter
      class << self
        def convert_type
          Object
        end

        def to_ruby(value)
          value
        end
      end
    end

    # Modifies a hash's values to be of types acceptable to Neo4j or matching what the user defined using `type` in property definitions.
    # @param [ActiveGraph::Shared::Property] obj A node or rel that mixes in the Property module
    # @param [Symbol] medium Indicates the type of conversion to perform.
    # @param [Hash] properties A hash of symbol-keyed properties for conversion.
    def convert_properties_to(obj, medium, properties)
      direction = medium == :ruby ? :to_ruby : :to_db
      properties.to_h.each_pair do |key, value|
        next if skip_conversion?(obj, key, value)

        converted_value = convert_property(key, value, direction)
        if properties.is_a?(ActiveGraph::AttributeSet)
          properties.write_cast_value(key, converted_value)
        else
          properties[key] = converted_value
        end
      end
    end

    # Converts a single property from its current format to its db- or Ruby-expected output type.
    # @param [Symbol] key A property declared on the model
    # @param value The value intended for conversion
    # @param [Symbol] direction Either :to_ruby or :to_db, indicates the type of conversion to perform
    def convert_property(key, value, direction)
      converted_property(primitive_type(key.to_sym), value, direction)
    end

    def supports_array?(key)
      type = primitive_type(key.to_sym)
      type.respond_to?(:supports_array?) && type.supports_array?
    end

    def typecaster_for(value)
      ActiveGraph::Shared::TypeConverters.typecaster_for(value)
    end

    def typecast_attribute(typecaster, value)
      ActiveGraph::Shared::TypeConverters.typecast_attribute(typecaster, value)
    end

    private

    def converted_property(type, value, direction)
      return nil if value.nil?
      CONVERTERS[type] || type.respond_to?(:db_type) ? TypeConverters.to_other(direction, value, type) : value
    end

    # If the attribute is to be typecast using a custom converter, which converter should it use? If no, returns the type to find a native serializer.
    def primitive_type(attr)
      case
      when serialized_properties.include?(attr)
        serialized_properties[attr]
      when magic_typecast_properties.include?(attr)
        magic_typecast_properties[attr]
      else
        fetch_upstream_primitive(attr)
      end
    end

    # Returns true if the property isn't defined in the model or if it is nil
    def skip_conversion?(obj, attr, value)
      value.nil? || !obj.class.attributes.key?(attr)
    end

    class << self
      def included(_)
        ActiveGraph::Shared::TypeConverters.constants.each do |constant_name|
          constant = ActiveGraph::Shared::TypeConverters.const_get(constant_name)
          register_converter(constant) if constant.respond_to?(:convert_type)
        end
      end

      def typecast_attribute(typecaster, value)
        fail ArgumentError, "A typecaster must be given, #{typecaster} is invalid" unless typecaster.respond_to?(:to_ruby)
        return value if value.nil?
        typecaster.to_ruby(value)
      end

      def typecaster_for(primitive_type)
        return nil if primitive_type.nil?
        CONVERTERS[primitive_type]
      end

      # @param [Symbol] direction either :to_ruby or :to_other
      def to_other(direction, value, type)
        fail "Unknown direction given: #{direction}" unless direction == :to_ruby || direction == :to_db
        found_converter = converter_for(type)
        return value unless found_converter
        return value if direction == :to_db && formatted_for_db?(found_converter, value)
        found_converter.send(direction, value)
      end

      def converter_for(type)
        type.respond_to?(:db_type) ? type : CONVERTERS[type]
      end

      # Attempts to determine whether conversion should be skipped because the object is already of the anticipated output type.
      # @param [#convert_type] found_converter An object that responds to #convert_type, hinting that it is a type converter.
      # @param value The value for conversion.
      def formatted_for_db?(found_converter, value)
        return false unless found_converter.respond_to?(:db_type)
        found_converter.respond_to?(:converted?) ? found_converter.converted?(value) : value.is_a?(found_converter.db_type)
      end

      def register_converter(converter)
        CONVERTERS[converter.convert_type] = converter
      end
    end
  end
end