Skip to content

Commit

Permalink
Perf: Improve performance of where when using an array of values
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
eileencodes and tenderlove committed May 1, 2020
1 parent ff8f40e commit 72fd0ba
Show file tree
Hide file tree
Showing 17 changed files with 163 additions and 10 deletions.
12 changes: 9 additions & 3 deletions activemodel/lib/active_model/type/helpers/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion activemodel/lib/active_model/type/integer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions activemodel/lib/active_model/type/value.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion activerecord/lib/active_record/enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/relation/where_clause.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion activerecord/lib/active_record/type_caster/map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions activerecord/lib/arel/attributes/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/arel/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions activerecord/lib/arel/nodes/equality.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions activerecord/lib/arel/nodes/homogeneous_in.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions activerecord/lib/arel/nodes/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ def to_sql(engine = Table.engine)

def fetch_attribute
end

def equality?; false; end
end
end
end
4 changes: 4 additions & 0 deletions activerecord/lib/arel/nodes/table_alias.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions activerecord/lib/arel/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions activerecord/lib/arel/visitors/dot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions activerecord/lib/arel/visitors/to_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) << ")"
Expand Down

0 comments on commit 72fd0ba

Please sign in to comment.