and_one 0.1.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: 9ee084901a4915dba0065a11cf14a6996f277d6ca5fba2efd388b27ddc2f349a
4
+ data.tar.gz: 05d02c49d473359e70ba9b300711e6d15f52d391ab48e22ca1ddb8651e3eb7bc
5
+ SHA512:
6
+ metadata.gz: 5f59a82aac0fc38ef5f9d2d11b633a1b881378124834d8d8ec0dd4ecb85e887d16aec5feee30225205df60ab90cb1577eb02ebca0306685736231faad0e53799
7
+ data.tar.gz: '0663708679e397440e6e1fe26bee567e868e1909e1138cfb94d3da085cabdc4da8808005f72a610aa6f99fde5f262ffead6ff57bd4bc21e0b685c9c07195df0b'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-02-27
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "and_one" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["keiththomps@hey.com"](mailto:"keiththomps@hey.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Keith Thompson
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # 🏀 AndOne
2
+
3
+ Detect N+1 queries in Rails applications with zero configuration and actionable fix suggestions.
4
+
5
+ AndOne stays completely invisible until it detects an N+1 query — then it tells you exactly what's wrong and how to fix it. No external dependencies beyond Rails itself.
6
+
7
+ ## Features
8
+
9
+ - **Zero configuration** — Railtie auto-setup in development and test
10
+ - **Actionable fix suggestions** — suggests the exact `.includes()`, `.preload()`, or `.eager_load()` call
11
+ - **Smart location detection** — identifies both the origin (where the N+1 fires) and the fix location (where to add `.includes`)
12
+ - **Clean error handling** — never corrupts backtraces or interferes with exception propagation
13
+ - **No external dependencies** — only Rails itself
14
+ - **Auto-raises in test** — N+1s fail your test suite by default
15
+ - **Background job support** — ActiveJob (`around_perform`) and Sidekiq server middleware, with double-scan protection
16
+ - **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
17
+ - **Aggregate mode** — report each unique N+1 once per server session with occurrence counts
18
+ - **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
19
+ - **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
20
+ - **Rails console integration** — auto-scans in `rails console` and prints warnings inline
21
+ - **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
22
+ - **Per-environment thresholds** — different `min_n_queries` for development vs test
23
+ - **GitHub Actions annotations** — N+1s appear as warning annotations on PR diffs
24
+ - **`strict_loading` suggestions** — also suggests model-level prevention as an alternative
25
+ - **`has_many :through` and polymorphic support** — resolves complex association chains
26
+ - **Thread-safe under Puma** — per-thread isolation verified with concurrent stress tests
27
+
28
+ ## Installation
29
+
30
+ Add to your Gemfile:
31
+
32
+ ```ruby
33
+ group :development, :test do
34
+ gem "and_one"
35
+ end
36
+ ```
37
+
38
+ That's it. AndOne automatically activates in development and test environments via a Railtie.
39
+
40
+ ## What You'll See
41
+
42
+ When an N+1 is detected, you get output like:
43
+
44
+ ```
45
+ ──────────────────────────────────────────────────────────────────────────
46
+ 🏀 And One! 1 N+1 query detected
47
+ ──────────────────────────────────────────────────────────────────────────
48
+
49
+ 1) 9x repeated query on `comments`
50
+ fingerprint: a1b2c3d4e5f6
51
+
52
+ Query:
53
+ SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?
54
+
55
+ Origin (where the N+1 is triggered):
56
+ → app/views/posts/index.html.erb:5
57
+
58
+ Fix here (where to add .includes):
59
+ ⇒ app/controllers/posts_controller.rb:8
60
+
61
+ Call stack:
62
+ app/views/posts/index.html.erb:5
63
+ app/controllers/posts_controller.rb:8
64
+
65
+ 💡 Suggestion:
66
+ Add `.includes(:comments)` to your Post query
67
+
68
+ To ignore, add to .and_one_ignore:
69
+ fingerprint:a1b2c3d4e5f6
70
+
71
+ ──────────────────────────────────────────────────────────────────────────
72
+ ```
73
+
74
+ ## Background Jobs
75
+
76
+ ### ActiveJob (any backend)
77
+
78
+ Automatically hooked via `around_perform`. Works with **every** ActiveJob backend:
79
+ Sidekiq, GoodJob, SolidQueue, Delayed Job, Resque, and anything else that uses ActiveJob.
80
+
81
+ No configuration needed — the Railtie handles it.
82
+
83
+ ### Sidekiq (direct usage)
84
+
85
+ For jobs that use Sidekiq directly (bypassing ActiveJob), AndOne installs a server middleware automatically when Sidekiq is detected.
86
+
87
+ If you need manual installation:
88
+
89
+ ```ruby
90
+ Sidekiq.configure_server do |config|
91
+ config.server_middleware do |chain|
92
+ chain.add AndOne::SidekiqMiddleware
93
+ end
94
+ end
95
+ ```
96
+
97
+ When both hooks are active (ActiveJob job running through Sidekiq), the Sidekiq middleware detects the existing scan from ActiveJobHook and passes through — no double-scanning.
98
+
99
+ ## Ignoring N+1s
100
+
101
+ ### The `.and_one_ignore` file
102
+
103
+ Create a `.and_one_ignore` file in your project root to permanently silence known N+1s. Supports four rule types:
104
+
105
+ ```bash
106
+ # Ignore N+1s originating from a specific gem
107
+ # (matches against raw backtrace paths, e.g. /gems/devise-4.9.0/)
108
+ gem:devise
109
+ gem:administrate
110
+
111
+ # Ignore N+1s whose call stack matches a path pattern (supports * globs)
112
+ path:app/views/admin/*
113
+ path:lib/legacy/**
114
+
115
+ # Ignore N+1s matching a SQL pattern
116
+ query:schema_migrations
117
+ query:pg_catalog
118
+
119
+ # Ignore a specific detection by its fingerprint (shown in output)
120
+ fingerprint:a1b2c3d4e5f6
121
+ ```
122
+
123
+ This is especially useful for **N+1s coming from gems** where you can't add `.includes()` to the source. Instead of littering your code with `AndOne.pause` blocks, add a `gem:` rule.
124
+
125
+ ### When to use each rule type
126
+
127
+ | Rule | Use when... |
128
+ |---|---|
129
+ | `gem:devise` | A gem you depend on has an N+1 you can't fix |
130
+ | `path:app/views/admin/*` | An area of your app has known N+1s you've accepted |
131
+ | `query:some_table` | A specific query pattern should always be ignored |
132
+ | `fingerprint:abc123` | You want to silence one specific detection (shown in output) |
133
+
134
+ ## Aggregate Mode
135
+
136
+ In development, the same N+1 can fire on every request, flooding your logs. Aggregate mode reports each unique pattern only once per server session:
137
+
138
+ ```ruby
139
+ # config/initializers/and_one.rb
140
+ AndOne.aggregate_mode = true
141
+ ```
142
+
143
+ You can check the session summary at any time:
144
+
145
+ ```ruby
146
+ AndOne.aggregate.summary # formatted string of all unique N+1s
147
+ AndOne.aggregate.size # number of unique patterns
148
+ AndOne.aggregate.reset! # clear and start fresh
149
+ ```
150
+
151
+ ## Test Matchers
152
+
153
+ ### Minitest
154
+
155
+ ```ruby
156
+ class PostsControllerTest < ActionDispatch::IntegrationTest
157
+ include AndOne::MinitestHelper
158
+
159
+ test "index does not cause N+1 queries" do
160
+ assert_no_n_plus_one do
161
+ get posts_path
162
+ end
163
+ end
164
+
165
+ test "known N+1 is documented" do
166
+ detections = assert_n_plus_one do
167
+ get legacy_report_path
168
+ end
169
+ assert_equal "comments", detections.first.table_name
170
+ end
171
+ end
172
+ ```
173
+
174
+ ### RSpec
175
+
176
+ ```ruby
177
+ # In spec_helper.rb or rails_helper.rb
178
+ require "and_one/rspec"
179
+
180
+ # Then in your specs
181
+ RSpec.describe "Posts" do
182
+ it "loads posts efficiently" do
183
+ expect {
184
+ Post.includes(:comments).each { |p| p.comments.to_a }
185
+ }.not_to cause_n_plus_one
186
+ end
187
+
188
+ it "has a known N+1" do
189
+ expect {
190
+ Post.all.each { |p| p.comments.to_a }
191
+ }.to cause_n_plus_one
192
+ end
193
+ end
194
+ ```
195
+
196
+ The matchers temporarily disable `raise_on_detect` internally, so they work correctly regardless of your global configuration.
197
+
198
+ ## Behavior by Environment
199
+
200
+ - **Development**: Logs N+1 warnings to Rails logger and stderr
201
+ - **Test**: Raises `AndOne::NPlus1Error` so N+1s fail your test suite
202
+ - **Production**: Completely disabled (not even loaded)
203
+
204
+ ## Configuration
205
+
206
+ AndOne works out of the box, but you can customize:
207
+
208
+ ```ruby
209
+ # config/initializers/and_one.rb
210
+ AndOne.configure do |config|
211
+ # Raise on detection (default: true in test, false in development)
212
+ config.raise_on_detect = false
213
+
214
+ # Minimum repeated queries to trigger (default: 2)
215
+ config.min_n_queries = 3
216
+
217
+ # Aggregate mode — only report each unique N+1 once per session
218
+ config.aggregate_mode = true
219
+
220
+ # Path to ignore file (default: Rails.root/.and_one_ignore)
221
+ config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
222
+
223
+ # Allow specific patterns (won't flag these call stacks)
224
+ config.allow_stack_paths = [
225
+ /admin_controller/,
226
+ /some_legacy_code/
227
+ ]
228
+
229
+ # Ignore specific query patterns
230
+ config.ignore_queries = [
231
+ /pg_catalog/,
232
+ /schema_migrations/
233
+ ]
234
+
235
+ # Custom backtrace cleaner
236
+ config.backtrace_cleaner = Rails.backtrace_cleaner
237
+
238
+ # Custom callback for integrations (logging services, etc.)
239
+ config.notifications_callback = ->(detections, message) {
240
+ # detections is an array of AndOne::Detection objects
241
+ # message is the formatted string
242
+ MyLogger.warn(message)
243
+ }
244
+ end
245
+ ```
246
+
247
+ ## Manual Scanning
248
+
249
+ You can also scan specific blocks:
250
+
251
+ ```ruby
252
+ # In a test
253
+ detections = AndOne.scan do
254
+ posts = Post.all
255
+ posts.each { |p| p.comments.to_a }
256
+ end
257
+
258
+ assert_empty detections
259
+
260
+ # Pause/resume within a scan
261
+ AndOne.scan do
262
+ # This is scanned
263
+ posts.each { |p| p.comments.to_a }
264
+
265
+ AndOne.pause do
266
+ # This is NOT scanned
267
+ legacy_code_with_known_n_plus_ones
268
+ end
269
+
270
+ # Scanning resumes automatically after the pause block
271
+ end
272
+ ```
273
+
274
+ ## How It Works
275
+
276
+ 1. **Subscribe** to `sql.active_record` notifications (built into Rails)
277
+ 2. **Group** queries by call stack fingerprint
278
+ 3. **Fingerprint** SQL to detect same-shape queries with different bind values
279
+ 4. **Resolve** table names back to ActiveRecord models and associations
280
+ 5. **Suggest** the exact `.includes()` call to fix the N+1
281
+ 6. **Filter** against the `.and_one_ignore` file and aggregate tracker
282
+
283
+ The middleware is designed to **never interfere with error propagation**. If your app raises an exception during a request, AndOne silently stops scanning and re-raises the original exception with its backtrace completely intact.
284
+
285
+ ## License
286
+
287
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/TODO.md ADDED
@@ -0,0 +1,52 @@
1
+ # AndOne — Feature Roadmap
2
+
3
+ ## ✅ Completed
4
+
5
+ - [x] Core detection engine using `sql.active_record` notifications
6
+ - [x] SQL fingerprinting without external dependencies
7
+ - [x] Association resolver that suggests exact `.includes()` fixes
8
+ - [x] Rich formatted output with query, call stack, and fix suggestions
9
+ - [x] Rack middleware that never corrupts error backtraces
10
+ - [x] Railtie for zero-config auto-setup in dev/test
11
+ - [x] Raises in test env, warns in dev, disabled in production
12
+ - [x] Pause/resume support for known N+1s
13
+ - [x] ActiveJob `around_perform` hook (works with any backend)
14
+ - [x] Sidekiq server middleware (for jobs bypassing ActiveJob)
15
+ - [x] `ScanHelper` shared module to DRY up scan lifecycle across entry points
16
+ - [x] Double-scan protection (ActiveJob + Sidekiq don't conflict)
17
+
18
+ ## 🎯 High Value — Completed
19
+
20
+ - [x] **Auto-detect the "fix location"** — Walks the backtrace to identify two key frames: the "origin" (where the N+1 is triggered inside a loop) and the "fix location" (the outer frame where `.includes()` should be added). Both are highlighted in the output.
21
+
22
+ - [x] **Ignore file (`.and_one_ignore`)** — Supports four rule types: `gem:` (for N+1s from gems like devise/administrate you can't fix), `path:` (glob patterns for app areas), `query:` (SQL patterns), and `fingerprint:` (specific detections). Checked into source control.
23
+
24
+ - [x] **Aggregate mode for development** — `AndOne.aggregate_mode = true` reports each unique N+1 only once per server session. Tracks occurrence counts. `AndOne.aggregate.summary` shows a session overview. Thread-safe.
25
+
26
+ - [x] **RSpec / Minitest matchers** — `assert_no_n_plus_one { ... }` / `assert_n_plus_one { ... }` for Minitest. `expect { ... }.not_to cause_n_plus_one` for RSpec. Matchers temporarily disable `raise_on_detect` internally so they work regardless of config.
27
+
28
+ ## ✅ Medium Value — Polish & Power User Features (Completed)
29
+
30
+ - [x] **`strict_loading` suggestion** — When an N+1 is detected, also suggest the `strict_loading` approach as an alternative: "You could also add `has_many :comments, strict_loading: true` to prevent this at the model level."
31
+
32
+ - [x] **Query count in test failure messages** — "N+1 detected: 47 queries to `comments` (expected 1). Add `.includes(:comments)` to reduce to 1 query." Makes severity immediately obvious.
33
+
34
+ - [x] **Dev UI endpoint** — A tiny Rack endpoint (e.g., `/__and_one`) in development that shows all N+1s detected in the current server session with fix suggestions. Like a mini BetterErrors for N+1s.
35
+
36
+ - [x] **GitHub Actions / CI annotations** — When `GITHUB_ACTIONS` env var is set, output detections in `::warning file=...` format so they appear as annotations on the PR diff.
37
+
38
+ - [x] **Ignore by caller pattern** — In addition to `ignore_queries` (SQL patterns), support `ignore_callers` to suppress detections originating from specific paths: "ignore any N+1 from `app/views/admin/*`".
39
+
40
+ - [x] **`has_many :through` and polymorphic support** — Extend the association resolver to handle `has_many :through` join chains and polymorphic associations, which are common sources of confusing N+1s.
41
+
42
+ - [x] **`preload` vs `includes` vs `eager_load` recommendation** — Suggest the optimal loading strategy based on the query pattern (e.g., `eager_load` when there's a WHERE on the association).
43
+
44
+ ## ✅ Lower Priority — Nice to Have (Completed)
45
+
46
+ - [x] **Structured JSON logging** — A JSON output mode for log aggregation services (Datadog, Splunk, etc.). Set `AndOne.json_logging = true`. Uses `JsonFormatter` which outputs structured JSON with event, table, fingerprint, query count, suggestion, and backtrace. Also provides `format_hashes` for integrations that accept Ruby hashes directly.
47
+
48
+ - [x] **Thread-safety audit for Puma** — Formal audit and stress test suite complete. Found and fixed a **critical cross-thread contamination bug** in `Detector#subscribe`: the `ActiveSupport::Notifications` callback closure captured `self`, causing SQL from one thread to be recorded in another thread's Detector. Fixed by checking `Thread.current[:and_one_detector].object_id` in the callback. Also added Mutex protection for lazy singletons (`aggregate`, `ignore_list`), `AssociationResolver.@table_model_cache`, and report output serialization. 14 concurrent stress tests verify isolation, atomicity, and correctness under Puma-like load.
49
+
50
+ - [x] **Rails console integration** — Auto-scan in `rails console` sessions and print warnings inline. Activated automatically by the Railtie in development, or manually via `AndOne::Console.activate!`. Hooks into IRB (via `Context#evaluate` prepend) and Pry (via `:after_eval` hook) to cycle scans between commands.
51
+
52
+ - [x] **Configurable per-environment thresholds** — Different `min_n_queries` for dev vs test. Configure via `AndOne.env_thresholds = { "development" => 3, "test" => 2 }`. Falls back to global `min_n_queries` when no env-specific threshold is set. Supports both string and symbol keys.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Hooks into ActiveJob's around_perform callback to scan every job for N+1 queries.
5
+ # Works with any ActiveJob backend: Sidekiq, GoodJob, SolidQueue, Delayed Job, etc.
6
+ #
7
+ # Automatically installed by the Railtie. Can also be installed manually:
8
+ #
9
+ # ActiveJob::Base.include(AndOne::ActiveJobHook)
10
+ #
11
+ module ActiveJobHook
12
+ extend ActiveSupport::Concern
13
+ include ScanHelper
14
+
15
+ included do
16
+ around_perform :and_one_scan
17
+ end
18
+
19
+ private
20
+
21
+ def and_one_scan
22
+ and_one_wrap { yield }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Tracks unique N+1 detections across requests/jobs in a server session.
5
+ # In aggregate mode, each unique N+1 (by fingerprint) is only reported once.
6
+ # Subsequent occurrences are silently counted.
7
+ #
8
+ # Usage:
9
+ # AndOne.aggregate_mode = true
10
+ #
11
+ # The aggregate can be queried at any time:
12
+ # AndOne.aggregate.summary # => formatted string
13
+ # AndOne.aggregate.detections # => { fingerprint => { detection:, count:, first_seen_at: } }
14
+ # AndOne.aggregate.reset!
15
+ #
16
+ class Aggregate
17
+ Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at, keyword_init: true)
18
+
19
+ def initialize
20
+ @mutex = Mutex.new
21
+ @entries = {}
22
+ end
23
+
24
+ # Record a detection. Returns true if this is a NEW unique detection
25
+ # (first time seeing this fingerprint), false if it's a repeat.
26
+ def record(detection)
27
+ fp = detection.fingerprint
28
+
29
+ @mutex.synchronize do
30
+ if @entries.key?(fp)
31
+ @entries[fp].occurrences += 1
32
+ @entries[fp].last_seen_at = Time.now
33
+ false
34
+ else
35
+ @entries[fp] = Entry.new(
36
+ detection: detection,
37
+ occurrences: 1,
38
+ first_seen_at: Time.now,
39
+ last_seen_at: Time.now
40
+ )
41
+ true
42
+ end
43
+ end
44
+ end
45
+
46
+ def detections
47
+ @mutex.synchronize { @entries.dup }
48
+ end
49
+
50
+ def size
51
+ @mutex.synchronize { @entries.size }
52
+ end
53
+
54
+ def empty?
55
+ @mutex.synchronize { @entries.empty? }
56
+ end
57
+
58
+ def reset!
59
+ @mutex.synchronize { @entries.clear }
60
+ end
61
+
62
+ def summary
63
+ @mutex.synchronize do
64
+ return "No N+1 queries detected this session." if @entries.empty?
65
+
66
+ lines = []
67
+ lines << ""
68
+ lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{'s' if @entries.size != 1}"
69
+ lines << "─" * 60
70
+
71
+ @entries.each_with_index do |(fp, entry), i|
72
+ det = entry.detection
73
+ lines << " #{i + 1}) #{det.table_name || 'unknown'} — #{entry.occurrences} occurrence#{'s' if entry.occurrences != 1}"
74
+ lines << " #{det.sample_query[0, 120]}"
75
+ lines << " origin: #{det.origin_frame}" if det.origin_frame
76
+ lines << " fingerprint: #{fp}"
77
+ lines << ""
78
+ end
79
+
80
+ lines << "─" * 60
81
+ lines.join("\n")
82
+ end
83
+ end
84
+ end
85
+ end