pg_canary 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +14 -28
- data/lib/pg_canary/configuration.rb +2 -28
- data/lib/pg_canary/detector.rb +64 -56
- data/lib/pg_canary/middleware.rb +58 -59
- data/lib/pg_canary/pg_query_refinement.rb +127 -0
- data/lib/pg_canary/rules/base.rb +87 -36
- data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +33 -34
- data/lib/pg_canary/rules/definitions/cartesian_join.rb +67 -68
- data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +32 -33
- data/lib/pg_canary/rules/definitions/count_star_without_where.rb +14 -15
- data/lib/pg_canary/rules/definitions/deep_offset.rb +19 -23
- data/lib/pg_canary/rules/definitions/distinct_with_join.rb +9 -10
- data/lib/pg_canary/rules/definitions/function_on_column.rb +46 -47
- data/lib/pg_canary/rules/definitions/huge_in_list.rb +24 -28
- data/lib/pg_canary/rules/definitions/implicit_cast.rb +42 -43
- data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +61 -63
- data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +43 -44
- data/lib/pg_canary/rules/definitions/not_in_subquery.rb +32 -33
- data/lib/pg_canary/rules/definitions/or_across_columns.rb +32 -33
- data/lib/pg_canary/rules/definitions/order_by_random.rb +7 -8
- data/lib/pg_canary/rules/definitions/query_complexity.rb +25 -28
- data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +17 -18
- data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +27 -31
- data/lib/pg_canary/rules/definitions/unindexed_join.rb +38 -39
- data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +9 -10
- data/lib/pg_canary/rules/definitions/unindexed_where.rb +59 -60
- data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +4 -7
- data/lib/pg_canary/rules/detection.rb +1 -1
- data/lib/pg_canary/rules/index_predicates.rb +20 -20
- data/lib/pg_canary/rules/schema_introspection.rb +74 -0
- data/lib/pg_canary/rules/scope.rb +74 -0
- data/lib/pg_canary/subscriber.rb +9 -9
- data/lib/pg_canary/version.rb +1 -1
- data/lib/pg_canary.rb +18 -18
- metadata +4 -4
- data/lib/pg_canary/pg_query_support.rb +0 -115
- data/lib/pg_canary/rules/query_context.rb +0 -147
- data/lib/pg_canary/schema_introspection.rb +0 -72
|
@@ -2,23 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# DISTINCT combined with JOIN. Frequently the DISTINCT
|
|
6
6
|
# only exists to undo row fanout caused by the join — the work of
|
|
7
7
|
# producing and then deduplicating the duplicates is wasted. Legitimate
|
|
8
8
|
# uses exist, hence opt-in.
|
|
9
9
|
class DistinctWithJoin < Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
default_enabled false
|
|
11
|
+
|
|
12
|
+
using PgCanary::PgQueryRefinement
|
|
13
13
|
|
|
14
|
-
def check
|
|
14
|
+
def check
|
|
15
15
|
detections = []
|
|
16
|
-
|
|
16
|
+
each_scope do |scope|
|
|
17
17
|
next unless scope.stmt.distinct_clause.any?
|
|
18
18
|
next unless joined?(scope)
|
|
19
19
|
|
|
20
20
|
detections << detection(
|
|
21
|
-
query,
|
|
22
21
|
table: scope.tables.first,
|
|
23
22
|
message: "DISTINCT combined with JOIN often hides row fanout from the join: duplicate rows " \
|
|
24
23
|
"are produced and then sorted/hashed away.",
|
|
@@ -31,9 +30,9 @@ module PgCanary
|
|
|
31
30
|
|
|
32
31
|
private
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
def joined?(scope)
|
|
34
|
+
scope.stmt.from_clause.any? { |item| item.unwrap.is_a?(PgQuery::JoinExpr) }
|
|
35
|
+
end
|
|
37
36
|
end
|
|
38
37
|
end
|
|
39
38
|
end
|
|
@@ -7,22 +7,22 @@ module PgCanary
|
|
|
7
7
|
# Silent when a matching expression index (same function, same column)
|
|
8
8
|
# exists.
|
|
9
9
|
class FunctionOnColumn < Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
default_enabled true
|
|
11
|
+
|
|
12
|
+
using PgCanary::PgQueryRefinement
|
|
13
13
|
|
|
14
14
|
CHECKED_KINDS = %i[AEXPR_OP AEXPR_LIKE AEXPR_ILIKE AEXPR_IN AEXPR_BETWEEN].freeze
|
|
15
15
|
|
|
16
|
-
def check
|
|
16
|
+
def check
|
|
17
17
|
detections = []
|
|
18
|
-
|
|
18
|
+
each_scope do |scope|
|
|
19
19
|
next unless scope.where_clause
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
scope.where_clause.walk_scope do |node|
|
|
22
22
|
next unless node.is_a?(PgQuery::A_Expr) && CHECKED_KINDS.include?(node.kind)
|
|
23
23
|
|
|
24
24
|
[node.lexpr, node.rexpr].each do |side|
|
|
25
|
-
detections << inspect_side(
|
|
25
|
+
detections << inspect_side(scope, side)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -31,55 +31,54 @@ module PgCanary
|
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
# The first ColumnRef inside the function call (arguments may nest,
|
|
35
|
+
# e.g. lower(trim(email))).
|
|
36
|
+
def first_column_ref(node)
|
|
37
|
+
found = nil
|
|
38
|
+
node.walk_scope do |msg|
|
|
39
|
+
found ||= msg if msg.is_a?(PgQuery::ColumnRef)
|
|
40
|
+
end
|
|
41
|
+
found
|
|
40
42
|
end
|
|
41
|
-
found
|
|
42
|
-
end
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
def inspect_side(scope, side)
|
|
45
|
+
func = side&.strip_casts
|
|
46
|
+
return nil unless func.is_a?(PgQuery::FuncCall)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
column_ref = first_column_ref(func)
|
|
49
|
+
return nil unless column_ref
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
table, column = scope.resolve(column_ref)
|
|
52
|
+
return nil unless table && column
|
|
53
|
+
return nil unless applicable_table?(table)
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
func_name = func.function_name
|
|
56
|
+
return nil if expression_index?(table, column, func_name)
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
end
|
|
58
|
+
detection(
|
|
59
|
+
table: table,
|
|
60
|
+
columns: column,
|
|
61
|
+
message: "#{table}.#{column} is wrapped in #{func_name}() inside WHERE, " \
|
|
62
|
+
"so a plain index on #{column} cannot be used.",
|
|
63
|
+
suggestion: <<~SUGGESTION.chomp
|
|
64
|
+
Consider adding an expression index:
|
|
65
|
+
CREATE INDEX index_#{table}_on_#{func_name}_#{column} ON #{table} ((#{func_name}(#{column})));
|
|
66
|
+
SUGGESTION
|
|
67
|
+
)
|
|
68
|
+
end
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
# Matches by (function name, column name) word match against the index
|
|
71
|
+
# expression SQL — a deliberate approximation that already avoids the
|
|
72
|
+
# common false positives.
|
|
73
|
+
def expression_index?(table, column, func_name)
|
|
74
|
+
indexes(table).any? do |index|
|
|
75
|
+
expressions = index.expressions
|
|
76
|
+
next false unless expressions
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
expressions.match?(/\b#{Regexp.escape(func_name)}\s*\(/i) &&
|
|
79
|
+
expressions.match?(/\b#{Regexp.escape(column)}\b/)
|
|
80
|
+
end
|
|
81
81
|
end
|
|
82
|
-
end
|
|
83
82
|
end
|
|
84
83
|
end
|
|
85
84
|
end
|
|
@@ -8,29 +8,25 @@ module PgCanary
|
|
|
8
8
|
# SQL linters cannot see.
|
|
9
9
|
# Threshold: config.rules.huge_in_list.threshold (default 500).
|
|
10
10
|
class HugeInList < Base
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
end
|
|
11
|
+
default_enabled true
|
|
12
|
+
option :threshold, default: 500
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
{ threshold: 500 }
|
|
17
|
-
end
|
|
14
|
+
using PgCanary::PgQueryRefinement
|
|
18
15
|
|
|
19
|
-
def check
|
|
20
|
-
threshold = rule_config
|
|
16
|
+
def check
|
|
17
|
+
threshold = rule_config.threshold
|
|
21
18
|
detections = []
|
|
22
|
-
|
|
19
|
+
each_scope do |scope|
|
|
23
20
|
next unless scope.where_clause
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
scope.where_clause.walk_scope do |node|
|
|
26
23
|
next unless node.is_a?(PgQuery::A_Expr)
|
|
27
24
|
|
|
28
|
-
count = value_count(
|
|
25
|
+
count = value_count(node)
|
|
29
26
|
next unless count && count > threshold
|
|
30
27
|
|
|
31
28
|
table, column = resolve_lexpr(scope, node)
|
|
32
29
|
detections << detection(
|
|
33
|
-
query,
|
|
34
30
|
table: table,
|
|
35
31
|
columns: column,
|
|
36
32
|
message: "IN / ANY list with #{count} values (threshold: #{threshold}). Huge value lists " \
|
|
@@ -45,26 +41,26 @@ module PgCanary
|
|
|
45
41
|
|
|
46
42
|
private
|
|
47
43
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
44
|
+
def value_count(a_expr)
|
|
45
|
+
case a_expr.kind
|
|
46
|
+
when :AEXPR_IN
|
|
47
|
+
list = a_expr.rexpr&.unwrap
|
|
48
|
+
list.is_a?(PgQuery::List) ? list.items.length : nil
|
|
49
|
+
when :AEXPR_OP_ANY
|
|
50
|
+
param = a_expr.rexpr&.strip_casts
|
|
51
|
+
return nil unless param.is_a?(PgQuery::ParamRef)
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
value = bind_value(param.number)
|
|
54
|
+
value.is_a?(Array) ? value.length : nil
|
|
55
|
+
end
|
|
59
56
|
end
|
|
60
|
-
end
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
def resolve_lexpr(scope, a_expr)
|
|
59
|
+
column_ref = a_expr.lexpr&.strip_casts
|
|
60
|
+
return nil unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
scope.resolve(column_ref)
|
|
63
|
+
end
|
|
68
64
|
end
|
|
69
65
|
end
|
|
70
66
|
end
|
|
@@ -6,22 +6,22 @@ module PgCanary
|
|
|
6
6
|
# (age = 1.5) makes PostgreSQL cast the *column* to numeric, which
|
|
7
7
|
# disables its index. Restricted to cases provable from the AST alone.
|
|
8
8
|
class ImplicitCast < Base
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
default_enabled true
|
|
10
|
+
|
|
11
|
+
using PgCanary::PgQueryRefinement
|
|
12
12
|
|
|
13
13
|
INTEGER_TYPES = %w[smallint integer bigint].freeze
|
|
14
14
|
NUMERIC_TYPE_NAMES = %w[numeric decimal float4 float8].freeze
|
|
15
15
|
|
|
16
|
-
def check
|
|
16
|
+
def check
|
|
17
17
|
detections = []
|
|
18
|
-
|
|
18
|
+
each_scope do |scope|
|
|
19
19
|
next unless scope.where_clause
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
next unless node.is_a?(PgQuery::A_Expr) &&
|
|
21
|
+
scope.where_clause.walk_scope do |node|
|
|
22
|
+
next unless node.is_a?(PgQuery::A_Expr) && node.comparison?
|
|
23
23
|
|
|
24
|
-
detections << inspect_comparison(
|
|
24
|
+
detections << inspect_comparison(scope, node)
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
detections.compact
|
|
@@ -29,47 +29,46 @@ module PgCanary
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
def inspect_comparison(scope, expr)
|
|
33
|
+
left = expr.lexpr&.unwrap
|
|
34
|
+
right = expr.rexpr&.unwrap
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
column_ref, value = if left.is_a?(PgQuery::ColumnRef)
|
|
37
|
+
[left, right]
|
|
38
|
+
elsif right.is_a?(PgQuery::ColumnRef)
|
|
39
|
+
[right, left]
|
|
40
|
+
end
|
|
41
|
+
return nil unless column_ref
|
|
42
|
+
return nil unless numeric_literal?(value)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
table, column = scope.resolve(column_ref)
|
|
45
|
+
return nil unless table && column
|
|
46
|
+
return nil unless applicable_table?(table)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
type = column_type(table, column)
|
|
49
|
+
return nil unless INTEGER_TYPES.include?(type)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
end
|
|
51
|
+
detection(
|
|
52
|
+
table: table,
|
|
53
|
+
columns: column,
|
|
54
|
+
message: "Comparing #{table}.#{column} (#{type}) with a numeric literal implicitly " \
|
|
55
|
+
"casts the column to numeric, disabling any index on #{column}.",
|
|
56
|
+
suggestion: "Use a literal that matches the column type (integer)."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
60
|
+
# A numeric/float literal, or an explicit cast to numeric of a literal.
|
|
61
|
+
def numeric_literal?(node)
|
|
62
|
+
case node
|
|
63
|
+
when PgQuery::A_Const
|
|
64
|
+
node.val == :fval
|
|
65
|
+
when PgQuery::TypeCast
|
|
66
|
+
type = node.type_name.names.string_values.last
|
|
67
|
+
NUMERIC_TYPE_NAMES.include?(type) && node.strip_casts.is_a?(PgQuery::A_Const)
|
|
68
|
+
else
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
71
|
end
|
|
72
|
-
end
|
|
73
72
|
end
|
|
74
73
|
end
|
|
75
74
|
end
|
|
@@ -8,28 +8,28 @@ module PgCanary
|
|
|
8
8
|
# - key extraction (->> etc.) compared against a value needs a matching
|
|
9
9
|
# expression index
|
|
10
10
|
class JsonbSearchWithoutGin < Base
|
|
11
|
+
default_enabled true
|
|
12
|
+
|
|
11
13
|
include IndexPredicates
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
true
|
|
15
|
-
end
|
|
15
|
+
using PgCanary::PgQueryRefinement
|
|
16
16
|
|
|
17
17
|
CONTAINMENT_OPS = %w[@> <@ ? ?| ?&].freeze
|
|
18
18
|
EXTRACTION_OPS = %w[-> ->> #> #>>].freeze
|
|
19
19
|
|
|
20
|
-
def check
|
|
20
|
+
def check
|
|
21
21
|
detections = []
|
|
22
|
-
|
|
22
|
+
each_scope do |scope|
|
|
23
23
|
next unless scope.where_clause
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
scope.where_clause.walk_scope do |node|
|
|
26
26
|
next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP
|
|
27
27
|
|
|
28
|
-
operator =
|
|
28
|
+
operator = node.operator
|
|
29
29
|
if CONTAINMENT_OPS.include?(operator)
|
|
30
|
-
detections << inspect_containment(
|
|
30
|
+
detections << inspect_containment(scope, node, operator)
|
|
31
31
|
elsif EXTRACTION_OPS.include?(operator)
|
|
32
|
-
detections << inspect_extraction(
|
|
32
|
+
detections << inspect_extraction(scope, node, operator)
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -38,67 +38,65 @@ module PgCanary
|
|
|
38
38
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
end
|
|
41
|
+
def inspect_containment(scope, expr, operator)
|
|
42
|
+
table, column = jsonb_column(scope, [expr.lexpr, expr.rexpr])
|
|
43
|
+
return nil unless table
|
|
44
|
+
return nil if gin_index_on?(table, column)
|
|
45
|
+
|
|
46
|
+
detection(
|
|
47
|
+
table: table,
|
|
48
|
+
columns: column,
|
|
49
|
+
message: "#{operator} search on jsonb column #{table}.#{column} has no GIN index " \
|
|
50
|
+
"and will scan every row in production.",
|
|
51
|
+
suggestion: <<~SUGGESTION.chomp
|
|
52
|
+
Consider a GIN index (jsonb_path_ops is smaller/faster if you only use @>):
|
|
53
|
+
CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
|
|
54
|
+
SUGGESTION
|
|
55
|
+
)
|
|
56
|
+
end
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
end
|
|
58
|
+
def inspect_extraction(scope, expr, operator)
|
|
59
|
+
table, column = jsonb_column(scope, [expr.lexpr])
|
|
60
|
+
return nil unless table
|
|
61
|
+
return nil if expression_index_referencing?(table, column)
|
|
62
|
+
|
|
63
|
+
key = extraction_key(expr)
|
|
64
|
+
expr_sql = "#{column} #{operator} #{key ? "'#{key}'" : "..."}"
|
|
65
|
+
detection(
|
|
66
|
+
table: table,
|
|
67
|
+
columns: column,
|
|
68
|
+
message: "Filtering on #{table}.#{expr_sql} has no matching expression index, " \
|
|
69
|
+
"so no index can serve this predicate.",
|
|
70
|
+
suggestion: <<~SUGGESTION.chomp
|
|
71
|
+
Consider an expression index on the extracted key:
|
|
72
|
+
CREATE INDEX index_#{table}_on_#{column}_key ON #{table} ((#{expr_sql.sub("...", "'key'")}));
|
|
73
|
+
SUGGESTION
|
|
74
|
+
)
|
|
75
|
+
end
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
# First side that is a jsonb-typed, resolvable column.
|
|
78
|
+
def jsonb_column(scope, sides)
|
|
79
|
+
sides.each do |side|
|
|
80
|
+
column_ref = side&.strip_casts
|
|
81
|
+
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
table, column = scope.resolve(column_ref)
|
|
84
|
+
next unless table && column
|
|
85
|
+
next unless applicable_table?(table)
|
|
86
|
+
next unless column_type(table, column) == "jsonb"
|
|
89
87
|
|
|
90
|
-
|
|
88
|
+
return [table, column]
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
91
|
end
|
|
92
|
-
nil
|
|
93
|
-
end
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
def extraction_key(expr)
|
|
94
|
+
const = expr.rexpr&.strip_casts
|
|
95
|
+
return nil unless const.is_a?(PgQuery::A_Const)
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
value = const.value
|
|
98
|
+
value.is_a?(String) ? value : nil
|
|
99
|
+
end
|
|
102
100
|
end
|
|
103
101
|
end
|
|
104
102
|
end
|
|
@@ -7,21 +7,21 @@ module PgCanary
|
|
|
7
7
|
# helps, so we stay silent when one exists on the column.
|
|
8
8
|
# Bind parameters ($1) are resolved from the event's binds.
|
|
9
9
|
class LeadingWildcardLike < Base
|
|
10
|
+
default_enabled true
|
|
11
|
+
|
|
10
12
|
include IndexPredicates
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
true
|
|
14
|
-
end
|
|
14
|
+
using PgCanary::PgQueryRefinement
|
|
15
15
|
|
|
16
16
|
LIKE_KINDS = %i[AEXPR_LIKE AEXPR_ILIKE].freeze
|
|
17
17
|
|
|
18
|
-
def check
|
|
18
|
+
def check
|
|
19
19
|
detections = []
|
|
20
|
-
|
|
20
|
+
each_scope do |scope|
|
|
21
21
|
next unless scope.where_clause
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
detections << inspect_expr(
|
|
23
|
+
scope.where_clause.walk_scope do |node|
|
|
24
|
+
detections << inspect_expr(scope, node) if like_expr?(node)
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
detections.compact
|
|
@@ -29,49 +29,48 @@ module PgCanary
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
def like_expr?(node)
|
|
33
|
+
node.is_a?(PgQuery::A_Expr) && LIKE_KINDS.include?(node.kind)
|
|
34
|
+
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
def inspect_expr(scope, expr)
|
|
37
|
+
column_ref = expr.lexpr&.strip_casts
|
|
38
|
+
return nil unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
pattern = pattern_value(expr.rexpr)
|
|
41
|
+
return nil unless pattern&.start_with?("%", "_")
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
table, column = scope.resolve(column_ref)
|
|
44
|
+
return nil unless table && column
|
|
45
|
+
return nil unless applicable_table?(table)
|
|
46
|
+
return nil if trgm_index?(table, column)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
48
|
+
operator = expr.kind == :AEXPR_ILIKE ? "ILIKE" : "LIKE"
|
|
49
|
+
detection(
|
|
50
|
+
table: table,
|
|
51
|
+
columns: column,
|
|
52
|
+
message: "Leading-wildcard #{operator} (#{pattern.inspect}) on #{table}.#{column} cannot use " \
|
|
53
|
+
"a btree index and will scan every row in production.",
|
|
54
|
+
suggestion: <<~SUGGESTION.chomp
|
|
55
|
+
Consider the pg_trgm extension with a GIN index:
|
|
56
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
57
|
+
CREATE INDEX index_#{table}_on_#{column}_trgm ON #{table} USING gin (#{column} gin_trgm_ops);
|
|
58
|
+
SUGGESTION
|
|
59
|
+
)
|
|
60
|
+
end
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
# Pattern string from a literal, a cast literal, or a bind parameter.
|
|
63
|
+
def pattern_value(rexpr)
|
|
64
|
+
node = rexpr&.strip_casts
|
|
65
|
+
case node
|
|
66
|
+
when PgQuery::A_Const
|
|
67
|
+
value = node.value
|
|
68
|
+
value.is_a?(String) ? value : nil
|
|
69
|
+
when PgQuery::ParamRef
|
|
70
|
+
value = bind_value(node.number)
|
|
71
|
+
value.is_a?(String) ? value : nil
|
|
72
|
+
end
|
|
73
73
|
end
|
|
74
|
-
end
|
|
75
74
|
end
|
|
76
75
|
end
|
|
77
76
|
end
|