rails_vitals 0.6.0 → 0.6.2
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 +4 -4
- data/app/controllers/rails_vitals/playgrounds_controller.rb +1 -1
- data/app/helpers/rails_vitals/application_helper.rb +10 -0
- data/app/views/rails_vitals/explains/show.html.erb +8 -1
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +95 -9
- data/lib/rails_vitals/mcp/tools/explain_query.rb +13 -1
- data/lib/rails_vitals/playground/safe_chain_builder.rb +292 -0
- data/lib/rails_vitals/playground/sandbox.rb +19 -30
- data/lib/rails_vitals/version.rb +1 -1
- data/lib/rails_vitals.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c655986f52ddc0e0848b8e67c28d603ccea2a97dc6b85d9157a7a670fd298fc6
|
|
4
|
+
data.tar.gz: 49bbb1b8fc0041bd75edb7d3d990ed517dcd907f64b7f99af95eb2d33dbdee80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e905ff8aa217f398c7521b677c088f637b5483cd153af51db1453fbee560476d32b230a3a9d432d975910f60fd9992cd4368a79b6be07abb4a6f676cff5c866
|
|
7
|
+
data.tar.gz: fcd80c62bc58c1adfed045eb3764f29918705d6b9e0952af68f0f2601197f3af5079ca5abbe62aa6361c950b188711102f96ba3e36b28fa2766138b1fb69ce1d
|
|
@@ -104,6 +104,16 @@ module RailsVitals
|
|
|
104
104
|
}[risk.to_sym] || COLOR_NEUTRAL
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Returns a hex color for EXPLAIN warning severity (:danger, :warning, :info)
|
|
108
|
+
def explain_severity_color(severity)
|
|
109
|
+
case severity&.to_sym
|
|
110
|
+
when :danger then COLOR_LIGHT_RED
|
|
111
|
+
when :warning then COLOR_ORANGE
|
|
112
|
+
when :info then "#90cdf4"
|
|
113
|
+
else COLOR_ORANGE
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
107
117
|
# Returns a readable hex text color for a numeric health score (0-100)
|
|
108
118
|
def score_text_color(score)
|
|
109
119
|
case score.to_i
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
|
|
52
52
|
<% @result.warnings.each do |w| %>
|
|
53
|
-
<% severity_color = w[:severity]
|
|
53
|
+
<% severity_color = explain_severity_color(w[:severity]) %>
|
|
54
54
|
|
|
55
55
|
<div
|
|
56
56
|
style="background:#1a202c;border-left:3px solid <%= severity_color %>;
|
|
@@ -81,6 +81,13 @@
|
|
|
81
81
|
<div class="text-muted">
|
|
82
82
|
Nested Loop processed a large number of rows. Verify join columns are indexed.
|
|
83
83
|
</div>
|
|
84
|
+
<% when :function_call %>
|
|
85
|
+
<div class="bold mb-4" style="color:<%= severity_color %>;">
|
|
86
|
+
Function Call Detected
|
|
87
|
+
</div>
|
|
88
|
+
<div class="text-muted">
|
|
89
|
+
<%= w[:message] %>
|
|
90
|
+
</div>
|
|
84
91
|
<% end %>
|
|
85
92
|
</div>
|
|
86
93
|
<% end %>
|
|
@@ -10,7 +10,14 @@ module RailsVitals
|
|
|
10
10
|
COLOR_NEUTRAL = "#a0aec0"
|
|
11
11
|
COLOR_COOL = "#76e4f7"
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# ─── Result / PlanNode structs ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
Result = Struct.new(
|
|
16
|
+
:sql, :plan, :warnings, :suggestions,
|
|
17
|
+
:total_cost, :actual_time_ms, :rows_examined,
|
|
18
|
+
:interpretation, :error, :function_calls, :dry_run_result,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
14
21
|
NODE_METADATA = {
|
|
15
22
|
"Seq Scan" => {
|
|
16
23
|
risk: :danger,
|
|
@@ -114,12 +121,7 @@ module RailsVitals
|
|
|
114
121
|
}
|
|
115
122
|
}.freeze
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
:sql, :plan, :warnings, :suggestions,
|
|
119
|
-
:total_cost, :actual_time_ms, :rows_examined,
|
|
120
|
-
:interpretation, :error,
|
|
121
|
-
keyword_init: true
|
|
122
|
-
)
|
|
124
|
+
# ─── Safe SQL analysis ───────────────────────────────────────────────────────
|
|
123
125
|
|
|
124
126
|
PlanNode = Struct.new(
|
|
125
127
|
:node_type, :relation, :alias_name,
|
|
@@ -132,15 +134,34 @@ module RailsVitals
|
|
|
132
134
|
keyword_init: true
|
|
133
135
|
)
|
|
134
136
|
|
|
137
|
+
ALLOWED_FUNCTIONS = %w[
|
|
138
|
+
COUNT SUM AVG MIN MAX
|
|
139
|
+
COALESCE NULLIF
|
|
140
|
+
NOW CURRENT_TIMESTAMP CURRENT_DATE CURRENT_TIME LOCALTIMESTAMP LOCALTIME
|
|
141
|
+
EXTRACT DATE_PART DATE_TRUNC AGE
|
|
142
|
+
UPPER LOWER TRIM LENGTH SUBSTRING CONCAT REPLACE POSITION REVERSE
|
|
143
|
+
ROUND FLOOR CEIL ABS MOD POWER SQRT
|
|
144
|
+
GREATEST LEAST CAST
|
|
145
|
+
ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTILE
|
|
146
|
+
ARRAY_AGG STRING_AGG
|
|
147
|
+
JSON_EXTRACT_PATH_TEXT JSONB_EXTRACT_PATH
|
|
148
|
+
].freeze
|
|
149
|
+
|
|
150
|
+
FUNCTION_CALL_PATTERN = /\b(?!#{ALLOWED_FUNCTIONS.join('|')}\b)[a-zA-Z_]\w*\s*\(/
|
|
151
|
+
|
|
135
152
|
def self.analyze(sql, binds: [])
|
|
136
153
|
return unsupported_env unless supported_environment?
|
|
137
154
|
return unsupported_sql unless select_query?(sql)
|
|
138
155
|
|
|
139
156
|
safe_sql = substitute_binds(sql, binds)
|
|
157
|
+
has_function_calls = contains_function_calls?(safe_sql)
|
|
140
158
|
|
|
141
|
-
|
|
159
|
+
dry_run_result = dry_run(safe_sql)
|
|
160
|
+
return dry_run_result if dry_run_result.error
|
|
161
|
+
|
|
162
|
+
raw = exec_in_read_only_transaction(
|
|
142
163
|
"EXPLAIN (FORMAT JSON, ANALYZE true, BUFFERS false) #{safe_sql}"
|
|
143
|
-
)
|
|
164
|
+
)
|
|
144
165
|
|
|
145
166
|
plan_json = JSON.parse(raw)
|
|
146
167
|
root_plan = plan_json.first["Plan"]
|
|
@@ -150,6 +171,14 @@ module RailsVitals
|
|
|
150
171
|
warnings = extract_warnings(root_node)
|
|
151
172
|
suggestions = build_suggestions(warnings, root_node)
|
|
152
173
|
|
|
174
|
+
if has_function_calls
|
|
175
|
+
warnings << {
|
|
176
|
+
type: :function_call,
|
|
177
|
+
severity: :info,
|
|
178
|
+
message: "Query contains function calls — executed inside a read-only transaction that prevents writes"
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
153
182
|
result = Result.new(
|
|
154
183
|
sql: safe_sql,
|
|
155
184
|
plan: root_node,
|
|
@@ -158,6 +187,8 @@ module RailsVitals
|
|
|
158
187
|
total_cost: root_plan["Total Cost"]&.round(2),
|
|
159
188
|
actual_time_ms: exec_time,
|
|
160
189
|
rows_examined: count_rows_examined(root_plan),
|
|
190
|
+
function_calls: has_function_calls,
|
|
191
|
+
dry_run_result: dry_run_result,
|
|
161
192
|
error: nil
|
|
162
193
|
)
|
|
163
194
|
|
|
@@ -169,6 +200,28 @@ module RailsVitals
|
|
|
169
200
|
actual_time_ms: nil, rows_examined: nil)
|
|
170
201
|
end
|
|
171
202
|
|
|
203
|
+
def self.dry_run(sql, binds: [])
|
|
204
|
+
safe_sql = substitute_binds(sql, binds)
|
|
205
|
+
|
|
206
|
+
raw = exec_in_read_only_transaction(
|
|
207
|
+
"EXPLAIN (FORMAT JSON, ANALYZE false, BUFFERS false) #{safe_sql}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
plan_json = JSON.parse(raw)
|
|
211
|
+
root_plan = plan_json.first["Plan"]
|
|
212
|
+
|
|
213
|
+
Result.new(
|
|
214
|
+
sql: safe_sql,
|
|
215
|
+
total_cost: root_plan["Total Cost"]&.round(2),
|
|
216
|
+
plan: build_node(root_plan),
|
|
217
|
+
error: nil
|
|
218
|
+
)
|
|
219
|
+
rescue => e
|
|
220
|
+
Result.new(error: e.message, sql: sql, plan: nil,
|
|
221
|
+
warnings: [], suggestions: [], total_cost: nil,
|
|
222
|
+
actual_time_ms: nil, rows_examined: nil)
|
|
223
|
+
end
|
|
224
|
+
|
|
172
225
|
private
|
|
173
226
|
|
|
174
227
|
def self.supported_environment?
|
|
@@ -179,6 +232,35 @@ module RailsVitals
|
|
|
179
232
|
sql.strip.match?(/\ASELECT/i)
|
|
180
233
|
end
|
|
181
234
|
|
|
235
|
+
def self.contains_function_calls?(sql)
|
|
236
|
+
clean = sql.gsub(/'.*?(?:''|')/, "").gsub(/".*?"/, "")
|
|
237
|
+
clean.match?(FUNCTION_CALL_PATTERN)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.exec_in_read_only_transaction(sql)
|
|
241
|
+
if postgresql?
|
|
242
|
+
connection = ActiveRecord::Base.connection
|
|
243
|
+
connection.execute("BEGIN")
|
|
244
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
245
|
+
begin
|
|
246
|
+
result = connection.execute(sql)
|
|
247
|
+
connection.execute("COMMIT")
|
|
248
|
+
result.first["QUERY PLAN"]
|
|
249
|
+
rescue => e
|
|
250
|
+
connection.execute("ROLLBACK") rescue nil
|
|
251
|
+
raise e
|
|
252
|
+
end
|
|
253
|
+
else
|
|
254
|
+
ActiveRecord::Base.connection.execute(sql).first["QUERY PLAN"]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.postgresql?
|
|
259
|
+
ActiveRecord::Base.connection.adapter_name.match?(/postgresql/i)
|
|
260
|
+
rescue
|
|
261
|
+
false
|
|
262
|
+
end
|
|
263
|
+
|
|
182
264
|
def self.substitute_binds(sql, binds)
|
|
183
265
|
# Replace $1, $2 placeholders with safe literal values
|
|
184
266
|
result = sql.dup
|
|
@@ -334,6 +416,10 @@ module RailsVitals
|
|
|
334
416
|
parts << "Sequential scan detected — missing index is causing full table reads"
|
|
335
417
|
end
|
|
336
418
|
|
|
419
|
+
if result.function_calls
|
|
420
|
+
parts << "Query uses function calls — executed in a read-only transaction that prevents writes"
|
|
421
|
+
end
|
|
422
|
+
|
|
337
423
|
if result.actual_time_ms.to_f > 100
|
|
338
424
|
parts << "query took #{result.actual_time_ms}ms — above the 100ms warning threshold"
|
|
339
425
|
end
|
|
@@ -39,7 +39,14 @@ module RailsVitals
|
|
|
39
39
|
return missing_sql_response if sql.nil? || sql.strip.empty?
|
|
40
40
|
return rejected_sql_response(sql) if dml_present?(sql)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
cleaned_sql = sql.strip
|
|
43
|
+
|
|
44
|
+
cost_estimate = Analyzers::ExplainAnalyzer.dry_run(cleaned_sql)
|
|
45
|
+
if cost_estimate.error
|
|
46
|
+
return error_response("Cost estimate failed: #{cost_estimate.error}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result = Analyzers::ExplainAnalyzer.analyze(cleaned_sql)
|
|
43
50
|
|
|
44
51
|
return error_response(result.error) if result.error
|
|
45
52
|
|
|
@@ -48,6 +55,11 @@ module RailsVitals
|
|
|
48
55
|
total_cost: result.total_cost,
|
|
49
56
|
actual_time_ms: result.actual_time_ms,
|
|
50
57
|
rows_examined: result.rows_examined,
|
|
58
|
+
cost_estimate: {
|
|
59
|
+
total_cost: cost_estimate.total_cost,
|
|
60
|
+
note: "Cost estimate from dry-run EXPLAIN (ANALYZE was not executed for this estimate)"
|
|
61
|
+
},
|
|
62
|
+
function_calls: result.function_calls,
|
|
51
63
|
warnings: serialize_warnings(result.warnings),
|
|
52
64
|
suggestions: serialize_suggestions(result.suggestions),
|
|
53
65
|
interpretation: result.interpretation
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
module RailsVitals
|
|
4
|
+
module Playground
|
|
5
|
+
class SafeChainBuilder
|
|
6
|
+
ALLOWED_METHODS = %w[
|
|
7
|
+
all where select limit offset order group
|
|
8
|
+
includes preload eager_load joins left_joins
|
|
9
|
+
find find_by first last count sum average
|
|
10
|
+
pluck distinct having references unscoped not
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
DISALLOWED_CLASS_METHODS = %w[
|
|
14
|
+
connection execute exec system eval send public_send __send__
|
|
15
|
+
instance_eval class_eval module_eval define_method method_missing
|
|
16
|
+
delete destroy delete_all destroy_all update_all
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
ParseError = Class.new(StandardError)
|
|
20
|
+
|
|
21
|
+
def self.build(chain_str, model)
|
|
22
|
+
relation = model.all
|
|
23
|
+
parse_chain(chain_str).each do |method_name, args|
|
|
24
|
+
if DISALLOWED_CLASS_METHODS.include?(method_name)
|
|
25
|
+
raise ParseError, "Method '#{method_name}' is not allowed for security reasons"
|
|
26
|
+
end
|
|
27
|
+
unless ALLOWED_METHODS.include?(method_name)
|
|
28
|
+
raise ParseError, "Method '#{method_name}' is not allowed. Allowed: #{ALLOWED_METHODS.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
relation = relation.public_send(method_name, *args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
raise ParseError, "Expression must return an ActiveRecord::Relation" unless relation.is_a?(ActiveRecord::Relation)
|
|
34
|
+
relation
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def self.parse_chain(str)
|
|
40
|
+
scanner = StringScanner.new(str)
|
|
41
|
+
calls = []
|
|
42
|
+
scanner.skip(/\s+/)
|
|
43
|
+
|
|
44
|
+
until scanner.eos?
|
|
45
|
+
scanner.skip(/\.\s*/)
|
|
46
|
+
|
|
47
|
+
name = scanner.scan(/[a-z_][a-zA-Z0-9_!?]*/)
|
|
48
|
+
raise ParseError, "Expected method name at position #{scanner.pos}" unless name
|
|
49
|
+
|
|
50
|
+
scanner.skip(/\s+/)
|
|
51
|
+
if scanner.scan(/\(/)
|
|
52
|
+
args = parse_args(scanner)
|
|
53
|
+
scanner.skip(/\s*\)/)
|
|
54
|
+
calls << [ name, args ]
|
|
55
|
+
else
|
|
56
|
+
calls << [ name, [] ]
|
|
57
|
+
end
|
|
58
|
+
scanner.skip(/\s+/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
calls
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.parse_args(scanner)
|
|
65
|
+
args = []
|
|
66
|
+
scanner.skip(/\s+/)
|
|
67
|
+
return args if scanner.eos? || scanner.peek(1) == ")"
|
|
68
|
+
|
|
69
|
+
loop do
|
|
70
|
+
scanner.skip(/\s+/)
|
|
71
|
+
break if scanner.eos? || scanner.peek(1) == ")"
|
|
72
|
+
|
|
73
|
+
if keyword_hash_start?(scanner)
|
|
74
|
+
args << scan_keyword_hash(scanner)
|
|
75
|
+
else
|
|
76
|
+
args << scan_value(scanner)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
scanner.skip(/\s+/)
|
|
80
|
+
break unless scanner.scan(/,/)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
args
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.keyword_hash_start?(scanner)
|
|
87
|
+
pos = scanner.pos
|
|
88
|
+
ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
89
|
+
return false unless ident
|
|
90
|
+
|
|
91
|
+
scanner.skip(/\s*/)
|
|
92
|
+
|
|
93
|
+
if scanner.scan(/:/) && !scanner.scan(/:/)
|
|
94
|
+
scanner.pos = pos
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
scanner.pos = pos
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.scan_keyword_hash(scanner)
|
|
103
|
+
hash = {}
|
|
104
|
+
loop do
|
|
105
|
+
scanner.skip(/\s+/)
|
|
106
|
+
break if scanner.eos? || scanner.peek(1) == ")" || scanner.peek(1) == "}"
|
|
107
|
+
|
|
108
|
+
key = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
109
|
+
raise ParseError, "Expected hash key" unless key
|
|
110
|
+
scanner.skip(/\s*:\s*/)
|
|
111
|
+
value = scan_value(scanner)
|
|
112
|
+
hash[key.to_sym] = value
|
|
113
|
+
scanner.skip(/\s+/)
|
|
114
|
+
break unless scanner.scan(/,/)
|
|
115
|
+
end
|
|
116
|
+
hash
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.scan_value(scanner)
|
|
120
|
+
scanner.skip(/\s+/)
|
|
121
|
+
ch = scanner.peek(1)
|
|
122
|
+
raise ParseError, "Unexpected end of expression" unless ch
|
|
123
|
+
|
|
124
|
+
case ch
|
|
125
|
+
when "'" then scan_single_quoted_string(scanner)
|
|
126
|
+
when '"' then scan_double_quoted_string(scanner)
|
|
127
|
+
when ":" then scan_symbol(scanner)
|
|
128
|
+
when "t"
|
|
129
|
+
if scanner.scan(/true\b/)
|
|
130
|
+
true
|
|
131
|
+
else
|
|
132
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
133
|
+
end
|
|
134
|
+
when "f"
|
|
135
|
+
if scanner.scan(/false\b/)
|
|
136
|
+
false
|
|
137
|
+
else
|
|
138
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
139
|
+
end
|
|
140
|
+
when "n"
|
|
141
|
+
if scanner.scan(/nil\b/)
|
|
142
|
+
nil
|
|
143
|
+
else
|
|
144
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
145
|
+
end
|
|
146
|
+
when "[" then scan_array(scanner)
|
|
147
|
+
when "{" then scan_hash_literal(scanner)
|
|
148
|
+
when "-", "+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" then scan_number(scanner)
|
|
149
|
+
else
|
|
150
|
+
raise ParseError, "Unexpected token '#{ch}' at position #{scanner.pos}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.scan_single_quoted_string(scanner)
|
|
155
|
+
scanner.pos += 1
|
|
156
|
+
result = +""
|
|
157
|
+
until scanner.eos?
|
|
158
|
+
case scanner.peek(1)
|
|
159
|
+
when "'"
|
|
160
|
+
scanner.pos += 1
|
|
161
|
+
return result
|
|
162
|
+
when "\\"
|
|
163
|
+
scanner.pos += 1
|
|
164
|
+
escaped = scanner.getch
|
|
165
|
+
case escaped
|
|
166
|
+
when "'" then result << "'"
|
|
167
|
+
when "\\" then result << "\\"
|
|
168
|
+
else result << "\\#{escaped}"
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
result << scanner.getch
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
raise ParseError, "Unterminated single-quoted string"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.scan_double_quoted_string(scanner)
|
|
178
|
+
scanner.pos += 1
|
|
179
|
+
result = +""
|
|
180
|
+
until scanner.eos?
|
|
181
|
+
case scanner.peek(1)
|
|
182
|
+
when '"'
|
|
183
|
+
scanner.pos += 1
|
|
184
|
+
return result
|
|
185
|
+
when "\\"
|
|
186
|
+
scanner.pos += 1
|
|
187
|
+
escaped = scanner.getch
|
|
188
|
+
case escaped
|
|
189
|
+
when '"' then result << '"'
|
|
190
|
+
when "\\" then result << "\\"
|
|
191
|
+
when "n" then result << "\n"
|
|
192
|
+
when "t" then result << "\t"
|
|
193
|
+
when "r" then result << "\r"
|
|
194
|
+
when "#" then result << "#"
|
|
195
|
+
else result << "\\#{escaped}"
|
|
196
|
+
end
|
|
197
|
+
else
|
|
198
|
+
result << scanner.getch
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
raise ParseError, "Unterminated double-quoted string"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.scan_symbol(scanner)
|
|
205
|
+
scanner.pos += 1
|
|
206
|
+
if scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
207
|
+
scanner.matched.to_sym
|
|
208
|
+
elsif (str = scanner.scan(/"[^"]*"/) || scanner.scan(/'[^']*'/))
|
|
209
|
+
str[1..-2].to_sym
|
|
210
|
+
else
|
|
211
|
+
raise ParseError, "Invalid symbol at position #{scanner.pos}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.scan_number(scanner)
|
|
216
|
+
num_str = scanner.scan(/-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)
|
|
217
|
+
raise ParseError, "Invalid number at position #{scanner.pos}" unless num_str
|
|
218
|
+
if num_str.include?(".") || num_str.match?(/[eE]/)
|
|
219
|
+
num_str.to_f
|
|
220
|
+
else
|
|
221
|
+
num_str.to_i
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.scan_array(scanner)
|
|
226
|
+
scanner.pos += 1
|
|
227
|
+
arr = []
|
|
228
|
+
scanner.skip(/\s+/)
|
|
229
|
+
unless scanner.peek(1) == "]"
|
|
230
|
+
loop do
|
|
231
|
+
arr << scan_value(scanner)
|
|
232
|
+
scanner.skip(/\s+/)
|
|
233
|
+
break unless scanner.scan(/,/)
|
|
234
|
+
scanner.skip(/\s+/)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
scanner.skip(/\s*\]/)
|
|
238
|
+
raise ParseError, "Unterminated array" unless scanner.matched
|
|
239
|
+
arr
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.scan_hash_literal(scanner)
|
|
243
|
+
scanner.pos += 1
|
|
244
|
+
hash = {}
|
|
245
|
+
scanner.skip(/\s+/)
|
|
246
|
+
unless scanner.eos? || scanner.peek(1) == "}"
|
|
247
|
+
loop do
|
|
248
|
+
scanner.skip(/\s+/)
|
|
249
|
+
break if scanner.eos? || scanner.peek(1) == "}"
|
|
250
|
+
|
|
251
|
+
key = parse_hash_key(scanner)
|
|
252
|
+
scanner.skip(/\s+/)
|
|
253
|
+
|
|
254
|
+
if scanner.scan(/=>/)
|
|
255
|
+
scanner.skip(/\s+/)
|
|
256
|
+
hash[key] = scan_value(scanner)
|
|
257
|
+
elsif scanner.scan(/:\s*/)
|
|
258
|
+
hash[key.to_sym] = scan_value(scanner)
|
|
259
|
+
else
|
|
260
|
+
raise ParseError, "Expected '=>' or ':' after hash key"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
scanner.skip(/\s+/)
|
|
264
|
+
break unless scanner.scan(/,/)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
scanner.skip(/\s*}/)
|
|
268
|
+
raise ParseError, "Unterminated hash" unless scanner.matched
|
|
269
|
+
hash
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.parse_hash_key(scanner)
|
|
273
|
+
case scanner.peek(1)
|
|
274
|
+
when '"', "'" then scan_string(scanner)
|
|
275
|
+
when ":" then scan_symbol(scanner)
|
|
276
|
+
else
|
|
277
|
+
ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
278
|
+
raise ParseError, "Expected hash key" unless ident
|
|
279
|
+
ident
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def self.scan_string(scanner)
|
|
284
|
+
case scanner.peek(1)
|
|
285
|
+
when "'" then scan_single_quoted_string(scanner)
|
|
286
|
+
when '"' then scan_double_quoted_string(scanner)
|
|
287
|
+
else raise ParseError, "Expected string"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
module RailsVitals
|
|
2
2
|
module Playground
|
|
3
3
|
class Sandbox
|
|
4
|
-
ALLOWED_METHODS = %w[
|
|
5
|
-
all where select limit offset order group
|
|
6
|
-
includes preload eager_load joins left_joins
|
|
7
|
-
find find_by first last count sum average
|
|
8
|
-
pluck distinct having references unscoped
|
|
9
|
-
].freeze
|
|
10
|
-
|
|
11
4
|
BLOCKED_PATTERNS = [
|
|
12
5
|
/\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
|
|
13
6
|
/\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
|
|
14
7
|
/\.destroy/i, /`/
|
|
15
8
|
].freeze
|
|
16
9
|
|
|
10
|
+
SAFE_EXPRESSION_PATTERN = /\A[a-zA-Z0-9_\.\s\(\),:\[\]{}'"!?=<>|&*+\-\/\\%]+\z/
|
|
11
|
+
|
|
12
|
+
ASSOCIATION_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
13
|
+
|
|
17
14
|
DEFAULT_LIMIT = 100
|
|
18
15
|
|
|
19
16
|
Result = Struct.new(
|
|
@@ -26,6 +23,13 @@ module RailsVitals
|
|
|
26
23
|
def self.run(expression, access_associations: [])
|
|
27
24
|
return blocked_result("No expression provided") if expression.blank?
|
|
28
25
|
|
|
26
|
+
expression = expression.gsub(/#[^\n]*/, "").strip
|
|
27
|
+
return blocked_result("No expression provided") if expression.blank?
|
|
28
|
+
|
|
29
|
+
return blocked_result(
|
|
30
|
+
"Expression contains invalid characters."
|
|
31
|
+
) unless expression.match?(SAFE_EXPRESSION_PATTERN)
|
|
32
|
+
|
|
29
33
|
BLOCKED_PATTERNS.each do |pattern|
|
|
30
34
|
return blocked_result(
|
|
31
35
|
"Expression contains blocked operation. " \
|
|
@@ -33,6 +37,10 @@ module RailsVitals
|
|
|
33
37
|
) if expression.match?(pattern)
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
access_associations = access_associations.select do |name|
|
|
41
|
+
name.to_s.match?(ASSOCIATION_NAME_PATTERN)
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
model_name = extract_model_name(expression)
|
|
37
45
|
return blocked_result(
|
|
38
46
|
"Could not detect model from expression. " \
|
|
@@ -111,10 +119,7 @@ module RailsVitals
|
|
|
111
119
|
end
|
|
112
120
|
|
|
113
121
|
def self.extract_model_name(expression)
|
|
114
|
-
|
|
115
|
-
clean = expression.gsub(/#[^\n]*/, "").strip
|
|
116
|
-
# First word before a dot or whitespace — must look like a constant (CamelCase)
|
|
117
|
-
match = clean.match(/\A([A-Z][A-Za-z0-9]*)/)
|
|
122
|
+
match = expression.match(/\A([A-Z][A-Za-z0-9]*)/)
|
|
118
123
|
match ? match[1] : nil
|
|
119
124
|
end
|
|
120
125
|
|
|
@@ -132,31 +137,15 @@ module RailsVitals
|
|
|
132
137
|
end
|
|
133
138
|
|
|
134
139
|
def self.build_relation(expression, model)
|
|
135
|
-
# Parse "Post.includes(:likes).where(published: true).limit(10)"
|
|
136
|
-
# Strip the model name prefix if present
|
|
137
140
|
chain_str = expression
|
|
138
141
|
.sub(/\A#{Regexp.escape(model.name)}\s*\.?\s*/, "")
|
|
139
142
|
.strip
|
|
140
143
|
|
|
141
144
|
return model.all if chain_str.blank?
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
relation = eval(chain_str, sandbox_binding) # rubocop:disable Security/Eval
|
|
147
|
-
|
|
148
|
-
unless relation.is_a?(ActiveRecord::Relation)
|
|
149
|
-
raise "Expression must return an ActiveRecord::Relation"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
relation
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def self.build_binding(model)
|
|
156
|
-
# Create a minimal binding with only the model exposed
|
|
157
|
-
ctx = Object.new
|
|
158
|
-
ctx.define_singleton_method(:relation) { model.all }
|
|
159
|
-
ctx.instance_eval { binding }
|
|
146
|
+
SafeChainBuilder.build(chain_str, model)
|
|
147
|
+
rescue SafeChainBuilder::ParseError => e
|
|
148
|
+
raise "Expression error: #{e.message}"
|
|
160
149
|
end
|
|
161
150
|
|
|
162
151
|
def self.apply_limit(relation)
|
data/lib/rails_vitals/version.rb
CHANGED
data/lib/rails_vitals.rb
CHANGED
|
@@ -14,6 +14,7 @@ require "rails_vitals/scorers/base_scorer"
|
|
|
14
14
|
require "rails_vitals/scorers/query_scorer"
|
|
15
15
|
require "rails_vitals/scorers/n_plus_one_scorer"
|
|
16
16
|
require "rails_vitals/scorers/composite_scorer"
|
|
17
|
+
require "rails_vitals/playground/safe_chain_builder"
|
|
17
18
|
require "rails_vitals/playground/sandbox"
|
|
18
19
|
require "rails_vitals/panel_renderer"
|
|
19
20
|
require "rails_vitals/middleware/panel_injector"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_vitals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Sanchez
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/rails_vitals/middleware/panel_injector.rb
|
|
96
96
|
- lib/rails_vitals/notifications/subscriber.rb
|
|
97
97
|
- lib/rails_vitals/panel_renderer.rb
|
|
98
|
+
- lib/rails_vitals/playground/safe_chain_builder.rb
|
|
98
99
|
- lib/rails_vitals/playground/sandbox.rb
|
|
99
100
|
- lib/rails_vitals/request_record.rb
|
|
100
101
|
- lib/rails_vitals/scorers/base_scorer.rb
|