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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/gh-pages.yml +44 -0
  3. data/.github/workflows/test.yml +91 -0
  4. data/.gitignore +13 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +43 -0
  7. data/.yardopts +7 -0
  8. data/CHANGELOG.md +6 -0
  9. data/Gemfile +8 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +108 -0
  12. data/Rakefile +35 -0
  13. data/benchmark/README.md +69 -0
  14. data/benchmark/memorize.rb +86 -0
  15. data/benchmark/parse_table.rb +103 -0
  16. data/benchmark/shell.rb +26 -0
  17. data/bin/console +15 -0
  18. data/bin/setup +8 -0
  19. data/config/default.yml +83 -0
  20. data/config/enable-only-performance.yml +30 -0
  21. data/gemfiles/activerecord_6_1.gemfile +14 -0
  22. data/gemfiles/activerecord_7_0.gemfile +14 -0
  23. data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector/correctable_methods.rb +66 -0
  24. data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector/replace_methods.rb +127 -0
  25. data/lib/rubocop/cop/isucon/correctors/mysql2_n_plus_one_query_corrector.rb +112 -0
  26. data/lib/rubocop/cop/isucon/mixin/database_methods.rb +59 -0
  27. data/lib/rubocop/cop/isucon/mixin/mysql2_xquery_methods.rb +176 -0
  28. data/lib/rubocop/cop/isucon/mixin/sinatra_methods.rb +37 -0
  29. data/lib/rubocop/cop/isucon/mysql2/join_without_index.rb +100 -0
  30. data/lib/rubocop/cop/isucon/mysql2/many_join_table.rb +86 -0
  31. data/lib/rubocop/cop/isucon/mysql2/n_plus_one_query.rb +179 -0
  32. data/lib/rubocop/cop/isucon/mysql2/prepare_execute.rb +136 -0
  33. data/lib/rubocop/cop/isucon/mysql2/select_asterisk.rb +171 -0
  34. data/lib/rubocop/cop/isucon/mysql2/where_without_index.rb +105 -0
  35. data/lib/rubocop/cop/isucon/shell/backtick.rb +36 -0
  36. data/lib/rubocop/cop/isucon/shell/system.rb +36 -0
  37. data/lib/rubocop/cop/isucon/sinatra/disable_logging.rb +83 -0
  38. data/lib/rubocop/cop/isucon/sinatra/logger.rb +52 -0
  39. data/lib/rubocop/cop/isucon/sinatra/rack_logger.rb +58 -0
  40. data/lib/rubocop/cop/isucon/sinatra/serve_static_file.rb +73 -0
  41. data/lib/rubocop/cop/isucon_cops.rb +20 -0
  42. data/lib/rubocop/isucon/database_connection.rb +42 -0
  43. data/lib/rubocop/isucon/gda/client.rb +184 -0
  44. data/lib/rubocop/isucon/gda/gda_ext.rb +119 -0
  45. data/lib/rubocop/isucon/gda/join_condition.rb +25 -0
  46. data/lib/rubocop/isucon/gda/join_operand.rb +46 -0
  47. data/lib/rubocop/isucon/gda/node_location.rb +42 -0
  48. data/lib/rubocop/isucon/gda/node_patcher.rb +101 -0
  49. data/lib/rubocop/isucon/gda/where_condition.rb +73 -0
  50. data/lib/rubocop/isucon/gda/where_operand.rb +32 -0
  51. data/lib/rubocop/isucon/gda.rb +28 -0
  52. data/lib/rubocop/isucon/inject.rb +20 -0
  53. data/lib/rubocop/isucon/memorize_methods.rb +38 -0
  54. data/lib/rubocop/isucon/version.rb +7 -0
  55. data/lib/rubocop/isucon.rb +20 -0
  56. data/lib/rubocop-isucon.rb +16 -0
  57. data/rubocop-isucon.gemspec +52 -0
  58. metadata +286 -0
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "rubocop/isucon"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,83 @@
1
+ Isucon/Mysql2:
2
+ StyleGuideBaseURL: https://sue445.github.io/rubocop-isucon/RuboCop/Cop/Isucon/Mysql2/
3
+ Database:
4
+
5
+ Isucon/Mysql2/JoinWithoutIndex:
6
+ Description: 'Check for `JOIN` without index'
7
+ Enabled: true
8
+ VersionAdded: '0.1.0'
9
+ StyleGuide: JoinWithoutIndex.html
10
+
11
+ Isucon/Mysql2/ManyJoinTable:
12
+ Description: 'Check if SQL contains many JOINs'
13
+ Enabled: true
14
+ VersionAdded: '0.1.0'
15
+ StyleGuide: ManyJoinTable.html
16
+ CountTables: 3
17
+
18
+ Isucon/Mysql2/NPlusOneQuery:
19
+ Description: 'Checks that N+1 query is not used'
20
+ Enabled: true
21
+ VersionAdded: '0.1.0'
22
+ SafeAutoCorrect: false
23
+ StyleGuide: NPlusOneQuery.html
24
+
25
+ Isucon/Mysql2/PrepareExecute:
26
+ Description: 'Use `db.xquery` instead of `db.prepare`'
27
+ Enabled: true
28
+ VersionAdded: '0.1.0'
29
+ StyleGuide: PrepareExecute.html
30
+
31
+ Isucon/Mysql2/SelectAsterisk:
32
+ Description: 'Avoid `SELECT *` in `db.xquery`'
33
+ Enabled: true
34
+ VersionAdded: '0.1.0'
35
+ StyleGuide: SelectAsterisk.html
36
+
37
+ Isucon/Mysql2/WhereWithoutIndex:
38
+ Description: 'Check for `WHERE` without index'
39
+ Enabled: true
40
+ VersionAdded: '0.1.0'
41
+ StyleGuide: WhereWithoutIndex.html
42
+
43
+ Isucon/Shell:
44
+ StyleGuideBaseURL: https://sue445.github.io/rubocop-isucon/RuboCop/Cop/Isucon/Shell/
45
+
46
+ Isucon/Shell/Backtick:
47
+ Description: 'Avoid external command calls with backtick'
48
+ Enabled: true
49
+ VersionAdded: '0.1.0'
50
+ StyleGuide: Backtick.html
51
+
52
+ Isucon/Shell/System:
53
+ Description: 'Avoid external command calls with `Kernel#system`'
54
+ Enabled: true
55
+ VersionAdded: '0.1.0'
56
+ StyleGuide: System.html
57
+
58
+ Isucon/Sinatra:
59
+ StyleGuideBaseURL: https://sue445.github.io/rubocop-isucon/RuboCop/Cop/Isucon/Sinatra/
60
+
61
+ Isucon/Sinatra/DisableLogging:
62
+ Description: 'Disable sinatra logging'
63
+ Enabled: true
64
+ VersionAdded: '0.1.0'
65
+ StyleGuide: DisableLogging.html
66
+
67
+ Isucon/Sinatra/Logger:
68
+ Description: "Disable sinatra logger logging"
69
+ Enabled: true
70
+ VersionAdded: '0.1.0'
71
+ StyleGuide: Logger.html
72
+
73
+ Isucon/Sinatra/RackLogger:
74
+ Description: "Disable `request.env['rack.logger']` logging"
75
+ Enabled: true
76
+ VersionAdded: '0.1.0'
77
+ StyleGuide: RackLogger.html
78
+
79
+ Isucon/Sinatra/ServeStaticFile:
80
+ Description: 'Serve static files on front server (e.g. nginx) instead of sinatra app'
81
+ Enabled: true
82
+ VersionAdded: '0.1.0'
83
+ StyleGuide: ServeStaticFile.html
@@ -0,0 +1,30 @@
1
+ require:
2
+ - rubocop-performance
3
+
4
+ # Disable default cops (except Performance cops)
5
+ Bundler:
6
+ Enabled: false
7
+
8
+ Gemspec:
9
+ Enabled: false
10
+
11
+ Layout:
12
+ Enabled: false
13
+
14
+ Lint:
15
+ Enabled: false
16
+
17
+ Metrics:
18
+ Enabled: false
19
+
20
+ Naming:
21
+ Enabled: false
22
+
23
+ Performance:
24
+ Enabled: true
25
+
26
+ Security:
27
+ Enabled: false
28
+
29
+ Style:
30
+ Enabled: false
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.1.0"
6
+
7
+ group :sqlite3 do
8
+ # c.f. https://github.com/rails/rails/blob/v6.1.0/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L13
9
+ gem "sqlite3", "~> 1.4"
10
+ end
11
+
12
+ # eval_gemfile "#{__dir__}/common.gemfile"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 7.0.1"
6
+
7
+ group :sqlite3 do
8
+ # c.f. https://github.com/rails/rails/blob/v7.0.1/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L13
9
+ gem "sqlite3", "~> 1.4"
10
+ end
11
+
12
+ # eval_gemfile "#{__dir__}/common.gemfile"
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Isucon
6
+ module Correctors
7
+ class Mysql2NPlusOneQueryCorrector
8
+ # Check whether can correct
9
+ module CorrectableMethods
10
+ # @return [Boolean]
11
+ def correctable?
12
+ correctable_gda? && correctable_xquery_arg? &&
13
+ correctable_parent_receiver? && current_node.child_nodes.count == 3 &&
14
+ xquery_lvar.lvasgn_type? && %i[first last].include?(xquery_chained_method)
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def correctable_gda?
19
+ gda&.select_query? && gda.table_names.count == 1 && !gda.limit_clause? &&
20
+ !gda.group_by_clause? && !gda.contains_aggregate_functions? && where_clause_with_only_single_unique_key?
21
+ end
22
+
23
+ # @return [Boolean]
24
+ def where_clause_with_only_single_unique_key?
25
+ where_clause_with_only_primary_key? || where_clause_with_only_single_unique_index_column?
26
+ end
27
+
28
+ # @return [Boolean]
29
+ def where_clause_with_only_primary_key?
30
+ return false unless gda.where_nodes.count == 1
31
+
32
+ primary_keys = connection.primary_keys(gda.table_names[0])
33
+ return false unless primary_keys.count == 1
34
+
35
+ primary_keys.first == where_column_without_quote
36
+ end
37
+
38
+ # @return [Boolean]
39
+ def where_clause_with_only_single_unique_index_column?
40
+ return false unless gda.where_nodes.count == 1
41
+
42
+ unique_index_columns = connection.unique_index_columns(gda.table_names[0])
43
+ unique_index_columns.any? { |columns| columns.count == 1 && columns.first == where_column_without_quote }
44
+ end
45
+
46
+ # @return [Boolean]
47
+ def correctable_xquery_arg? # rubocop:disable Metrics/AbcSize
48
+ return false if !xquery_arg&.send_type? || xquery_arg.node_parts.count != 3 || !xquery_arg.node_parts[0].lvar_type?
49
+
50
+ # Check one of hash[:key], hash["key"], hash.fetch(:key), hash.fetch("key")
51
+ return false unless %i[[] fetch].include?(xquery_arg.node_parts[1])
52
+ return false unless %i[sym str].include?(xquery_arg.node_parts[2].type)
53
+
54
+ true
55
+ end
56
+
57
+ # @return [Boolean]
58
+ def correctable_parent_receiver?
59
+ parent_receiver.lvar_type? || parent_receiver.send_type?
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Isucon
6
+ module Correctors
7
+ class Mysql2NPlusOneQueryCorrector
8
+ # replace ast
9
+ module ReplaceMethods
10
+ def replace
11
+ replace_where_condition_in_sql
12
+ replace_xquery_2nd_arg
13
+ replace_chained_method_to_each_with_object
14
+ replace_to_2_lines
15
+ end
16
+
17
+ # Replace where condition in SQL (e.g. `id = ?` -> `id IN (?)`)
18
+ def replace_where_condition_in_sql
19
+ loc = offense_location(type: type, node: current_node, gda_location: where_condition_gda_loc)
20
+ return unless loc
21
+
22
+ corrector.replace(loc, "#{where_column} IN (?)")
23
+ end
24
+
25
+ # Replace 2nd arg in db.xquery (e.g. `course[:teacher_id]` -> `courses.map { |course| course[:teacher_id] }`)
26
+ def replace_xquery_2nd_arg
27
+ object_source = xquery_arg.node_parts[0].source
28
+ symbol_source = xquery_arg.node_parts[2].source
29
+
30
+ corrector.replace(
31
+ xquery_arg.loc.expression,
32
+ # e.g.
33
+ # courses.map { |course| course[:teacher_id] }
34
+ "#{parent_receiver.source}.map { |#{object_source}| #{object_source}[#{symbol_source}] }",
35
+ )
36
+ end
37
+
38
+ # Replace `.first` -> `.each_with_object({}) { |v, hash| hash[v[:id]] = v }`
39
+ def replace_chained_method_to_each_with_object
40
+ xquery_chained_method_begin_pos = current_node.loc.end.end_pos + 1
41
+ xquery_chained_method_range =
42
+ Parser::Source::Range.new(current_node.loc.expression.source_buffer,
43
+ xquery_chained_method_begin_pos,
44
+ xquery_chained_method_begin_pos + xquery_chained_method.length)
45
+
46
+ corrector.replace(xquery_chained_method_range, generate_each_with_object)
47
+ end
48
+
49
+ # @return [String]
50
+ #
51
+ # @example response example
52
+ # each_with_object({}) { |v, hash| hash[v[:id]] = v }
53
+ def generate_each_with_object
54
+ hash_key =
55
+ case xquery_arg.node_parts[2].type
56
+ when :sym
57
+ ":#{where_column_without_quote}"
58
+ when :str
59
+ %("#{where_column_without_quote}")
60
+ end
61
+
62
+ "each_with_object({}) { |v, hash| hash[v[#{hash_key}]] = v }"
63
+ end
64
+
65
+ # rubocop:disable Layout/LineLength
66
+
67
+ # Split line
68
+ #
69
+ # @example Before
70
+ # teacher = db.xquery("SELECT * FROM `users` WHERE `id` IN (?)", courses.map { |course| course[:teacher_id] }).each_with_object({}) { |v, hash| hash[v[:id]] = v }
71
+ #
72
+ # @example After
73
+ # @users_by_id ||= db.xquery("SELECT * FROM `users` WHERE `id` IN (?)", ...).each_with_object({}) { |v, hash| hash[v[:id]] = v }
74
+ # teacher = @users_by_id[course[:teacher_id]]
75
+ def replace_to_2_lines
76
+ # rubocop:enable Layout/LineLength
77
+
78
+ replace_to_2_lines_for_1st_line
79
+ replace_to_2_lines_for_2nd_line
80
+ end
81
+
82
+ def replace_to_2_lines_for_1st_line
83
+ range =
84
+ Parser::Source::Range.new(current_node.loc.expression.source_buffer,
85
+ xquery_lvar.loc.expression.begin_pos, current_node.loc.expression.begin_pos)
86
+
87
+ corrector.replace(range, "#{instance_var_name} ||= ")
88
+ end
89
+
90
+ # @return [String]
91
+ def instance_var_name
92
+ "@#{gda.table_names[0]}_by_#{where_column_without_quote}"
93
+ end
94
+
95
+ def replace_to_2_lines_for_2nd_line
96
+ indent_level = indent_level(current_node)
97
+
98
+ pos = xquery_lvar.loc.expression.end_pos + 1
99
+ range = Parser::Source::Range.new(current_node.loc.expression.source_buffer, pos, pos)
100
+
101
+ corrector.replace(range, "#{' ' * indent_level}#{generate_second_line}")
102
+ end
103
+
104
+ # @param node [RuboCop::AST::Node]
105
+ # @return [Integer]
106
+ def indent_level(node)
107
+ node.loc.expression.source_line =~ /^(\s+)/
108
+ return 0 unless Regexp.last_match(1)
109
+
110
+ Regexp.last_match(1).length
111
+ end
112
+
113
+ # @return [String]
114
+ #
115
+ # @example response example
116
+ # teacher = @users_by_id[course[:teacher_id]]
117
+ def generate_second_line
118
+ object_source = xquery_arg.node_parts[0].source
119
+ symbol_source = xquery_arg.node_parts[2].source
120
+ "#{xquery_lvar.node_parts[0]} = #{instance_var_name}[#{object_source}[#{symbol_source}]]\n"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mysql2_n_plus_one_query_corrector/correctable_methods"
4
+ require_relative "mysql2_n_plus_one_query_corrector/replace_methods"
5
+
6
+ module RuboCop
7
+ module Cop
8
+ module Isucon
9
+ module Correctors
10
+ # rubocop:disable Layout/LineLength
11
+
12
+ # Corrector for {RuboCop::Cop::Isucon::Mysql2::NPlusOneQuery}
13
+ #
14
+ # @example Before
15
+ # courses.map do |course|
16
+ # teacher = db.xquery('SELECT * FROM `users` WHERE `id` = ?', course[:teacher_id]).first
17
+ # end
18
+ #
19
+ # @example After
20
+ # courses.map do |course|
21
+ # @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 }
22
+ # teacher = @users_by_id[course[:teacher_id]]
23
+ # end
24
+ class Mysql2NPlusOneQueryCorrector
25
+ # rubocop:enable Layout/LineLength
26
+
27
+ include Mixin::Mysql2XqueryMethods
28
+ include CorrectableMethods
29
+ include ReplaceMethods
30
+
31
+ # @return [RuboCop::Cop::Corrector]
32
+ attr_reader :corrector
33
+
34
+ # @return [RuboCop::AST::Node]
35
+ attr_reader :current_node
36
+
37
+ # @return [RuboCop::AST::Node]
38
+ attr_reader :parent_node
39
+
40
+ # @return [Symbol]
41
+ attr_reader :type
42
+
43
+ # @return [RuboCop::Isucon::GDA::Client]
44
+ attr_reader :gda
45
+
46
+ # @return [RuboCop::Isucon::DatabaseConnection]
47
+ attr_reader :connection
48
+
49
+ # @param corrector [RuboCop::Cop::Corrector]
50
+ # @param current_node [RuboCop::AST::Node]
51
+ # @param parent_node [RuboCop::AST::Node]
52
+ # @param type [Symbol] Node type. one of `:str`, `:dstr`
53
+ # @param gda [RuboCop::Isucon::GDA::Client]
54
+ # @param connection [RuboCop::Isucon::DatabaseConnection]
55
+ def initialize(corrector:, current_node:, parent_node:, type:, gda:, connection:) # rubocop:disable Metrics/ParameterLists
56
+ @corrector = corrector
57
+ @current_node = current_node
58
+ @parent_node = parent_node
59
+ @type = type
60
+ @gda = gda
61
+ @connection = connection
62
+ end
63
+
64
+ def correct
65
+ replace if correctable?
66
+ end
67
+
68
+ private
69
+
70
+ # @return [RuboCop::AST::Node,nil]
71
+ def parent_receiver
72
+ parent_node.child_nodes&.first&.receiver
73
+ end
74
+
75
+ # @return [RuboCop::Isucon::GDA::NodeLocation]
76
+ def where_condition_gda_loc
77
+ gda.where_nodes.first.location
78
+ end
79
+
80
+ # @return [String,nil]
81
+ def where_column
82
+ matched = where_condition_gda_loc&.body&.match(/([^\s]+)\s*=\s*\?/)
83
+
84
+ return nil unless matched
85
+
86
+ matched[1]
87
+ end
88
+
89
+ # @return [RuboCop::AST::Node]
90
+ def xquery_arg
91
+ current_node.child_nodes[2]
92
+ end
93
+
94
+ # @return [RuboCop::AST::Node]
95
+ def xquery_chained_method
96
+ current_node.parent.node_parts[1]
97
+ end
98
+
99
+ # @return [String]
100
+ def where_column_without_quote
101
+ where_column&.delete("`")
102
+ end
103
+
104
+ # @return [RuboCop::AST::Node,nil]
105
+ def xquery_lvar
106
+ current_node.parent&.parent
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Isucon
6
+ module Mixin
7
+ # Database util methods for {RuboCop::Cop::Isucon::Mysql2}
8
+ module DatabaseMethods
9
+ # @return [RuboCop::Isucon::DatabaseConnection]
10
+ # @raise [RuboCop::Isucon::DatabaseConfigurationError] `Database` isn't configured in `.rubocop.yml`
11
+ def connection
12
+ return @connection if @connection
13
+
14
+ unless enabled_database?
15
+ raise RuboCop::Isucon::DatabaseConfigurationError, "`Database` isn't configured in `.rubocop.yml`"
16
+ end
17
+
18
+ @connection = RuboCop::Isucon::DatabaseConnection.new(cop_config["Database"])
19
+ end
20
+
21
+ # @return [Boolean]
22
+ def enabled_database?
23
+ adapter = cop_config.dig("Database", "adapter")
24
+ adapter && !adapter.empty?
25
+ end
26
+
27
+ # @param table_names [Array<String>]
28
+ # @param column_name [String]
29
+ # @return [String,nil]
30
+ def find_table_name_from_column_name(table_names:, column_name:)
31
+ table_names.each do |table_name|
32
+ column_names = connection.column_names(table_name)
33
+ return table_name if column_names.include?(column_name)
34
+ end
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ # @param node [RuboCop::AST::Node]
41
+ def with_error_handling(node)
42
+ yield
43
+ rescue ActiveRecord::StatementInvalid => e
44
+ # NOTE: suppress error (e.g. table isn't found in database)
45
+ print_warning(node: node, error: e)
46
+ end
47
+
48
+ # @param node [RuboCop::AST::Node]
49
+ # @param error [StandardError]
50
+ def print_warning(node:, error:)
51
+ file_path = processed_source.file_path
52
+ line_num = node.loc.expression.line
53
+ warn "Warning: #{error.message} (#{file_path}:#{line_num})"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end