1use std::collections::HashMap;
59
60#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SqlQueryRecord {
91 pub sql: String,
93 pub template: String,
95 pub table: String,
97 pub timestamp_ms: u64,
99 pub query_index: u64,
101}
102
103impl SqlQueryRecord {
104 pub fn new(sql: &str, table: &str, timestamp_ms: u64, query_index: u64) -> Self {
115 Self {
116 sql: sql.to_string(),
117 template: extract_template(sql),
118 table: table.to_string(),
119 timestamp_ms,
120 query_index,
121 }
122 }
123
124 pub fn sql(&self) -> &str {
126 &self.sql
127 }
128
129 pub fn template(&self) -> &str {
131 &self.template
132 }
133
134 pub fn table(&self) -> &str {
136 &self.table
137 }
138
139 pub fn timestamp_ms(&self) -> u64 {
141 self.timestamp_ms
142 }
143
144 pub fn query_index(&self) -> u64 {
146 self.query_index
147 }
148}
149
150pub fn extract_template(sql: &str) -> String {
179 let chars: Vec<char> = sql.chars().collect();
180 let mut result = String::with_capacity(sql.len());
181 let mut i = 0;
182 while i < chars.len() {
183 let c = chars[i];
184 if c == '\'' {
185 result.push('?');
187 i += 1;
188 while i < chars.len() && chars[i] != '\'' {
189 i += 1;
190 }
191 if i < chars.len() {
193 i += 1;
194 }
195 } else if c == '"' {
196 result.push('?');
198 i += 1;
199 while i < chars.len() && chars[i] != '"' {
200 i += 1;
201 }
202 if i < chars.len() {
203 i += 1;
204 }
205 } else if c.is_ascii_digit() {
206 result.push('?');
208 i += 1;
209 while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
211 i += 1;
212 }
213 } else {
214 result.push(c);
215 i += 1;
216 }
217 }
218 result
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct DetectionConfig {
245 pub threshold: usize,
247 pub time_window_ms: u64,
249}
250
251impl Default for DetectionConfig {
252 fn default() -> Self {
253 Self {
254 threshold: 5,
255 time_window_ms: 1000,
256 }
257 }
258}
259
260impl DetectionConfig {
261 pub fn new(threshold: usize, time_window_ms: u64) -> Self {
263 Self {
264 threshold,
265 time_window_ms,
266 }
267 }
268
269 pub fn threshold(&self) -> usize {
271 self.threshold
272 }
273
274 pub fn time_window_ms(&self) -> u64 {
276 self.time_window_ms
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct NPlusOneAlert {
312 pub template: String,
314 pub table: String,
316 pub query_count: usize,
318 pub time_span_ms: u64,
320 pub suggestion: String,
322}
323
324impl NPlusOneAlert {
325 pub fn new(template: &str, table: &str, query_count: usize, time_span_ms: u64) -> Self {
327 Self {
328 template: template.to_string(),
329 table: table.to_string(),
330 query_count,
331 time_span_ms,
332 suggestion: suggest_with_usage(table, query_count),
333 }
334 }
335
336 pub fn template(&self) -> &str {
338 &self.template
339 }
340
341 pub fn table(&self) -> &str {
343 &self.table
344 }
345
346 pub fn query_count(&self) -> usize {
348 self.query_count
349 }
350
351 pub fn time_span_ms(&self) -> u64 {
353 self.time_span_ms
354 }
355
356 pub fn suggestion(&self) -> &str {
358 &self.suggestion
359 }
360}
361
362pub fn suggest_with_usage(table: &str, count: usize) -> String {
386 format!(
387 "Detected N+1 problem: {} queries on table '{}' with same template. \
388 Consider using `with('{}')` for batch preloading to reduce {} queries to 1.",
389 count, table, table, count
390 )
391}
392
393pub fn detect_n_plus_one(
436 records: &[SqlQueryRecord],
437 config: &DetectionConfig,
438) -> Vec<NPlusOneAlert> {
439 let mut groups: HashMap<String, Vec<&SqlQueryRecord>> = HashMap::new();
441 for record in records {
442 groups
443 .entry(record.template.clone())
444 .or_default()
445 .push(record);
446 }
447
448 let mut alerts: Vec<NPlusOneAlert> = Vec::new();
450 for group_records in groups.values() {
451 let mut sorted_records: Vec<&&SqlQueryRecord> = group_records.iter().collect();
453 sorted_records.sort_by_key(|r| r.timestamp_ms);
454
455 if sorted_records.len() < config.threshold {
456 continue;
457 }
458
459 let window = config.time_window_ms;
461 let threshold = config.threshold;
462 let mut start = 0;
463 while start < sorted_records.len() {
464 let start_time = sorted_records[start].timestamp_ms;
465 let mut end = start;
466 while end < sorted_records.len()
467 && sorted_records[end].timestamp_ms <= start_time + window
468 {
469 end += 1;
470 }
471 let count = end - start;
473 if count >= threshold {
474 let template = sorted_records[start].template.clone();
476 let table = sorted_records[start].table.clone();
477 let time_span = if end > 0 {
478 sorted_records[end - 1]
479 .timestamp_ms
480 .saturating_sub(start_time)
481 } else {
482 0
483 };
484 let total_count = group_records.len();
486 alerts.push(NPlusOneAlert::new(
487 &template,
488 &table,
489 total_count,
490 time_span,
491 ));
492 break; }
494 start += 1;
495 }
496 }
497
498 alerts.sort_by_key(|a| std::cmp::Reverse(a.query_count));
500 alerts
501}
502
503#[derive(Debug, Clone, Default)]
528pub struct NPlusOneDetector {
529 records: Vec<SqlQueryRecord>,
530 config: DetectionConfig,
531 next_query_index: u64,
532}
533
534impl NPlusOneDetector {
535 pub fn new(config: DetectionConfig) -> Self {
537 Self {
538 records: Vec::new(),
539 config,
540 next_query_index: 0,
541 }
542 }
543
544 pub fn record(&mut self, sql: &str, table: &str, timestamp_ms: u64) {
554 let record = SqlQueryRecord::new(sql, table, timestamp_ms, self.next_query_index);
555 self.next_query_index += 1;
556 self.records.push(record);
557 }
558
559 pub fn record_with_index(
561 &mut self,
562 sql: &str,
563 table: &str,
564 timestamp_ms: u64,
565 query_index: u64,
566 ) {
567 let record = SqlQueryRecord::new(sql, table, timestamp_ms, query_index);
568 self.records.push(record);
569 if query_index >= self.next_query_index {
570 self.next_query_index = query_index + 1;
571 }
572 }
573
574 pub fn detect(&self) -> Vec<NPlusOneAlert> {
576 detect_n_plus_one(&self.records, &self.config)
577 }
578
579 pub fn clear(&mut self) {
581 self.records.clear();
582 self.next_query_index = 0;
583 }
584
585 pub fn record_count(&self) -> usize {
587 self.records.len()
588 }
589
590 pub fn config(&self) -> &DetectionConfig {
592 &self.config
593 }
594
595 pub fn set_config(&mut self, config: DetectionConfig) {
597 self.config = config;
598 }
599
600 pub fn records(&self) -> &[SqlQueryRecord] {
602 &self.records
603 }
604}
605
606#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
619 fn test_sql_query_record_new() {
620 let record =
621 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 1000, 0);
622 assert_eq!(record.sql, "SELECT * FROM orders WHERE user_id = 1");
623 assert_eq!(record.template, "SELECT * FROM orders WHERE user_id = ?");
624 assert_eq!(record.table, "orders");
625 assert_eq!(record.timestamp_ms, 1000);
626 assert_eq!(record.query_index, 0);
627 }
628
629 #[test]
630 fn test_sql_query_record_accessors() {
631 let record = SqlQueryRecord::new("SELECT * FROM users WHERE id = 5", "users", 2000, 3);
632 assert_eq!(record.sql(), "SELECT * FROM users WHERE id = 5");
633 assert_eq!(record.template(), "SELECT * FROM users WHERE id = ?");
634 assert_eq!(record.table(), "users");
635 assert_eq!(record.timestamp_ms(), 2000);
636 assert_eq!(record.query_index(), 3);
637 }
638
639 #[test]
640 fn test_sql_query_record_string_param() {
641 let record = SqlQueryRecord::new(
642 "SELECT * FROM users WHERE email = 'abc@x.com'",
643 "users",
644 1000,
645 0,
646 );
647 assert_eq!(record.template, "SELECT * FROM users WHERE email = ?");
648 }
649
650 #[test]
651 fn test_sql_query_record_multiple_params() {
652 let record = SqlQueryRecord::new(
653 "SELECT * FROM users WHERE id = 5 AND email = 'abc' AND age > 18",
654 "users",
655 1000,
656 0,
657 );
658 assert_eq!(
659 record.template,
660 "SELECT * FROM users WHERE id = ? AND email = ? AND age > ?"
661 );
662 }
663
664 #[test]
665 fn test_sql_query_record_in_clause() {
666 let record = SqlQueryRecord::new(
667 "SELECT * FROM orders WHERE user_id IN (1, 2, 3)",
668 "orders",
669 1000,
670 0,
671 );
672 assert_eq!(
673 record.template,
674 "SELECT * FROM orders WHERE user_id IN (?, ?, ?)"
675 );
676 }
677
678 #[test]
679 fn test_sql_query_record_clone_eq() {
680 let record1 = SqlQueryRecord::new("SELECT * FROM users WHERE id = 1", "users", 1000, 0);
681 let record2 = record1.clone();
682 assert_eq!(record1, record2);
683 }
684
685 #[test]
690 fn test_extract_template_numeric_param() {
691 assert_eq!(
692 extract_template("SELECT * FROM orders WHERE user_id = 1"),
693 "SELECT * FROM orders WHERE user_id = ?"
694 );
695 assert_eq!(
696 extract_template("SELECT * FROM orders WHERE user_id = 123"),
697 "SELECT * FROM orders WHERE user_id = ?"
698 );
699 }
700
701 #[test]
702 fn test_extract_template_string_param() {
703 assert_eq!(
704 extract_template("SELECT * FROM users WHERE email = 'abc'"),
705 "SELECT * FROM users WHERE email = ?"
706 );
707 assert_eq!(
708 extract_template("SELECT * FROM users WHERE name = 'John Doe'"),
709 "SELECT * FROM users WHERE name = ?"
710 );
711 }
712
713 #[test]
714 fn test_extract_template_multiple_params() {
715 assert_eq!(
716 extract_template("SELECT * FROM users WHERE id = 1 AND name = 'abc'"),
717 "SELECT * FROM users WHERE id = ? AND name = ?"
718 );
719 }
720
721 #[test]
722 fn test_extract_template_in_clause() {
723 assert_eq!(
724 extract_template("SELECT * FROM orders WHERE user_id IN (1, 2, 3)"),
725 "SELECT * FROM orders WHERE user_id IN (?, ?, ?)"
726 );
727 }
728
729 #[test]
730 fn test_extract_template_no_params() {
731 assert_eq!(
732 extract_template("SELECT * FROM users"),
733 "SELECT * FROM users"
734 );
735 }
736
737 #[test]
738 fn test_extract_template_float_param() {
739 assert_eq!(
740 extract_template("SELECT * FROM products WHERE price = 9.99"),
741 "SELECT * FROM products WHERE price = ?"
742 );
743 }
744
745 #[test]
746 fn test_extract_template_double_quoted_string() {
747 assert_eq!(
748 extract_template("SELECT * FROM users WHERE name = \"abc\""),
749 "SELECT * FROM users WHERE name = ?"
750 );
751 }
752
753 #[test]
754 fn test_extract_template_empty_string() {
755 assert_eq!(extract_template(""), "");
756 }
757
758 #[test]
763 fn test_detection_config_default() {
764 let config = DetectionConfig::default();
765 assert_eq!(config.threshold, 5);
766 assert_eq!(config.time_window_ms, 1000);
767 }
768
769 #[test]
770 fn test_detection_config_new() {
771 let config = DetectionConfig::new(10, 5000);
772 assert_eq!(config.threshold, 10);
773 assert_eq!(config.time_window_ms, 5000);
774 }
775
776 #[test]
777 fn test_detection_config_accessors() {
778 let config = DetectionConfig::new(8, 2000);
779 assert_eq!(config.threshold(), 8);
780 assert_eq!(config.time_window_ms(), 2000);
781 }
782
783 #[test]
784 fn test_detection_config_clone_eq() {
785 let config1 = DetectionConfig::new(5, 1000);
786 let config2 = config1.clone();
787 assert_eq!(config1, config2);
788 }
789
790 #[test]
795 fn test_n_plus_one_alert_new() {
796 let alert = NPlusOneAlert::new("SELECT * FROM orders WHERE user_id = ?", "orders", 10, 500);
797 assert_eq!(alert.template, "SELECT * FROM orders WHERE user_id = ?");
798 assert_eq!(alert.table, "orders");
799 assert_eq!(alert.query_count, 10);
800 assert_eq!(alert.time_span_ms, 500);
801 assert!(alert.suggestion.contains("with"));
802 assert!(alert.suggestion.contains("orders"));
803 assert!(alert.suggestion.contains("10"));
804 }
805
806 #[test]
807 fn test_n_plus_one_alert_accessors() {
808 let alert = NPlusOneAlert::new("SELECT * FROM users WHERE id = ?", "users", 8, 300);
809 assert_eq!(alert.template(), "SELECT * FROM users WHERE id = ?");
810 assert_eq!(alert.table(), "users");
811 assert_eq!(alert.query_count(), 8);
812 assert_eq!(alert.time_span_ms(), 300);
813 assert!(alert.suggestion().contains("with"));
814 }
815
816 #[test]
817 fn test_n_plus_one_alert_clone_eq() {
818 let alert1 = NPlusOneAlert::new("SELECT * FROM users WHERE id = ?", "users", 5, 100);
819 let alert2 = alert1.clone();
820 assert_eq!(alert1, alert2);
821 }
822
823 #[test]
828 fn test_suggest_with_usage_basic() {
829 let suggestion = suggest_with_usage("orders", 10);
830 assert!(suggestion.contains("with"));
831 assert!(suggestion.contains("orders"));
832 assert!(suggestion.contains("10"));
833 }
834
835 #[test]
836 fn test_suggest_with_usage_different_table() {
837 let suggestion = suggest_with_usage("users", 5);
838 assert!(suggestion.contains("users"));
839 assert!(suggestion.contains("5"));
840 }
841
842 #[test]
843 fn test_suggest_with_usage_count_zero() {
844 let suggestion = suggest_with_usage("orders", 0);
845 assert!(suggestion.contains("0"));
846 }
847
848 #[test]
849 fn test_suggest_with_usage_large_count() {
850 let suggestion = suggest_with_usage("orders", 1000);
851 assert!(suggestion.contains("1000"));
852 }
853
854 #[test]
859 fn test_detect_n_plus_one_no_alerts_under_threshold() {
860 let records = vec![
862 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
863 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
864 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
865 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
866 ];
867 let config = DetectionConfig::new(5, 1000);
868 let alerts = detect_n_plus_one(&records, &config);
869 assert!(alerts.is_empty());
870 }
871
872 #[test]
873 fn test_detect_n_plus_one_alert_at_threshold() {
874 let records = vec![
876 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
877 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
878 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
879 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
880 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
881 ];
882 let config = DetectionConfig::new(5, 1000);
883 let alerts = detect_n_plus_one(&records, &config);
884 assert_eq!(alerts.len(), 1);
885 assert_eq!(alerts[0].query_count, 5);
886 assert_eq!(alerts[0].table, "orders");
887 }
888
889 #[test]
890 fn test_detect_n_plus_one_alert_over_threshold() {
891 let records = vec![
893 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
894 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
895 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
896 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
897 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
898 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 600, 5),
899 ];
900 let config = DetectionConfig::new(5, 1000);
901 let alerts = detect_n_plus_one(&records, &config);
902 assert_eq!(alerts.len(), 1);
903 assert_eq!(alerts[0].query_count, 6);
904 }
905
906 #[test]
907 fn test_detect_n_plus_one_multiple_templates() {
908 let records = vec![
910 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
912 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
913 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
914 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
915 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
916 SqlQueryRecord::new(
918 "SELECT * FROM profiles WHERE user_id = 1",
919 "profiles",
920 600,
921 5,
922 ),
923 SqlQueryRecord::new(
924 "SELECT * FROM profiles WHERE user_id = 2",
925 "profiles",
926 700,
927 6,
928 ),
929 SqlQueryRecord::new(
930 "SELECT * FROM profiles WHERE user_id = 3",
931 "profiles",
932 800,
933 7,
934 ),
935 SqlQueryRecord::new(
936 "SELECT * FROM profiles WHERE user_id = 4",
937 "profiles",
938 900,
939 8,
940 ),
941 SqlQueryRecord::new(
942 "SELECT * FROM profiles WHERE user_id = 5",
943 "profiles",
944 1000,
945 9,
946 ),
947 ];
948 let config = DetectionConfig::new(5, 2000);
949 let alerts = detect_n_plus_one(&records, &config);
950 assert_eq!(alerts.len(), 2);
951 let tables: Vec<&str> = alerts.iter().map(|a| a.table.as_str()).collect();
953 assert!(tables.contains(&"orders"));
954 assert!(tables.contains(&"profiles"));
955 }
956
957 #[test]
958 fn test_detect_n_plus_one_outside_time_window() {
959 let records = vec![
961 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 0, 0),
962 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 500, 1),
963 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 1000, 2),
964 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 1500, 3),
965 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 2000, 4),
966 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 2500, 5),
967 ];
968 let config = DetectionConfig::new(5, 100);
970 let alerts = detect_n_plus_one(&records, &config);
971 assert!(alerts.is_empty());
972 }
973
974 #[test]
975 fn test_detect_n_plus_one_empty_records() {
976 let records: Vec<SqlQueryRecord> = vec![];
977 let config = DetectionConfig::default();
978 let alerts = detect_n_plus_one(&records, &config);
979 assert!(alerts.is_empty());
980 }
981
982 #[test]
983 fn test_detect_n_plus_one_different_tables_same_template() {
984 let records = vec![
986 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
987 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
988 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
989 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
990 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
991 ];
992 let config = DetectionConfig::new(5, 1000);
993 let alerts = detect_n_plus_one(&records, &config);
994 assert_eq!(alerts.len(), 1);
995 assert_eq!(alerts[0].table, "orders");
996 }
997
998 #[test]
999 fn test_detect_n_plus_one_sorted_by_count_desc() {
1000 let records = vec![
1002 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
1003 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
1004 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
1005 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
1006 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
1007 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 600, 5),
1008 SqlQueryRecord::new(
1009 "SELECT * FROM profiles WHERE user_id = 1",
1010 "profiles",
1011 700,
1012 6,
1013 ),
1014 SqlQueryRecord::new(
1015 "SELECT * FROM profiles WHERE user_id = 2",
1016 "profiles",
1017 800,
1018 7,
1019 ),
1020 SqlQueryRecord::new(
1021 "SELECT * FROM profiles WHERE user_id = 3",
1022 "profiles",
1023 900,
1024 8,
1025 ),
1026 SqlQueryRecord::new(
1027 "SELECT * FROM profiles WHERE user_id = 4",
1028 "profiles",
1029 1000,
1030 9,
1031 ),
1032 SqlQueryRecord::new(
1033 "SELECT * FROM profiles WHERE user_id = 5",
1034 "profiles",
1035 1100,
1036 10,
1037 ),
1038 ];
1039 let config = DetectionConfig::new(5, 2000);
1040 let alerts = detect_n_plus_one(&records, &config);
1041 assert_eq!(alerts.len(), 2);
1042 assert_eq!(alerts[0].query_count, 6); assert_eq!(alerts[1].query_count, 5); }
1045
1046 #[test]
1051 fn test_detector_default() {
1052 let detector = NPlusOneDetector::default();
1053 assert_eq!(detector.record_count(), 0);
1054 assert_eq!(detector.config().threshold, 5);
1055 assert_eq!(detector.config().time_window_ms, 1000);
1056 }
1057
1058 #[test]
1059 fn test_detector_new_with_config() {
1060 let config = DetectionConfig::new(10, 5000);
1061 let detector = NPlusOneDetector::new(config);
1062 assert_eq!(detector.config().threshold, 10);
1063 assert_eq!(detector.config().time_window_ms, 5000);
1064 }
1065
1066 #[test]
1067 fn test_detector_record_auto_index() {
1068 let mut detector = NPlusOneDetector::default();
1069 detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
1070 detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
1071 assert_eq!(detector.record_count(), 2);
1072 assert_eq!(detector.records()[0].query_index, 0);
1073 assert_eq!(detector.records()[1].query_index, 1);
1074 }
1075
1076 #[test]
1077 fn test_detector_record_with_explicit_index() {
1078 let mut detector = NPlusOneDetector::default();
1079 detector.record_with_index("SELECT * FROM users WHERE id = 1", "users", 100, 5);
1080 assert_eq!(detector.records()[0].query_index, 5);
1081 detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
1083 assert_eq!(detector.records()[1].query_index, 6);
1084 }
1085
1086 #[test]
1087 fn test_detector_detect_no_alerts() {
1088 let mut detector = NPlusOneDetector::default();
1089 detector.record("SELECT * FROM orders WHERE user_id = 1", "orders", 100);
1090 detector.record("SELECT * FROM orders WHERE user_id = 2", "orders", 200);
1091 let alerts = detector.detect();
1092 assert!(alerts.is_empty());
1093 }
1094
1095 #[test]
1096 fn test_detector_detect_with_alerts() {
1097 let mut detector = NPlusOneDetector::default();
1098 for i in 1..=6 {
1099 detector.record(
1100 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1101 "orders",
1102 i * 100,
1103 );
1104 }
1105 let alerts = detector.detect();
1106 assert_eq!(alerts.len(), 1);
1107 assert_eq!(alerts[0].query_count, 6);
1108 assert_eq!(alerts[0].table, "orders");
1109 }
1110
1111 #[test]
1112 fn test_detector_clear() {
1113 let mut detector = NPlusOneDetector::default();
1114 detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
1115 assert_eq!(detector.record_count(), 1);
1116 detector.clear();
1117 assert_eq!(detector.record_count(), 0);
1118 detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
1120 assert_eq!(detector.records()[0].query_index, 0);
1121 }
1122
1123 #[test]
1124 fn test_detector_set_config() {
1125 let mut detector = NPlusOneDetector::default();
1126 assert_eq!(detector.config().threshold, 5);
1127 detector.set_config(DetectionConfig::new(20, 10000));
1128 assert_eq!(detector.config().threshold, 20);
1129 assert_eq!(detector.config().time_window_ms, 10000);
1130 }
1131
1132 #[test]
1133 fn test_detector_records_accessor() {
1134 let mut detector = NPlusOneDetector::default();
1135 detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
1136 detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
1137 let records = detector.records();
1138 assert_eq!(records.len(), 2);
1139 assert_eq!(records[0].table, "users");
1140 assert_eq!(records[1].table, "users");
1141 }
1142
1143 #[test]
1148 fn test_r5_php_n_plus_one_pattern_detection() {
1149 let mut records = vec![SqlQueryRecord::new("SELECT * FROM users", "users", 0, 0)];
1156 for i in 1..=6 {
1157 records.push(SqlQueryRecord::new(
1158 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1159 "orders",
1160 i * 100,
1161 i,
1162 ));
1163 }
1164 let config = DetectionConfig::new(5, 1000);
1165 let alerts = detect_n_plus_one(&records, &config);
1166 assert_eq!(alerts.len(), 1);
1168 assert_eq!(alerts[0].table, "orders");
1169 assert_eq!(alerts[0].query_count, 6);
1170 }
1171
1172 #[test]
1173 fn test_r5_php_with_avoids_n_plus_one() {
1174 let records = vec![
1179 SqlQueryRecord::new("SELECT * FROM users", "users", 0, 0),
1180 SqlQueryRecord::new(
1181 "SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5, 6)",
1182 "orders",
1183 100,
1184 1,
1185 ),
1186 ];
1187 let config = DetectionConfig::new(5, 1000);
1188 let alerts = detect_n_plus_one(&records, &config);
1189 assert!(alerts.is_empty());
1191 }
1192
1193 #[test]
1194 fn test_r5_php_eagerly_result_set_in_query_template() {
1195 let sql = "SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5)";
1199 let template = extract_template(sql);
1200 assert_eq!(
1201 template,
1202 "SELECT * FROM orders WHERE user_id IN (?, ?, ?, ?, ?)"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_r5_php_single_query_no_n_plus_one() {
1208 let records = vec![SqlQueryRecord::new(
1210 "SELECT * FROM orders WHERE user_id = 1",
1211 "orders",
1212 100,
1213 0,
1214 )];
1215 let config = DetectionConfig::default();
1216 let alerts = detect_n_plus_one(&records, &config);
1217 assert!(alerts.is_empty());
1218 }
1219
1220 #[test]
1221 fn test_r5_php_belongs_to_n_plus_one_detection() {
1222 let mut records = vec![SqlQueryRecord::new("SELECT * FROM orders", "orders", 0, 0)];
1229 for i in 1..=6 {
1230 records.push(SqlQueryRecord::new(
1231 &format!("SELECT * FROM users WHERE id = {}", i),
1232 "users",
1233 i * 100,
1234 i,
1235 ));
1236 }
1237 let config = DetectionConfig::new(5, 1000);
1238 let alerts = detect_n_plus_one(&records, &config);
1239 assert_eq!(alerts.len(), 1);
1240 assert_eq!(alerts[0].table, "users");
1241 assert_eq!(alerts[0].query_count, 6);
1242 }
1243
1244 #[test]
1245 fn test_r5_php_morph_to_n_plus_one_detection() {
1246 let mut records = vec![SqlQueryRecord::new(
1253 "SELECT * FROM comments",
1254 "comments",
1255 0,
1256 0,
1257 )];
1258 for i in 1..=3 {
1262 records.push(SqlQueryRecord::new(
1263 &format!("SELECT * FROM posts WHERE id = {}", i),
1264 "posts",
1265 i * 100,
1266 i,
1267 ));
1268 }
1269 for i in 1..=3 {
1270 records.push(SqlQueryRecord::new(
1271 &format!("SELECT * FROM videos WHERE id = {}", i),
1272 "videos",
1273 (i + 3) * 100,
1274 i + 3,
1275 ));
1276 }
1277 let config = DetectionConfig::new(3, 1000);
1278 let alerts = detect_n_plus_one(&records, &config);
1279 assert_eq!(alerts.len(), 2);
1281 let tables: Vec<&str> = alerts.iter().map(|a| a.table.as_str()).collect();
1282 assert!(tables.contains(&"posts"));
1283 assert!(tables.contains(&"videos"));
1284 }
1285
1286 #[test]
1287 fn test_r5_php_suggest_with_usage_format() {
1288 let suggestion = suggest_with_usage("orders", 10);
1290 assert!(suggestion.contains("with("));
1291 assert!(suggestion.contains("orders"));
1292 assert!(suggestion.contains("10"));
1293 assert!(suggestion.contains("batch preloading"));
1294 }
1295
1296 #[test]
1297 fn test_r5_php_threshold_default_5() {
1298 let config = DetectionConfig::default();
1302 assert_eq!(config.threshold, 5);
1303 }
1304
1305 #[test]
1306 fn test_r5_php_time_window_default_1000ms() {
1307 let config = DetectionConfig::default();
1309 assert_eq!(config.time_window_ms, 1000);
1310 }
1311
1312 #[test]
1313 fn test_r5_php_different_query_no_n_plus_one() {
1314 let records = vec![
1316 SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
1317 SqlQueryRecord::new(
1318 "SELECT * FROM orders WHERE user_id = 2 AND status = 1",
1319 "orders",
1320 200,
1321 1,
1322 ),
1323 SqlQueryRecord::new(
1324 "SELECT * FROM orders WHERE user_id = 3 AND status = 2",
1325 "orders",
1326 300,
1327 2,
1328 ),
1329 ];
1330 let config = DetectionConfig::default();
1331 let alerts = detect_n_plus_one(&records, &config);
1332 assert!(alerts.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_r5_php_detector_integration() {
1338 let mut detector = NPlusOneDetector::default();
1340 detector.record("SELECT * FROM users", "users", 0);
1342 for i in 1..=6 {
1343 detector.record(
1344 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1345 "orders",
1346 i * 50,
1347 );
1348 }
1349 let alerts = detector.detect();
1350 assert_eq!(alerts.len(), 1);
1351 assert_eq!(alerts[0].table, "orders");
1352 assert_eq!(alerts[0].query_count, 6);
1353 assert!(alerts[0].suggestion.contains("with"));
1354 }
1355
1356 #[test]
1361 fn test_integration_detector_with_config_change() {
1362 let mut detector = NPlusOneDetector::new(DetectionConfig::new(10, 1000));
1364 for i in 1..=6 {
1365 detector.record(
1366 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1367 "orders",
1368 i * 100,
1369 );
1370 }
1371 assert!(detector.detect().is_empty());
1373 detector.set_config(DetectionConfig::new(5, 1000));
1375 let alerts = detector.detect();
1376 assert_eq!(alerts.len(), 1);
1377 }
1378
1379 #[test]
1380 fn test_integration_multiple_rounds() {
1381 let mut detector = NPlusOneDetector::default();
1383 for i in 1..=6 {
1385 detector.record(
1386 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1387 "orders",
1388 i * 100,
1389 );
1390 }
1391 assert_eq!(detector.detect().len(), 1);
1392 detector.clear();
1394 assert_eq!(detector.record_count(), 0);
1395 detector.record("SELECT * FROM users", "users", 0);
1397 detector.record(
1398 "SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5, 6)",
1399 "orders",
1400 100,
1401 );
1402 assert!(detector.detect().is_empty());
1403 }
1404
1405 #[test]
1406 fn test_integration_complex_scenario() {
1407 let mut detector = NPlusOneDetector::default();
1409 detector.record("SELECT * FROM users WHERE status = 1", "users", 0);
1411 for i in 1..=5 {
1413 detector.record(
1414 &format!("SELECT * FROM orders WHERE user_id = {}", i),
1415 "orders",
1416 i * 100,
1417 );
1418 }
1419 detector.record("SELECT * FROM profiles WHERE user_id = 1", "profiles", 600);
1421 for i in 1..=7 {
1423 detector.record(
1424 &format!("SELECT * FROM comments WHERE post_id = {}", i),
1425 "comments",
1426 700 + i * 50,
1427 );
1428 }
1429 let alerts = detector.detect();
1430 assert_eq!(alerts.len(), 2);
1431 assert_eq!(alerts[0].table, "comments");
1433 assert_eq!(alerts[0].query_count, 7);
1434 assert_eq!(alerts[1].table, "orders");
1435 assert_eq!(alerts[1].query_count, 5);
1436 }
1437}