pg_canary 0.1.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +114 -0
  5. data/demo.png +0 -0
  6. data/lib/pg_canary/configuration.rb +80 -0
  7. data/lib/pg_canary/detector.rb +94 -0
  8. data/lib/pg_canary/middleware.rb +115 -0
  9. data/lib/pg_canary/pg_query_support.rb +115 -0
  10. data/lib/pg_canary/rules/base.rb +77 -0
  11. data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +75 -0
  12. data/lib/pg_canary/rules/definitions/cartesian_join.rb +108 -0
  13. data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +80 -0
  14. data/lib/pg_canary/rules/definitions/count_star_without_where.rb +57 -0
  15. data/lib/pg_canary/rules/definitions/deep_offset.rb +63 -0
  16. data/lib/pg_canary/rules/definitions/distinct_with_join.rb +43 -0
  17. data/lib/pg_canary/rules/definitions/function_on_column.rb +89 -0
  18. data/lib/pg_canary/rules/definitions/huge_in_list.rb +74 -0
  19. data/lib/pg_canary/rules/definitions/implicit_cast.rb +79 -0
  20. data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +108 -0
  21. data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +81 -0
  22. data/lib/pg_canary/rules/definitions/not_in_subquery.rb +69 -0
  23. data/lib/pg_canary/rules/definitions/or_across_columns.rb +69 -0
  24. data/lib/pg_canary/rules/definitions/order_by_random.rb +41 -0
  25. data/lib/pg_canary/rules/definitions/query_complexity.rb +65 -0
  26. data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +70 -0
  27. data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +73 -0
  28. data/lib/pg_canary/rules/definitions/unindexed_join.rb +85 -0
  29. data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +51 -0
  30. data/lib/pg_canary/rules/definitions/unindexed_where.rb +95 -0
  31. data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +30 -0
  32. data/lib/pg_canary/rules/detection.rb +21 -0
  33. data/lib/pg_canary/rules/index_predicates.rb +39 -0
  34. data/lib/pg_canary/rules/query_context.rb +147 -0
  35. data/lib/pg_canary/schema_introspection.rb +72 -0
  36. data/lib/pg_canary/subscriber.rb +41 -0
  37. data/lib/pg_canary/version.rb +5 -0
  38. data/lib/pg_canary.rb +77 -0
  39. metadata +126 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Searching an array column (@>, <@, &&, or value = ANY(column)) without
6
+ # a GIN index scans every row.
7
+ class ArraySearchWithoutGin < Base
8
+ include IndexPredicates
9
+
10
+ def default_enabled
11
+ true
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ ARRAY_OPS = %w[@> <@ &&].freeze
19
+
20
+ def check(query)
21
+ detections = []
22
+ query.each_scope do |scope|
23
+ next unless scope.where_clause
24
+
25
+ walk_within_scope(scope.where_clause) do |node|
26
+ next unless node.is_a?(PgQuery::A_Expr)
27
+
28
+ case node.kind
29
+ when :AEXPR_OP
30
+ operator = operator_name(node)
31
+ next unless ARRAY_OPS.include?(operator)
32
+
33
+ detections << inspect_sides(query, scope, [node.lexpr, node.rexpr], operator)
34
+ when :AEXPR_OP_ANY
35
+ detections << inspect_sides(query, scope, [node.rexpr], "= ANY(column)")
36
+ end
37
+ end
38
+ end
39
+ detections.compact
40
+ end
41
+
42
+ private
43
+
44
+ def inspect_sides(query, scope, sides, operator)
45
+ sides.each do |side|
46
+ column_ref = strip_type_casts(side)
47
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
48
+
49
+ table, column = scope.resolve(column_ref)
50
+ next unless table && column
51
+ next unless applicable_table?(query, table)
52
+ next unless array_type?(query.column_type(table, column))
53
+ next if gin_index_on?(query, table, column)
54
+
55
+ return detection(
56
+ query,
57
+ table: table,
58
+ columns: column,
59
+ message: "Array search (#{operator}) on #{table}.#{column} has no GIN index " \
60
+ "and will scan every row in production.",
61
+ suggestion: <<~SUGGESTION.chomp
62
+ Consider a GIN index on the array column:
63
+ CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
64
+ SUGGESTION
65
+ )
66
+ end
67
+ nil
68
+ end
69
+
70
+ def array_type?(sql_type)
71
+ sql_type&.end_with?("[]")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # A JOIN with no join condition — an explicit CROSS JOIN between real
6
+ # tables, or a comma join whose WHERE clause never connects the tables —
7
+ # produces a cross product whose row count is the product of both sides.
8
+ class CartesianJoin < Base
9
+ def default_enabled
10
+ true
11
+ end
12
+
13
+ def size_dependent?
14
+ false
15
+ end
16
+
17
+ def check(query)
18
+ detections = []
19
+ query.each_scope do |scope|
20
+ scope.stmt.from_clause.each do |item|
21
+ each_join(unwrap_node(item)) do |join|
22
+ detections << join_detection(query, join) if unconditioned?(join)
23
+ end
24
+ end
25
+ detections << comma_detection(query, scope) if comma_cartesian?(scope)
26
+ end
27
+ detections
28
+ end
29
+
30
+ private
31
+
32
+ def each_join(node, &)
33
+ return unless node.is_a?(PgQuery::JoinExpr)
34
+
35
+ yield node
36
+ each_join(unwrap_node(node.larg), &)
37
+ each_join(unwrap_node(node.rarg), &)
38
+ end
39
+
40
+ def unconditioned?(join)
41
+ join.jointype == :JOIN_INNER &&
42
+ join.quals.nil? &&
43
+ !join.is_natural &&
44
+ join.using_clause.empty? &&
45
+ real_table?(join.rarg) &&
46
+ joinable_side?(join.larg)
47
+ end
48
+
49
+ # Restrict to joins between real tables so that deliberate cross joins
50
+ # against functions (generate_series etc.) stay silent.
51
+ def real_table?(node)
52
+ unwrap_node(node).is_a?(PgQuery::RangeVar)
53
+ end
54
+
55
+ def joinable_side?(node)
56
+ inner = unwrap_node(node)
57
+ inner.is_a?(PgQuery::RangeVar) || inner.is_a?(PgQuery::JoinExpr)
58
+ end
59
+
60
+ # FROM a, b (2+ plain tables) with no column-to-column equality in WHERE.
61
+ def comma_cartesian?(scope)
62
+ plain_tables = scope.stmt.from_clause.map { |n| unwrap_node(n) }.grep(PgQuery::RangeVar)
63
+ return false if plain_tables.length < 2
64
+
65
+ !cross_table_equality?(scope)
66
+ end
67
+
68
+ def cross_table_equality?(scope)
69
+ return false unless scope.where_clause
70
+
71
+ found = false
72
+ walk_within_scope(scope.where_clause) do |node|
73
+ next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
74
+
75
+ left = unwrap_node(node.lexpr)
76
+ right = unwrap_node(node.rexpr)
77
+ next unless left.is_a?(PgQuery::ColumnRef) && right.is_a?(PgQuery::ColumnRef)
78
+
79
+ left_table, = scope.resolve(left)
80
+ right_table, = scope.resolve(right)
81
+ found ||= left_table && right_table && left_table != right_table
82
+ end
83
+ found
84
+ end
85
+
86
+ def join_detection(query, join)
87
+ tables = [join.larg, join.rarg].map { |n| unwrap_node(n) }
88
+ .grep(PgQuery::RangeVar).map(&:relname)
89
+ build(query, tables)
90
+ end
91
+
92
+ def comma_detection(query, scope)
93
+ build(query, scope.tables)
94
+ end
95
+
96
+ def build(query, tables)
97
+ detection(
98
+ query,
99
+ table: tables.first,
100
+ message: "JOIN between #{tables.join(" and ")} has no join condition, producing a cross " \
101
+ "product — the result grows with the product of both tables' row counts.",
102
+ suggestion: "Add a join condition (ON / USING). If a cross product is really intended, " \
103
+ "add the table to config.ignore_tables to silence this."
104
+ )
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # A scalar subquery in the SELECT list that references the outer table
6
+ # runs once per result row (N+1 inside a single query).
7
+ class CorrelatedSubqueryInSelect < Base
8
+ def default_enabled
9
+ true
10
+ end
11
+
12
+ def size_dependent?
13
+ false
14
+ end
15
+
16
+ def check(query)
17
+ detections = []
18
+ query.each_scope do |scope|
19
+ scope.stmt.target_list.each do |target|
20
+ res_target = unwrap_node(target)
21
+ next unless res_target.is_a?(PgQuery::ResTarget)
22
+
23
+ walk_within_scope(res_target.val) do |node|
24
+ next unless node.is_a?(PgQuery::SubLink) && node.sub_link_type == :EXPR_SUBLINK
25
+
26
+ table, column = correlated_reference(node, scope)
27
+ next unless table && column
28
+ next unless applicable_table?(query, table)
29
+
30
+ detections << detection(
31
+ query,
32
+ table: table,
33
+ columns: column,
34
+ message: "Scalar subquery in the SELECT list references #{table}.#{column} from the " \
35
+ "outer query, so it executes once per returned row.",
36
+ suggestion: "Rewrite as a JOIN + GROUP BY (or a LATERAL join) so the lookup runs " \
37
+ "once as a set operation."
38
+ )
39
+ end
40
+ end
41
+ end
42
+ detections
43
+ end
44
+
45
+ private
46
+
47
+ # [table, column] of the first outer-scope reference inside the
48
+ # subquery, or nil when the subquery is self-contained.
49
+ def correlated_reference(sublink, outer_scope)
50
+ subselect = unwrap_node(sublink.subselect)
51
+ return nil unless subselect.is_a?(PgQuery::SelectStmt)
52
+
53
+ inner_names = inner_relation_names(subselect)
54
+ found = nil
55
+ walk_ast(subselect) do |node|
56
+ next unless found.nil? && node.is_a?(PgQuery::ColumnRef)
57
+
58
+ fields = column_ref_fields(node)
59
+ next unless fields && fields.length >= 2
60
+ next if inner_names.include?(fields[-2])
61
+
62
+ found = outer_scope.resolve(node)
63
+ end
64
+ found
65
+ end
66
+
67
+ # Every relation name/alias visible anywhere inside the subquery.
68
+ def inner_relation_names(subselect)
69
+ names = []
70
+ walk_ast(subselect) do |node|
71
+ next unless node.is_a?(PgQuery::RangeVar)
72
+
73
+ names << node.relname
74
+ names << node.alias.aliasname if node.alias
75
+ end
76
+ names
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): SELECT COUNT(*) without WHERE. Because of MVCC,
6
+ # PostgreSQL has no O(1) row count — this scans the whole table.
7
+ class CountStarWithoutWhere < Base
8
+ def default_enabled
9
+ false
10
+ end
11
+
12
+ def size_dependent?
13
+ true
14
+ end
15
+
16
+ def check(query)
17
+ detections = []
18
+ query.each_scope do |scope|
19
+ stmt = scope.stmt
20
+ next if stmt.where_clause || stmt.group_clause.any?
21
+ next unless count_star?(stmt)
22
+
23
+ tables = scope.tables
24
+ next unless tables.length == 1
25
+
26
+ table = tables.first
27
+ next unless applicable_table?(query, table)
28
+
29
+ detections << detection(
30
+ query,
31
+ table: table,
32
+ message: "COUNT(*) without WHERE scans the whole #{table} table — PostgreSQL's MVCC " \
33
+ "has no O(1) row count.",
34
+ suggestion: <<~SUGGESTION.chomp
35
+ If an approximation is acceptable, use the planner's estimate:
36
+ SELECT reltuples::bigint FROM pg_class WHERE relname = '#{table}';
37
+ For exact counts displayed frequently, maintain a counter cache.
38
+ SUGGESTION
39
+ )
40
+ end
41
+ detections
42
+ end
43
+
44
+ private
45
+
46
+ def count_star?(stmt)
47
+ stmt.target_list.any? do |target|
48
+ res_target = unwrap_node(target)
49
+ next false unless res_target.is_a?(PgQuery::ResTarget)
50
+
51
+ func = unwrap_node(res_target.val)
52
+ func.is_a?(PgQuery::FuncCall) && func.agg_star && function_name(func) == "count"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # OFFSET-based pagination reads and throws away every skipped row, so
6
+ # deep pages degrade linearly. The offset value is read from the runtime
7
+ # bind ($n), which static SQL linters cannot do.
8
+ # Threshold: config.rules.deep_offset.threshold (default 1000).
9
+ class DeepOffset < Base
10
+ def default_enabled
11
+ true
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ def self.options
19
+ { threshold: 1000 }
20
+ end
21
+
22
+ def check(query)
23
+ threshold = rule_config(query).threshold
24
+ detections = []
25
+ query.each_scope do |scope|
26
+ next unless scope.stmt.limit_offset
27
+
28
+ value = numeric_value(query, scope.stmt.limit_offset)
29
+ next unless value && value >= threshold
30
+
31
+ detections << detection(
32
+ query,
33
+ table: scope.tables.length == 1 ? scope.tables.first : nil,
34
+ message: "OFFSET #{value} reads and discards #{value} rows before returning anything — " \
35
+ "offset pagination degrades linearly with page depth.",
36
+ suggestion: <<~SUGGESTION.chomp
37
+ Consider keyset pagination instead:
38
+ WHERE (created_at, id) < (:last_seen_created_at, :last_seen_id) ORDER BY created_at DESC, id DESC LIMIT n
39
+ SUGGESTION
40
+ )
41
+ end
42
+ detections
43
+ end
44
+
45
+ private
46
+
47
+ def numeric_value(query, node)
48
+ node = strip_type_casts(node)
49
+ case node
50
+ when PgQuery::A_Const
51
+ value = constant_value(node)
52
+ value.is_a?(Numeric) ? value.to_i : nil
53
+ when PgQuery::ParamRef
54
+ begin
55
+ Integer(query.bind_value(node.number), exception: false)
56
+ rescue TypeError
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): DISTINCT combined with JOIN. Frequently the DISTINCT
6
+ # only exists to undo row fanout caused by the join — the work of
7
+ # producing and then deduplicating the duplicates is wasted. Legitimate
8
+ # uses exist, hence opt-in.
9
+ class DistinctWithJoin < Base
10
+ def default_enabled
11
+ false
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ def check(query)
19
+ detections = []
20
+ query.each_scope do |scope|
21
+ next unless scope.stmt.distinct_clause.any?
22
+ next unless joined?(scope)
23
+
24
+ detections << detection(
25
+ query,
26
+ table: scope.tables.first,
27
+ message: "DISTINCT combined with JOIN often hides row fanout from the join: duplicate rows " \
28
+ "are produced and then sorted/hashed away.",
29
+ suggestion: "If DISTINCT only compensates for join duplication, rewrite the join as a " \
30
+ "semi-join: WHERE EXISTS (SELECT 1 FROM joined WHERE joined.ref_id = t.id)."
31
+ )
32
+ end
33
+ detections
34
+ end
35
+
36
+ private
37
+
38
+ def joined?(scope)
39
+ scope.stmt.from_clause.any? { |item| unwrap_node(item).is_a?(PgQuery::JoinExpr) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # A column wrapped in a function inside WHERE (lower(email) = ?,
6
+ # date(created_at) = ?) can never use a plain index on that column.
7
+ # Silent when a matching expression index (same function, same column)
8
+ # exists.
9
+ class FunctionOnColumn < Base
10
+ def default_enabled
11
+ true
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ CHECKED_KINDS = %i[AEXPR_OP AEXPR_LIKE AEXPR_ILIKE AEXPR_IN AEXPR_BETWEEN].freeze
19
+
20
+ def check(query)
21
+ detections = []
22
+ query.each_scope do |scope|
23
+ next unless scope.where_clause
24
+
25
+ walk_within_scope(scope.where_clause) do |node|
26
+ next unless node.is_a?(PgQuery::A_Expr) && CHECKED_KINDS.include?(node.kind)
27
+
28
+ [node.lexpr, node.rexpr].each do |side|
29
+ detections << inspect_side(query, scope, side)
30
+ end
31
+ end
32
+ end
33
+ detections.compact
34
+ end
35
+
36
+ private
37
+
38
+ # The first ColumnRef inside the function call (arguments may nest,
39
+ # e.g. lower(trim(email))).
40
+ def first_column_ref(node)
41
+ found = nil
42
+ walk_within_scope(node) do |msg|
43
+ found ||= msg if msg.is_a?(PgQuery::ColumnRef)
44
+ end
45
+ found
46
+ end
47
+
48
+ def inspect_side(query, scope, side)
49
+ func = strip_type_casts(side)
50
+ return nil unless func.is_a?(PgQuery::FuncCall)
51
+
52
+ column_ref = first_column_ref(func)
53
+ return nil unless column_ref
54
+
55
+ table, column = scope.resolve(column_ref)
56
+ return nil unless table && column
57
+ return nil unless applicable_table?(query, table)
58
+
59
+ func_name = function_name(func)
60
+ return nil if expression_index?(query, table, column, func_name)
61
+
62
+ detection(
63
+ query,
64
+ table: table,
65
+ columns: column,
66
+ message: "#{table}.#{column} is wrapped in #{func_name}() inside WHERE, " \
67
+ "so a plain index on #{column} cannot be used.",
68
+ suggestion: <<~SUGGESTION.chomp
69
+ Consider adding an expression index:
70
+ CREATE INDEX index_#{table}_on_#{func_name}_#{column} ON #{table} ((#{func_name}(#{column})));
71
+ SUGGESTION
72
+ )
73
+ end
74
+
75
+ # Matches by (function name, column name) word match against the index
76
+ # expression SQL — a deliberate approximation that already avoids the
77
+ # common false positives.
78
+ def expression_index?(query, table, column, func_name)
79
+ query.indexes(table).any? do |index|
80
+ expressions = index.expressions
81
+ next false unless expressions
82
+
83
+ expressions.match?(/\b#{Regexp.escape(func_name)}\s*\(/i) &&
84
+ expressions.match?(/\b#{Regexp.escape(column)}\b/)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # IN (...) / = ANY(...) with a huge number of values: the statement
6
+ # itself becomes expensive to parse/plan and usually signals a missing
7
+ # JOIN. Counts literal list items and runtime array binds, which static
8
+ # SQL linters cannot see.
9
+ # Threshold: config.rules.huge_in_list.threshold (default 500).
10
+ class HugeInList < Base
11
+ def default_enabled
12
+ true
13
+ end
14
+
15
+ def size_dependent?
16
+ false
17
+ end
18
+
19
+ def self.options
20
+ { threshold: 500 }
21
+ end
22
+
23
+ def check(query)
24
+ threshold = rule_config(query).threshold
25
+ detections = []
26
+ query.each_scope do |scope|
27
+ next unless scope.where_clause
28
+
29
+ walk_within_scope(scope.where_clause) do |node|
30
+ next unless node.is_a?(PgQuery::A_Expr)
31
+
32
+ count = value_count(query, node)
33
+ next unless count && count > threshold
34
+
35
+ table, column = resolve_lexpr(scope, node)
36
+ detections << detection(
37
+ query,
38
+ table: table,
39
+ columns: column,
40
+ message: "IN / ANY list with #{count} values (threshold: #{threshold}). Huge value lists " \
41
+ "are expensive to parse and plan, and usually mean a JOIN is missing.",
42
+ suggestion: "Rewrite as a JOIN against the source of the values (subquery or VALUES list) " \
43
+ "instead of materializing the ids in the query."
44
+ )
45
+ end
46
+ end
47
+ detections
48
+ end
49
+
50
+ private
51
+
52
+ def value_count(query, a_expr)
53
+ case a_expr.kind
54
+ when :AEXPR_IN
55
+ list = unwrap_node(a_expr.rexpr)
56
+ list.is_a?(PgQuery::List) ? list.items.length : nil
57
+ when :AEXPR_OP_ANY
58
+ param = strip_type_casts(a_expr.rexpr)
59
+ return nil unless param.is_a?(PgQuery::ParamRef)
60
+
61
+ value = query.bind_value(param.number)
62
+ value.is_a?(Array) ? value.length : nil
63
+ end
64
+ end
65
+
66
+ def resolve_lexpr(scope, a_expr)
67
+ column_ref = strip_type_casts(a_expr.lexpr)
68
+ return nil unless column_ref.is_a?(PgQuery::ColumnRef)
69
+
70
+ scope.resolve(column_ref)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Comparing an integer-family column with a numeric/float literal
6
+ # (age = 1.5) makes PostgreSQL cast the *column* to numeric, which
7
+ # disables its index. Restricted to cases provable from the AST alone.
8
+ class ImplicitCast < Base
9
+ def default_enabled
10
+ true
11
+ end
12
+
13
+ def size_dependent?
14
+ false
15
+ end
16
+
17
+ INTEGER_TYPES = %w[smallint integer bigint].freeze
18
+ NUMERIC_TYPE_NAMES = %w[numeric decimal float4 float8].freeze
19
+
20
+ def check(query)
21
+ detections = []
22
+ query.each_scope do |scope|
23
+ next unless scope.where_clause
24
+
25
+ walk_within_scope(scope.where_clause) do |node|
26
+ next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
27
+
28
+ detections << inspect_comparison(query, scope, node)
29
+ end
30
+ end
31
+ detections.compact
32
+ end
33
+
34
+ private
35
+
36
+ def inspect_comparison(query, scope, expr)
37
+ left = unwrap_node(expr.lexpr)
38
+ right = unwrap_node(expr.rexpr)
39
+
40
+ column_ref, value = if left.is_a?(PgQuery::ColumnRef)
41
+ [left, right]
42
+ elsif right.is_a?(PgQuery::ColumnRef)
43
+ [right, left]
44
+ end
45
+ return nil unless column_ref
46
+ return nil unless numeric_literal?(value)
47
+
48
+ table, column = scope.resolve(column_ref)
49
+ return nil unless table && column
50
+ return nil unless applicable_table?(query, table)
51
+
52
+ column_type = query.column_type(table, column)
53
+ return nil unless INTEGER_TYPES.include?(column_type)
54
+
55
+ detection(
56
+ query,
57
+ table: table,
58
+ columns: column,
59
+ message: "Comparing #{table}.#{column} (#{column_type}) with a numeric literal implicitly " \
60
+ "casts the column to numeric, disabling any index on #{column}.",
61
+ suggestion: "Use a literal that matches the column type (integer)."
62
+ )
63
+ end
64
+
65
+ # A numeric/float literal, or an explicit cast to numeric of a literal.
66
+ def numeric_literal?(node)
67
+ case node
68
+ when PgQuery::A_Const
69
+ node.val == :fval
70
+ when PgQuery::TypeCast
71
+ type = string_values(node.type_name.names).last
72
+ NUMERIC_TYPE_NAMES.include?(type) && strip_type_casts(node).is_a?(PgQuery::A_Const)
73
+ else
74
+ false
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end