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 +7 -0
- data/CHANGELOG.md +118 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/exe/bemdito +6 -0
- data/exe/benedictus +6 -0
- data/exe/bnd +6 -0
- data/lib/benedictus/cli.rb +79 -0
- data/lib/benedictus/color.rb +52 -0
- data/lib/benedictus/errors.rb +31 -0
- data/lib/benedictus/expression_evaluator.rb +24 -0
- data/lib/benedictus/heuristics/base.rb +21 -0
- data/lib/benedictus/heuristics/expensive_per_row_scan.rb +71 -0
- data/lib/benedictus/heuristics/external_sort.rb +31 -0
- data/lib/benedictus/heuristics/nested_loop_blowup.rb +34 -0
- data/lib/benedictus/heuristics/registry.rb +45 -0
- data/lib/benedictus/heuristics/row_estimate_drift.rb +41 -0
- data/lib/benedictus/heuristics/seq_scan_on_large_table.rb +34 -0
- data/lib/benedictus/heuristics/warning.rb +16 -0
- data/lib/benedictus/plan/node.rb +75 -0
- data/lib/benedictus/plan/parser.rb +40 -0
- data/lib/benedictus/plan/tree.rb +52 -0
- data/lib/benedictus/plan_runner.rb +71 -0
- data/lib/benedictus/rails_loader.rb +41 -0
- data/lib/benedictus/relation_resolver.rb +46 -0
- data/lib/benedictus/renderers/json_renderer.rb +15 -0
- data/lib/benedictus/renderers/raw_renderer.rb +16 -0
- data/lib/benedictus/renderers/tree_renderer.rb +196 -0
- data/lib/benedictus/runner.rb +122 -0
- data/lib/benedictus/safety_guard.rb +144 -0
- data/lib/benedictus/sql_formatter.rb +161 -0
- data/lib/benedictus/version.rb +5 -0
- data/lib/benedictus.rb +32 -0
- metadata +99 -0
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
data/exe/benedictus
ADDED
data/exe/bnd
ADDED
|
@@ -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
|