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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb33135791d90607ecc14dc44818adc8a7173cb9ed4f5da995dbfeacc31de62e
|
|
4
|
+
data.tar.gz: a25ba3b887e3ec90961f63442b5a1721a60f41a8d11fe2902d3110cad904cf26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1116dfd6490e78a40f974d884d56e7ce095b39e5d61a1d6bb2e48de13e63ea2f3ad50f0b40168ccbbdb88269d1a5a72d5f0bad4319b23a06f3205cd22980ec9
|
|
7
|
+
data.tar.gz: dd130131ff6bcb1944cdc2173d99e3652c4a43572f89c3118d34634ff43ec4b4ec062a134f31237ac251d1d8ebc71088275dfeeb54e4579c8abb51e5738b0fc4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-07-20
|
|
4
|
+
|
|
5
|
+
- Default `config.enabled` to development only (was development and test). The HTML panel is the only output, so running in test only added overhead and could alter response bodies under test.
|
|
6
|
+
- Remove per-rule `severity` (`:error` / `:warning`). It only changed the panel's label color and implied consequences (failing tests) the gem doesn't have; detections are uniformly warnings now.
|
|
7
|
+
- Remove `[]` access to rule settings (`config.rules[:deep_offset]`); use the method form (`config.rules.deep_offset`).
|
|
8
|
+
|
|
3
9
|
## [0.2.0] - 2026-07-18
|
|
4
10
|
|
|
5
11
|
- Remove `config.table_size_hints` / `config.size_rule_threshold`. Tier 2 rules are already opt-in per rule; use `config.ignore_tables` to exclude small tables.
|
data/README.md
CHANGED
|
@@ -15,7 +15,7 @@ end
|
|
|
15
15
|
|
|
16
16
|
## Detection rules
|
|
17
17
|
|
|
18
|
-
###
|
|
18
|
+
### Enabled by default
|
|
19
19
|
|
|
20
20
|
| Rule | Detects | Rationale |
|
|
21
21
|
|---|---|---|
|
|
@@ -32,12 +32,7 @@ end
|
|
|
32
32
|
| `cartesian_join` | JOIN with no join condition — explicit `CROSS JOIN` between tables, or a comma join never connected in WHERE | The result grows with the product of both tables' row counts |
|
|
33
33
|
| `implicit_cast` | Integer column compared with a numeric literal (`age = 1.5`) | The *column* gets implicitly cast to numeric, disabling its index. Restricted to cases provable from the AST alone |
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- `deep_offset` and `huge_in_list` read the **runtime bind values** (`OFFSET $1`, array binds) from the `sql.active_record` event — static SQL linters that only see query text cannot do this. `leading_wildcard_like` uses the same mechanism for `LIKE $1`.
|
|
38
|
-
- `function_on_column` matches expression indexes by (function name, column name) — a deliberate approximation rather than full location-insensitive AST equality, which is sufficient in practice
|
|
39
|
-
|
|
40
|
-
### Tier 2 — disabled by default (opt-in)
|
|
35
|
+
### Disabled by default
|
|
41
36
|
|
|
42
37
|
| Rule | Detects |
|
|
43
38
|
|---|---|
|
|
@@ -51,44 +46,35 @@ Notes:
|
|
|
51
46
|
| `select_star_with_heavy_columns` | `SELECT *` (ActiveRecord's default) on tables with heavy columns (bytea/text/jsonb, configurable) |
|
|
52
47
|
| `query_complexity` | More than 8 joins or subquery nesting deeper than 4 (both configurable) — the "spaghetti query" guard |
|
|
53
48
|
|
|
54
|
-
**Why disabled by default:** the first four depend on production table size — a missing index is not a problem in itself. Lookup tables like prefectures or plans hold a few dozen rows in production too, and a Seq Scan is the
|
|
55
|
-
|
|
56
|
-
Rule-specific thresholds are set through the same `config.rules` interface:
|
|
57
|
-
|
|
58
|
-
```ruby
|
|
59
|
-
config.rules.deep_offset.threshold = 2000
|
|
60
|
-
config.rules.huge_in_list.threshold = 200
|
|
61
|
-
config.rules.query_complexity.max_joins = 12
|
|
62
|
-
config.rules.select_star_with_heavy_columns.heavy_types = %w[bytea jsonb]
|
|
63
|
-
```
|
|
49
|
+
**Why disabled by default:** the first four depend on production table size — a missing index is not a problem in itself. Lookup tables like prefectures or plans hold a few dozen rows in production too, and a Seq Scan is the right plan for them. The rest depend on the author's intent (deliberate DISTINCT, required deduplication, acceptable payloads). Nothing in the schema or the AST reveals either, so these rules would produce many false positives if enabled by default. Enable the ones that fit your app individually (see [Configuration](#configuration)).
|
|
64
50
|
|
|
65
51
|
Once opted in, keep false positives down by listing small tables in `config.ignore_tables`.
|
|
66
52
|
|
|
67
53
|
## Configuration
|
|
68
54
|
|
|
69
|
-
Everything works with the defaults
|
|
55
|
+
Everything works with the defaults. To change them, create an initializer:
|
|
70
56
|
|
|
71
57
|
```ruby
|
|
72
58
|
# config/initializers/pg_canary.rb
|
|
73
59
|
PgCanary.configure do |config|
|
|
74
|
-
|
|
60
|
+
# Where to run (defaults to development)
|
|
61
|
+
config.enabled = Rails.env.development? || Rails.env.test?
|
|
62
|
+
|
|
63
|
+
# Excluding tables
|
|
64
|
+
config.ignore_tables += %w[prefectures plans]
|
|
75
65
|
|
|
76
66
|
# Per-rule settings
|
|
77
67
|
config.rules.unindexed_where.enabled = true
|
|
78
|
-
config.rules.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
config.
|
|
68
|
+
config.rules.deep_offset.threshold = 2000
|
|
69
|
+
config.rules.huge_in_list.threshold = 200
|
|
70
|
+
config.rules.query_complexity.max_joins = 12
|
|
71
|
+
config.rules.select_star_with_heavy_columns.heavy_types = %w[bytea jsonb]
|
|
82
72
|
end
|
|
83
73
|
```
|
|
84
74
|
|
|
85
|
-
## Notifications
|
|
86
|
-
|
|
87
|
-
Detections show up in a panel at the bottom of the page, each with: the rule name, the SQL, the table/columns involved, why it's a problem, the suggested fix, and the source location (file:line).
|
|
88
|
-
|
|
89
75
|
## How it works
|
|
90
76
|
|
|
91
|
-
pg_canary subscribes to ActiveRecord's `sql.active_record` notifications and analyzes each executed SELECT. Detection is based on the query's AST — parsed with [`pg_query`](https://github.com/pganalyze/pg_query), a binding to PostgreSQL's own parser — combined with schema metadata (index definitions, opclasses, expression indexes, column types) read through ActiveRecord's schema cache. Detections raised during a request are collected by a Rack middleware, which injects the panel into the HTML response.
|
|
77
|
+
pg_canary subscribes to ActiveRecord's `sql.active_record` notifications and analyzes each executed SELECT. Detection is based on the query's AST — parsed with [`pg_query`](https://github.com/pganalyze/pg_query), a binding to PostgreSQL's own parser — combined with schema metadata (index definitions, opclasses, expression indexes, column types) read through ActiveRecord's schema cache. Because analysis happens at query execution time, runtime bind values are visible too — `OFFSET $1` with a bound value of 50000 is still caught, which linters that only see query text cannot do. Detections raised during a request are collected by a Rack middleware, which injects the panel into the HTML response.
|
|
92
78
|
|
|
93
79
|
## Development
|
|
94
80
|
|
|
@@ -2,14 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module PgCanary
|
|
4
4
|
class Configuration
|
|
5
|
-
|
|
6
|
-
# pg_canary in development/test only.
|
|
7
|
-
attr_accessor :enabled
|
|
8
|
-
|
|
9
|
-
# False-positive controls
|
|
10
|
-
attr_accessor :ignore_tables
|
|
11
|
-
|
|
12
|
-
attr_accessor :logger, :app_root
|
|
5
|
+
attr_accessor :enabled, :ignore_tables, :logger, :app_root
|
|
13
6
|
attr_reader :rules
|
|
14
7
|
|
|
15
8
|
def initialize
|
|
@@ -25,32 +18,17 @@ module PgCanary
|
|
|
25
18
|
end
|
|
26
19
|
end
|
|
27
20
|
|
|
28
|
-
# Exposes one RuleConfig per built-in rule, both as methods and via [].
|
|
29
|
-
#
|
|
30
|
-
# config.rules.unindexed_where.enabled = true
|
|
31
|
-
# config.rules[:leading_wildcard_like].severity = :error
|
|
32
21
|
class RulesConfig
|
|
33
22
|
def initialize
|
|
34
|
-
@configs = {}
|
|
35
23
|
Rules::Base.all.each do |klass|
|
|
36
24
|
rule_config = RuleConfig.new(klass.options)
|
|
37
|
-
@configs[klass.rule_name] = rule_config
|
|
38
25
|
define_singleton_method(klass.rule_name) { rule_config }
|
|
39
26
|
end
|
|
40
27
|
end
|
|
41
|
-
|
|
42
|
-
def [](name)
|
|
43
|
-
@configs.fetch(name.to_sym)
|
|
44
|
-
end
|
|
45
28
|
end
|
|
46
29
|
|
|
47
|
-
# Per-rule settings. `enabled` / `severity` default to nil, meaning
|
|
48
|
-
# "use the rule class's default". Accessors for rule-specific options
|
|
49
|
-
# (e.g. config.rules.deep_offset.threshold = 2000) are generated from the
|
|
50
|
-
# rule class's declared options, so a typo raises NoMethodError instead of
|
|
51
|
-
# silently creating a setting nobody reads.
|
|
52
30
|
class RuleConfig
|
|
53
|
-
attr_accessor :enabled
|
|
31
|
+
attr_accessor :enabled
|
|
54
32
|
|
|
55
33
|
def initialize(option_defaults = {})
|
|
56
34
|
@options = option_defaults.dup
|
|
@@ -59,9 +37,5 @@ module PgCanary
|
|
|
59
37
|
define_singleton_method(:"#{name}=") { |value| @options[name] = value }
|
|
60
38
|
end
|
|
61
39
|
end
|
|
62
|
-
|
|
63
|
-
def [](key)
|
|
64
|
-
@options.fetch(key.to_sym)
|
|
65
|
-
end
|
|
66
40
|
end
|
|
67
41
|
end
|
data/lib/pg_canary/detector.rb
CHANGED
|
@@ -3,92 +3,100 @@
|
|
|
3
3
|
require "active_support/backtrace_cleaner"
|
|
4
4
|
|
|
5
5
|
module PgCanary
|
|
6
|
-
# Parses an event's SQL
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# see Middleware.
|
|
6
|
+
# Parses an event's SQL, runs every enabled rule against it, and filters
|
|
7
|
+
# ignored tables. Each returned Detection carries the query's fingerprint,
|
|
8
|
+
# so a consumer that sees the same query and rule repeatedly (e.g. an N+1
|
|
9
|
+
# loop within one request) can collapse them — see Middleware.
|
|
11
10
|
class Detector
|
|
12
|
-
|
|
11
|
+
using PgCanary::PgQueryRefinement
|
|
13
12
|
|
|
14
13
|
# Swallows the configuration whole at construction: pg_canary treats it
|
|
15
14
|
# as fixed after boot (set in an initializer).
|
|
16
15
|
def initialize(config)
|
|
17
16
|
@config = config
|
|
18
|
-
@
|
|
19
|
-
@backtrace_cleaner =
|
|
17
|
+
@rule_classes = Rules::Base.all.select { |klass| klass.enabled?(config) }
|
|
18
|
+
@backtrace_cleaner = ActiveSupport::BacktraceCleaner.new.tap do |cleaner|
|
|
19
|
+
root = config.app_root
|
|
20
|
+
cleaner.add_silencer { |line| line.include?("lib/pg_canary") }
|
|
21
|
+
cleaner.add_filter { |line| line.delete_prefix("#{root}/") } if root
|
|
22
|
+
end
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
# => [Detection] the detections that should be notified.
|
|
23
26
|
def call(payload)
|
|
24
|
-
|
|
25
|
-
return [] unless
|
|
27
|
+
state = build_state(payload)
|
|
28
|
+
return [] unless state
|
|
26
29
|
|
|
27
30
|
location = nil
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
fingerprint = nil
|
|
32
|
+
@rule_classes.flat_map do |klass|
|
|
33
|
+
detections = check_rule(klass, state)
|
|
30
34
|
next [] if detections.empty?
|
|
31
35
|
|
|
32
36
|
location ||= source_location
|
|
37
|
+
fingerprint ||= PgQuery.fingerprint(state[:sql])
|
|
33
38
|
detections.each do |d|
|
|
34
39
|
d.location = location
|
|
35
|
-
d.fingerprint =
|
|
40
|
+
d.fingerprint = fingerprint
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
private
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Rules::QueryContext.new(
|
|
49
|
-
sql: payload[:sql],
|
|
50
|
-
connection: payload[:connection],
|
|
51
|
-
parse_result: parse_result,
|
|
52
|
-
config: @config,
|
|
53
|
-
binds: payload[:binds],
|
|
54
|
-
type_casted_binds: payload[:type_casted_binds]
|
|
55
|
-
)
|
|
56
|
-
rescue PgQuery::ParseError
|
|
57
|
-
nil
|
|
58
|
-
end
|
|
47
|
+
# nil for anything that is not a plain SELECT (or fails to parse).
|
|
48
|
+
def build_state(payload)
|
|
49
|
+
parse_result = PgQuery.parse(payload[:sql])
|
|
50
|
+
stmt = parse_result.tree.stmts.first&.stmt&.unwrap
|
|
51
|
+
return nil unless stmt.is_a?(PgQuery::SelectStmt)
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
53
|
+
{
|
|
54
|
+
sql: payload[:sql],
|
|
55
|
+
config: @config,
|
|
56
|
+
connection: payload[:connection],
|
|
57
|
+
parse_result: parse_result,
|
|
58
|
+
scopes: build_scopes(parse_result),
|
|
59
|
+
binds: payload[:binds],
|
|
60
|
+
type_casted_binds: payload[:type_casted_binds]
|
|
61
|
+
}
|
|
62
|
+
rescue PgQuery::ParseError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
# All SELECT scopes in the statement, including CTEs, subqueries in
|
|
67
|
+
# FROM/WHERE and UNION branches.
|
|
68
|
+
def build_scopes(parse_result)
|
|
69
|
+
stmts = []
|
|
70
|
+
parse_result.tree.stmts.each do |raw|
|
|
71
|
+
raw.stmt.walk { |msg| stmts << msg if msg.is_a?(PgQuery::SelectStmt) }
|
|
72
|
+
end
|
|
73
|
+
stmts.map { |s| Rules::Scope.new(s) }
|
|
74
|
+
end
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
def check_rule(klass, state)
|
|
77
|
+
klass.check(**state).reject { |d| ignored?(d, state[:scopes]) }
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
PgCanary.internal_error(e)
|
|
80
|
+
[]
|
|
76
81
|
end
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
def ignored?(detection, scopes)
|
|
84
|
+
return true if @config.ignore_table?(detection.table)
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
# Table-less detections (e.g. ORDER BY RANDOM() over a subquery) are
|
|
87
|
+
# dropped when every table in the query is ignored.
|
|
88
|
+
if detection.table.nil?
|
|
89
|
+
tables = scopes.flat_map(&:tables).uniq
|
|
90
|
+
return tables.any? && tables.all? { |t| @config.ignore_table?(t) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
false
|
|
85
94
|
end
|
|
86
|
-
end
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
# The application frame that triggered the query.
|
|
97
|
+
def source_location
|
|
98
|
+
line = @backtrace_cleaner.clean(caller).first
|
|
99
|
+
line && line[/\A.+?:\d+/]
|
|
100
|
+
end
|
|
93
101
|
end
|
|
94
102
|
end
|
data/lib/pg_canary/middleware.rb
CHANGED
|
@@ -35,81 +35,80 @@ module PgCanary
|
|
|
35
35
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
def active?
|
|
39
|
+
PgCanary.config.enabled
|
|
40
|
+
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# Collapses repeats of the same (query, rule) — e.g. an N+1 loop — into
|
|
43
|
+
# a single panel entry. Scoped to this one request: a fresh page load
|
|
44
|
+
# (or a reload of the same page) always shows detections again.
|
|
45
|
+
def dedup(detections)
|
|
46
|
+
detections.uniq { |d| [d.fingerprint, d.rule_name] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inject(status, headers, body, detections)
|
|
50
|
+
return [status, headers, body] unless html_response?(headers)
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
content = +""
|
|
53
|
+
body.each { |part| content << part }
|
|
54
|
+
body.close if body.respond_to?(:close)
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
snippet = footer_panel(detections)
|
|
57
|
+
if content.include?("</body>")
|
|
58
|
+
content = content.sub("</body>", "#{snippet}</body>")
|
|
59
|
+
else
|
|
60
|
+
content << snippet
|
|
61
|
+
end
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
content = content.sub("</body>", "#{snippet}</body>")
|
|
59
|
-
else
|
|
60
|
-
content << snippet
|
|
63
|
+
headers = headers.merge("Content-Length" => content.bytesize.to_s) if content_length?(headers)
|
|
64
|
+
[status, headers, [content]]
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
def html_response?(headers)
|
|
68
|
+
headers.any? { |k, v| k.to_s.casecmp("content-type").zero? && v.to_s.include?("text/html") }
|
|
69
|
+
end
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
def content_length?(headers)
|
|
72
|
+
headers.any? { |k, _| k.to_s.casecmp("content-length").zero? }
|
|
73
|
+
end
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
def footer_panel(detections)
|
|
76
|
+
items = detections.map do |d|
|
|
77
|
+
<<~HTML
|
|
78
|
+
<div style="padding:8px 12px;border-top:1px solid #444;">
|
|
79
|
+
<div>
|
|
80
|
+
<strong style="color:#ffd93d;">#{h(d.rule_name)}</strong>
|
|
81
|
+
#{subject_span(d)}
|
|
82
|
+
</div>
|
|
83
|
+
<pre style="margin:4px 0;white-space:pre-wrap;color:#9ecbff;">#{h(d.truncated_sql)}</pre>
|
|
84
|
+
<div style="white-space:pre-wrap;">→ #{h(d.message)}</div>
|
|
85
|
+
#{%(<pre style="margin:4px 0;white-space:pre-wrap;color:#98c379;">#{h(d.suggestion)}</pre>) if d.suggestion}
|
|
86
|
+
#{%(<div style="opacity:.7;">at #{h(d.location)}</div>) if d.location}
|
|
87
|
+
</div>
|
|
88
|
+
HTML
|
|
89
|
+
end
|
|
74
90
|
|
|
75
|
-
def footer_panel(detections)
|
|
76
|
-
items = detections.map do |d|
|
|
77
91
|
<<~HTML
|
|
78
|
-
<div style="
|
|
79
|
-
<div>
|
|
80
|
-
<strong
|
|
81
|
-
<
|
|
82
|
-
#{subject_span(d)}
|
|
92
|
+
<div id="pg-canary-panel" style="position:fixed;left:0;right:0;bottom:0;max-height:45vh;overflow-y:auto;z-index:2147483647;background:#1e1e1e;color:#eee;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;box-shadow:0 -2px 8px rgba(0,0,0,.4);">
|
|
93
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 12px;background:#2d2d2d;">
|
|
94
|
+
<strong>🐤 PgCanary — #{detections.size} anti-pattern#{"s" if detections.size > 1} detected</strong>
|
|
95
|
+
<button onclick="document.getElementById('pg-canary-panel').remove()" style="background:none;border:1px solid #666;color:#eee;cursor:pointer;padding:2px 8px;">×</button>
|
|
83
96
|
</div>
|
|
84
|
-
|
|
85
|
-
<div style="white-space:pre-wrap;">→ #{h(d.message)}</div>
|
|
86
|
-
#{%(<pre style="margin:4px 0;white-space:pre-wrap;color:#98c379;">#{h(d.suggestion)}</pre>) if d.suggestion}
|
|
87
|
-
#{%(<div style="opacity:.7;">at #{h(d.location)}</div>) if d.location}
|
|
97
|
+
#{items.join}
|
|
88
98
|
</div>
|
|
89
99
|
HTML
|
|
90
100
|
end
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 12px;background:#2d2d2d;">
|
|
95
|
-
<strong>🐤 PgCanary — #{detections.size} anti-pattern#{"s" if detections.size > 1} detected</strong>
|
|
96
|
-
<button onclick="document.getElementById('pg-canary-panel').remove()" style="background:none;border:1px solid #666;color:#eee;cursor:pointer;padding:2px 8px;">×</button>
|
|
97
|
-
</div>
|
|
98
|
-
#{items.join}
|
|
99
|
-
</div>
|
|
100
|
-
HTML
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def subject_span(detection)
|
|
104
|
-
return "" unless detection.table
|
|
102
|
+
def subject_span(detection)
|
|
103
|
+
return "" unless detection.table
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
subject = detection.table
|
|
106
|
+
subject += ".#{detection.columns.join(", ")}" if detection.columns.any?
|
|
107
|
+
%(<span style="opacity:.8;"> — #{h(subject)}</span>)
|
|
108
|
+
end
|
|
110
109
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
def h(value)
|
|
111
|
+
CGI.escapeHTML(value.to_s)
|
|
112
|
+
end
|
|
114
113
|
end
|
|
115
114
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgCanary
|
|
4
|
+
module PgQueryRefinement
|
|
5
|
+
COMPARISON_OPS = %w[= <> != < > <= >=].freeze
|
|
6
|
+
|
|
7
|
+
module Impl
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def unwrap(node)
|
|
11
|
+
return nil if node.nil?
|
|
12
|
+
return node unless node.is_a?(PgQuery::Node)
|
|
13
|
+
|
|
14
|
+
node.node ? node.inner : nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def strip_casts(node)
|
|
18
|
+
node = unwrap(node)
|
|
19
|
+
node = unwrap(node.arg) while node.is_a?(PgQuery::TypeCast)
|
|
20
|
+
node
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def walk(node, prune: nil, &block)
|
|
24
|
+
node = unwrap(node)
|
|
25
|
+
return if node.nil?
|
|
26
|
+
return unless node.is_a?(Google::Protobuf::MessageExts)
|
|
27
|
+
|
|
28
|
+
yield node
|
|
29
|
+
return if prune&.call(node)
|
|
30
|
+
|
|
31
|
+
each_child(node) { |child| walk(child, prune: prune, &block) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each_child(node, &block)
|
|
35
|
+
node.class.descriptor.each do |field|
|
|
36
|
+
next unless field.type == :message
|
|
37
|
+
|
|
38
|
+
value = node[field.name]
|
|
39
|
+
next if value.nil?
|
|
40
|
+
|
|
41
|
+
if value.is_a?(Google::Protobuf::RepeatedField)
|
|
42
|
+
value.each(&block)
|
|
43
|
+
else
|
|
44
|
+
yield value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def walk_scope(node, &)
|
|
50
|
+
root_seen = false
|
|
51
|
+
walk(node, prune: lambda { |msg|
|
|
52
|
+
if root_seen
|
|
53
|
+
msg.is_a?(PgQuery::SelectStmt)
|
|
54
|
+
else
|
|
55
|
+
root_seen = true
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
}, &)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def string_values(nodes)
|
|
62
|
+
nodes.map { |n| unwrap(n) }.grep(PgQuery::String).map(&:sval)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module MessageMethods
|
|
67
|
+
def unwrap = self
|
|
68
|
+
|
|
69
|
+
def walk(prune: nil, &) = Impl.walk(self, prune: prune, &)
|
|
70
|
+
|
|
71
|
+
def walk_scope(&) = Impl.walk_scope(self, &)
|
|
72
|
+
|
|
73
|
+
def each_child(&) = Impl.each_child(self, &)
|
|
74
|
+
|
|
75
|
+
def strip_casts = Impl.strip_casts(self)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
message_classes = PgQuery.constants
|
|
79
|
+
.map { |name| PgQuery.const_get(name) }
|
|
80
|
+
.grep(Class)
|
|
81
|
+
.select { |klass| klass < Google::Protobuf::MessageExts }
|
|
82
|
+
|
|
83
|
+
message_classes.each do |klass|
|
|
84
|
+
refine klass do
|
|
85
|
+
import_methods MessageMethods
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
refine PgQuery::Node do
|
|
90
|
+
def unwrap = Impl.unwrap(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
refine PgQuery::ColumnRef do
|
|
94
|
+
def field_names
|
|
95
|
+
unwrapped = fields.map { |f| Impl.unwrap(f) }
|
|
96
|
+
return nil unless unwrapped.all?(PgQuery::String)
|
|
97
|
+
|
|
98
|
+
unwrapped.map(&:sval)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
refine PgQuery::A_Expr do
|
|
103
|
+
def operator = Impl.string_values(name).last
|
|
104
|
+
|
|
105
|
+
def comparison? = kind == :AEXPR_OP && COMPARISON_OPS.include?(operator)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
refine PgQuery::FuncCall do
|
|
109
|
+
def function_name = Impl.string_values(funcname).last&.downcase
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
refine PgQuery::A_Const do
|
|
113
|
+
def value
|
|
114
|
+
case val
|
|
115
|
+
when :ival then ival.ival
|
|
116
|
+
when :fval then fval.fval.to_f
|
|
117
|
+
when :sval then sval.sval
|
|
118
|
+
when :boolval then boolval.boolval
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
refine Google::Protobuf::RepeatedField do
|
|
124
|
+
def string_values = Impl.string_values(self)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|