lib/frozen_record/scope.rb
# frozen_string_literal: true
module FrozenRecord
class Scope
DISALLOWED_ARRAY_METHODS = [
:compact!, :flatten!, :reject!, :reverse!, :rotate!, :map!,
:shuffle!, :slice!, :sort!, :sort_by!, :delete_if,
:keep_if, :pop, :shift, :delete_at, :compact
].to_set
delegate :first, :last, :length, :collect, :map, :each, :all?, :include?, :to_ary, :to_json, :as_json, :count, to: :to_a
alias_method :find_each, :each
if defined? ActiveModel::Serializers::Xml
delegate :to_xml, to: :to_a
end
class WhereChain
def initialize(scope)
@scope = scope
end
def not(criterias)
@scope.where_not(criterias)
end
end
def initialize(klass)
@klass = klass
@where_values = []
@where_not_values = []
@order_values = []
@limit = nil
@offset = nil
end
def find_by_id(id)
find_by(@klass.primary_key => id)
end
def find(id)
raise RecordNotFound, "Can't lookup record without ID" unless id
scope = self
if @limit || @offset
scope = limit(nil).offset(nil)
end
scope.find_by_id(id) or raise RecordNotFound, "Couldn't find a record with ID = #{id.inspect}"
end
def find_by(criterias)
where(criterias).first
end
def find_by!(criterias)
where(criterias).first!
end
def first!
first or raise RecordNotFound, "No record matched"
end
def last!
last or raise RecordNotFound, "No record matched"
end
def to_a
query_results
end
def pluck(*attributes)
case attributes.length
when 1
to_a.map(&attributes.first.to_sym)
when 0
raise NotImplementedError, '`.pluck` without arguments is not supported yet'
else
to_a.map { |r| attributes.map { |a| r[a] }}
end
end
def ids
pluck(primary_key)
end
def sum(attribute)
pluck(attribute).sum
end
def average(attribute)
pluck(attribute).sum.to_f / count
end
def minimum(attribute)
pluck(attribute).min
end
def maximum(attribute)
pluck(attribute).max
end
def exists?
!empty?
end
def where(criterias = :chain)
if criterias == :chain
WhereChain.new(self)
else
spawn.where!(criterias)
end
end
def where_not(criterias)
spawn.where_not!(criterias)
end
def order(*ordering)
spawn.order!(*ordering)
end
def limit(amount)
spawn.limit!(amount)
end
def offset(amount)
spawn.offset!(amount)
end
def respond_to_missing?(method_name, *)
array_delegable?(method_name) || @klass.respond_to?(method_name) || super
end
def hash
comparable_attributes.hash
end
def ==(other)
self.class === other &&
comparable_attributes == other.comparable_attributes
end
protected
def comparable_attributes
@comparable_attributes ||= {
klass: @klass,
where_values: @where_values.uniq.sort,
where_not_values: @where_not_values.uniq.sort,
order_values: @order_values.uniq,
limit: @limit,
offset: @offset,
}
end
def scoping
previous, @klass.current_scope = @klass.current_scope, self
yield
ensure
@klass.current_scope = previous
end
def spawn
clone.clear_cache!
end
def clear_cache!
@comparable_attributes = nil
@results = nil
@matches = nil
self
end
def query_results
slice_records(matching_records)
end
def matching_records
sort_records(select_records(@klass.load_records))
end
ARRAY_INTERSECTION = Array.method_defined?(:intersection)
def select_records(records)
return records if @where_values.empty? && @where_not_values.empty?
indices = @klass.index_definitions
indexed_where_values, unindexed_where_values = @where_values.partition { |criteria| indices.key?(criteria.first) }
unless indexed_where_values.empty?
usable_indexes = indexed_where_values.map { |(attribute, value)| [attribute, value, indices[attribute].query(value)] }
usable_indexes.sort_by! { |r| r[2].size }
records = usable_indexes.shift.last
# If the index is 5 times bigger that the current set of records it's not worth doing an array intersection.
# The value is somewhat arbitrary and could be adjusted.
useless_indexes, usable_indexes = usable_indexes.partition { |_, _, indexed_records| indexed_records.size > records.size * 5 }
unindexed_where_values += useless_indexes.map { |a| a.first(2) }
unless usable_indexes.empty?
if ARRAY_INTERSECTION
records = records.intersection(*usable_indexes.map(&:last))
else
usable_indexes.each do |_, _, indexed_records|
records &= indexed_records
end
end
end
end
if FrozenRecord.enforce_max_records_scan && @klass.max_records_scan && records.size > @klass.max_records_scan
raise SlowQuery, "Scanning #{records.size} records is too slow, the allowed maximum is #{@klass.max_records_scan}. Try to find a better index or consider an alternative storage"
end
records.select do |record|
unindexed_where_values.all? { |attr, matcher| matcher.match?(record[attr]) } &&
!@where_not_values.any? { |attr, matcher| matcher.match?(record[attr]) }
end
end
def sort_records(records)
return records if @order_values.empty?
records.sort do |record_a, record_b|
compare(record_a, record_b)
end
end
def slice_records(records)
return records unless @limit || @offset
first = @offset || 0
last = first + (@limit || records.length)
records[first...last] || []
end
def compare(record_a, record_b)
@order_values.each do |attr, order|
a_value, b_value = record_a.send(attr), record_b.send(attr)
cmp = a_value <=> b_value
next if cmp == 0
return order == :asc ? cmp : -cmp
end
0
end
def method_missing(method_name, *args, &block)
if array_delegable?(method_name)
to_a.public_send(method_name, *args, &block)
elsif @klass.respond_to?(method_name)
scoping { @klass.public_send(method_name, *args, &block) }
else
super
end
end
ruby2_keywords :method_missing if respond_to?(:ruby2_keywords, true)
def array_delegable?(method)
Array.method_defined?(method) && !DISALLOWED_ARRAY_METHODS.include?(method)
end
def where!(criterias)
@where_values += criterias.map { |k, v| [k.to_s, Matcher.for(v)] }
self
end
def where_not!(criterias)
@where_not_values += criterias.map { |k, v| [k.to_s, Matcher.for(v)] }
self
end
def order!(*ordering)
@order_values += ordering.map do |order|
order.respond_to?(:to_a) ? order.to_a : [[order, :asc]]
end.flatten(1)
self
end
def limit!(amount)
@limit = amount
self
end
def offset!(amount)
@offset = amount
self
end
private
class Matcher
class << self
def for(value)
case value
when Array
IncludeMatcher.new(value)
when Range
CoverMatcher.new(value)
else
new(value)
end
end
end
attr_reader :value
def hash
self.class.hash ^ value.hash
end
def initialize(value)
@value = value
end
def ranged?
false
end
def match?(other)
@value == other
end
def ==(other)
self.class == other.class && value == other.value
end
alias_method :eql?, :==
end
class IncludeMatcher < Matcher
def ranged?
true
end
def match?(other)
@value.include?(other)
end
end
class CoverMatcher < Matcher
def ranged?
true
end
def match?(other)
@value.cover?(other)
end
end
end
end