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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82d62304d92259c9c248b8033948784ba6bb5075d7f7af627c445624a042901a
4
- data.tar.gz: 6285bf97528093389e861f96bb45259d434e336042c7d23e30fab02109e690ce
3
+ metadata.gz: c655986f52ddc0e0848b8e67c28d603ccea2a97dc6b85d9157a7a670fd298fc6
4
+ data.tar.gz: 49bbb1b8fc0041bd75edb7d3d990ed517dcd907f64b7f99af95eb2d33dbdee80
5
5
  SHA512:
6
- metadata.gz: 0ae1ce76d1ac8c1bbf7559a119e1680f87b40faafe989f0e6c6afb9ec5f3cb8c374a1899a32ca28f04d39ee7ddb4e6f4b73251f16835c8a9587590ecc6ff6cc8
7
- data.tar.gz: 48245cadf704dc9bced9103cad91485d0b9b1c1173b11b50fd79b8964259cb41eaba8af42ff86ca95c1d6ba16ddc52e1f88120159317065e36fb2a4c53d10792
6
+ metadata.gz: 7e905ff8aa217f398c7521b677c088f637b5483cd153af51db1453fbee560476d32b230a3a9d432d975910f60fd9992cd4368a79b6be07abb4a6f676cff5c866
7
+ data.tar.gz: fcd80c62bc58c1adfed045eb3764f29918705d6b9e0952af68f0f2601197f3af5079ca5abbe62aa6361c950b188711102f96ba3e36b28fa2766138b1fb69ce1d
@@ -13,7 +13,7 @@ module RailsVitals
13
13
  access_associations = Array(params[:access_associations]).reject(&:blank?)
14
14
 
15
15
  result = Playground::Sandbox.run(
16
- expression,
16
+ clean_expr,
17
17
  access_associations: access_associations
18
18
  )
19
19
 
@@ -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] == :danger ? "#fc8181" : "#f6ad55" %>
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
- # Node types and their risk/education metadata
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
- Result = Struct.new(
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
- raw = ActiveRecord::Base.connection.execute(
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
- ).first["QUERY PLAN"]
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
- result = Analyzers::ExplainAnalyzer.analyze(sql.strip)
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
- # Strip comments first
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
- # Build the chain by safe eval within a controlled binding
144
- # Only the model constant is exposed, no access to app globals
145
- sandbox_binding = build_binding(model)
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)
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.2"
3
3
  end
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.0
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