fidothe/saxon-rb

View on GitHub
lib/saxon/item_type.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
94%
require_relative 's9api'
require_relative 'qname'
require_relative 'item_type/lexical_string_conversion'
require_relative 'item_type/value_to_ruby'

module Saxon
  # Represent XDM types abstractly
  class ItemType
    # lazy-loading Hash so we can avoid eager-loading saxon Jars, which prevents
    # using external Saxon Jars unless the user is more careful than they should
    # have to be.
    class LazyReadOnlyHash
      include Enumerable

      attr_reader :loaded_hash, :load_mutex, :init_block
      private :loaded_hash, :load_mutex, :init_block

      def initialize(&init_block)
        @init_block = init_block
        @load_mutex = Mutex.new
        @loaded_hash = nil
      end

      def [](key)
        ensure_loaded!
        loaded_hash[key]
      end

      def fetch(*args, &block)
        ensure_loaded!
        loaded_hash.fetch(*args, &block)
      end

      def each(&block)
        ensure_loaded!
        loaded_hash.each(&block)
      end

      private

      def ensure_loaded!
        return true unless loaded_hash.nil?
        load_mutex.synchronize do
          return true unless loaded_hash.nil?
          @loaded_hash = init_block.call
        end
      end
    end

    # Error raised when a Ruby class has no equivalent XDM type to
    # be converted into
    class UnmappedRubyTypeError < StandardError
      def initialize(class_name)
        @class_name = class_name
      end

      # error message including class name no type equivalent found for
      def to_s
        "Ruby class <#{@class_name}> has no XDM type equivalent"
      end
    end

    # Error raise when an attempt to reify an <tt>xs:*</tt> type string is
    # made, but the type string doesn't match any of the built-in <tt>xs:*</tt>
    # types
    class UnmappedXSDTypeNameError < StandardError
      def initialize(type_str)
        @type_str = type_str
      end

      # error message including type string with no matching built-in type
      def to_s
        "'#{@type_str}' is not recognised as an XSD built-in type"
      end
    end

    TYPE_CACHE_MUTEX = Mutex.new
    private_constant :TYPE_CACHE_MUTEX
    # A mapping of Ruby types to XDM type constants
    TYPE_MAPPING = {
      'String' => :STRING,
      'Array'  => :ANY_ARRAY,
      'Hash'   => :ANY_MAP,
      'TrueClass'  => :BOOLEAN,
      'FalseClass' => :BOOLEAN,
      'Date' => :DATE,
      'DateTime' => :DATE_TIME,
      'Time' => :DATE_TIME,
      'BigDecimal' => :DECIMAL,
      'Integer' => :INTEGER,
      'Fixnum' => :INTEGER, # Fixnum/Bignum needed for JRuby 9.1/Ruby 2.3
      'Bignum' => :INTEGER,
      'Float' => :FLOAT,
      'Numeric' => :NUMERIC
    }.freeze

    # A mapping of QNames to XDM type constants
    QNAME_MAPPING = LazyReadOnlyHash.new do
      {
        'anyAtomicType' => :ANY_ATOMIC_VALUE,
        'anyURI' => :ANY_URI,
        'base64Binary' => :BASE64_BINARY,
        'boolean' => :BOOLEAN,
        'byte' => :BYTE,
        'date' => :DATE,
        'dateTime' => :DATE_TIME,
        'dateTimeStamp' => :DATE_TIME_STAMP,
        'dayTimeDuration' => :DAY_TIME_DURATION,
        'decimal' => :DECIMAL,
        'double' => :DOUBLE,
        'duration' => :DURATION,
        'ENTITY' => :ENTITY,
        'float' => :FLOAT,
        'gDay' => :G_DAY,
        'gMonth' => :G_MONTH,
        'gMonthDay' => :G_MONTH_DAY,
        'gYear' => :G_YEAR,
        'gYearMonth' => :G_YEAR_MONTH,
        'hexBinary' => :HEX_BINARY,
        'ID' => :ID,
        'IDREF' => :IDREF,
        'int' => :INT,
        'integer' => :INTEGER,
        'language' => :LANGUAGE,
        'long' => :LONG,
        'Name' => :NAME,
        'NCName' => :NCNAME,
        'negativeInteger' => :NEGATIVE_INTEGER,
        'NMTOKEN' => :NMTOKEN,
        'nonNegativeInteger' => :NON_NEGATIVE_INTEGER,
        'nonPositiveInteger' => :NON_POSITIVE_INTEGER,
        'normalizedString' => :NORMALIZED_STRING,
        'NOTATION' => :NOTATION,
        'numeric' => :NUMERIC,
        'positiveInteger' => :POSITIVE_INTEGER,
        'QName' => :QNAME,
        'short' => :SHORT,
        'string' => :STRING,
        'time' => :TIME,
        'token' => :TOKEN,
        'unsignedByte' => :UNSIGNED_BYTE,
        'unsignedInt' => :UNSIGNED_INT,
        'unsignedLong' => :UNSIGNED_LONG,
        'unsignedShort' => :UNSIGNED_SHORT,
        'untypedAtomic' => :UNTYPED_ATOMIC,
        'yearMonthDuration' => :YEAR_MONTH_DURATION
      }.map { |local_name, constant|
        qname = Saxon::QName.create({
          prefix: 'xs', uri: 'http://www.w3.org/2001/XMLSchema',
          local_name: local_name
        })
        [qname, constant]
      }.to_h.freeze
    end

    # A mapping of type names/QNames to XDM type constants
    STR_MAPPING = LazyReadOnlyHash.new do
      {
        'array(*)' => :ANY_ARRAY,
        'item()' => :ANY_ITEM,
        'map(*)' => :ANY_MAP,
        'node()' => :ANY_NODE
      }.merge(
        Hash[QNAME_MAPPING.map { |qname, v| [qname.to_s, v] }]
      ).freeze
    end

    # convertors to generate lexical strings for a given {ItemType}, as a hash keyed on the ItemType
    ATOMIC_VALUE_LEXICAL_STRING_CONVERTORS = LazyReadOnlyHash.new do
      LexicalStringConversion::Convertors.constants.map { |const|
        [S9API::ItemType.const_get(const), LexicalStringConversion::Convertors.const_get(const)]
      }.to_h.freeze
    end

    # convertors from {XDM::AtomicValue} to a ruby primitve value, as a hash keyed on the ItemType
    ATOMIC_VALUE_TO_RUBY_CONVERTORS = LazyReadOnlyHash.new do
      ValueToRuby::Convertors.constants.map { |const|
        [S9API::ItemType.const_get(const), ValueToRuby::Convertors.const_get(const)]
      }.to_h.freeze
    end

    class << self
      # Get an appropriate {ItemType} for a Ruby type or given a type name as a
      # string
      #
      # @return [Saxon::ItemType]
      # @overload get_type(ruby_class)
      #   Get an appropriate {ItemType} for object of a given Ruby class
      #   @param ruby_class [Class] The Ruby class to get a type for
      # @overload get_type(type_name)
      #   Get the {ItemType} for the name
      #   @param type_name [String] name of the built-in {ItemType} to fetch
      #   (e.g. +xs:string+ or +element()+)
      # @overload get_type(item_type)
      #   Given an instance of {ItemType}, simply return the instance
      #   @param item_type [Saxon::ItemType] an existing ItemType instance
      def get_type(arg)
        case arg
        when Saxon::ItemType
          arg
        else
          fetch_type_instance(get_s9_type(arg))
        end
      end

      private

      def fetch_type_instance(s9_type)
        TYPE_CACHE_MUTEX.synchronize do
          @type_instance_cache = {} if !instance_variable_defined?(:@type_instance_cache)
          if type_instance = @type_instance_cache[s9_type]
            type_instance
          else
            @type_instance_cache[s9_type] = new(s9_type)
          end
        end
      end

      def get_s9_type(arg)
        case arg
        when S9API::ItemType
          arg
        when Saxon::QName
          get_s9_qname_mapped_type(arg)
        when Class
          get_s9_class_mapped_type(arg)
        when String
          get_s9_str_mapped_type(arg)
        end
      end

      def get_s9_qname_mapped_type(qname)
        if mapped_type = QNAME_MAPPING.fetch(qname, false)
          S9API::ItemType.const_get(mapped_type)
        else
          raise UnmappedXSDTypeNameError, qname.to_s
        end
      end

      def get_s9_class_mapped_type(klass)
        class_name = klass.name
        if mapped_type = TYPE_MAPPING.fetch(class_name, false)
          S9API::ItemType.const_get(mapped_type)
        else
          raise UnmappedRubyTypeError, class_name
        end
      end

      def get_s9_str_mapped_type(type_str)
        if mapped_type = STR_MAPPING.fetch(type_str, false)
          # ANY_ITEM is a method, not a constant, for reasons not entirely
          # clear to me
          return S9API::ItemType.ANY_ITEM if mapped_type == :ANY_ITEM
          S9API::ItemType.const_get(mapped_type)
        else
          raise UnmappedXSDTypeNameError, type_str
        end
      end
    end

    attr_reader :s9_item_type
    private :s9_item_type

    # @api private
    def initialize(s9_item_type)
      @s9_item_type = s9_item_type
    end

    # Return the {QName} which represents this type
    #
    # @return [Saxon::QName] the {QName} of the type
    def type_name
      @type_name ||= Saxon::QName.new(s9_item_type.getTypeName)
    end

    # @return [Saxon::S9API::ItemType] The underlying Saxon Java ItemType object
    def to_java
      s9_item_type
    end

    # compares two {ItemType}s using the underlying Saxon and XDM comparision rules
    # @param other [Saxon::ItemType]
    # @return [Boolean]
    def ==(other)
      return false unless other.is_a?(ItemType)
      s9_item_type.equals(other.to_java)
    end

    alias_method :eql?, :==

    # Return a hash code so this can be used as a key in a {::Hash}.
    # @return [Fixnum] the hash code
    def hash
      @hash ||= s9_item_type.hashCode
    end

    # Generate the appropriate lexical string representation of the value
    # given the ItemType's schema definition.
    #
    # Types with no explcit formatter defined just get to_s called on them...
    #
    # @param value [Object] The Ruby value to generate the lexical string
    #   representation of
    # @return [String] The XML Schema-defined lexical string representation of
    #   the value
    def lexical_string(value)
      lexical_string_convertor.call(value, self)
    end

    # Convert an XDM Atomic Value to an instance of an appropriate Ruby class, or return the lexical string.
    #
    # It's assumed that the XDM::AtomicValue is of this type, otherwise an error is raised.
    # @param xdm_atomic_value [Saxon::XDM::AtomicValue] The XDM atomic value to be converted.
    def ruby_value(xdm_atomic_value)
      value_to_ruby_convertor.call(xdm_atomic_value)
    end

    private

    def lexical_string_convertor
      @lexical_string_convertor ||= ATOMIC_VALUE_LEXICAL_STRING_CONVERTORS.fetch(s9_item_type, ->(value, item) { value.to_s })
    end

    def value_to_ruby_convertor
      @value_to_ruby_convertor ||= ATOMIC_VALUE_TO_RUBY_CONVERTORS.fetch(s9_item_type, ->(xdm_atomic_value) {
        xdm_atomic_value.to_s
      })
    end
  end
end