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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +114 -0
- data/demo.png +0 -0
- data/lib/pg_canary/configuration.rb +80 -0
- data/lib/pg_canary/detector.rb +94 -0
- data/lib/pg_canary/middleware.rb +115 -0
- data/lib/pg_canary/pg_query_support.rb +115 -0
- data/lib/pg_canary/rules/base.rb +77 -0
- data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +75 -0
- data/lib/pg_canary/rules/definitions/cartesian_join.rb +108 -0
- data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +80 -0
- data/lib/pg_canary/rules/definitions/count_star_without_where.rb +57 -0
- data/lib/pg_canary/rules/definitions/deep_offset.rb +63 -0
- data/lib/pg_canary/rules/definitions/distinct_with_join.rb +43 -0
- data/lib/pg_canary/rules/definitions/function_on_column.rb +89 -0
- data/lib/pg_canary/rules/definitions/huge_in_list.rb +74 -0
- data/lib/pg_canary/rules/definitions/implicit_cast.rb +79 -0
- data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +108 -0
- data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +81 -0
- data/lib/pg_canary/rules/definitions/not_in_subquery.rb +69 -0
- data/lib/pg_canary/rules/definitions/or_across_columns.rb +69 -0
- data/lib/pg_canary/rules/definitions/order_by_random.rb +41 -0
- data/lib/pg_canary/rules/definitions/query_complexity.rb +65 -0
- data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +70 -0
- data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +73 -0
- data/lib/pg_canary/rules/definitions/unindexed_join.rb +85 -0
- data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +51 -0
- data/lib/pg_canary/rules/definitions/unindexed_where.rb +95 -0
- data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +30 -0
- data/lib/pg_canary/rules/detection.rb +21 -0
- data/lib/pg_canary/rules/index_predicates.rb +39 -0
- data/lib/pg_canary/rules/query_context.rb +147 -0
- data/lib/pg_canary/schema_introspection.rb +72 -0
- data/lib/pg_canary/subscriber.rb +41 -0
- data/lib/pg_canary/version.rb +5 -0
- data/lib/pg_canary.rb +77 -0
- 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
|