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
@@ -2,23 +2,22 @@
2
2
 
3
3
  module PgCanary
4
4
  module Rules
5
- # Tier 2 (opt-in): DISTINCT combined with JOIN. Frequently the DISTINCT
5
+ # DISTINCT combined with JOIN. Frequently the DISTINCT
6
6
  # only exists to undo row fanout caused by the join — the work of
7
7
  # producing and then deduplicating the duplicates is wasted. Legitimate
8
8
  # uses exist, hence opt-in.
9
9
  class DistinctWithJoin < Base
10
- def default_enabled
11
- false
12
- end
10
+ default_enabled false
11
+
12
+ using PgCanary::PgQueryRefinement
13
13
 
14
- def check(query)
14
+ def check
15
15
  detections = []
16
- query.each_scope do |scope|
16
+ each_scope do |scope|
17
17
  next unless scope.stmt.distinct_clause.any?
18
18
  next unless joined?(scope)
19
19
 
20
20
  detections << detection(
21
- query,
22
21
  table: scope.tables.first,
23
22
  message: "DISTINCT combined with JOIN often hides row fanout from the join: duplicate rows " \
24
23
  "are produced and then sorted/hashed away.",
@@ -31,9 +30,9 @@ module PgCanary
31
30
 
32
31
  private
33
32
 
34
- def joined?(scope)
35
- scope.stmt.from_clause.any? { |item| unwrap_node(item).is_a?(PgQuery::JoinExpr) }
36
- end
33
+ def joined?(scope)
34
+ scope.stmt.from_clause.any? { |item| item.unwrap.is_a?(PgQuery::JoinExpr) }
35
+ end
37
36
  end
38
37
  end
39
38
  end
@@ -7,22 +7,22 @@ module PgCanary
7
7
  # Silent when a matching expression index (same function, same column)
8
8
  # exists.
9
9
  class FunctionOnColumn < Base
10
- def default_enabled
11
- true
12
- end
10
+ default_enabled true
11
+
12
+ using PgCanary::PgQueryRefinement
13
13
 
14
14
  CHECKED_KINDS = %i[AEXPR_OP AEXPR_LIKE AEXPR_ILIKE AEXPR_IN AEXPR_BETWEEN].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) && CHECKED_KINDS.include?(node.kind)
23
23
 
24
24
  [node.lexpr, node.rexpr].each do |side|
25
- detections << inspect_side(query, scope, side)
25
+ detections << inspect_side(scope, side)
26
26
  end
27
27
  end
28
28
  end
@@ -31,55 +31,54 @@ module PgCanary
31
31
 
32
32
  private
33
33
 
34
- # The first ColumnRef inside the function call (arguments may nest,
35
- # e.g. lower(trim(email))).
36
- def first_column_ref(node)
37
- found = nil
38
- walk_within_scope(node) do |msg|
39
- found ||= msg if msg.is_a?(PgQuery::ColumnRef)
34
+ # The first ColumnRef inside the function call (arguments may nest,
35
+ # e.g. lower(trim(email))).
36
+ def first_column_ref(node)
37
+ found = nil
38
+ node.walk_scope do |msg|
39
+ found ||= msg if msg.is_a?(PgQuery::ColumnRef)
40
+ end
41
+ found
40
42
  end
41
- found
42
- end
43
43
 
44
- def inspect_side(query, scope, side)
45
- func = strip_type_casts(side)
46
- return nil unless func.is_a?(PgQuery::FuncCall)
44
+ def inspect_side(scope, side)
45
+ func = side&.strip_casts
46
+ return nil unless func.is_a?(PgQuery::FuncCall)
47
47
 
48
- column_ref = first_column_ref(func)
49
- return nil unless column_ref
48
+ column_ref = first_column_ref(func)
49
+ return nil unless column_ref
50
50
 
51
- table, column = scope.resolve(column_ref)
52
- return nil unless table && column
53
- return nil unless applicable_table?(query, table)
51
+ table, column = scope.resolve(column_ref)
52
+ return nil unless table && column
53
+ return nil unless applicable_table?(table)
54
54
 
55
- func_name = function_name(func)
56
- return nil if expression_index?(query, table, column, func_name)
55
+ func_name = func.function_name
56
+ return nil if expression_index?(table, column, func_name)
57
57
 
58
- detection(
59
- query,
60
- table: table,
61
- columns: column,
62
- message: "#{table}.#{column} is wrapped in #{func_name}() inside WHERE, " \
63
- "so a plain index on #{column} cannot be used.",
64
- suggestion: <<~SUGGESTION.chomp
65
- Consider adding an expression index:
66
- CREATE INDEX index_#{table}_on_#{func_name}_#{column} ON #{table} ((#{func_name}(#{column})));
67
- SUGGESTION
68
- )
69
- end
58
+ detection(
59
+ table: table,
60
+ columns: column,
61
+ message: "#{table}.#{column} is wrapped in #{func_name}() inside WHERE, " \
62
+ "so a plain index on #{column} cannot be used.",
63
+ suggestion: <<~SUGGESTION.chomp
64
+ Consider adding an expression index:
65
+ CREATE INDEX index_#{table}_on_#{func_name}_#{column} ON #{table} ((#{func_name}(#{column})));
66
+ SUGGESTION
67
+ )
68
+ end
70
69
 
71
- # Matches by (function name, column name) word match against the index
72
- # expression SQL — a deliberate approximation that already avoids the
73
- # common false positives.
74
- def expression_index?(query, table, column, func_name)
75
- query.indexes(table).any? do |index|
76
- expressions = index.expressions
77
- next false unless expressions
70
+ # Matches by (function name, column name) word match against the index
71
+ # expression SQL — a deliberate approximation that already avoids the
72
+ # common false positives.
73
+ def expression_index?(table, column, func_name)
74
+ indexes(table).any? do |index|
75
+ expressions = index.expressions
76
+ next false unless expressions
78
77
 
79
- expressions.match?(/\b#{Regexp.escape(func_name)}\s*\(/i) &&
80
- expressions.match?(/\b#{Regexp.escape(column)}\b/)
78
+ expressions.match?(/\b#{Regexp.escape(func_name)}\s*\(/i) &&
79
+ expressions.match?(/\b#{Regexp.escape(column)}\b/)
80
+ end
81
81
  end
82
- end
83
82
  end
84
83
  end
85
84
  end
@@ -8,29 +8,25 @@ module PgCanary
8
8
  # SQL linters cannot see.
9
9
  # Threshold: config.rules.huge_in_list.threshold (default 500).
10
10
  class HugeInList < Base
11
- def default_enabled
12
- true
13
- end
11
+ default_enabled true
12
+ option :threshold, default: 500
14
13
 
15
- def self.options
16
- { threshold: 500 }
17
- end
14
+ using PgCanary::PgQueryRefinement
18
15
 
19
- def check(query)
20
- threshold = rule_config(query).threshold
16
+ def check
17
+ threshold = rule_config.threshold
21
18
  detections = []
22
- query.each_scope do |scope|
19
+ each_scope do |scope|
23
20
  next unless scope.where_clause
24
21
 
25
- walk_within_scope(scope.where_clause) do |node|
22
+ scope.where_clause.walk_scope do |node|
26
23
  next unless node.is_a?(PgQuery::A_Expr)
27
24
 
28
- count = value_count(query, node)
25
+ count = value_count(node)
29
26
  next unless count && count > threshold
30
27
 
31
28
  table, column = resolve_lexpr(scope, node)
32
29
  detections << detection(
33
- query,
34
30
  table: table,
35
31
  columns: column,
36
32
  message: "IN / ANY list with #{count} values (threshold: #{threshold}). Huge value lists " \
@@ -45,26 +41,26 @@ module PgCanary
45
41
 
46
42
  private
47
43
 
48
- def value_count(query, a_expr)
49
- case a_expr.kind
50
- when :AEXPR_IN
51
- list = unwrap_node(a_expr.rexpr)
52
- list.is_a?(PgQuery::List) ? list.items.length : nil
53
- when :AEXPR_OP_ANY
54
- param = strip_type_casts(a_expr.rexpr)
55
- return nil unless param.is_a?(PgQuery::ParamRef)
44
+ def value_count(a_expr)
45
+ case a_expr.kind
46
+ when :AEXPR_IN
47
+ list = a_expr.rexpr&.unwrap
48
+ list.is_a?(PgQuery::List) ? list.items.length : nil
49
+ when :AEXPR_OP_ANY
50
+ param = a_expr.rexpr&.strip_casts
51
+ return nil unless param.is_a?(PgQuery::ParamRef)
56
52
 
57
- value = query.bind_value(param.number)
58
- value.is_a?(Array) ? value.length : nil
53
+ value = bind_value(param.number)
54
+ value.is_a?(Array) ? value.length : nil
55
+ end
59
56
  end
60
- end
61
57
 
62
- def resolve_lexpr(scope, a_expr)
63
- column_ref = strip_type_casts(a_expr.lexpr)
64
- return nil unless column_ref.is_a?(PgQuery::ColumnRef)
58
+ def resolve_lexpr(scope, a_expr)
59
+ column_ref = a_expr.lexpr&.strip_casts
60
+ return nil unless column_ref.is_a?(PgQuery::ColumnRef)
65
61
 
66
- scope.resolve(column_ref)
67
- end
62
+ scope.resolve(column_ref)
63
+ end
68
64
  end
69
65
  end
70
66
  end
@@ -6,22 +6,22 @@ module PgCanary
6
6
  # (age = 1.5) makes PostgreSQL cast the *column* to numeric, which
7
7
  # disables its index. Restricted to cases provable from the AST alone.
8
8
  class ImplicitCast < Base
9
- def default_enabled
10
- true
11
- end
9
+ default_enabled true
10
+
11
+ using PgCanary::PgQueryRefinement
12
12
 
13
13
  INTEGER_TYPES = %w[smallint integer bigint].freeze
14
14
  NUMERIC_TYPE_NAMES = %w[numeric decimal float4 float8].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|
22
- next unless node.is_a?(PgQuery::A_Expr) && comparison_expr?(node)
21
+ scope.where_clause.walk_scope do |node|
22
+ next unless node.is_a?(PgQuery::A_Expr) && node.comparison?
23
23
 
24
- detections << inspect_comparison(query, scope, node)
24
+ detections << inspect_comparison(scope, node)
25
25
  end
26
26
  end
27
27
  detections.compact
@@ -29,47 +29,46 @@ module PgCanary
29
29
 
30
30
  private
31
31
 
32
- def inspect_comparison(query, scope, expr)
33
- left = unwrap_node(expr.lexpr)
34
- right = unwrap_node(expr.rexpr)
32
+ def inspect_comparison(scope, expr)
33
+ left = expr.lexpr&.unwrap
34
+ right = expr.rexpr&.unwrap
35
35
 
36
- column_ref, value = if left.is_a?(PgQuery::ColumnRef)
37
- [left, right]
38
- elsif right.is_a?(PgQuery::ColumnRef)
39
- [right, left]
40
- end
41
- return nil unless column_ref
42
- return nil unless numeric_literal?(value)
36
+ column_ref, value = if left.is_a?(PgQuery::ColumnRef)
37
+ [left, right]
38
+ elsif right.is_a?(PgQuery::ColumnRef)
39
+ [right, left]
40
+ end
41
+ return nil unless column_ref
42
+ return nil unless numeric_literal?(value)
43
43
 
44
- table, column = scope.resolve(column_ref)
45
- return nil unless table && column
46
- return nil unless applicable_table?(query, table)
44
+ table, column = scope.resolve(column_ref)
45
+ return nil unless table && column
46
+ return nil unless applicable_table?(table)
47
47
 
48
- column_type = query.column_type(table, column)
49
- return nil unless INTEGER_TYPES.include?(column_type)
48
+ type = column_type(table, column)
49
+ return nil unless INTEGER_TYPES.include?(type)
50
50
 
51
- detection(
52
- query,
53
- table: table,
54
- columns: column,
55
- message: "Comparing #{table}.#{column} (#{column_type}) with a numeric literal implicitly " \
56
- "casts the column to numeric, disabling any index on #{column}.",
57
- suggestion: "Use a literal that matches the column type (integer)."
58
- )
59
- end
51
+ detection(
52
+ table: table,
53
+ columns: column,
54
+ message: "Comparing #{table}.#{column} (#{type}) with a numeric literal implicitly " \
55
+ "casts the column to numeric, disabling any index on #{column}.",
56
+ suggestion: "Use a literal that matches the column type (integer)."
57
+ )
58
+ end
60
59
 
61
- # A numeric/float literal, or an explicit cast to numeric of a literal.
62
- def numeric_literal?(node)
63
- case node
64
- when PgQuery::A_Const
65
- node.val == :fval
66
- when PgQuery::TypeCast
67
- type = string_values(node.type_name.names).last
68
- NUMERIC_TYPE_NAMES.include?(type) && strip_type_casts(node).is_a?(PgQuery::A_Const)
69
- else
70
- false
60
+ # A numeric/float literal, or an explicit cast to numeric of a literal.
61
+ def numeric_literal?(node)
62
+ case node
63
+ when PgQuery::A_Const
64
+ node.val == :fval
65
+ when PgQuery::TypeCast
66
+ type = node.type_name.names.string_values.last
67
+ NUMERIC_TYPE_NAMES.include?(type) && node.strip_casts.is_a?(PgQuery::A_Const)
68
+ else
69
+ false
70
+ end
71
71
  end
72
- end
73
72
  end
74
73
  end
75
74
  end
@@ -8,28 +8,28 @@ module PgCanary
8
8
  # - key extraction (->> etc.) compared against a value needs a matching
9
9
  # expression index
10
10
  class JsonbSearchWithoutGin < Base
11
+ default_enabled true
12
+
11
13
  include IndexPredicates
12
14
 
13
- def default_enabled
14
- true
15
- end
15
+ using PgCanary::PgQueryRefinement
16
16
 
17
17
  CONTAINMENT_OPS = %w[@> <@ ? ?| ?&].freeze
18
18
  EXTRACTION_OPS = %w[-> ->> #> #>>].freeze
19
19
 
20
- def check(query)
20
+ def check
21
21
  detections = []
22
- query.each_scope do |scope|
22
+ each_scope do |scope|
23
23
  next unless scope.where_clause
24
24
 
25
- walk_within_scope(scope.where_clause) do |node|
25
+ scope.where_clause.walk_scope do |node|
26
26
  next unless node.is_a?(PgQuery::A_Expr) && node.kind == :AEXPR_OP
27
27
 
28
- operator = operator_name(node)
28
+ operator = node.operator
29
29
  if CONTAINMENT_OPS.include?(operator)
30
- detections << inspect_containment(query, scope, node, operator)
30
+ detections << inspect_containment(scope, node, operator)
31
31
  elsif EXTRACTION_OPS.include?(operator)
32
- detections << inspect_extraction(query, scope, node, operator)
32
+ detections << inspect_extraction(scope, node, operator)
33
33
  end
34
34
  end
35
35
  end
@@ -38,67 +38,65 @@ module PgCanary
38
38
 
39
39
  private
40
40
 
41
- def inspect_containment(query, scope, expr, operator)
42
- table, column = jsonb_column(query, scope, [expr.lexpr, expr.rexpr])
43
- return nil unless table
44
- return nil if gin_index_on?(query, table, column)
45
-
46
- detection(
47
- query,
48
- table: table,
49
- columns: column,
50
- message: "#{operator} search on jsonb column #{table}.#{column} has no GIN index " \
51
- "and will scan every row in production.",
52
- suggestion: <<~SUGGESTION.chomp
53
- Consider a GIN index (jsonb_path_ops is smaller/faster if you only use @>):
54
- CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
55
- SUGGESTION
56
- )
57
- end
41
+ def inspect_containment(scope, expr, operator)
42
+ table, column = jsonb_column(scope, [expr.lexpr, expr.rexpr])
43
+ return nil unless table
44
+ return nil if gin_index_on?(table, column)
45
+
46
+ detection(
47
+ table: table,
48
+ columns: column,
49
+ message: "#{operator} search on jsonb column #{table}.#{column} has no GIN index " \
50
+ "and will scan every row in production.",
51
+ suggestion: <<~SUGGESTION.chomp
52
+ Consider a GIN index (jsonb_path_ops is smaller/faster if you only use @>):
53
+ CREATE INDEX index_#{table}_on_#{column} ON #{table} USING gin (#{column});
54
+ SUGGESTION
55
+ )
56
+ end
58
57
 
59
- def inspect_extraction(query, scope, expr, operator)
60
- table, column = jsonb_column(query, scope, [expr.lexpr])
61
- return nil unless table
62
- return nil if expression_index_referencing?(query, table, column)
63
-
64
- key = extraction_key(expr)
65
- expr_sql = "#{column} #{operator} #{key ? "'#{key}'" : "..."}"
66
- detection(
67
- query,
68
- table: table,
69
- columns: column,
70
- message: "Filtering on #{table}.#{expr_sql} has no matching expression index, " \
71
- "so no index can serve this predicate.",
72
- suggestion: <<~SUGGESTION.chomp
73
- Consider an expression index on the extracted key:
74
- CREATE INDEX index_#{table}_on_#{column}_key ON #{table} ((#{expr_sql.sub("...", "'key'")}));
75
- SUGGESTION
76
- )
77
- end
58
+ def inspect_extraction(scope, expr, operator)
59
+ table, column = jsonb_column(scope, [expr.lexpr])
60
+ return nil unless table
61
+ return nil if expression_index_referencing?(table, column)
62
+
63
+ key = extraction_key(expr)
64
+ expr_sql = "#{column} #{operator} #{key ? "'#{key}'" : "..."}"
65
+ detection(
66
+ table: table,
67
+ columns: column,
68
+ message: "Filtering on #{table}.#{expr_sql} has no matching expression index, " \
69
+ "so no index can serve this predicate.",
70
+ suggestion: <<~SUGGESTION.chomp
71
+ Consider an expression index on the extracted key:
72
+ CREATE INDEX index_#{table}_on_#{column}_key ON #{table} ((#{expr_sql.sub("...", "'key'")}));
73
+ SUGGESTION
74
+ )
75
+ end
78
76
 
79
- # First side that is a jsonb-typed, resolvable column.
80
- def jsonb_column(query, scope, sides)
81
- sides.each do |side|
82
- column_ref = strip_type_casts(side)
83
- next unless column_ref.is_a?(PgQuery::ColumnRef)
77
+ # First side that is a jsonb-typed, resolvable column.
78
+ def jsonb_column(scope, sides)
79
+ sides.each do |side|
80
+ column_ref = side&.strip_casts
81
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
84
82
 
85
- table, column = scope.resolve(column_ref)
86
- next unless table && column
87
- next unless applicable_table?(query, table)
88
- next unless query.column_type(table, column) == "jsonb"
83
+ table, column = scope.resolve(column_ref)
84
+ next unless table && column
85
+ next unless applicable_table?(table)
86
+ next unless column_type(table, column) == "jsonb"
89
87
 
90
- return [table, column]
88
+ return [table, column]
89
+ end
90
+ nil
91
91
  end
92
- nil
93
- end
94
92
 
95
- def extraction_key(expr)
96
- const = strip_type_casts(expr.rexpr)
97
- return nil unless const.is_a?(PgQuery::A_Const)
93
+ def extraction_key(expr)
94
+ const = expr.rexpr&.strip_casts
95
+ return nil unless const.is_a?(PgQuery::A_Const)
98
96
 
99
- value = constant_value(const)
100
- value.is_a?(String) ? value : nil
101
- end
97
+ value = const.value
98
+ value.is_a?(String) ? value : nil
99
+ end
102
100
  end
103
101
  end
104
102
  end
@@ -7,21 +7,21 @@ module PgCanary
7
7
  # helps, so we stay silent when one exists on the column.
8
8
  # Bind parameters ($1) are resolved from the event's binds.
9
9
  class LeadingWildcardLike < Base
10
+ default_enabled true
11
+
10
12
  include IndexPredicates
11
13
 
12
- def default_enabled
13
- true
14
- end
14
+ using PgCanary::PgQueryRefinement
15
15
 
16
16
  LIKE_KINDS = %i[AEXPR_LIKE AEXPR_ILIKE].freeze
17
17
 
18
- def check(query)
18
+ def check
19
19
  detections = []
20
- query.each_scope do |scope|
20
+ each_scope do |scope|
21
21
  next unless scope.where_clause
22
22
 
23
- walk_within_scope(scope.where_clause) do |node|
24
- detections << inspect_expr(query, scope, node) if like_expr?(node)
23
+ scope.where_clause.walk_scope do |node|
24
+ detections << inspect_expr(scope, node) if like_expr?(node)
25
25
  end
26
26
  end
27
27
  detections.compact
@@ -29,49 +29,48 @@ module PgCanary
29
29
 
30
30
  private
31
31
 
32
- def like_expr?(node)
33
- node.is_a?(PgQuery::A_Expr) && LIKE_KINDS.include?(node.kind)
34
- end
32
+ def like_expr?(node)
33
+ node.is_a?(PgQuery::A_Expr) && LIKE_KINDS.include?(node.kind)
34
+ end
35
35
 
36
- def inspect_expr(query, scope, expr)
37
- column_ref = strip_type_casts(expr.lexpr)
38
- return nil unless column_ref.is_a?(PgQuery::ColumnRef)
36
+ def inspect_expr(scope, expr)
37
+ column_ref = expr.lexpr&.strip_casts
38
+ return nil unless column_ref.is_a?(PgQuery::ColumnRef)
39
39
 
40
- pattern = pattern_value(query, expr.rexpr)
41
- return nil unless pattern&.start_with?("%", "_")
40
+ pattern = pattern_value(expr.rexpr)
41
+ return nil unless pattern&.start_with?("%", "_")
42
42
 
43
- table, column = scope.resolve(column_ref)
44
- return nil unless table && column
45
- return nil unless applicable_table?(query, table)
46
- return nil if trgm_index?(query, table, column)
43
+ table, column = scope.resolve(column_ref)
44
+ return nil unless table && column
45
+ return nil unless applicable_table?(table)
46
+ return nil if trgm_index?(table, column)
47
47
 
48
- operator = expr.kind == :AEXPR_ILIKE ? "ILIKE" : "LIKE"
49
- detection(
50
- query,
51
- table: table,
52
- columns: column,
53
- message: "Leading-wildcard #{operator} (#{pattern.inspect}) on #{table}.#{column} cannot use " \
54
- "a btree index and will scan every row in production.",
55
- suggestion: <<~SUGGESTION.chomp
56
- Consider the pg_trgm extension with a GIN index:
57
- CREATE EXTENSION IF NOT EXISTS pg_trgm;
58
- CREATE INDEX index_#{table}_on_#{column}_trgm ON #{table} USING gin (#{column} gin_trgm_ops);
59
- SUGGESTION
60
- )
61
- end
48
+ operator = expr.kind == :AEXPR_ILIKE ? "ILIKE" : "LIKE"
49
+ detection(
50
+ table: table,
51
+ columns: column,
52
+ message: "Leading-wildcard #{operator} (#{pattern.inspect}) on #{table}.#{column} cannot use " \
53
+ "a btree index and will scan every row in production.",
54
+ suggestion: <<~SUGGESTION.chomp
55
+ Consider the pg_trgm extension with a GIN index:
56
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
57
+ CREATE INDEX index_#{table}_on_#{column}_trgm ON #{table} USING gin (#{column} gin_trgm_ops);
58
+ SUGGESTION
59
+ )
60
+ end
62
61
 
63
- # Pattern string from a literal, a cast literal, or a bind parameter.
64
- def pattern_value(query, rexpr)
65
- node = strip_type_casts(rexpr)
66
- case node
67
- when PgQuery::A_Const
68
- value = constant_value(node)
69
- value.is_a?(String) ? value : nil
70
- when PgQuery::ParamRef
71
- value = query.bind_value(node.number)
72
- value.is_a?(String) ? value : nil
62
+ # Pattern string from a literal, a cast literal, or a bind parameter.
63
+ def pattern_value(rexpr)
64
+ node = rexpr&.strip_casts
65
+ case node
66
+ when PgQuery::A_Const
67
+ value = node.value
68
+ value.is_a?(String) ? value : nil
69
+ when PgQuery::ParamRef
70
+ value = bind_value(node.number)
71
+ value.is_a?(String) ? value : nil
72
+ end
73
73
  end
74
- end
75
74
  end
76
75
  end
77
76
  end