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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +14 -28
  4. data/lib/pg_canary/configuration.rb +2 -28
  5. data/lib/pg_canary/detector.rb +64 -56
  6. data/lib/pg_canary/middleware.rb +58 -59
  7. data/lib/pg_canary/pg_query_refinement.rb +127 -0
  8. data/lib/pg_canary/rules/base.rb +87 -36
  9. data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +33 -34
  10. data/lib/pg_canary/rules/definitions/cartesian_join.rb +67 -68
  11. data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +32 -33
  12. data/lib/pg_canary/rules/definitions/count_star_without_where.rb +14 -15
  13. data/lib/pg_canary/rules/definitions/deep_offset.rb +19 -23
  14. data/lib/pg_canary/rules/definitions/distinct_with_join.rb +9 -10
  15. data/lib/pg_canary/rules/definitions/function_on_column.rb +46 -47
  16. data/lib/pg_canary/rules/definitions/huge_in_list.rb +24 -28
  17. data/lib/pg_canary/rules/definitions/implicit_cast.rb +42 -43
  18. data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +61 -63
  19. data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +43 -44
  20. data/lib/pg_canary/rules/definitions/not_in_subquery.rb +32 -33
  21. data/lib/pg_canary/rules/definitions/or_across_columns.rb +32 -33
  22. data/lib/pg_canary/rules/definitions/order_by_random.rb +7 -8
  23. data/lib/pg_canary/rules/definitions/query_complexity.rb +25 -28
  24. data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +17 -18
  25. data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +27 -31
  26. data/lib/pg_canary/rules/definitions/unindexed_join.rb +38 -39
  27. data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +9 -10
  28. data/lib/pg_canary/rules/definitions/unindexed_where.rb +59 -60
  29. data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +4 -7
  30. data/lib/pg_canary/rules/detection.rb +1 -1
  31. data/lib/pg_canary/rules/index_predicates.rb +20 -20
  32. data/lib/pg_canary/rules/schema_introspection.rb +74 -0
  33. data/lib/pg_canary/rules/scope.rb +74 -0
  34. data/lib/pg_canary/subscriber.rb +9 -9
  35. data/lib/pg_canary/version.rb +1 -1
  36. data/lib/pg_canary.rb +18 -18
  37. metadata +4 -4
  38. data/lib/pg_canary/pg_query_support.rb +0 -115
  39. data/lib/pg_canary/rules/query_context.rb +0 -147
  40. 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
- def default_enabled
10
- true
11
- end
9
+ default_enabled true
10
+
11
+ using PgCanary::PgQueryRefinement
12
12
 
13
- def check(query)
13
+ def check
14
14
  detections = []
15
- query.each_scope do |scope|
15
+ each_scope do |scope|
16
16
  next unless scope.where_clause
17
17
 
18
- walk_within_scope(scope.where_clause) do |node|
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 = unwrap_node(arg)
22
+ sublink = arg.unwrap
23
23
  next unless any_sublink?(sublink)
24
24
 
25
- detections << build(query, scope, sublink)
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
- def not_expr?(node)
35
- node.is_a?(PgQuery::BoolExpr) && node.boolop == :NOT_EXPR
36
- end
34
+ def not_expr?(node)
35
+ node.is_a?(PgQuery::BoolExpr) && node.boolop == :NOT_EXPR
36
+ end
37
37
 
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
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
- operator = string_values(node.oper_name).last
44
- operator.nil? || operator == "="
45
- end
43
+ operator = node.oper_name.string_values.last
44
+ operator.nil? || operator == "="
45
+ end
46
46
 
47
- def build(query, scope, sublink)
48
- test = strip_type_casts(sublink.testexpr)
49
- table, column = test.is_a?(PgQuery::ColumnRef) ? scope.resolve(test) : nil
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
- detection(
52
- query,
53
- table: table,
54
- columns: column,
55
- message: "NOT IN (SELECT ...) returns zero rows if the subquery yields even one NULL, " \
56
- "and the planner optimizes it poorly compared to an anti-join — a classic slow query.",
57
- suggestion: <<~SUGGESTION.chomp
58
- Consider rewriting to NOT EXISTS:
59
- SELECT ... FROM t WHERE NOT EXISTS (SELECT 1 FROM sub WHERE sub.ref_id = t.id)
60
- SUGGESTION
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
- # Tier 2 (opt-in): OR conditions spanning different columns. PostgreSQL
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
- def default_enabled
10
- false
11
- end
9
+ default_enabled false
10
+
11
+ using PgCanary::PgQueryRefinement
12
12
 
13
- def check(query)
13
+ def check
14
14
  detections = []
15
- query.each_scope do |scope|
15
+ each_scope do |scope|
16
16
  next unless scope.where_clause
17
17
 
18
18
  seen = []
19
- walk_within_scope(scope.where_clause) do |node|
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(query, scope, node)
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(query, columns)
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
- # Distinct (table, column) pairs among the OR branches' simple
36
- # column-vs-constant predicates.
37
- def predicate_columns(query, scope, bool_expr)
38
- columns = bool_expr.args.filter_map do |arg|
39
- expr = unwrap_node(arg)
40
- next nil unless expr.is_a?(PgQuery::A_Expr)
41
- next nil unless comparison_expr?(expr) || %i[AEXPR_IN AEXPR_LIKE AEXPR_ILIKE].include?(expr.kind)
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
- column_ref = strip_type_casts(expr.lexpr)
44
- next nil unless column_ref.is_a?(PgQuery::ColumnRef)
43
+ column_ref = expr.lexpr&.strip_casts
44
+ next nil unless column_ref.is_a?(PgQuery::ColumnRef)
45
45
 
46
- resolved = scope.resolve(column_ref)
47
- resolved if resolved && applicable_table?(query, resolved.first)
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
- def build(query, columns)
53
- column_list = columns.map { |table, column| "#{table}.#{column}" }.join(", ")
54
- detection(
55
- query,
56
- table: columns.first.first,
57
- columns: columns.map(&:last),
58
- message: "OR across different columns (#{column_list}) often prevents a single index scan.",
59
- suggestion: "Ensure each column has its own index (PostgreSQL can then BitmapOr them), " \
60
- "or split the query into a UNION of two indexed queries."
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
- def default_enabled
9
- true
10
- end
8
+ default_enabled true
9
+
10
+ using PgCanary::PgQueryRefinement
11
11
 
12
- def check(query)
12
+ def check
13
13
  detections = []
14
- query.each_scope do |scope|
14
+ each_scope do |scope|
15
15
  scope.sort_items.each do |sort_by|
16
- func = unwrap_node(sort_by.node)
16
+ func = sort_by.node&.unwrap
17
17
  next unless func.is_a?(PgQuery::FuncCall)
18
- next unless function_name(func) == "random"
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
- # Tier 2 (opt-in): "spaghetti query" guard — too many joins or too much
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
- def default_enabled
11
- false
12
- end
10
+ default_enabled false
11
+ option :max_joins, default: 8
12
+ option :max_depth, default: 4
13
13
 
14
- def self.options
15
- { max_joins: 8, max_depth: 4 }
16
- end
14
+ using PgCanary::PgQueryRefinement
17
15
 
18
- def check(query)
19
- max_joins = rule_config(query).max_joins
20
- max_depth = rule_config(query).max_depth
21
- stmt = query.parse_result.tree.stmts.first&.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
- def count_joins(stmt)
42
- joins = 0
43
- walk_ast(stmt) { |node| joins += 1 if node.is_a?(PgQuery::JoinExpr) }
44
- joins
45
- end
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
- deepest_child = 0
53
- each_ast_child(node) do |child|
54
- depth = max_select_depth(child)
55
- deepest_child = depth if depth > deepest_child
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
- def default_enabled
12
- true
13
- end
13
+ using PgCanary::PgQueryRefinement
14
14
 
15
15
  REGEX_OPS = %w[~ ~* !~ !~*].freeze
16
16
 
17
- def check(query)
17
+ def check
18
18
  detections = []
19
- query.each_scope do |scope|
19
+ each_scope do |scope|
20
20
  next unless scope.where_clause
21
21
 
22
- walk_within_scope(scope.where_clause) do |node|
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 = strip_type_casts(node.lexpr)
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?(query, table)
34
- next if trgm_index?(query, table, column)
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
- def display_operator(a_expr)
56
- case a_expr.kind
57
- when :AEXPR_OP
58
- operator = operator_name(a_expr)
59
- REGEX_OPS.include?(operator) ? operator : nil
60
- when :AEXPR_SIMILAR
61
- "SIMILAR TO"
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
- # Tier 2 (opt-in): SELECT * (ActiveRecord's default) on a table that has
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
- def default_enabled
11
- false
12
- end
10
+ default_enabled false
11
+ option :heavy_types, default: %w[bytea jsonb text].freeze
13
12
 
14
- def self.options
15
- { heavy_types: %w[bytea jsonb text].freeze }
16
- end
13
+ using PgCanary::PgQueryRefinement
17
14
 
18
- def check(query)
19
- heavy_types = rule_config(query).heavy_types
15
+ def check
16
+ heavy_types = rule_config.heavy_types
20
17
  detections = []
21
- query.each_scope do |scope|
18
+ each_scope do |scope|
22
19
  star_tables(scope).each do |table|
23
- next unless applicable_table?(query, table)
20
+ next unless applicable_table?(table)
24
21
 
25
- heavy = query.column_types(table).select { |_, type| heavy_types.include?(type) }.keys
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
- # Tables whose rows are fetched with a star target (t.* or bare *).
45
- def star_tables(scope)
46
- tables = []
47
- scope.stmt.target_list.each do |target|
48
- res_target = unwrap_node(target)
49
- next unless res_target.is_a?(PgQuery::ResTarget)
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
- column_ref = unwrap_node(res_target.val)
52
- next unless column_ref.is_a?(PgQuery::ColumnRef)
47
+ column_ref = res_target.val&.unwrap
48
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
53
49
 
54
- fields = column_ref.fields.map { |f| unwrap_node(f) }
55
- next unless fields.last.is_a?(PgQuery::A_Star)
50
+ fields = column_ref.fields.map(&:unwrap)
51
+ next unless fields.last.is_a?(PgQuery::A_Star)
56
52
 
57
- qualifier = fields[-2]
58
- if qualifier.is_a?(PgQuery::String)
59
- table = scope.aliases[qualifier.sval]
60
- tables << table if table
61
- else
62
- tables.concat(scope.tables)
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
- # Tier 2 (opt-in): join-condition columns with no index leading with
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
- def default_enabled
12
- false
13
- end
13
+ using PgCanary::PgQueryRefinement
14
14
 
15
- def check(query)
15
+ def check
16
16
  detections = []
17
- query.each_scope do |scope|
17
+ each_scope do |scope|
18
18
  join_columns(scope).each do |table, column|
19
- next unless applicable_table?(query, table)
20
- next if index_leading_with?(query, table, column)
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
- # Unique [table, column] pairs appearing in cross-table equality
41
- # conditions (JOIN ... ON and comma joins connected in WHERE).
42
- def join_columns(scope)
43
- columns = []
44
- scope.stmt.from_clause.each do |item|
45
- collect_join_quals(unwrap_node(item)) do |quals|
46
- columns.concat(equality_columns(scope, quals))
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
- def collect_join_quals(node, &)
54
- return unless node.is_a?(PgQuery::JoinExpr)
52
+ def collect_join_quals(node, &)
53
+ return unless node.is_a?(PgQuery::JoinExpr)
55
54
 
56
- yield node.quals if node.quals
57
- collect_join_quals(unwrap_node(node.larg), &)
58
- collect_join_quals(unwrap_node(node.rarg), &)
59
- end
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
- def equality_columns(scope, clause)
62
- columns = []
63
- walk_within_scope(clause) do |node|
64
- next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP && operator_name(node) == "="
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
- left = unwrap_node(node.lexpr)
67
- right = unwrap_node(node.rexpr)
68
- next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
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
- left_resolved = scope.resolve(left)
71
- right_resolved = scope.resolve(right)
72
- next unless left_resolved && right_resolved
73
- next if left_resolved.first == right_resolved.first # same table: not a join condition
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
- columns << left_resolved << right_resolved
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
- # Tier 2 (opt-in): ORDER BY x LIMIT n without an index led by x forces a
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
- def default_enabled
12
- false
13
- end
13
+ using PgCanary::PgQueryRefinement
14
14
 
15
- def check(query)
15
+ def check
16
16
  detections = []
17
- query.each_scope do |scope|
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 = unwrap_node(sort_by.node)
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?(query, table)
29
- next if index_leading_with?(query, table, column)
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 " \