benedictus 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 596fa217f7075749c18b7f4310037667fbbb066be7354d587aa93386712c95f5
4
+ data.tar.gz: 82faf203b522663f984ec867b6060aaa47798897e90cd40641bc67d3326e9fc4
5
+ SHA512:
6
+ metadata.gz: eb0cb3195f56ee8038049d383c58d70c6d7128d49a35c070d13b87603de8a832c62df658ba68b1e5512be0f201adceedd30ac833e0f28b74854c0f37cd058486
7
+ data.tar.gz: abd0a1ae4f084ccfdec16105b780d59f802cb7f4972df7422f47b00bfb18a2424471e473d7175411a3a8411f78a8c5ffdb8052798659a8bde337723fdb62a89f
data/CHANGELOG.md ADDED
@@ -0,0 +1,118 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-05-11
9
+
10
+ ### Changed
11
+
12
+ - **Dropped `pastel` and `anbt-sql-formatter` runtime dependencies.**
13
+ Both gems are effectively unmaintained. Their roles are now filled by
14
+ small internal modules:
15
+ - `Benedictus::Color` — ~50-line ANSI wrapper with the same API
16
+ surface we used (`red`/`yellow`/`green`/`cyan`/`bold`/`dim`/`italic`
17
+ plus chained calls like `color.red.bold(text)`) and the same
18
+ `enabled:` toggle.
19
+ - `Benedictus::SqlFormatter` — tokenizer-light SQL pretty-printer for
20
+ `--sql`. Strips string literals, dollar-quoted strings, line
21
+ comments, and nested block comments before doing any keyword work,
22
+ so embedded text like `WHERE bio = 'I will DELETE you'` is never
23
+ mistaken for a clause.
24
+
25
+ The only remaining runtime dependency is `thor`. No public CLI flags
26
+ or output formats changed.
27
+
28
+ ### Added
29
+
30
+ - **Fifth heuristic: `ExpensivePerRowScan`** — flags scan nodes whose
31
+ per-row cost (`(total_cost − startup_cost) / plan_rows`) exceeds
32
+ `1.0`, the most common indicator that correlated subplans (or
33
+ expensive filter functions) are multiplying the work the scan does
34
+ for every outer row. Severity is `:critical` (red) when the scan has
35
+ subplan children, `:warning` (yellow) otherwise. Catches the
36
+ "small Seq Scan with huge total cost" pattern that the row-count
37
+ heuristic alone can't see.
38
+ - **Configurable heuristic thresholds via CLI flags**:
39
+ - `--seq-scan-threshold ROWS` (default `10_000`)
40
+ - `--drift-factor X` (default `10`)
41
+ - `--nested-loop-threshold LOOPS` (default `10_000`)
42
+ - `--per-row-cost-threshold COST` (default `1.0`)
43
+
44
+ Heuristic constructors now accept a `threshold:` kwarg, and
45
+ `Heuristics::Registry.new(config:)` routes named thresholds to the
46
+ matching heuristic via each class's `.config_key`. Existing defaults
47
+ unchanged.
48
+
49
+ ## [0.1.0] - 2026-05-08
50
+
51
+ ### Added — initial release
52
+
53
+ - **CLI** — `benedictus`, `bnd`, `bemdito` executables (Thor-backed).
54
+ Positional Ruby expression dispatched to the `explain` task by
55
+ default; `--version`/`-v` and `help` work without booting Rails.
56
+ - **Rails integration** — locates and loads the host application's
57
+ `config/environment.rb`, evaluates the expression in `TOPLEVEL_BINDING`,
58
+ resolves to an `ActiveRecord::Relation` (with `.call` / `.to_relation`
59
+ / `.to_sql` fallbacks).
60
+ - **Plan parser** — typed in-memory tree of `Plan::Node`s built from
61
+ Postgres' `EXPLAIN (FORMAT JSON)` envelope. Defensive against unknown
62
+ or non-array `Plans` keys (preserved under `attrs`); empty-array
63
+ envelopes raise a clear `DatabaseError`.
64
+ - **Tree renderer** — colored, indented box-drawing output with
65
+ inline warnings, severity-aware coloring (critical red, warning
66
+ yellow, info cyan, Index Scan / Index Only Scan green), Pastel
67
+ with NO_COLOR/TTY honoring. `format_rows` preserves negative signs
68
+ and truncates floats.
69
+ - **JSON renderer** — pretty-printed Postgres plan, paste-ready for
70
+ explain.dalibo.com.
71
+ - **Raw renderer** — verbatim text-format EXPLAIN.
72
+ - **Heuristics** — four annotations: Seq Scan on large tables, row
73
+ estimate drift (suggests `ANALYZE`), external sort, Nested Loop
74
+ blowup. `Heuristics::Base#apply` raises `NotImplementedError` if a
75
+ subclass forgets to override.
76
+ - **Safety (`--analyze` only)**:
77
+ - SELECT-only static check that **first normalizes** the SQL —
78
+ strips single-quoted *and* dollar-quoted strings, line comments,
79
+ and *nested* block comments — before scanning the first keyword
80
+ or detecting data-modifying CTEs. Eliminates false positives like
81
+ `WHERE bio = 'I will DELETE you'` and false negatives that hide
82
+ DML behind comments.
83
+ - **Multi-statement rejection**: SQL containing `;` outside string
84
+ literals is refused. `"SELECT 1; DELETE FROM users"` no longer
85
+ passes through to the adapter.
86
+ - **`RawSqlAdapter` rejection under `--analyze`**: only actual
87
+ `ActiveRecord::Relation` instances are accepted; arbitrary objects
88
+ responding to `.to_sql` are refused with a clear error.
89
+ - **Stricter rollback wrapper**: `klass <= ActiveRecord::Base` is
90
+ enforced. A duck-typed `.transaction` that doesn't actually open a
91
+ DB transaction can no longer silently disable the rollback.
92
+ - Footer wording calls out that volatile-function side effects
93
+ (`setval`, `pg_advisory_lock`, `pg_notify`, `dblink`, …) are NOT
94
+ reverted by ROLLBACK. README documents the full list.
95
+ - **Error→exit-code mapping** — RailsNotFound (2), Evaluation /
96
+ Unresolvable (3), UnsafeQuery (4), Database (5).
97
+ - **CLI dispatch hardening**: `--analyze` and other Thor flags route
98
+ to the right command; expressions starting with `-` (e.g.
99
+ `-1.times`, `-User.count`) are correctly treated as expressions
100
+ rather than flags.
101
+ - **`--buffers` without `--analyze`**: emits a stderr warning per
102
+ ERD §5.5 instead of being silently dropped.
103
+
104
+ ### Configuration
105
+
106
+ - `BENEDICTUS_RAILS_ENV` overrides `RAILS_ENV` for the CLI invocation.
107
+ - `NO_COLOR` and `--no-color` both disable color.
108
+
109
+ ### Known limitations (deferred)
110
+
111
+ - Terminal-width-aware truncation for very wide rows (PRD §11 Q3) is
112
+ not implemented; deep/wide trees may wrap awkwardly on narrow
113
+ terminals.
114
+ - The four heuristics are the v1 set; more (N+1 detection, index
115
+ suggestions backed by schema introspection, plan diffing) are
116
+ marked post-v1 in PRD §9.
117
+ - Only Rails 8.x is exercised by CI; 6.1 / 7.x compatibility is
118
+ asserted by API surface but not by automated tests.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antonio Paulino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # Benedictus
2
+
3
+ > A query bem-dita.
4
+
5
+ A Ruby gem that turns the obscure PostgreSQL `EXPLAIN` output into a
6
+ clear, colored, human-readable tree — for any ActiveRecord scope, query
7
+ object, or expression that resolves to an `ActiveRecord::Relation`.
8
+
9
+ ```sh
10
+ bundle exec benedictus "User.active.with_recent_orders" --analyze
11
+ ```
12
+
13
+ ```
14
+ Query: User.active.with_recent_orders
15
+ Total cost: 1834.21 Execution time: 412.9 ms Planning: 1.30 ms Rows: 1,204
16
+
17
+ └─ Hash Join (cost=234.10..1834.21 actual=412.3 ms rows=1,204)
18
+ Hash Cond: (orders.user_id = users.id)
19
+ ├─ Seq Scan on orders (cost=0.00..1500.00 actual=389.2 ms rows=120,000)
20
+ │ ⚠ Seq Scan on a table with ~12000 estimated rows
21
+ │ Consider adding an index that matches the filter on `orders`.
22
+ │ ⚠ Plan estimated 12000 rows, actual was 120000 (10.0x drift).
23
+ │ Run `ANALYZE orders` to refresh planner statistics.
24
+ └─ Hash (cost=234.10..234.10 actual=18.70 ms rows=1,500)
25
+ └─ Index Scan using index_users_on_active on users (cost=0.42..234.10 actual=18.50 ms rows=1,500)
26
+ Index Cond: (active = true)
27
+
28
+ Executed inside rolled-back transaction. Note: side effects of volatile
29
+ Postgres functions (setval, pg_advisory_lock, pg_notify, dblink, …) are
30
+ NOT reverted by ROLLBACK.
31
+ ```
32
+
33
+ ## Why?
34
+
35
+ PostgreSQL `EXPLAIN` is powerful but hard to read at a glance:
36
+
37
+ - The indentation-based tree is fragile and unfamiliar.
38
+ - Costs and times are interleaved with rows in a wall of text.
39
+ - Critical issues — sequential scans on large tables, bad row
40
+ estimates, sorts spilling to disk — aren't visually highlighted.
41
+ - You have to mentally translate a Ruby scope chain back to SQL
42
+ before the plan even starts to make sense.
43
+
44
+ Benedictus reads the plan, applies a small set of heuristics, and
45
+ renders it the way you'd actually want to read it.
46
+
47
+ ## Installation
48
+
49
+ In your application's Gemfile, in the `:development` group:
50
+
51
+ ```ruby
52
+ group :development do
53
+ gem "benedictus"
54
+ end
55
+ ```
56
+
57
+ Then:
58
+
59
+ ```sh
60
+ bundle install
61
+ ```
62
+
63
+ Three executables are installed: `benedictus`, `bnd`, `bemdito`.
64
+ They are interchangeable.
65
+
66
+ ### Requirements
67
+
68
+ - Ruby >= 3.1
69
+ - ActiveRecord >= 6.1 (verified against Rails 8.x; 6.1 / 7.x should
70
+ work but aren't covered by CI yet)
71
+ - PostgreSQL (any version supported by your Rails app)
72
+
73
+ ## Usage
74
+
75
+ The first positional argument is a Ruby expression that resolves to an
76
+ `ActiveRecord::Relation` — or a query object responding to `.call`, or
77
+ anything responding to `.to_sql`.
78
+
79
+ ```sh
80
+ bundle exec benedictus "User.active.recent"
81
+ bundle exec benedictus "User.where(active: true).order(:created_at)"
82
+ bundle exec benedictus "OrdersQuery.new(user_id: 123).call"
83
+ bundle exec bnd "User.active" --analyze --sql
84
+ ```
85
+
86
+ ### Options
87
+
88
+ | Flag | Description | Default |
89
+ |-----------------------|-----------------------------------------------------|-------------|
90
+ | `--analyze` / `--analyse` | Run `EXPLAIN ANALYZE` inside a rolled-back transaction | `false` |
91
+ | `--format FORMAT` | Output format: `tree`, `json`, or `raw` | `tree` |
92
+ | `--sql` | Print the SQL being analyzed (formatted) | `false` |
93
+ | `--buffers` | Include buffer usage info (requires `--analyze`) | `false` |
94
+ | `--verbose` | Verbose plan output | `false` |
95
+ | `--no-color` | Disable colored output | TTY-aware |
96
+ | `--seq-scan-threshold ROWS` | Min `plan_rows` to flag a Seq Scan | `10_000` |
97
+ | `--drift-factor X` | Min actual/plan ratio to flag row-estimate drift | `10` |
98
+ | `--nested-loop-threshold LOOPS` | Min inner loops to flag Nested Loop blowup | `10_000` |
99
+ | `--per-row-cost-threshold COST` | Min `(total − startup) / rows` to flag a scan | `1.0` |
100
+ | `--version` / `-v` | Print version | — |
101
+ | `--help` / `-h` | Show help | — |
102
+
103
+ The `NO_COLOR` environment variable also disables color, per
104
+ [no-color.org](https://no-color.org). `--buffers` without `--analyze`
105
+ emits a stderr warning and is otherwise ignored.
106
+
107
+ ### Output formats
108
+
109
+ - `tree` (default) — the colored, annotated tree shown above.
110
+ - `json` — pretty-printed Postgres JSON plan; paste into
111
+ [explain.dalibo.com](https://explain.dalibo.com) for a deeper look.
112
+ - `raw` — the text-format `EXPLAIN` exactly as Postgres returns it.
113
+
114
+ ## Heuristics (v1)
115
+
116
+ Inline warnings flag the most common issues. Every threshold is
117
+ overridable via a CLI flag — see *Tuning thresholds* below.
118
+
119
+ | Heuristic | Default trigger | Severity | Override flag |
120
+ |---|---|---|---|
121
+ | Seq Scan on a large table | `plan_rows > 10_000` | Critical (red) | `--seq-scan-threshold` |
122
+ | Row estimate drift | `actual / plan > 10x` (under `--analyze`) | Warning (yellow) | `--drift-factor` |
123
+ | External sort | `Sort Method` includes `"external"` | Critical (red) | — |
124
+ | Nested Loop blowup | inner-side `actual_loops > 10_000` (under `--analyze`) | Warning (yellow) | `--nested-loop-threshold` |
125
+ | Expensive per-row scan | `(total_cost − startup_cost) / plan_rows > 1.0` | Critical when correlated subplans drive it; Warning otherwise | `--per-row-cost-threshold` |
126
+
127
+ The "expensive per-row scan" heuristic catches the *most common
128
+ hidden-cost pattern*: a small Seq Scan that looks fine on row counts
129
+ but whose total cost is multiplied by correlated subplans running once
130
+ per outer row, like:
131
+
132
+ ```
133
+ └─ Seq Scan on stars (cost=0.00..49318.79 rows=519)
134
+ ⚠ Seq Scan on stars costs 95.03 per row over 519 rows (total 49318.79). Driven by SubPlan 1, SubPlan 2.
135
+ Correlated subplans run once per outer row. Consider rewriting as a JOIN, LATERAL, or window function.
136
+ ```
137
+
138
+ ### Tuning thresholds
139
+
140
+ ```sh
141
+ # Lower the Seq Scan trigger for a small dev database
142
+ bundle exec benedictus "User.active" --seq-scan-threshold=500
143
+
144
+ # Be more aggressive about flagging row-estimate drift
145
+ bundle exec benedictus "User.active" --analyze --drift-factor=3
146
+
147
+ # Catch even mildly expensive per-row work
148
+ bundle exec benedictus "User.active" --per-row-cost-threshold=0.5
149
+ ```
150
+
151
+ Each flag accepts a number; the units match the trigger column above.
152
+
153
+ ## Safety
154
+
155
+ When `--analyze` is set, Benedictus applies three layers of defense:
156
+
157
+ 1. **Tokenizing static check.** The SQL is normalized — string
158
+ literals (single-quoted *and* dollar-quoted), line comments, and
159
+ nested block comments are stripped before any keyword scan. The
160
+ first remaining keyword must be `SELECT` (or `WITH` with no
161
+ data-modifying CTE).
162
+ 2. **Multi-statement rejection.** SQL containing `;` outside string
163
+ literals is rejected, so a `RawSqlAdapter` returning
164
+ `"SELECT 1; DELETE FROM users"` is refused.
165
+ 3. **AR-class rollback.** The EXPLAIN ANALYZE runs inside
166
+ `relation.klass.transaction(requires_new: true) { …; raise
167
+ ActiveRecord::Rollback }`. The class is verified to be
168
+ `<= ActiveRecord::Base`, so an arbitrary duck-typed
169
+ `transaction(...)` cannot silently disable the rollback.
170
+
171
+ `RawSqlAdapter` (the fallback wrapper for any object that just
172
+ responds to `.to_sql`) is **rejected outright under `--analyze`** —
173
+ pass an actual `ActiveRecord::Relation` if you want to analyze.
174
+
175
+ ### Caveats
176
+
177
+ `EXPLAIN ANALYZE` *physically executes the query plan* in order to
178
+ measure runtime. The transaction wrapping reverts row-level changes,
179
+ but the following kinds of side effects **escape ROLLBACK** and will
180
+ persist:
181
+
182
+ - Sequence advancement: `setval(...)`, `nextval(...)`
183
+ - Advisory locks: `pg_advisory_lock(...)`, including session-level locks
184
+ - Notifications: `pg_notify(...)`, `LISTEN`/`NOTIFY`
185
+ - Cross-database writes via `dblink(...)`, `postgres_fdw`, foreign data wrappers
186
+ - Large-object operations: `lo_create`, `lo_unlink`, `lo_put`
187
+ - `COPY ... TO PROGRAM` (executes shell commands on the server)
188
+ - `SECURITY DEFINER` UDFs whose body performs the above
189
+
190
+ If your scope or query object passes a value through one of these
191
+ functions, `--analyze` will run it for real and there is no rolling it
192
+ back. **Use `--analyze` only on read-only queries** that don't call
193
+ volatile functions.
194
+
195
+ ## Security note
196
+
197
+ Benedictus uses `eval` to evaluate the expression you pass in.
198
+ **The expression is your own Ruby code, executed in your own
199
+ development environment.** Do not pass untrusted input.
200
+
201
+ ## Development
202
+
203
+ ```sh
204
+ git clone https://github.com/tonyaraujop/benedictus
205
+ cd benedictus
206
+ bundle install
207
+ bundle exec rspec spec/benedictus # unit tests, no DB required
208
+ bundle exec rspec spec/integration # full integration, needs PG
209
+ ```
210
+
211
+ Integration tests need a reachable PostgreSQL — see
212
+ [`spec/support/dummy_app/README.md`](spec/support/dummy_app/README.md).
213
+ A one-line `docker run` is documented there.
214
+
215
+ ## Etymology
216
+
217
+ Latin *benedictus* = *bene* (well) + *dictus* (said) = "well-said".
218
+
219
+ ## License
220
+
221
+ MIT. See [`LICENSE`](LICENSE).
data/exe/bemdito ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "benedictus"
5
+
6
+ Benedictus::CLI.start(ARGV)
data/exe/benedictus ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "benedictus"
5
+
6
+ Benedictus::CLI.start(ARGV)
data/exe/bnd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "benedictus"
5
+
6
+ Benedictus::CLI.start(ARGV)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Benedictus
6
+ class CLI < Thor
7
+ default_task :explain
8
+ map %w[--version -v] => :version
9
+
10
+ class << self
11
+ def exit_on_failure?
12
+ true
13
+ end
14
+
15
+ def start(given_args = ARGV, config = {})
16
+ args = Array(given_args)
17
+ return super if args.empty?
18
+ return super if dispatchable_directly?(args.first)
19
+
20
+ super(["explain", *args], config)
21
+ end
22
+
23
+ def dispatchable_directly?(arg)
24
+ return true if all_tasks.key?(arg.to_s)
25
+ return true if arg == "help"
26
+
27
+ # Match Thor-style flags: -x, --foo, --foo-bar.
28
+ # Does NOT match expressions starting with `-`, e.g. `-1.times`, `-User.count`.
29
+ arg.match?(/\A--?[a-zA-Z][\w-]*\z/)
30
+ end
31
+ end
32
+
33
+ desc "explain EXPRESSION", "Render an EXPLAIN tree for a Ruby/Rails expression"
34
+ long_desc <<~DESC
35
+ Evaluates EXPRESSION inside the host Rails app and renders the
36
+ EXPLAIN plan of the resolved ActiveRecord::Relation as a colored,
37
+ annotated tree. Pass --analyze to run EXPLAIN ANALYZE inside a
38
+ rolled-back transaction (SELECT-only).
39
+ DESC
40
+ option :analyze, type: :boolean, default: false, aliases: ["--analyse"],
41
+ desc: "Run EXPLAIN ANALYZE inside a rolled-back transaction"
42
+ option :format, type: :string, default: "tree", enum: %w[tree json raw],
43
+ desc: "Output format"
44
+ option :sql, type: :boolean, default: false,
45
+ desc: "Print the SQL being analyzed"
46
+ option :buffers, type: :boolean, default: false,
47
+ desc: "Include buffer usage info (requires --analyze)"
48
+ option :verbose, type: :boolean, default: false,
49
+ desc: "Enable verbose plan output"
50
+ option :"no-color", type: :boolean, default: false,
51
+ desc: "Disable colored output"
52
+ option :"seq-scan-threshold", type: :numeric, banner: "ROWS",
53
+ desc: "Min plan_rows for a Seq Scan to be flagged (default: 10000)"
54
+ option :"drift-factor", type: :numeric, banner: "X",
55
+ desc: "Min actual/plan ratio to flag row-estimate drift (default: 10)"
56
+ option :"nested-loop-threshold", type: :numeric, banner: "LOOPS",
57
+ desc: "Min inner loops to flag a Nested Loop blowup (default: 10000)"
58
+ option :"per-row-cost-threshold", type: :numeric, banner: "COST",
59
+ desc: "Min per-row cost to flag an expensive scan (default: 1.0)"
60
+ def explain(expression)
61
+ Benedictus::Runner.new(expression: expression, options: options).call
62
+ rescue Benedictus::Error => e
63
+ handle_error(e)
64
+ end
65
+
66
+ desc "version", "Print the Benedictus version"
67
+ def version
68
+ puts Benedictus::VERSION
69
+ end
70
+
71
+ private
72
+
73
+ def handle_error(error)
74
+ color = Benedictus::Color.new(enabled: $stderr.tty? && !ENV["NO_COLOR"])
75
+ warn(color.red("benedictus: #{error.message}"))
76
+ exit error.exit_code
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ class Color
5
+ CODES = {
6
+ bold: 1,
7
+ dim: 2,
8
+ italic: 3,
9
+ red: 31,
10
+ green: 32,
11
+ yellow: 33,
12
+ blue: 34,
13
+ cyan: 36
14
+ }.freeze
15
+
16
+ def initialize(enabled: true)
17
+ @enabled = enabled
18
+ end
19
+
20
+ CODES.each_key do |style|
21
+ define_method(style) do |text = nil|
22
+ styled(text, [style])
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def styled(text, styles)
29
+ return Decorator.new(@enabled, styles) if text.nil?
30
+ return text unless @enabled
31
+
32
+ "\e[#{styles.map { |s| CODES.fetch(s) }.join(";")}m#{text}\e[0m"
33
+ end
34
+
35
+ class Decorator
36
+ def initialize(enabled, styles)
37
+ @enabled = enabled
38
+ @styles = styles
39
+ end
40
+
41
+ CODES.each_key do |style|
42
+ define_method(style) do |text = nil|
43
+ next self.class.new(@enabled, @styles + [style]) if text.nil?
44
+ next text unless @enabled
45
+
46
+ codes = (@styles + [style]).map { |s| CODES.fetch(s) }.join(";")
47
+ "\e[#{codes}m#{text}\e[0m"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ class Error < StandardError
5
+ EXIT_CODE = 1
6
+
7
+ def exit_code
8
+ self.class::EXIT_CODE
9
+ end
10
+ end
11
+
12
+ class RailsNotFoundError < Error
13
+ EXIT_CODE = 2
14
+ end
15
+
16
+ class EvaluationError < Error
17
+ EXIT_CODE = 3
18
+ end
19
+
20
+ class UnresolvableExpression < Error
21
+ EXIT_CODE = 3
22
+ end
23
+
24
+ class UnsafeQueryError < Error
25
+ EXIT_CODE = 4
26
+ end
27
+
28
+ class DatabaseError < Error
29
+ EXIT_CODE = 5
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ class ExpressionEvaluator
5
+ EVAL_ERRORS = [NameError, NoMethodError, SyntaxError, LoadError].freeze
6
+
7
+ def self.call(expression)
8
+ new(expression).call
9
+ end
10
+
11
+ def initialize(expression)
12
+ @expression = expression
13
+ end
14
+
15
+ def call
16
+ raise Benedictus::EvaluationError, "Expression is empty" if @expression.to_s.strip.empty?
17
+
18
+ eval(@expression, TOPLEVEL_BINDING, "(benedictus)")
19
+ rescue *EVAL_ERRORS => e
20
+ raise Benedictus::EvaluationError,
21
+ "Failed to evaluate expression `#{@expression}`: #{e.class}: #{e.message}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ module Heuristics
5
+ class Base
6
+ def self.apply(node)
7
+ new.apply(node)
8
+ end
9
+
10
+ def apply(_node)
11
+ raise NotImplementedError, "#{self.class}#apply must return an Array<Warning>"
12
+ end
13
+
14
+ protected
15
+
16
+ def warning(severity:, code:, message:, suggestion: nil)
17
+ Warning.new(severity: severity, code: code, message: message, suggestion: suggestion)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benedictus
4
+ module Heuristics
5
+ # Flags scan nodes whose per-row cost is anomalously high — usually
6
+ # a sign that correlated subplans (or expensive filter functions) are
7
+ # multiplying the work the scan does for every outer row.
8
+ #
9
+ # A normal Seq Scan tends to cost ~0.05 per row; anything above ~1.0 is
10
+ # almost always doing something extra (a SubPlan running per row, a
11
+ # SECURITY DEFINER UDF in a filter, etc.).
12
+ class ExpensivePerRowScan < Base
13
+ DEFAULT_THRESHOLD = 1.0
14
+ SCAN_TYPES = ["Seq Scan", "Index Scan", "Index Only Scan", "Bitmap Heap Scan"].freeze
15
+
16
+ def self.config_key
17
+ :per_row_cost_threshold
18
+ end
19
+
20
+ def initialize(threshold: DEFAULT_THRESHOLD)
21
+ super()
22
+ @threshold = threshold
23
+ end
24
+
25
+ def apply(node)
26
+ return [] unless SCAN_TYPES.include?(node.node_type)
27
+ return [] unless node.plan_rows&.positive?
28
+ return [] unless node.total_cost && node.startup_cost
29
+
30
+ per_row = (node.total_cost - node.startup_cost) / node.plan_rows.to_f
31
+ return [] if per_row < @threshold
32
+
33
+ subplans = subplan_children(node)
34
+
35
+ [
36
+ warning(
37
+ severity: subplans.any? ? :critical : :warning,
38
+ code: :expensive_per_row_scan,
39
+ message: build_message(node, per_row, subplans),
40
+ suggestion: build_suggestion(subplans)
41
+ )
42
+ ]
43
+ end
44
+
45
+ private
46
+
47
+ def subplan_children(node)
48
+ node.children.select { |c| %w[SubPlan InitPlan].include?(c.parent_relationship) }
49
+ end
50
+
51
+ def build_message(node, per_row, subplans)
52
+ target = node.relation_name || "unknown"
53
+ base = "#{node.node_type} on #{target} costs #{format("%.2f", per_row)} per row over #{node.plan_rows} rows " \
54
+ "(total #{format("%.2f", node.total_cost - node.startup_cost)})."
55
+ return base if subplans.empty?
56
+
57
+ names = subplans.filter_map(&:subplan_name).uniq
58
+ suffix = names.empty? ? "#{subplans.size} correlated subplan(s)" : names.join(", ")
59
+ "#{base} Driven by #{suffix}."
60
+ end
61
+
62
+ def build_suggestion(subplans)
63
+ if subplans.any?
64
+ "Correlated subplans run once per outer row. Consider rewriting as a JOIN, LATERAL, or window function."
65
+ else
66
+ "Heavy per-row processing — check the filter for expensive function calls."
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end