lib/dynamoid/criteria/chain.rb in dynamoid-3.1.0 vs lib/dynamoid/criteria/chain.rb in dynamoid-3.2.0

- old
+ new

@@ -1,14 +1,19 @@ # frozen_string_literal: true -module Dynamoid #:nodoc: +require_relative 'key_fields_detector' +require_relative 'ignored_conditions_detector' +require_relative 'overwritten_conditions_detector' +require_relative 'nonexistent_fields_detector' + +module Dynamoid module Criteria # The criteria chain is equivalent to an ActiveRecord relation (and realistically I should change the name from # chain to relation). It is a chainable object that builds up a query and eventually executes it by a Query or Scan. class Chain - attr_accessor :query, :source, :values, :consistent_read - attr_reader :hash_key, :range_key, :index_name + attr_reader :query, :source, :consistent_read, :key_fields_detector + include Enumerable # Create a new criteria chain. # # @param [Class] source the class upon which the ultimate query will be performed. def initialize(source) @@ -20,10 +25,13 @@ # Honor STI and :type field if it presents type = @source.inheritance_field if @source.attributes.key?(type) @query[:"#{type}.in"] = @source.deep_subclasses.map(&:name) << @source.name end + + # we should re-initialize keys detector every time we change query + @key_fields_detector = KeyFieldsDetector.new(@query, @source) end # The workhorse method of the criteria chain. Each key in the passed in hash will become another criteria that the # ultimate query must match. A key can either be a symbol or a string, and should be an attribute name or # an attribute name with a range operator. @@ -34,11 +42,30 @@ # @example A more complicated criteria # where(:name => 'Josh', 'created_at.gt' => DateTime.now - 1.day) # # @since 0.2.0 def where(args) - query.update(args.dup.symbolize_keys) + detector = IgnoredConditionsDetector.new(args) + if detector.found? + Dynamoid.logger.warn(detector.warning_message) + end + + detector = OverwrittenConditionsDetector.new(@query, args) + if detector.found? + Dynamoid.logger.warn(detector.warning_message) + end + + detector = NonexistentFieldsDetector.new(args, @source) + if detector.found? + Dynamoid.logger.warn(detector.warning_message) + end + + query.update(args.symbolize_keys) + + # we should re-initialize keys detector every time we change query + @key_fields_detector = KeyFieldsDetector.new(@query, @source) + self end def consistent @consistent_read = true @@ -51,11 +78,11 @@ def all records end def count - if key_present? + if @key_fields_detector.key_present? count_via_query else count_via_scan end end @@ -72,17 +99,17 @@ # def delete_all ids = [] ranges = [] - if key_present? - Dynamoid.adapter.query(source.table_name, range_query).collect do |hash| + if @key_fields_detector.key_present? + Dynamoid.adapter.query(source.table_name, range_query).flat_map{ |i| i }.collect do |hash| ids << hash[source.hash_key.to_sym] ranges << hash[source.range_key.to_sym] if source.range_key end else - Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).collect do |hash| + Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).flat_map{ |i| i }.collect do |hash| ids << hash[source.hash_key.to_sym] ranges << hash[source.range_key.to_sym] if source.range_key end end @@ -126,54 +153,73 @@ # @since 0.2.0 def each(&block) records.each(&block) end + def find_by_pages(&block) + pages.each(&block) + end + private # The actual records referenced by the association. # # @return [Enumerator] an iterator of the found records. # # @since 0.2.0 def records - if key_present? - records_via_query + pages.lazy.flat_map { |i| i } + end + + # Arrays of records, sized based on the actual pages produced by DynamoDB + # + # @return [Enumerator] an iterator of the found records. + # + # @since 3.1.0 + def pages + if @key_fields_detector.key_present? + pages_via_query else - records_via_scan + issue_scan_warning if Dynamoid::Config.warn_on_scan && query.present? + pages_via_scan end end - def records_via_query + # If the query matches an index, we'll query the associated table to find results. + # + # @return [Enumerator] an iterator of the found pages. An array of records + # + # @since 3.1.0 + def pages_via_query Enumerator.new do |yielder| - Dynamoid.adapter.query(source.table_name, range_query).each do |hash| - yielder.yield source.from_database(hash) + Dynamoid.adapter.query(source.table_name, range_query).each do |items, metadata| + yielder.yield items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key) end end end # If the query does not match an index, we'll manually scan the associated table to find results. # - # @return [Enumerator] an iterator of the found records. + # @return [Enumerator] an iterator of the found pages. An array of records # - # @since 0.2.0 - def records_via_scan - if Dynamoid::Config.warn_on_scan && query.present? - Dynamoid.logger.warn 'Queries without an index are forced to use scan and are generally much slower than indexed queries!' - Dynamoid.logger.warn "You can index this query by adding index declaration to #{source.to_s.downcase}.rb:" - Dynamoid.logger.warn "* global_secondary_index hash_key: 'some-name', range_key: 'some-another-name'" - Dynamoid.logger.warn "* local_secondary_indexe range_key: 'some-name'" - Dynamoid.logger.warn "Not indexed attributes: #{query.keys.sort.collect { |name| ":#{name}" }.join(', ')}" - end - + # @since 3.1.0 + def pages_via_scan Enumerator.new do |yielder| - Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).each do |hash| - yielder.yield source.from_database(hash) + Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).each do |items, metadata| + yielder.yield(items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key)) end end end + def issue_scan_warning + Dynamoid.logger.warn 'Queries without an index are forced to use scan and are generally much slower than indexed queries!' + Dynamoid.logger.warn "You can index this query by adding index declaration to #{source.to_s.downcase}.rb:" + Dynamoid.logger.warn "* global_secondary_index hash_key: 'some-name', range_key: 'some-another-name'" + Dynamoid.logger.warn "* local_secondary_index range_key: 'some-name'" + Dynamoid.logger.warn "Not indexed attributes: #{query.keys.sort.collect { |name| ":#{name}" }.join(', ')}" + end + def count_via_query Dynamoid.adapter.query_count(source.table_name, range_query) end def count_via_scan @@ -236,28 +282,28 @@ def range_query opts = {} # Add hash key - opts[:hash_key] = @hash_key - opts[:hash_value] = type_cast_condition_parameter(@hash_key, query[@hash_key]) + opts[:hash_key] = @key_fields_detector.hash_key + opts[:hash_value] = type_cast_condition_parameter(@key_fields_detector.hash_key, query[@key_fields_detector.hash_key]) # Add range key - if @range_key - opts[:range_key] = @range_key - if query[@range_key].present? - value = type_cast_condition_parameter(@range_key, query[@range_key]) + if @key_fields_detector.range_key + opts[:range_key] = @key_fields_detector.range_key + if query[@key_fields_detector.range_key].present? + value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key]) opts.update(range_eq: value) end - query.keys.select { |k| k.to_s =~ /^#{@range_key}\./ }.each do |key| + query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key| opts.merge!(range_hash(key)) end end - (query.keys.map(&:to_sym) - [@hash_key.to_sym, @range_key.try(:to_sym)]) - .reject { |k, _| k.to_s =~ /^#{@range_key}\./ } + (query.keys.map(&:to_sym) - [@key_fields_detector.hash_key.to_sym, @key_fields_detector.range_key.try(:to_sym)]) + .reject { |k, _| k.to_s =~ /^#{@key_fields_detector.range_key}\./ } .each do |key| if key.to_s.include?('.') opts.update(field_hash(key)) else value = type_cast_condition_parameter(key, query[key]) @@ -282,58 +328,19 @@ Dumping.dump_field(value_casted, options) end end end - def key_present? - query_keys = query.keys.collect { |k| k.to_s.split('.').first } - - # See if querying based on table hash key - if query.keys.map(&:to_s).include?(source.hash_key.to_s) - @hash_key = source.hash_key - - # Use table's default range key - if query_keys.include?(source.range_key.to_s) - @range_key = source.range_key - return true - end - - # See if can use any local secondary index range key - # Chooses the first LSI found that can be utilized for the query - source.local_secondary_indexes.each do |_, lsi| - next unless query_keys.include?(lsi.range_key.to_s) - @range_key = lsi.range_key - @index_name = lsi.name - end - - return true - end - - # See if can use any global secondary index - # Chooses the first GSI found that can be utilized for the query - # But only do so if projects ALL attributes otherwise we won't - # get back full data - source.global_secondary_indexes.each do |_, gsi| - next unless query.keys.map(&:to_s).include?(gsi.hash_key.to_s) && gsi.projected_attributes == :all - @hash_key = gsi.hash_key - @range_key = gsi.range_key - @index_name = gsi.name - return true - end - - # Could not utilize any indices so we'll have to scan - false - end - # Start key needs to be set up based on the index utilized # If using a secondary index then we must include the index's composite key # as well as the tables composite key. def start_key return @start if @start.is_a?(Hash) - hash_key = @hash_key || source.hash_key - range_key = @range_key || source.range_key + hash_key = @key_fields_detector.hash_key || source.hash_key + range_key = @key_fields_detector.range_key || source.range_key + key = {} key[hash_key] = type_cast_condition_parameter(hash_key, @start.send(hash_key)) if range_key key[range_key] = type_cast_condition_parameter(range_key, @start.send(range_key)) end @@ -347,10 +354,10 @@ key end def query_opts opts = {} - opts[:index_name] = @index_name if @index_name + opts[:index_name] = @key_fields_detector.index_name if @key_fields_detector.index_name opts[:select] = 'ALL_ATTRIBUTES' opts[:record_limit] = @record_limit if @record_limit opts[:scan_limit] = @scan_limit if @scan_limit opts[:batch_size] = @batch_size if @batch_size opts[:exclusive_start_key] = start_key if @start