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
|
@@ -6,23 +6,23 @@ module PgCanary
|
|
|
6
6
|
# NULL the whole predicate yields no rows, and the planner cannot use an
|
|
7
7
|
# anti-join as effectively as with NOT EXISTS.
|
|
8
8
|
class NotInSubquery < 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
|
next unless scope.where_clause
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
scope.where_clause.walk_scope do |node|
|
|
19
19
|
next unless not_expr?(node)
|
|
20
20
|
|
|
21
21
|
node.args.each do |arg|
|
|
22
|
-
sublink =
|
|
22
|
+
sublink = arg.unwrap
|
|
23
23
|
next unless any_sublink?(sublink)
|
|
24
24
|
|
|
25
|
-
detections << build(
|
|
25
|
+
detections << build(scope, sublink)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -31,35 +31,34 @@ module PgCanary
|
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
def not_expr?(node)
|
|
35
|
+
node.is_a?(PgQuery::BoolExpr) && node.boolop == :NOT_EXPR
|
|
36
|
+
end
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
# x NOT IN (SELECT ...) parses as NOT(SubLink ANY, "=" test).
|
|
39
|
+
def any_sublink?(node)
|
|
40
|
+
return false unless node.is_a?(PgQuery::SubLink)
|
|
41
|
+
return false unless node.sub_link_type == :ANY_SUBLINK
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
operator = node.oper_name.string_values.last
|
|
44
|
+
operator.nil? || operator == "="
|
|
45
|
+
end
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
def build(scope, sublink)
|
|
48
|
+
test = sublink.testexpr&.strip_casts
|
|
49
|
+
table, column = test.is_a?(PgQuery::ColumnRef) ? scope.resolve(test) : nil
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
end
|
|
51
|
+
detection(
|
|
52
|
+
table: table,
|
|
53
|
+
columns: column,
|
|
54
|
+
message: "NOT IN (SELECT ...) returns zero rows if the subquery yields even one NULL, " \
|
|
55
|
+
"and the planner optimizes it poorly compared to an anti-join — a classic slow query.",
|
|
56
|
+
suggestion: <<~SUGGESTION.chomp
|
|
57
|
+
Consider rewriting to NOT EXISTS:
|
|
58
|
+
SELECT ... FROM t WHERE NOT EXISTS (SELECT 1 FROM sub WHERE sub.ref_id = t.id)
|
|
59
|
+
SUGGESTION
|
|
60
|
+
)
|
|
61
|
+
end
|
|
63
62
|
end
|
|
64
63
|
end
|
|
65
64
|
end
|
|
@@ -2,29 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# OR conditions spanning different columns. PostgreSQL
|
|
6
6
|
# can sometimes combine per-column indexes with a BitmapOr, so this is a
|
|
7
7
|
# warning-level hint rather than a certainty.
|
|
8
8
|
class OrAcrossColumns < Base
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
default_enabled false
|
|
10
|
+
|
|
11
|
+
using PgCanary::PgQueryRefinement
|
|
12
12
|
|
|
13
|
-
def check
|
|
13
|
+
def check
|
|
14
14
|
detections = []
|
|
15
|
-
|
|
15
|
+
each_scope do |scope|
|
|
16
16
|
next unless scope.where_clause
|
|
17
17
|
|
|
18
18
|
seen = []
|
|
19
|
-
|
|
19
|
+
scope.where_clause.walk_scope do |node|
|
|
20
20
|
next unless node.is_a?(PgQuery::BoolExpr) && node.boolop == :OR_EXPR
|
|
21
21
|
|
|
22
|
-
columns = predicate_columns(
|
|
22
|
+
columns = predicate_columns(scope, node)
|
|
23
23
|
next if columns.length < 2
|
|
24
24
|
next if seen.include?(columns)
|
|
25
25
|
|
|
26
26
|
seen << columns
|
|
27
|
-
detections << build(
|
|
27
|
+
detections << build(columns)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
detections
|
|
@@ -32,34 +32,33 @@ module PgCanary
|
|
|
32
32
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
# Distinct (table, column) pairs among the OR branches' simple
|
|
36
|
+
# column-vs-constant predicates.
|
|
37
|
+
def predicate_columns(scope, bool_expr)
|
|
38
|
+
columns = bool_expr.args.filter_map do |arg|
|
|
39
|
+
expr = arg.unwrap
|
|
40
|
+
next nil unless expr.is_a?(PgQuery::A_Expr)
|
|
41
|
+
next nil unless expr.comparison? || %i[AEXPR_IN AEXPR_LIKE AEXPR_ILIKE].include?(expr.kind)
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
column_ref = expr.lexpr&.strip_casts
|
|
44
|
+
next nil unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
resolved = scope.resolve(column_ref)
|
|
47
|
+
resolved if resolved && applicable_table?(resolved.first)
|
|
48
|
+
end
|
|
49
|
+
columns.uniq.sort
|
|
48
50
|
end
|
|
49
|
-
columns.uniq.sort
|
|
50
|
-
end
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
end
|
|
52
|
+
def build(columns)
|
|
53
|
+
column_list = columns.map { |table, column| "#{table}.#{column}" }.join(", ")
|
|
54
|
+
detection(
|
|
55
|
+
table: columns.first.first,
|
|
56
|
+
columns: columns.map(&:last),
|
|
57
|
+
message: "OR across different columns (#{column_list}) often prevents a single index scan.",
|
|
58
|
+
suggestion: "Ensure each column has its own index (PostgreSQL can then BitmapOr them), " \
|
|
59
|
+
"or split the query into a UNION of two indexed queries."
|
|
60
|
+
)
|
|
61
|
+
end
|
|
63
62
|
end
|
|
64
63
|
end
|
|
65
64
|
end
|
|
@@ -5,20 +5,19 @@ module PgCanary
|
|
|
5
5
|
# ORDER BY RANDOM() sorts the entire result set just to pick rows —
|
|
6
6
|
# always suspicious regardless of table size.
|
|
7
7
|
class OrderByRandom < 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.sort_items.each do |sort_by|
|
|
16
|
-
func =
|
|
16
|
+
func = sort_by.node&.unwrap
|
|
17
17
|
next unless func.is_a?(PgQuery::FuncCall)
|
|
18
|
-
next unless function_name
|
|
18
|
+
next unless func.function_name == "random"
|
|
19
19
|
|
|
20
20
|
detections << detection(
|
|
21
|
-
query,
|
|
22
21
|
table: scope.tables.length == 1 ? scope.tables.first : nil,
|
|
23
22
|
message: "ORDER BY RANDOM() reads and sorts every row, so it gets slower " \
|
|
24
23
|
"in proportion to table size.",
|
|
@@ -2,23 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# "spaghetti query" guard — too many joins or too much
|
|
6
6
|
# subquery nesting. Thresholds:
|
|
7
7
|
# config.rules.query_complexity.max_joins (default 8)
|
|
8
8
|
# config.rules.query_complexity.max_depth (default 4)
|
|
9
9
|
class QueryComplexity < Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
default_enabled false
|
|
11
|
+
option :max_joins, default: 8
|
|
12
|
+
option :max_depth, default: 4
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
{ max_joins: 8, max_depth: 4 }
|
|
16
|
-
end
|
|
14
|
+
using PgCanary::PgQueryRefinement
|
|
17
15
|
|
|
18
|
-
def check
|
|
19
|
-
max_joins = rule_config
|
|
20
|
-
max_depth = rule_config
|
|
21
|
-
stmt =
|
|
16
|
+
def check
|
|
17
|
+
max_joins = rule_config.max_joins
|
|
18
|
+
max_depth = rule_config.max_depth
|
|
19
|
+
stmt = parse_result.tree.stmts.first&.stmt
|
|
22
20
|
return [] unless stmt
|
|
23
21
|
|
|
24
22
|
problems = []
|
|
@@ -29,7 +27,6 @@ module PgCanary
|
|
|
29
27
|
return [] if problems.empty?
|
|
30
28
|
|
|
31
29
|
[detection(
|
|
32
|
-
query,
|
|
33
30
|
message: "Query complexity exceeds thresholds: #{problems.join(", ")}.",
|
|
34
31
|
suggestion: "Consider splitting the query, precomputing intermediate results, " \
|
|
35
32
|
"or reviewing whether every join/subquery is necessary."
|
|
@@ -38,24 +35,24 @@ module PgCanary
|
|
|
38
35
|
|
|
39
36
|
private
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# Maximum nesting depth of SELECT statements (a flat query is 1).
|
|
48
|
-
def max_select_depth(node)
|
|
49
|
-
node = unwrap_node(node)
|
|
50
|
-
return 0 unless node.is_a?(Google::Protobuf::MessageExts)
|
|
38
|
+
def count_joins(stmt)
|
|
39
|
+
joins = 0
|
|
40
|
+
stmt.walk { |node| joins += 1 if node.is_a?(PgQuery::JoinExpr) }
|
|
41
|
+
joins
|
|
42
|
+
end
|
|
51
43
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
44
|
+
# Maximum nesting depth of SELECT statements (a flat query is 1).
|
|
45
|
+
def max_select_depth(node)
|
|
46
|
+
node = node.unwrap
|
|
47
|
+
return 0 unless node.is_a?(Google::Protobuf::MessageExts)
|
|
48
|
+
|
|
49
|
+
deepest_child = 0
|
|
50
|
+
node.each_child do |child|
|
|
51
|
+
depth = max_select_depth(child)
|
|
52
|
+
deepest_child = depth if depth > deepest_child
|
|
53
|
+
end
|
|
54
|
+
(node.is_a?(PgQuery::SelectStmt) ? 1 : 0) + deepest_child
|
|
56
55
|
end
|
|
57
|
-
(node.is_a?(PgQuery::SelectStmt) ? 1 : 0) + deepest_child
|
|
58
|
-
end
|
|
59
56
|
end
|
|
60
57
|
end
|
|
61
58
|
end
|
|
@@ -6,35 +6,34 @@ module PgCanary
|
|
|
6
6
|
# index; only a pg_trgm GIN/GiST index can serve it. The regex variant of
|
|
7
7
|
# leading_wildcard_like.
|
|
8
8
|
class RegexWithoutTrgm < Base
|
|
9
|
+
default_enabled true
|
|
10
|
+
|
|
9
11
|
include IndexPredicates
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
true
|
|
13
|
-
end
|
|
13
|
+
using PgCanary::PgQueryRefinement
|
|
14
14
|
|
|
15
15
|
REGEX_OPS = %w[~ ~* !~ !~*].freeze
|
|
16
16
|
|
|
17
|
-
def check
|
|
17
|
+
def check
|
|
18
18
|
detections = []
|
|
19
|
-
|
|
19
|
+
each_scope do |scope|
|
|
20
20
|
next unless scope.where_clause
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
scope.where_clause.walk_scope do |node|
|
|
23
23
|
next unless node.is_a?(PgQuery::A_Expr)
|
|
24
24
|
|
|
25
25
|
operator = display_operator(node)
|
|
26
26
|
next unless operator
|
|
27
27
|
|
|
28
|
-
column_ref =
|
|
28
|
+
column_ref = node.lexpr&.strip_casts
|
|
29
29
|
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
30
30
|
|
|
31
31
|
table, column = scope.resolve(column_ref)
|
|
32
32
|
next unless table && column
|
|
33
|
-
next unless applicable_table?(
|
|
34
|
-
next if trgm_index?(
|
|
33
|
+
next unless applicable_table?(table)
|
|
34
|
+
next if trgm_index?(table, column)
|
|
35
35
|
|
|
36
36
|
detections << detection(
|
|
37
|
-
query,
|
|
38
37
|
table: table,
|
|
39
38
|
columns: column,
|
|
40
39
|
message: "Regular-expression search (#{operator}) on #{table}.#{column} cannot use " \
|
|
@@ -52,15 +51,15 @@ module PgCanary
|
|
|
52
51
|
|
|
53
52
|
private
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
def display_operator(a_expr)
|
|
55
|
+
case a_expr.kind
|
|
56
|
+
when :AEXPR_OP
|
|
57
|
+
operator = a_expr.operator
|
|
58
|
+
REGEX_OPS.include?(operator) ? operator : nil
|
|
59
|
+
when :AEXPR_SIMILAR
|
|
60
|
+
"SIMILAR TO"
|
|
61
|
+
end
|
|
62
62
|
end
|
|
63
|
-
end
|
|
64
63
|
end
|
|
65
64
|
end
|
|
66
65
|
end
|
|
@@ -2,31 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# SELECT * (ActiveRecord's default) on a table that has
|
|
6
6
|
# heavy columns (bytea / text / jsonb) transfers those payloads on every
|
|
7
7
|
# query. Whether that matters depends on the data, hence opt-in.
|
|
8
8
|
# Heavy types: config.rules.select_star_with_heavy_columns.heavy_types.
|
|
9
9
|
class SelectStarWithHeavyColumns < Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
end
|
|
10
|
+
default_enabled false
|
|
11
|
+
option :heavy_types, default: %w[bytea jsonb text].freeze
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
{ heavy_types: %w[bytea jsonb text].freeze }
|
|
16
|
-
end
|
|
13
|
+
using PgCanary::PgQueryRefinement
|
|
17
14
|
|
|
18
|
-
def check
|
|
19
|
-
heavy_types = rule_config
|
|
15
|
+
def check
|
|
16
|
+
heavy_types = rule_config.heavy_types
|
|
20
17
|
detections = []
|
|
21
|
-
|
|
18
|
+
each_scope do |scope|
|
|
22
19
|
star_tables(scope).each do |table|
|
|
23
|
-
next unless applicable_table?(
|
|
20
|
+
next unless applicable_table?(table)
|
|
24
21
|
|
|
25
|
-
heavy =
|
|
22
|
+
heavy = column_types(table).select { |_, type| heavy_types.include?(type) }.keys
|
|
26
23
|
next if heavy.empty?
|
|
27
24
|
|
|
28
25
|
detections << detection(
|
|
29
|
-
query,
|
|
30
26
|
table: table,
|
|
31
27
|
columns: heavy,
|
|
32
28
|
message: "SELECT * on #{table} transfers its heavy columns (#{heavy.join(", ")}) " \
|
|
@@ -41,29 +37,29 @@ module PgCanary
|
|
|
41
37
|
|
|
42
38
|
private
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
# Tables whose rows are fetched with a star target (t.* or bare *).
|
|
41
|
+
def star_tables(scope)
|
|
42
|
+
tables = []
|
|
43
|
+
scope.stmt.target_list.each do |target|
|
|
44
|
+
res_target = target.unwrap
|
|
45
|
+
next unless res_target.is_a?(PgQuery::ResTarget)
|
|
50
46
|
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
column_ref = res_target.val&.unwrap
|
|
48
|
+
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
fields = column_ref.fields.map(&:unwrap)
|
|
51
|
+
next unless fields.last.is_a?(PgQuery::A_Star)
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
qualifier = fields[-2]
|
|
54
|
+
if qualifier.is_a?(PgQuery::String)
|
|
55
|
+
table = scope.aliases[qualifier.sval]
|
|
56
|
+
tables << table if table
|
|
57
|
+
else
|
|
58
|
+
tables.concat(scope.tables)
|
|
59
|
+
end
|
|
63
60
|
end
|
|
61
|
+
tables.uniq
|
|
64
62
|
end
|
|
65
|
-
tables.uniq
|
|
66
|
-
end
|
|
67
63
|
end
|
|
68
64
|
end
|
|
69
65
|
end
|
|
@@ -2,25 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# join-condition columns with no index leading with
|
|
6
6
|
# them. Unlike active_record_doctor's static association check, this
|
|
7
7
|
# looks at joins that actually ran, so raw-SQL joins are covered too.
|
|
8
8
|
class UnindexedJoin < Base
|
|
9
|
+
default_enabled false
|
|
10
|
+
|
|
9
11
|
include IndexPredicates
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
false
|
|
13
|
-
end
|
|
13
|
+
using PgCanary::PgQueryRefinement
|
|
14
14
|
|
|
15
|
-
def check
|
|
15
|
+
def check
|
|
16
16
|
detections = []
|
|
17
|
-
|
|
17
|
+
each_scope do |scope|
|
|
18
18
|
join_columns(scope).each do |table, column|
|
|
19
|
-
next unless applicable_table?(
|
|
20
|
-
next if index_leading_with?(
|
|
19
|
+
next unless applicable_table?(table)
|
|
20
|
+
next if index_leading_with?(table, column)
|
|
21
21
|
|
|
22
22
|
detections << detection(
|
|
23
|
-
query,
|
|
24
23
|
table: table,
|
|
25
24
|
columns: column,
|
|
26
25
|
message: "Join condition on #{table}.#{column} has no index leading with it — " \
|
|
@@ -37,45 +36,45 @@ module PgCanary
|
|
|
37
36
|
|
|
38
37
|
private
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
# Unique [table, column] pairs appearing in cross-table equality
|
|
40
|
+
# conditions (JOIN ... ON and comma joins connected in WHERE).
|
|
41
|
+
def join_columns(scope)
|
|
42
|
+
columns = []
|
|
43
|
+
scope.stmt.from_clause.each do |item|
|
|
44
|
+
collect_join_quals(item.unwrap) do |quals|
|
|
45
|
+
columns.concat(equality_columns(scope, quals))
|
|
46
|
+
end
|
|
47
47
|
end
|
|
48
|
+
columns.concat(equality_columns(scope, scope.where_clause)) if scope.where_clause
|
|
49
|
+
columns.uniq
|
|
48
50
|
end
|
|
49
|
-
columns.concat(equality_columns(scope, scope.where_clause)) if scope.where_clause
|
|
50
|
-
columns.uniq
|
|
51
|
-
end
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
def collect_join_quals(node, &)
|
|
53
|
+
return unless node.is_a?(PgQuery::JoinExpr)
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
yield node.quals if node.quals
|
|
56
|
+
collect_join_quals(node.larg&.unwrap, &)
|
|
57
|
+
collect_join_quals(node.rarg&.unwrap, &)
|
|
58
|
+
end
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
def equality_columns(scope, clause)
|
|
61
|
+
columns = []
|
|
62
|
+
clause.walk_scope do |node|
|
|
63
|
+
next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP && node.operator == "="
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
left = node.lexpr&.unwrap
|
|
66
|
+
right = node.rexpr&.unwrap
|
|
67
|
+
next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
left_resolved = scope.resolve(left)
|
|
70
|
+
right_resolved = scope.resolve(right)
|
|
71
|
+
next unless left_resolved && right_resolved
|
|
72
|
+
next if left_resolved.first == right_resolved.first # same table: not a join condition
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
columns << left_resolved << right_resolved
|
|
75
|
+
end
|
|
76
|
+
columns
|
|
76
77
|
end
|
|
77
|
-
columns
|
|
78
|
-
end
|
|
79
78
|
end
|
|
80
79
|
end
|
|
81
80
|
end
|
|
@@ -2,34 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
5
|
+
# ORDER BY x LIMIT n without an index led by x forces a
|
|
6
6
|
# full sort before the limit can apply. Size-dependent, so disabled by
|
|
7
7
|
# default.
|
|
8
8
|
class UnindexedOrderByWithLimit < Base
|
|
9
|
+
default_enabled false
|
|
10
|
+
|
|
9
11
|
include IndexPredicates
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
false
|
|
13
|
-
end
|
|
13
|
+
using PgCanary::PgQueryRefinement
|
|
14
14
|
|
|
15
|
-
def check
|
|
15
|
+
def check
|
|
16
16
|
detections = []
|
|
17
|
-
|
|
17
|
+
each_scope do |scope|
|
|
18
18
|
next unless scope.limited?
|
|
19
19
|
|
|
20
20
|
sort_by = scope.sort_items.first
|
|
21
21
|
next unless sort_by
|
|
22
22
|
|
|
23
|
-
column_ref =
|
|
23
|
+
column_ref = sort_by.node&.unwrap
|
|
24
24
|
next unless column_ref.is_a?(PgQuery::ColumnRef)
|
|
25
25
|
|
|
26
26
|
table, column = scope.resolve(column_ref)
|
|
27
27
|
next unless table && column
|
|
28
|
-
next unless applicable_table?(
|
|
29
|
-
next if index_leading_with?(
|
|
28
|
+
next unless applicable_table?(table)
|
|
29
|
+
next if index_leading_with?(table, column)
|
|
30
30
|
|
|
31
31
|
detections << detection(
|
|
32
|
-
query,
|
|
33
32
|
table: table,
|
|
34
33
|
columns: column,
|
|
35
34
|
message: "ORDER BY #{column} LIMIT sorts every row before the limit can apply " \
|