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,90 +2,89 @@
2
2
 
3
3
  module PgCanary
4
4
  module Rules
5
- # Tier 2 (opt-in): equality/range predicates on columns with no index
5
+ # equality/range predicates on columns with no index
6
6
  # whose leading column could serve them. Depends on production table
7
7
  # size — a 30-row lookup table is fine without indexes — hence disabled
8
8
  # by default.
9
9
  class UnindexedWhere < Base
10
- def default_enabled
11
- false
12
- end
10
+ default_enabled false
11
+
12
+ using PgCanary::PgQueryRefinement
13
13
 
14
- def check(query)
15
- query.each_scope.with_object([]) do |scope, detections|
14
+ def check
15
+ each_scope.with_object([]) do |scope, detections|
16
16
  next unless scope.where_clause
17
17
 
18
- predicate_columns(query, scope).each do |table, columns|
19
- next unless applicable_table?(query, table)
20
- next if served_by_index?(query, table, columns)
18
+ predicate_columns(scope).each do |table, columns|
19
+ next unless applicable_table?(table)
20
+ next if served_by_index?(table, columns)
21
21
 
22
- detections << build(query, table, columns)
22
+ detections << build(table, columns)
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- # => { table => [column, ...] } for plain-column predicates in WHERE.
30
- def predicate_columns(_query, scope)
31
- result = Hash.new { |h, k| h[k] = [] }
32
- walk_within_scope(scope.where_clause) do |node|
33
- next unless node.is_a?(PgQuery::A_Expr) && indexable_predicate?(node)
29
+ # => { table => [column, ...] } for plain-column predicates in WHERE.
30
+ def predicate_columns(scope)
31
+ result = Hash.new { |h, k| h[k] = [] }
32
+ scope.where_clause.walk_scope do |node|
33
+ next unless node.is_a?(PgQuery::A_Expr) && indexable_predicate?(node)
34
34
 
35
- [node.lexpr, node.rexpr].each do |side|
36
- column_ref = unwrap_node(side)
37
- next unless column_ref.is_a?(PgQuery::ColumnRef)
38
- next unless constant_side?(node, side)
35
+ [node.lexpr, node.rexpr].each do |side|
36
+ column_ref = side&.unwrap
37
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
38
+ next unless constant_side?(node, side)
39
39
 
40
- table, column = scope.resolve(column_ref)
41
- result[table] << column if table && column
40
+ table, column = scope.resolve(column_ref)
41
+ result[table] << column if table && column
42
+ end
42
43
  end
44
+ result.transform_values(&:uniq)
43
45
  end
44
- result.transform_values(&:uniq)
45
- end
46
46
 
47
- def indexable_predicate?(a_expr)
48
- comparison_expr?(a_expr) ||
49
- %i[AEXPR_IN AEXPR_BETWEEN AEXPR_LIKE AEXPR_ILIKE].include?(a_expr.kind)
50
- end
47
+ def indexable_predicate?(a_expr)
48
+ a_expr.comparison? ||
49
+ %i[AEXPR_IN AEXPR_BETWEEN AEXPR_LIKE AEXPR_ILIKE].include?(a_expr.kind)
50
+ end
51
51
 
52
- # Only count `column <op> constant-ish` predicates; column-to-column
53
- # comparisons (join conditions in WHERE) are not our business here.
54
- def constant_side?(a_expr, column_side)
55
- other = a_expr.lexpr.equal?(column_side) ? a_expr.rexpr : a_expr.lexpr
56
- node = strip_type_casts(other)
57
- case node
58
- when PgQuery::A_Const, PgQuery::ParamRef
59
- true
60
- when PgQuery::List
61
- node.items.all? { |i| strip_type_casts(i).is_a?(PgQuery::A_Const) || strip_type_casts(i).is_a?(PgQuery::ParamRef) }
62
- else
63
- false
52
+ # Only count `column <op> constant-ish` predicates; column-to-column
53
+ # comparisons (join conditions in WHERE) are not our business here.
54
+ def constant_side?(a_expr, column_side)
55
+ other = a_expr.lexpr.equal?(column_side) ? a_expr.rexpr : a_expr.lexpr
56
+ node = other&.strip_casts
57
+ case node
58
+ when PgQuery::A_Const, PgQuery::ParamRef
59
+ true
60
+ when PgQuery::List
61
+ node.items.all? { |i| i.strip_casts.is_a?(PgQuery::A_Const) || i.strip_casts.is_a?(PgQuery::ParamRef) }
62
+ else
63
+ false
64
+ end
64
65
  end
65
- end
66
66
 
67
- # Leftmost-prefix rule: an index helps when its leading column is one
68
- # of the predicate columns.
69
- def served_by_index?(query, table, columns)
70
- query.indexes(table).any? do |index|
71
- index.leading_column && columns.include?(index.leading_column)
67
+ # Leftmost-prefix rule: an index helps when its leading column is one
68
+ # of the predicate columns.
69
+ def served_by_index?(table, columns)
70
+ indexes(table).any? do |index|
71
+ index.leading_column && columns.include?(index.leading_column)
72
+ end
72
73
  end
73
- end
74
74
 
75
- def build(query, table, columns)
76
- column_list = columns.join(", ")
77
- detection(
78
- query,
79
- table: table,
80
- columns: columns,
81
- message: "No index leads with any of the WHERE predicate columns (#{table}.#{column_list}). " \
82
- "Depending on production row counts, this becomes a full scan.",
83
- suggestion: <<~SUGGESTION.chomp
84
- Consider adding an index (equality columns first):
85
- CREATE INDEX index_#{table}_on_#{columns.join("_and_")} ON #{table} (#{column_list});
86
- SUGGESTION
87
- )
88
- end
75
+ def build(table, columns)
76
+ column_list = columns.join(", ")
77
+ detection(
78
+ table: table,
79
+ columns: columns,
80
+ message: "No index leads with any of the WHERE predicate columns (#{table}.#{column_list}). " \
81
+ "Depending on production row counts, this becomes a full scan.",
82
+ suggestion: <<~SUGGESTION.chomp
83
+ Consider adding an index (equality columns first):
84
+ CREATE INDEX index_#{table}_on_#{columns.join("_and_")} ON #{table} (#{column_list});
85
+ SUGGESTION
86
+ )
87
+ end
89
88
  end
90
89
  end
91
90
  end
@@ -2,21 +2,18 @@
2
2
 
3
3
  module PgCanary
4
4
  module Rules
5
- # Tier 2 (opt-in): UNION without ALL deduplicates the combined result by
5
+ # UNION without ALL deduplicates the combined result by
6
6
  # sorting/hashing all rows. When the branches cannot overlap (or
7
7
  # duplicates are acceptable), UNION ALL skips that work. Whether
8
8
  # deduplication is intended is the author's call, hence opt-in.
9
9
  class UnionInsteadOfUnionAll < Base
10
- def default_enabled
11
- false
12
- end
10
+ default_enabled false
13
11
 
14
- def check(query)
15
- union = query.scopes.find { |scope| scope.stmt.op == :SETOP_UNION && !scope.stmt.all }
12
+ def check
13
+ union = scopes.find { |scope| scope.stmt.op == :SETOP_UNION && !scope.stmt.all }
16
14
  return [] unless union
17
15
 
18
16
  [detection(
19
- query,
20
17
  message: "UNION (without ALL) sorts/hashes the entire combined result to remove duplicates.",
21
18
  suggestion: "If the branches cannot produce overlapping rows — or duplicates are acceptable — use UNION ALL."
22
19
  )]
@@ -8,7 +8,7 @@ module PgCanary
8
8
  # fired, on which query, which table/columns are involved, why it is a
9
9
  # problem, and how to fix it.
10
10
  Detection = Struct.new(
11
- :rule_name, :severity, :sql, :table, :columns,
11
+ :rule_name, :sql, :table, :columns,
12
12
  :message, :suggestion, :location, :fingerprint,
13
13
  keyword_init: true
14
14
  ) do
@@ -9,31 +9,31 @@ module PgCanary
9
9
 
10
10
  private
11
11
 
12
- def trgm_index?(query, table, column)
13
- query.indexes(table).any? do |index|
14
- TRGM_ACCESS_METHODS.include?(index.using) &&
15
- index.columns.include?(column) &&
16
- index.opclasses[column].to_s.include?("trgm")
12
+ def trgm_index?(table, column)
13
+ indexes(table).any? do |index|
14
+ TRGM_ACCESS_METHODS.include?(index.using) &&
15
+ index.columns.include?(column) &&
16
+ index.opclasses[column].to_s.include?("trgm")
17
+ end
17
18
  end
18
- end
19
19
 
20
- def gin_index_on?(query, table, column)
21
- query.indexes(table).any? { |index| index.using == "gin" && index.columns.include?(column) }
22
- end
20
+ def gin_index_on?(table, column)
21
+ indexes(table).any? { |index| index.using == "gin" && index.columns.include?(column) }
22
+ end
23
23
 
24
- # Any expression index whose expression mentions the column.
25
- # Deliberately loose (column-level word match, not expression
26
- # equality) to avoid false positives.
27
- def expression_index_referencing?(query, table, column)
28
- query.indexes(table).any? do |index|
29
- index.expressions&.match?(/\b#{Regexp.escape(column)}\b/)
24
+ # Any expression index whose expression mentions the column.
25
+ # Deliberately loose (column-level word match, not expression
26
+ # equality) to avoid false positives.
27
+ def expression_index_referencing?(table, column)
28
+ indexes(table).any? do |index|
29
+ index.expressions&.match?(/\b#{Regexp.escape(column)}\b/)
30
+ end
30
31
  end
31
- end
32
32
 
33
- # Whether any index's leading column is +column+ (leftmost-prefix rule).
34
- def index_leading_with?(query, table, column)
35
- query.indexes(table).any? { |index| index.leading_column == column }
36
- end
33
+ # Whether any index's leading column is +column+ (leftmost-prefix rule).
34
+ def index_leading_with?(table, column)
35
+ indexes(table).any? { |index| index.leading_column == column }
36
+ end
37
37
  end
38
38
  end
39
39
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Index and column metadata for the rules, read through ActiveRecord's
6
+ # schema cache — caching, invalidation and thread safety ride on Rails.
7
+ # The catalog queries ActiveRecord issues are named "SCHEMA", which the
8
+ # Subscriber already ignores, so they are never analyzed themselves.
9
+ module SchemaIntrospection
10
+ IndexInfo = Struct.new(
11
+ :name, # index name
12
+ :using, # access method: "btree", "gin", "gist", ...
13
+ :columns, # [String] — empty for expression indexes
14
+ :opclasses, # { column => opclass } for non-default opclasses
15
+ :expressions, # SQL string of the indexed expressions, or nil
16
+ keyword_init: true
17
+ ) do
18
+ def leading_column
19
+ columns.first
20
+ end
21
+ end
22
+
23
+ module_function
24
+
25
+ # => [IndexInfo] — includes the primary key, which ActiveRecord's
26
+ # #indexes omits.
27
+ def indexes(connection, table)
28
+ cache = connection.schema_cache
29
+ return [] unless cache.data_source_exists?(table)
30
+
31
+ list = cache.indexes(table).map { |definition| index_info(definition) }
32
+ primary_key = Array(cache.primary_keys(table))
33
+ if primary_key.any?
34
+ list << IndexInfo.new(name: "#{table}_pkey", using: "btree",
35
+ columns: primary_key, opclasses: {}, expressions: nil)
36
+ end
37
+ list
38
+ rescue StandardError => e
39
+ PgCanary.internal_error(e)
40
+ []
41
+ end
42
+
43
+ def column_type(connection, table, column)
44
+ column_types(connection, table)[column]
45
+ end
46
+
47
+ # => { column_name => sql_type } e.g. { "id" => "bigint", "tags" => "text[]" }
48
+ def column_types(connection, table)
49
+ cache = connection.schema_cache
50
+ return {} unless cache.data_source_exists?(table)
51
+
52
+ cache.columns(table).to_h do |column|
53
+ type = column.sql_type
54
+ type = "#{type}[]" if column.respond_to?(:array?) && column.array?
55
+ [column.name, type]
56
+ end
57
+ rescue StandardError => e
58
+ PgCanary.internal_error(e)
59
+ {}
60
+ end
61
+
62
+ # ActiveRecord returns plain-column indexes with an Array of column
63
+ # names, and expression indexes with the expressions as one SQL String.
64
+ def index_info(definition)
65
+ expressions = definition.columns.is_a?(String) ? definition.columns : nil
66
+ columns = expressions ? [] : Array(definition.columns)
67
+ opclasses = definition.opclasses
68
+ opclasses = columns.to_h { |c| [c, opclasses.to_s] } unless opclasses.is_a?(Hash)
69
+ IndexInfo.new(name: definition.name, using: definition.using.to_s,
70
+ columns: columns, opclasses: opclasses, expressions: expressions)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # One SELECT scope (top-level statement, CTE, subquery, ...) with its
6
+ # own FROM-clause alias resolution.
7
+ class Scope
8
+ using PgCanary::PgQueryRefinement
9
+
10
+ attr_reader :stmt, :aliases
11
+
12
+ def initialize(stmt)
13
+ @stmt = stmt
14
+ @aliases = {}
15
+ collect_aliases(stmt.from_clause)
16
+ end
17
+
18
+ def where_clause
19
+ stmt.where_clause
20
+ end
21
+
22
+ # => [PgQuery::SortBy]
23
+ def sort_items
24
+ stmt.sort_clause.map(&:unwrap).grep(PgQuery::SortBy)
25
+ end
26
+
27
+ def limited?
28
+ !stmt.limit_count.nil?
29
+ end
30
+
31
+ # Real table names referenced directly in this scope's FROM clause.
32
+ def tables
33
+ @aliases.values.compact.uniq
34
+ end
35
+
36
+ # Resolves a ColumnRef to [table, column]. Returns nil when the ref
37
+ # cannot be attributed to a real table unambiguously (unknown alias,
38
+ # subquery output, multiple candidate tables for an unqualified ref).
39
+ def resolve(column_ref)
40
+ fields = column_ref.field_names
41
+ return nil if fields.nil? || fields.empty?
42
+
43
+ column = fields.last
44
+ if fields.length >= 2
45
+ table = @aliases[fields[-2]]
46
+ table ? [table, column] : nil
47
+ else
48
+ tables.length == 1 ? [tables.first, column] : nil
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def collect_aliases(from_clause)
55
+ from_clause.each { |node| collect_from_node(node.unwrap) }
56
+ end
57
+
58
+ def collect_from_node(node)
59
+ case node
60
+ when PgQuery::RangeVar
61
+ name = node.alias&.aliasname
62
+ name = node.relname if name.nil? || name.empty?
63
+ @aliases[name] = node.relname
64
+ when PgQuery::JoinExpr
65
+ collect_from_node(node.larg&.unwrap)
66
+ collect_from_node(node.rarg&.unwrap)
67
+ when PgQuery::RangeSubselect
68
+ name = node.alias&.aliasname
69
+ @aliases[name] = nil if name && !name.empty?
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -25,17 +25,17 @@ module PgCanary
25
25
 
26
26
  private
27
27
 
28
- def analysis_target?(payload)
29
- return false if payload[:cached]
30
- return false if payload[:name] && IGNORED_NAMES.include?(payload[:name])
31
- return false unless payload[:sql]&.match?(SELECT_PREFIX)
28
+ def analysis_target?(payload)
29
+ return false if payload[:cached]
30
+ return false if payload[:name] && IGNORED_NAMES.include?(payload[:name])
31
+ return false unless payload[:sql]&.match?(SELECT_PREFIX)
32
32
 
33
- !!payload[:connection]&.adapter_name&.match?(/postg/i)
34
- end
33
+ !!payload[:connection]&.adapter_name&.match?(/postg/i)
34
+ end
35
35
 
36
- def detector
37
- @detector ||= Detector.new(PgCanary.config)
38
- end
36
+ def detector
37
+ @detector ||= Detector.new(PgCanary.config)
38
+ end
39
39
  end
40
40
  end
41
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgCanary
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pg_canary.rb CHANGED
@@ -5,10 +5,10 @@ require "pg_query"
5
5
 
6
6
  require_relative "pg_canary/version"
7
7
  require_relative "pg_canary/configuration"
8
- require_relative "pg_canary/pg_query_support"
9
- require_relative "pg_canary/schema_introspection"
8
+ require_relative "pg_canary/pg_query_refinement"
9
+ require_relative "pg_canary/rules/schema_introspection"
10
10
  require_relative "pg_canary/rules/detection"
11
- require_relative "pg_canary/rules/query_context"
11
+ require_relative "pg_canary/rules/scope"
12
12
  require_relative "pg_canary/rules/base"
13
13
  require_relative "pg_canary/rules/index_predicates"
14
14
  Dir[File.join(__dir__, "pg_canary", "rules", "definitions", "*.rb")].each { |file| require file }
@@ -35,24 +35,24 @@ module PgCanary
35
35
 
36
36
  private
37
37
 
38
- def logger
39
- config.logger || default_logger
40
- end
38
+ def logger
39
+ config.logger || default_logger
40
+ end
41
41
 
42
- def default_logger
43
- @default_logger ||= begin
44
- require "logger"
45
- ::Logger.new($stderr)
42
+ def default_logger
43
+ @default_logger ||= begin
44
+ require "logger"
45
+ ::Logger.new($stderr)
46
+ end
46
47
  end
47
- end
48
48
 
49
- def warn_once(message)
50
- @warned ||= {}
51
- return if @warned[message]
49
+ def warn_once(message)
50
+ @warned ||= {}
51
+ return if @warned[message]
52
52
 
53
- @warned[message] = true
54
- logger.warn(message)
55
- end
53
+ @warned[message] = true
54
+ logger.warn(message)
55
+ end
56
56
  end
57
57
 
58
58
  if defined?(Rails::Railtie)
@@ -67,7 +67,7 @@ module PgCanary
67
67
 
68
68
  config.after_initialize do
69
69
  config = PgCanary.config
70
- config.enabled = Rails.env.development? || Rails.env.test? if config.enabled.nil?
70
+ config.enabled = Rails.env.development? if config.enabled.nil?
71
71
  config.logger ||= Rails.logger
72
72
  Subscriber.subscribe! if config.enabled
73
73
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_canary
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyuuri1791
@@ -68,7 +68,7 @@ files:
68
68
  - lib/pg_canary/configuration.rb
69
69
  - lib/pg_canary/detector.rb
70
70
  - lib/pg_canary/middleware.rb
71
- - lib/pg_canary/pg_query_support.rb
71
+ - lib/pg_canary/pg_query_refinement.rb
72
72
  - lib/pg_canary/rules/base.rb
73
73
  - lib/pg_canary/rules/definitions/array_search_without_gin.rb
74
74
  - lib/pg_canary/rules/definitions/cartesian_join.rb
@@ -93,8 +93,8 @@ files:
93
93
  - lib/pg_canary/rules/definitions/union_instead_of_union_all.rb
94
94
  - lib/pg_canary/rules/detection.rb
95
95
  - lib/pg_canary/rules/index_predicates.rb
96
- - lib/pg_canary/rules/query_context.rb
97
- - lib/pg_canary/schema_introspection.rb
96
+ - lib/pg_canary/rules/schema_introspection.rb
97
+ - lib/pg_canary/rules/scope.rb
98
98
  - lib/pg_canary/subscriber.rb
99
99
  - lib/pg_canary/version.rb
100
100
  homepage: https://github.com/kyuuri1791/pg_canary
@@ -1,115 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module PgCanary
4
- # Helpers over the pg_query protobuf AST — the unwrapping, traversal and
5
- # value extraction the pg_query gem does not provide itself. Mix into
6
- # anything that inspects parsed queries (include for instance level,
7
- # extend for class level).
8
- module PgQuerySupport
9
- COMPARISON_OPS = %w[= <> != < > <= >=].freeze
10
-
11
- private
12
-
13
- # --- unwrapping wrapper nodes -------------------------------------
14
-
15
- # Unwraps a PgQuery::Node (oneof wrapper) into its inner message.
16
- def unwrap_node(node)
17
- return nil if node.nil?
18
- return node unless node.is_a?(PgQuery::Node)
19
-
20
- node.node ? node.inner : nil
21
- end
22
-
23
- # Strips TypeCast wrappers: `'foo'::text` → the A_Const for 'foo'.
24
- def strip_type_casts(node)
25
- node = unwrap_node(node)
26
- node = unwrap_node(node.arg) while node.is_a?(PgQuery::TypeCast)
27
- node
28
- end
29
-
30
- # --- traversal ----------------------------------------------------
31
-
32
- # Depth-first walk over every protobuf message under +node+, yielding
33
- # each unwrapped message. When +prune+ returns true for a message, it is
34
- # yielded but its children are not visited.
35
- def walk_ast(node, prune: nil, &block)
36
- node = unwrap_node(node)
37
- return if node.nil?
38
- return unless node.is_a?(Google::Protobuf::MessageExts)
39
-
40
- yield node
41
- return if prune&.call(node)
42
-
43
- each_ast_child(node) { |child| walk_ast(child, prune: prune, &block) }
44
- end
45
-
46
- # Yields every message-typed child of a protobuf message.
47
- def each_ast_child(node, &block)
48
- node.class.descriptor.each do |field|
49
- next unless field.type == :message
50
-
51
- value = node[field.name]
52
- next if value.nil?
53
-
54
- if value.is_a?(Google::Protobuf::RepeatedField)
55
- value.each(&block)
56
- else
57
- yield value
58
- end
59
- end
60
- end
61
-
62
- # Walk that does not descend into nested sub-selects, so each scope is
63
- # analyzed exactly once. Yielding starts at the given node's children,
64
- # i.e. pass a where_clause / sort item, not a whole statement.
65
- def walk_within_scope(node, &)
66
- root_seen = false
67
- walk_ast(node, prune: lambda { |msg|
68
- if root_seen
69
- msg.is_a?(PgQuery::SelectStmt)
70
- else
71
- root_seen = true # never prune the root itself
72
- false
73
- end
74
- }, &)
75
- end
76
-
77
- # --- reading values out of nodes ----------------------------------
78
-
79
- # ["users", "name"] for "users"."name"; nil when the ref contains A_Star.
80
- def column_ref_fields(column_ref)
81
- fields = column_ref.fields.map { |f| unwrap_node(f) }
82
- return nil unless fields.all?(PgQuery::String)
83
-
84
- fields.map(&:sval)
85
- end
86
-
87
- def string_values(nodes)
88
- nodes.map { |n| unwrap_node(n) }.grep(PgQuery::String).map(&:sval)
89
- end
90
-
91
- # Operator name of an A_Expr, e.g. "=", "~~", "~~*".
92
- def operator_name(a_expr)
93
- string_values(a_expr.name).last
94
- end
95
-
96
- # Unqualified, downcased function name of a FuncCall.
97
- def function_name(func_call)
98
- string_values(func_call.funcname).last&.downcase
99
- end
100
-
101
- # Ruby value of an A_Const: Integer, Float, String, true/false or nil.
102
- def constant_value(a_const)
103
- case a_const.val
104
- when :ival then a_const.ival.ival
105
- when :fval then a_const.fval.fval.to_f
106
- when :sval then a_const.sval.sval
107
- when :boolval then a_const.boolval.boolval
108
- end
109
- end
110
-
111
- def comparison_expr?(a_expr)
112
- a_expr.kind == :AEXPR_OP && COMPARISON_OPS.include?(operator_name(a_expr))
113
- end
114
- end
115
- end