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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): ORDER BY x LIMIT n without an index led by x forces a
6
+ # full sort before the limit can apply. Size-dependent, so disabled by
7
+ # default and gated by config.table_size_hints.
8
+ class UnindexedOrderByWithLimit < Base
9
+ include IndexPredicates
10
+
11
+ def default_enabled
12
+ false
13
+ end
14
+
15
+ def size_dependent?
16
+ true
17
+ end
18
+
19
+ def check(query)
20
+ detections = []
21
+ query.each_scope do |scope|
22
+ next unless scope.limited?
23
+
24
+ sort_by = scope.sort_items.first
25
+ next unless sort_by
26
+
27
+ column_ref = unwrap_node(sort_by.node)
28
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
29
+
30
+ table, column = scope.resolve(column_ref)
31
+ next unless table && column
32
+ next unless applicable_table?(query, table)
33
+ next if index_leading_with?(query, table, column)
34
+
35
+ detections << detection(
36
+ query,
37
+ table: table,
38
+ columns: column,
39
+ message: "ORDER BY #{column} LIMIT sorts every row before the limit can apply " \
40
+ "(no index on #{table} leads with #{column}).",
41
+ suggestion: <<~SUGGESTION.chomp
42
+ Consider indexing the sort key:
43
+ CREATE INDEX index_#{table}_on_#{column} ON #{table} (#{column});
44
+ SUGGESTION
45
+ )
46
+ end
47
+ detections
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): equality/range predicates on columns with no index
6
+ # whose leading column could serve them. Depends on production table
7
+ # size — a 30-row lookup table is fine without indexes — hence disabled
8
+ # by default and gated by config.table_size_hints.
9
+ class UnindexedWhere < Base
10
+ def default_enabled
11
+ false
12
+ end
13
+
14
+ def size_dependent?
15
+ true
16
+ end
17
+
18
+ def check(query)
19
+ query.each_scope.with_object([]) do |scope, detections|
20
+ next unless scope.where_clause
21
+
22
+ predicate_columns(query, scope).each do |table, columns|
23
+ next unless applicable_table?(query, table)
24
+ next if served_by_index?(query, table, columns)
25
+
26
+ detections << build(query, table, columns)
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # => { table => [column, ...] } for plain-column predicates in WHERE.
34
+ def predicate_columns(_query, scope)
35
+ result = Hash.new { |h, k| h[k] = [] }
36
+ walk_within_scope(scope.where_clause) do |node|
37
+ next unless node.is_a?(PgQuery::A_Expr) && indexable_predicate?(node)
38
+
39
+ [node.lexpr, node.rexpr].each do |side|
40
+ column_ref = unwrap_node(side)
41
+ next unless column_ref.is_a?(PgQuery::ColumnRef)
42
+ next unless constant_side?(node, side)
43
+
44
+ table, column = scope.resolve(column_ref)
45
+ result[table] << column if table && column
46
+ end
47
+ end
48
+ result.transform_values(&:uniq)
49
+ end
50
+
51
+ def indexable_predicate?(a_expr)
52
+ comparison_expr?(a_expr) ||
53
+ %i[AEXPR_IN AEXPR_BETWEEN AEXPR_LIKE AEXPR_ILIKE].include?(a_expr.kind)
54
+ end
55
+
56
+ # Only count `column <op> constant-ish` predicates; column-to-column
57
+ # comparisons (join conditions in WHERE) are not our business here.
58
+ def constant_side?(a_expr, column_side)
59
+ other = a_expr.lexpr.equal?(column_side) ? a_expr.rexpr : a_expr.lexpr
60
+ node = strip_type_casts(other)
61
+ case node
62
+ when PgQuery::A_Const, PgQuery::ParamRef
63
+ true
64
+ when PgQuery::List
65
+ node.items.all? { |i| strip_type_casts(i).is_a?(PgQuery::A_Const) || strip_type_casts(i).is_a?(PgQuery::ParamRef) }
66
+ else
67
+ false
68
+ end
69
+ end
70
+
71
+ # Leftmost-prefix rule: an index helps when its leading column is one
72
+ # of the predicate columns.
73
+ def served_by_index?(query, table, columns)
74
+ query.indexes(table).any? do |index|
75
+ index.leading_column && columns.include?(index.leading_column)
76
+ end
77
+ end
78
+
79
+ def build(query, table, columns)
80
+ column_list = columns.join(", ")
81
+ detection(
82
+ query,
83
+ table: table,
84
+ columns: columns,
85
+ message: "No index leads with any of the WHERE predicate columns (#{table}.#{column_list}). " \
86
+ "Depending on production row counts, this becomes a full scan.",
87
+ suggestion: <<~SUGGESTION.chomp
88
+ Consider adding an index (equality columns first):
89
+ CREATE INDEX index_#{table}_on_#{columns.join("_and_")} ON #{table} (#{column_list});
90
+ SUGGESTION
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Tier 2 (opt-in): UNION without ALL deduplicates the combined result by
6
+ # sorting/hashing all rows. When the branches cannot overlap (or
7
+ # duplicates are acceptable), UNION ALL skips that work. Whether
8
+ # deduplication is intended is the author's call, hence opt-in.
9
+ class UnionInsteadOfUnionAll < Base
10
+ def default_enabled
11
+ false
12
+ end
13
+
14
+ def size_dependent?
15
+ false
16
+ end
17
+
18
+ def check(query)
19
+ union = query.scopes.find { |scope| scope.stmt.op == :SETOP_UNION && !scope.stmt.all }
20
+ return [] unless union
21
+
22
+ [detection(
23
+ query,
24
+ message: "UNION (without ALL) sorts/hashes the entire combined result to remove duplicates.",
25
+ suggestion: "If the branches cannot produce overlapping rows — or duplicates are acceptable — use UNION ALL."
26
+ )]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ MAX_SQL_LENGTH = 500
6
+
7
+ # What a rule's #check returns: one anti-pattern finding — which rule
8
+ # fired, on which query, which table/columns are involved, why it is a
9
+ # problem, and how to fix it.
10
+ Detection = Struct.new(
11
+ :rule_name, :severity, :sql, :table, :columns,
12
+ :message, :suggestion, :location, :fingerprint,
13
+ keyword_init: true
14
+ ) do
15
+ def truncated_sql
16
+ squashed = sql.to_s.strip.gsub(/\s+/, " ")
17
+ squashed.length > MAX_SQL_LENGTH ? "#{squashed[0, MAX_SQL_LENGTH]}…" : squashed
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # Predicates over index metadata, for rules that stay silent when a
6
+ # suitable index exists. Include only in rules that need them.
7
+ module IndexPredicates
8
+ TRGM_ACCESS_METHODS = %w[gin gist].freeze
9
+
10
+ private
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")
17
+ end
18
+ end
19
+
20
+ def gin_index_on?(query, table, column)
21
+ query.indexes(table).any? { |index| index.using == "gin" && index.columns.include?(column) }
22
+ end
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/)
30
+ end
31
+ end
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
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ module Rules
5
+ # What a rule's #check receives: everything about one executed query —
6
+ # the parsed AST (as Scopes), bind parameter values, the configuration,
7
+ # and access to schema metadata via the connection the query ran on.
8
+ class QueryContext
9
+ include PgQuerySupport
10
+
11
+ # One SELECT scope (top-level statement, CTE, subquery, ...) with its
12
+ # own FROM-clause alias resolution.
13
+ class Scope
14
+ include PgQuerySupport
15
+
16
+ attr_reader :stmt, :aliases
17
+
18
+ def initialize(stmt)
19
+ @stmt = stmt
20
+ @aliases = {}
21
+ collect_aliases(stmt.from_clause)
22
+ end
23
+
24
+ def where_clause
25
+ stmt.where_clause
26
+ end
27
+
28
+ # => [PgQuery::SortBy]
29
+ def sort_items
30
+ stmt.sort_clause.map { |n| unwrap_node(n) }.grep(PgQuery::SortBy)
31
+ end
32
+
33
+ def limited?
34
+ !stmt.limit_count.nil?
35
+ end
36
+
37
+ # Real table names referenced directly in this scope's FROM clause.
38
+ def tables
39
+ @aliases.values.compact.uniq
40
+ end
41
+
42
+ # Resolves a ColumnRef to [table, column]. Returns nil when the ref
43
+ # cannot be attributed to a real table unambiguously (unknown alias,
44
+ # subquery output, multiple candidate tables for an unqualified ref).
45
+ def resolve(column_ref)
46
+ fields = column_ref_fields(column_ref)
47
+ return nil if fields.nil? || fields.empty?
48
+
49
+ column = fields.last
50
+ if fields.length >= 2
51
+ table = @aliases[fields[-2]]
52
+ table ? [table, column] : nil
53
+ else
54
+ tables.length == 1 ? [tables.first, column] : nil
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def collect_aliases(from_clause)
61
+ from_clause.each { |node| collect_from_node(unwrap_node(node)) }
62
+ end
63
+
64
+ def collect_from_node(node)
65
+ case node
66
+ when PgQuery::RangeVar
67
+ name = node.alias&.aliasname
68
+ name = node.relname if name.nil? || name.empty?
69
+ @aliases[name] = node.relname
70
+ when PgQuery::JoinExpr
71
+ collect_from_node(unwrap_node(node.larg))
72
+ collect_from_node(unwrap_node(node.rarg))
73
+ when PgQuery::RangeSubselect
74
+ name = node.alias&.aliasname
75
+ @aliases[name] = nil if name && !name.empty?
76
+ end
77
+ end
78
+ end
79
+
80
+ attr_reader :sql, :connection, :parse_result, :config
81
+
82
+ def initialize(sql:, connection:, parse_result:, config:, binds: [], type_casted_binds: nil)
83
+ @sql = sql
84
+ @connection = connection
85
+ @parse_result = parse_result
86
+ @config = config
87
+ @binds = binds || []
88
+ @type_casted_binds = type_casted_binds
89
+ end
90
+
91
+ # Cannot fail: a QueryContext is only built for SQL that already parsed.
92
+ def fingerprint
93
+ @fingerprint ||= PgQuery.fingerprint(sql)
94
+ end
95
+
96
+ # All SELECT scopes in the statement, including CTEs, subqueries in
97
+ # FROM/WHERE and UNION branches.
98
+ def scopes
99
+ @scopes ||= begin
100
+ stmts = []
101
+ parse_result.tree.stmts.each do |raw|
102
+ walk_ast(raw.stmt) { |msg| stmts << msg if msg.is_a?(PgQuery::SelectStmt) }
103
+ end
104
+ stmts.map { |s| Scope.new(s) }
105
+ end
106
+ end
107
+
108
+ def each_scope(&)
109
+ scopes.each(&)
110
+ end
111
+
112
+ # All real table names touched anywhere in the query.
113
+ def tables
114
+ @tables ||= scopes.flat_map(&:tables).uniq
115
+ end
116
+
117
+ # Value for a ParamRef ($n, 1-based). nil when unknown.
118
+ def bind_value(number)
119
+ index = number - 1
120
+ return nil if index.negative?
121
+
122
+ casted = @type_casted_binds
123
+ return casted[index] if casted.is_a?(Array) && index < casted.length
124
+
125
+ bind = @binds[index]
126
+ return nil if bind.nil?
127
+
128
+ bind.respond_to?(:value_for_database) ? bind.value_for_database : bind
129
+ end
130
+
131
+ # --- schema metadata for the query's connection ---
132
+
133
+ def indexes(table)
134
+ SchemaIntrospection.indexes(connection, table)
135
+ end
136
+
137
+ def column_type(table, column)
138
+ SchemaIntrospection.column_type(connection, table, column)
139
+ end
140
+
141
+ # => { column_name => sql_type }
142
+ def column_types(table)
143
+ SchemaIntrospection.column_types(connection, table)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ # Index and column metadata for the rules, read through ActiveRecord's
5
+ # schema cache — caching, invalidation and thread safety ride on Rails.
6
+ # The catalog queries ActiveRecord issues are named "SCHEMA", which the
7
+ # Subscriber already ignores, so they are never analyzed themselves.
8
+ module SchemaIntrospection
9
+ IndexInfo = Struct.new(
10
+ :name, # index name
11
+ :using, # access method: "btree", "gin", "gist", ...
12
+ :columns, # [String] — empty for expression indexes
13
+ :opclasses, # { column => opclass } for non-default opclasses
14
+ :expressions, # SQL string of the indexed expressions, or nil
15
+ keyword_init: true
16
+ ) do
17
+ def leading_column
18
+ columns.first
19
+ end
20
+ end
21
+
22
+ module_function
23
+
24
+ # => [IndexInfo] — includes the primary key, which ActiveRecord's
25
+ # #indexes omits.
26
+ def indexes(connection, table)
27
+ cache = connection.schema_cache
28
+ return [] unless cache.data_source_exists?(table)
29
+
30
+ list = cache.indexes(table).map { |definition| index_info(definition) }
31
+ primary_key = Array(cache.primary_keys(table))
32
+ if primary_key.any?
33
+ list << IndexInfo.new(name: "#{table}_pkey", using: "btree",
34
+ columns: primary_key, opclasses: {}, expressions: nil)
35
+ end
36
+ list
37
+ rescue StandardError => e
38
+ PgCanary.internal_error(e)
39
+ []
40
+ end
41
+
42
+ def column_type(connection, table, column)
43
+ column_types(connection, table)[column]
44
+ end
45
+
46
+ # => { column_name => sql_type } e.g. { "id" => "bigint", "tags" => "text[]" }
47
+ def column_types(connection, table)
48
+ cache = connection.schema_cache
49
+ return {} unless cache.data_source_exists?(table)
50
+
51
+ cache.columns(table).to_h do |column|
52
+ type = column.sql_type
53
+ type = "#{type}[]" if column.respond_to?(:array?) && column.array?
54
+ [column.name, type]
55
+ end
56
+ rescue StandardError => e
57
+ PgCanary.internal_error(e)
58
+ {}
59
+ end
60
+
61
+ # ActiveRecord returns plain-column indexes with an Array of column
62
+ # names, and expression indexes with the expressions as one SQL String.
63
+ def index_info(definition)
64
+ expressions = definition.columns.is_a?(String) ? definition.columns : nil
65
+ columns = expressions ? [] : Array(definition.columns)
66
+ opclasses = definition.opclasses
67
+ opclasses = columns.to_h { |c| [c, opclasses.to_s] } unless opclasses.is_a?(Hash)
68
+ IndexInfo.new(name: definition.name, using: definition.using.to_s,
69
+ columns: columns, opclasses: opclasses, expressions: expressions)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ # Subscribes to sql.active_record, filters events down to likely SELECT
5
+ # statements, and hands them to the Detector. Stateless per event, so
6
+ # everything lives at class level.
7
+ class Subscriber
8
+ # Event names that never carry application SELECTs. "SCHEMA" also covers
9
+ # the catalog queries ActiveRecord's schema cache runs on our behalf.
10
+ IGNORED_NAMES = %w[SCHEMA TRANSACTION].freeze
11
+ # Cheap pre-filter before paying for a real parse.
12
+ SELECT_PREFIX = %r{\A\s*(?:/\*.*?\*/\s*)*(?:SELECT|WITH)\b}im
13
+
14
+ class << self
15
+ def subscribe!
16
+ @subscribe ||= ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
17
+ next unless analysis_target?(payload)
18
+
19
+ detections = detector.call(payload)
20
+ Middleware.collect(detections) if detections.any?
21
+ rescue StandardError => e
22
+ PgCanary.internal_error(e)
23
+ end
24
+ end
25
+
26
+ private
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)
32
+
33
+ !!payload[:connection]&.adapter_name&.match?(/postg/i)
34
+ end
35
+
36
+ def detector
37
+ @detector ||= Detector.new(PgCanary.config)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgCanary
4
+ VERSION = "0.1.0"
5
+ end
data/lib/pg_canary.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "pg_query"
5
+
6
+ require_relative "pg_canary/version"
7
+ require_relative "pg_canary/configuration"
8
+ require_relative "pg_canary/pg_query_support"
9
+ require_relative "pg_canary/schema_introspection"
10
+ require_relative "pg_canary/rules/detection"
11
+ require_relative "pg_canary/rules/query_context"
12
+ require_relative "pg_canary/rules/base"
13
+ require_relative "pg_canary/rules/index_predicates"
14
+ Dir[File.join(__dir__, "pg_canary", "rules", "definitions", "*.rb")].each { |file| require file }
15
+ require_relative "pg_canary/detector"
16
+ require_relative "pg_canary/subscriber"
17
+ require_relative "pg_canary/middleware"
18
+
19
+ module PgCanary
20
+ class Error < StandardError; end
21
+
22
+ @config = Configuration.new
23
+
24
+ class << self
25
+ attr_reader :config
26
+
27
+ def configure
28
+ yield config
29
+ end
30
+
31
+ # Reports an internal pg_canary failure without ever breaking the host app.
32
+ def internal_error(error)
33
+ warn_once("[PgCanary] internal error (detection skipped): #{error.class}: #{error.message}")
34
+ end
35
+
36
+ private
37
+
38
+ def logger
39
+ config.logger || default_logger
40
+ end
41
+
42
+ def default_logger
43
+ @default_logger ||= begin
44
+ require "logger"
45
+ ::Logger.new($stderr)
46
+ end
47
+ end
48
+
49
+ def warn_once(message)
50
+ @warned ||= {}
51
+ return if @warned[message]
52
+
53
+ @warned[message] = true
54
+ logger.warn(message)
55
+ end
56
+ end
57
+
58
+ if defined?(Rails::Railtie)
59
+ # Middleware insertion and automatic enabling in development/test.
60
+ class Railtie < Rails::Railtie
61
+ initializer "pg_canary.app_root", before: :load_config_initializers do
62
+ PgCanary.config.app_root ||= Rails.root.to_s
63
+ end
64
+
65
+ initializer "pg_canary.middleware" do |app|
66
+ app.middleware.use PgCanary::Middleware
67
+ end
68
+
69
+ config.after_initialize do
70
+ config = PgCanary.config
71
+ config.enabled = Rails.env.development? || Rails.env.test? if config.enabled.nil?
72
+ config.logger ||= Rails.logger
73
+ Subscriber.subscribe! if config.enabled
74
+ end
75
+ end
76
+ end
77
+ end