Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Perf: Improve performance of where when using an array of values #39009

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 10 additions & 3 deletions activemodel/lib/active_model/type/helpers/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ def serialize(value)
cast(value)
end

def serialize2(value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why serialize2 exists? All definitions of it in this PR seems to have the same code as serialize.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used here https://github.com/rails/rails/pull/39009/files#diff-3bcf02e80bc52706c3df424d2a8c4db2R31 and it's a terrible name but we're not sure what to call it. The other serialize methods do both serialize and validation, this one just does serialization.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if all the implementations of serialize2 on this PR is always the same as the implementation as serialize I don't see the different and serialize2 is also doing serialization and validation, no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not the same. Check the serialize method on Integer here. It's doing both serialization and validation. This one is only doing serialization.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the role of serialize2 to be a fast, unchecked serialize (i.e. "garbage in, garbage out")? If so, would unchecked_serialize be an appropriate name?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that was the context I was missing. In this diff all serialize2 methods have the same implementation of the serialize in the same files, but I was missing that the subclasses were overriding the method.

If we want to serialize2 to be part of the public API I'd go with @jonathanhefner's suggestion, but with the long term goal of renaming it to serialize and renaming the current serialize to something as verify_and_serialize.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rafaelfranca I like @jonathanhefner's suggestion too. @eileencodes wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me!

cast(value)
end

def cast(value)
value = \
value = if value <=> 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably add a comment here about why we're doing this. This is a huge "hack" to check to see whether or not value is numeric. Non-numbers will return nil for <=>, and it avoid us need to do an is_a? check. We found using the spaceship operator was faster than checking types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why not use value.is_a?(Numeric). <=> is faster alternative of value.is_a?(Numeric)?

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
8 changes: 8 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,10 @@ def initialize(precision: nil, limit: nil, scale: nil)
@limit = limit
end

def serializable?(_)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be part of the public API?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should. I would like it if active model types can tell us whether or not a value can be serialized for that particular type. We should add some docs, but I didn't want to go that far unless everyone was OK with adding this to the public API

true
end

def type # :nodoc:
end

Expand Down Expand Up @@ -46,6 +50,10 @@ def serialize(value)
value
end

def serialize2(value)
value
end

# Type casts a value for schema dumping. This method is private, as we are
# hoping to remove it entirely.
def type_cast_for_schema(value) # :nodoc:
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
8 changes: 7 additions & 1 deletion activerecord/lib/active_record/enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,18 @@ 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 :serialize2 :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
4 changes: 4 additions & 0 deletions activerecord/lib/active_record/relation/predicate_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def initialize(table)
register_handler(Set, ArrayHandler.new(self))
end

def connection
@table.connection
end

def build_from_hash(attributes)
attributes = convert_dot_notation_to_hash(attributes)
expand_from_hash(attributes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@ 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?
predicate_builder.connection.in_clause_length
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
predicate_builder.connection.in_clause_length

I think we can remove this.

join_name = attribute.relation.table_alias || attribute.relation.name
quoted_column_name = "#{predicate_builder.connection.quote_table_name(join_name)}.#{predicate_builder.connection.quote_column_name(attribute.name)}"
type = attribute.type_caster

casted_values = values.map do |raw_value|
type.serialize2(raw_value) if type.serializable?(raw_value)
end

casted_values.compact!

return Arel::Nodes::HomogeneousIn.new(quoted_column_name, casted_values, attribute, :in)
else
build_in(values, predicate_builder, attribute)
end
values.empty? ? NullPredicate : attribute.in(values)
end

unless nils.empty?
Expand All @@ -39,6 +51,12 @@ def call(attribute, value)
private
attr_reader :predicate_builder

def build_in(values, predicate_builder, attribute)
attribute.in values.map { |v|
predicate_builder.build_bind_attribute(attribute.name, v)
}
end

module NullPredicate # :nodoc:
def self.or(other)
other
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 @@ -130,7 +130,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
8 changes: 7 additions & 1 deletion activerecord/lib/active_record/table_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module ActiveRecord
class TableMetadata # :nodoc:
delegate :foreign_type, :foreign_key, :join_primary_key, :join_foreign_key, to: :association, prefix: true

attr_reader :klass

def initialize(klass, arel_table, association = nil, types = klass)
@klass = klass
@types = types
Expand Down Expand Up @@ -41,6 +43,10 @@ def associated_with?(association_name)
klass && klass._reflect_on_association(association_name)
end

def connection
klass ? klass.connection : @types.connection
end

def associated_table(table_name)
association = klass._reflect_on_association(table_name) || klass._reflect_on_association(table_name.to_s.singularize)

Expand Down Expand Up @@ -85,6 +91,6 @@ def predicate_builder
end

private
attr_reader :klass, :types, :arel_table, :association
attr_reader :types, :arel_table, :association
end
end
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/type_caster/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def type_for_attribute(attr_name)
type || Type.default_value
end

delegate :connection, to: :@klass, private: true
delegate :connection, to: :@klass

private
attr_reader :table_name
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 @@ -7,6 +7,8 @@ def operator; :== end
alias :operand1 :left
alias :operand2 :right

def equality?; true; end

def invert
Arel::Nodes::NotEqual.new(left, right)
end
Expand Down
50 changes: 50 additions & 0 deletions activerecord/lib/arel/nodes/homogeneous_in.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Arel # :nodoc: all
module Nodes
class HomogeneousIn < Node
attr_reader :attribute, :quoted_column_name, :values, :type

def initialize(quoted_column_name, values, attribute, type)
@quoted_column_name = quoted_column_name
@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(quoted_column_name, values, attribute, type == :in ? :notin : :in)
end

def left
attribute
end

def fetch_attribute(&block)
if attribute
yield attribute
else
expr.fetch_attribute(&block)
end
end

protected
def ivars
[@attribute, @quoted_column_name, @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
8 changes: 7 additions & 1 deletion activerecord/lib/arel/visitors/dot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,13 @@ def visit_String(o)
alias :visit_Symbol :visit_String
alias :visit_Arel_Nodes_SqlLiteral :visit_String

def visit_Arel_Nodes_BindParam(o); end
def visit_Arel_Nodes_BindParam(o)
edge("value") { visit o.value }
end

def visit_ActiveRecord_Relation_QueryAttribute(o)
edge("value_before_type_cast") { visit o.value_before_type_cast }
end

def visit_Hash(o)
o.each_with_index do |pair, i|
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 @@ -324,6 +324,30 @@ def visit_Arel_Nodes_Grouping(o, collector)
end
end

def visit_Arel_Nodes_HomogeneousIn(o, collector)
collector << "("

collector << o.quoted_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
4 changes: 4 additions & 0 deletions activerecord/test/cases/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,10 @@ def test_find_by_slug
assert_equal Topic.find("1-meowmeow"), Topic.find(1)
end

def test_out_of_range_slugs
assert_equal [Topic.find(1)], Topic.where(id: ["1-meowmeow", "9223372036854775808-hello"])
end

def test_find_by_slug_with_array
assert_equal Topic.find([1, 2]), Topic.find(["1-meowmeow", "2-hello"])
assert_equal "The Second Topic of the day", Topic.find(["2-hello", "1-meowmeow"]).first.title
Expand Down