Class: QueryGuard::Explain::PlanNode

Inherits:
Object
  • Object
show all
Defined in:
lib/query_guard/explain/plan_signals.rb

Overview

Represents a single node in the query plan tree

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data, depth = 0) ⇒ PlanNode

Returns a new instance of PlanNode.



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/query_guard/explain/plan_signals.rb', line 14

def initialize(data, depth = 0)
  @raw_data = data
  @node_type = data.dig("Node Type") || "Unknown"  # Seq Scan, Index Scan, etc.
  @relation_name = data.dig("Relation Name")       # users, orders, etc.
  @index_name = data.dig("Index Name")             # idx_users_email, etc.
  @depth = depth

  # Estimated metrics (from planner)
  @estimated_rows = data.dig("Estimated Rows") || 0
  @estimated_cost = data.dig("Total Cost") || 0.0

  # Actual metrics (only with ANALYZE)
  @actual_rows = data.dig("Actual Rows")
  @actual_duration_ms = data.dig("Actual Total Time")

  # Plan details
  @filter = data.dig("Filter")                      # WHERE condition
  @sort_key = data.dig("Sort Key")                  # ORDER BY clause

  # Recursively process child plans
  child_plans = data.dig("Plans") || []
  @children = child_plans.map { |child| PlanNode.new(child, depth + 1) }
end

Instance Attribute Details

#actual_duration_ms ⇒ Object (readonly)

Returns the value of attribute actual_duration_ms.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def actual_duration_ms
  @actual_duration_ms
end

#actual_rows ⇒ Object (readonly)

Returns the value of attribute actual_rows.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def actual_rows
  @actual_rows
end

#children ⇒ Object (readonly)

Returns the value of attribute children.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def children
  @children
end

#depth ⇒ Object (readonly)

Returns the value of attribute depth.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def depth
  @depth
end

#estimated_cost ⇒ Object (readonly)

Returns the value of attribute estimated_cost.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def estimated_cost
  @estimated_cost
end

#estimated_rows ⇒ Object (readonly)

Returns the value of attribute estimated_rows.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def estimated_rows
  @estimated_rows
end

#filter ⇒ Object (readonly)

Returns the value of attribute filter.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def filter
  @filter
end

#index_name ⇒ Object (readonly)

Returns the value of attribute index_name.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def index_name
  @index_name
end

#node_type ⇒ Object (readonly)

Returns the value of attribute node_type.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def node_type
  @node_type
end

#relation_name ⇒ Object (readonly)

Returns the value of attribute relation_name.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def relation_name
  @relation_name
end

#sort_key ⇒ Object (readonly)

Returns the value of attribute sort_key.



10
11
12
# File 'lib/query_guard/explain/plan_signals.rb', line 10

def sort_key
  @sort_key
end

Instance Method Details

#estimate_accuracy_ratio ⇒ Float?

Estimate quality: ratio of actual vs estimated rows Returns nil if no actual execution data

Returns:

  • (Float, nil) —

    Actual rows / Estimated rows (or nil)



75
76
77
78
79
# File 'lib/query_guard/explain/plan_signals.rb', line 75

def estimate_accuracy_ratio
  return nil if actual_rows.nil? || estimated_rows.zero?

  (actual_rows.to_f / estimated_rows).round(2)
end

#estimate_inaccurate?(threshold = 10) ⇒ Boolean

Check if estimates were wildly inaccurate

Parameters:

  • threshold (Float) (defaults to: 10) —

    How many times off is "bad"? (default 10x)

Returns:

  • (Boolean)


85
86
87
88
89
90
# File 'lib/query_guard/explain/plan_signals.rb', line 85

def estimate_inaccurate?(threshold = 10)
  ratio = estimate_accuracy_ratio
  return false if ratio.nil?

  ratio > threshold || ratio < (1.0 / threshold)
end

#index_scan? ⇒ Boolean

Check if this node uses an index

Returns:

  • (Boolean)


48
49
50
# File 'lib/query_guard/explain/plan_signals.rb', line 48

def index_scan?
  node_type.include?("Index")
end

#nodes_of_type(type) ⇒ Array<PlanNode>

Find all nodes of given type

Parameters:

  • type (String) —

    Node type to search for

Returns:



65
66
67
68
69
# File 'lib/query_guard/explain/plan_signals.rb', line 65

def nodes_of_type(type)
  nodes = node_type == type ? [self] : []
  children.each { |child| nodes.concat(child.nodes_of_type(type)) }
  nodes
end

#sequential_scan? ⇒ Boolean

Check if this node performs a sequential scan

Returns:

  • (Boolean)


41
42
43
# File 'lib/query_guard/explain/plan_signals.rb', line 41

def sequential_scan?
  node_type == "Seq Scan"
end

#sequential_scans ⇒ Array<PlanNode>

Find all sequential scans in plan tree

Returns:



55
56
57
58
59
# File 'lib/query_guard/explain/plan_signals.rb', line 55

def sequential_scans
  scans = sequential_scan? ? [self] : []
  children.each { |child| scans.concat(child.sequential_scans) }
  scans
end

#to_h ⇒ Hash

Convert to hash for serialization

Returns:

  • (Hash)


105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/query_guard/explain/plan_signals.rb', line 105

def to_h
  {
    node_type: node_type,
    relation_name: relation_name,
    index_name: index_name,
    depth: depth,
    estimated_rows: estimated_rows,
    estimated_cost: estimated_cost,
    actual_rows: actual_rows,
    actual_duration_ms: actual_duration_ms,
    is_sequential_scan: sequential_scan?,
    is_index_scan: index_scan?,
    estimate_friendly: estimate_accuracy_ratio,
    children: children.map(&:to_h)
  }
end

#to_s ⇒ String

Human-readable node description

Returns:

  • (String)


95
96
97
98
99
100
# File 'lib/query_guard/explain/plan_signals.rb', line 95

def to_s
  parts = [node_type]
  parts << "on #{relation_name}" if relation_name
  parts << "using #{index_name}" if index_name
  parts.join(" ")
end