1use std::collections::HashMap;
23use std::sync::atomic::{AtomicUsize, Ordering};
24
25#[derive(Debug)]
27pub struct N1QueryTracker {
28 counts: HashMap<(&'static str, &'static str), AtomicUsize>,
30 threshold: usize,
32 enabled: bool,
34 call_sites: Vec<CallSite>,
36}
37
38impl Default for N1QueryTracker {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct CallSite {
47 pub parent_type: &'static str,
49 pub relationship: &'static str,
51 pub file: &'static str,
53 pub line: u32,
55 pub timestamp: std::time::Instant,
57}
58
59#[derive(Debug, Clone, Default)]
61pub struct N1Stats {
62 pub total_loads: usize,
64 pub relationships_loaded: usize,
66 pub potential_n1: usize,
68}
69
70impl N1QueryTracker {
71 #[must_use]
73 pub fn new() -> Self {
74 Self {
75 counts: HashMap::new(),
76 threshold: 3,
77 enabled: true,
78 call_sites: Vec::new(),
79 }
80 }
81
82 #[must_use]
87 pub fn with_threshold(mut self, threshold: usize) -> Self {
88 self.threshold = threshold;
89 self
90 }
91
92 #[must_use]
94 pub fn threshold(&self) -> usize {
95 self.threshold
96 }
97
98 #[must_use]
100 pub fn is_enabled(&self) -> bool {
101 self.enabled
102 }
103
104 pub fn disable(&mut self) {
106 self.enabled = false;
107 }
108
109 pub fn enable(&mut self) {
111 self.enabled = true;
112 }
113
114 #[track_caller]
120 pub fn record_load(&mut self, parent_type: &'static str, relationship: &'static str) {
121 if !self.enabled {
122 return;
123 }
124
125 let key = (parent_type, relationship);
126 let count = self
127 .counts
128 .entry(key)
129 .or_insert_with(|| AtomicUsize::new(0))
130 .fetch_add(1, Ordering::Relaxed)
131 + 1;
132
133 let caller = std::panic::Location::caller();
135 self.call_sites.push(CallSite {
136 parent_type,
137 relationship,
138 file: caller.file(),
139 line: caller.line(),
140 timestamp: std::time::Instant::now(),
141 });
142
143 if count == self.threshold {
145 self.emit_warning(parent_type, relationship, count);
146 }
147 }
148
149 fn emit_warning(&self, parent_type: &'static str, relationship: &'static str, count: usize) {
151 tracing::warn!(
152 target: "sqlmodel::n1",
153 parent = parent_type,
154 relationship = relationship,
155 queries = count,
156 threshold = self.threshold,
157 "N+1 QUERY PATTERN DETECTED! Consider using Session::load_many() for batch loading."
158 );
159
160 let sites: Vec<_> = self
162 .call_sites
163 .iter()
164 .filter(|s| s.parent_type == parent_type && s.relationship == relationship)
165 .take(5)
166 .collect();
167
168 for (i, site) in sites.iter().enumerate() {
169 tracing::debug!(
170 target: "sqlmodel::n1",
171 index = i,
172 file = site.file,
173 line = site.line,
174 " [{}] {}:{}",
175 i,
176 site.file,
177 site.line
178 );
179 }
180 }
181
182 pub fn reset(&mut self) {
186 self.counts.clear();
187 self.call_sites.clear();
188 }
189
190 #[must_use]
192 pub fn count_for(&self, parent_type: &'static str, relationship: &'static str) -> usize {
193 self.counts
194 .get(&(parent_type, relationship))
195 .map_or(0, |c| c.load(Ordering::Relaxed))
196 }
197
198 #[must_use]
200 pub fn stats(&self) -> N1Stats {
201 N1Stats {
202 total_loads: self
203 .counts
204 .values()
205 .map(|c| c.load(Ordering::Relaxed))
206 .sum(),
207 relationships_loaded: self.counts.len(),
208 potential_n1: self
209 .counts
210 .iter()
211 .filter(|(_, c)| c.load(Ordering::Relaxed) >= self.threshold)
212 .count(),
213 }
214 }
215
216 #[must_use]
218 pub fn call_sites(&self) -> &[CallSite] {
219 &self.call_sites
220 }
221}
222
223pub struct N1DetectionScope {
252 initial_stats: N1Stats,
254 threshold: usize,
256 verbose: bool,
258}
259
260impl N1DetectionScope {
261 #[must_use]
272 pub fn new(initial_stats: N1Stats, threshold: usize) -> Self {
273 tracing::debug!(
274 target: "sqlmodel::n1",
275 threshold = threshold,
276 "N+1 detection scope started"
277 );
278
279 Self {
280 initial_stats,
281 threshold,
282 verbose: false,
283 }
284 }
285
286 #[must_use]
290 pub fn from_tracker(tracker: &N1QueryTracker) -> Self {
291 Self::new(tracker.stats(), tracker.threshold())
292 }
293
294 #[must_use]
296 pub fn verbose(mut self) -> Self {
297 self.verbose = true;
298 self
299 }
300
301 pub fn log_summary(&self, final_stats: &N1Stats) {
306 let new_loads = final_stats
307 .total_loads
308 .saturating_sub(self.initial_stats.total_loads);
309 let new_relationships = final_stats
310 .relationships_loaded
311 .saturating_sub(self.initial_stats.relationships_loaded);
312 let new_n1 = final_stats
313 .potential_n1
314 .saturating_sub(self.initial_stats.potential_n1);
315
316 if new_n1 > 0 {
317 tracing::warn!(
318 target: "sqlmodel::n1",
319 potential_n1 = new_n1,
320 total_loads = new_loads,
321 relationships = new_relationships,
322 threshold = self.threshold,
323 "N+1 ISSUES DETECTED in this scope! Consider using Session::load_many() for batch loading."
324 );
325 } else if self.verbose {
326 tracing::info!(
327 target: "sqlmodel::n1",
328 total_loads = new_loads,
329 relationships = new_relationships,
330 "N+1 detection scope completed (no issues)"
331 );
332 } else {
333 tracing::debug!(
334 target: "sqlmodel::n1",
335 total_loads = new_loads,
336 relationships = new_relationships,
337 "N+1 detection scope completed (no issues)"
338 );
339 }
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_tracker_new_defaults() {
349 let tracker = N1QueryTracker::new();
350 assert_eq!(tracker.threshold(), 3);
351 assert!(tracker.is_enabled());
352 }
353
354 #[test]
355 fn test_tracker_with_threshold() {
356 let tracker = N1QueryTracker::new().with_threshold(5);
357 assert_eq!(tracker.threshold(), 5);
358 }
359
360 #[test]
361 fn test_tracker_enable_disable() {
362 let mut tracker = N1QueryTracker::new();
363 assert!(tracker.is_enabled());
364
365 tracker.disable();
366 assert!(!tracker.is_enabled());
367
368 tracker.enable();
369 assert!(tracker.is_enabled());
370 }
371
372 #[test]
373 fn test_tracker_records_single_load() {
374 let mut tracker = N1QueryTracker::new();
375 tracker.record_load("Hero", "team");
376 assert_eq!(tracker.count_for("Hero", "team"), 1);
377 }
378
379 #[test]
380 fn test_tracker_records_multiple_loads() {
381 let mut tracker = N1QueryTracker::new().with_threshold(10);
382 for _ in 0..5 {
383 tracker.record_load("Hero", "team");
384 }
385 assert_eq!(tracker.count_for("Hero", "team"), 5);
386 }
387
388 #[test]
389 fn test_tracker_records_multiple_relationships() {
390 let mut tracker = N1QueryTracker::new();
391 tracker.record_load("Hero", "team");
392 tracker.record_load("Hero", "team");
393 tracker.record_load("Hero", "powers");
394 tracker.record_load("Team", "heroes");
395
396 assert_eq!(tracker.count_for("Hero", "team"), 2);
397 assert_eq!(tracker.count_for("Hero", "powers"), 1);
398 assert_eq!(tracker.count_for("Team", "heroes"), 1);
399 }
400
401 #[test]
402 fn test_tracker_disabled_no_recording() {
403 let mut tracker = N1QueryTracker::new();
404 tracker.disable();
405 tracker.record_load("Hero", "team");
406 assert_eq!(tracker.count_for("Hero", "team"), 0);
407 }
408
409 #[test]
410 fn test_tracker_reset_clears_counts() {
411 let mut tracker = N1QueryTracker::new();
412 tracker.record_load("Hero", "team");
413 tracker.record_load("Hero", "team");
414 assert_eq!(tracker.count_for("Hero", "team"), 2);
415
416 tracker.reset();
417 assert_eq!(tracker.count_for("Hero", "team"), 0);
418 assert!(tracker.call_sites().is_empty());
419 }
420
421 #[test]
422 fn test_callsite_captures_location() {
423 let mut tracker = N1QueryTracker::new();
424 tracker.record_load("Hero", "team");
425
426 assert_eq!(tracker.call_sites().len(), 1);
427 let site = &tracker.call_sites()[0];
428 assert_eq!(site.parent_type, "Hero");
429 assert_eq!(site.relationship, "team");
430 assert!(site.file.contains("n1_detection.rs"));
431 assert!(site.line > 0);
432 }
433
434 #[test]
435 fn test_callsite_timestamp_monotonic() {
436 let mut tracker = N1QueryTracker::new();
437 tracker.record_load("Hero", "team");
438 tracker.record_load("Hero", "team");
439
440 let sites = tracker.call_sites();
441 assert!(sites[1].timestamp >= sites[0].timestamp);
442 }
443
444 #[test]
445 fn test_stats_total_loads_accurate() {
446 let mut tracker = N1QueryTracker::new().with_threshold(10);
447 tracker.record_load("Hero", "team");
448 tracker.record_load("Hero", "team");
449 tracker.record_load("Hero", "powers");
450
451 let stats = tracker.stats();
452 assert_eq!(stats.total_loads, 3);
453 }
454
455 #[test]
456 fn test_stats_relationships_count() {
457 let mut tracker = N1QueryTracker::new();
458 tracker.record_load("Hero", "team");
459 tracker.record_load("Hero", "powers");
460 tracker.record_load("Team", "heroes");
461
462 let stats = tracker.stats();
463 assert_eq!(stats.relationships_loaded, 3);
464 }
465
466 #[test]
467 fn test_stats_potential_n1_count() {
468 let mut tracker = N1QueryTracker::new().with_threshold(2);
469 tracker.record_load("Hero", "team");
470 tracker.record_load("Hero", "team"); tracker.record_load("Hero", "powers"); let stats = tracker.stats();
474 assert_eq!(stats.potential_n1, 1);
475 }
476
477 #[test]
478 fn test_stats_default() {
479 let stats = N1Stats::default();
480 assert_eq!(stats.total_loads, 0);
481 assert_eq!(stats.relationships_loaded, 0);
482 assert_eq!(stats.potential_n1, 0);
483 }
484
485 #[test]
490 fn test_scope_new_captures_initial_state() {
491 let initial = N1Stats {
492 total_loads: 5,
493 relationships_loaded: 2,
494 potential_n1: 1,
495 };
496 let scope = N1DetectionScope::new(initial.clone(), 3);
497 assert_eq!(scope.initial_stats.total_loads, 5);
498 assert_eq!(scope.threshold, 3);
499 }
500
501 #[test]
502 fn test_scope_from_tracker() {
503 let mut tracker = N1QueryTracker::new().with_threshold(5);
504 tracker.record_load("Hero", "team");
505 tracker.record_load("Hero", "team");
506
507 let scope = N1DetectionScope::from_tracker(&tracker);
508 assert_eq!(scope.threshold, 5);
509 assert_eq!(scope.initial_stats.total_loads, 2);
510 }
511
512 #[test]
513 fn test_scope_verbose_flag() {
514 let initial = N1Stats::default();
515 let scope = N1DetectionScope::new(initial, 3);
516 assert!(!scope.verbose);
517
518 let verbose_scope = scope.verbose();
519 assert!(verbose_scope.verbose);
520 }
521
522 #[test]
523 fn test_scope_log_summary_no_issues() {
524 let initial = N1Stats::default();
525 let scope = N1DetectionScope::new(initial, 3);
526
527 let final_stats = N1Stats {
529 total_loads: 2,
530 relationships_loaded: 1,
531 potential_n1: 0,
532 };
533
534 scope.log_summary(&final_stats);
536 }
537
538 #[test]
539 fn test_scope_log_summary_with_issues() {
540 let initial = N1Stats::default();
541 let scope = N1DetectionScope::new(initial, 3);
542
543 let final_stats = N1Stats {
545 total_loads: 10,
546 relationships_loaded: 2,
547 potential_n1: 1,
548 };
549
550 scope.log_summary(&final_stats);
552 }
553
554 #[test]
555 fn test_scope_calculates_delta() {
556 let initial = N1Stats {
557 total_loads: 5,
558 relationships_loaded: 2,
559 potential_n1: 0,
560 };
561 let scope = N1DetectionScope::new(initial, 3);
562
563 let final_stats = N1Stats {
564 total_loads: 15,
565 relationships_loaded: 4,
566 potential_n1: 2,
567 };
568
569 scope.log_summary(&final_stats);
572 }
573}