ruby-rdf/spira

View on GitHub
lib/spira/base.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require "set"
require "active_model"
require "rdf/isomorphic"
require "active_support/core_ext/hash/indifferent_access"

require "spira/resource"
require "spira/persistence"
require "spira/validations"
require "spira/reflections"
require "spira/serialization"

module Spira

  ##
  # Spira::Base aims to perform similar to ActiveRecord::Base
  # You should inherit your models from it.
  #
  class Base
    extend ActiveModel::Callbacks
    extend ActiveModel::Naming
    include ActiveModel::Conversion
    include ActiveModel::Dirty
    include ActiveModel::Serialization

    include ::RDF, ::RDF::Enumerable, ::RDF::Queryable, Utils

    define_model_callbacks :save, :destroy, :create, :update

    ##
    # This instance's URI.
    #
    # @return [RDF::URI]
    attr_reader :subject

    class << self
      attr_reader :reflections, :properties

      def types
        Set.new
      end

      ##
      # The base URI for this class.  Attempts to create instances for non-URI
      # objects will be appended to this base URI.
      #
      # @return [Void]
      def base_uri
        # should be redefined in children, if required
        # see also Spira::Resource.configure :base_uri option
        nil
      end

      ##
      # The default vocabulary for this class.  Setting a default vocabulary
      # will allow properties to be defined without a `:predicate` option.
      # Predicates will instead be created by appending the property name to
      # the given string.
      #
      # @return [Void]
      def default_vocabulary
        # should be redefined in children, if required
        # see also Spira::Resource.configure :default_vocabulary option
        nil
      end

      def serialize(node, options = {})
        if node.respond_to?(:subject)
          node.subject
        elsif node.respond_to?(:blank?) && node.blank?
          nil
        else
          raise TypeError, "cannot serialize #{node.inspect} as a Spira resource"
        end
      end

      def unserialize(value, options = {})
        if value.respond_to?(:blank?) && value.blank?
          nil
        else
          # Spira resources are instantiated as "promised"
          # to avoid instantiation loops in case of resource-to-resource relations.
          promise { instantiate_record(value) }
        end
      end


      private

      def inherited(child)
        child.instance_variable_set :@properties, @properties.dup
        child.instance_variable_set :@reflections, @reflections.dup
        super
      end

      def instantiate_record(subj)
        new(_subject: id_for(subj))
      end

    end # class methods


    def id
      new_record? ? nil : subject.path.split(/\//).last
    end

    ##
    # Initialize a new Spira::Base instance of this resource class using
    # a new blank node subject.  Accepts a hash of arguments for initial
    # attributes.  To use a URI or existing blank node as a subject, use
    # the `.for` method on the subclass instead.
    #
    # @example
    #   class Person < Spira::Base; end
    #   bob = Person.for("bob")
    #
    # @param [Hash{Symbol => Any}] props Default attributes for this instance
    # @yield [self] Executes a given block
    # @yieldparam [self] self The newly created instance
    # @see Spira::Persistence::ClassMethods#for
    # @see RDF::URI#as
    # @see RDF::Node#as
    def initialize(props = {}, options = {})
      @subject = props.delete(:_subject) || RDF::Node.new
      @attrs = {}

      reload props

      yield self if block_given?
    end

    # Returns the attributes
    def attributes
      @attrs
    end

    # Freeze the attributes hash such that associations are still accessible, even on destroyed records.
    def freeze
      @attrs.freeze; self
    end

    # Returns +true+ if the attributes hash has been frozen.
    def frozen?
      @attrs.frozen?
    end

    ##
    # The `RDF.type` associated with this class.
    #
    # This just takes a first type from "types" list,
    # so make sure you know what you're doing if you use it.
    #
    # @return [nil,RDF::URI] The RDF type associated with this instance's class.
    def type
      self.class.type
    end

    ##
    # All `RDF.type` nodes associated with this class.
    #
    # @return [nil,RDF::URI] The RDF type associated with this instance's class.
    def types
      self.class.types
    end

    ##
    # Assign all attributes from the given hash.
    #
    def reload(props = {})
      reset_changes
      super
      assign_attributes(props)
      self
    end

    ##
    # Returns the RDF representation of this resource.
    #
    # @return [RDF::Enumerable]
    def to_rdf
      self
    end

    ##
    # A developer-friendly view of this projection
    #
    def inspect
      "<#{self.class}:#{self.object_id} @subject: #{@subject}>"
    end

    ##
    # Compare this instance with another instance.  The comparison is done on
    # an RDF level, and will work across subclasses as long as the attributes
    # are the same.
    #
    # @see https://rubygems.org/gems/rdf-isomorphic/
    def ==(other)
      # TODO: define behavior for equality on subclasses.
      # TODO: should we compare attributes here?
      if self.class == other.class
        subject == other.uri
      elsif other.is_a?(RDF::Enumerable)
        self.isomorphic_with?(other)
      else
        false
      end
    end

    ##
    # Returns true for :to_uri if this instance's subject is a URI, and false if it is not.
    # Returns true for :to_node if this instance's subject is a Node, and false if it is not.
    # Calls super otherwise.
    #
    def respond_to?(*args)
      case args[0]
      when :to_uri
        subject.respond_to?(:to_uri)
      when :to_node
        subject.node?
      else
        super(*args)
      end
    end

    ##
    # Returns the RDF::URI associated with this instance if this instance's
    # subject is an RDF::URI, and nil otherwise.
    #
    # @return [RDF::URI,nil]
    def uri
      subject.respond_to?(:to_uri) ? subject : nil
    end

    ##
    # Returns the URI representation of this resource, if available.  If this
    # resource's subject is a BNode, raises a NoMethodError.
    #
    # @return [RDF::URI]
    # @raise [NoMethodError]
    def to_uri
      uri || (raise NoMethodError, "No such method: :to_uri (this instance's subject is not a URI)")
    end

    ##
    # Returns true if the subject associated with this instance is a blank node.
    #
    # @return [true, false]
    def node?
      subject.node?
    end

    ##
    # Returns the Node subject of this resource, if available.  If this
    # resource's subject is a URI, raises a NoMethodError.
    #
    # @return [RDF::Node]
    # @raise [NoMethodError]
    def to_node
      subject.node? ? subject : (raise NoMethodError, "No such method: :to_uri (this instance's subject is not a URI)")
    end

    ##
    # Returns a new instance of this class with the new subject instead of self.subject
    #
    # @param [RDF::Resource] new_subject
    # @return [Spira::Base] copy
    def copy(new_subject)
      self.class.new(@attrs.merge(_subject: new_subject))
    end

    ##
    # Returns a new instance of this class with the new subject instead of
    # self.subject after saving the new copy to the repository.
    #
    # @param [RDF::Resource] new_subject
    # @return [Spira::Base, String] copy
    def copy!(new_subject)
      copy(new_subject).save!
    end

    ##
    # Assign attributes to the resource
    # without persisting it.
    def assign_attributes(attrs)
      attrs.each do |name, value|
        attribute_will_change!(name.to_s)
        send "#{name}=", value
      end
    end


    private

    def reset_changes
      clear_changes_information
    end

    def write_attribute(name, value)
      name = name.to_s
      if self.class.properties[name]
        if @attrs[name].is_a?(Promise)
          changed_attributes[name] = @attrs[name] unless changed_attributes.include?(name)
          @attrs[name] = value
        else
          if value != read_attribute(name)
            attribute_will_change!(name)
            @attrs[name] = value
          end
        end
      else
        raise Spira::PropertyMissingError, "attempt to assign a value to a non-existing property '#{name}'"
      end
    end

    ##
    # Get the current value for the given attribute
    #
    def read_attribute(name)
      value = @attrs[name.to_s]

      refl = self.class.reflections[name]
      if refl && !value
        # yield default values for empty reflections
        case refl.macro
        when :has_many
          # TODO: this should be actually handled by the reflection class
          []
        end
      else
        value
      end
    end

    ## Localized properties functions

    def merge_localized_property(name, arg)
      values = read_attribute("#{name}_native")
      values.delete_if { |s| s.language == I18n.locale }
      values << serialize_localized_property(arg, I18n.locale) if arg
      values
    end

    def serialize_localized_property(value, locale)
      RDF::Literal.new(value, language: locale)
    end

    def unserialize_localized_properties(values, locale)
      v = values.detect { |s| s.language == locale || s.simple? }
      v && v.object
    end

    def hash_localized_properties(values)
      values.inject({}) do |out, v|
        out[v.language] = v.object
        out
      end
    end

    def serialize_hash_localized_properties(values)
      values.map { |lang, property| RDF::Literal.new(property, language: lang) }
    end

    # Build a Ruby value from an RDF value.
    def build_value(node, type)
      klass = classize_resource(type)
      if klass.respond_to?(:unserialize)
        klass.unserialize(node)
      else
        raise TypeError, "Unable to unserialize #{node} as #{type}"
      end
    end

    # Build an RDF value from a Ruby value for a property
    def build_rdf_value(value, type)
      klass = classize_resource(type)
      if klass.respond_to?(:serialize)
        klass.serialize(value)
      else
        raise TypeError, "Unable to serialize #{value} as #{type}"
      end
    end

    def valid_object?(node)
      node && (!node.literal? || node.valid?)
    end

    extend Resource
    extend Reflections
    include Types
    include Persistence
    include Validations
    include Serialization

    @reflections = HashWithIndifferentAccess.new
    @properties = HashWithIndifferentAccess.new
  end
end