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
data/lib/pg_canary/rules/base.rb
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
3
5
|
module PgCanary
|
|
4
6
|
module Rules
|
|
5
7
|
# Base class for detection rules.
|
|
6
8
|
#
|
|
7
|
-
# A rule
|
|
8
|
-
#
|
|
9
|
-
# (query.config); rules never reach for global state themselves.
|
|
9
|
+
# A rule instance is built per analyzed query, holds that query's state,
|
|
10
|
+
# and implements #check returning an array of Detection.
|
|
10
11
|
class Base
|
|
11
|
-
include PgQuerySupport
|
|
12
|
-
|
|
13
12
|
class << self
|
|
14
13
|
def all
|
|
15
14
|
subclasses.sort_by(&:name)
|
|
@@ -17,52 +16,104 @@ module PgCanary
|
|
|
17
16
|
|
|
18
17
|
# :leading_wildcard_like for LeadingWildcardLike
|
|
19
18
|
def rule_name
|
|
20
|
-
@rule_name ||= name.
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
@rule_name ||= name.demodulize.underscore.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def option(name, default:)
|
|
23
|
+
options[name] = default
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# Rule-specific options and their defaults, e.g. { threshold: 1000 }.
|
|
26
|
-
# Declared statically so RuleConfig can generate real accessors.
|
|
27
26
|
def options
|
|
28
|
-
{}
|
|
27
|
+
@options ||= {}
|
|
29
28
|
end
|
|
30
|
-
end
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
def default_enabled(value = nil)
|
|
31
|
+
if value.nil?
|
|
32
|
+
raise NotImplementedError, "#{self} must declare default_enabled" if @default_enabled.nil?
|
|
33
|
+
|
|
34
|
+
@default_enabled
|
|
35
|
+
else
|
|
36
|
+
@default_enabled = value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def enabled?(config)
|
|
41
|
+
setting = config.rules.public_send(rule_name).enabled
|
|
42
|
+
setting.nil? ? default_enabled : setting
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check(**state)
|
|
46
|
+
new(**state).check
|
|
47
|
+
end
|
|
34
48
|
end
|
|
35
49
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
def initialize(sql:, config:, connection:, parse_result:, scopes:, binds: [], type_casted_binds: nil)
|
|
51
|
+
@sql = sql
|
|
52
|
+
@config = config
|
|
53
|
+
@connection = connection
|
|
54
|
+
@parse_result = parse_result
|
|
55
|
+
@scopes = scopes
|
|
56
|
+
@binds = binds || []
|
|
57
|
+
@type_casted_binds = type_casted_binds
|
|
39
58
|
end
|
|
40
59
|
|
|
41
|
-
def
|
|
42
|
-
raise NotImplementedError, "#{self.class} must
|
|
60
|
+
def check
|
|
61
|
+
raise NotImplementedError, "#{self.class} must implement #check"
|
|
43
62
|
end
|
|
44
63
|
|
|
45
64
|
private
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
query.config.rules[self.class.rule_name]
|
|
49
|
-
end
|
|
66
|
+
attr_reader :sql, :config, :connection, :parse_result, :scopes
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
def each_scope(&)
|
|
69
|
+
scopes.each(&)
|
|
70
|
+
end
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
# Value for a ParamRef ($n, 1-based). nil when unknown.
|
|
73
|
+
def bind_value(number)
|
|
74
|
+
index = number - 1
|
|
75
|
+
return nil if index.negative?
|
|
76
|
+
|
|
77
|
+
casted = @type_casted_binds
|
|
78
|
+
return casted[index] if casted.is_a?(Array) && index < casted.length
|
|
79
|
+
|
|
80
|
+
bind = @binds[index]
|
|
81
|
+
return nil if bind.nil?
|
|
82
|
+
|
|
83
|
+
bind.respond_to?(:value_for_database) ? bind.value_for_database : bind
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def indexes(table)
|
|
87
|
+
SchemaIntrospection.indexes(connection, table)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def column_type(table, column)
|
|
91
|
+
SchemaIntrospection.column_type(connection, table, column)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# => { column_name => sql_type }
|
|
95
|
+
def column_types(table)
|
|
96
|
+
SchemaIntrospection.column_types(connection, table)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def rule_config
|
|
100
|
+
config.rules.public_send(self.class.rule_name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def applicable_table?(table)
|
|
104
|
+
!config.ignore_table?(table)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def detection(message:, suggestion: nil, table: nil, columns: nil)
|
|
108
|
+
Detection.new(
|
|
109
|
+
rule_name: self.class.rule_name,
|
|
110
|
+
sql: sql,
|
|
111
|
+
table: table,
|
|
112
|
+
columns: Array(columns),
|
|
113
|
+
message: message,
|
|
114
|
+
suggestion: suggestion
|
|
115
|
+
)
|
|
116
|
+
end
|
|
66
117
|
end
|
|
67
118
|
end
|
|
68
119
|
end
|
|
@@ -5,30 +5,30 @@ module PgCanary
|
|
|
5
5
|
# Searching an array column (@>, <@, &&, or value = ANY(column)) without
|
|
6
6
|
# a GIN index scans every row.
|
|
7
7
|
class ArraySearchWithoutGin < Base
|
|
8
|
+
default_enabled true
|
|
9
|
+
|
|
8
10
|
include IndexPredicates
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
true
|
|
12
|
-
end
|
|
12
|
+
using PgCanary::PgQueryRefinement
|
|
13
13
|
|
|
14
14
|
ARRAY_OPS = %w[@> <@ &&].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)
|
|
23
23
|
|
|
24
24
|
case node.kind
|
|
25
25
|
when :AEXPR_OP
|
|
26
|
-
operator =
|
|
26
|
+
operator = node.operator
|
|
27
27
|
next unless ARRAY_OPS.include?(operator)
|
|
28
28
|
|
|
29
|
-
detections << inspect_sides(
|
|
29
|
+
detections << inspect_sides(scope, [node.lexpr, node.rexpr], operator)
|
|
30
30
|
when :AEXPR_OP_ANY
|
|
31
|
-
detections << inspect_sides(
|
|
31
|
+
detections << inspect_sides(scope, [node.rexpr], "= ANY(column)")
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -37,35 +37,34 @@ module PgCanary
|
|
|
37
37
|
|
|
38
38
|
private
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
def inspect_sides(scope, sides, operator)
|
|
41
|
+
sides.each do |side|
|
|
42
|
+
column_ref = side&.strip_casts
|
|
43
|
+
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
table, column = scope.resolve(column_ref)
|
|
46
|
+
next unless table && column
|
|
47
|
+
next unless applicable_table?(table)
|
|
48
|
+
next unless array_type?(column_type(table, column))
|
|
49
|
+
next if gin_index_on?(table, column)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
return detection(
|
|
52
|
+
table: table,
|
|
53
|
+
columns: column,
|
|
54
|
+
message: "Array search (#{operator}) on #{table}.#{column} has no GIN index " \
|
|
55
|
+
"and will scan every row in production.",
|
|
56
|
+
suggestion: <<~SUGGESTION.chomp
|
|
57
|
+
Consider a GIN index on the array column:
|
|
58
|
+
CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
|
|
59
|
+
SUGGESTION
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
62
63
|
end
|
|
63
|
-
nil
|
|
64
|
-
end
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
def array_type?(sql_type)
|
|
66
|
+
sql_type&.end_with?("[]")
|
|
67
|
+
end
|
|
69
68
|
end
|
|
70
69
|
end
|
|
71
70
|
end
|
|
@@ -6,99 +6,98 @@ module PgCanary
|
|
|
6
6
|
# tables, or a comma join whose WHERE clause never connects the tables —
|
|
7
7
|
# produces a cross product whose row count is the product of both sides.
|
|
8
8
|
class CartesianJoin < Base
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
default_enabled true
|
|
10
|
+
|
|
11
|
+
using PgCanary::PgQueryRefinement
|
|
12
12
|
|
|
13
|
-
def check
|
|
13
|
+
def check
|
|
14
14
|
detections = []
|
|
15
|
-
|
|
15
|
+
each_scope do |scope|
|
|
16
16
|
scope.stmt.from_clause.each do |item|
|
|
17
|
-
each_join(
|
|
18
|
-
detections << join_detection(
|
|
17
|
+
each_join(item.unwrap) do |join|
|
|
18
|
+
detections << join_detection(join) if unconditioned?(join)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
|
-
detections << comma_detection(
|
|
21
|
+
detections << comma_detection(scope) if comma_cartesian?(scope)
|
|
22
22
|
end
|
|
23
23
|
detections
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
def each_join(node, &)
|
|
29
|
+
return unless node.is_a?(PgQuery::JoinExpr)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
yield node
|
|
32
|
+
each_join(node.larg&.unwrap, &)
|
|
33
|
+
each_join(node.rarg&.unwrap, &)
|
|
34
|
+
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
def unconditioned?(join)
|
|
37
|
+
join.jointype == :JOIN_INNER &&
|
|
38
|
+
join.quals.nil? &&
|
|
39
|
+
!join.is_natural &&
|
|
40
|
+
join.using_clause.empty? &&
|
|
41
|
+
real_table?(join.rarg) &&
|
|
42
|
+
joinable_side?(join.larg)
|
|
43
|
+
end
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
# Restrict to joins between real tables so that deliberate cross joins
|
|
46
|
+
# against functions (generate_series etc.) stay silent.
|
|
47
|
+
def real_table?(node)
|
|
48
|
+
node&.unwrap.is_a?(PgQuery::RangeVar)
|
|
49
|
+
end
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
def joinable_side?(node)
|
|
52
|
+
inner = node&.unwrap
|
|
53
|
+
inner.is_a?(PgQuery::RangeVar) || inner.is_a?(PgQuery::JoinExpr)
|
|
54
|
+
end
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
# FROM a, b (2+ plain tables) with no column-to-column equality in WHERE.
|
|
57
|
+
def comma_cartesian?(scope)
|
|
58
|
+
plain_tables = scope.stmt.from_clause.map(&:unwrap).grep(PgQuery::RangeVar)
|
|
59
|
+
return false if plain_tables.length < 2
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
!cross_table_equality?(scope)
|
|
62
|
+
end
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
def cross_table_equality?(scope)
|
|
65
|
+
return false unless scope.where_clause
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
found = false
|
|
68
|
+
scope.where_clause.walk_scope do |node|
|
|
69
|
+
next unless node.is_a?(PgQuery::A_Expr) && node.comparison?
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
left = node.lexpr&.unwrap
|
|
72
|
+
right = node.rexpr&.unwrap
|
|
73
|
+
next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
left_table, = scope.resolve(left)
|
|
76
|
+
right_table, = scope.resolve(right)
|
|
77
|
+
found ||= left_table && right_table && left_table != right_table
|
|
78
|
+
end
|
|
79
|
+
found
|
|
78
80
|
end
|
|
79
|
-
found
|
|
80
|
-
end
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
def join_detection(join)
|
|
83
|
+
tables = [join.larg, join.rarg].map(&:unwrap)
|
|
84
|
+
.grep(PgQuery::RangeVar).map(&:relname)
|
|
85
|
+
build(tables)
|
|
86
|
+
end
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
def comma_detection(scope)
|
|
89
|
+
build(scope.tables)
|
|
90
|
+
end
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
92
|
+
def build(tables)
|
|
93
|
+
detection(
|
|
94
|
+
table: tables.first,
|
|
95
|
+
message: "JOIN between #{tables.join(" and ")} has no join condition, producing a cross " \
|
|
96
|
+
"product — the result grows with the product of both tables' row counts.",
|
|
97
|
+
suggestion: "Add a join condition (ON / USING). If a cross product is really intended, " \
|
|
98
|
+
"add the table to config.ignore_tables to silence this."
|
|
99
|
+
)
|
|
100
|
+
end
|
|
102
101
|
end
|
|
103
102
|
end
|
|
104
103
|
end
|
|
@@ -5,26 +5,25 @@ module PgCanary
|
|
|
5
5
|
# A scalar subquery in the SELECT list that references the outer table
|
|
6
6
|
# runs once per result row (N+1 inside a single query).
|
|
7
7
|
class CorrelatedSubqueryInSelect < Base
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
default_enabled true
|
|
9
|
+
|
|
10
|
+
using PgCanary::PgQueryRefinement
|
|
11
11
|
|
|
12
|
-
def check
|
|
12
|
+
def check
|
|
13
13
|
detections = []
|
|
14
|
-
|
|
14
|
+
each_scope do |scope|
|
|
15
15
|
scope.stmt.target_list.each do |target|
|
|
16
|
-
res_target =
|
|
16
|
+
res_target = target.unwrap
|
|
17
17
|
next unless res_target.is_a?(PgQuery::ResTarget)
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
res_target.val.walk_scope do |node|
|
|
20
20
|
next unless node.is_a?(PgQuery::SubLink) && node.sub_link_type == :EXPR_SUBLINK
|
|
21
21
|
|
|
22
22
|
table, column = correlated_reference(node, scope)
|
|
23
23
|
next unless table && column
|
|
24
|
-
next unless applicable_table?(
|
|
24
|
+
next unless applicable_table?(table)
|
|
25
25
|
|
|
26
26
|
detections << detection(
|
|
27
|
-
query,
|
|
28
27
|
table: table,
|
|
29
28
|
columns: column,
|
|
30
29
|
message: "Scalar subquery in the SELECT list references #{table}.#{column} from the " \
|
|
@@ -40,37 +39,37 @@ module PgCanary
|
|
|
40
39
|
|
|
41
40
|
private
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# [table, column] of the first outer-scope reference inside the
|
|
43
|
+
# subquery, or nil when the subquery is self-contained.
|
|
44
|
+
def correlated_reference(sublink, outer_scope)
|
|
45
|
+
subselect = sublink.subselect&.unwrap
|
|
46
|
+
return nil unless subselect.is_a?(PgQuery::SelectStmt)
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
inner_names = inner_relation_names(subselect)
|
|
49
|
+
found = nil
|
|
50
|
+
subselect.walk do |node|
|
|
51
|
+
next unless found.nil? && node.is_a?(PgQuery::ColumnRef)
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
fields = node.field_names
|
|
54
|
+
next unless fields && fields.length >= 2
|
|
55
|
+
next if inner_names.include?(fields[-2])
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
found = outer_scope.resolve(node)
|
|
58
|
+
end
|
|
59
|
+
found
|
|
59
60
|
end
|
|
60
|
-
found
|
|
61
|
-
end
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
# Every relation name/alias visible anywhere inside the subquery.
|
|
63
|
+
def inner_relation_names(subselect)
|
|
64
|
+
names = []
|
|
65
|
+
subselect.walk do |node|
|
|
66
|
+
next unless node.is_a?(PgQuery::RangeVar)
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
names << node.relname
|
|
69
|
+
names << node.alias.aliasname if node.alias
|
|
70
|
+
end
|
|
71
|
+
names
|
|
71
72
|
end
|
|
72
|
-
names
|
|
73
|
-
end
|
|
74
73
|
end
|
|
75
74
|
end
|
|
76
75
|
end
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# SELECT COUNT(*) without WHERE. Because of MVCC,
|
|
6
6
|
# PostgreSQL has no O(1) row count — this scans the whole table.
|
|
7
7
|
class CountStarWithoutWhere < Base
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
default_enabled false
|
|
9
|
+
|
|
10
|
+
using PgCanary::PgQueryRefinement
|
|
11
11
|
|
|
12
|
-
def check
|
|
12
|
+
def check
|
|
13
13
|
detections = []
|
|
14
|
-
|
|
14
|
+
each_scope do |scope|
|
|
15
15
|
stmt = scope.stmt
|
|
16
16
|
next if stmt.where_clause || stmt.group_clause.any?
|
|
17
17
|
next unless count_star?(stmt)
|
|
@@ -20,10 +20,9 @@ module PgCanary
|
|
|
20
20
|
next unless tables.length == 1
|
|
21
21
|
|
|
22
22
|
table = tables.first
|
|
23
|
-
next unless applicable_table?(
|
|
23
|
+
next unless applicable_table?(table)
|
|
24
24
|
|
|
25
25
|
detections << detection(
|
|
26
|
-
query,
|
|
27
26
|
table: table,
|
|
28
27
|
message: "COUNT(*) without WHERE scans the whole #{table} table — PostgreSQL's MVCC " \
|
|
29
28
|
"has no O(1) row count.",
|
|
@@ -39,15 +38,15 @@ module PgCanary
|
|
|
39
38
|
|
|
40
39
|
private
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
def count_star?(stmt)
|
|
42
|
+
stmt.target_list.any? do |target|
|
|
43
|
+
res_target = target.unwrap
|
|
44
|
+
next false unless res_target.is_a?(PgQuery::ResTarget)
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
func = res_target.val&.unwrap
|
|
47
|
+
func.is_a?(PgQuery::FuncCall) && func.agg_star && func.function_name == "count"
|
|
48
|
+
end
|
|
49
49
|
end
|
|
50
|
-
end
|
|
51
50
|
end
|
|
52
51
|
end
|
|
53
52
|
end
|
|
@@ -7,25 +7,21 @@ module PgCanary
|
|
|
7
7
|
# bind ($n), which static SQL linters cannot do.
|
|
8
8
|
# Threshold: config.rules.deep_offset.threshold (default 1000).
|
|
9
9
|
class DeepOffset < Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
end
|
|
10
|
+
default_enabled true
|
|
11
|
+
option :threshold, default: 1000
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
{ threshold: 1000 }
|
|
16
|
-
end
|
|
13
|
+
using PgCanary::PgQueryRefinement
|
|
17
14
|
|
|
18
|
-
def check
|
|
19
|
-
threshold = rule_config
|
|
15
|
+
def check
|
|
16
|
+
threshold = rule_config.threshold
|
|
20
17
|
detections = []
|
|
21
|
-
|
|
18
|
+
each_scope do |scope|
|
|
22
19
|
next unless scope.stmt.limit_offset
|
|
23
20
|
|
|
24
|
-
value = numeric_value(
|
|
21
|
+
value = numeric_value(scope.stmt.limit_offset)
|
|
25
22
|
next unless value && value >= threshold
|
|
26
23
|
|
|
27
24
|
detections << detection(
|
|
28
|
-
query,
|
|
29
25
|
table: scope.tables.length == 1 ? scope.tables.first : nil,
|
|
30
26
|
message: "OFFSET #{value} reads and discards #{value} rows before returning anything — " \
|
|
31
27
|
"offset pagination degrades linearly with page depth.",
|
|
@@ -40,20 +36,20 @@ module PgCanary
|
|
|
40
36
|
|
|
41
37
|
private
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
def numeric_value(node)
|
|
40
|
+
node = node&.strip_casts
|
|
41
|
+
case node
|
|
42
|
+
when PgQuery::A_Const
|
|
43
|
+
value = node.value
|
|
44
|
+
value.is_a?(Numeric) ? value.to_i : nil
|
|
45
|
+
when PgQuery::ParamRef
|
|
46
|
+
begin
|
|
47
|
+
Integer(bind_value(node.number), exception: false)
|
|
48
|
+
rescue TypeError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
54
51
|
end
|
|
55
52
|
end
|
|
56
|
-
end
|
|
57
53
|
end
|
|
58
54
|
end
|
|
59
55
|
end
|