chronos-ruby 0.9.0.pre.4 → 1.1.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +108 -197
  4. data/contracts/apm-batch-v1.schema.json +51 -1
  5. data/docs/adr/ADR-007-feature-detection.md +25 -0
  6. data/docs/adr/ADR-008-context-store.md +25 -0
  7. data/docs/adr/ADR-009-sampling.md +25 -0
  8. data/docs/adr/ADR-010-opentelemetry-interoperability.md +25 -0
  9. data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
  10. data/docs/adr/ADR-018-pre-1.0-hardening.md +6 -2
  11. data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
  12. data/docs/architecture.md +5 -2
  13. data/docs/compatibility.md +30 -23
  14. data/docs/configuration.md +21 -0
  15. data/docs/data-collected.md +9 -3
  16. data/docs/deprecation-policy.md +1 -1
  17. data/docs/examples/plain-ruby.md +6 -0
  18. data/docs/migration-from-airbrake.md +1 -1
  19. data/docs/modules/apm-aggregation.md +18 -5
  20. data/docs/modules/breadcrumbs.md +23 -0
  21. data/docs/modules/context.md +21 -0
  22. data/docs/modules/deploys.md +23 -0
  23. data/docs/modules/job-monitoring.md +22 -0
  24. data/docs/modules/request-monitoring.md +20 -0
  25. data/docs/modules/runtime-metrics.md +22 -0
  26. data/docs/modules/sampling.md +22 -0
  27. data/docs/modules/sidekiq-legacy.md +1 -1
  28. data/docs/modules/sql-monitoring.md +76 -0
  29. data/docs/modules/telemetry-events.md +1 -1
  30. data/docs/performance.md +30 -3
  31. data/docs/privacy-lgpd.md +6 -3
  32. data/docs/protocol-v1.md +2 -2
  33. data/docs/release-1.0-readiness.md +17 -15
  34. data/docs/release-1.1-readiness.md +51 -0
  35. data/docs/security-review.md +7 -3
  36. data/docs/troubleshooting.md +6 -0
  37. data/lib/chronos/agent.rb +12 -2
  38. data/lib/chronos/application/apm_aggregator.rb +179 -29
  39. data/lib/chronos/configuration/apm_validation.rb +51 -1
  40. data/lib/chronos/configuration.rb +17 -1
  41. data/lib/chronos/core/metric_aggregate.rb +69 -7
  42. data/lib/chronos/core/sql_query_analyzer.rb +309 -0
  43. data/lib/chronos/ports/query_inspector.rb +23 -0
  44. data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
  45. data/lib/chronos/rails/notifications_subscriber.rb +163 -2
  46. data/lib/chronos/rails.rb +1 -0
  47. data/lib/chronos/version.rb +1 -1
  48. data/lib/chronos.rb +2 -0
  49. metadata +21 -4
@@ -0,0 +1,309 @@
1
+ module Chronos
2
+ module Core
3
+ # Derives bounded query-shape evidence and index recommendations from normalized SQL.
4
+ #
5
+ # @responsibility Extract tables and access columns, compare index metadata, and emit diagnostics.
6
+ # @motivation Give the Chronos consumer actionable SQL evidence without collecting binds or raw SQL.
7
+ # @limits It is a defensive heuristic for SELECT statements, not a dialect-complete parser or optimizer.
8
+ # @collaborators SqlNormalizer and an optional query-inspection adapter.
9
+ # @thread_safety Instances are stateless and safe to share.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails and database drivers.
11
+ # @example
12
+ # SqlQueryAnalyzer.new.call("normalized_query" => "SELECT * FROM users WHERE email = ?")
13
+ # @errors Malformed input returns an empty bounded analysis and never escapes.
14
+ # @performance Input, tables, columns, candidates, diagnostics, and inspection evidence are capped.
15
+ class SqlQueryAnalyzer # rubocop:disable Metrics/ClassLength
16
+ MAX_TABLES = 8
17
+ MAX_COLUMNS = 12
18
+ MAX_INDEXES = 20
19
+ MAX_DIAGNOSTICS = 20
20
+ IDENTIFIER = "[A-Za-z_][A-Za-z0-9_$]*".freeze
21
+ TABLE_PATTERN = /\b(?:FROM|JOIN)\s+["`\[]?(#{IDENTIFIER}(?:\.#{IDENTIFIER})?)["`\]]?
22
+ (?:\s+(?:AS\s+)?(#{IDENTIFIER}))?/ix
23
+ CLAUSE_END = /\b(?:GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION|RETURNING)\b/i
24
+ RESERVED = %w(WHERE JOIN LEFT RIGHT INNER OUTER FULL CROSS ON GROUP ORDER HAVING LIMIT OFFSET).freeze
25
+
26
+ def call(query, inspection = {})
27
+ data = hash(query)
28
+ normalized = bounded(data["normalized_query"] || data[:normalized_query], 512)
29
+ operation = (data["operation"] || data[:operation]).to_s.upcase
30
+ return empty_analysis(operation) unless operation == "SELECT" && !normalized.empty?
31
+
32
+ aliases = table_aliases(normalized, data["table"] || data[:table])
33
+ access = access_columns(normalized, aliases)
34
+ candidates = index_candidates(access, hash(inspection))
35
+ evidence = inspection_evidence(inspection)
36
+ {
37
+ "operation" => operation,
38
+ "tables" => aliases.values.uniq.first(MAX_TABLES),
39
+ "access_columns" => access,
40
+ "index_candidates" => candidates,
41
+ "inspection" => evidence,
42
+ "diagnostics" => diagnostics(candidates, evidence)
43
+ }
44
+ rescue StandardError
45
+ empty_analysis("UNKNOWN")
46
+ end
47
+
48
+ private
49
+
50
+ def empty_analysis(operation)
51
+ {
52
+ "operation" => operation.to_s.empty? ? "UNKNOWN" : operation.to_s,
53
+ "tables" => [], "access_columns" => {}, "index_candidates" => [],
54
+ "inspection" => {}, "diagnostics" => []
55
+ }
56
+ end
57
+
58
+ def table_aliases(sql, fallback)
59
+ result = {}
60
+ sql.scan(TABLE_PATTERN) do |table, name|
61
+ alias_name = name.to_s
62
+ alias_name = table.to_s.split(".").last if alias_name.empty? || RESERVED.include?(alias_name.upcase)
63
+ result[alias_name] = bounded_identifier(table)
64
+ break if result.length >= MAX_TABLES
65
+ end
66
+ if result.empty? && !fallback.to_s.empty?
67
+ table = bounded_identifier(fallback)
68
+ result[table.split(".").last] = table unless table.empty?
69
+ end
70
+ result
71
+ end
72
+
73
+ def access_columns(sql, aliases)
74
+ result = {}
75
+ aliases.values.uniq.each do |table|
76
+ result[table] = {"equality" => [], "range" => [], "join" => [], "order" => []}
77
+ end
78
+ base_table = aliases.values.first
79
+ where = clause(sql, /\bWHERE\b/i, CLAUSE_END)
80
+ collect_predicates(where, aliases, base_table, result)
81
+ collect_join_predicates(sql, aliases, result)
82
+ collect_order_columns(clause(sql, /\bORDER\s+BY\b/i, /\b(?:LIMIT|OFFSET|UNION|RETURNING)\b/i), aliases,
83
+ base_table, result)
84
+ result.delete_if { |_table, groups| groups.values.all?(&:empty?) }
85
+ end
86
+
87
+ def collect_predicates(section, aliases, base_table, result)
88
+ return if section.empty?
89
+
90
+ pattern = /(?:(#{IDENTIFIER})\.)?(#{IDENTIFIER})\s*(=|<>|!=|<=|>=|<|>|\bIN\b|\bIS\b|\bLIKE\b)/i
91
+ section.scan(pattern).each do |alias_name, column, operator|
92
+ table = alias_name.to_s.empty? ? base_table : aliases[alias_name]
93
+ next unless table && result[table]
94
+
95
+ group = ["=", "IN", "IS"].include?(operator.to_s.upcase) ? "equality" : "range"
96
+ append_column(result[table][group], column)
97
+ end
98
+ end
99
+
100
+ def collect_join_predicates(sql, aliases, result)
101
+ sql.scan(/\bON\b(.*?)(?=\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET)\b|\z)/im) do |match|
102
+ match.first.to_s.scan(/(#{IDENTIFIER})\.(#{IDENTIFIER})/i).each do |alias_name, column|
103
+ table = aliases[alias_name]
104
+ append_column(result[table]["join"], column) if table && result[table]
105
+ end
106
+ end
107
+ end
108
+
109
+ def collect_order_columns(section, aliases, base_table, result)
110
+ section.split(",").first(MAX_COLUMNS).each do |item|
111
+ match = item.match(/(?:(#{IDENTIFIER})\.)?(#{IDENTIFIER})(?:\s+(?:ASC|DESC))?/i)
112
+ next unless match
113
+
114
+ table = match[1].to_s.empty? ? base_table : aliases[match[1]]
115
+ append_column(result[table]["order"], match[2]) if table && result[table]
116
+ end
117
+ end
118
+
119
+ def index_candidates(access, inspection)
120
+ indexes = Array(inspection["indexes"] || inspection[:indexes]).first(MAX_INDEXES)
121
+ access.map do |table, groups|
122
+ columns = (groups["equality"] + groups["join"] + groups["range"] + groups["order"]).uniq.first(MAX_COLUMNS)
123
+ next if columns.empty?
124
+
125
+ covering = indexes.find { |index| index_covers?(index, table, columns) }
126
+ {
127
+ "table" => table, "columns" => columns,
128
+ "status" => candidate_status(indexes, covering),
129
+ "covered_by" => covering ? bounded(hash(covering)["name"] || hash(covering)[:name], 128) : nil,
130
+ "confidence" => candidate_confidence(inspection, covering)
131
+ }.delete_if { |_key, value| value.nil? || value == "" }
132
+ end.compact.first(MAX_TABLES)
133
+ end
134
+
135
+ def candidate_status(indexes, covering)
136
+ return "unverified" if indexes.empty?
137
+
138
+ covering ? "covered" : "missing"
139
+ end
140
+
141
+ def index_covers?(index, table, columns)
142
+ data = hash(index)
143
+ return false unless (data["table"] || data[:table]).to_s == table.to_s
144
+
145
+ existing = Array(data["columns"] || data[:columns]).map(&:to_s)
146
+ existing.first(columns.length) == columns
147
+ end
148
+
149
+ def candidate_confidence(inspection, covering)
150
+ return "high" if covering
151
+
152
+ plan = hash(hash(inspection)["plan"] || hash(inspection)[:plan])
153
+ return "high" if plan["sequential_scan"] == true || plan[:sequential_scan] == true
154
+ return "medium" unless Array(hash(inspection)["indexes"] || hash(inspection)[:indexes]).empty?
155
+
156
+ "low"
157
+ end
158
+
159
+ def inspection_evidence(value)
160
+ data = hash(value)
161
+ result = {"indexes" => bounded_indexes(data["indexes"] || data[:indexes])}
162
+ statistics = hash(data["statistics"] || data[:statistics])
163
+ result["statistics"] = bounded_statistics(statistics) unless statistics.empty?
164
+ plan = hash(data["plan"] || data[:plan])
165
+ result["plan"] = bounded_plan(plan) unless plan.empty?
166
+ errors = bounded_errors(data["errors"] || data[:errors])
167
+ result["errors"] = errors unless errors.empty?
168
+ result
169
+ end
170
+
171
+ def bounded_indexes(values)
172
+ Array(values).first(MAX_INDEXES).map do |index|
173
+ item = hash(index)
174
+ columns = Array(item["columns"] || item[:columns]).first(MAX_COLUMNS)
175
+ {
176
+ "table" => bounded_identifier(item["table"] || item[:table]),
177
+ "name" => bounded(item["name"] || item[:name], 128),
178
+ "columns" => columns.map { |column| bounded_identifier(column) },
179
+ "unique" => item["unique"] == true || item[:unique] == true
180
+ }
181
+ end
182
+ end
183
+
184
+ def bounded_errors(values)
185
+ Array(values).first(5).map { |error| bounded(error, 128) }
186
+ end
187
+
188
+ def bounded_statistics(statistics)
189
+ {
190
+ "estimated_rows" => non_negative_integer(statistics["estimated_rows"] || statistics[:estimated_rows]),
191
+ "source" => bounded(statistics["source"] || statistics[:source], 64)
192
+ }.delete_if { |_key, value| value.nil? || value == "" }
193
+ end
194
+
195
+ def bounded_plan(plan)
196
+ {
197
+ "nodes" => Array(plan["nodes"] || plan[:nodes]).first(20).map do |node|
198
+ bounded_plan_node(node)
199
+ end,
200
+ "uses_index" => plan["uses_index"] == true || plan[:uses_index] == true,
201
+ "sequential_scan" => plan["sequential_scan"] == true || plan[:sequential_scan] == true
202
+ }
203
+ end
204
+
205
+ def bounded_plan_node(node)
206
+ data = hash(node)
207
+ {
208
+ "type" => bounded(data["type"] || data[:type], 64),
209
+ "table" => bounded_identifier(data["table"] || data[:table]),
210
+ "index" => bounded(data["index"] || data[:index], 128),
211
+ "estimated_rows" => non_negative_integer(data["estimated_rows"] || data[:estimated_rows]),
212
+ "total_cost" => non_negative_number(data["total_cost"] || data[:total_cost])
213
+ }.delete_if { |_key, child| child.nil? || child == "" }
214
+ end
215
+
216
+ def diagnostics(candidates, inspection)
217
+ result = [diagnostic("query_pattern_analyzed", "info", "query_pattern", {},
218
+ "Normalized SELECT access pattern was analyzed")]
219
+ candidates.each do |candidate|
220
+ status = candidate["status"]
221
+ code = index_diagnostic_code(status)
222
+ severity = status == "covered" ? "info" : "suggestion"
223
+ result << diagnostic(code, severity, "index", candidate, index_diagnostic_message(status))
224
+ end
225
+ plan = hash(inspection["plan"])
226
+ if plan["sequential_scan"] == true
227
+ result << diagnostic("sequential_scan", "warning", "plan", {},
228
+ "The bounded query plan contains a sequential scan")
229
+ end
230
+ Array(inspection["errors"]).each do |error|
231
+ result << diagnostic("query_inspection_failed", "error", "inspection", {"error_class" => error},
232
+ "Read-only query inspection failed")
233
+ end
234
+ result.first(MAX_DIAGNOSTICS)
235
+ end
236
+
237
+ def index_diagnostic_code(status)
238
+ return "missing_index_candidate" if status == "missing"
239
+ return "index_covered" if status == "covered"
240
+
241
+ "index_candidate"
242
+ end
243
+
244
+ def index_diagnostic_message(status)
245
+ return "Existing index covers the observed access pattern" if status == "covered"
246
+
247
+ "Review a candidate index for the observed access pattern"
248
+ end
249
+
250
+ def diagnostic(code, severity, category, evidence, message)
251
+ result = {
252
+ "code" => code, "severity" => severity, "category" => category,
253
+ "message" => message, "evidence" => evidence
254
+ }
255
+ if severity == "suggestion"
256
+ result["recommendation"] = "Validate selectivity and write cost before creating the candidate index"
257
+ end
258
+ result
259
+ end
260
+
261
+ def clause(sql, start_pattern, finish_pattern)
262
+ start = sql.index(start_pattern)
263
+ return "" unless start
264
+
265
+ tail = sql[start..-1].to_s.sub(start_pattern, "")
266
+ finish = tail.index(finish_pattern)
267
+ finish ? tail[0...finish] : tail
268
+ end
269
+
270
+ def append_column(collection, value)
271
+ column = bounded_identifier(value)
272
+ collection << column if !column.empty? && !collection.include?(column) && collection.length < MAX_COLUMNS
273
+ end
274
+
275
+ def bounded_identifier(value)
276
+ bounded(value, 128).gsub(/[^A-Za-z0-9_.$-]/, "")
277
+ end
278
+
279
+ def bounded(value, limit)
280
+ text = value.to_s
281
+ text = text.scrub("?") if text.respond_to?(:scrub)
282
+ text.bytesize > limit ? text.byteslice(0, limit).to_s : text
283
+ rescue StandardError
284
+ ""
285
+ end
286
+
287
+ def non_negative_integer(value)
288
+ return nil if value.nil?
289
+
290
+ [value.to_i, 0].max
291
+ rescue StandardError
292
+ nil
293
+ end
294
+
295
+ def non_negative_number(value)
296
+ return nil if value.nil?
297
+
298
+ number = value.to_f
299
+ number < 0.0 ? 0.0 : number
300
+ rescue StandardError
301
+ nil
302
+ end
303
+
304
+ def hash(value)
305
+ value.is_a?(Hash) ? value : {}
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,23 @@
1
+ module Chronos
2
+ module Ports
3
+ # Documents the optional read-only database-inspection boundary used by SQL monitoring.
4
+ #
5
+ # @responsibility Define compatibility for index, statistics, and plan inspection adapters.
6
+ # @motivation Keep database and ActiveRecord behavior outside the framework-independent core.
7
+ # @limits It does not require inspection, execute DDL, or prescribe a database driver.
8
+ # @thread_safety Implementations define their own connection and concurrency guarantees.
9
+ # @compatibility The compatibility check uses APIs available in Ruby 2.2.10.
10
+ # @example
11
+ # QueryInspector.compatible?(adapter) #=> true
12
+ # @errors Compatibility checks contain objects with failing reflection methods.
13
+ module QueryInspector
14
+ REQUIRED_METHODS = [:call].freeze
15
+
16
+ def self.compatible?(object)
17
+ REQUIRED_METHODS.all? { |method_name| object.respond_to?(method_name) }
18
+ rescue StandardError
19
+ false
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,235 @@
1
+ require "json"
2
+
3
+ module Chronos
4
+ module Rails
5
+ # Performs bounded, read-only ActiveRecord index, statistics, and query-plan inspection.
6
+ #
7
+ # @responsibility Read public schema metadata and optional non-executing SELECT plans.
8
+ # @motivation Add database evidence to query diagnostics without exposing raw SQL or bind values.
9
+ # @limits It never runs EXPLAIN ANALYZE or DDL; support is best-effort and explicitly opt-in.
10
+ # @collaborators An ActiveRecord connection supplied by sql.active_record notifications.
11
+ # @thread_safety Calls use the notification connection and a thread-local recursion guard.
12
+ # @compatibility Feature-detected Rails 4.2/5.2 connections for PostgreSQL and MySQL-family adapters.
13
+ # @example
14
+ # inspector.call(sql, query_metadata, :plan => false)
15
+ # @errors Adapter failures become bounded error-class evidence and never escape.
16
+ # @performance Each uncached inspection adds schema/statistics queries and optionally EXPLAIN.
17
+ class ActiveRecordQueryInspector
18
+ GUARD_KEY = :__chronos_query_inspection
19
+ MAX_PLAN_NODES = 20
20
+
21
+ def self.suppressed?
22
+ Thread.current[GUARD_KEY] == true
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ def call(raw_sql, query, options = {})
28
+ connection = options[:connection]
29
+ return {} unless eligible?(raw_sql, query, connection)
30
+
31
+ previous = Thread.current[GUARD_KEY]
32
+ Thread.current[GUARD_KEY] = true
33
+ guarded = true
34
+ result = {"errors" => []}
35
+ capture(result) { result["indexes"] = indexes(connection, table(query)) }
36
+ if options[:statistics] == true
37
+ capture(result) { result["statistics"] = statistics(connection, table(query)) }
38
+ end
39
+ if options[:plan] == true
40
+ capture(result) do
41
+ plan_result = plan(connection, raw_sql, adapter_name(connection))
42
+ result["errors"].concat(Array(plan_result.delete("errors")))
43
+ result["plan"] = plan_result
44
+ end
45
+ end
46
+ result.delete("errors") if result["errors"].empty?
47
+ result
48
+ rescue StandardError => error
49
+ {"errors" => [safe_error_class(error)]}
50
+ ensure
51
+ Thread.current[GUARD_KEY] = previous if guarded
52
+ end
53
+
54
+ private
55
+
56
+ def capture(result)
57
+ yield
58
+ rescue StandardError => error
59
+ result["errors"] << safe_error_class(error) if result["errors"].length < 5
60
+ end
61
+
62
+ def eligible?(raw_sql, query, connection)
63
+ return false unless connection
64
+ return false unless (query["operation"] || query[:operation]).to_s == "SELECT"
65
+ return false if table(query).empty?
66
+ return false unless raw_sql.to_s.lstrip =~ /\ASELECT\b/i
67
+
68
+ normalized = (query["normalized_query"] || query[:normalized_query]).to_s
69
+ normalized.sub(/;\s*\z/, "") !~ /;/
70
+ end
71
+
72
+ def indexes(connection, table_name)
73
+ return [] unless connection.respond_to?(:indexes)
74
+
75
+ Array(connection.indexes(table_name)).first(20).map do |index|
76
+ {
77
+ "table" => bounded_identifier(table_name), "name" => bounded(value(index, :name), 128),
78
+ "columns" => Array(value(index, :columns)).first(12).map { |column| bounded_identifier(column) },
79
+ "unique" => value(index, :unique) == true
80
+ }
81
+ end
82
+ end
83
+
84
+ def statistics(connection, table_name)
85
+ adapter = adapter_name(connection)
86
+ if adapter =~ /postgres/i
87
+ row = first_row(connection, "SELECT reltuples AS estimated_rows FROM pg_class WHERE oid = " \
88
+ "#{quote(connection, table_name)}::regclass")
89
+ return {"estimated_rows" => numeric(row, "estimated_rows"), "source" => "pg_class"}
90
+ end
91
+ if adapter =~ /mysql|trilogy/i
92
+ sql = "SELECT table_rows AS estimated_rows FROM information_schema.tables " \
93
+ "WHERE table_schema = DATABASE() AND table_name = #{quote(connection, table_name)}"
94
+ row = first_row(connection, sql)
95
+ return {"estimated_rows" => numeric(row, "estimated_rows"), "source" => "information_schema"}
96
+ end
97
+
98
+ {}
99
+ end
100
+
101
+ def plan(connection, raw_sql, adapter)
102
+ return {} unless connection.respond_to?(:select_all)
103
+
104
+ if adapter =~ /postgres/i
105
+ rows = rows(connection.select_all("EXPLAIN (FORMAT JSON) #{raw_sql}"))
106
+ return postgres_plan(rows)
107
+ end
108
+ if adapter =~ /mysql|trilogy/i
109
+ return mysql_plan(rows(connection.select_all("EXPLAIN #{raw_sql}")))
110
+ end
111
+
112
+ {}
113
+ end
114
+
115
+ def postgres_plan(rows_value)
116
+ raw = rows_value.first || {}
117
+ document = raw["QUERY PLAN"] || raw["query_plan"] || raw.values.first
118
+ parsed = document.is_a?(String) ? JSON.parse(document) : document
119
+ root = Array(parsed).first
120
+ root = root["Plan"] if root.is_a?(Hash) && root["Plan"]
121
+ nodes = []
122
+ collect_postgres_nodes(root, nodes)
123
+ summarize_plan(nodes)
124
+ rescue StandardError => error
125
+ {"nodes" => [], "uses_index" => false, "sequential_scan" => false,
126
+ "errors" => [safe_error_class(error)]}
127
+ end
128
+
129
+ def collect_postgres_nodes(node, result)
130
+ return unless node.is_a?(Hash) && result.length < MAX_PLAN_NODES
131
+
132
+ result << {
133
+ "type" => bounded(node["Node Type"], 64),
134
+ "table" => bounded_identifier(node["Relation Name"]),
135
+ "index" => bounded(node["Index Name"], 128),
136
+ "estimated_rows" => integer(node["Plan Rows"]),
137
+ "total_cost" => number(node["Total Cost"])
138
+ }.delete_if { |_key, child| child.nil? || child == "" }
139
+ Array(node["Plans"]).each { |child| collect_postgres_nodes(child, result) }
140
+ end
141
+
142
+ def mysql_plan(rows_value)
143
+ nodes = rows_value.first(MAX_PLAN_NODES).map do |row|
144
+ {
145
+ "type" => bounded(row["type"] || row[:type] || row["select_type"], 64),
146
+ "table" => bounded_identifier(row["table"] || row[:table]),
147
+ "index" => bounded(row["key"] || row[:key], 128),
148
+ "estimated_rows" => integer(row["rows"] || row[:rows])
149
+ }.delete_if { |_key, child| child.nil? || child == "" }
150
+ end
151
+ summarize_plan(nodes)
152
+ end
153
+
154
+ def summarize_plan(nodes)
155
+ types = nodes.map { |node| node["type"].to_s }
156
+ {
157
+ "nodes" => nodes,
158
+ "uses_index" => nodes.any? { |node| !node["index"].to_s.empty? || node["type"].to_s =~ /Index/i },
159
+ "sequential_scan" => types.any? { |type| type =~ /Seq Scan|ALL/i }
160
+ }
161
+ end
162
+
163
+ def first_row(connection, sql)
164
+ return {} unless connection.respond_to?(:select_all)
165
+
166
+ rows(connection.select_all(sql)).first || {}
167
+ end
168
+
169
+ def rows(value)
170
+ return value.to_a if value.respond_to?(:to_a)
171
+
172
+ Array(value)
173
+ rescue StandardError
174
+ []
175
+ end
176
+
177
+ def quote(connection, value)
178
+ return connection.quote(value.to_s) if connection.respond_to?(:quote)
179
+
180
+ "'#{value.to_s.gsub("'", "''")}'"
181
+ end
182
+
183
+ def adapter_name(connection)
184
+ connection.respond_to?(:adapter_name) ? connection.adapter_name.to_s : ""
185
+ rescue StandardError
186
+ ""
187
+ end
188
+
189
+ def table(query)
190
+ bounded_identifier(query["table"] || query[:table])
191
+ end
192
+
193
+ def value(object, method_name)
194
+ object.respond_to?(method_name) ? object.public_send(method_name) : nil
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ def numeric(row, key)
200
+ value = row[key] || row[key.to_sym]
201
+ integer(value)
202
+ end
203
+
204
+ def integer(value)
205
+ value.nil? ? nil : [value.to_i, 0].max
206
+ rescue StandardError
207
+ nil
208
+ end
209
+
210
+ def number(value)
211
+ value.nil? ? nil : [value.to_f, 0.0].max
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def safe_error_class(error)
217
+ bounded(error.class.name.to_s, 128)
218
+ rescue StandardError
219
+ "StandardError"
220
+ end
221
+
222
+ def bounded_identifier(value)
223
+ bounded(value, 128).gsub(/[^A-Za-z0-9_.$-]/, "")
224
+ end
225
+
226
+ def bounded(value, limit)
227
+ text = value.to_s
228
+ text = text.scrub("?") if text.respond_to?(:scrub)
229
+ text.bytesize > limit ? text.byteslice(0, limit).to_s : text
230
+ rescue StandardError
231
+ ""
232
+ end
233
+ end
234
+ end
235
+ end