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
@@ -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