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
@@ -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 implements #check(query) and returns an array of Detection.
8
- # Configuration flows in explicitly through the QueryContext
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.split("::").last
21
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
22
- .downcase.to_sym
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
- def check(_query)
33
- raise NotImplementedError, "#{self.class} must implement #check"
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 enabled?(config)
37
- setting = config.rules[self.class.rule_name].enabled
38
- setting.nil? ? default_enabled : setting
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 default_enabled
42
- raise NotImplementedError, "#{self.class} must declare #default_enabled (true for Tier 1, false for Tier 2)"
60
+ def check
61
+ raise NotImplementedError, "#{self.class} must implement #check"
43
62
  end
44
63
 
45
64
  private
46
65
 
47
- def rule_config(query)
48
- query.config.rules[self.class.rule_name]
49
- end
66
+ attr_reader :sql, :config, :connection, :parse_result, :scopes
50
67
 
51
- def applicable_table?(query, table)
52
- !query.config.ignore_table?(table)
53
- end
68
+ def each_scope(&)
69
+ scopes.each(&)
70
+ end
54
71
 
55
- def detection(query, message:, suggestion: nil, table: nil, columns: nil)
56
- Detection.new(
57
- rule_name: self.class.rule_name,
58
- severity: rule_config(query).severity || :warning,
59
- sql: query.sql,
60
- table: table,
61
- columns: Array(columns),
62
- message: message,
63
- suggestion: suggestion
64
- )
65
- end
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
- def default_enabled
11
- true
12
- end
12
+ using PgCanary::PgQueryRefinement
13
13
 
14
14
  ARRAY_OPS = %w[@> <@ &&].freeze
15
15
 
16
- def check(query)
16
+ def check
17
17
  detections = []
18
- query.each_scope do |scope|
18
+ each_scope do |scope|
19
19
  next unless scope.where_clause
20
20
 
21
- walk_within_scope(scope.where_clause) do |node|
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 = operator_name(node)
26
+ operator = node.operator
27
27
  next unless ARRAY_OPS.include?(operator)
28
28
 
29
- detections << inspect_sides(query, scope, [node.lexpr, node.rexpr], operator)
29
+ detections << inspect_sides(scope, [node.lexpr, node.rexpr], operator)
30
30
  when :AEXPR_OP_ANY
31
- detections << inspect_sides(query, scope, [node.rexpr], "= ANY(column)")
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
- def inspect_sides(query, scope, sides, operator)
41
- sides.each do |side|
42
- column_ref = strip_type_casts(side)
43
- next unless column_ref.is_a?(PgQuery::ColumnRef)
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
- table, column = scope.resolve(column_ref)
46
- next unless table && column
47
- next unless applicable_table?(query, table)
48
- next unless array_type?(query.column_type(table, column))
49
- next if gin_index_on?(query, table, column)
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
- return detection(
52
- query,
53
- table: table,
54
- columns: column,
55
- message: "Array search (#{operator}) on #{table}.#{column} has no GIN index " \
56
- "and will scan every row in production.",
57
- suggestion: <<~SUGGESTION.chomp
58
- Consider a GIN index on the array column:
59
- CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
60
- SUGGESTION
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
- def array_type?(sql_type)
67
- sql_type&.end_with?("[]")
68
- end
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
- 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
  scope.stmt.from_clause.each do |item|
17
- each_join(unwrap_node(item)) do |join|
18
- detections << join_detection(query, join) if unconditioned?(join)
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(query, scope) if comma_cartesian?(scope)
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
- def each_join(node, &)
29
- return unless node.is_a?(PgQuery::JoinExpr)
28
+ def each_join(node, &)
29
+ return unless node.is_a?(PgQuery::JoinExpr)
30
30
 
31
- yield node
32
- each_join(unwrap_node(node.larg), &)
33
- each_join(unwrap_node(node.rarg), &)
34
- end
31
+ yield node
32
+ each_join(node.larg&.unwrap, &)
33
+ each_join(node.rarg&.unwrap, &)
34
+ end
35
35
 
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
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
- # 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
- unwrap_node(node).is_a?(PgQuery::RangeVar)
49
- end
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
- def joinable_side?(node)
52
- inner = unwrap_node(node)
53
- inner.is_a?(PgQuery::RangeVar) || inner.is_a?(PgQuery::JoinExpr)
54
- end
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
- # 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 { |n| unwrap_node(n) }.grep(PgQuery::RangeVar)
59
- return false if plain_tables.length < 2
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
- !cross_table_equality?(scope)
62
- end
61
+ !cross_table_equality?(scope)
62
+ end
63
63
 
64
- def cross_table_equality?(scope)
65
- return false unless scope.where_clause
64
+ def cross_table_equality?(scope)
65
+ return false unless scope.where_clause
66
66
 
67
- found = false
68
- walk_within_scope(scope.where_clause) do |node|
69
- next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
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
- left = unwrap_node(node.lexpr)
72
- right = unwrap_node(node.rexpr)
73
- next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
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
- left_table, = scope.resolve(left)
76
- right_table, = scope.resolve(right)
77
- found ||= left_table && right_table && left_table != right_table
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
- def join_detection(query, join)
83
- tables = [join.larg, join.rarg].map { |n| unwrap_node(n) }
84
- .grep(PgQuery::RangeVar).map(&:relname)
85
- build(query, tables)
86
- end
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
- def comma_detection(query, scope)
89
- build(query, scope.tables)
90
- end
88
+ def comma_detection(scope)
89
+ build(scope.tables)
90
+ end
91
91
 
92
- def build(query, tables)
93
- detection(
94
- query,
95
- table: tables.first,
96
- message: "JOIN between #{tables.join(" and ")} has no join condition, producing a cross " \
97
- "product the result grows with the product of both tables' row counts.",
98
- suggestion: "Add a join condition (ON / USING). If a cross product is really intended, " \
99
- "add the table to config.ignore_tables to silence this."
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
- 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.stmt.target_list.each do |target|
16
- res_target = unwrap_node(target)
16
+ res_target = target.unwrap
17
17
  next unless res_target.is_a?(PgQuery::ResTarget)
18
18
 
19
- walk_within_scope(res_target.val) do |node|
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?(query, 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
- # [table, column] of the first outer-scope reference inside the
44
- # subquery, or nil when the subquery is self-contained.
45
- def correlated_reference(sublink, outer_scope)
46
- subselect = unwrap_node(sublink.subselect)
47
- return nil unless subselect.is_a?(PgQuery::SelectStmt)
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
- inner_names = inner_relation_names(subselect)
50
- found = nil
51
- walk_ast(subselect) do |node|
52
- next unless found.nil? && node.is_a?(PgQuery::ColumnRef)
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
- fields = column_ref_fields(node)
55
- next unless fields && fields.length >= 2
56
- next if inner_names.include?(fields[-2])
53
+ fields = node.field_names
54
+ next unless fields && fields.length >= 2
55
+ next if inner_names.include?(fields[-2])
57
56
 
58
- found = outer_scope.resolve(node)
57
+ found = outer_scope.resolve(node)
58
+ end
59
+ found
59
60
  end
60
- found
61
- end
62
61
 
63
- # Every relation name/alias visible anywhere inside the subquery.
64
- def inner_relation_names(subselect)
65
- names = []
66
- walk_ast(subselect) do |node|
67
- next unless node.is_a?(PgQuery::RangeVar)
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
- names << node.relname
70
- names << node.alias.aliasname if node.alias
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
- # Tier 2 (opt-in): SELECT COUNT(*) without WHERE. Because of MVCC,
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
- def default_enabled
9
- false
10
- end
8
+ default_enabled false
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
  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?(query, 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
- def count_star?(stmt)
43
- stmt.target_list.any? do |target|
44
- res_target = unwrap_node(target)
45
- next false unless res_target.is_a?(PgQuery::ResTarget)
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
- func = unwrap_node(res_target.val)
48
- func.is_a?(PgQuery::FuncCall) && func.agg_star && function_name(func) == "count"
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
- def default_enabled
11
- true
12
- end
10
+ default_enabled true
11
+ option :threshold, default: 1000
13
12
 
14
- def self.options
15
- { threshold: 1000 }
16
- end
13
+ using PgCanary::PgQueryRefinement
17
14
 
18
- def check(query)
19
- threshold = rule_config(query).threshold
15
+ def check
16
+ threshold = rule_config.threshold
20
17
  detections = []
21
- query.each_scope do |scope|
18
+ each_scope do |scope|
22
19
  next unless scope.stmt.limit_offset
23
20
 
24
- value = numeric_value(query, scope.stmt.limit_offset)
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
- def numeric_value(query, node)
44
- node = strip_type_casts(node)
45
- case node
46
- when PgQuery::A_Const
47
- value = constant_value(node)
48
- value.is_a?(Numeric) ? value.to_i : nil
49
- when PgQuery::ParamRef
50
- begin
51
- Integer(query.bind_value(node.number), exception: false)
52
- rescue TypeError
53
- nil
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