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
|
@@ -1,147 +0,0 @@
|
|
|
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
|
|
@@ -1,72 +0,0 @@
|
|
|
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
|