wrstudios/frodata

View on GitHub
lib/frodata/entity.rb

Summary

Maintainability
C
7 hrs
Test Coverage
module FrOData
  # An FrOData::Entity represents a single record returned by the service. All
  # Entities have a type and belong to a specific namespace. They are written
  # back to the service via the EntitySet they came from. FrOData::Entity
  # instances should not be instantiated directly; instead, they should either
  # be read or instantiated from their respective FrOData::EntitySet.
  class Entity
    # The Entity type name
    attr_reader :type
    # The FrOData::Service's identifying name
    attr_reader :service_name
    # The entity set this entity belongs to
    attr_reader :entity_set
    # List of errors on entity
    attr_reader :errors

    PROPERTY_NOT_LOADED = :not_loaded

    XML_NAMESPACES = {
      'xmlns'           => 'http://www.w3.org/2005/Atom',
      'xmlns:data'      => 'http://docs.oasis-open.org/odata/ns/data',
      'xmlns:metadata'  => 'http://docs.oasis-open.org/odata/ns/metadata',
      'xmlns:georss'    => 'http://www.georss.org/georss',
      'xmlns:gml'       => 'http://www.opengis.net/gml',
    }.freeze

    # Initializes a bare Entity
    # @param options [Hash]
    def initialize(options = {})
      @id = options[:id]
      @type = options[:type]
      @service_name = options[:service_name]
      @entity_set = options[:entity_set]
      @context = options[:context]
      @links = options[:links]
      @errors = []
    end

    def namespace
      @namespace ||= type.rpartition('.').first
    end

    # Returns name of Entity from Service specified type.
    # @return [String]
    def name
      @name ||= type.split('.').last
    end

    # Returns context URL for this entity
    # @return [String]
    def context
      @context ||= context_url
    end

    # Get property value
    # @param property_name [to_s]
    # @return [*]
    def [](property_name)
      if get_property(property_name).is_a?(::FrOData::Properties::Complex)
        get_property(property_name)
      else
        get_property(property_name).value
      end
    end

    # Set property value
    # @param property_name [to_s]
    # @param value [*]
    def []=(property_name, value)
      get_property(property_name).value = value
    end

    def get_property(property_name)
      prop_name = property_name.to_s
      # Property is lazy loaded
      if properties_xml_value.has_key?(prop_name)
        property = instantiate_property(prop_name, properties_xml_value[prop_name])
        set_property(prop_name, property.dup)
        properties_xml_value.delete(prop_name)
      end

      if properties.has_key? prop_name
        properties[prop_name]
      elsif navigation_properties.has_key? prop_name
        navigation_properties[prop_name]
      else
        raise ArgumentError, "Unknown property: #{property_name}"
      end
    end

    def property_names
      [
        @properties_xml_value.andand.keys,
        @properties.andand.keys
      ].compact.flatten
    end

    def navigation_property_names
      navigation_properties.keys
    end

    def navigation_properties
      @navigation_properties ||= links.keys.map do |nav_name|
        [
          nav_name,
          FrOData::NavigationProperty::Proxy.new(self, nav_name)
        ]
      end.to_h
    end

    # Links to other FrOData entitites
    # @return [Hash]
    def links
      @links ||= schema.navigation_properties[name].map do |nav_name, details|
        [
          nav_name,
          { type: details.nav_type, href: "#{id}/#{nav_name}" }
        ]
      end.to_h
    end

    # Create Entity with provided properties and options.
    # @param new_properties [Hash]
    # @param options [Hash]
    # @param [FrOData::Entity]
    def self.with_properties(new_properties = {}, options = {})
      entity = FrOData::Entity.new(options)
      entity.instance_eval do
        service.properties_for_entity(type).each do |property_name, instance|
          set_property(property_name, instance)
        end

        new_properties.each do |property_name, property_value|
          self[property_name] = property_value
        end
      end
      entity
    end

    # Create Entity from JSON document with provided options.
    # @param json [Hash|to_s]
    # @param options [Hash]
    # @return [FrOData::Entity]
    def self.from_json(json, options = {})
      return nil if json.nil?
      json = JSON.parse(json.to_s) unless json.is_a?(Hash)
      metadata = extract_metadata(json)
      options.merge!(context: metadata['@odata.context'])
      entity = with_properties(json, options)
      process_metadata(entity, metadata)
      entity
    end

    # Create Entity from XML document with provided options.
    # @param xml_doc [Nokogiri::XML]
    # @param options [Hash]
    # @return [FrOData::Entity]
    def self.from_xml(xml_doc, options = {})
      return nil if xml_doc.nil?
      entity = FrOData::Entity.new(options)
      process_properties(entity, xml_doc)
      process_links(entity, xml_doc)
      entity
    end

    # Converts Entity to its XML representation.
    # @return [String]
    def to_xml
      namespaces = XML_NAMESPACES.merge('xml:base' => service.service_url)
      builder = Nokogiri::XML::Builder.new do |xml|
        xml.entry(namespaces) do
          xml.category(term: type,
                       scheme: 'http://docs.oasis-open.org/odata/ns/scheme')
          xml.author { xml.name }

          xml.content(type: 'application/xml') do
            xml['metadata'].properties do
              property_names.each do |name|
                next if name == primary_key
                get_property(name).to_xml(xml)
              end
            end
          end
        end
      end
      builder.to_xml
    end

    # Converts Entity to its JSON representation.
    # @return [String]
    def to_json
      # TODO: add @odata.context
      to_hash.to_json
    end

    # Converts Entity to a hash.
    # @return [Hash]
    def to_hash
      property_names.map do |name|
        [name, get_property(name).json_value]
      end.to_h
    end

    # Returns the canonical URL for this entity
    # @return [String]
    def id
      @id ||= lambda {
        entity_set = self.entity_set.andand.name
        entity_set ||= context.split('#').last.split('/').first
        "#{entity_set}(#{self[primary_key]})"
      }.call
    end

    # Returns the primary key for the Entity.
    # @return [String]
    def primary_key
      schema.primary_key_for(name)
    end

    def is_new?
      self[primary_key].nil?
    end

    def any_errors?
      !errors.empty?
    end

    def service
      @service ||= FrOData::ServiceRegistry[service_name]
    end

    def schema
      @schema ||= service.schemas[namespace]
    end

    private

    def instantiate_property(property_name, value_xml)
      prop_type = schema.get_property_type(name, property_name)
      prop_type, value_type = prop_type.split(/\(|\)/)

      if prop_type == 'Collection'
        klass = ::FrOData::Properties::Collection
        options = { value_type: value_type }
      else
        klass = ::FrOData::PropertyRegistry[prop_type]
        options = {}
      end

      if klass.nil?
        raise RuntimeError, "Unknown property type: #{prop_type}"
      else
        klass.from_xml(value_xml, options.merge(service: service))
      end
    end

    def properties
      @properties ||= {}
    end

    def properties_xml_value
      @properties_xml_value ||= {}
    end

    # Computes the entity's canonical context URL
    def context_url
      "#{service.service_url}/$metadata##{entity_set.name}/$entity"
    end

    def set_property(name, property)
      properties[name.to_s] = property
    end

    # Instantiating properties takes time, so we can lazy load properties by passing xml_value and lookup when needed
    def set_property_lazy_load(name, xml_value )
      properties_xml_value[name.to_s] = xml_value
    end

    def self.process_properties(entity, xml_doc)
      entity.instance_eval do
        unless instance_variable_get(:@context)
          context = xml_doc.xpath('/entry').first.andand['context']
          instance_variable_set(:@context, context)
        end

        xml_doc.xpath('./content/properties/*').each do |property_xml|
          # Doing lazy loading here because instantiating each object takes a long time
          set_property_lazy_load(property_xml.name, property_xml)
        end
      end
    end

    def self.process_links(entity, xml_doc)
      entity.instance_eval do
        new_links = instance_variable_get(:@links) || {}
        schema.navigation_properties[name].each do |nav_name, details|
          xml_doc.xpath("./link[@title='#{nav_name}']").each do |node|
            next if node.attributes['type'].nil?
            next unless node.attributes['type'].value =~ /^application\/atom\+xml;type=(feed|entry)$/i
            link_type = node.attributes['type'].value =~ /type=entry$/i ? :entity : :collection
            new_links[nav_name] = {
              type: link_type,
              href: node.attributes['href'].value
            }
          end
        end
        instance_variable_set(:@links, new_links)
      end
    end

    def self.extract_metadata(json)
      metadata = json.select { |key, val| key =~ /@odata/ }
      json.delete_if { |key, val| key =~ /@odata/ }
      metadata
    end

    def self.process_metadata(entity, metadata)
      entity.instance_eval do
        new_links = instance_variable_get(:@links) || {}
        schema.navigation_properties[name].each do |nav_name, details|
          href = metadata["#{nav_name}@odata.navigationLink"]
          next if href.nil?
          new_links[nav_name] = {
            type: details.nav_type,
            href: href
          }
        end
        instance_variable_set(:@links, new_links) unless new_links.empty?
      end
    end
  end
end