benedictus 0.2.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.
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ module Renderers
5
+ class TreeRenderer
6
+ BRANCH_LAST = "└─ "
7
+ BRANCH_MID = "├─ "
8
+ CONTINUE_BAR = "│ "
9
+ CONTINUE_GAP = " "
10
+ WARNING_GLYPH = "⚠"
11
+ SEVERITY_ORDER = { critical: 0, warning: 1, info: 2, default: 3, good: 4 }.freeze
12
+
13
+ def initialize(tree, expression: nil, color: nil, sql: nil, footer: nil, output: $stdout)
14
+ @tree = tree
15
+ @expression = expression
16
+ @sql = sql
17
+ @footer = footer
18
+ @output = output
19
+ @pastel = Benedictus::Color.new(enabled: color_enabled?(color))
20
+ end
21
+
22
+ def render
23
+ [
24
+ render_header,
25
+ render_sql,
26
+ render_tree,
27
+ render_footer
28
+ ].compact.reject(&:empty?).join("\n\n")
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :tree, :expression, :pastel
34
+
35
+ def color_enabled?(explicit)
36
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
37
+ return explicit unless explicit.nil?
38
+
39
+ @output.respond_to?(:tty?) && @output.tty?
40
+ end
41
+
42
+ def render_header
43
+ lines = []
44
+ lines << "#{pastel.bold("Query:")} #{expression}" if expression
45
+ lines << header_summary_line
46
+ lines.join("\n")
47
+ end
48
+
49
+ def header_summary_line
50
+ parts = []
51
+ parts << "Total cost: #{format_cost(tree.total_cost)}"
52
+ parts << "Execution time: #{format_ms(tree.execution_time)}" if tree.execution_time
53
+ parts << "Planning: #{format_ms(tree.planning_time)}" if tree.planning_time
54
+ parts << "Rows: #{format_rows(tree.total_rows)}" if tree.total_rows
55
+ pastel.bold(parts.join(" "))
56
+ end
57
+
58
+ def render_sql
59
+ return nil unless @sql
60
+
61
+ [pastel.bold("SQL:").to_s, @sql].join("\n")
62
+ end
63
+
64
+ def render_footer
65
+ @footer
66
+ end
67
+
68
+ def render_tree
69
+ render_node(tree.root, "", true).join("\n")
70
+ end
71
+
72
+ def render_node(node, prefix, is_last)
73
+ lines = []
74
+ branch = is_last ? BRANCH_LAST : BRANCH_MID
75
+ child_prefix = prefix + (is_last ? CONTINUE_GAP : CONTINUE_BAR)
76
+
77
+ lines << "#{prefix}#{branch}#{node_label(node)}"
78
+ decoration_lines(node).each { |line| lines << "#{child_prefix}#{line}" }
79
+ warning_lines(node).each { |line| lines << "#{child_prefix}#{line}" }
80
+
81
+ last = node.children.length - 1
82
+ node.children.each_with_index do |child, i|
83
+ lines.concat(render_node(child, child_prefix, i == last))
84
+ end
85
+
86
+ lines
87
+ end
88
+
89
+ def node_label(node)
90
+ "#{paint_node_type(node)}#{relation_suffix(node)} #{paint_metrics(node)}".rstrip
91
+ end
92
+
93
+ def paint_node_type(node)
94
+ type = node.node_type.to_s
95
+ case severity_for(node)
96
+ when :critical then pastel.red.bold(type)
97
+ when :warning then pastel.yellow.bold(type)
98
+ when :good then pastel.green.bold(type)
99
+ else pastel.bold(type)
100
+ end
101
+ end
102
+
103
+ def severity_for(node)
104
+ return node.warnings.map(&:severity).min_by { |s| SEVERITY_ORDER[s] } if node.warnings.any?
105
+ return :good if node.node_type =~ /\AIndex (Only )?Scan\z/
106
+
107
+ :default
108
+ end
109
+
110
+ def relation_suffix(node)
111
+ case node.node_type
112
+ when "Index Scan", "Index Only Scan", "Bitmap Index Scan"
113
+ parts = []
114
+ parts << "using #{node.index_name}" if node.index_name
115
+ parts << "on #{node.target}" if node.target
116
+ parts.empty? ? "" : " #{parts.join(" ")}"
117
+ when "Seq Scan", "Bitmap Heap Scan"
118
+ node.target ? " on #{node.target}" : ""
119
+ else
120
+ ""
121
+ end
122
+ end
123
+
124
+ def paint_metrics(node)
125
+ parts = []
126
+ parts << "cost=#{format_cost(node.startup_cost)}..#{format_cost(node.total_cost)}"
127
+ parts << "actual=#{format_ms(node.actual_total_time)}" if node.actual_total_time
128
+ parts << "rows=#{format_rows(node.actual_rows || node.plan_rows)}"
129
+ parts << "loops=#{format_rows(node.actual_loops)}" if node.actual_loops && node.actual_loops > 1
130
+ pastel.dim("(#{parts.join(" ")})")
131
+ end
132
+
133
+ def decoration_lines(node)
134
+ decorations = []
135
+ decorations << "Hash Cond: #{node.hash_cond}" if node.hash_cond
136
+ decorations << "Index Cond: #{node.index_cond}" if node.index_cond
137
+ decorations << "Recheck Cond: #{node.recheck_cond}" if node.recheck_cond
138
+ decorations << "Join Filter: #{node.join_filter}" if node.join_filter
139
+ decorations << "Filter: #{node.filter}" if node.filter
140
+ decorations << "Sort Key: #{Array(node.sort_key).join(", ")}" if node.sort_key
141
+ decorations << "Sort Method: #{node.sort_method}#{sort_space(node)}" if node.sort_method
142
+ decorations.map { |d| pastel.dim(d) }
143
+ end
144
+
145
+ def sort_space(node)
146
+ return "" unless node.sort_space_used
147
+
148
+ " (#{node.sort_space_type}: #{node.sort_space_used} kB)"
149
+ end
150
+
151
+ def warning_lines(node)
152
+ node.warnings.flat_map { |warning| paint_warning(warning) }
153
+ end
154
+
155
+ def paint_warning(warning)
156
+ glyph = paint_glyph(warning.severity)
157
+ head = "#{glyph} #{warning.message}"
158
+ return [head] unless warning.suggestion
159
+
160
+ [head, " #{pastel.italic(warning.suggestion)}"]
161
+ end
162
+
163
+ def paint_glyph(severity)
164
+ case severity
165
+ when :critical then pastel.red.bold(WARNING_GLYPH)
166
+ when :warning then pastel.yellow(WARNING_GLYPH)
167
+ else pastel.cyan(WARNING_GLYPH)
168
+ end
169
+ end
170
+
171
+ def format_cost(value)
172
+ return "?" if value.nil?
173
+
174
+ format("%.2f", value)
175
+ end
176
+
177
+ def format_ms(value)
178
+ return "?" if value.nil?
179
+
180
+ if value >= 100
181
+ format("%.1f ms", value)
182
+ else
183
+ format("%.2f ms", value)
184
+ end
185
+ end
186
+
187
+ def format_rows(value)
188
+ return "?" if value.nil?
189
+
190
+ n = value.to_i
191
+ digits = n.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
192
+ n.negative? ? "-#{digits}" : digits
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ class Runner
5
+ ANALYZE_FOOTER = "Executed inside rolled-back transaction. " \
6
+ "Note: side effects of volatile Postgres functions " \
7
+ "(setval, pg_advisory_lock, pg_notify, dblink, …) are NOT reverted by ROLLBACK."
8
+
9
+ def initialize(expression:, options: {}, output: $stdout, error: $stderr, skip_rails_load: false)
10
+ @expression = expression
11
+ @options = normalize_options(options)
12
+ @output = output
13
+ @error = error
14
+ @skip_rails_load = skip_rails_load
15
+ end
16
+
17
+ def call
18
+ warn_unused_buffers
19
+
20
+ Benedictus::RailsLoader.load! unless @skip_rails_load
21
+
22
+ value = Benedictus::ExpressionEvaluator.call(@expression)
23
+ relation = Benedictus::RelationResolver.call(value)
24
+ sql = relation.to_sql
25
+
26
+ output_string =
27
+ case @options[:format]
28
+ when "tree" then render_tree(relation, sql)
29
+ when "json" then render_json(relation)
30
+ when "raw" then render_raw(relation)
31
+ else raise ArgumentError, "unknown format: #{@options[:format]}"
32
+ end
33
+
34
+ @output.puts(output_string)
35
+ end
36
+
37
+ HEURISTIC_OPTION_KEYS = %i[seq_scan_threshold drift_factor nested_loop_threshold per_row_cost_threshold].freeze
38
+
39
+ private
40
+
41
+ def normalize_options(options)
42
+ h = options.to_h.transform_keys { |k| k.to_s.tr("-", "_").to_sym }
43
+ {
44
+ analyze: h.fetch(:analyze, false),
45
+ buffers: h.fetch(:buffers, false),
46
+ verbose: h.fetch(:verbose, false),
47
+ sql: h.fetch(:sql, false),
48
+ format: h.fetch(:format, "tree"),
49
+ no_color: h.fetch(:no_color, false)
50
+ }.merge(HEURISTIC_OPTION_KEYS.each_with_object({}) { |k, acc| acc[k] = h[k] if h.key?(k) })
51
+ end
52
+
53
+ def heuristics_config
54
+ HEURISTIC_OPTION_KEYS.each_with_object({}) do |key, acc|
55
+ acc[key] = @options[key] unless @options[key].nil?
56
+ end
57
+ end
58
+
59
+ def render_tree(relation, sql)
60
+ plan_data = run_plan(relation, format: "JSON")
61
+ tree = Benedictus::Plan::Parser.parse(plan_data)
62
+ Benedictus::Heuristics::Registry.annotate(tree, config: heuristics_config)
63
+
64
+ Benedictus::Renderers::TreeRenderer.new(
65
+ tree,
66
+ expression: @expression,
67
+ color: color_choice,
68
+ sql: sql_to_show(sql),
69
+ footer: footer,
70
+ output: @output
71
+ ).render
72
+ end
73
+
74
+ def render_json(relation)
75
+ plan_data = run_plan(relation, format: "JSON")
76
+ Benedictus::Renderers::JsonRenderer.new(plan_data).render
77
+ end
78
+
79
+ def render_raw(relation)
80
+ result = run_plan(relation, format: "TEXT")
81
+ Benedictus::Renderers::RawRenderer.new(result).render
82
+ end
83
+
84
+ def run_plan(relation, format:)
85
+ Benedictus::PlanRunner.call(
86
+ relation: relation,
87
+ analyze: @options[:analyze],
88
+ buffers: @options[:buffers],
89
+ verbose: @options[:verbose],
90
+ format: format
91
+ )
92
+ end
93
+
94
+ def color_choice
95
+ return false if @options[:no_color]
96
+
97
+ nil
98
+ end
99
+
100
+ def sql_to_show(sql)
101
+ return nil unless @options[:sql]
102
+
103
+ formatted_sql(sql)
104
+ end
105
+
106
+ def formatted_sql(sql)
107
+ Benedictus::SqlFormatter.format(sql)
108
+ end
109
+
110
+ def footer
111
+ return ANALYZE_FOOTER if @options[:analyze]
112
+
113
+ nil
114
+ end
115
+
116
+ def warn_unused_buffers
117
+ return unless @options[:buffers] && !@options[:analyze]
118
+
119
+ @error.puts("benedictus: --buffers is ignored without --analyze")
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ class SafetyGuard
5
+ DATA_MODIFYING_CTE = /\b(insert|update|delete|merge|truncate)\b/i
6
+
7
+ def self.assert_select!(sql)
8
+ normalized = normalize(sql)
9
+
10
+ reject_multi_statement!(normalized)
11
+
12
+ keyword = first_keyword(normalized)
13
+
14
+ return if keyword.casecmp("SELECT").zero?
15
+ return if keyword.casecmp("WITH").zero? && contains_no_data_modifying_cte?(normalized)
16
+
17
+ raise Benedictus::UnsafeQueryError,
18
+ "--analyze can only be used with SELECT queries (got: #{keyword.empty? ? "?" : keyword}...)"
19
+ end
20
+
21
+ def self.with_rollback(klass)
22
+ unless klass.respond_to?(:transaction) && active_record_class?(klass)
23
+ raise ArgumentError, "klass must be an ActiveRecord::Base subclass"
24
+ end
25
+
26
+ result = nil
27
+ klass.transaction(requires_new: true) do
28
+ result = yield
29
+ raise ActiveRecord::Rollback
30
+ end
31
+ result
32
+ end
33
+
34
+ def self.active_record_class?(klass)
35
+ return false unless defined?(ActiveRecord::Base)
36
+ return false unless klass.is_a?(Class)
37
+
38
+ klass <= ActiveRecord::Base
39
+ rescue TypeError, ArgumentError
40
+ false
41
+ end
42
+
43
+ # Replaces SQL string literals (single-quoted, dollar-quoted) and comments
44
+ # (line `--`, nested block `/* */`) with single spaces, preserving token
45
+ # boundaries. The result has identical keyword structure to the original
46
+ # but cannot bypass keyword scans by hiding payloads in comments or
47
+ # string literals.
48
+ def self.normalize(sql)
49
+ result = String.new
50
+ i = 0
51
+ n = sql.length
52
+
53
+ while i < n
54
+ c = sql[i]
55
+ nxt = sql[i + 1]
56
+
57
+ if c == "'"
58
+ i = skip_single_quoted(sql, i)
59
+ result << " "
60
+ elsif c == "$" && (tag = match_dollar_tag(sql, i))
61
+ i = skip_dollar_quoted(sql, i, tag)
62
+ result << " "
63
+ elsif c == "-" && nxt == "-"
64
+ i = sql.index("\n", i + 2) || n
65
+ result << " "
66
+ elsif c == "/" && nxt == "*"
67
+ i = skip_nested_block_comment(sql, i)
68
+ result << " "
69
+ else
70
+ result << c
71
+ i += 1
72
+ end
73
+ end
74
+
75
+ result
76
+ end
77
+
78
+ def self.first_keyword(normalized)
79
+ normalized.lstrip[/\A[A-Za-z_]+/].to_s
80
+ end
81
+
82
+ def self.reject_multi_statement!(normalized)
83
+ trimmed = normalized.rstrip
84
+ trimmed = trimmed.chomp(";").rstrip if trimmed.end_with?(";")
85
+
86
+ return unless trimmed.include?(";")
87
+
88
+ raise Benedictus::UnsafeQueryError,
89
+ "--analyze refuses multi-statement SQL (found ';' between statements)"
90
+ end
91
+
92
+ def self.contains_no_data_modifying_cte?(normalized)
93
+ !DATA_MODIFYING_CTE.match?(normalized)
94
+ end
95
+
96
+ def self.skip_single_quoted(sql, start)
97
+ i = start + 1
98
+ n = sql.length
99
+
100
+ while i < n
101
+ if sql[i] == "'" && sql[i + 1] == "'"
102
+ i += 2
103
+ elsif sql[i] == "'"
104
+ return i + 1
105
+ else
106
+ i += 1
107
+ end
108
+ end
109
+
110
+ n
111
+ end
112
+
113
+ def self.match_dollar_tag(sql, i)
114
+ m = sql[i..].match(/\A\$([A-Za-z_]\w*)?\$/)
115
+ m && m[0]
116
+ end
117
+
118
+ def self.skip_dollar_quoted(sql, start, tag)
119
+ i = start + tag.length
120
+ end_idx = sql.index(tag, i)
121
+ end_idx ? end_idx + tag.length : sql.length
122
+ end
123
+
124
+ def self.skip_nested_block_comment(sql, start)
125
+ i = start + 2
126
+ depth = 1
127
+ n = sql.length
128
+
129
+ while i < n && depth.positive?
130
+ if sql[i] == "/" && sql[i + 1] == "*"
131
+ depth += 1
132
+ i += 2
133
+ elsif sql[i] == "*" && sql[i + 1] == "/"
134
+ depth -= 1
135
+ i += 2
136
+ else
137
+ i += 1
138
+ end
139
+ end
140
+
141
+ i
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ module SqlFormatter
5
+ INDENT = " "
6
+
7
+ BLOCK_KEYWORDS = [
8
+ "WITH RECURSIVE", "WITH",
9
+ "SELECT DISTINCT", "SELECT",
10
+ "FROM",
11
+ "INNER JOIN",
12
+ "LEFT OUTER JOIN", "LEFT JOIN",
13
+ "RIGHT OUTER JOIN", "RIGHT JOIN",
14
+ "FULL OUTER JOIN", "FULL JOIN",
15
+ "CROSS JOIN", "JOIN",
16
+ "WHERE", "GROUP BY", "ORDER BY", "HAVING",
17
+ "LIMIT", "OFFSET",
18
+ "UNION ALL", "UNION", "INTERSECT", "EXCEPT",
19
+ "RETURNING"
20
+ ].freeze
21
+
22
+ INLINE_KEYWORDS = %w[
23
+ ON AS AND OR NOT IN IS LIKE ILIKE BETWEEN
24
+ NULL TRUE FALSE
25
+ ASC DESC NULLS FIRST LAST
26
+ USING CASE WHEN THEN ELSE END
27
+ DISTINCT ALL ANY EXISTS INTERVAL
28
+ ].freeze
29
+
30
+ # ASCII placeholder delimiters chosen so they cannot appear in
31
+ # well-formed SQL or in keyword names. Using printable ASCII keeps
32
+ # the file text-classifiable (no control bytes).
33
+ PLACEHOLDER_OPEN = "[[!benedictus-mask:"
34
+ PLACEHOLDER_CLOSE = "!]]"
35
+ PLACEHOLDER_RE = /\[\[!benedictus-mask:(\d+)!\]\]/
36
+
37
+ class << self
38
+ def format(sql)
39
+ return "" if sql.nil? || sql.empty?
40
+
41
+ masked, literals = mask_literals(sql.to_s)
42
+ upcased = uppercase_keywords(masked)
43
+ broken = break_blocks(upcased)
44
+ restore(broken, literals).strip
45
+ rescue StandardError
46
+ sql.to_s
47
+ end
48
+
49
+ private
50
+
51
+ def mask_literals(sql)
52
+ literals = []
53
+ out = +""
54
+ i = 0
55
+ n = sql.length
56
+
57
+ while i < n
58
+ j = scan_protected_run(sql, i, n)
59
+
60
+ if j > i
61
+ literals << sql[i...j]
62
+ out << "#{PLACEHOLDER_OPEN}#{literals.length - 1}#{PLACEHOLDER_CLOSE}"
63
+ i = j
64
+ else
65
+ out << sql[i]
66
+ i += 1
67
+ end
68
+ end
69
+
70
+ [out, literals]
71
+ end
72
+
73
+ def scan_protected_run(sql, i, n)
74
+ case sql[i]
75
+ when "'" then scan_single_quoted(sql, i, n)
76
+ when '"' then scan_double_quoted(sql, i, n)
77
+ when "-" then sql[i + 1] == "-" ? (sql.index("\n", i) || n) : i
78
+ when "/" then sql[i + 1] == "*" ? scan_block_comment(sql, i, n) : i
79
+ when "$" then scan_dollar_quoted(sql, i, n)
80
+ else i
81
+ end
82
+ end
83
+
84
+ def scan_single_quoted(sql, i, n)
85
+ j = i + 1
86
+ while j < n
87
+ if sql[j] == "'" && sql[j + 1] == "'"
88
+ j += 2
89
+ elsif sql[j] == "'"
90
+ return j + 1
91
+ else
92
+ j += 1
93
+ end
94
+ end
95
+ n
96
+ end
97
+
98
+ def scan_double_quoted(sql, i, n)
99
+ j = i + 1
100
+ while j < n
101
+ if sql[j] == '"' && sql[j + 1] == '"'
102
+ j += 2
103
+ elsif sql[j] == '"'
104
+ return j + 1
105
+ else
106
+ j += 1
107
+ end
108
+ end
109
+ n
110
+ end
111
+
112
+ def scan_block_comment(sql, i, n)
113
+ depth = 1
114
+ j = i + 2
115
+ while j < n && depth.positive?
116
+ if sql[j] == "/" && sql[j + 1] == "*"
117
+ depth += 1
118
+ j += 2
119
+ elsif sql[j] == "*" && sql[j + 1] == "/"
120
+ depth -= 1
121
+ j += 2
122
+ else
123
+ j += 1
124
+ end
125
+ end
126
+ j
127
+ end
128
+
129
+ def scan_dollar_quoted(sql, i, _n)
130
+ m = sql[i..].match(/\A\$([A-Za-z0-9_]*)\$/)
131
+ return i unless m
132
+
133
+ close = "$#{m[1]}$"
134
+ j = sql.index(close, i + m[0].length)
135
+ return i unless j
136
+
137
+ j + close.length
138
+ end
139
+
140
+ def uppercase_keywords(s)
141
+ (BLOCK_KEYWORDS + INLINE_KEYWORDS).each do |kw|
142
+ s = s.gsub(/\b#{Regexp.escape(kw)}\b/i, kw)
143
+ end
144
+ s
145
+ end
146
+
147
+ def break_blocks(s)
148
+ union = BLOCK_KEYWORDS
149
+ .sort_by { |k| -k.length }
150
+ .map { |k| "\\b#{Regexp.escape(k)}\\b" }
151
+ .join("|")
152
+ re = /\s*(#{union})\s*/
153
+ s.gsub(re) { "\n#{Regexp.last_match(1)}\n#{INDENT}" }
154
+ end
155
+
156
+ def restore(s, literals)
157
+ s.gsub(PLACEHOLDER_RE) { literals[Regexp.last_match(1).to_i] }
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ VERSION = "0.2.0"
5
+ end
data/lib/benedictus.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "benedictus/version"
6
+ require_relative "benedictus/errors"
7
+ require_relative "benedictus/color"
8
+ require_relative "benedictus/sql_formatter"
9
+ require_relative "benedictus/plan/node"
10
+ require_relative "benedictus/plan/tree"
11
+ require_relative "benedictus/plan/parser"
12
+ require_relative "benedictus/heuristics/warning"
13
+ require_relative "benedictus/heuristics/base"
14
+ require_relative "benedictus/heuristics/seq_scan_on_large_table"
15
+ require_relative "benedictus/heuristics/row_estimate_drift"
16
+ require_relative "benedictus/heuristics/external_sort"
17
+ require_relative "benedictus/heuristics/nested_loop_blowup"
18
+ require_relative "benedictus/heuristics/expensive_per_row_scan"
19
+ require_relative "benedictus/heuristics/registry"
20
+ require_relative "benedictus/renderers/tree_renderer"
21
+ require_relative "benedictus/renderers/json_renderer"
22
+ require_relative "benedictus/renderers/raw_renderer"
23
+ require_relative "benedictus/safety_guard"
24
+ require_relative "benedictus/plan_runner"
25
+ require_relative "benedictus/expression_evaluator"
26
+ require_relative "benedictus/relation_resolver"
27
+ require_relative "benedictus/rails_loader"
28
+ require_relative "benedictus/runner"
29
+ require_relative "benedictus/cli"
30
+
31
+ module Benedictus
32
+ end