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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# Searching an array column (@>, <@, &&, or value = ANY(column)) without
|
|
6
|
+
# a GIN index scans every row.
|
|
7
|
+
class ArraySearchWithoutGin < Base
|
|
8
|
+
include IndexPredicates
|
|
9
|
+
|
|
10
|
+
def default_enabled
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def size_dependent?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ARRAY_OPS = %w[@> <@ &&].freeze
|
|
19
|
+
|
|
20
|
+
def check(query)
|
|
21
|
+
detections = []
|
|
22
|
+
query.each_scope do |scope|
|
|
23
|
+
next unless scope.where_clause
|
|
24
|
+
|
|
25
|
+
walk_within_scope(scope.where_clause) do |node|
|
|
26
|
+
next unless node.is_a?(PgQuery::A_Expr)
|
|
27
|
+
|
|
28
|
+
case node.kind
|
|
29
|
+
when :AEXPR_OP
|
|
30
|
+
operator = operator_name(node)
|
|
31
|
+
next unless ARRAY_OPS.include?(operator)
|
|
32
|
+
|
|
33
|
+
detections << inspect_sides(query, scope, [node.lexpr, node.rexpr], operator)
|
|
34
|
+
when :AEXPR_OP_ANY
|
|
35
|
+
detections << inspect_sides(query, scope, [node.rexpr], "= ANY(column)")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
detections.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def inspect_sides(query, scope, sides, operator)
|
|
45
|
+
sides.each do |side|
|
|
46
|
+
column_ref = strip_type_casts(side)
|
|
47
|
+
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
48
|
+
|
|
49
|
+
table, column = scope.resolve(column_ref)
|
|
50
|
+
next unless table && column
|
|
51
|
+
next unless applicable_table?(query, table)
|
|
52
|
+
next unless array_type?(query.column_type(table, column))
|
|
53
|
+
next if gin_index_on?(query, table, column)
|
|
54
|
+
|
|
55
|
+
return detection(
|
|
56
|
+
query,
|
|
57
|
+
table: table,
|
|
58
|
+
columns: column,
|
|
59
|
+
message: "Array search (#{operator}) on #{table}.#{column} has no GIN index " \
|
|
60
|
+
"and will scan every row in production.",
|
|
61
|
+
suggestion: <<~SUGGESTION.chomp
|
|
62
|
+
Consider a GIN index on the array column:
|
|
63
|
+
CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
|
|
64
|
+
SUGGESTION
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def array_type?(sql_type)
|
|
71
|
+
sql_type&.end_with?("[]")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# A JOIN with no join condition — an explicit CROSS JOIN between real
|
|
6
|
+
# tables, or a comma join whose WHERE clause never connects the tables —
|
|
7
|
+
# produces a cross product whose row count is the product of both sides.
|
|
8
|
+
class CartesianJoin < 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
|
+
scope.stmt.from_clause.each do |item|
|
|
21
|
+
each_join(unwrap_node(item)) do |join|
|
|
22
|
+
detections << join_detection(query, join) if unconditioned?(join)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
detections << comma_detection(query, scope) if comma_cartesian?(scope)
|
|
26
|
+
end
|
|
27
|
+
detections
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def each_join(node, &)
|
|
33
|
+
return unless node.is_a?(PgQuery::JoinExpr)
|
|
34
|
+
|
|
35
|
+
yield node
|
|
36
|
+
each_join(unwrap_node(node.larg), &)
|
|
37
|
+
each_join(unwrap_node(node.rarg), &)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def unconditioned?(join)
|
|
41
|
+
join.jointype == :JOIN_INNER &&
|
|
42
|
+
join.quals.nil? &&
|
|
43
|
+
!join.is_natural &&
|
|
44
|
+
join.using_clause.empty? &&
|
|
45
|
+
real_table?(join.rarg) &&
|
|
46
|
+
joinable_side?(join.larg)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Restrict to joins between real tables so that deliberate cross joins
|
|
50
|
+
# against functions (generate_series etc.) stay silent.
|
|
51
|
+
def real_table?(node)
|
|
52
|
+
unwrap_node(node).is_a?(PgQuery::RangeVar)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def joinable_side?(node)
|
|
56
|
+
inner = unwrap_node(node)
|
|
57
|
+
inner.is_a?(PgQuery::RangeVar) || inner.is_a?(PgQuery::JoinExpr)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# FROM a, b (2+ plain tables) with no column-to-column equality in WHERE.
|
|
61
|
+
def comma_cartesian?(scope)
|
|
62
|
+
plain_tables = scope.stmt.from_clause.map { |n| unwrap_node(n) }.grep(PgQuery::RangeVar)
|
|
63
|
+
return false if plain_tables.length < 2
|
|
64
|
+
|
|
65
|
+
!cross_table_equality?(scope)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cross_table_equality?(scope)
|
|
69
|
+
return false unless scope.where_clause
|
|
70
|
+
|
|
71
|
+
found = false
|
|
72
|
+
walk_within_scope(scope.where_clause) do |node|
|
|
73
|
+
next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
|
|
74
|
+
|
|
75
|
+
left = unwrap_node(node.lexpr)
|
|
76
|
+
right = unwrap_node(node.rexpr)
|
|
77
|
+
next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
|
|
78
|
+
|
|
79
|
+
left_table, = scope.resolve(left)
|
|
80
|
+
right_table, = scope.resolve(right)
|
|
81
|
+
found ||= left_table && right_table && left_table != right_table
|
|
82
|
+
end
|
|
83
|
+
found
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def join_detection(query, join)
|
|
87
|
+
tables = [join.larg, join.rarg].map { |n| unwrap_node(n) }
|
|
88
|
+
.grep(PgQuery::RangeVar).map(&:relname)
|
|
89
|
+
build(query, tables)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def comma_detection(query, scope)
|
|
93
|
+
build(query, scope.tables)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build(query, tables)
|
|
97
|
+
detection(
|
|
98
|
+
query,
|
|
99
|
+
table: tables.first,
|
|
100
|
+
message: "JOIN between #{tables.join(" and ")} has no join condition, producing a cross " \
|
|
101
|
+
"product — the result grows with the product of both tables' row counts.",
|
|
102
|
+
suggestion: "Add a join condition (ON / USING). If a cross product is really intended, " \
|
|
103
|
+
"add the table to config.ignore_tables to silence this."
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# A scalar subquery in the SELECT list that references the outer table
|
|
6
|
+
# runs once per result row (N+1 inside a single query).
|
|
7
|
+
class CorrelatedSubqueryInSelect < 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.stmt.target_list.each do |target|
|
|
20
|
+
res_target = unwrap_node(target)
|
|
21
|
+
next unless res_target.is_a?(PgQuery::ResTarget)
|
|
22
|
+
|
|
23
|
+
walk_within_scope(res_target.val) do |node|
|
|
24
|
+
next unless node.is_a?(PgQuery::SubLink) && node.sub_link_type == :EXPR_SUBLINK
|
|
25
|
+
|
|
26
|
+
table, column = correlated_reference(node, scope)
|
|
27
|
+
next unless table && column
|
|
28
|
+
next unless applicable_table?(query, table)
|
|
29
|
+
|
|
30
|
+
detections << detection(
|
|
31
|
+
query,
|
|
32
|
+
table: table,
|
|
33
|
+
columns: column,
|
|
34
|
+
message: "Scalar subquery in the SELECT list references #{table}.#{column} from the " \
|
|
35
|
+
"outer query, so it executes once per returned row.",
|
|
36
|
+
suggestion: "Rewrite as a JOIN + GROUP BY (or a LATERAL join) so the lookup runs " \
|
|
37
|
+
"once as a set operation."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
detections
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# [table, column] of the first outer-scope reference inside the
|
|
48
|
+
# subquery, or nil when the subquery is self-contained.
|
|
49
|
+
def correlated_reference(sublink, outer_scope)
|
|
50
|
+
subselect = unwrap_node(sublink.subselect)
|
|
51
|
+
return nil unless subselect.is_a?(PgQuery::SelectStmt)
|
|
52
|
+
|
|
53
|
+
inner_names = inner_relation_names(subselect)
|
|
54
|
+
found = nil
|
|
55
|
+
walk_ast(subselect) do |node|
|
|
56
|
+
next unless found.nil? && node.is_a?(PgQuery::ColumnRef)
|
|
57
|
+
|
|
58
|
+
fields = column_ref_fields(node)
|
|
59
|
+
next unless fields && fields.length >= 2
|
|
60
|
+
next if inner_names.include?(fields[-2])
|
|
61
|
+
|
|
62
|
+
found = outer_scope.resolve(node)
|
|
63
|
+
end
|
|
64
|
+
found
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Every relation name/alias visible anywhere inside the subquery.
|
|
68
|
+
def inner_relation_names(subselect)
|
|
69
|
+
names = []
|
|
70
|
+
walk_ast(subselect) do |node|
|
|
71
|
+
next unless node.is_a?(PgQuery::RangeVar)
|
|
72
|
+
|
|
73
|
+
names << node.relname
|
|
74
|
+
names << node.alias.aliasname if node.alias
|
|
75
|
+
end
|
|
76
|
+
names
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# Tier 2 (opt-in): SELECT COUNT(*) without WHERE. Because of MVCC,
|
|
6
|
+
# PostgreSQL has no O(1) row count — this scans the whole table.
|
|
7
|
+
class CountStarWithoutWhere < Base
|
|
8
|
+
def default_enabled
|
|
9
|
+
false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def size_dependent?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def check(query)
|
|
17
|
+
detections = []
|
|
18
|
+
query.each_scope do |scope|
|
|
19
|
+
stmt = scope.stmt
|
|
20
|
+
next if stmt.where_clause || stmt.group_clause.any?
|
|
21
|
+
next unless count_star?(stmt)
|
|
22
|
+
|
|
23
|
+
tables = scope.tables
|
|
24
|
+
next unless tables.length == 1
|
|
25
|
+
|
|
26
|
+
table = tables.first
|
|
27
|
+
next unless applicable_table?(query, table)
|
|
28
|
+
|
|
29
|
+
detections << detection(
|
|
30
|
+
query,
|
|
31
|
+
table: table,
|
|
32
|
+
message: "COUNT(*) without WHERE scans the whole #{table} table — PostgreSQL's MVCC " \
|
|
33
|
+
"has no O(1) row count.",
|
|
34
|
+
suggestion: <<~SUGGESTION.chomp
|
|
35
|
+
If an approximation is acceptable, use the planner's estimate:
|
|
36
|
+
SELECT reltuples::bigint FROM pg_class WHERE relname = '#{table}';
|
|
37
|
+
For exact counts displayed frequently, maintain a counter cache.
|
|
38
|
+
SUGGESTION
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
detections
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def count_star?(stmt)
|
|
47
|
+
stmt.target_list.any? do |target|
|
|
48
|
+
res_target = unwrap_node(target)
|
|
49
|
+
next false unless res_target.is_a?(PgQuery::ResTarget)
|
|
50
|
+
|
|
51
|
+
func = unwrap_node(res_target.val)
|
|
52
|
+
func.is_a?(PgQuery::FuncCall) && func.agg_star && function_name(func) == "count"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# OFFSET-based pagination reads and throws away every skipped row, so
|
|
6
|
+
# deep pages degrade linearly. The offset value is read from the runtime
|
|
7
|
+
# bind ($n), which static SQL linters cannot do.
|
|
8
|
+
# Threshold: config.rules.deep_offset.threshold (default 1000).
|
|
9
|
+
class DeepOffset < Base
|
|
10
|
+
def default_enabled
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def size_dependent?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.options
|
|
19
|
+
{ threshold: 1000 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check(query)
|
|
23
|
+
threshold = rule_config(query).threshold
|
|
24
|
+
detections = []
|
|
25
|
+
query.each_scope do |scope|
|
|
26
|
+
next unless scope.stmt.limit_offset
|
|
27
|
+
|
|
28
|
+
value = numeric_value(query, scope.stmt.limit_offset)
|
|
29
|
+
next unless value && value >= threshold
|
|
30
|
+
|
|
31
|
+
detections << detection(
|
|
32
|
+
query,
|
|
33
|
+
table: scope.tables.length == 1 ? scope.tables.first : nil,
|
|
34
|
+
message: "OFFSET #{value} reads and discards #{value} rows before returning anything — " \
|
|
35
|
+
"offset pagination degrades linearly with page depth.",
|
|
36
|
+
suggestion: <<~SUGGESTION.chomp
|
|
37
|
+
Consider keyset pagination instead:
|
|
38
|
+
WHERE (created_at, id) < (:last_seen_created_at, :last_seen_id) ORDER BY created_at DESC, id DESC LIMIT n
|
|
39
|
+
SUGGESTION
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
detections
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def numeric_value(query, node)
|
|
48
|
+
node = strip_type_casts(node)
|
|
49
|
+
case node
|
|
50
|
+
when PgQuery::A_Const
|
|
51
|
+
value = constant_value(node)
|
|
52
|
+
value.is_a?(Numeric) ? value.to_i : nil
|
|
53
|
+
when PgQuery::ParamRef
|
|
54
|
+
begin
|
|
55
|
+
Integer(query.bind_value(node.number), exception: false)
|
|
56
|
+
rescue TypeError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# Tier 2 (opt-in): DISTINCT combined with JOIN. Frequently the DISTINCT
|
|
6
|
+
# only exists to undo row fanout caused by the join — the work of
|
|
7
|
+
# producing and then deduplicating the duplicates is wasted. Legitimate
|
|
8
|
+
# uses exist, hence opt-in.
|
|
9
|
+
class DistinctWithJoin < Base
|
|
10
|
+
def default_enabled
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def size_dependent?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check(query)
|
|
19
|
+
detections = []
|
|
20
|
+
query.each_scope do |scope|
|
|
21
|
+
next unless scope.stmt.distinct_clause.any?
|
|
22
|
+
next unless joined?(scope)
|
|
23
|
+
|
|
24
|
+
detections << detection(
|
|
25
|
+
query,
|
|
26
|
+
table: scope.tables.first,
|
|
27
|
+
message: "DISTINCT combined with JOIN often hides row fanout from the join: duplicate rows " \
|
|
28
|
+
"are produced and then sorted/hashed away.",
|
|
29
|
+
suggestion: "If DISTINCT only compensates for join duplication, rewrite the join as a " \
|
|
30
|
+
"semi-join: WHERE EXISTS (SELECT 1 FROM joined WHERE joined.ref_id = t.id)."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
detections
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def joined?(scope)
|
|
39
|
+
scope.stmt.from_clause.any? { |item| unwrap_node(item).is_a?(PgQuery::JoinExpr) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# A column wrapped in a function inside WHERE (lower(email) = ?,
|
|
6
|
+
# date(created_at) = ?) can never use a plain index on that column.
|
|
7
|
+
# Silent when a matching expression index (same function, same column)
|
|
8
|
+
# exists.
|
|
9
|
+
class FunctionOnColumn < Base
|
|
10
|
+
def default_enabled
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def size_dependent?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
CHECKED_KINDS = %i[AEXPR_OP AEXPR_LIKE AEXPR_ILIKE AEXPR_IN AEXPR_BETWEEN].freeze
|
|
19
|
+
|
|
20
|
+
def check(query)
|
|
21
|
+
detections = []
|
|
22
|
+
query.each_scope do |scope|
|
|
23
|
+
next unless scope.where_clause
|
|
24
|
+
|
|
25
|
+
walk_within_scope(scope.where_clause) do |node|
|
|
26
|
+
next unless node.is_a?(PgQuery::A_Expr) && CHECKED_KINDS.include?(node.kind)
|
|
27
|
+
|
|
28
|
+
[node.lexpr, node.rexpr].each do |side|
|
|
29
|
+
detections << inspect_side(query, scope, side)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
detections.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The first ColumnRef inside the function call (arguments may nest,
|
|
39
|
+
# e.g. lower(trim(email))).
|
|
40
|
+
def first_column_ref(node)
|
|
41
|
+
found = nil
|
|
42
|
+
walk_within_scope(node) do |msg|
|
|
43
|
+
found ||= msg if msg.is_a?(PgQuery::ColumnRef)
|
|
44
|
+
end
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect_side(query, scope, side)
|
|
49
|
+
func = strip_type_casts(side)
|
|
50
|
+
return nil unless func.is_a?(PgQuery::FuncCall)
|
|
51
|
+
|
|
52
|
+
column_ref = first_column_ref(func)
|
|
53
|
+
return nil unless column_ref
|
|
54
|
+
|
|
55
|
+
table, column = scope.resolve(column_ref)
|
|
56
|
+
return nil unless table && column
|
|
57
|
+
return nil unless applicable_table?(query, table)
|
|
58
|
+
|
|
59
|
+
func_name = function_name(func)
|
|
60
|
+
return nil if expression_index?(query, table, column, func_name)
|
|
61
|
+
|
|
62
|
+
detection(
|
|
63
|
+
query,
|
|
64
|
+
table: table,
|
|
65
|
+
columns: column,
|
|
66
|
+
message: "#{table}.#{column} is wrapped in #{func_name}() inside WHERE, " \
|
|
67
|
+
"so a plain index on #{column} cannot be used.",
|
|
68
|
+
suggestion: <<~SUGGESTION.chomp
|
|
69
|
+
Consider adding an expression index:
|
|
70
|
+
CREATE INDEX index_#{table}_on_#{func_name}_#{column} ON #{table} ((#{func_name}(#{column})));
|
|
71
|
+
SUGGESTION
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Matches by (function name, column name) word match against the index
|
|
76
|
+
# expression SQL — a deliberate approximation that already avoids the
|
|
77
|
+
# common false positives.
|
|
78
|
+
def expression_index?(query, table, column, func_name)
|
|
79
|
+
query.indexes(table).any? do |index|
|
|
80
|
+
expressions = index.expressions
|
|
81
|
+
next false unless expressions
|
|
82
|
+
|
|
83
|
+
expressions.match?(/\b#{Regexp.escape(func_name)}\s*\(/i) &&
|
|
84
|
+
expressions.match?(/\b#{Regexp.escape(column)}\b/)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# IN (...) / = ANY(...) with a huge number of values: the statement
|
|
6
|
+
# itself becomes expensive to parse/plan and usually signals a missing
|
|
7
|
+
# JOIN. Counts literal list items and runtime array binds, which static
|
|
8
|
+
# SQL linters cannot see.
|
|
9
|
+
# Threshold: config.rules.huge_in_list.threshold (default 500).
|
|
10
|
+
class HugeInList < Base
|
|
11
|
+
def default_enabled
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def size_dependent?
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.options
|
|
20
|
+
{ threshold: 500 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def check(query)
|
|
24
|
+
threshold = rule_config(query).threshold
|
|
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)
|
|
31
|
+
|
|
32
|
+
count = value_count(query, node)
|
|
33
|
+
next unless count && count > threshold
|
|
34
|
+
|
|
35
|
+
table, column = resolve_lexpr(scope, node)
|
|
36
|
+
detections << detection(
|
|
37
|
+
query,
|
|
38
|
+
table: table,
|
|
39
|
+
columns: column,
|
|
40
|
+
message: "IN / ANY list with #{count} values (threshold: #{threshold}). Huge value lists " \
|
|
41
|
+
"are expensive to parse and plan, and usually mean a JOIN is missing.",
|
|
42
|
+
suggestion: "Rewrite as a JOIN against the source of the values (subquery or VALUES list) " \
|
|
43
|
+
"instead of materializing the ids in the query."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
detections
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def value_count(query, a_expr)
|
|
53
|
+
case a_expr.kind
|
|
54
|
+
when :AEXPR_IN
|
|
55
|
+
list = unwrap_node(a_expr.rexpr)
|
|
56
|
+
list.is_a?(PgQuery::List) ? list.items.length : nil
|
|
57
|
+
when :AEXPR_OP_ANY
|
|
58
|
+
param = strip_type_casts(a_expr.rexpr)
|
|
59
|
+
return nil unless param.is_a?(PgQuery::ParamRef)
|
|
60
|
+
|
|
61
|
+
value = query.bind_value(param.number)
|
|
62
|
+
value.is_a?(Array) ? value.length : nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve_lexpr(scope, a_expr)
|
|
67
|
+
column_ref = strip_type_casts(a_expr.lexpr)
|
|
68
|
+
return nil unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
69
|
+
|
|
70
|
+
scope.resolve(column_ref)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module Rules
|
|
5
|
+
# Comparing an integer-family column with a numeric/float literal
|
|
6
|
+
# (age = 1.5) makes PostgreSQL cast the *column* to numeric, which
|
|
7
|
+
# disables its index. Restricted to cases provable from the AST alone.
|
|
8
|
+
class ImplicitCast < Base
|
|
9
|
+
def default_enabled
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def size_dependent?
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
INTEGER_TYPES = %w[smallint integer bigint].freeze
|
|
18
|
+
NUMERIC_TYPE_NAMES = %w[numeric decimal float4 float8].freeze
|
|
19
|
+
|
|
20
|
+
def check(query)
|
|
21
|
+
detections = []
|
|
22
|
+
query.each_scope do |scope|
|
|
23
|
+
next unless scope.where_clause
|
|
24
|
+
|
|
25
|
+
walk_within_scope(scope.where_clause) do |node|
|
|
26
|
+
next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
|
|
27
|
+
|
|
28
|
+
detections << inspect_comparison(query, scope, node)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
detections.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def inspect_comparison(query, scope, expr)
|
|
37
|
+
left = unwrap_node(expr.lexpr)
|
|
38
|
+
right = unwrap_node(expr.rexpr)
|
|
39
|
+
|
|
40
|
+
column_ref, value = if left.is_a?(PgQuery::ColumnRef)
|
|
41
|
+
[left, right]
|
|
42
|
+
elsif right.is_a?(PgQuery::ColumnRef)
|
|
43
|
+
[right, left]
|
|
44
|
+
end
|
|
45
|
+
return nil unless column_ref
|
|
46
|
+
return nil unless numeric_literal?(value)
|
|
47
|
+
|
|
48
|
+
table, column = scope.resolve(column_ref)
|
|
49
|
+
return nil unless table && column
|
|
50
|
+
return nil unless applicable_table?(query, table)
|
|
51
|
+
|
|
52
|
+
column_type = query.column_type(table, column)
|
|
53
|
+
return nil unless INTEGER_TYPES.include?(column_type)
|
|
54
|
+
|
|
55
|
+
detection(
|
|
56
|
+
query,
|
|
57
|
+
table: table,
|
|
58
|
+
columns: column,
|
|
59
|
+
message: "Comparing #{table}.#{column} (#{column_type}) with a numeric literal implicitly " \
|
|
60
|
+
"casts the column to numeric, disabling any index on #{column}.",
|
|
61
|
+
suggestion: "Use a literal that matches the column type (integer)."
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A numeric/float literal, or an explicit cast to numeric of a literal.
|
|
66
|
+
def numeric_literal?(node)
|
|
67
|
+
case node
|
|
68
|
+
when PgQuery::A_Const
|
|
69
|
+
node.val == :fval
|
|
70
|
+
when PgQuery::TypeCast
|
|
71
|
+
type = string_values(node.type_name.names).last
|
|
72
|
+
NUMERIC_TYPE_NAMES.include?(type) && strip_type_casts(node).is_a?(PgQuery::A_Const)
|
|
73
|
+
else
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|