# frozen_string_literal: true module Dynamoid # This module defines the finder methods that hang off the document at the # class level, like find, find_by_id, and the method_missing style finders. module Finders extend ActiveSupport::Concern # @private RANGE_MAP = { 'gt' => :range_greater_than, 'lt' => :range_less_than, 'gte' => :range_gte, 'lte' => :range_lte, 'begins_with' => :range_begins_with, 'between' => :range_between, 'eq' => :range_eq }.freeze module ClassMethods # Find one or many objects, specified by one id or an array of ids. # # By default it raises +RecordNotFound+ exception if at least one model # isn't found. This behavior can be changed with +raise_error+ option. If # specified +raise_error: false+ option then +find+ will not raise the # exception. # # When a document schema includes range key it always should be specified # in +find+ method call. In case it's missing +MissingRangeKey+ exception # will be raised. # # Please note that +find+ doesn't preserve order of models in result when # passes multiple ids. # # Supported following options: # * +consistent_read+ # * +range_key+ # * +raise_error+ # # @param ids [String|Array] hash key or an array of hash keys # @param options [Hash] # @return [Dynamoid::Document] one object or an array of objects, depending on whether the input was an array or not # # @example Find by partition key # Document.find(101) # # @example Find by partition key and sort key # Document.find(101, range_key: 'archived') # # @example Find several documents by partition key # Document.find(101, 102, 103) # Document.find([101, 102, 103]) # # @example Find several documents by partition key and sort key # Document.find([[101, 'archived'], [102, 'new'], [103, 'deleted']]) # # @example Perform strong consistent reads # Document.find(101, consistent_read: true) # Document.find(101, 102, 103, consistent_read: true) # Document.find(101, range_key: 'archived', consistent_read: true) # # @since 0.2.0 def find(*ids, **options) if ids.size == 1 && !ids[0].is_a?(Array) _find_by_id(ids[0], options.reverse_merge(raise_error: true)) else _find_all(ids.flatten(1), options.reverse_merge(raise_error: true)) end end # Find several models at once. # # Returns objects found by the given array of ids, either hash keys, or # hash/range key combinations using +BatchGetItem+. # # Returns empty array if no results found. # # Uses backoff specified by +Dynamoid::Config.backoff+ config option. # # @param ids [Array] array of primary keys # @param options [Hash] # @option options [true|false] :consistent_read # @option options [true|false] :raise_error # # @example # # Find all the user with hash key # User.find_all(['1', '2', '3']) # # # Find all the tweets using hash key and range key with consistent read # Tweet.find_all([['1', 'red'], ['1', 'green']], consistent_read: true) def find_all(ids, options = {}) ActiveSupport::Deprecation.warn('[Dynamoid] .find_all is deprecated! Call .find instead of') _find_all(ids, options) end # Find one object directly by primary key. # # @param id [String] the id of the object to find # @param options [Hash] # @option options [true|false] :consistent_read # @option options [true|false] :raise_error # @option options [Scalar value] :range_key # @return [Dynamoid::Document] the found object, or nil if nothing was found # # @example Find by partition key # Document.find_by_id(101) # # @example Find by partition key and sort key # Document.find_by_id(101, range_key: 'archived') # # @since 0.2.0 def find_by_id(id, options = {}) ActiveSupport::Deprecation.warn('[Dynamoid] .find_by_id is deprecated! Call .find instead of', caller[1..-1]) _find_by_id(id, options) end # @private def _find_all(ids, options = {}) raise Errors::MissingRangeKey if range_key && ids.any? { |pk, sk| sk.nil? } if range_key ids = ids.map do |pk, sk| sk_casted = TypeCasting.cast_field(sk, attributes[range_key]) sk_dumped = Dumping.dump_field(sk_casted, attributes[range_key]) [pk, sk_dumped] end end read_options = options.slice(:consistent_read) items = if Dynamoid.config.backoff items = [] backoff = nil Dynamoid.adapter.read(table_name, ids, read_options) do |hash, has_unprocessed_items| items += hash[table_name] if has_unprocessed_items backoff ||= Dynamoid.config.build_backoff backoff.call else backoff = nil end end items else items = Dynamoid.adapter.read(table_name, ids, read_options) items ? items[table_name] : [] end if items.size == ids.size || !options[:raise_error] items ? items.map { |i| from_database(i) } : [] else ids_list = range_key ? ids.map { |pk, sk| "(#{pk},#{sk})" } : ids.map(&:to_s) message = "Couldn't find all #{name.pluralize} with primary keys [#{ids_list.join(', ')}] " message += "(found #{items.size} results, but was looking for #{ids.size})" raise Errors::RecordNotFound, message end end # @private def _find_by_id(id, options = {}) raise Errors::MissingRangeKey if range_key && options[:range_key].nil? if range_key key = options[:range_key] key_casted = TypeCasting.cast_field(key, attributes[range_key]) key_dumped = Dumping.dump_field(key_casted, attributes[range_key]) options[:range_key] = key_dumped end if item = Dynamoid.adapter.read(table_name, id, options.slice(:range_key, :consistent_read)) from_database(item) elsif options[:raise_error] primary_key = range_key ? "(#{id},#{options[:range_key]})" : id message = "Couldn't find #{name} with primary key #{primary_key}" raise Errors::RecordNotFound, message end end # Find one object directly by hash and range keys. # # @param hash_key [Scalar value] hash key of the object to find # @param range_key [Scalar value] range key of the object to find # def find_by_composite_key(hash_key, range_key, options = {}) ActiveSupport::Deprecation.warn('[Dynamoid] .find_by_composite_key is deprecated! Call .find instead of') _find_by_id(hash_key, options.merge(range_key: range_key)) end # Find all objects by hash and range keys. # # @example find all ChamberTypes whose level is greater than 1 # class ChamberType # include Dynamoid::Document # field :chamber_type, :string # range :level, :integer # table :key => :chamber_type # end # # ChamberType.find_all_by_composite_key('DustVault', range_greater_than: 1) # # @param [String] hash_key of the objects to find # @param [Hash] options the options for the range key # @option options [Range] :range_value find the range key within this range # @option options [Number] :range_greater_than find range keys greater than this # @option options [Number] :range_less_than find range keys less than this # @option options [Number] :range_gte find range keys greater than or equal to this # @option options [Number] :range_lte find range keys less than or equal to this # # @return [Array] an array of all matching items def find_all_by_composite_key(hash_key, options = {}) ActiveSupport::Deprecation.warn('[Dynamoid] .find_all_composite_key is deprecated! Call .where instead of') Dynamoid.adapter.query(table_name, options.merge(hash_value: hash_key)).flat_map { |i| i }.collect do |item| from_database(item) end end # Find all objects by using local secondary or global secondary index # # @example # class User # include Dynamoid::Document # # table :key => :email # global_secondary_index hash_key: :age, range_key: :rank # # field :email, :string # field :age, :integer # field :gender, :string # field :rank :number # end # # # NOTE: the first param and the second param are both hashes, # # so curly braces must be used on first hash param if sending both params # User.find_all_by_secondary_index({ age: 5 }, range: { "rank.lte": 10 }) # # @param hash [Hash] conditions for the hash key e.g. +{ age: 5 }+ # @param options [Hash] conditions on range key e.g. +{ "rank.lte": 10 }, query filter, projected keys, scan_index_forward etc. # @return [Array] an array of all matching items def find_all_by_secondary_index(hash, options = {}) ActiveSupport::Deprecation.warn('[Dynamoid] .find_all_by_secondary_index is deprecated! Call .where instead of') range = options[:range] || {} hash_key_field, hash_key_value = hash.first range_key_field, range_key_value = range.first range_op_mapped = nil if range_key_field range_key_field = range_key_field.to_s range_key_op = 'eq' if range_key_field.include?('.') range_key_field, range_key_op = range_key_field.split('.', 2) end range_op_mapped = RANGE_MAP.fetch(range_key_op) end # Find the index index = find_index(hash_key_field, range_key_field) raise Dynamoid::Errors::MissingIndex, "attempted to find #{[hash_key_field, range_key_field]}" if index.nil? # query opts = { hash_key: hash_key_field.to_s, hash_value: hash_key_value, index_name: index.name } if range_key_field opts[:range_key] = range_key_field opts[range_op_mapped] = range_key_value end dynamo_options = opts.merge(options.reject { |key, _| key == :range }) Dynamoid.adapter.query(table_name, dynamo_options).flat_map { |i| i }.map do |item| from_database(item) end end # Find using exciting method_missing finders attributes. Uses criteria # chains under the hood to accomplish this neatness. # # @example find a user by a first name # User.find_by_first_name('Josh') # # @example find all users by first and last name # User.find_all_by_first_name_and_last_name('Josh', 'Symonds') # # @return [Dynamoid::Document|Array] the found object, or an array of found objects if all was somewhere in the method # # @private # @since 0.2.0 def method_missing(method, *args) if method =~ /find/ ActiveSupport::Deprecation.warn("[Dynamoid] .#{method} is deprecated! Call .where instead of") finder = method.to_s.split('_by_').first attributes = method.to_s.split('_by_').last.split('_and_') chain = Dynamoid::Criteria::Chain.new(self) chain = chain.where({}.tap { |h| attributes.each_with_index { |attr, index| h[attr.to_sym] = args[index] } }) if finder =~ /all/ chain.all else chain.first end else super end end end end end