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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3117b64abbdaf73701f98780b6dd794822d6bf19919365e3a7c4ffffa62b69a5
4
- data.tar.gz: c1c73a9c77af75bdcbc7bd3b3685654387728986a5d1d7a880c69c7263dc4ca6
3
+ metadata.gz: cb33135791d90607ecc14dc44818adc8a7173cb9ed4f5da995dbfeacc31de62e
4
+ data.tar.gz: a25ba3b887e3ec90961f63442b5a1721a60f41a8d11fe2902d3110cad904cf26
5
5
  SHA512:
6
- metadata.gz: 17df21da5a3d3a6f221db4aa087d4627fac79de5e8a9d4c7cd96c77849df96a47a63ea4d681a9404bd0b52274cbf7424e926cb7a84769532b8e7b81dd0daa089
7
- data.tar.gz: '08302281e57605688fd0eaded8f1a2d031ebae2b5e9499d67c564aecd81e420de5d8f015b495e3a5153297e0e3113366874fe13563c78eb403db7bd3a3eb40c5'
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
- ### Tier 1 — enabled by default (suspicious regardless of table size)
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
- Notes:
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 *correct* 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 enabling these rules by default would drown you in false positives.
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; an initializer is only needed to change them e.g. to enable Tier 2 rules:
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
- config.enabled = Rails.env.development?
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.leading_wildcard_like.severity = :error # :error / :warning
79
-
80
- # Excluding tables
81
- config.ignore_tables = %w[prefectures plans schema_migrations ar_internal_metadata]
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
- # nil means "not explicitly configured" — the Railtie then enables
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, :severity
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
@@ -3,92 +3,100 @@
3
3
  require "active_support/backtrace_cleaner"
4
4
 
5
5
  module PgCanary
6
- # Parses an event's SQL into a QueryContext, runs every enabled rule
7
- # against it, and filters ignored tables. Each returned Detection carries
8
- # the query's fingerprint, so a consumer that sees the same query and rule
9
- # repeatedly (e.g. an N+1 loop within one request) can collapse them —
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
- include PgQuerySupport
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
- @rules = Rules::Base.all.map(&:new).select { |rule| rule.enabled?(config) }
19
- @backtrace_cleaner = build_backtrace_cleaner(config.app_root)
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
- query = build_query(payload)
25
- return [] unless query
27
+ state = build_state(payload)
28
+ return [] unless state
26
29
 
27
30
  location = nil
28
- @rules.flat_map do |rule|
29
- detections = check_rule(rule, query)
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 = query.fingerprint
40
+ d.fingerprint = fingerprint
36
41
  end
37
42
  end
38
43
  end
39
44
 
40
45
  private
41
46
 
42
- # nil for anything that is not a plain SELECT (or fails to parse).
43
- def build_query(payload)
44
- parse_result = PgQuery.parse(payload[:sql])
45
- stmt = unwrap_node(parse_result.tree.stmts.first&.stmt)
46
- return nil unless stmt.is_a?(PgQuery::SelectStmt)
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
- def check_rule(rule, query)
61
- rule.check(query).reject { |d| ignored?(d, query) }
62
- rescue StandardError => e
63
- PgCanary.internal_error(e)
64
- []
65
- end
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
- def ignored?(detection, query)
68
- config = query.config
69
- return true if config.ignore_table?(detection.table)
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
- # Table-less detections (e.g. ORDER BY RANDOM() over a subquery) are
72
- # dropped when every table in the query is ignored.
73
- if detection.table.nil?
74
- tables = query.tables
75
- return tables.any? && tables.all? { |t| config.ignore_table?(t) }
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
- false
79
- end
83
+ def ignored?(detection, scopes)
84
+ return true if @config.ignore_table?(detection.table)
80
85
 
81
- def build_backtrace_cleaner(root)
82
- ActiveSupport::BacktraceCleaner.new.tap do |cleaner|
83
- cleaner.add_silencer { |line| line.include?("lib/pg_canary") }
84
- cleaner.add_filter { |line| line.delete_prefix("#{root}/") } if root
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
- # The application frame that triggered the query.
89
- def source_location
90
- line = @backtrace_cleaner.clean(caller).first
91
- line && line[/\A.+?:\d+/]
92
- end
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
@@ -35,81 +35,80 @@ module PgCanary
35
35
 
36
36
  private
37
37
 
38
- def active?
39
- PgCanary.config.enabled
40
- end
38
+ def active?
39
+ PgCanary.config.enabled
40
+ end
41
41
 
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
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
- def inject(status, headers, body, detections)
50
- return [status, headers, body] unless html_response?(headers)
52
+ content = +""
53
+ body.each { |part| content << part }
54
+ body.close if body.respond_to?(:close)
51
55
 
52
- content = +""
53
- body.each { |part| content << part }
54
- body.close if body.respond_to?(:close)
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
- snippet = footer_panel(detections)
57
- if content.include?("</body>")
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
- headers = headers.merge("Content-Length" => content.bytesize.to_s) if content_length?(headers)
64
- [status, headers, [content]]
65
- end
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
- def html_response?(headers)
68
- headers.any? { |k, v| k.to_s.casecmp("content-type").zero? && v.to_s.include?("text/html") }
69
- end
71
+ def content_length?(headers)
72
+ headers.any? { |k, _| k.to_s.casecmp("content-length").zero? }
73
+ end
70
74
 
71
- def content_length?(headers)
72
- headers.any? { |k, _| k.to_s.casecmp("content-length").zero? }
73
- end
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="padding:8px 12px;border-top:1px solid #444;">
79
- <div>
80
- <strong style="color:#{d.severity == :error ? "#ff6b6b" : "#ffd93d"};">#{h(d.rule_name)}</strong>
81
- <span style="opacity:.7;">(#{h(d.severity)})</span>
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
- <pre style="margin:4px 0;white-space:pre-wrap;color:#9ecbff;">#{h(d.truncated_sql)}</pre>
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
- <<~HTML
93
- <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);">
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
- subject = detection.table
107
- subject += ".#{detection.columns.join(", ")}" if detection.columns.any?
108
- %(<span style="opacity:.8;"> — #{h(subject)}</span>)
109
- end
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
- def h(value)
112
- CGI.escapeHTML(value.to_s)
113
- end
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