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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +118 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/exe/bemdito +6 -0
- data/exe/benedictus +6 -0
- data/exe/bnd +6 -0
- data/lib/benedictus/cli.rb +79 -0
- data/lib/benedictus/color.rb +52 -0
- data/lib/benedictus/errors.rb +31 -0
- data/lib/benedictus/expression_evaluator.rb +24 -0
- data/lib/benedictus/heuristics/base.rb +21 -0
- data/lib/benedictus/heuristics/expensive_per_row_scan.rb +71 -0
- data/lib/benedictus/heuristics/external_sort.rb +31 -0
- data/lib/benedictus/heuristics/nested_loop_blowup.rb +34 -0
- data/lib/benedictus/heuristics/registry.rb +45 -0
- data/lib/benedictus/heuristics/row_estimate_drift.rb +41 -0
- data/lib/benedictus/heuristics/seq_scan_on_large_table.rb +34 -0
- data/lib/benedictus/heuristics/warning.rb +16 -0
- data/lib/benedictus/plan/node.rb +75 -0
- data/lib/benedictus/plan/parser.rb +40 -0
- data/lib/benedictus/plan/tree.rb +52 -0
- data/lib/benedictus/plan_runner.rb +71 -0
- data/lib/benedictus/rails_loader.rb +41 -0
- data/lib/benedictus/relation_resolver.rb +46 -0
- data/lib/benedictus/renderers/json_renderer.rb +15 -0
- data/lib/benedictus/renderers/raw_renderer.rb +16 -0
- data/lib/benedictus/renderers/tree_renderer.rb +196 -0
- data/lib/benedictus/runner.rb +122 -0
- data/lib/benedictus/safety_guard.rb +144 -0
- data/lib/benedictus/sql_formatter.rb +161 -0
- data/lib/benedictus/version.rb +5 -0
- data/lib/benedictus.rb +32 -0
- metadata +99 -0
|
@@ -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
|
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
|