pg_canary 0.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +114 -0
  5. data/demo.png +0 -0
  6. data/lib/pg_canary/configuration.rb +80 -0
  7. data/lib/pg_canary/detector.rb +94 -0
  8. data/lib/pg_canary/middleware.rb +115 -0
  9. data/lib/pg_canary/pg_query_support.rb +115 -0
  10. data/lib/pg_canary/rules/base.rb +77 -0
  11. data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +75 -0
  12. data/lib/pg_canary/rules/definitions/cartesian_join.rb +108 -0
  13. data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +80 -0
  14. data/lib/pg_canary/rules/definitions/count_star_without_where.rb +57 -0
  15. data/lib/pg_canary/rules/definitions/deep_offset.rb +63 -0
  16. data/lib/pg_canary/rules/definitions/distinct_with_join.rb +43 -0
  17. data/lib/pg_canary/rules/definitions/function_on_column.rb +89 -0
  18. data/lib/pg_canary/rules/definitions/huge_in_list.rb +74 -0
  19. data/lib/pg_canary/rules/definitions/implicit_cast.rb +79 -0
  20. data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +108 -0
  21. data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +81 -0
  22. data/lib/pg_canary/rules/definitions/not_in_subquery.rb +69 -0
  23. data/lib/pg_canary/rules/definitions/or_across_columns.rb +69 -0
  24. data/lib/pg_canary/rules/definitions/order_by_random.rb +41 -0
  25. data/lib/pg_canary/rules/definitions/query_complexity.rb +65 -0
  26. data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +70 -0
  27. data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +73 -0
  28. data/lib/pg_canary/rules/definitions/unindexed_join.rb +85 -0
  29. data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +51 -0
  30. data/lib/pg_canary/rules/definitions/unindexed_where.rb +95 -0
  31. data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +30 -0
  32. data/lib/pg_canary/rules/detection.rb +21 -0
  33. data/lib/pg_canary/rules/index_predicates.rb +39 -0
  34. data/lib/pg_canary/rules/query_context.rb +147 -0
  35. data/lib/pg_canary/schema_introspection.rb +72 -0
  36. data/lib/pg_canary/subscriber.rb +41 -0
  37. data/lib/pg_canary/version.rb +5 -0
  38. data/lib/pg_canary.rb +77 -0
  39. metadata +126 -0
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Searching a jsonb column in WHERE:
6
+ # - containment/existence operators (@>, <@, ?, ?|, ?&) need a GIN index
7
+ # on the column
8
+ # - key extraction (->> etc.) compared against a value needs a matching
9
+ # expression index
10
+ class JsonbSearchWithoutGin < Base
11
+ include IndexPredicates
12
+
13
+ def default_enabled
14
+ true
15
+ end
16
+
17
+ def size_dependent?
18
+ false
19
+ end
20
+
21
+ CONTAINMENT_OPS = %w[@> <@ ? ?| ?&].freeze
22
+ EXTRACTION_OPS = %w[-> ->> #> #>>].freeze
23
+
24
+ def check(query)
25
+ detections = []
26
+ query.each_scope do |scope|
27
+ next unless scope.where_clause
28
+
29
+ walk_within_scope(scope.where_clause) do |node|
30
+ next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP
31
+
32
+ operator = operator_name(node)
33
+ if CONTAINMENT_OPS.include?(operator)
34
+ detections << inspect_containment(query, scope, node, operator)
35
+ elsif EXTRACTION_OPS.include?(operator)
36
+ detections << inspect_extraction(query, scope, node, operator)
37
+ end
38
+ end
39
+ end
40
+ detections.compact
41
+ end
42
+
43
+ private
44
+
45
+ def inspect_containment(query, scope, expr, operator)
46
+ table, column = jsonb_column(query, scope, [expr.lexpr, expr.rexpr])
47
+ return nil unless table
48
+ return nil if gin_index_on?(query, table, column)
49
+
50
+ detection(
51
+ query,
52
+ table: table,
53
+ columns: column,
54
+ message: "#{operator} search on jsonb column #{table}.#{column} has no GIN index " \
55
+ "and will scan every row in production.",
56
+ suggestion: <<~SUGGESTION.chomp
57
+ Consider a GIN index (jsonb_path_ops is smaller/faster if you only use @>):
58
+ CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
59
+ SUGGESTION
60
+ )
61
+ end
62
+
63
+ def inspect_extraction(query, scope, expr, operator)
64
+ table, column = jsonb_column(query, scope, [expr.lexpr])
65
+ return nil unless table
66
+ return nil if expression_index_referencing?(query, table, column)
67
+
68
+ key = extraction_key(expr)
69
+ expr_sql = "#{column} #{operator} #{key ? "'#{key}'" : "..."}"
70
+ detection(
71
+ query,
72
+ table: table,
73
+ columns: column,
74
+ message: "Filtering on #{table}.#{expr_sql} has no matching expression index, " \
75
+ "so no index can serve this predicate.",
76
+ suggestion: <<~SUGGESTION.chomp
77
+ Consider an expression index on the extracted key:
78
+ CREATE INDEX index_#{table}_on_#{column}_key ON #{table} ((#{expr_sql.sub("...", "'key'")}));
79
+ SUGGESTION
80
+ )
81
+ end
82
+
83
+ # First side that is a jsonb-typed, resolvable column.
84
+ def jsonb_column(query, scope, sides)
85
+ sides.each do |side|
86
+ column_ref = strip_type_casts(side)
87
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
88
+
89
+ table, column = scope.resolve(column_ref)
90
+ next unless table && column
91
+ next unless applicable_table?(query, table)
92
+ next unless query.column_type(table, column) == "jsonb"
93
+
94
+ return [table, column]
95
+ end
96
+ nil
97
+ end
98
+
99
+ def extraction_key(expr)
100
+ const = strip_type_casts(expr.rexpr)
101
+ return nil unless const.is_a?(PgQuery::A_Const)
102
+
103
+ value = constant_value(const)
104
+ value.is_a?(String) ? value : nil
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # LIKE / ILIKE with a leading wildcard ('%foo', '%foo%') cannot use a
6
+ # btree index regardless of table size. Only a pg_trgm GIN/GiST index
7
+ # helps, so we stay silent when one exists on the column.
8
+ # Bind parameters ($1) are resolved from the event's binds.
9
+ class LeadingWildcardLike < Base
10
+ include IndexPredicates
11
+
12
+ def default_enabled
13
+ true
14
+ end
15
+
16
+ def size_dependent?
17
+ false
18
+ end
19
+
20
+ LIKE_KINDS = %i[AEXPR_LIKE AEXPR_ILIKE].freeze
21
+
22
+ def check(query)
23
+ detections = []
24
+ query.each_scope do |scope|
25
+ next unless scope.where_clause
26
+
27
+ walk_within_scope(scope.where_clause) do |node|
28
+ detections << inspect_expr(query, scope, node) if like_expr?(node)
29
+ end
30
+ end
31
+ detections.compact
32
+ end
33
+
34
+ private
35
+
36
+ def like_expr?(node)
37
+ node.is_a?(PgQuery::A_Expr) && LIKE_KINDS.include?(node.kind)
38
+ end
39
+
40
+ def inspect_expr(query, scope, expr)
41
+ column_ref = strip_type_casts(expr.lexpr)
42
+ return nil unless column_ref.is_a?(PgQuery::ColumnRef)
43
+
44
+ pattern = pattern_value(query, expr.rexpr)
45
+ return nil unless pattern&.start_with?("%", "_")
46
+
47
+ table, column = scope.resolve(column_ref)
48
+ return nil unless table && column
49
+ return nil unless applicable_table?(query, table)
50
+ return nil if trgm_index?(query, table, column)
51
+
52
+ operator = expr.kind == :AEXPR_ILIKE ? "ILIKE" : "LIKE"
53
+ detection(
54
+ query,
55
+ table: table,
56
+ columns: column,
57
+ message: "Leading-wildcard #{operator} (#{pattern.inspect}) on #{table}.#{column} cannot use " \
58
+ "a btree index and will scan every row in production.",
59
+ suggestion: <<~SUGGESTION.chomp
60
+ Consider the pg_trgm extension with a GIN index:
61
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
62
+ CREATE INDEX index_#{table}_on_#{column}_trgm ON #{table} USING gin (#{column} gin_trgm_ops);
63
+ SUGGESTION
64
+ )
65
+ end
66
+
67
+ # Pattern string from a literal, a cast literal, or a bind parameter.
68
+ def pattern_value(query, rexpr)
69
+ node = strip_type_casts(rexpr)
70
+ case node
71
+ when PgQuery::A_Const
72
+ value = constant_value(node)
73
+ value.is_a?(String) ? value : nil
74
+ when PgQuery::ParamRef
75
+ value = query.bind_value(node.number)
76
+ value.is_a?(String) ? value : nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # NOT IN (SELECT ...) is a double trap: if the subquery ever returns a
6
+ # NULL the whole predicate yields no rows, and the planner cannot use an
7
+ # anti-join as effectively as with NOT EXISTS.
8
+ class NotInSubquery < Base
9
+ def default_enabled
10
+ true
11
+ end
12
+
13
+ def size_dependent?
14
+ false
15
+ end
16
+
17
+ def check(query)
18
+ detections = []
19
+ query.each_scope do |scope|
20
+ next unless scope.where_clause
21
+
22
+ walk_within_scope(scope.where_clause) do |node|
23
+ next unless not_expr?(node)
24
+
25
+ node.args.each do |arg|
26
+ sublink = unwrap_node(arg)
27
+ next unless any_sublink?(sublink)
28
+
29
+ detections << build(query, scope, sublink)
30
+ end
31
+ end
32
+ end
33
+ detections
34
+ end
35
+
36
+ private
37
+
38
+ def not_expr?(node)
39
+ node.is_a?(PgQuery::BoolExpr) && node.boolop == :NOT_EXPR
40
+ end
41
+
42
+ # x NOT IN (SELECT ...) parses as NOT(SubLink ANY, "=" test).
43
+ def any_sublink?(node)
44
+ return false unless node.is_a?(PgQuery::SubLink)
45
+ return false unless node.sub_link_type == :ANY_SUBLINK
46
+
47
+ operator = string_values(node.oper_name).last
48
+ operator.nil? || operator == "="
49
+ end
50
+
51
+ def build(query, scope, sublink)
52
+ test = strip_type_casts(sublink.testexpr)
53
+ table, column = test.is_a?(PgQuery::ColumnRef) ? scope.resolve(test) : nil
54
+
55
+ detection(
56
+ query,
57
+ table: table,
58
+ columns: column,
59
+ message: "NOT IN (SELECT ...) returns zero rows if the subquery yields even one NULL, " \
60
+ "and the planner optimizes it poorly compared to an anti-join — a classic slow query.",
61
+ suggestion: <<~SUGGESTION.chomp
62
+ Consider rewriting to NOT EXISTS:
63
+ SELECT ... FROM t WHERE NOT EXISTS (SELECT 1 FROM sub WHERE sub.ref_id = t.id)
64
+ SUGGESTION
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): OR conditions spanning different columns. PostgreSQL
6
+ # can sometimes combine per-column indexes with a BitmapOr, so this is a
7
+ # warning-level hint rather than a certainty.
8
+ class OrAcrossColumns < Base
9
+ def default_enabled
10
+ false
11
+ end
12
+
13
+ def size_dependent?
14
+ true
15
+ end
16
+
17
+ def check(query)
18
+ detections = []
19
+ query.each_scope do |scope|
20
+ next unless scope.where_clause
21
+
22
+ seen = []
23
+ walk_within_scope(scope.where_clause) do |node|
24
+ next unless node.is_a?(PgQuery::BoolExpr) && node.boolop == :OR_EXPR
25
+
26
+ columns = predicate_columns(query, scope, node)
27
+ next if columns.length < 2
28
+ next if seen.include?(columns)
29
+
30
+ seen << columns
31
+ detections << build(query, columns)
32
+ end
33
+ end
34
+ detections
35
+ end
36
+
37
+ private
38
+
39
+ # Distinct (table, column) pairs among the OR branches' simple
40
+ # column-vs-constant predicates.
41
+ def predicate_columns(query, scope, bool_expr)
42
+ columns = bool_expr.args.filter_map do |arg|
43
+ expr = unwrap_node(arg)
44
+ next nil unless expr.is_a?(PgQuery::A_Expr)
45
+ next nil unless comparison_expr?(expr) || %i[AEXPR_IN AEXPR_LIKE AEXPR_ILIKE].include?(expr.kind)
46
+
47
+ column_ref = strip_type_casts(expr.lexpr)
48
+ next nil unless column_ref.is_a?(PgQuery::ColumnRef)
49
+
50
+ resolved = scope.resolve(column_ref)
51
+ resolved if resolved && applicable_table?(query, resolved.first)
52
+ end
53
+ columns.uniq.sort
54
+ end
55
+
56
+ def build(query, columns)
57
+ column_list = columns.map { |table, column| "#{table}.#{column}" }.join(", ")
58
+ detection(
59
+ query,
60
+ table: columns.first.first,
61
+ columns: columns.map(&:last),
62
+ message: "OR across different columns (#{column_list}) often prevents a single index scan.",
63
+ suggestion: "Ensure each column has its own index (PostgreSQL can then BitmapOr them), " \
64
+ "or split the query into a UNION of two indexed queries."
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # ORDER BY RANDOM() sorts the entire result set just to pick rows —
6
+ # always suspicious regardless of table size.
7
+ class OrderByRandom < Base
8
+ def default_enabled
9
+ true
10
+ end
11
+
12
+ def size_dependent?
13
+ false
14
+ end
15
+
16
+ def check(query)
17
+ detections = []
18
+ query.each_scope do |scope|
19
+ scope.sort_items.each do |sort_by|
20
+ func = unwrap_node(sort_by.node)
21
+ next unless func.is_a?(PgQuery::FuncCall)
22
+ next unless function_name(func) == "random"
23
+
24
+ detections << detection(
25
+ query,
26
+ table: scope.tables.length == 1 ? scope.tables.first : nil,
27
+ message: "ORDER BY RANDOM() reads and sorts every row, so it gets slower " \
28
+ "in proportion to table size.",
29
+ suggestion: <<~SUGGESTION.chomp
30
+ Alternatives:
31
+ - TABLESAMPLE: SELECT * FROM t TABLESAMPLE SYSTEM (1) LIMIT n
32
+ - random primary-key range: WHERE id >= (SELECT (random() * max(id))::bigint FROM t) LIMIT n
33
+ SUGGESTION
34
+ )
35
+ end
36
+ end
37
+ detections
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): "spaghetti query" guard — too many joins or too much
6
+ # subquery nesting. Thresholds:
7
+ # config.rules.query_complexity.max_joins (default 8)
8
+ # config.rules.query_complexity.max_depth (default 4)
9
+ class QueryComplexity < Base
10
+ def default_enabled
11
+ false
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ def self.options
19
+ { max_joins: 8, max_depth: 4 }
20
+ end
21
+
22
+ def check(query)
23
+ max_joins = rule_config(query).max_joins
24
+ max_depth = rule_config(query).max_depth
25
+ stmt = query.parse_result.tree.stmts.first&.stmt
26
+ return [] unless stmt
27
+
28
+ problems = []
29
+ joins = count_joins(stmt)
30
+ depth = max_select_depth(stmt)
31
+ problems << "#{joins} joins (max #{max_joins})" if joins > max_joins
32
+ problems << "subquery depth #{depth} (max #{max_depth})" if depth > max_depth
33
+ return [] if problems.empty?
34
+
35
+ [detection(
36
+ query,
37
+ message: "Query complexity exceeds thresholds: #{problems.join(", ")}.",
38
+ suggestion: "Consider splitting the query, precomputing intermediate results, " \
39
+ "or reviewing whether every join/subquery is necessary."
40
+ )]
41
+ end
42
+
43
+ private
44
+
45
+ def count_joins(stmt)
46
+ joins = 0
47
+ walk_ast(stmt) { |node| joins += 1 if node.is_a?(PgQuery::JoinExpr) }
48
+ joins
49
+ end
50
+
51
+ # Maximum nesting depth of SELECT statements (a flat query is 1).
52
+ def max_select_depth(node)
53
+ node = unwrap_node(node)
54
+ return 0 unless node.is_a?(Google::Protobuf::MessageExts)
55
+
56
+ deepest_child = 0
57
+ each_ast_child(node) do |child|
58
+ depth = max_select_depth(child)
59
+ deepest_child = depth if depth > deepest_child
60
+ end
61
+ (node.is_a?(PgQuery::SelectStmt) ? 1 : 0) + deepest_child
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Regular-expression search (~, ~*, SIMILAR TO) cannot use a plain btree
6
+ # index; only a pg_trgm GIN/GiST index can serve it. The regex variant of
7
+ # leading_wildcard_like.
8
+ class RegexWithoutTrgm < Base
9
+ include IndexPredicates
10
+
11
+ def default_enabled
12
+ true
13
+ end
14
+
15
+ def size_dependent?
16
+ false
17
+ end
18
+
19
+ REGEX_OPS = %w[~ ~* !~ !~*].freeze
20
+
21
+ def check(query)
22
+ detections = []
23
+ query.each_scope do |scope|
24
+ next unless scope.where_clause
25
+
26
+ walk_within_scope(scope.where_clause) do |node|
27
+ next unless node.is_a?(PgQuery::A_Expr)
28
+
29
+ operator = display_operator(node)
30
+ next unless operator
31
+
32
+ column_ref = strip_type_casts(node.lexpr)
33
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
34
+
35
+ table, column = scope.resolve(column_ref)
36
+ next unless table && column
37
+ next unless applicable_table?(query, table)
38
+ next if trgm_index?(query, table, column)
39
+
40
+ detections << detection(
41
+ query,
42
+ table: table,
43
+ columns: column,
44
+ message: "Regular-expression search (#{operator}) on #{table}.#{column} cannot use " \
45
+ "a btree index and will scan every row in production.",
46
+ suggestion: <<~SUGGESTION.chomp
47
+ Consider the pg_trgm extension with a GIN index:
48
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
49
+ CREATE INDEX index_#{table}_on_#{column}_trgm ON #{table} USING gin (#{column} gin_trgm_ops);
50
+ SUGGESTION
51
+ )
52
+ end
53
+ end
54
+ detections
55
+ end
56
+
57
+ private
58
+
59
+ def display_operator(a_expr)
60
+ case a_expr.kind
61
+ when :AEXPR_OP
62
+ operator = operator_name(a_expr)
63
+ REGEX_OPS.include?(operator) ? operator : nil
64
+ when :AEXPR_SIMILAR
65
+ "SIMILAR TO"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): SELECT * (ActiveRecord's default) on a table that has
6
+ # heavy columns (bytea / text / jsonb) transfers those payloads on every
7
+ # query. Whether that matters depends on the data, hence opt-in.
8
+ # Heavy types: config.rules.select_star_with_heavy_columns.heavy_types.
9
+ class SelectStarWithHeavyColumns < Base
10
+ def default_enabled
11
+ false
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ def self.options
19
+ { heavy_types: %w[bytea jsonb text].freeze }
20
+ end
21
+
22
+ def check(query)
23
+ heavy_types = rule_config(query).heavy_types
24
+ detections = []
25
+ query.each_scope do |scope|
26
+ star_tables(scope).each do |table|
27
+ next unless applicable_table?(query, table)
28
+
29
+ heavy = query.column_types(table).select { |_, type| heavy_types.include?(type) }.keys
30
+ next if heavy.empty?
31
+
32
+ detections << detection(
33
+ query,
34
+ table: table,
35
+ columns: heavy,
36
+ message: "SELECT * on #{table} transfers its heavy columns (#{heavy.join(", ")}) " \
37
+ "on every query.",
38
+ suggestion: "Select only the columns you need (.select(:id, ...)), or move large " \
39
+ "payloads to a separate table loaded on demand."
40
+ )
41
+ end
42
+ end
43
+ detections
44
+ end
45
+
46
+ private
47
+
48
+ # Tables whose rows are fetched with a star target (t.* or bare *).
49
+ def star_tables(scope)
50
+ tables = []
51
+ scope.stmt.target_list.each do |target|
52
+ res_target = unwrap_node(target)
53
+ next unless res_target.is_a?(PgQuery::ResTarget)
54
+
55
+ column_ref = unwrap_node(res_target.val)
56
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
57
+
58
+ fields = column_ref.fields.map { |f| unwrap_node(f) }
59
+ next unless fields.last.is_a?(PgQuery::A_Star)
60
+
61
+ qualifier = fields[-2]
62
+ if qualifier.is_a?(PgQuery::String)
63
+ table = scope.aliases[qualifier.sval]
64
+ tables << table if table
65
+ else
66
+ tables.concat(scope.tables)
67
+ end
68
+ end
69
+ tables.uniq
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): join-condition columns with no index leading with
6
+ # them. Unlike active_record_doctor's static association check, this
7
+ # looks at joins that actually ran, so raw-SQL joins are covered too.
8
+ class UnindexedJoin < Base
9
+ include IndexPredicates
10
+
11
+ def default_enabled
12
+ false
13
+ end
14
+
15
+ def size_dependent?
16
+ true
17
+ end
18
+
19
+ def check(query)
20
+ detections = []
21
+ query.each_scope do |scope|
22
+ join_columns(scope).each do |table, column|
23
+ next unless applicable_table?(query, table)
24
+ next if index_leading_with?(query, table, column)
25
+
26
+ detections << detection(
27
+ query,
28
+ table: table,
29
+ columns: column,
30
+ message: "Join condition on #{table}.#{column} has no index leading with it — " \
31
+ "joins on unindexed columns degrade as the tables grow.",
32
+ suggestion: <<~SUGGESTION.chomp
33
+ Consider indexing the join column:
34
+ CREATE INDEX index_#{table}_on_#{column} ON #{table} (#{column});
35
+ SUGGESTION
36
+ )
37
+ end
38
+ end
39
+ detections
40
+ end
41
+
42
+ private
43
+
44
+ # Unique [table, column] pairs appearing in cross-table equality
45
+ # conditions (JOIN ... ON and comma joins connected in WHERE).
46
+ def join_columns(scope)
47
+ columns = []
48
+ scope.stmt.from_clause.each do |item|
49
+ collect_join_quals(unwrap_node(item)) do |quals|
50
+ columns.concat(equality_columns(scope, quals))
51
+ end
52
+ end
53
+ columns.concat(equality_columns(scope, scope.where_clause)) if scope.where_clause
54
+ columns.uniq
55
+ end
56
+
57
+ def collect_join_quals(node, &)
58
+ return unless node.is_a?(PgQuery::JoinExpr)
59
+
60
+ yield node.quals if node.quals
61
+ collect_join_quals(unwrap_node(node.larg), &)
62
+ collect_join_quals(unwrap_node(node.rarg), &)
63
+ end
64
+
65
+ def equality_columns(scope, clause)
66
+ columns = []
67
+ walk_within_scope(clause) do |node|
68
+ next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP && operator_name(node) == "="
69
+
70
+ left = unwrap_node(node.lexpr)
71
+ right = unwrap_node(node.rexpr)
72
+ next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
73
+
74
+ left_resolved = scope.resolve(left)
75
+ right_resolved = scope.resolve(right)
76
+ next unless left_resolved && right_resolved
77
+ next if left_resolved.first == right_resolved.first # same table: not a join condition
78
+
79
+ columns << left_resolved << right_resolved
80
+ end
81
+ columns
82
+ end
83
+ end
84
+ end
85
+ end