Skip to content

Commit 62b6a6d

Browse files
BuonOmorafiss
authored andcommitted
feat: Add support for table optimizer hints
Add two new methods to `ActiveRecord::Relation::QueryMethods`. - `#force_index(index_name, direction: )` - `#index_hint(hint)` Fixes #266
1 parent 7f90178 commit 62b6a6d

File tree

4 files changed

+228
-0
lines changed

4 files changed

+228
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Ongoing
4+
5+
- Add support for table optimize hints (#266).
6+
37
## 7.0.2 - 2023-05-23
48

59
- Fix default numbers test to expect the correct result after

lib/active_record/connection_adapters/cockroachdb_adapter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
require_relative "../migration/cockroachdb/compatibility"
2424
require_relative "../../version"
2525

26+
require_relative "../relation/query_methods_ext"
27+
2628
# Run to ignore spatial tables that will break schemna dumper.
2729
# Defined in ./setup.rb
2830
ActiveRecord::ConnectionAdapters::CockroachDB.initial_setup
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
class Relation
5+
module QueryMethodsExt
6+
def from!(...) # :nodoc:
7+
@force_index = nil
8+
@index_hint = nil
9+
super
10+
end
11+
12+
# Set table index hint for the query to the
13+
# given `index_name`, and `direction` (either
14+
# `ASC` or `DESC`).
15+
#
16+
# Any call to `ActiveRecord::QueryMethods#from`
17+
# will reset the index hint. Index hints are
18+
# not set if the `from` clause is not a table
19+
# name.
20+
#
21+
# @see https://www.cockroachlabs.com/docs/v22.2/table-expressions#force-index-selection
22+
def force_index(index_name, direction: nil)
23+
spawn.force_index!(index_name, direction: direction)
24+
end
25+
26+
def force_index!(index_name, direction: nil)
27+
return self unless from_clause_is_a_table_name?
28+
29+
index_name = sanitize_sql(index_name.to_s)
30+
direction = direction.to_s.upcase
31+
direction = %w[ASC DESC].include?(direction) ? ",#{direction}" : ""
32+
33+
@force_index = "FORCE_INDEX=#{index_name}#{direction}"
34+
self.from_clause = build_from_clause_with_hints
35+
self
36+
end
37+
38+
# Set table index hint for the query with the
39+
# given `hint`. This allows more control over
40+
# the hint than `ActiveRecord::Relation#force_index`.
41+
# For instance, you could set it to `NO_FULL_SCAN`.
42+
#
43+
# Any call to `ActiveRecord::QueryMethods#from`
44+
# will reset the index hint. Index hints are
45+
# not set if the `from` clause is not a table
46+
# name.
47+
#
48+
# @see https://www.cockroachlabs.com/docs/v22.2/table-expressions#force-index-selection
49+
def index_hint(hint)
50+
spawn.index_hint!(hint)
51+
end
52+
53+
def index_hint!(hint)
54+
return self unless from_clause_is_a_table_name?
55+
56+
hint = sanitize_sql(hint.to_s)
57+
@index_hint = hint.to_s
58+
self.from_clause = build_from_clause_with_hints
59+
self
60+
end
61+
62+
private
63+
64+
def from_clause_is_a_table_name?
65+
# if empty, we are just dealing with the current table.
66+
return true if from_clause.empty?
67+
# `from_clause` can be a subquery.
68+
return false unless from_clause.value.is_a?(String)
69+
# `from_clause` can be a list of tables or a function.
70+
# A simple way to check is to see if the string
71+
# contains special characters. But we have to
72+
# not check against an existing table hint.
73+
return !from_clause.value.gsub(/\@{.*?\}/, "").match?(/[,\(]/)
74+
end
75+
76+
def build_from_clause_with_hints
77+
table_hints = [@index_hint, @force_index].compact.join(",")
78+
79+
table_name =
80+
if from_clause.empty?
81+
quoted_table_name
82+
else
83+
# Remove previous table hints if any. And spaces.
84+
from_clause.value.partition("@").first.strip
85+
end
86+
Relation::FromClause.new("#{table_name}@{#{table_hints}}", nil)
87+
end
88+
end
89+
90+
QueryMethods.prepend(QueryMethodsExt)
91+
end
92+
# `ActiveRecord::Base` ancestors do not include `QueryMethods`.
93+
# But the `#all` method returns a relation, which has `QueryMethods`
94+
# as ancestor. That is how active_record is doing is as well.
95+
#
96+
# @see https://github.com/rails/rails/blob/914130a9f/activerecord/lib/active_record/querying.rb#L23
97+
Querying.delegate(:force_index, :index_hint, to: :all)
98+
end
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper_cockroachdb"
4+
require "models/post"
5+
6+
module CockroachDB
7+
class TableHintsTest < ActiveRecord::TestCase
8+
fixtures :posts
9+
10+
def test_add_hint
11+
force_index = ->(q) { q.force_index("index_posts_on_author_id") }
12+
index_hint = ->(q) { q.index_hint("NO_FULL_SCAN") }
13+
14+
assert_sql(/"posts"@\{FORCE_INDEX=index_posts_on_author_id\}/) do
15+
Post.then(&force_index).take
16+
end
17+
18+
assert_sql(/"posts"@\{NO_FULL_SCAN\}/) do
19+
Post.then(&index_hint).take
20+
end
21+
22+
[force_index, index_hint].permutation do |procs|
23+
assert_sql(/"posts"@\{NO_FULL_SCAN,FORCE_INDEX=index_posts_on_author_id\}/) do
24+
Post.then(&procs.reduce(:<<)).take
25+
end
26+
end
27+
end
28+
29+
def test_choose_index_order
30+
idx = "index_posts_on_author_id"
31+
assert_sql(/"posts"@\{FORCE_INDEX=#{idx},ASC\}/) do
32+
Post.force_index(idx, direction: "ASC").take
33+
end
34+
assert_sql(/"posts"@\{FORCE_INDEX=#{idx},DESC\}/) do
35+
Post.force_index(idx, direction: "DESC").take
36+
end
37+
assert_sql(/"posts"@\{NO_FULL_SCAN,FORCE_INDEX=#{idx},DESC\}/) do
38+
Post.force_index(idx, direction: "DESC").index_hint("NO_FULL_SCAN").take
39+
end
40+
end
41+
42+
def test_use_other_table
43+
force_index = ->(q) { q.force_index("index_subscribers_on_nick") }
44+
index_hint = ->(q) { q.index_hint("NO_FULL_SCAN") }
45+
post_from = Post.select(:id).from("subscribers")
46+
47+
assert_sql(/subscribers@\{FORCE_INDEX=index_subscribers_on_nick\}/) do
48+
post_from.then(&force_index).take
49+
end
50+
51+
assert_sql(/subscribers@\{NO_FULL_SCAN\}/) do
52+
post_from.then(&index_hint).take
53+
end
54+
55+
[force_index, index_hint].permutation do |procs|
56+
assert_sql(/subscribers@\{NO_FULL_SCAN,FORCE_INDEX=index_subscribers_on_nick\}/) do
57+
post_from.then(&procs.reduce(:<<)).take
58+
end
59+
end
60+
end
61+
62+
def test_from_with_space
63+
["foo\t", "foo "].each do
64+
assert_match(/FROM foo@\{H\}/, Post.from(_1).index_hint("H").to_sql)
65+
assert_match(
66+
/FROM foo@\{H,FORCE_INDEX=i\}/,
67+
Post.from(_1).index_hint("H").force_index("i").to_sql
68+
)
69+
end
70+
end
71+
72+
def test_reset_with_from
73+
force_index = ->(q) { q.force_index("type") }
74+
index_hint = ->(q) { q.index_hint("NO_FULL_SCAN") }
75+
76+
[force_index, index_hint].permutation do
77+
assert_match(
78+
/FROM age\z/,
79+
Post.then(&_1).then(&_2).from("age").to_sql
80+
)
81+
end
82+
[force_index, index_hint].each do
83+
assert_match(/FROM age\z/, Post.then(&_1).from("age").to_sql)
84+
end
85+
86+
end
87+
88+
def test_ignore_from_multiple_tables
89+
tables = "ta, tb"
90+
from = ->(q) { q.from(tables) }
91+
force_index = ->(q) { q.force_index("type") }
92+
index_hint = ->(q) { q.index_hint("NO_FULL_SCAN") }
93+
94+
(
95+
[from, force_index, index_hint].permutation +
96+
[from, force_index].permutation +
97+
[from, index_hint].permutation
98+
).each do |procs|
99+
assert_match(
100+
/FROM #{tables}\z/,
101+
Post.then(&procs.reduce(:<<)).to_sql
102+
)
103+
end
104+
end
105+
106+
def test_ignore_from_subquery
107+
subquery = Post.where("created_at < ?", 1.year.ago)
108+
from = ->(q) { q.from(subquery) }
109+
force_index = ->(q) { q.force_index("safeword") }
110+
index_hint = ->(q) { q.index_hint("NO_FULL_SCAN") }
111+
112+
(
113+
[from, force_index, index_hint].permutation +
114+
[from, force_index].permutation +
115+
[from, index_hint].permutation
116+
).each do |procs|
117+
refute_match(
118+
/safeword|NO_FULL_SCAN|@/,
119+
Post.then(&procs.reduce(:<<)).to_sql
120+
)
121+
end
122+
end
123+
end
124+
end

0 commit comments

Comments
 (0)