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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +114 -0
- data/demo.png +0 -0
- data/lib/pg_canary/configuration.rb +80 -0
- data/lib/pg_canary/detector.rb +94 -0
- data/lib/pg_canary/middleware.rb +115 -0
- data/lib/pg_canary/pg_query_support.rb +115 -0
- data/lib/pg_canary/rules/base.rb +77 -0
- data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +75 -0
- data/lib/pg_canary/rules/definitions/cartesian_join.rb +108 -0
- data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +80 -0
- data/lib/pg_canary/rules/definitions/count_star_without_where.rb +57 -0
- data/lib/pg_canary/rules/definitions/deep_offset.rb +63 -0
- data/lib/pg_canary/rules/definitions/distinct_with_join.rb +43 -0
- data/lib/pg_canary/rules/definitions/function_on_column.rb +89 -0
- data/lib/pg_canary/rules/definitions/huge_in_list.rb +74 -0
- data/lib/pg_canary/rules/definitions/implicit_cast.rb +79 -0
- data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +108 -0
- data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +81 -0
- data/lib/pg_canary/rules/definitions/not_in_subquery.rb +69 -0
- data/lib/pg_canary/rules/definitions/or_across_columns.rb +69 -0
- data/lib/pg_canary/rules/definitions/order_by_random.rb +41 -0
- data/lib/pg_canary/rules/definitions/query_complexity.rb +65 -0
- data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +70 -0
- data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +73 -0
- data/lib/pg_canary/rules/definitions/unindexed_join.rb +85 -0
- data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +51 -0
- data/lib/pg_canary/rules/definitions/unindexed_where.rb +95 -0
- data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +30 -0
- data/lib/pg_canary/rules/detection.rb +21 -0
- data/lib/pg_canary/rules/index_predicates.rb +39 -0
- data/lib/pg_canary/rules/query_context.rb +147 -0
- data/lib/pg_canary/schema_introspection.rb +72 -0
- data/lib/pg_canary/subscriber.rb +41 -0
- data/lib/pg_canary/version.rb +5 -0
- data/lib/pg_canary.rb +77 -0
- 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
|
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
|