ruby-rdf/spira

View on GitHub
lib/spira/persistence.rb

Summary

Maintainability
C
1 day
Test Coverage
module Spira
  module Persistence
    extend ActiveSupport::Concern

    module ClassMethods

      ##
      # The current repository for this class
      #
      # @return [RDF::Repository, nil]
      def repository
        Spira.repository || raise(NoRepositoryError)
      end

      ##
      # Simple finder method.
      #
      # @param [Symbol, ID] scope
      #   scope can be :all, :first or an ID
      # @param [Hash] args
      #   args can contain:
      #     :conditions - Hash of properties and values
      #     :limit      - Integer, limiting the amount of returned records
      # @return [Spira::Base, Array]
      def find(scope, *args)
        case scope
        when :first
          find_each(*args).first
        when :all
          find_all(*args)
        else
          instantiate_record(scope)
        end
      end

      def all(*args)
        find(:all, *args)
      end

      def first(*args)
        find(:first, *args)
      end

      ##
      # Enumerate over all resources projectable as this class.  This method is
      # only valid for classes which declare a `type` with the `type` method in
      # the Resource.
      #
      # Note that the instantiated records are "promises" not real instances.
      #
      # @raise  [Spira::NoTypeError] if the resource class does not have an RDF type declared
      # @overload each
      #   @yield [instance] A block to perform for each available projection of this class
      #   @yieldparam [self] instance
      #   @yieldreturn [Void]
      #   @return [Void]
      #
      # @overload each
      #   @return [Enumerator]
      def each(*args)
        raise Spira::NoTypeError, "Cannot count a #{self} without a reference type URI" unless type

        options = args.extract_options!
        conditions = options.delete(:conditions) || {}

        raise Spira::SpiraError, "Cannot accept :type in query conditions" if conditions.delete(:type) || conditions.delete("type")

        if block_given?
          limit = options[:limit] || -1
          offset = options[:offset] || 0
          # TODO: ideally, all types should be joined in a conjunction
          #       within "conditions_to_query", but since RDF::Query
          #       cannot handle such patterns, we iterate across types "manually"
          types.each do |tp|
            break if limit.zero?
            q = conditions_to_query(conditions.merge(type: tp))
            repository.query(q) do |solution|
              break if limit.zero?
              if offset.zero?
                yield unserialize(solution[:subject])
                limit -= 1
              else
                offset -= 1
              end
            end
          end
        else
          enum_for(:each, *args)
        end
      end
      alias_method :find_each, :each

      ##
      # The number of URIs projectable as a given class in the repository.
      # This method is only valid for classes which declare a `type` with the
      # `type` method in the Resource.
      #
      # @raise  [Spira::NoTypeError] if the resource class does not have an RDF type declared
      # @return [Integer] the count
      def count
        each.count
      end

      # Creates an object (or multiple objects) and saves it to the database, if validations pass.
      # The resulting object is returned whether the object was saved successfully to the database or not.
      #
      # The +attributes+ parameter can be either be a Hash or an Array of Hashes. These Hashes describe the
      # attributes on the objects that are to be created.
      #
      # +create+ respects mass-assignment security and accepts either +:as+ or +:without_protection+ options
      # in the +options+ parameter.
      #
      # ==== Examples
      #   # Create a single new object
      #   User.create(first_name: 'Jamie')
      #
      #   # Create a single new object using the :admin mass-assignment security role
      #   User.create({ first_name: 'Jamie', is_admin: true }, as: :admin)
      #
      #   # Create a single new object bypassing mass-assignment security
      #   User.create({ first_name: 'Jamie', is_admin: true }, without_protection: true)
      #
      #   # Create an Array of new objects
      #   User.create([{ first_name: 'Jamie' }, { first_name: 'Jeremy' }])
      #
      #   # Create a single object and pass it into a block to set other attributes.
      #   User.create(first_name: 'Jamie') do |u|
      #     u.is_admin = false
      #   end
      #
      #   # Creating an Array of new objects using a block, where the block is executed for each object:
      #   User.create([{ first_name: 'Jamie' }, { first_name: 'Jeremy' }]) do |u|
      #     u.is_admin = false
      #   end
      def create(attributes = nil, options = {}, &block)
        if attributes.is_a?(Array)
          attributes.collect { |attr| create(attr, options, &block) }
        else
          object = new(attributes, options, &block)
          object.save
          object
        end
      end

      ##
      # Create a new projection instance of this class for the given URI.  If a
      # class has a base_uri given, and the argument is not an `RDF::URI`, the
      # given identifier will be appended to the base URI.
      #
      # Spira does not have 'find' or 'create' functions.  As RDF identifiers
      # are globally unique, they all simply 'are'.
      #
      # On calling `for`, a new projection is created for the given URI.  The
      # first time access is attempted on a field, the repository will be
      # queried for existing attributes, which will be used for the given URI.
      # Underlying repositories are not accessed at the time of calling `for`.
      #
      # A class with a base URI may still be projected for any URI, whether or
      # not it uses the given resource class' base URI.
      #
      # @raise [TypeError] if an RDF type is given in the attributes and one is
      # given in the attributes.
      # @raise [ArgumentError] if a non-URI is given and the class does not
      # have a base URI.
      # @overload for(uri, attributes = {})
      #   @param [RDF::URI] uri The URI to create an instance for
      #   @param [Hash{Symbol => Any}] attributes Initial attributes
      # @overload for(identifier, attributes = {})
      #   @param [Any] uri The identifier to append to the base URI for this class
      #   @param [Hash{Symbol => Any}] attributes Initial attributes
      # @yield [self] Executes a given block and calls `#save!`
      # @yieldparam [self] self The newly created instance
      # @return  [Spira::Base] The newly created instance
      # @see https://ruby-rdf.github.io/rdf/RDF/URI.html
      def for(identifier, attributes = {}, &block)
        self.project(id_for(identifier), attributes, &block)
      end
      alias_method :[], :for

      ##
      # Create a new instance with the given subject without any modification to
      # the given subject at all.  This method exists to provide an entry point
      # for implementing classes that want to create a more intelligent .for
      # and/or .id_for for their given use cases, such as simple string
      # appending to base URIs or calculated URIs from other representations.
      #
      # @example Using simple string concatentation with base_uri in .for instead of joining delimiters
      #     def for(identifier, attributes = {}, &block)
      #       self.project(RDF::URI(self.base_uri.to_s + identifier.to_s), attributes, &block)
      #     end
      # @param [RDF::URI, RDF::Node] subject
      # @param [Hash{Symbol => Any}] attributes Initial attributes
      # @return [Spira::Base] the newly created instance
      def project(subject, attributes = {}, &block)
        new(attributes.merge(_subject: subject), &block)
      end

      ##
      # Creates a URI or RDF::Node based on a potential base_uri and string,
      # URI, or Node, or Addressable::URI.  If not a URI or Node, the given
      # identifier should be a string representing an absolute URI, or
      # something responding to to_s which can be appended to a base URI, which
      # this class must have.
      #
      # @param  [Any] identifier
      # @return [RDF::URI, RDF::Node]
      # @raise  [ArgumentError] If this class cannot create an identifier from the given argument
      # @see https://ruby-rdf.github.io/rdf/RDF/URI.html
      # @see Spira.base_uri
      # @see Spira.for
      def id_for(identifier)
        case
          # Absolute URI's go through unchanged
        when identifier.is_a?(RDF::URI) && identifier.absolute?
          identifier
          # We don't have a base URI to join this fragment with, so go ahead and instantiate it as-is.
        when identifier.is_a?(RDF::URI) && self.base_uri.nil?
          identifier
          # Blank nodes go through unchanged
        when identifier.respond_to?(:node?) && identifier.node?
          identifier
          # Anything that can be an RDF::URI, we re-run this case statement
          # on it for the fragment logic above.
        when identifier.respond_to?(:to_uri) && !identifier.is_a?(RDF::URI)
          id_for(identifier.to_uri)
          # see comment with #to_uri above, this might be a fragment
        else
          uri = identifier.is_a?(RDF::URI) ? identifier : RDF::URI.intern(identifier.to_s)
          case
          when uri.absolute?
            uri
          when self.base_uri.nil?
            raise ArgumentError, "Cannot create identifier for #{self} by String without base_uri; an RDF::URI is required"
          else
            separator = self.base_uri.to_s[-1,1] =~ /(\/|#)/ ? '' : '/'
            RDF::URI.intern(self.base_uri.to_s + separator + identifier.to_s)
          end
        end
      end


      private

      def find_all(*args)
        [].tap do |records|
          find_each(*args) do |record|
            records << record
          end
        end
      end

      def conditions_to_query(conditions)
        patterns = []
        conditions.each do |name, value|
          if name.to_s == "type"
            patterns << [:subject, RDF.type, value]
          else
            patterns << [:subject, properties[name][:predicate], value]
          end
        end

        RDF::Query.new do
          patterns.each { |pat| pattern(pat) }
        end
      end
    end

    # A resource is considered to be new if the repository
    # does not have statements where subject == resource type
    def new_record?
      !self.class.repository.has_subject?(subject)
    end

    def destroyed?
      @destroyed
    end

    def persisted?
      # FIXME: an object should be considered persisted
      # when its attributes (and their exact values) are all available in the storage.
      # This should check for !(changed? || new_record? || destroyed?) actually.
      !(new_record? || destroyed?)
    end

    def save(*)
      create_or_update
    end

    def save!(*)
      create_or_update || raise(RecordNotSaved)
    end

    def destroy(*args)
      run_callbacks :destroy do
        destroy_model_data(*args)
      end
    end

    def destroy!(*args)
      destroy(*args) || raise(RecordNotSaved)
    end

    ##
    # Enumerate each RDF statement that makes up this projection.  This makes
    # each instance an `RDF::Enumerable`, with all of the nifty benefits
    # thereof.  See <https://ruby-rdf.github.io/rdf/RDF/Enumerable.html> for
    # information on arguments.
    #
    # @see https://ruby-rdf.github.io/rdf/RDF/Enumerable.html
    def each
      if block_given?
        self.class.properties.each do |name, property|
          if value = read_attribute(name)
            if self.class.reflect_on_association(name)
              value.each do |val|
                node = build_rdf_value(val, property[:type])
                yield RDF::Statement.new(subject, property[:predicate], node) if valid_object?(node)
              end
            else
              node = build_rdf_value(value, property[:type])
              yield RDF::Statement.new(subject, property[:predicate], node) if valid_object?(node)
            end
          end
        end
        self.class.types.each do |t|
          yield RDF::Statement.new(subject, RDF.type, t)
        end
      else
        enum_for(:each)
      end
    end

    ##
    # The number of RDF::Statements this projection has.
    #
    # @see https://ruby-rdf.github.io/rdf/RDF/Enumerable.html#count
    def count
      each.count
    end

    ##
    # Update multiple attributes of this repository.
    #
    # @example Update multiple attributes
    #     person.update_attributes(name: 'test', age: 10)
    #     #=> person
    #     person.name
    #     #=> 'test'
    #     person.age
    #     #=> 10
    #     person.dirty?
    #     #=> true
    # @param  [Hash{Symbol => Any}] properties
    # @param  [Hash{Symbol => Any}] options
    # @return [self]
    def update_attributes(properties, options = {})
      assign_attributes properties
      save options
    end

    ##
    # Reload all attributes for this instance.
    # This resource will block if the underlying repository
    # blocks the next time it accesses attributes.
    #
    # NB: "props" argument is ignored, it is handled in Base
    #
    def reload(props = {})
      sts = self.class.repository.query({subject: subject})
      self.class.properties.each do |name, options|
        name = name.to_s
        if sts
          objects = sts.select { |s| s.predicate == options[:predicate] }
          attributes[name] = retrieve_attribute(name, options, objects)
        end
      end
    end


    private

    def create_or_update
      run_callbacks :save do
        # "create" callback is triggered only when persisting a resource definition
        persistance_callback = new_record? && type ? :create : :update
        run_callbacks persistance_callback do
          materizalize
          persist!
          reset_changes
        end
      end
      self
    end

    ##
    # Save changes to the repository
    #
    def persist!
      repo = self.class.repository
      self.class.properties.each do |name, property|
        value = read_attribute name
        if self.class.reflect_on_association(name)
          # TODO: for now, always persist associations,
          #       as it's impossible to reliably determine
          #       whether the "association property" was changed
          #       (e.g. for "in-place" changes like "association << 1")
          #       This should be solved by splitting properties
          #       into "true attributes" and associations
          #       and not mixing the both in @properties.
          repo.delete [subject, property[:predicate], nil]
          value.each do |val|
            store_attribute(name, val, property[:predicate], repo)
          end
        else
          if attribute_changed?(name.to_s)
            repo.delete [subject, property[:predicate], nil]
            store_attribute(name, value, property[:predicate], repo)
          end
        end
      end
      types.each do |type|
        # NB: repository won't accept duplicates,
        #     but this should be avoided anyway, for performance
        repo.insert RDF::Statement.new(subject, RDF.type, type)
      end
    end

    # "Materialize" the resource:
    # assign a persistable subject to a non-persisted resource,
    # so that it can be properly stored.
    def materizalize
      if new_record? && subject.anonymous? && type
        # TODO: doesn't subject.anonymous? imply subject.id == nil ???
        @subject = self.class.id_for(subject.id)
      end
    end

    def store_attribute(property, value, predicate, repository)
      unless value.nil?
        val = build_rdf_value(value, self.class.properties[property][:type])
        repository.insert RDF::Statement.new(subject, predicate, val) if valid_object?(val)
      end
    end

    # Directly retrieve an attribute value from the storage
    def retrieve_attribute(name, options, sts)
      if self.class.reflections[name]
        sts.inject([]) do |values, statement|
          if statement.predicate == options[:predicate]
            values << build_value(statement.object, options[:type])
          else
            values
          end
        end
      else
        sts.first ? build_value(sts.first.object, options[:type]) : nil
      end
    end

    # Destroy all model data
    # AND non-model data, where this resource is referred to as object.
    def destroy_model_data(*args)
      if self.class.repository.delete(statements) && self.class.repository.delete([nil, nil, subject])
        @destroyed = true
        freeze
      end
    end

    # Return the appropriate class object for a string or symbol
    # representation.  Throws errors correctly if the given class cannot be
    # located, or if it is not a Spira::Base
    #
    def classize_resource(type)
      return type unless type.is_a?(Symbol) || type.is_a?(String)

      klass = nil
      begin
        klass = qualified_const_get(type.to_s)
      rescue NameError
        raise NameError, "Could not find relation class #{type} (referenced as #{type} by #{self})"
      end
      klass
    end

    # Resolve a constant from a string, relative to this class' namespace, if
    # available, and from root, otherwise.
    #
    # FIXME: this is not really 'qualified', but it's one of those
    # impossible-to-name functions.  Open to suggestions.
    #
    # @author njh
    # @private
    def qualified_const_get(str)
      path = str.to_s.split('::')
      from_root = path[0].empty?
      if from_root
        from_root = []
        path = path[1..-1]
      else
        start_ns = ((Class === self)||(Module === self)) ? self : self.class
        from_root = start_ns.to_s.split('::')
      end
      until from_root.empty?
        begin
          return (from_root+path).inject(Object) { |ns,name| ns.const_get(name) }
        rescue NameError
          from_root.delete_at(-1)
        end
      end
      path.inject(Object) { |ns,name| ns.const_get(name) }
    end

  end
end