lib/aws/record/scope.rb



# Copyright 2011 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
  
module AWS
  module Record

    # The primary interface for finding records with AWS::Record.
    #
    # == Getting a Scope Object
    #
    # You should normally never need to construct a Scope object directly.
    # Scope objects are returned from the AWS::Record::Base finder methods
    # (e.g. +find+ +all+, +where+, +order+, +limit+, etc).
    #
    #   books = Book.where(:author => 'John Doe')
    #   books.class #=> AWS::Record::Scope, not Array
    #
    # Scopes are also returned from methods defined with the +scope+ method.
    #
    # == Delayed Execution
    #
    # Scope objects represent a select expression, but do not actually
    # cause a request to be made until enumerated.
    #
    #   # no request made yet
    #   books = Book.where(:author => 'John Doe')
    #
    #   # a request is made now
    #   books.each {|book| ... }
    #
    # You can refine a scope object by calling other scope methods on
    # it.
    #
    #   # refine the previous books Scope, no request
    #   top_10 = books.order(:popularity, :desc).limit(10)
    #
    #   # another request is made now
    #   top_10.first
    #
    class Scope
  
      include Enumerable
      
      # @param [Record::Base] base_class A class that extends 
      #   {AWS::Record::Base}.  
      # @param [Hash] options
      # @option options :
      # @private
      def initialize base_class, options = {}
        @base_class = base_class
        @options = options
      end
  
      # @return [Class] Returns the AWS::Record::Base extending class that
      #   this scope will find records for.
      attr_reader :base_class
  
      # @overload find(id)
      #   Finds and returns a single record by id.  If no record is found
      #   with the given +id+, then a RecordNotFound error will be raised.
      #   @param [String] id ID of the record to find.
      #   @return [Record::Base] Returns the record.
      #   
      # @overload find(:first, options = {})
      #   Returns the first record found.  If no records were matched then
      #   nil will be returned (raises no exceptions).
      #   @param [Symbol] mode (:first)
      #   @return [Object,nil] Returns the first record or nil if no
      #     records matched the conditions.
      #
      # @overload find(:all, options = {})
      #   Returns an enumerable Scope object that represents all matching
      #   records.  No request is made to AWS until the scope is enumerated.
      #
      #     Book.find(:all, :limit => 100).each do |book|
      #       # ...
      #     end
      #
      #   @param [Symbol] mode (:all)
      #   @return [Scope] Returns an enumerable scope object.
      #
      def find id_or_mode, options = {}

        scope = _handle_options(options)

        case
        when id_or_mode == :all   then scope
        when id_or_mode == :first then scope.limit(1).first
        when scope.send(:_empty?) then base_class[id_or_mode]
        else
          object = scope.where('itemName() = ?', id_or_mode).limit(1).first
          if object.nil?
            raise RecordNotFound, "no data found for record `#{id_or_mode}`"
          end
          object
        end
  
      end

      # @return [Integer] Returns the number of records that match the
      #   current scoped finder.
      def count options = {}
        if scope = _handle_options(options) and scope != self
          scope.count
        else
          _item_collection.count
        end
      end
      alias_method :size, :count

      # Applies conditions to the scope that limit which records are returned.
      # Only those matching all given conditions will be returned.
      #
      # @overload where(conditions_hash)
      #   Specify a hash of conditions to query with.  Multiple conditions
      #   are joined together with AND.
      #
      #     Book.where(:author => 'John Doe', :softcover => true)
      #     # where `author` = `John Doe` AND `softcover` = `1`
      #
      #   @param [Hash] conditions
      #
      # @overload where(conditions_string, *values)
      #   A sql-like query fragment with optional placeholders and values.
      #   Placeholders are replaced with properly quoted values.
      #
      #     Book.where('author = ?', 'John Doe')
      #
      #   @param [String] conditions_string A sql-like where string with
      #     question mark placeholders.  For each placeholder there should
      #     be a value that will be quoted into that position.
      #   @param [String] *values A value that should be quoted into the
      #     corresponding (by position) placeholder.
      #
      # @return [Scope] Returns a new scope with the passed conditions applied.
      def where *conditions
        if conditions.empty?
          raise ArgumentError, 'missing required condition'
        end
        _with(:where => Record.as_array(@options[:where]) + [conditions])
      end
  
      # Specifies how to sort records returned.  
      #
      #   # enumerate books, starting with the most recently published ones
      #   Book.order(:published_at, :desc).each do |book|
      #     # ...
      #   end
      #
      # Only one order may be applied.  If order is specified more than
      # once the last one in the chain takes precedence:
      #
      #    
      #   # books returned by this scope will be ordered by :published_at
      #   # and not :author.
      #   Book.where(:read => false).order(:author).order(:published_at)
      #
      # @param [String,Symbol] attribute_name The attribute to sort by.
      # @param [:asc, :desc] order (:asc) The direct to sort.
      def order attribute_name, order = :asc
        _with(:order => [attribute_name, order])
      end
  
      # Limits the maximum number of total records to return when finding
      # or counting.  Returns a scope, does not make a request.
      #
      #   books = Book.limit(100)
      #
      # @param [Integer] limit The maximum number of records to return.
      # @return [Scope] Returns a new scope that has the applied limit.
      def limit limit
        _with(:limit => limit)
      end
  
      # Yields once for each record matching the request made by this scope.
      #
      #   books = Book.where(:author => 'me').order(:price, :asc).limit(10)
      #
      #   books.each do |book|
      #     puts book.attributes.to_yaml
      #   end
      #
      # @yieldparam [Object] record 
      def each &block
        if block_given?
          _each_object(&block)
        else
          Enumerator.new(self, :"_each_object")
        end
      end
  
      # @private
      private
      def _empty?
        @options == {}
      end
  
      # @private
      private
      def _each_object &block

        items = _item_collection

        items.select.each do |item_data|
          obj = base_class.new
          obj.send(:hydrate, item_data.name, item_data.attributes)
          yield(obj)
        end

      end
  
      # @private
      private
      def _with options
        Scope.new(base_class, @options.merge(options))
      end
  
      # @private
      private
      def method_missing scope_name, *args
        # @todo only proxy named scope methods
        _merge_scope(base_class.send(scope_name, *args))
      end
  
      # Merges another scope with this scope.  Conditions are added together
      # and the limit and order parts replace those in this scope (if set).
      # @param [Scope] scope A scope to merge with this one.
      # @return [Scope] Returns a new scope with merged conditions and 
      #   overriden order and limit.
      # @private
      private
      def _merge_scope scope
        merged = self
        scope.instance_variable_get('@options').each_pair do |opt_name,opt_value|
          unless [nil, []].include?(opt_value)
            if opt_name == :where
              opt_value.each do |condition| 
                merged = merged.where(*condition) 
              end
            else
              merged = merged.send(opt_name, *opt_value)
            end
          end
        end
        merged
      end

      # Consumes a hash of options (e.g. +:where+, +:order+ and +:limit+) and
      # builds them onto the current scope, returning a new one.
      # @param [Hash] options
      # @option options :where
      # @option options :order
      # @option options [Integer] :limit
      # @return [Scope] Returns a new scope with the hash of scope 
      #   options applied.
      # @private
      private
      def _handle_options options
        scope = self
        options.each_pair do |method, args|
          if method == :where and args.is_a?(Hash)
            # splatting a hash turns it into an array, bad juju
            scope = scope.send(method, args)
          else
            scope = scope.send(method, *args)
          end
        end
        scope
      end

      # Converts this scope object into an AWS::SimpleDB::ItemCollection
      # @return [SimpleDB::ItemCollection]
      # @private
      private
      def _item_collection
        items = base_class.sdb_domain.items
        items = items.order(*@options[:order]) if @options[:order]
        items = items.limit(*@options[:limit]) if @options[:limit]
        Record.as_array(@options[:where]).each do |where_condition|
          items = items.where(*where_condition)
        end
        items
      end

    end
  end
end