datamapper/dm-core

View on GitHub
lib/dm-core/query/conditions/comparison.rb

Summary

Maintainability
C
1 day
Test Coverage
module DataMapper
  class Query
    # The Conditions module contains classes used as part of a Query when
    # filtering collections of resources.
    #
    # The Conditions module contains two types of class used for filtering
    # queries: Comparison and Operation. Although these are used on all
    # repository types -- not just SQL-based repos -- these classes are best
    # thought of as being the DataMapper counterpart to an SQL WHERE clause.
    #
    # Comparisons compare properties and relationships with values, while
    # operations tie Comparisons together to form more complex expressions.
    #
    # For example, the following SQL query fragment:
    #
    #   ... WHERE my_field = my_value AND another_field = another_value ...
    #
    # ... would be represented as two EqualToComparison instances tied
    # together with an AndOperation.
    #
    # Conditions -- together with the Query class -- allow DataMapper to
    # represent SQL-like expressions in an ORM-agnostic manner, and are used
    # for both in-memory filtering of loaded Collection instances, and by
    # adapters to retrieve records directly from your repositories.
    #
    # The classes contained in the Conditions module are for internal use by
    # DataMapper and DataMapper plugins, and are not intended to be used
    # directly in your applications.
    module Conditions

      # An abstract class which provides easy access to comparison operators
      #
      # @example Creating a new comparison
      #   Comparison.new(:eql, MyClass.my_property, "value")
      #
      class Comparison

        # Creates a new Comparison instance
        #
        # The returned instance will be suitable for matching the given
        # subject (property or relationship) against the value.
        #
        # @param [Symbol] slug
        #   The type of comparison operator required. One of: :eql, :in, :gt,
        #   :gte, :lt, :lte, :regexp, :like.
        # @param [Property, Associations::Relationship]
        #   The subject of the comparison - the value of the subject will be
        #   matched against the given value parameter.
        # @param [Object] value
        #   The value for the comparison.
        #
        # @return [DataMapper::Query::Conditions::AbstractComparison]
        #
        # @example
        #   Comparison.new(:eql, MyClass.properties[:id], 1)
        #
        # @api semipublic
        def self.new(slug, subject, value)
          if klass = comparison_class(slug)
            klass.new(subject, value)
          else
            raise ArgumentError, "No Comparison class for #{slug.inspect} has been defined"
          end
        end

        # Returns an array of all slugs registered with Comparison
        #
        # @return [Array<Symbol>]
        #
        # @api private
        def self.slugs
          AbstractComparison.descendants.map { |comparison_class| comparison_class.slug }
        end

        class << self
          private

          # Holds comparison subclasses keyed on their slug
          #
          # @return [Hash]
          #
          # @api private
          def comparison_classes
            @comparison_classes ||= {}
          end

          # Returns the comparison class identified by the given slug
          #
          # @param [Symbol] slug
          #   See slug parameter for Comparison.new
          #
          # @return [AbstractComparison, nil]
          #
          # @api private
          def comparison_class(slug)
            comparison_classes[slug] ||= AbstractComparison.descendants.detect { |comparison_class| comparison_class.slug == slug }
          end
        end
      end # class Comparison

      # A base class for the various comparison classes.
      class AbstractComparison
        extend Equalizer

        equalize :subject, :value

        # @api semipublic
        attr_accessor :parent

        # The property or relationship which is being matched against
        #
        # @return [Property, Associations::Relationship]
        #
        # @api semipublic
        attr_reader :subject

        # Value to be compared with the subject
        #
        # This value is compared against that contained in the subject when
        # filtering collections, or the value in the repository when
        # performing queries.
        #
        # In the case of primitive property, this is the value as it
        # is stored in the repository.
        #
        # @return [Object]
        #
        # @api semipublic
        def value
          dumped_value
        end

        # The loaded/typecast value
        #
        # In the case of primitive types, this will be the same as +value+,
        # however when using primitive property this stores the loaded value.
        #
        # If writing an adapter, you should use +value+, while plugin authors
        # should refer to +loaded_value+.
        #
        #--
        # As an example, you might use symbols with the Enum type in dm-types
        #
        #   property :myprop, Enum[:open, :closed]
        #
        # These are stored in repositories as 1 and 2, respectively. +value+
        # returns the 1 or 2, while +loaded_value+ returns the symbol.
        #++
        #
        # @return [Object]
        #
        # @api semipublic
        attr_reader :loaded_value

        # Keeps track of AbstractComparison subclasses (used in Comparison)
        #
        # @return [Set<AbstractComparison>]
        # @api private
        def self.descendants
          @descendants ||= DescendantSet.new
        end

        # Registers AbstractComparison subclasses (used in Comparison)
        #
        # @api private
        def self.inherited(descendant)
          descendants << descendant
          super
        end

        # Setter/getter: allows subclasses to easily set their slug
        #
        # @param [Symbol] slug
        #   The slug to be set for this class. Passing nil returns the current
        #   value instead.
        #
        # @return [Symbol]
        #   The current slug set for the Comparison.
        #
        # @example Creating a MyComparison compairson with slug :exact.
        #   class MyComparison < AbstractComparison
        #     slug :exact
        #   end
        #
        # @api semipublic
        def self.slug(slug = nil)
          slug ? @slug = slug : @slug
        end

        # Return the comparison class slug
        #
        # @return [Symbol]
        #   the comparison class slug
        #
        # @api private
        def slug
          self.class.slug
        end

        # Test that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          match_property?(record)
        end

        # Tests that the Comparison is valid
        #
        # Subclasses can overload this to customise the means by which they
        # determine the validity of the comparison. #valid? is called prior to
        # performing a query on the repository: each Comparison within a Query
        # must be valid otherwise the query will not be performed.
        #
        # @see DataMapper::Property#valid?
        # @see DataMapper::Associations::Relationship#valid?
        #
        # @return [Boolean]
        #
        # @api semipublic
        def valid?
          valid_for_subject?(loaded_value)
        end

        # Returns whether the subject is a Relationship
        #
        # @return [Boolean]
        #
        # @api semipublic
        def relationship?
          false
        end

        # Returns whether the subject is a Property
        #
        # @return [Boolean]
        #
        # @api semipublic
        def property?
          subject.kind_of?(Property)
        end

        # Returns a human-readable representation of this object
        #
        # @return [String]
        #
        # @api semipublic
        def inspect
          "#<#{self.class} @subject=#{@subject.inspect} " \
            "@dumped_value=#{@dumped_value.inspect} @loaded_value=#{@loaded_value.inspect}>"
        end

        # Returns a string version of this Comparison object
        #
        # @example
        #   Comparison.new(:==, MyClass.my_property, "value")
        #   # => "my_property == value"
        #
        # @return [String]
        #
        # @api semipublic
        def to_s
          "#{subject.name} #{comparator_string} #{dumped_value.inspect}"
        end

        # @api private
        def negated?
          parent = self.parent
          parent ? parent.negated? : false
        end

        private

        # @api private
        attr_reader :dumped_value

        # Creates a new AbstractComparison instance with +subject+ and +value+
        #
        # @param [Property, Associations::Relationship] subject
        #   The subject of the comparison - the value of the subject will be
        #   matched against the given value parameter.
        # @param [Object] value
        #   The value for the comparison.
        #
        # @api semipublic
        def initialize(subject, value)
          @subject      = subject
          @loaded_value = typecast(value)
          @dumped_value = dump
        end

        # @api private
        def match_property?(record, operator = :===)
          expected.send(operator, record_value(record))
        end

        # Typecasts the given +val+ using subject#typecast
        #
        # If the subject has no typecast method the value is returned without
        # any changes.
        #
        # @param [Object] val
        #   The object to attempt to typecast.
        #
        # @return [Object]
        #   The typecasted object.
        #
        # @see Property#typecast
        #
        # @api private
        def typecast(value)
          typecast_property(value)
        end

        # @api private
        def typecast_property(value)
          subject.typecast(value)
        end

        # Dumps the given loaded_value using subject#value
        #
        # This converts property values to the primitive as stored in the
        # repository.
        #
        # @return [Object]
        #   The raw (dumped) object.
        #
        # @see Property#value
        #
        # @api private
        def dump
          dump_property(loaded_value)
        end

        # @api private
        def dump_property(value)
          subject.dump(value)
        end

        # Returns a value for the comparison +subject+
        #
        # Extracts value for the +subject+ property or relationship from the
        # given +record+, where +record+ is a Resource instance or a Hash.
        #
        # @param [DataMapper::Resource, Hash] record
        #   The resource or hash from which to retrieve the value.
        # @param [Property, Associations::Relationship]
        #   The subject of the comparison. For example, if this is a property,
        #   the value for the resources +subject+ property is retrieved.
        # @param [Symbol] key_type
        #   In the event that +subject+ is a relationship, key_type indicated
        #   which key should be used to retrieve the value from the resource.
        #
        # @return [Object]
        #
        # @api semipublic
        def record_value(record, key_type = :source_key)
          subject = self.subject
          case record
            when Hash
              record_value_from_hash(record, subject, key_type)
            when Resource
              record_value_from_resource(record, subject, key_type)
            else
              record
          end
        end

        # Returns a value from a record hash
        #
        # Retrieves value for the +subject+ property or relationship from the
        # given +hash+.
        #
        # @return [Object]
        #
        # @see AbstractComparison#record_value
        #
        # @api private
        def record_value_from_hash(hash, subject, key_type)
          hash.fetch subject, case subject
            when Property
              subject.load(hash[subject.field])
            when Associations::Relationship
              subject.send(key_type).map { |property|
                record_value_from_hash(hash, property, key_type)
              }
          end
        end

        # Returns a value from a resource
        #
        # Extracts value for the +subject+ property or relationship from the
        # given +resource+.
        #
        # @return [Object]
        #
        # @see AbstractComparison#record_value
        #
        # @api private
        def record_value_from_resource(resource, subject, key_type)
          case subject
            when Property
              subject.get!(resource)
            when Associations::Relationship
              subject.send(key_type).get!(resource)
          end
        end

        # Retrieves the value of the +subject+
        #
        # @return [Object]
        #
        # @api semipublic
        def expected(value = @loaded_value)
          expected = record_value(value, :target_key)

          if @subject.respond_to?(:source_key)
            @subject.source_key.typecast(expected)
          else
            expected
          end
        end

        # Test the value to see if it is valid
        #
        # @return [Boolean] true if the value is valid
        #
        # @api semipublic
        def valid_for_subject?(loaded_value)
          subject.valid?(loaded_value, negated?)
        end
      end # class AbstractComparison

      # Included into comparisons which are capable of supporting
      # Relationships.
      module RelationshipHandler
        # Returns whether this comparison subject is a Relationship
        #
        # @return [Boolean]
        #
        # @api semipublic
        def relationship?
          subject.kind_of?(Associations::Relationship)
        end

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          if relationship? && expected.respond_to?(:query)
            match_relationship?(record)
          else
            super
          end
        end

        # Returns the conditions required to match the subject relationship
        #
        # @return [Hash]
        #
        # @api semipublic
        def foreign_key_mapping
          relationship = subject.inverse
          relationship = relationship.links.first if relationship.respond_to?(:links)

          Query.target_conditions(value, relationship.source_key, relationship.target_key)
        end

        private

        # @api private
        def match_relationship?(record)
          expected.query.conditions.matches?(record_value(record))
        end

        # Typecasts each value in the inclusion set
        #
        # @return [Array<Object>]
        #
        # @see AbtractComparison#typecast
        #
        # @api private
        def typecast(value)
          if relationship?
            typecast_relationship(value)
          else
            super
          end
        end

        # @api private
        def dump
          if relationship?
            dump_relationship(loaded_value)
          else
            super
          end
        end

        # @api private
        def dump_relationship(value)
          value
        end
      end # module RelationshipHandler

      # Tests whether the value in the record is equal to the expected
      # set for the Comparison.
      class EqualToComparison < AbstractComparison
        include RelationshipHandler

        slug :eql

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          if expected.nil?
            record_value(record).nil?
          else
            super
          end
        end

        private

        # @api private
        def typecast_relationship(value)
          case value
            when Hash     then typecast_hash(value)
            when Resource then typecast_resource(value)
          end
        end

        # @api private
        def typecast_hash(hash)
          subject = self.subject
          subject.target_model.new(subject.query.merge(hash))
        end

        # @api private
        def typecast_resource(resource)
          resource
        end

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '='
        end
      end # class EqualToComparison

      # Tests whether the value in the record is contained in the
      # expected set for the Comparison, where expected is an
      # Array, Range, or Set.
      class InclusionComparison < AbstractComparison
        include RelationshipHandler

        slug :in

        # Checks that the Comparison is valid
        #
        # @see DataMapper::Query::Conditions::AbstractComparison#valid?
        #
        # @return [Boolean]
        #
        # @api semipublic
        def valid?
          loaded_value = self.loaded_value
          case loaded_value
            when Collection then valid_collection?(loaded_value)
            when Range      then valid_range?(loaded_value)
            when Enumerable then valid_enumerable?(loaded_value)
            else
              false
          end
        end

        private

        # @api private
        def match_property?(record)
          super(record, :include?)
        end

        # Overloads AbtractComparison#expected
        #
        # @return [Array<Object>]
        # @see AbtractComparison#expected
        #
        # @api private
        def expected
          loaded_value = self.loaded_value
          if loaded_value.kind_of?(Range)
            typecast_range(loaded_value)
          elsif loaded_value.respond_to?(:map)
            # FIXME: causes a lazy load when a Collection
            loaded_value.map { |val| super(val) }
          else
            super
          end
        end

        # @api private
        def valid_collection?(collection)
          valid_for_subject?(collection)
        end

        # @api private
        def valid_range?(range)
          (range.any? || negated?) && valid_for_subject?(range.first) && valid_for_subject?(range.last)
        end

        # @api private
        def valid_enumerable?(enumerable)
          (!enumerable.empty? || negated?) && enumerable.all? { |entry| valid_for_subject?(entry) }
        end

        # @api private
        def typecast_property(value)
          if value.kind_of?(Range)
            typecast_range(value)
          elsif value.respond_to?(:map) && !value.kind_of?(String)
            value.map { |entry| super(entry) }
          else
            super
          end
        end

        # @api private
        def typecast_range(range)
          range.class.new(typecast_property(range.first), typecast_property(range.last), range.exclude_end?)
        end

        # @api private
        def typecast_relationship(value)
          case value
            when Hash       then typecast_hash(value)
            when Resource   then typecast_resource(value)
            when Collection then typecast_collection(value)
            when Enumerable then typecast_enumerable(value)
          end
        end

        # @api private
        def typecast_hash(hash)
          subject = self.subject
          subject.target_model.all(subject.query.merge(hash))
        end

        # @api private
        def typecast_resource(resource)
          resource.collection_for_self
        end

        # @api private
        def typecast_collection(collection)
          collection
        end

        # @api private
        def typecast_enumerable(enumerable)
          collection = nil
          enumerable.each do |entry|
            typecasted = typecast_relationship(entry)
            if collection
              collection |= typecasted
            else
              collection = typecasted
            end
          end
          collection
        end

        # Dumps the given +val+ using subject#value
        #
        # @return [Array<Object>]
        #
        # @see AbtractComparison#dump
        #
        # @api private
        def dump
          loaded_value = self.loaded_value
          if subject.respond_to?(:dump) && loaded_value.respond_to?(:map) && !loaded_value.kind_of?(Range)
            dumped_value = loaded_value.map { |value| dump_property(value) }
            dumped_value.uniq!
            dumped_value
          else
            super
          end
        end

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          'IN'
        end
      end # class InclusionComparison

      # Tests whether the value in the record matches the expected
      # regexp set for the Comparison.
      class RegexpComparison < AbstractComparison
        slug :regexp

        # Checks that the Comparison is valid
        #
        # @see AbstractComparison#valid?
        #
        # @api semipublic
        def valid?
          loaded_value.kind_of?(Regexp)
        end

        private

        # Returns the value untouched
        #
        # @return [Object]
        #
        # @api private
        def typecast(value)
          value
        end

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '=~'
        end
      end # class RegexpComparison

      # Tests whether the value in the record is like the expected set
      # for the Comparison. Equivalent to a LIKE clause in an SQL database.
      #
      # TODO: move this to dm-more with DataObjectsAdapter plugins
      class LikeComparison < AbstractComparison
        slug :like

        private

        # Overloads the +expected+ method in AbstractComparison
        #
        # Return a regular expression suitable for matching against the
        # records value.
        #
        # @return [Regexp]
        #
        # @see AbtractComparison#expected
        #
        # @api semipublic
        def expected
          Regexp.new('\A' << super.gsub('%', '.*').tr('_', '.') << '\z')
        end

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          'LIKE'
        end
      end # class LikeComparison

      # Tests whether the value in the record is greater than the
      # expected set for the Comparison.
      class GreaterThanComparison < AbstractComparison
        slug :gt

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          return false if expected.nil?
          record_value = record_value(record)
          !record_value.nil? && record_value > expected
        end

        private

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '>'
        end
      end # class GreaterThanComparison

      # Tests whether the value in the record is less than the expected
      # set for the Comparison.
      class LessThanComparison < AbstractComparison
        slug :lt

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          return false if expected.nil?
          record_value = record_value(record)
          !record_value.nil? && record_value < expected
        end

        private

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '<'
        end
      end # class LessThanComparison

      # Tests whether the value in the record is greater than, or equal to,
      # the expected set for the Comparison.
      class GreaterThanOrEqualToComparison < AbstractComparison
        slug :gte

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          return false if expected.nil?
          record_value = record_value(record)
          !record_value.nil? && record_value >= expected
        end

        private

        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '>='
        end
      end # class GreaterThanOrEqualToComparison

      # Tests whether the value in the record is less than, or equal to, the
      # expected set for the Comparison.
      class LessThanOrEqualToComparison < AbstractComparison
        slug :lte

        # Tests that the record value matches the comparison
        #
        # @param [Resource, Hash] record
        #   The record containing the value to be matched
        #
        # @return [Boolean]
        #
        # @api semipublic
        def matches?(record)
          return false if expected.nil?
          record_value = record_value(record)
          !record_value.nil? && record_value <= expected
        end

        private

        # @return [String]
        #
        # @see AbstractComparison#to_s
        #
        # @api private
        def comparator_string
          '<='
        end
      end # class LessThanOrEqualToComparison

    end # module Conditions
  end # class Query
end # module DataMapper