query_police 0.1.0.beta

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryPolice
4
+ class Analysis
5
+ # Module to define methods related to dynamic message
6
+ module DynamicMessage
7
+ private
8
+
9
+ LISTED_VAR = %w[amount column impact table tag value].freeze
10
+
11
+ # to pretty print the analysis with warnings and suggestions
12
+ # @param opts [Hash] opts to get specifc dyanmic message
13
+ # eg. {"table" => "users", "column" => "select_type", "tag" => "SIMPLE", "type" => "message"}
14
+ # @return [String]
15
+ def dynamic_message(opts)
16
+ table, column, tag, type = opts.values_at("table", "column", "tag", "type")
17
+ message = self.tables.dig(table, "analysis", column, "tags", tag, type) || ""
18
+
19
+ variables = message.scan(/\$(\w+)/).uniq.map { |var| var[0] }
20
+ variables.each do |var|
21
+ value = dynamic_value_of(var, opts)
22
+
23
+ message.gsub!(/\$#{var}/, value.to_s) if value.present?
24
+ end
25
+
26
+ message
27
+ end
28
+
29
+ def dynamic_value_of(var, opts)
30
+ LISTED_VAR.include?(var) ? send(var, opts) : relative_value_of(var, opts.dig("table"))
31
+ end
32
+
33
+ def relative_value_of(var, table)
34
+ value_type = var.match(/amount_/).present? ? "amount" : "value"
35
+ self.tables.dig(table, "analysis", var.gsub(/amount_/, ""), value_type)
36
+ end
37
+
38
+ # dynamic variable methods
39
+ def amount(opts)
40
+ table, column = opts.values_at("table", "column")
41
+
42
+ self.tables.dig(table, "analysis", column, "amount")
43
+ end
44
+
45
+ def column(opts)
46
+ opts.dig("column")
47
+ end
48
+
49
+ def impact(opts)
50
+ table, column, tag = opts.values_at("table", "column", "tag")
51
+
52
+ impact = self.tables.dig(table, "analysis", column, "tags", tag, "impact")
53
+
54
+ opts.dig("colours").present? ? impact.send(IMPACTS[impact].colour) : impact
55
+ end
56
+
57
+ def table(opts)
58
+ opts.dig("table")
59
+ end
60
+
61
+ def tag(opts)
62
+ opts.dig("tag")
63
+ end
64
+
65
+ def value(opts)
66
+ table, column = opts.values_at("table", "column")
67
+
68
+ self.tables.dig(table, "analysis", column, "value")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'analysis/dynamic_message'
4
+
5
+ module QueryPolice
6
+ # This class is used to store analysis of a query and provide methods over them
7
+ class Analysis
8
+ include DynamicMessage
9
+
10
+ IMPACTS = {
11
+ "negative" => { "colour" => "red" },
12
+ "positive" => { "colour" => "green" },
13
+ "caution" => { "colour" => "yellow" }
14
+ }.freeze
15
+
16
+ # initialize analysis object
17
+ # tables [Array] Array of table analysis
18
+ # Eg.
19
+ # {
20
+ # "users" => {
21
+ # "id"=>1,
22
+ # "name"=>"users",
23
+ # "analysis"=>{
24
+ # "type"=>{
25
+ # "value" => "all",
26
+ # "tags" => {
27
+ # "all" => {
28
+ # "impact"=>"negative",
29
+ # "warning"=>"warning to represent the issue",
30
+ # "suggestions"=>"some follow up suggestions"
31
+ # }
32
+ # }
33
+ # }
34
+ # }
35
+ # }
36
+ # }
37
+ # summary [Hash] hash of analysis summary
38
+ # Eg.
39
+ # {
40
+ # "cardinality"=>{
41
+ # "amount"=>10,
42
+ # "warning"=>"warning to represent the issue",
43
+ # "suggestions"=>"some follow up suggestions"
44
+ # }
45
+ # }
46
+ def initialize
47
+ @table_count = 0
48
+ @tables = {}
49
+ @summary = {}
50
+ end
51
+
52
+ attr_accessor :tables, :table_count, :summary
53
+
54
+ # register a table analysis in analysis object
55
+ # @param name [String] name of the table
56
+ # @param table_analysis [Hash] analysis of a table
57
+ # Eg.
58
+ # {
59
+ # "id"=>1,
60
+ # "name"=>"users",
61
+ # "analysis"=>{
62
+ # "type"=>[
63
+ # {
64
+ # "tag"=>"all",
65
+ # "impact"=>"negative",
66
+ # "warning"=>"warning to represent the issue",
67
+ # "suggestions"=>"some follow up suggestions"
68
+ # }
69
+ # ]
70
+ # }
71
+ # }
72
+ def register_table(name, table_analysis)
73
+ self.table_count += 1
74
+ self.tables.merge!(
75
+ {
76
+ name => {
77
+ "id" => self.table_count,
78
+ "name" => name,
79
+ "analysis" => table_analysis
80
+ }
81
+ }
82
+ )
83
+ end
84
+
85
+ # register summary based in different attributes
86
+ # @param summary [Hash] hash of summary of analysis
87
+ def register_summary(summary)
88
+ self.summary.merge!(summary)
89
+ end
90
+
91
+ # to get analysis in pretty format with warnings and suggestions
92
+ # @param opts [Hash] - possible options [positive: <boolean>, negative: <boolean>, caution: <boolean>]
93
+ # @return [String] pretty analysis
94
+ def pretty_analysis(opts)
95
+ final_message = ""
96
+
97
+ opts.slice(*IMPACTS.keys).each do |impact, value|
98
+ final_message += pretty_analysis_for(impact) if value.present?
99
+ end
100
+
101
+ final_message
102
+ end
103
+
104
+ # to get analysis in pretty format with warnings and suggestions for a impact
105
+ # @param impact [String]
106
+ # @return [String] pretty analysis
107
+ def pretty_analysis_for(impact)
108
+ final_message = ""
109
+
110
+ self.tables.keys.each do |table|
111
+ table_message = table_pretty_analysis(table, {impact => true})
112
+
113
+ final_message += "table: #{table}\n#{table_message}\n" if table_message.present?
114
+ end
115
+
116
+ final_message
117
+ end
118
+
119
+ # to get analysis in pretty format with warnings and suggestions for a table
120
+ # @param table [String] - table name
121
+ # @param opts [Hash] - possible options [positive: <boolean>, negative: <boolean>, caution: <boolean>]
122
+ # @return [String] pretty analysis
123
+ def table_pretty_analysis(table, opts)
124
+ table_message = ""
125
+
126
+ self.tables.dig(table, "analysis").each do |column, column_analysis|
127
+ tags_message = ""
128
+ column_analysis.dig("tags").each do |tag, tag_analysis|
129
+ next unless opts.dig(tag_analysis.dig("impact")).present?
130
+
131
+ tags_message += tag_pretty_analysis(table, column, tag)
132
+ end
133
+
134
+ table_message += "column: #{column}\n#{tags_message}" if tags_message.present?
135
+ end
136
+
137
+ table_message
138
+ end
139
+
140
+ private
141
+
142
+ def tag_pretty_analysis(table, column, tag)
143
+ tag_message = ""
144
+
145
+ opts = { "table" => table, "column" => column, "tag" => tag }
146
+ message = dynamic_message(opts.merge({ "type" => "message" }))
147
+ suggestion = dynamic_message(opts.merge({ "type" => "suggestion" }))
148
+ tag_message += "impact: #{impact(opts.merge({ "colours" => true }))}\n"
149
+ tag_message += "message: #{message}\n"
150
+ tag_message += "suggestion: #{suggestion}\n" if suggestion.present?
151
+
152
+ tag_message
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryPolice
4
+ # This class is used for configuration of query police
5
+ class Config
6
+ def initialize(detailed, rules_path)
7
+ @detailed = detailed
8
+ @rules_path = rules_path
9
+ end
10
+
11
+ def detailed?
12
+ @detailed.present?
13
+ end
14
+
15
+ attr_accessor :detailed, :rules_path
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryPolice
4
+ module Constants
5
+ DEFAULT_COLUMN_RULES = {
6
+ "value_type" => "string"
7
+ }.freeze
8
+ DEFAULT_DETAILED = true
9
+ DEFAULT_LOGGER_CONFIG = {
10
+ "negative" => true
11
+ }.freeze
12
+ DEFAULT_RULES_PATH = File.join(File.dirname(__FILE__), "rules.json")
13
+ end
14
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helper"
4
+
5
+ module QueryPolice
6
+ # This module provides tools to explain queries and ActiveRecord::Relation
7
+ module Explain
8
+ # to get explain result in parsable format
9
+ # @param relation [ActiveRecord::Relation, String] active record relation or raw sql query
10
+ # @return [Array] parsed_result - array of hashes representing EXPLAIN result for each row
11
+ def full_explain(relation, detailed = true)
12
+ explain_result = explain(relation)
13
+ return explain_result unless detailed
14
+
15
+ detailed_explain_result = detailed_explain(relation)
16
+
17
+ [*explain_result.keys, *detailed_explain_result.keys].uniq.map do |key|
18
+ (
19
+ explain_result.dig(key)&.merge(
20
+ detailed_explain_result.dig(key) || {}
21
+ ) || detailed_explain_result.dig(key)
22
+ )
23
+ end
24
+ end
25
+
26
+ # to get explain result in parsable format using "EXPLAIN <query>"
27
+ # @param relation [ActiveRecord::Relation, String] active record relation or raw sql query
28
+ # @return [Array] parsed_result - array of hashes representing EXPLAIN result for each row
29
+ def explain(relation)
30
+ query = load_query(relation)
31
+ explain_result = ActiveRecord::Base.connection.execute("EXPLAIN #{query}")
32
+ parsed_result = {}
33
+
34
+ explain_result.each(as: :json) do |ele|
35
+ parsed_result[ele.dig("table")] = ele
36
+ end
37
+
38
+ parsed_result
39
+ end
40
+
41
+ # to get detailed explain result in parsable format using "EXPLAIN format=JSON <query>"
42
+ # @param relation [ActiveRecord::Relation, String] active record relation or raw sql query
43
+ # @param prefix [String] prefix to append before each key "prefix#<key>"
44
+ # @return [Array] parsed_result - array of flatten hashes representing EXPLAIN result for each row
45
+ def detailed_explain(relation, prefix = "detailed")
46
+ query = load_query(relation)
47
+ explain_result = ActiveRecord::Base.connection.execute("EXPLAIN format=json #{query}")
48
+ explain_result = parse_detailed_explain(explain_result)
49
+
50
+ explain_result.map { |ele| [ele.dig("table_name"), Helper.flatten_hash(ele, prefix)] }.to_h
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ def load_query(relation)
57
+ relation.class.name == "ActiveRecord::Relation" ? relation.to_sql : relation
58
+ end
59
+
60
+ def parse_detailed_explain(explain_result)
61
+ parsed_result = JSON.parse(explain_result&.first&.first || "{}").dig("query_block")
62
+ return parsed_result.dig("nested_loop").map { |e| e.dig("table") } if parsed_result.key?("nested_loop")
63
+
64
+ parsed_result.key?("table") ? [parsed_result.dig("table")] : []
65
+ end
66
+ end
67
+
68
+ module_function :full_explain, :explain, :detailed_explain
69
+ end
70
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryPolice
4
+ # This module define helper methods for query police
5
+ module Helper
6
+ def flatten_hash(hash, prefix_key = "")
7
+ flat_hash = {}
8
+
9
+ hash.each do |key, value|
10
+ key = prefix_key.present? ? "#{prefix_key}##{key}" : key.to_s
11
+
12
+ flat_hash.merge!(value.is_a?(Hash) ? flatten_hash(value, key) : { key => value })
13
+ end
14
+
15
+ flat_hash
16
+ end
17
+
18
+ def logger(message, type="info")
19
+ if defined?(Rails) && Rails.logger
20
+ Rails.logger.send(type, message)
21
+ else
22
+ puts "#{type.upcase}: #{message}"
23
+ end
24
+ end
25
+
26
+ def load_config(rules_path)
27
+ unless File.exists?(rules_path)
28
+ raise Error.new(
29
+ "Failed to load the rule file from '#{rules_path}'. " \
30
+ "The file may be missing or there is a problem with the path. " \
31
+ "Please ensure that the file exists and the path is correct."
32
+ )
33
+ end
34
+
35
+ rules_config = JSON.parse(File.read(rules_path))
36
+
37
+ rules_config
38
+ end
39
+
40
+ module_function :flatten_hash, :logger, :load_config
41
+ end
42
+
43
+ private_constant :Helper
44
+ end
@@ -0,0 +1,171 @@
1
+ {
2
+ "select_type": {
3
+ "description": "Type of select used in the table.",
4
+ "value_type": "string",
5
+ "rules": {
6
+ "SIMPLE": {
7
+ "impact": "positive",
8
+ "message": "A simple query without subqueries or unions.",
9
+ "suggestion": ""
10
+ }
11
+ }
12
+ },
13
+ "type": {
14
+ "description": "Join used in the query for a specific table.",
15
+ "value_type": "string",
16
+ "rules": {
17
+ "system": {
18
+ "impact": "positive",
19
+ "message": "Table has zero or one row, no change required.",
20
+ "suggestion": ""
21
+ },
22
+ "const": {
23
+ "impact": "positive",
24
+ "message": "Table has only one indexed matching row, fastest join type.",
25
+ "suggestion": ""
26
+ },
27
+ "eq_ref": {
28
+ "impact": "positive",
29
+ "message": "All index parts used in join and index is primary_key or unique not null.",
30
+ "suggestion": ""
31
+ },
32
+ "ref": {
33
+ "impact": "caution",
34
+ "message": "All matching rows of an indexed column read for each combination of rows from previous table.",
35
+ "suggestion": "Ensure the referenced column is indexed and look for null values, dupilcates. Upgrade to eq_ref join type if possible.\nYou can acheive eq_ref by adding unique and not null to the index - $key used in $table table table."
36
+ },
37
+ "fulltext": {
38
+ "impact": "caution",
39
+ "message": "Join uses table FULLTEXT index, index key used - $key.",
40
+ "suggestion": "Should only be used for text heavy columns."
41
+ },
42
+ "ref_or_null": {
43
+ "impact": "caution",
44
+ "message": "Using ref index with Null value in $table table.",
45
+ "suggestion": "Please check if you can upgrade to eq_ref, you can acheive eq_ref by adding unique and not null to the index - $key used in $table table."
46
+ },
47
+ "index_merge": {
48
+ "impact": "caution",
49
+ "message": "Join uses list of indexes, keys used: $key.",
50
+ "suggestion": "Slow if the indexes are not well-chosen or if there are too many indexes being used."
51
+ },
52
+ "range": {
53
+ "impact": "caution",
54
+ "message": "Index used to find matching rows in specific range.",
55
+ "suggestion": "Please check the range it should not be too broad."
56
+ },
57
+ "index": {
58
+ "impact": "caution",
59
+ "message": "Entire index tree scanned to find matching rows.",
60
+ "suggestion": "Can be slow for large indexes(Your key length: $key_len), use carefully."
61
+ },
62
+ "ALL": {
63
+ "impact": "negative",
64
+ "message": "Entire $table table is scanned to find matching rows, you have $amount_possible_keys possible keys to use.",
65
+ "suggestion": "Use index here. You can use index from possible key: $possible_keys or add new one to $table table as per the requirements."
66
+ }
67
+ }
68
+ },
69
+ "rows": {
70
+ "description": "Estimated number of rows scanned to find matching rows.",
71
+ "value_type": "number",
72
+ "rules": {
73
+ "threshold": {
74
+ "amount": 100,
75
+ "impact": "negative",
76
+ "message": "$value rows are being scanned per join for $table table.",
77
+ "suggestion": "Please see if it is possible to use index from $possible_keys or add new one to $table table as per the requirements to reduce the number of rows scanned."
78
+ }
79
+ }
80
+ },
81
+ "possible_keys": {
82
+ "description": "Index keys possible for a specifc table",
83
+ "value_type": "array",
84
+ "delimiter": ",",
85
+ "rules": {
86
+ "absent": {
87
+ "impact": "negative",
88
+ "message": "There are no possible keys for $table table to be used, can result into full scan",
89
+ "suggestion": "Please add index keys for $table table"
90
+ },
91
+ "threshold": {
92
+ "amount": 5,
93
+ "impact": "negative",
94
+ "message": "There are $amount possible keys for $table table, having too many index keys can be unoptimal",
95
+ "suggestion": "Please check if there are extra indexes in $table table."
96
+ }
97
+ }
98
+ },
99
+ "key": {
100
+ "description": "",
101
+ "value_type": "string",
102
+ "rules": {
103
+ "absent": {
104
+ "impact": "negative",
105
+ "message": "There is no index key used for $table table, and can result into full scan of the $table table",
106
+ "suggestion": "Please use index from possible_keys: $possible_keys or add new one to $table table as per the requirements."
107
+ }
108
+ }
109
+ },
110
+ "key_len": {
111
+ "description": "Length of the key index used",
112
+ "value_type": "number",
113
+ "rules": {}
114
+ },
115
+ "filtered": {
116
+ "description": "Indicates percentage of rows appearing from the total.",
117
+ "value_type": "number",
118
+ "rules": {}
119
+ },
120
+ "extra": {
121
+ "description": "Additional information about the plan",
122
+ "value_type": "array",
123
+ "delimiter": ";",
124
+ "rules": {
125
+ "Using temporary": {
126
+ "impact": "",
127
+ "message": "",
128
+ "suggestion": ""
129
+ },
130
+ "Using filesort": {
131
+ "impact": "negative",
132
+ "message": "A file-based algorithm in being applied over your result, This can be inefficient and result into long query time.",
133
+ "suggestion": "Please ensure either result set is small or use proper index."
134
+ },
135
+ "Using join buffer": {
136
+ "impact": "",
137
+ "message": "",
138
+ "suggestion": ""
139
+ },
140
+ "Using index condition": {
141
+ "impact": "",
142
+ "message": "",
143
+ "suggestion": ""
144
+ }
145
+ }
146
+ },
147
+ "detailed#used_columns": {
148
+ "description": "",
149
+ "value_type": "array",
150
+ "rules": {
151
+ "threshold": {
152
+ "amount": 7,
153
+ "impact": "negative",
154
+ "message": "You have selected $amount columns, You should not select too many columns.",
155
+ "suggestion": "Please only select required columns."
156
+ }
157
+ }
158
+ },
159
+ "cardinality": {
160
+ "description": "",
161
+ "value_type": "number",
162
+ "rules": {
163
+ "threshold": {
164
+ "amount": 100,
165
+ "impact": "negative",
166
+ "message": "The cardinality of table is $amount, and its too high.",
167
+ "suggestion": "Please use proper index, query only requried data and ensure you are using proper joins."
168
+ }
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryPolice
4
+ VERSION = "0.1.0.beta"
5
+ end