Skip to content

Commit

Permalink
Merge pull request #35615 from kamipo/optimizer_hints
Browse files Browse the repository at this point in the history
Support Optimizer Hints
  • Loading branch information
kamipo committed Mar 16, 2019
2 parents 6486f80 + 97347d8 commit 1db0506
Show file tree
Hide file tree
Showing 20 changed files with 184 additions and 6 deletions.
25 changes: 25 additions & 0 deletions activerecord/CHANGELOG.md
@@ -1,3 +1,28 @@
* Support Optimizer Hints.

In most databases, there is a way to control the optimizer is by using optimizer hints,
which can be specified within individual statements.

Example (for MySQL):

Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)")
# SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics`

Example (for PostgreSQL with pg_hint_plan):

Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)")
# SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics"

See also:

* https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
* https://pghintplan.osdn.jp/pg_hint_plan.html
* https://docs.oracle.com/en/database/oracle/oracle-database/12.2/tgsql/influencing-the-optimizer.html
* https://docs.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=sql-server-2017
* https://www.ibm.com/support/knowledgecenter/en/SSEPGG_11.1.0/com.ibm.db2.luw.admin.perf.doc/doc/c0070117.html

*Ryuta Kamizono*

* Fix query attribute method on user-defined attribute to be aware of typecasted value.

For example, the following code no longer return false as casted non-empty string:
Expand Down
Expand Up @@ -384,6 +384,11 @@ def supports_foreign_tables?
false
end

# Does this adapter support optimizer hints?
def supports_optimizer_hints?
false
end

def supports_lazy_transactions?
false
end
Expand Down
Expand Up @@ -103,6 +103,11 @@ def supports_virtual_columns?
mariadb? || version >= "5.7.5"
end

# See https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html for more details.
def supports_optimizer_hints?
!mariadb? && version >= "5.7.7"
end

def supports_advisory_locks?
true
end
Expand Down
Expand Up @@ -351,6 +351,13 @@ def supports_pgcrypto_uuid?
postgresql_version >= 90400
end

def supports_optimizer_hints?
unless defined?(@has_pg_hint_plan)
@has_pg_hint_plan = extension_available?("pg_hint_plan")
end
@has_pg_hint_plan
end

def supports_lazy_transactions?
true
end
Expand Down Expand Up @@ -381,9 +388,12 @@ def disable_extension(name)
}
end

def extension_available?(name)
query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA")
end

def extension_enabled?(name)
res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA")
res.cast_values.first
query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA")
end

def extensions
Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/querying.rb
Expand Up @@ -14,7 +14,7 @@ module Querying
:find_each, :find_in_batches, :in_batches,
:select, :reselect, :order, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins,
:where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, :or,
:having, :create_with, :distinct, :references, :none, :unscope, :merge, :except, :only,
:having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only,
:count, :average, :minimum, :maximum, :sum, :calculate,
:pluck, :pick, :ids
].freeze # :nodoc:
Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/relation.rb
Expand Up @@ -5,7 +5,7 @@ module ActiveRecord
class Relation
MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group,
:order, :joins, :left_outer_joins, :references,
:extending, :unscope]
:extending, :unscope, :optimizer_hints]

SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering,
:reverse_order, :distinct, :create_with, :skip_query_cache]
Expand Down
24 changes: 24 additions & 0 deletions activerecord/lib/active_record/relation/query_methods.rb
Expand Up @@ -901,6 +901,29 @@ def extending!(*modules, &block) # :nodoc:
self
end

# Specify optimizer hints to be used in the SELECT statement.
#
# Example (for MySQL):
#
# Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)")
# # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics`
#
# Example (for PostgreSQL with pg_hint_plan):
#
# Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)")
# # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics"
def optimizer_hints(*args)
check_if_method_has_arguments!(:optimizer_hints, args)
spawn.optimizer_hints!(*args)
end

def optimizer_hints!(*args) # :nodoc:
args.flatten!

self.optimizer_hints_values += args
self
end

# Reverse the existing order clause on the relation.
#
# User.order('name ASC').reverse_order # generated SQL has 'ORDER BY name DESC'
Expand Down Expand Up @@ -977,6 +1000,7 @@ def build_arel(aliases)

build_select(arel)

arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty?
arel.distinct(distinct_value)
arel.from(build_from) unless from_clause.empty?
arel.lock(lock_value) if lock_value
Expand Down
5 changes: 3 additions & 2 deletions activerecord/lib/arel/nodes/select_core.rb
Expand Up @@ -4,7 +4,7 @@ module Arel # :nodoc: all
module Nodes
class SelectCore < Arel::Nodes::Node
attr_accessor :projections, :wheres, :groups, :windows
attr_accessor :havings, :source, :set_quantifier
attr_accessor :havings, :source, :set_quantifier, :optimizer_hints

def initialize
super()
Expand Down Expand Up @@ -42,7 +42,7 @@ def initialize_copy(other)

def hash
[
@source, @set_quantifier, @projections,
@source, @set_quantifier, @projections, @optimizer_hints,
@wheres, @groups, @havings, @windows
].hash
end
Expand All @@ -51,6 +51,7 @@ def eql?(other)
self.class == other.class &&
self.source == other.source &&
self.set_quantifier == other.set_quantifier &&
self.optimizer_hints == other.optimizer_hints &&
self.projections == other.projections &&
self.wheres == other.wheres &&
self.groups == other.groups &&
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/arel/nodes/unary.rb
Expand Up @@ -35,6 +35,7 @@ def eql?(other)
Not
Offset
On
OptimizerHints
Ordering
RollUp
}.each do |name|
Expand Down
7 changes: 7 additions & 0 deletions activerecord/lib/arel/select_manager.rb
Expand Up @@ -146,6 +146,13 @@ def projections=(projections)
@ctx.projections = projections
end

def optimizer_hints(*hints)
unless hints.empty?
@ctx.optimizer_hints = Arel::Nodes::OptimizerHints.new(hints)
end
self
end

def distinct(value = true)
if value
@ctx.set_quantifier = Arel::Nodes::Distinct.new
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/arel/visitors/depth_first.rb
Expand Up @@ -35,6 +35,7 @@ def unary(o)
alias :visit_Arel_Nodes_Ascending :unary
alias :visit_Arel_Nodes_Descending :unary
alias :visit_Arel_Nodes_UnqualifiedColumn :unary
alias :visit_Arel_Nodes_OptimizerHints :unary

def function(o)
visit o.expressions
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/arel/visitors/dot.rb
Expand Up @@ -82,6 +82,7 @@ def unary(o)
alias :visit_Arel_Nodes_Offset :unary
alias :visit_Arel_Nodes_On :unary
alias :visit_Arel_Nodes_UnqualifiedColumn :unary
alias :visit_Arel_Nodes_OptimizerHints :unary
alias :visit_Arel_Nodes_Preceding :unary
alias :visit_Arel_Nodes_Following :unary
alias :visit_Arel_Nodes_Rows :unary
Expand Down
12 changes: 12 additions & 0 deletions activerecord/lib/arel/visitors/ibm_db.rb
Expand Up @@ -4,6 +4,14 @@ module Arel # :nodoc: all
module Visitors
class IBM_DB < Arel::Visitors::ToSql
private
def visit_Arel_Nodes_SelectCore(o, collector)
collector = super
maybe_visit o.optimizer_hints, collector
end

def visit_Arel_Nodes_OptimizerHints(o, collector)
collector << "/* <OPTGUIDELINES>#{sanitize_as_sql_comment(o).join}</OPTGUIDELINES> */"
end

def visit_Arel_Nodes_Limit(o, collector)
collector << "FETCH FIRST "
Expand All @@ -16,6 +24,10 @@ def is_distinct_from(o, collector)
collector = visit [o.left, o.right, 0, 1], collector
collector << ")"
end

def collect_optimizer_hints(o, collector)
collector
end
end
end
end
5 changes: 5 additions & 0 deletions activerecord/lib/arel/visitors/informix.rb
Expand Up @@ -42,10 +42,15 @@ def visit_Arel_Nodes_SelectCore(o, collector)
collector
end

def visit_Arel_Nodes_OptimizerHints(o, collector)
collector << "/*+ #{sanitize_as_sql_comment(o).join(", ")} */"
end

def visit_Arel_Nodes_Offset(o, collector)
collector << "SKIP "
visit o.expr, collector
end

def visit_Arel_Nodes_Limit(o, collector)
collector << "FIRST "
visit o.expr, collector
Expand Down
13 changes: 13 additions & 0 deletions activerecord/lib/arel/visitors/mssql.rb
Expand Up @@ -76,6 +76,15 @@ def visit_Arel_Nodes_SelectStatement(o, collector)
end
end

def visit_Arel_Nodes_SelectCore(o, collector)
collector = super
maybe_visit o.optimizer_hints, collector
end

def visit_Arel_Nodes_OptimizerHints(o, collector)
collector << "OPTION (#{sanitize_as_sql_comment(o).join(", ")})"
end

def get_offset_limit_clause(o)
first_row = o.offset ? o.offset.expr.to_i + 1 : 1
last_row = o.limit ? o.limit.expr.to_i - 1 + first_row : nil
Expand Down Expand Up @@ -103,6 +112,10 @@ def visit_Arel_Nodes_DeleteStatement(o, collector)
end
end

def collect_optimizer_hints(o, collector)
collector
end

def determine_order_by(orders, x)
if orders.any?
orders
Expand Down
13 changes: 13 additions & 0 deletions activerecord/lib/arel/visitors/to_sql.rb
Expand Up @@ -219,6 +219,7 @@ def visit_Arel_Nodes_SelectOptions(o, collector)
def visit_Arel_Nodes_SelectCore(o, collector)
collector << "SELECT"

collector = collect_optimizer_hints(o, collector)
collector = maybe_visit o.set_quantifier, collector

collect_nodes_for o.projections, collector, SPACE
Expand All @@ -236,6 +237,10 @@ def visit_Arel_Nodes_SelectCore(o, collector)
collector
end

def visit_Arel_Nodes_OptimizerHints(o, collector)
collector << "/*+ #{sanitize_as_sql_comment(o).join(" ")} */"
end

def collect_nodes_for(nodes, collector, spacer, connector = COMMA)
unless nodes.empty?
collector << spacer
Expand Down Expand Up @@ -799,6 +804,14 @@ def quote_column_name(name)
@connection.quote_column_name(name)
end

def sanitize_as_sql_comment(o)
o.expr.map { |v| v.gsub(%r{ /\*\+?\s* | \s*\*/ }x, "") }
end

def collect_optimizer_hints(o, collector)
maybe_visit o.optimizer_hints, collector
end

def maybe_visit(thing, collector)
return collector unless thing
collector << " "
Expand Down
24 changes: 24 additions & 0 deletions activerecord/test/cases/adapters/mysql2/optimizer_hints_test.rb
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "cases/helper"
require "models/post"

if supports_optimizer_hints?
class Mysql2OptimzerHintsTest < ActiveRecord::Mysql2TestCase
fixtures :posts

def test_optimizer_hints
assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do
posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)")
posts = posts.select(:id).where(author_id: [0, 1])
assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |"
end

assert_sql(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do
posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */")
posts = posts.select(:id).where(author_id: [0, 1])
assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |"
end
end
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require "cases/helper"
require "models/post"

if supports_optimizer_hints?
class PostgresqlOptimzerHintsTest < ActiveRecord::PostgreSQLTestCase
fixtures :posts

def setup
enable_extension!("pg_hint_plan", ActiveRecord::Base.connection)
end

def test_optimizer_hints
assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do
posts = Post.optimizer_hints("SeqScan(posts)")
posts = posts.select(:id).where(author_id: [0, 1])
assert_includes posts.explain, "Seq Scan on posts"
end

assert_sql(%r{\ASELECT /\*\+ SeqScan\(posts\) \*/}) do
posts = Post.optimizer_hints("/*+ SeqScan(posts) */")
posts = posts.select(:id).where(author_id: [0, 1])
assert_includes posts.explain, "Seq Scan on posts"
end
end
end
end
1 change: 1 addition & 0 deletions activerecord/test/cases/helper.rb
Expand Up @@ -64,6 +64,7 @@ def supports_default_expression?
supports_insert_on_duplicate_skip?
supports_insert_on_duplicate_update?
supports_insert_conflict_target?
supports_optimizer_hints?
].each do |method_name|
define_method method_name do
ActiveRecord::Base.connection.public_send(method_name)
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/cases/invertible_migration_test.rb
Expand Up @@ -308,6 +308,8 @@ def test_migrate_enable_and_disable_extension
migration2 = DisableExtension1.new
migration3 = DisableExtension2.new

assert_equal true, Horse.connection.extension_available?("hstore")

migration1.migrate(:up)
migration2.migrate(:up)
assert_equal true, Horse.connection.extension_enabled?("hstore")
Expand Down

0 comments on commit 1db0506

Please sign in to comment.