# frozen_string_literal: true
module ActiveMocker
module Queries
class Find
def initialize(record)
@record = record
end
def is_of(conditions = {})
conditions.all? do |col, match|
if match.is_a? Enumerable
any_match(col, match)
else
compare(col, match)
end
end
end
private
def any_match(col, match)
match.any? { |m| compare(col, m) }
end
def compare(col, match)
@record.send(col) == match
end
end
class WhereNotChain
def initialize(collection, parent_class)
@collection = collection
@parent_class = parent_class
end
def not(conditions = {})
@parent_class.call(@collection.reject do |record|
Find.new(record).is_of(conditions)
end)
end
end
# Deletes the records matching +conditions+ by instantiating each
# record and calling its +delete+ method.
#
# ==== Parameters
#
# * +conditions+ - A string, array, or hash that specifies which records
# to destroy. If omitted, all records are destroyed.
#
# ==== Examples
#
# PersonMock.destroy_all(status: "inactive")
# PersonMock.where(age: 0..18).destroy_all
#
# If a limit scope is supplied, +delete_all+ raises an ActiveMocker error:
#
# Post.limit(100).delete_all
# # => ActiveMocker::Error: delete_all doesn't support limit scope
def delete_all(conditions = nil)
check_for_limit_scope!
collection = conditions.nil? ? to_a.each(&:delete).clear : where(conditions)
collection.map(&:delete).count
end
alias destroy_all delete_all
# Returns a new relation, which is the result of filtering the current relation
# according to the conditions in the arguments.
#
# === hash
#
# #where will accept a hash condition, in which the keys are fields and the values
# are values to be searched for.
#
# Fields can be symbols or strings. Values can be single values, arrays, or ranges.
#
# User.where({ name: "Joe", email: "joe@example.com" })
#
# User.where({ name: ["Alice", "Bob"]})
#
# User.where({ created_at: (Time.now.midnight - 1.day)..Time.now.midnight })
#
# In the case of a belongs_to relationship, an association key can be used
# to specify the model if an ActiveRecord object is used as the value.
#
# author = Author.find(1)
#
# # The following queries will be equivalent:
# Post.where(author: author)
# Post.where(author_id: author)
#
# This also works with polymorphic belongs_to relationships:
#
# treasure = Treasure.create(name: 'gold coins')
# treasure.price_estimates << PriceEstimate.create(price: 125)
#
# # The following queries will be equivalent:
# PriceEstimate.where(estimate_of: treasure)
# PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: treasure)
#
# === no argument
#
# If no argument is passed, #where returns a new instance of WhereChain, that
# can be chained with #not to return a new relation that negates the where clause.
#
# User.where.not(name: "Jon")
#
# See WhereChain for more details on #not.
def where(conditions = nil)
return WhereNotChain.new(all, method(:__new_relation__)) if conditions.nil?
__new_relation__(to_a.select do |record|
Find.new(record).is_of(conditions)
end)
end
# Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]).
# If no record can be found for all of the listed ids, then RecordNotFound will be raised. If the primary key
# is an integer, find by id coerces its arguments using +to_i+.
#
# Person.find(1) # returns the object for ID = 1
# Person.find(1, 2, 6) # returns an array for objects with IDs in (1, 2, 6)
# Person.find([7, 17]) # returns an array for objects with IDs in (7, 17)
# Person.find([1]) # returns an array for the object with ID = 1
#
# ActiveMocker::RecordNotFound will be raised if one or more ids are not found.
def find(ids)
raise RecordNotFound.new("Couldn't find #{name} without an ID") if ids.nil?
results = [*ids].map do |id|
find_by!(id: id.to_i)
end
return __new_relation__(results) if ids.class == Array
results.first
end
# Updates all records with details given if they match a set of conditions supplied, limits and order can
# also be supplied.
#
# ==== Parameters
#
# * +updates+ - A string, array, or hash.
#
# ==== Examples
#
# # Update all customers with the given attributes
# Customer.update_all wants_email: true
#
# # Update all books with 'Rails' in their title
# BookMock.where(title: 'Rails').update_all(author: 'David')
#
# # Update all books that match conditions, but limit it to 5 ordered by date
# BookMock.where(title: 'Rails').order(:created_at).limit(5).update_all(author: 'David')
def update_all(attributes)
all.each { |i| i.update(attributes) }
end
# Updates an object (or multiple objects) and saves it.
#
# ==== Parameters
#
# * +id+ - This should be the id or an array of ids to be updated.
# * +attributes+ - This should be a hash of attributes or an array of hashes.
#
# ==== Examples
#
# # Updates one record
# Person.update(15, user_name: 'Samuel', group: 'expert')
#
# # Updates multiple records
# people = { 1 => { "first_name" => "David" }, 2 => { "first_name" => "Jeremy" } }
# Person.update(people.keys, people.values)
def update(id, attributes)
if id.is_a?(Array)
id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) }
else
object = find(id)
object.update(attributes)
object
end
end
# Finds the first record matching the specified conditions. There
# is no implied ordering so if order matters, you should specify it
# yourself.
#
# If no record is found, returns nil.
#
# Post.find_by name: 'Spartacus', rating: 4
def find_by(conditions = {})
to_a.detect do |record|
Find.new(record).is_of(conditions)
end
end
# Like find_by, except that if no record is found, raises
# an ActiveMocker::RecordNotFound error.
def find_by!(conditions = {})
result = find_by(conditions)
if result.nil?
raise RecordNotFound.new("Couldn't find #{name} with '#{conditions.keys.first}'=#{conditions.values.first}")
end
result
end
# Finds the first record with the given attributes, or creates a record
# with the attributes if one is not found:
#
# # Find the first user named "Penélope" or create a new one.
# UserMock.find_or_create_by(first_name: 'Penélope')
# # => #
#
# # Find the first user named "Penélope" or create a new one.
# # We already have one so the existing record will be returned.
# UserMock.find_or_create_by(first_name: 'Penélope')
# # => #
#
# This method accepts a block, which is passed down to +create+. The last example
# above can be alternatively written this way:
#
# # Find the first user named "Scarlett" or create a new one with a
# # different last name.
# User.find_or_create_by(first_name: 'Scarlett') do |user|
# user.last_name = 'Johansson'
# end
# # => #
#
def find_or_create_by(attributes, &block)
find_by(attributes) || create(attributes, &block)
end
alias find_or_create_by! find_or_create_by
# Like find_or_create_by, but calls new instead of create.
def find_or_initialize_by(attributes, &block)
find_by(attributes) || new(attributes, &block)
end
def first_or_create(attributes = nil, &block) # :nodoc:
first || create(attributes, &block)
end
def first_or_create!(attributes = nil, &block) # :nodoc:
first || create!(attributes, &block)
end
def first_or_initialize(attributes = nil, &block) # :nodoc:
first || new(attributes, &block)
end
# Count the records.
#
# PersonMock.count
# # => the total count of all people
#
# PersonMock.count(:age)
# # => returns the total count of all people whose age is present in database
def count(column_name = nil)
return all.size if column_name.nil?
where.not(column_name => nil).size
end
# Specifies a limit for the number of records to retrieve.
#
# User.limit(10)
def limit(num)
relation = __new_relation__(all.take(num))
relation.send(:set_from_limit)
relation
end
# Calculates the sum of values on a given column. The value is returned
# with the same data type of the column, 0 if there's no row.
#
# Person.sum(:age) # => 4562
def sum(key)
values = values_by_key(key)
values.inject(0) do |sum, n|
sum + (n || 0)
end
end
# Calculates the average value on a given column. Returns +nil+ if there's
# no row.
#
# PersonMock.average(:age) # => 35.8
def average(key)
values = values_by_key(key)
total = values.inject { |sum, n| sum + n }
BigDecimal.new(total) / BigDecimal.new(values.count)
end
# Calculates the minimum value on a given column. The value is returned
# with the same data type of the column, or +nil+ if there's no row.
#
# Person.minimum(:age) # => 7
def minimum(key)
values_by_key(key).min_by { |i| i }
end
# Calculates the maximum value on a given column. The value is returned
# with the same data type of the column, or +nil+ if there's no row.
#
# Person.maximum(:age) # => 93
def maximum(key)
values_by_key(key).max_by { |i| i }
end
# Allows to specify an order attribute:
#
# User.order('name')
#
# User.order(:name)
#
# User.order(email: :desc)
#
# User.order(:name, email: :desc)
def order(*args)
options = args.extract_options!
if options.empty? && args.count == 1
__new_relation__(all.sort_by { |item| item.send(args.first) })
else
__new_relation__(Sort.order_mixed_args(all, args, options))
end
end
module Sort
class DESC
attr_reader :r
def initialize(r)
@r = r
end
def <=>(other)
-(r <=> other.r) # Flip negative/positive result
end
end
class << self
def desc(r)
DESC.new(r)
end
def asc(r)
r
end
def order_mixed_args(all, args, options)
options.merge!(args.each_with_object({}) { |a, h| h[a] = :asc }) # Add non specified direction keys
all.sort { |a, b| build_order(a, options) <=> build_order(b, options) }
end
def build_order(a, options)
options.map { |k, v| send(v, a.send(k)) }
end
end
end
# Reverse the existing order clause on the relation.
#
# User.order('name').reverse_order
def reverse_order
__new_relation__(to_a.reverse)
end
def all
__new_relation__(to_a || [])
end
# Returns a chainable relation with zero records.
#
# Any subsequent condition chained to the returned relation will continue
# generating an empty relation.
#
# Used in cases where a method or scope could return zero records but the
# result needs to be chainable.
#
# For example:
#
# @posts = current_user.visible_posts.where(name: params[:name])
# # => the visible_posts method is expected to return a chainable Relation
#
# def visible_posts
# case role
# when 'Country Manager'
# Post.where(country: country)
# when 'Reviewer'
# Post.published
# when 'Bad User'
# Post.none # It can't be chained if [] is returned.
# end
# end
#
def none
__new_relation__([])
end
private
def check_for_limit_scope!
raise ActiveMocker::Error.new("delete_all doesn't support limit scope") if from_limit?
end
def values_by_key(key)
all.map { |obj| obj.send(key) }
end
def __new_relation__(collection)
duped = dup
duped.collection = collection
duped
end
end
end