c02d6bfb27
This improves performance and reduces memory allocations for `sanitize_sql_like` across multiple use cases. Benchmark script: ```ruby require "benchmark/memory" require "benchmark/ips" def old_sanitize_sql_like(string, escape_character = "\\") pattern = Regexp.union(escape_character, "%", "_") string.gsub(pattern) { |x| [escape_character, x].join } end def new_sanitize_sql_like(string, escape_character = "\\") if string.include?(escape_character) && escape_character != "%" && escape_character != "_" string = string.gsub(escape_character, '\0\0') end string.gsub(/(?=[%_])/, escape_character) end [ "no-special-characters", "one_wildcard", "one\\escape", "two_wildcards%and\\two\\escapes", ].each do |string| puts "\n= #{string.inspect} ".ljust(72, "=") Benchmark.memory do |x| # Warmup: old_sanitize_sql_like(string) new_sanitize_sql_like(string) x.report("old") { old_sanitize_sql_like(string) } x.report("new") { new_sanitize_sql_like(string) } end puts Benchmark.ips do |x| x.report("old") { old_sanitize_sql_like(string) } x.report("new") { new_sanitize_sql_like(string) } x.compare! end end ``` Benchmark results: ``` = "no-special-characters" ============================================= Calculating ------------------------------------- old 875.000 memsize ( 0.000 retained) 10.000 objects ( 0.000 retained) 5.000 strings ( 0.000 retained) new 40.000 memsize ( 0.000 retained) 1.000 objects ( 0.000 retained) 1.000 strings ( 0.000 retained) Warming up -------------------------------------- old 9.483k i/100ms new 119.791k i/100ms Calculating ------------------------------------- old 95.694k (± 0.3%) i/s - 483.633k in 5.053985s new 1.228M (± 0.5%) i/s - 6.229M in 5.072317s Comparison: new: 1228094.1 i/s old: 95694.3 i/s - 12.83x (± 0.00) slower = "one_wildcard" ====================================================== Calculating ------------------------------------- old 1.395k memsize ( 0.000 retained) 15.000 objects ( 0.000 retained) 6.000 strings ( 0.000 retained) new 440.000 memsize ( 0.000 retained) 3.000 objects ( 0.000 retained) 1.000 strings ( 0.000 retained) Warming up -------------------------------------- old 6.527k i/100ms new 30.205k i/100ms Calculating ------------------------------------- old 65.255k (± 0.6%) i/s - 326.350k in 5.001318s new 300.058k (± 0.5%) i/s - 1.510M in 5.033333s Comparison: new: 300057.6 i/s old: 65255.2 i/s - 4.60x (± 0.00) slower = "one\\escape" ======================================================= Calculating ------------------------------------- old 1.395k memsize ( 0.000 retained) 15.000 objects ( 0.000 retained) 6.000 strings ( 0.000 retained) new 560.000 memsize ( 0.000 retained) 6.000 objects ( 0.000 retained) 2.000 strings ( 0.000 retained) Warming up -------------------------------------- old 6.506k i/100ms new 24.312k i/100ms Calculating ------------------------------------- old 65.467k (± 0.4%) i/s - 331.806k in 5.068392s new 245.704k (± 0.5%) i/s - 1.240M in 5.046485s Comparison: new: 245703.8 i/s old: 65466.9 i/s - 3.75x (± 0.00) slower = "two_wildcards%and\\two\\escapes" =================================== Calculating ------------------------------------- old 1.755k memsize ( 0.000 retained) 24.000 objects ( 0.000 retained) 8.000 strings ( 0.000 retained) new 832.000 memsize ( 0.000 retained) 8.000 objects ( 0.000 retained) 3.000 strings ( 0.000 retained) Warming up -------------------------------------- old 5.265k i/100ms new 12.360k i/100ms Calculating ------------------------------------- old 52.698k (± 0.3%) i/s - 268.515k in 5.095425s new 124.006k (± 0.3%) i/s - 630.360k in 5.083351s Comparison: new: 124006.2 i/s old: 52697.9 i/s - 2.35x (± 0.00) slower ```
236 lines
9.5 KiB
Ruby
236 lines
9.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "cases/helper"
|
|
require "models/binary"
|
|
require "models/author"
|
|
require "models/post"
|
|
require "models/customer"
|
|
|
|
class SanitizeTest < ActiveRecord::TestCase
|
|
def setup
|
|
end
|
|
|
|
def test_sanitize_sql_array_handles_string_interpolation
|
|
quoted_bambi = ActiveRecord::Base.connection.quote_string("Bambi")
|
|
assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi"])
|
|
assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi".mb_chars])
|
|
quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote_string("Bambi\nand\nThumper")
|
|
assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper"])
|
|
assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper".mb_chars])
|
|
end
|
|
|
|
def test_sanitize_sql_array_handles_bind_variables
|
|
quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
|
|
assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi"])
|
|
assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi".mb_chars])
|
|
quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
|
|
assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper"])
|
|
assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper".mb_chars])
|
|
end
|
|
|
|
def test_sanitize_sql_array_handles_named_bind_variables
|
|
quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
|
|
assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=:name", name: "Bambi"])
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "name=#{quoted_bambi} AND id='1'", Binary.sanitize_sql_array(["name=:name AND id=:id", name: "Bambi", id: 1])
|
|
else
|
|
assert_equal "name=#{quoted_bambi} AND id=1", Binary.sanitize_sql_array(["name=:name AND id=:id", name: "Bambi", id: 1])
|
|
end
|
|
|
|
quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
|
|
assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name", name: "Bambi\nand\nThumper"])
|
|
assert_equal "name=#{quoted_bambi_and_thumper} AND name2=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name AND name2=:name", name: "Bambi\nand\nThumper"])
|
|
end
|
|
|
|
def test_sanitize_sql_array_handles_relations
|
|
david = Author.create!(name: "David")
|
|
david_posts = david.posts.select(:id)
|
|
|
|
sub_query_pattern = /\(\bselect\b.*?\bwhere\b.*?\)/i
|
|
|
|
select_author_sql = Post.sanitize_sql_array(["id in (?)", david_posts])
|
|
assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for bind variables")
|
|
|
|
select_author_sql = Post.sanitize_sql_array(["id in (:post_ids)", post_ids: david_posts])
|
|
assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for named bind variables")
|
|
end
|
|
|
|
def test_sanitize_sql_array_handles_empty_statement
|
|
select_author_sql = Post.sanitize_sql_array([""])
|
|
assert_equal("", select_author_sql)
|
|
end
|
|
|
|
def test_sanitize_sql_like
|
|
assert_equal '100\%', Binary.sanitize_sql_like("100%")
|
|
assert_equal 'snake\_cased\_string', Binary.sanitize_sql_like("snake_cased_string")
|
|
assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint')
|
|
assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42")
|
|
end
|
|
|
|
def test_sanitize_sql_like_with_custom_escape_character
|
|
assert_equal "100!%", Binary.sanitize_sql_like("100%", "!")
|
|
assert_equal "snake!_cased!_string", Binary.sanitize_sql_like("snake_cased_string", "!")
|
|
assert_equal "great!!", Binary.sanitize_sql_like("great!", "!")
|
|
assert_equal 'C:\\Programs\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint', "!")
|
|
assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42", "!")
|
|
end
|
|
|
|
def test_sanitize_sql_like_with_wildcard_as_escape_character
|
|
assert_equal "1__000_%", Binary.sanitize_sql_like("1_000%", "_")
|
|
assert_equal "1%_000%%", Binary.sanitize_sql_like("1_000%", "%")
|
|
end
|
|
|
|
def test_sanitize_sql_like_example_use_case
|
|
searchable_post = Class.new(Post) do
|
|
def self.search_as_method(term)
|
|
where("title LIKE ?", sanitize_sql_like(term, "!"))
|
|
end
|
|
|
|
scope :search_as_scope, -> (term) {
|
|
where("title LIKE ?", sanitize_sql_like(term, "!"))
|
|
}
|
|
end
|
|
|
|
assert_sql(/LIKE '20!% !_reduction!_!!'/) do
|
|
searchable_post.search_as_method("20% _reduction_!").to_a
|
|
end
|
|
|
|
assert_sql(/LIKE '20!% !_reduction!_!!'/) do
|
|
searchable_post.search_as_scope("20% _reduction_!").to_a
|
|
end
|
|
end
|
|
|
|
def test_disallow_raw_sql_with_unknown_attribute_string
|
|
assert_raise(ActiveRecord::UnknownAttributeReference) { Binary.disallow_raw_sql!(["field(id, ?)"]) }
|
|
end
|
|
|
|
def test_disallow_raw_sql_with_unknown_attribute_sql_literal
|
|
assert_nothing_raised { Binary.disallow_raw_sql!([Arel.sql("field(id, ?)")]) }
|
|
end
|
|
|
|
def test_bind_arity
|
|
assert_nothing_raised { bind "" }
|
|
assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "", 1 }
|
|
|
|
assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "?" }
|
|
assert_nothing_raised { bind "?", 1 }
|
|
assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "?", 1, 1 }
|
|
end
|
|
|
|
def test_named_bind_variables
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'1'", bind(":a", a: 1) # ' ruby-mode
|
|
assert_equal "'1' '1'", bind(":a :a", a: 1) # ' ruby-mode
|
|
else
|
|
assert_equal "1", bind(":a", a: 1) # ' ruby-mode
|
|
assert_equal "1 1", bind(":a :a", a: 1) # ' ruby-mode
|
|
end
|
|
|
|
assert_nothing_raised { bind("'+00:00'", foo: "bar") }
|
|
end
|
|
|
|
def test_named_bind_arity
|
|
assert_nothing_raised { bind "name = :name", name: "37signals" }
|
|
assert_nothing_raised { bind "name = :name", name: "37signals", id: 1 }
|
|
assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "name = :name", id: 1 }
|
|
end
|
|
|
|
class SimpleEnumerable
|
|
include Enumerable
|
|
|
|
def initialize(ary)
|
|
@ary = ary
|
|
end
|
|
|
|
def each(&b)
|
|
@ary.each(&b)
|
|
end
|
|
end
|
|
|
|
def test_bind_enumerable
|
|
quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')})
|
|
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'1','2','3'", bind("?", [1, 2, 3])
|
|
else
|
|
assert_equal "1,2,3", bind("?", [1, 2, 3])
|
|
end
|
|
assert_equal quoted_abc, bind("?", %w(a b c))
|
|
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'1','2','3'", bind(":a", a: [1, 2, 3])
|
|
else
|
|
assert_equal "1,2,3", bind(":a", a: [1, 2, 3])
|
|
end
|
|
assert_equal quoted_abc, bind(":a", a: %w(a b c)) # '
|
|
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'1','2','3'", bind("?", SimpleEnumerable.new([1, 2, 3]))
|
|
else
|
|
assert_equal "1,2,3", bind("?", SimpleEnumerable.new([1, 2, 3]))
|
|
end
|
|
assert_equal quoted_abc, bind("?", SimpleEnumerable.new(%w(a b c)))
|
|
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'1','2','3'", bind(":a", a: SimpleEnumerable.new([1, 2, 3]))
|
|
else
|
|
assert_equal "1,2,3", bind(":a", a: SimpleEnumerable.new([1, 2, 3]))
|
|
end
|
|
assert_equal quoted_abc, bind(":a", a: SimpleEnumerable.new(%w(a b c))) # '
|
|
end
|
|
|
|
def test_bind_empty_enumerable
|
|
quoted_nil = ActiveRecord::Base.connection.quote(nil)
|
|
assert_equal quoted_nil, bind("?", [])
|
|
assert_equal " in (#{quoted_nil})", bind(" in (?)", [])
|
|
assert_equal "foo in (#{quoted_nil})", bind("foo in (?)", [])
|
|
end
|
|
|
|
def test_bind_range
|
|
quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')})
|
|
if current_adapter?(:Mysql2Adapter)
|
|
assert_equal "'0'", bind("?", 0..0)
|
|
assert_equal "'1','2','3'", bind("?", 1..3)
|
|
else
|
|
assert_equal "0", bind("?", 0..0)
|
|
assert_equal "1,2,3", bind("?", 1..3)
|
|
end
|
|
assert_equal quoted_abc, bind("?", "a"..."d")
|
|
end
|
|
|
|
def test_bind_empty_range
|
|
quoted_nil = ActiveRecord::Base.connection.quote(nil)
|
|
assert_equal quoted_nil, bind("?", 0...0)
|
|
assert_equal quoted_nil, bind("?", "a"..."a")
|
|
end
|
|
|
|
def test_bind_empty_string
|
|
quoted_empty = ActiveRecord::Base.connection.quote("")
|
|
assert_equal quoted_empty, bind("?", "")
|
|
end
|
|
|
|
def test_bind_chars
|
|
quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
|
|
quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
|
|
assert_equal "name=#{quoted_bambi}", bind("name=?", "Bambi")
|
|
assert_equal "name=#{quoted_bambi_and_thumper}", bind("name=?", "Bambi\nand\nThumper")
|
|
assert_equal "name=#{quoted_bambi}", bind("name=?", "Bambi".mb_chars)
|
|
assert_equal "name=#{quoted_bambi_and_thumper}", bind("name=?", "Bambi\nand\nThumper".mb_chars)
|
|
end
|
|
|
|
def test_named_bind_with_postgresql_type_casts
|
|
l = Proc.new { bind(":a::integer '2009-01-01'::date", a: "10") }
|
|
assert_nothing_raised(&l)
|
|
assert_equal "#{ActiveRecord::Base.connection.quote('10')}::integer '2009-01-01'::date", l.call
|
|
end
|
|
|
|
private
|
|
def bind(statement, *vars)
|
|
if vars.first.is_a?(Hash)
|
|
ActiveRecord::Base.send(:replace_named_bind_variables, statement, vars.first)
|
|
else
|
|
ActiveRecord::Base.send(:replace_bind_variables, statement, vars)
|
|
end
|
|
end
|
|
end
|