Perf: Improve performance of where when using an array of values
A coworker at GitHub found a few months back that if we used `santitize_sql` over `where` when we knew the values going into `where` it was a lot faster than `where`. This PR adds a new Arel node type called `HomogenousIn` that will be used when Rails knows the values are all homogenous and can therefore pick a faster codepath. This new codepath skips some of the required processing by `where` to make `wheres` with homogenous arrays faster without requiring the application author to know when to use which query type. Using our benchmark code: ```ruby ids = (1..1000).each.map do |n| Post.create!.id end Benchmark.ips do |x| x.report("where with ids") do Post.where(id: ids).to_a end x.report("where with sanitize") do Post.where(ActiveRecord::Base.sanitize_sql(["id IN (?)", ids])).to_a end x.compare! end ``` Before this PR comparing where with a list of IDs to santitize sql: ``` Warming up -------------------------------------- where with ids 11.000 i/100ms where with sanitize 17.000 i/100ms Calculating ------------------------------------- where with ids 115.733 (± 4.3%) i/s - 583.000 in 5.045828s where with sanitize 174.231 (± 4.0%) i/s - 884.000 in 5.081495s Comparison: where with sanitize: 174.2 i/s where with ids: 115.7 i/s - 1.51x slower ``` After this PR comparing where with a list of IDs to santitize sql: ``` Warming up -------------------------------------- where with ids 16.000 i/100ms where with sanitize 19.000 i/100ms Calculating ------------------------------------- where with ids 158.293 (± 6.3%) i/s - 800.000 in 5.072208s where with sanitize 169.141 (± 3.5%) i/s - 855.000 in 5.060878s Comparison: where with sanitize: 169.1 i/s where with ids: 158.3 i/s - same-ish: difference falls within error ``` Co-authored-by: Aaron Patterson <aaron.patterson@gmail.com>
This commit is contained in:
parent
ff8f40e722
commit
72fd0bae59
@ -7,15 +7,21 @@ module Numeric
|
||||
def serialize(value)
|
||||
cast(value)
|
||||
end
|
||||
alias :unchecked_serialize :serialize
|
||||
|
||||
def cast(value)
|
||||
value = \
|
||||
# Checks whether the value is numeric. Spaceship operator
|
||||
# will return nil if value is not numeric.
|
||||
value = if value <=> 0
|
||||
value
|
||||
else
|
||||
case value
|
||||
when true then 1
|
||||
when false then 0
|
||||
when ::String then value.presence
|
||||
else value
|
||||
else value.presence
|
||||
end
|
||||
end
|
||||
|
||||
super(value)
|
||||
end
|
||||
|
||||
|
@ -28,15 +28,24 @@ def serialize(value)
|
||||
ensure_in_range(super)
|
||||
end
|
||||
|
||||
def serializable?(value)
|
||||
cast_value = cast(value)
|
||||
in_range?(cast_value) && super
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :range
|
||||
|
||||
def in_range?(value)
|
||||
!value || range.cover?(value)
|
||||
end
|
||||
|
||||
def cast_value(value)
|
||||
value.to_i rescue nil
|
||||
end
|
||||
|
||||
def ensure_in_range(value)
|
||||
if value && !range.cover?(value)
|
||||
unless in_range?(value)
|
||||
raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{_limit} bytes"
|
||||
end
|
||||
value
|
||||
|
@ -11,6 +11,14 @@ def initialize(precision: nil, limit: nil, scale: nil)
|
||||
@limit = limit
|
||||
end
|
||||
|
||||
# Returns true if this type can convert +value+ to a type that is usable
|
||||
# by the database. For example a boolean type can return +true+ if the
|
||||
# value parameter is a Ruby boolean, but may return +false+ if the value
|
||||
# parameter is some other object.
|
||||
def serializable?(value)
|
||||
true
|
||||
end
|
||||
|
||||
def type # :nodoc:
|
||||
end
|
||||
|
||||
@ -45,6 +53,7 @@ def cast(value)
|
||||
def serialize(value)
|
||||
value
|
||||
end
|
||||
alias :unchecked_serialize :serialize
|
||||
|
||||
# Type casts a value for schema dumping. This method is private, as we are
|
||||
# hoping to remove it entirely.
|
||||
|
@ -24,6 +24,11 @@ def visit_Arel_Nodes_SqlLiteral(o, collector)
|
||||
@preparable = false
|
||||
super
|
||||
end
|
||||
|
||||
def visit_Arel_Nodes_HomogeneousIn(o, collector)
|
||||
@preparable = false
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -139,12 +139,17 @@ def deserialize(value)
|
||||
mapping.key(subtype.deserialize(value))
|
||||
end
|
||||
|
||||
def serializable?(value)
|
||||
(value.blank? || mapping.has_key?(value) || mapping.has_value?(value)) && super
|
||||
end
|
||||
|
||||
def serialize(value)
|
||||
mapping.fetch(value, value)
|
||||
end
|
||||
alias :unchecked_serialize :serialize
|
||||
|
||||
def assert_valid_value(value)
|
||||
unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
|
||||
unless serializable?(value)
|
||||
raise ArgumentError, "'#{value}' is not a valid #{name}"
|
||||
end
|
||||
end
|
||||
|
@ -21,10 +21,21 @@ def call(attribute, value)
|
||||
when 0 then NullPredicate
|
||||
when 1 then predicate_builder.build(attribute, values.first)
|
||||
else
|
||||
values.map! do |v|
|
||||
predicate_builder.build_bind_attribute(attribute.name, v)
|
||||
if nils.empty? && ranges.empty?
|
||||
type = attribute.type_caster
|
||||
|
||||
casted_values = values.map do |raw_value|
|
||||
type.unchecked_serialize(raw_value) if type.serializable?(raw_value)
|
||||
end
|
||||
|
||||
casted_values.compact!
|
||||
|
||||
return Arel::Nodes::HomogeneousIn.new(casted_values, attribute, :in)
|
||||
else
|
||||
attribute.in values.map { |v|
|
||||
predicate_builder.build_bind_attribute(attribute.name, v)
|
||||
}
|
||||
end
|
||||
values.empty? ? NullPredicate : attribute.in(values)
|
||||
end
|
||||
|
||||
unless nils.empty?
|
||||
|
@ -125,7 +125,7 @@ def predicates_unreferenced_by(other)
|
||||
end
|
||||
|
||||
def equality_node?(node)
|
||||
node.respond_to?(:operator) && node.operator == :==
|
||||
!node.is_a?(String) && node.equality?
|
||||
end
|
||||
|
||||
def invert_predicate(node)
|
||||
|
@ -9,10 +9,14 @@ def initialize(types)
|
||||
|
||||
def type_cast_for_database(attr_name, value)
|
||||
return value if value.is_a?(Arel::Nodes::BindParam)
|
||||
type = types.type_for_attribute(attr_name)
|
||||
type = type_for_attribute(attr_name)
|
||||
type.serialize(value)
|
||||
end
|
||||
|
||||
def type_for_attribute(name)
|
||||
types.type_for_attribute(name)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :types
|
||||
end
|
||||
|
@ -9,6 +9,10 @@ class Attribute < Struct.new :relation, :name
|
||||
include Arel::OrderPredications
|
||||
include Arel::Math
|
||||
|
||||
def type_caster
|
||||
relation.type_for_attribute(name)
|
||||
end
|
||||
|
||||
###
|
||||
# Create a node for lowering this attribute
|
||||
def lower
|
||||
|
@ -18,6 +18,7 @@
|
||||
# unary
|
||||
require "arel/nodes/unary"
|
||||
require "arel/nodes/grouping"
|
||||
require "arel/nodes/homogeneous_in"
|
||||
require "arel/nodes/ordering"
|
||||
require "arel/nodes/ascending"
|
||||
require "arel/nodes/descending"
|
||||
|
@ -5,6 +5,8 @@ module Nodes
|
||||
class Equality < Arel::Nodes::Binary
|
||||
def operator; :== end
|
||||
|
||||
def equality?; true; end
|
||||
|
||||
def invert
|
||||
Arel::Nodes::NotEqual.new(left, right)
|
||||
end
|
||||
|
57
activerecord/lib/arel/nodes/homogeneous_in.rb
Normal file
57
activerecord/lib/arel/nodes/homogeneous_in.rb
Normal file
@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Arel # :nodoc: all
|
||||
module Nodes
|
||||
class HomogeneousIn < Node
|
||||
attr_reader :attribute, :values, :type
|
||||
|
||||
def initialize(values, attribute, type)
|
||||
@values = values
|
||||
@attribute = attribute
|
||||
@type = type
|
||||
end
|
||||
|
||||
def hash
|
||||
ivars.hash
|
||||
end
|
||||
|
||||
def eql?(other)
|
||||
super || (self.class == other.class && self.ivars == other.ivars)
|
||||
end
|
||||
alias :== :eql?
|
||||
|
||||
def equality?
|
||||
true
|
||||
end
|
||||
|
||||
def invert
|
||||
Arel::Nodes::HomogeneousIn.new(values, attribute, type == :in ? :notin : :in)
|
||||
end
|
||||
|
||||
def left
|
||||
attribute
|
||||
end
|
||||
|
||||
def table_name
|
||||
attribute.relation.table_alias || attribute.relation.name
|
||||
end
|
||||
|
||||
def column_name
|
||||
attribute.name
|
||||
end
|
||||
|
||||
def fetch_attribute(&block)
|
||||
if attribute
|
||||
yield attribute
|
||||
else
|
||||
expr.fetch_attribute(&block)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
def ivars
|
||||
[@attribute, @values, @type]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -44,6 +44,8 @@ def to_sql(engine = Table.engine)
|
||||
|
||||
def fetch_attribute
|
||||
end
|
||||
|
||||
def equality?; false; end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -19,6 +19,10 @@ def type_cast_for_database(*args)
|
||||
relation.type_cast_for_database(*args)
|
||||
end
|
||||
|
||||
def type_for_attribute(name)
|
||||
relation.type_for_attribute(name)
|
||||
end
|
||||
|
||||
def able_to_type_cast?
|
||||
relation.respond_to?(:able_to_type_cast?) && relation.able_to_type_cast?
|
||||
end
|
||||
|
@ -100,6 +100,10 @@ def type_cast_for_database(attribute_name, value)
|
||||
type_caster.type_cast_for_database(attribute_name, value)
|
||||
end
|
||||
|
||||
def type_for_attribute(name)
|
||||
type_caster.type_for_attribute(name)
|
||||
end
|
||||
|
||||
def able_to_type_cast?
|
||||
!type_caster.nil?
|
||||
end
|
||||
|
@ -161,6 +161,12 @@ def visit_Arel_Nodes_Casted(o)
|
||||
visit_edge o, "attribute"
|
||||
end
|
||||
|
||||
def visit_Arel_Nodes_HomogeneousIn(o)
|
||||
visit_edge o, "values"
|
||||
visit_edge o, "type"
|
||||
visit_edge o, "attribute"
|
||||
end
|
||||
|
||||
def visit_Arel_Attribute(o)
|
||||
visit_edge o, "relation"
|
||||
visit_edge o, "name"
|
||||
|
@ -321,6 +321,30 @@ def visit_Arel_Nodes_Grouping(o, collector)
|
||||
end
|
||||
end
|
||||
|
||||
def visit_Arel_Nodes_HomogeneousIn(o, collector)
|
||||
collector << "("
|
||||
|
||||
collector << quote_table_name(o.table_name) << "." << quote_column_name(o.column_name)
|
||||
|
||||
if o.type == :in
|
||||
collector << "IN ("
|
||||
else
|
||||
collector << "NOT IN ("
|
||||
end
|
||||
|
||||
values = o.values.map { |v| @connection.quote v }
|
||||
|
||||
expr = if values.empty?
|
||||
@connection.quote(nil)
|
||||
else
|
||||
values.join(",")
|
||||
end
|
||||
|
||||
collector << expr
|
||||
collector << "))"
|
||||
collector
|
||||
end
|
||||
|
||||
def visit_Arel_SelectManager(o, collector)
|
||||
collector << "("
|
||||
visit(o.ast, collector) << ")"
|
||||
|
Loading…
Reference in New Issue
Block a user