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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +14 -28
- data/lib/pg_canary/configuration.rb +2 -28
- data/lib/pg_canary/detector.rb +64 -56
- data/lib/pg_canary/middleware.rb +58 -59
- data/lib/pg_canary/pg_query_refinement.rb +127 -0
- data/lib/pg_canary/rules/base.rb +87 -36
- data/lib/pg_canary/rules/definitions/array_search_without_gin.rb +33 -34
- data/lib/pg_canary/rules/definitions/cartesian_join.rb +67 -68
- data/lib/pg_canary/rules/definitions/correlated_subquery_in_select.rb +32 -33
- data/lib/pg_canary/rules/definitions/count_star_without_where.rb +14 -15
- data/lib/pg_canary/rules/definitions/deep_offset.rb +19 -23
- data/lib/pg_canary/rules/definitions/distinct_with_join.rb +9 -10
- data/lib/pg_canary/rules/definitions/function_on_column.rb +46 -47
- data/lib/pg_canary/rules/definitions/huge_in_list.rb +24 -28
- data/lib/pg_canary/rules/definitions/implicit_cast.rb +42 -43
- data/lib/pg_canary/rules/definitions/jsonb_search_without_gin.rb +61 -63
- data/lib/pg_canary/rules/definitions/leading_wildcard_like.rb +43 -44
- data/lib/pg_canary/rules/definitions/not_in_subquery.rb +32 -33
- data/lib/pg_canary/rules/definitions/or_across_columns.rb +32 -33
- data/lib/pg_canary/rules/definitions/order_by_random.rb +7 -8
- data/lib/pg_canary/rules/definitions/query_complexity.rb +25 -28
- data/lib/pg_canary/rules/definitions/regex_without_trgm.rb +17 -18
- data/lib/pg_canary/rules/definitions/select_star_with_heavy_columns.rb +27 -31
- data/lib/pg_canary/rules/definitions/unindexed_join.rb +38 -39
- data/lib/pg_canary/rules/definitions/unindexed_order_by_with_limit.rb +9 -10
- data/lib/pg_canary/rules/definitions/unindexed_where.rb +59 -60
- data/lib/pg_canary/rules/definitions/union_instead_of_union_all.rb +4 -7
- data/lib/pg_canary/rules/detection.rb +1 -1
- data/lib/pg_canary/rules/index_predicates.rb +20 -20
- data/lib/pg_canary/rules/schema_introspection.rb +74 -0
- data/lib/pg_canary/rules/scope.rb +74 -0
- data/lib/pg_canary/subscriber.rb +9 -9
- data/lib/pg_canary/version.rb +1 -1
- data/lib/pg_canary.rb +18 -18
- metadata +4 -4
- data/lib/pg_canary/pg_query_support.rb +0 -115
- data/lib/pg_canary/rules/query_context.rb +0 -147
- data/lib/pg_canary/schema_introspection.rb +0 -72
|
@@ -2,90 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
module Rules
|
|
5
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
default_enabled false
|
|
11
|
+
|
|
12
|
+
using PgCanary::PgQueryRefinement
|
|
13
13
|
|
|
14
|
-
def check
|
|
15
|
-
|
|
14
|
+
def check
|
|
15
|
+
each_scope.with_object([]) do |scope, detections|
|
|
16
16
|
next unless scope.where_clause
|
|
17
17
|
|
|
18
|
-
predicate_columns(
|
|
19
|
-
next unless applicable_table?(
|
|
20
|
-
next if served_by_index?(
|
|
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(
|
|
22
|
+
detections << build(table, columns)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
11
|
-
false
|
|
12
|
-
end
|
|
10
|
+
default_enabled false
|
|
13
11
|
|
|
14
|
-
def check
|
|
15
|
-
union =
|
|
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, :
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
def gin_index_on?(table, column)
|
|
21
|
+
indexes(table).any? { |index| index.using == "gin" && index.columns.include?(column) }
|
|
22
|
+
end
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
data/lib/pg_canary/subscriber.rb
CHANGED
|
@@ -25,17 +25,17 @@ module PgCanary
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
33
|
+
!!payload[:connection]&.adapter_name&.match?(/postg/i)
|
|
34
|
+
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
def detector
|
|
37
|
+
@detector ||= Detector.new(PgCanary.config)
|
|
38
|
+
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|
data/lib/pg_canary/version.rb
CHANGED
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/
|
|
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/
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
def logger
|
|
39
|
+
config.logger || default_logger
|
|
40
|
+
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
def warn_once(message)
|
|
50
|
+
@warned ||= {}
|
|
51
|
+
return if @warned[message]
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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?
|
|
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.
|
|
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/
|
|
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/
|
|
97
|
-
- lib/pg_canary/
|
|
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
|