rubocop-isucon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/gh-pages.yml +44 -0
- data/.github/workflows/test.yml +91 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +43 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/Rakefile +35 -0
- data/benchmark/README.md +69 -0
- data/benchmark/memorize.rb +86 -0
- data/benchmark/parse_table.rb +103 -0
- data/benchmark/shell.rb +26 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +83 -0
- data/config/enable-only-performance.yml +30 -0
- data/gemfiles/activerecord_6_1.gemfile +14 -0
- data/gemfiles/activerecord_7_0.gemfile +14 -0
- data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector/correctable_methods.rb +66 -0
- data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector/replace_methods.rb +127 -0
- data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector.rb +112 -0
- data/lib/rubocop/cop/isucon/mixin/database_methods.rb +59 -0
- data/lib/rubocop/cop/isucon/mixin/mysql2_xquery_methods.rb +176 -0
- data/lib/rubocop/cop/isucon/mixin/sinatra_methods.rb +37 -0
- data/lib/rubocop/cop/isucon/mysql2/join_without_index.rb +100 -0
- data/lib/rubocop/cop/isucon/mysql2/many_join_table.rb +86 -0
- data/lib/rubocop/cop/isucon/mysql2/n_plus_one_query.rb +179 -0
- data/lib/rubocop/cop/isucon/mysql2/prepare_execute.rb +136 -0
- data/lib/rubocop/cop/isucon/mysql2/select_asterisk.rb +171 -0
- data/lib/rubocop/cop/isucon/mysql2/where_without_index.rb +105 -0
- data/lib/rubocop/cop/isucon/shell/backtick.rb +36 -0
- data/lib/rubocop/cop/isucon/shell/system.rb +36 -0
- data/lib/rubocop/cop/isucon/sinatra/disable_logging.rb +83 -0
- data/lib/rubocop/cop/isucon/sinatra/logger.rb +52 -0
- data/lib/rubocop/cop/isucon/sinatra/rack_logger.rb +58 -0
- data/lib/rubocop/cop/isucon/sinatra/serve_static_file.rb +73 -0
- data/lib/rubocop/cop/isucon_cops.rb +20 -0
- data/lib/rubocop/isucon/database_connection.rb +42 -0
- data/lib/rubocop/isucon/gda/client.rb +184 -0
- data/lib/rubocop/isucon/gda/gda_ext.rb +119 -0
- data/lib/rubocop/isucon/gda/join_condition.rb +25 -0
- data/lib/rubocop/isucon/gda/join_operand.rb +46 -0
- data/lib/rubocop/isucon/gda/node_location.rb +42 -0
- data/lib/rubocop/isucon/gda/node_patcher.rb +101 -0
- data/lib/rubocop/isucon/gda/where_condition.rb +73 -0
- data/lib/rubocop/isucon/gda/where_operand.rb +32 -0
- data/lib/rubocop/isucon/gda.rb +28 -0
- data/lib/rubocop/isucon/inject.rb +20 -0
- data/lib/rubocop/isucon/memorize_methods.rb +38 -0
- data/lib/rubocop/isucon/version.rb +7 -0
- data/lib/rubocop/isucon.rb +20 -0
- data/lib/rubocop-isucon.rb +16 -0
- data/rubocop-isucon.gemspec +52 -0
- metadata +286 -0
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module Isucon
|
6
|
+
module Mixin
|
7
|
+
# Helper methods for `db.xquery` or `db.query` in AST
|
8
|
+
module Mysql2XqueryMethods
|
9
|
+
extend NodePattern::Macros
|
10
|
+
|
11
|
+
# @!method find_xquery(node)
|
12
|
+
# @param node [RuboCop::AST::Node]
|
13
|
+
def_node_search :find_xquery, <<~PATTERN
|
14
|
+
(send (send nil? _) {:xquery | :query} (${str dstr lvar ivar cvar} $...) ...)
|
15
|
+
PATTERN
|
16
|
+
|
17
|
+
NON_STRING_WARNING_MSG = "Warning: non-string was passed to `query` or `xquery` 1st argument. " \
|
18
|
+
"So argument doesn't parsed as SQL (%<file_path>s:%<line_num>d)"
|
19
|
+
|
20
|
+
# @param node [RuboCop::AST::Node]
|
21
|
+
# @yieldparam type [Symbol] Node type. one of `:str`, `:dstr`
|
22
|
+
# @yieldparam root_gda [RuboCop::Isucon::GDA::Client,nil]
|
23
|
+
#
|
24
|
+
# @note If arguments of `db.xquery` isn't string, `root_gda` is `nil`
|
25
|
+
def with_xquery(node)
|
26
|
+
find_xquery(node) do |type, params|
|
27
|
+
sql = xquery_param(type: type, params: params)
|
28
|
+
|
29
|
+
unless sql
|
30
|
+
warn format(NON_STRING_WARNING_MSG, file_path: processed_source.file_path, line_num: node.loc.expression.line)
|
31
|
+
end
|
32
|
+
|
33
|
+
root_gda = sql ? RuboCop::Isucon::GDA::Client.new(sql) : nil
|
34
|
+
|
35
|
+
yield type, root_gda
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
40
|
+
# @param node [RuboCop::AST::Node]
|
41
|
+
# @param gda_location [RuboCop::Isucon::GDA::NodeLocation]
|
42
|
+
# @return [Parser::Source::Range,nil]
|
43
|
+
def offense_location(type:, node:, gda_location:)
|
44
|
+
return nil unless gda_location
|
45
|
+
|
46
|
+
begin_pos = begin_position_from_gda_location(type: type, node: node, gda_location: gda_location)
|
47
|
+
return nil unless begin_pos
|
48
|
+
|
49
|
+
end_pos = begin_pos + gda_location.length
|
50
|
+
Parser::Source::Range.new(node.loc.expression.source_buffer, begin_pos, end_pos)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
56
|
+
# @param params [Array<RuboCop::AST::Node>]
|
57
|
+
# @return [String,nil]
|
58
|
+
def xquery_param(type:, params:)
|
59
|
+
case type
|
60
|
+
when :str
|
61
|
+
return params[0]
|
62
|
+
when :dstr
|
63
|
+
if params.all? { |param| param.respond_to?(:value) }
|
64
|
+
# heredoc
|
65
|
+
return params.map(&:value).join
|
66
|
+
end
|
67
|
+
end
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
72
|
+
# @param node [RuboCop::AST::Node]
|
73
|
+
# @param gda_location [RuboCop::Isucon::GDA::NodeLocation]
|
74
|
+
# @return [Integer,nil]
|
75
|
+
def begin_position_from_gda_location(type:, node:, gda_location:)
|
76
|
+
case type
|
77
|
+
when :str
|
78
|
+
return begin_position_from_gda_location_for_str(node: node, gda_location: gda_location)
|
79
|
+
when :dstr
|
80
|
+
return begin_position_from_gda_location_for_dstr(node: node, gda_location: gda_location)
|
81
|
+
end
|
82
|
+
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# @param node [RuboCop::AST::Node]
|
87
|
+
# @param gda_location [RuboCop::Isucon::GDA::NodeLocation]
|
88
|
+
# @return [Integer,nil]
|
89
|
+
def begin_position_from_gda_location_for_str(node:, gda_location:)
|
90
|
+
str_node = node.child_nodes[1]
|
91
|
+
return nil unless str_node&.str_type?
|
92
|
+
|
93
|
+
str_node.loc.begin.end_pos + gda_location.begin_pos
|
94
|
+
end
|
95
|
+
|
96
|
+
# @param node [RuboCop::AST::Node]
|
97
|
+
# @param gda_location [RuboCop::Isucon::GDA::NodeLocation]
|
98
|
+
# @return [Integer,nil]
|
99
|
+
def begin_position_from_gda_location_for_dstr(node:, gda_location:)
|
100
|
+
dstr_node = node.child_nodes[1]
|
101
|
+
return nil unless dstr_node&.dstr_type?
|
102
|
+
|
103
|
+
str_node = find_str_node_from_gda_location(dstr_node: dstr_node, gda_location: gda_location)
|
104
|
+
index = str_node.value.index(gda_location.body)
|
105
|
+
return nil unless index
|
106
|
+
|
107
|
+
str_node_begin_pos(str_node) + index + heredoc_indent_level(node)
|
108
|
+
end
|
109
|
+
|
110
|
+
# @param str_node [RuboCop::AST::StrNode]
|
111
|
+
# @return [Integer]
|
112
|
+
def str_node_begin_pos(str_node)
|
113
|
+
begin_pos = str_node.loc.expression.begin_pos
|
114
|
+
|
115
|
+
# e.g.
|
116
|
+
# db.xquery(
|
117
|
+
# "SELECT * " \
|
118
|
+
# "FROM users " \
|
119
|
+
# "LIMIT 10"
|
120
|
+
# )
|
121
|
+
return begin_pos + 1 if str_node.loc.expression.source_buffer.source[begin_pos] == '"'
|
122
|
+
|
123
|
+
begin_pos
|
124
|
+
end
|
125
|
+
|
126
|
+
# @param dstr_node [RuboCop::AST::DstrNode]
|
127
|
+
# @param gda_location [RuboCop::Isucon::GDA::NodeLocation]
|
128
|
+
# @return [RuboCop::AST::StrNode,nil]
|
129
|
+
def find_str_node_from_gda_location(dstr_node:, gda_location:)
|
130
|
+
return nil unless dstr_node
|
131
|
+
|
132
|
+
begin_pos = 0
|
133
|
+
dstr_node.child_nodes.each do |str_node|
|
134
|
+
return str_node if begin_pos <= gda_location.begin_pos && gda_location.begin_pos < begin_pos + str_node.value.length
|
135
|
+
|
136
|
+
begin_pos += str_node.value.length
|
137
|
+
end
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
# @param node [RuboCop::AST::Node]
|
142
|
+
# @return [Integer]
|
143
|
+
def heredoc_indent_level(node)
|
144
|
+
dstr_node = node.child_nodes[1]
|
145
|
+
return 0 unless dstr_node&.dstr_type?
|
146
|
+
|
147
|
+
heredoc_indent_type = heredoc_indent_type(node)
|
148
|
+
return 0 unless heredoc_indent_type == "~"
|
149
|
+
|
150
|
+
heredoc_body = dstr_node.loc.heredoc_body.source
|
151
|
+
indent_level(heredoc_body)
|
152
|
+
end
|
153
|
+
|
154
|
+
# @param str [String]
|
155
|
+
# @return [Integer]
|
156
|
+
# @see https://github.com/rubocop/rubocop/blob/v1.21.0/lib/rubocop/cop/mixin/heredoc.rb#L23-L28
|
157
|
+
def indent_level(str)
|
158
|
+
indentations = str.lines.
|
159
|
+
map { |line| line[/^\s*/] }.
|
160
|
+
reject { |line| line.end_with?("\n") }
|
161
|
+
indentations.empty? ? 0 : indentations.min_by(&:size).size
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns '~', '-' or nil
|
165
|
+
#
|
166
|
+
# @param node [RuboCop::AST::Node]
|
167
|
+
# @return [String,nil] '~', '-' or `nil`
|
168
|
+
# @see https://github.com/rubocop/rubocop/blob/v1.21.0/lib/rubocop/cop/layout/heredoc_indentation.rb#L146-L149
|
169
|
+
def heredoc_indent_type(node)
|
170
|
+
node.source[/<<([~-])/, 1]
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module Isucon
|
6
|
+
module Mixin
|
7
|
+
# Helper methods for {RuboCop::Cop::Isucon::Sinatra}
|
8
|
+
module SinatraMethods
|
9
|
+
extend NodePattern::Macros
|
10
|
+
|
11
|
+
# @!method subclass_of_sinatra_base?(node)
|
12
|
+
# Whether match to `class AnyClass < Sinatra::Base` node
|
13
|
+
# @param node [RuboCop::AST::Node]
|
14
|
+
# @return [Boolean]
|
15
|
+
def_node_matcher :subclass_of_sinatra_base?, <<~PATTERN
|
16
|
+
(class (const nil? _) (const (const nil? :Sinatra) :Base) ...)
|
17
|
+
PATTERN
|
18
|
+
|
19
|
+
# @!method subclass_of_sinatra_base_contains_logging?(node)
|
20
|
+
# Whether match to `class AnyClass < Sinatra::Base` node and contains :logging configuration
|
21
|
+
# @param node [RuboCop::AST::Node]
|
22
|
+
# @return [Boolean]
|
23
|
+
def_node_matcher :subclass_of_sinatra_base_contains_logging?, <<~PATTERN
|
24
|
+
(class (const nil? _) (const (const nil? :Sinatra) :Base) ... `(send nil? _ (sym :logging)))
|
25
|
+
PATTERN
|
26
|
+
|
27
|
+
# Whether parent node match to `class AnyClass < Sinatra::Base` node
|
28
|
+
# @param node [RuboCop::AST::Node]
|
29
|
+
# @return [Boolean]
|
30
|
+
def parent_is_sinatra_app?(node)
|
31
|
+
node.each_ancestor.any? { |ancestor| subclass_of_sinatra_base?(ancestor) }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module Isucon
|
6
|
+
module Mysql2
|
7
|
+
# Check for `JOIN` without index
|
8
|
+
#
|
9
|
+
# @note If `Database` isn't configured, this cop's feature (offense detection and auto-correct) will not be available.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
# # bad (user_id is not indexed)
|
13
|
+
# db.xquery('SELECT id, title FROM articles JOIN users ON users.id = articles.user_id')
|
14
|
+
#
|
15
|
+
# # good (user_id is indexed)
|
16
|
+
# db.xquery('SELECT id, title FROM articles JOIN users ON users.id = articles.user_id')
|
17
|
+
#
|
18
|
+
class JoinWithoutIndex < Base
|
19
|
+
include Mixin::DatabaseMethods
|
20
|
+
include Mixin::Mysql2XqueryMethods
|
21
|
+
|
22
|
+
MSG = "This join clause doesn't seem to have an index. " \
|
23
|
+
"(e.g. `ALTER TABLE %<table_name>s ADD INDEX index_%<column_name>s (%<column_name>s)`)"
|
24
|
+
|
25
|
+
# @param node [RuboCop::AST::Node]
|
26
|
+
def on_send(node)
|
27
|
+
with_error_handling(node) do
|
28
|
+
return unless enabled_database?
|
29
|
+
|
30
|
+
with_xquery(node) do |type, root_gda|
|
31
|
+
check_and_register_offence(type: type, root_gda: root_gda, node: node)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
39
|
+
# @param root_gda [RuboCop::Isucon::GDA::Client]
|
40
|
+
# @param node [RuboCop::AST::Node]
|
41
|
+
def check_and_register_offence(type:, root_gda:, node:)
|
42
|
+
return unless root_gda
|
43
|
+
|
44
|
+
root_gda.visit_all do |gda|
|
45
|
+
gda.join_conditions.each do |join_condition|
|
46
|
+
join_operand = join_operand_without_index(join_condition)
|
47
|
+
next unless join_operand
|
48
|
+
|
49
|
+
register_offense(type: type, node: node, join_operand: join_operand)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# @param join_condition [RuboCop::Isucon::GDA::JoinCondition]
|
55
|
+
# @return [RuboCop::Isucon::GDA::JoinOperand,nil]
|
56
|
+
def join_operand_without_index(join_condition)
|
57
|
+
join_condition.operands.each do |join_operand|
|
58
|
+
next unless join_operand.table_name
|
59
|
+
|
60
|
+
unless indexed_column?(table_name: join_operand.table_name, column_name: join_operand.column_name)
|
61
|
+
return join_operand
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
# @param table_name [String]
|
69
|
+
# @param column_name [String]
|
70
|
+
# @return [Boolean]
|
71
|
+
def indexed_column?(table_name:, column_name:)
|
72
|
+
primary_keys = connection.primary_keys(table_name)
|
73
|
+
|
74
|
+
return true if primary_keys&.first == column_name
|
75
|
+
|
76
|
+
indexes = connection.indexes(table_name)
|
77
|
+
index_first_columns = indexes.map { |index| index.columns[0] }
|
78
|
+
index_first_columns.include?(column_name)
|
79
|
+
end
|
80
|
+
|
81
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
82
|
+
# @param node [RuboCop::AST::Node]
|
83
|
+
# @param join_operand [RuboCop::Isucon::GDA::JoinOperand]
|
84
|
+
def register_offense(type:, node:, join_operand:)
|
85
|
+
loc = offense_location(type: type, node: node, gda_location: join_operand.node.location)
|
86
|
+
return unless loc
|
87
|
+
|
88
|
+
message = offense_message(join_operand)
|
89
|
+
add_offense(loc, message: message)
|
90
|
+
end
|
91
|
+
|
92
|
+
# @param join_operand [RuboCop::Isucon::GDA::JoinOperand]
|
93
|
+
def offense_message(join_operand)
|
94
|
+
format(MSG, table_name: join_operand.table_name, column_name: join_operand.column_name)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module Isucon
|
6
|
+
module Mysql2
|
7
|
+
# Check if SQL contains many JOINs
|
8
|
+
#
|
9
|
+
# @example CountTables: 3 (default)
|
10
|
+
# # bad
|
11
|
+
# totals = db.xquery(
|
12
|
+
# "SELECT IFNULL(SUM(`submissions`.`score`), 0) AS `total_score`" \
|
13
|
+
# " FROM `users`" \
|
14
|
+
# " JOIN `registrations` ON `users`.`id` = `registrations`.`user_id`" \
|
15
|
+
# " JOIN `courses` ON `registrations`.`course_id` = `courses`.`id`" \
|
16
|
+
# " LEFT JOIN `classes` ON `courses`.`id` = `classes`.`course_id`" \
|
17
|
+
# " LEFT JOIN `submissions` ON `users`.`id` = `submissions`.`user_id` AND `submissions`.`class_id` = `classes`.`id`" \
|
18
|
+
# " WHERE `courses`.`id` = ?" \
|
19
|
+
# " GROUP BY `users`.`id`",
|
20
|
+
# course[:id]
|
21
|
+
# ).map { |_| _[:total_score] }
|
22
|
+
#
|
23
|
+
# # good
|
24
|
+
# registration_users_count =
|
25
|
+
# db.xquery("SELECT COUNT(`user_id`) AS cnt FROM `registrations` WHERE `course_id` = ?", course[:id]).first[:cnt]
|
26
|
+
#
|
27
|
+
# totals = db.xquery(<<~SQL, course[:id]).map { |_| _[:total_score] }
|
28
|
+
# SELECT IFNULL(SUM(`submissions`.`score`), 0) AS `total_score`
|
29
|
+
# FROM `submissions`
|
30
|
+
# JOIN `classes` ON `classes`.`id` = `submissions`.`class_id`
|
31
|
+
# WHERE `classes`.`course_id` = ?
|
32
|
+
# GROUP BY `submissions`.`user_id`
|
33
|
+
# SQL
|
34
|
+
#
|
35
|
+
# if totals.count < registration_users_count
|
36
|
+
# no_submissions_count = registration_users_count - totals.count
|
37
|
+
# totals += [0] * no_submissions_count
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# @example CountTables: 5
|
41
|
+
# # good
|
42
|
+
# totals = db.xquery(
|
43
|
+
# "SELECT IFNULL(SUM(`submissions`.`score`), 0) AS `total_score`" \
|
44
|
+
# " FROM `users`" \
|
45
|
+
# " JOIN `registrations` ON `users`.`id` = `registrations`.`user_id`" \
|
46
|
+
# " JOIN `courses` ON `registrations`.`course_id` = `courses`.`id`" \
|
47
|
+
# " LEFT JOIN `classes` ON `courses`.`id` = `classes`.`course_id`" \
|
48
|
+
# " LEFT JOIN `submissions` ON `users`.`id` = `submissions`.`user_id` AND `submissions`.`class_id` = `classes`.`id`" \
|
49
|
+
# " WHERE `courses`.`id` = ?" \
|
50
|
+
# " GROUP BY `users`.`id`",
|
51
|
+
# course[:id]
|
52
|
+
# ).map { |_| _[:total_score] }
|
53
|
+
#
|
54
|
+
class ManyJoinTable < Base
|
55
|
+
include Mixin::Mysql2XqueryMethods
|
56
|
+
|
57
|
+
MSG = "Avoid SQL with lots of JOINs"
|
58
|
+
|
59
|
+
# @param node [RuboCop::AST::Node]
|
60
|
+
def on_send(node)
|
61
|
+
with_xquery(node) do |_, root_gda|
|
62
|
+
check_and_register_offence(root_gda: root_gda, node: node)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# @param root_gda [RuboCop::Isucon::GDA::Client]
|
69
|
+
# @param node [RuboCop::AST::Node]
|
70
|
+
def check_and_register_offence(root_gda:, node:)
|
71
|
+
return unless root_gda
|
72
|
+
|
73
|
+
root_gda.visit_all do |gda|
|
74
|
+
add_offense(node) if gda.table_names.count > count_tables
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [Integer]
|
79
|
+
def count_tables
|
80
|
+
cop_config["CountTables"]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,179 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RuboCop
|
4
|
+
module Cop
|
5
|
+
module Isucon
|
6
|
+
module Mysql2
|
7
|
+
# rubocop:disable Layout/LineLength
|
8
|
+
|
9
|
+
# Checks that N+1 query is not used
|
10
|
+
#
|
11
|
+
# @note If `Database` isn't configured, auto-correct will not be available. (Only offense detection can be used)
|
12
|
+
#
|
13
|
+
# @note For the number of N+1 queries that can be detected by this cop, there are too few that can be corrected automatically
|
14
|
+
#
|
15
|
+
# @example
|
16
|
+
# # bad
|
17
|
+
# reservations = db.xquery('SELECT * FROM `reservations` WHERE `schedule_id` = ?', schedule_id).map do |reservation|
|
18
|
+
# reservation[:user] = db.xquery('SELECT * FROM `users` WHERE `id` = ? LIMIT 1', id).first
|
19
|
+
# reservation
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# # good
|
23
|
+
# rows = db.xquery(<<~SQL, schedule_id)
|
24
|
+
# SELECT
|
25
|
+
# r.id AS reservation_id,
|
26
|
+
# r.schedule_id AS reservation_schedule_id,
|
27
|
+
# r.user_id AS reservation_user_id,
|
28
|
+
# r.created_at AS reservation_created_at,
|
29
|
+
# u.id AS user_id,
|
30
|
+
# u.email AS user_email,
|
31
|
+
# u.nickname AS user_nickname,
|
32
|
+
# u.staff AS user_staff,
|
33
|
+
# u.created_at AS user_created_at
|
34
|
+
# FROM `reservations` AS r
|
35
|
+
# INNER JOIN users u ON u.id = r.user_id
|
36
|
+
# WHERE r.schedule_id = ?
|
37
|
+
# SQL
|
38
|
+
#
|
39
|
+
# # bad
|
40
|
+
# courses.map do |course|
|
41
|
+
# teacher = db.xquery('SELECT * FROM `users` WHERE `id` = ?', course[:teacher_id]).first
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# # good
|
45
|
+
# # This is similar to ActiveRecord's preload
|
46
|
+
# # c.f. https://guides.rubyonrails.org/active_record_querying.html#preload
|
47
|
+
# courses.map do |course|
|
48
|
+
# @users_by_id ||= db.xquery('SELECT * FROM `users` WHERE `id` IN (?)', courses.map { |course| course[:teacher_id] }).each_with_object({}) { |v, hash| hash[v[:id]] = v }
|
49
|
+
# teacher = @users_by_id[course[:teacher_id]]
|
50
|
+
# end
|
51
|
+
class NPlusOneQuery < Base
|
52
|
+
# rubocop:enable Layout/LineLength
|
53
|
+
|
54
|
+
include Mixin::DatabaseMethods
|
55
|
+
include Mixin::Mysql2XqueryMethods
|
56
|
+
|
57
|
+
extend AutoCorrector
|
58
|
+
|
59
|
+
MSG = "This looks like N+1 query."
|
60
|
+
|
61
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L38
|
62
|
+
POST_CONDITION_LOOP_TYPES = %i[while_post until_post].freeze
|
63
|
+
|
64
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L39
|
65
|
+
LOOP_TYPES = (POST_CONDITION_LOOP_TYPES + %i[while until for]).freeze
|
66
|
+
|
67
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L41
|
68
|
+
ENUMERABLE_METHOD_NAMES = (Enumerable.instance_methods + [:each]).to_set.freeze
|
69
|
+
|
70
|
+
def_node_matcher :csv_loop?, <<~PATTERN
|
71
|
+
(block
|
72
|
+
(send (const nil? :CSV) :parse ...)
|
73
|
+
...)
|
74
|
+
PATTERN
|
75
|
+
|
76
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L68
|
77
|
+
def_node_matcher :kernel_loop?, <<~PATTERN
|
78
|
+
(block
|
79
|
+
(send {nil? (const nil? :Kernel)} :loop)
|
80
|
+
...)
|
81
|
+
PATTERN
|
82
|
+
|
83
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L74
|
84
|
+
def_node_matcher :enumerable_loop?, <<~PATTERN
|
85
|
+
(block
|
86
|
+
(send $_ #enumerable_method? ...)
|
87
|
+
...)
|
88
|
+
PATTERN
|
89
|
+
|
90
|
+
# @param node [RuboCop::AST::Node]
|
91
|
+
def on_send(node) # rubocop:disable Metrics/MethodLength
|
92
|
+
with_error_handling(node) do
|
93
|
+
with_xquery(node) do |type, root_gda|
|
94
|
+
receiver, = *node.children
|
95
|
+
|
96
|
+
next unless receiver.send_type?
|
97
|
+
|
98
|
+
parent = parent_loop_node(receiver)
|
99
|
+
next unless parent
|
100
|
+
|
101
|
+
next if or_assignment_to_instance_variable?(node)
|
102
|
+
|
103
|
+
add_offense(receiver) do |corrector|
|
104
|
+
perform_autocorrect(
|
105
|
+
corrector: corrector, current_node: receiver,
|
106
|
+
parent_node: parent, type: type, gda: root_gda
|
107
|
+
)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
# Whether match to `@instance_var ||=`
|
116
|
+
# @param node [RuboCop::AST::Node]
|
117
|
+
# @return [Boolean]
|
118
|
+
def or_assignment_to_instance_variable?(node)
|
119
|
+
_or_assignment_to_instance_variable?(node.parent&.parent) ||
|
120
|
+
_or_assignment_to_instance_variable?(node.parent&.parent&.parent)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Whether match to `@instance_var ||=`
|
124
|
+
# @param node [RuboCop::AST::Node]
|
125
|
+
# @return [Boolean]
|
126
|
+
def _or_assignment_to_instance_variable?(node)
|
127
|
+
node&.or_asgn_type? && node.child_nodes&.first&.ivasgn_type?
|
128
|
+
end
|
129
|
+
|
130
|
+
# @param node [RuboCop::AST::Node]
|
131
|
+
# @return [RuboCop::AST::Node]
|
132
|
+
def parent_loop_node(node)
|
133
|
+
node.each_ancestor.find { |ancestor| loop?(ancestor, node) }
|
134
|
+
end
|
135
|
+
|
136
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L106
|
137
|
+
def loop?(ancestor, node)
|
138
|
+
keyword_loop?(ancestor.type) ||
|
139
|
+
kernel_loop?(ancestor) ||
|
140
|
+
node_within_enumerable_loop?(node, ancestor) ||
|
141
|
+
csv_loop?(ancestor)
|
142
|
+
end
|
143
|
+
|
144
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L112
|
145
|
+
def keyword_loop?(type)
|
146
|
+
LOOP_TYPES.include?(type)
|
147
|
+
end
|
148
|
+
|
149
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L116
|
150
|
+
def node_within_enumerable_loop?(node, ancestor)
|
151
|
+
enumerable_loop?(ancestor) do |receiver|
|
152
|
+
receiver != node && !receiver&.descendants&.include?(node)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# @see https://github.com/rubocop/rubocop-performance/blob/v1.11.5/lib/rubocop/cop/performance/collection_literal_in_loop.rb#L130
|
157
|
+
def enumerable_method?(method_name)
|
158
|
+
ENUMERABLE_METHOD_NAMES.include?(method_name)
|
159
|
+
end
|
160
|
+
|
161
|
+
# @param corrector [RuboCop::Cop::Corrector]
|
162
|
+
# @param current_node [RuboCop::AST::Node]
|
163
|
+
# @param parent_node [RuboCop::AST::Node]
|
164
|
+
# @param type [Symbol] Node type. one of `:str`, `:dstr`
|
165
|
+
# @param gda [RuboCop::Isucon::GDA::Client]
|
166
|
+
def perform_autocorrect(corrector:, current_node:, parent_node:, type:, gda:)
|
167
|
+
return unless enabled_database?
|
168
|
+
|
169
|
+
corrector = Correctors::Mysql2NPlusOneQueryCorrector.new(
|
170
|
+
corrector: corrector, current_node: current_node,
|
171
|
+
parent_node: parent_node, type: type, gda: gda, connection: connection
|
172
|
+
)
|
173
|
+
corrector.correct
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|