use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqlQueryRecord {
pub sql: String,
pub template: String,
pub table: String,
pub timestamp_ms: u64,
pub query_index: u64,
}
impl SqlQueryRecord {
pub fn new(sql: &str, table: &str, timestamp_ms: u64, query_index: u64) -> Self {
Self {
sql: sql.to_string(),
template: extract_template(sql),
table: table.to_string(),
timestamp_ms,
query_index,
}
}
pub fn sql(&self) -> &str {
&self.sql
}
pub fn template(&self) -> &str {
&self.template
}
pub fn table(&self) -> &str {
&self.table
}
pub fn timestamp_ms(&self) -> u64 {
self.timestamp_ms
}
pub fn query_index(&self) -> u64 {
self.query_index
}
}
pub fn extract_template(sql: &str) -> String {
let chars: Vec<char> = sql.chars().collect();
let mut result = String::with_capacity(sql.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\'' {
result.push('?');
i += 1;
while i < chars.len() && chars[i] != '\'' {
i += 1;
}
if i < chars.len() {
i += 1;
}
} else if c == '"' {
result.push('?');
i += 1;
while i < chars.len() && chars[i] != '"' {
i += 1;
}
if i < chars.len() {
i += 1;
}
} else if c.is_ascii_digit() {
result.push('?');
i += 1;
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
} else {
result.push(c);
i += 1;
}
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectionConfig {
pub threshold: usize,
pub time_window_ms: u64,
}
impl Default for DetectionConfig {
fn default() -> Self {
Self {
threshold: 5,
time_window_ms: 1000,
}
}
}
impl DetectionConfig {
pub fn new(threshold: usize, time_window_ms: u64) -> Self {
Self {
threshold,
time_window_ms,
}
}
pub fn threshold(&self) -> usize {
self.threshold
}
pub fn time_window_ms(&self) -> u64 {
self.time_window_ms
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NPlusOneAlert {
pub template: String,
pub table: String,
pub query_count: usize,
pub time_span_ms: u64,
pub suggestion: String,
}
impl NPlusOneAlert {
pub fn new(template: &str, table: &str, query_count: usize, time_span_ms: u64) -> Self {
Self {
template: template.to_string(),
table: table.to_string(),
query_count,
time_span_ms,
suggestion: suggest_with_usage(table, query_count),
}
}
pub fn template(&self) -> &str {
&self.template
}
pub fn table(&self) -> &str {
&self.table
}
pub fn query_count(&self) -> usize {
self.query_count
}
pub fn time_span_ms(&self) -> u64 {
self.time_span_ms
}
pub fn suggestion(&self) -> &str {
&self.suggestion
}
}
pub fn suggest_with_usage(table: &str, count: usize) -> String {
format!(
"Detected N+1 problem: {} queries on table '{}' with same template. \
Consider using `with('{}')` for batch preloading to reduce {} queries to 1.",
count, table, table, count
)
}
pub fn detect_n_plus_one(
records: &[SqlQueryRecord],
config: &DetectionConfig,
) -> Vec<NPlusOneAlert> {
let mut groups: HashMap<String, Vec<&SqlQueryRecord>> = HashMap::new();
for record in records {
groups
.entry(record.template.clone())
.or_default()
.push(record);
}
let mut alerts: Vec<NPlusOneAlert> = Vec::new();
for group_records in groups.values() {
let mut sorted_records: Vec<&&SqlQueryRecord> = group_records.iter().collect();
sorted_records.sort_by_key(|r| r.timestamp_ms);
if sorted_records.len() < config.threshold {
continue;
}
let window = config.time_window_ms;
let threshold = config.threshold;
let mut start = 0;
while start < sorted_records.len() {
let start_time = sorted_records[start].timestamp_ms;
let mut end = start;
while end < sorted_records.len()
&& sorted_records[end].timestamp_ms <= start_time + window
{
end += 1;
}
let count = end - start;
if count >= threshold {
let template = sorted_records[start].template.clone();
let table = sorted_records[start].table.clone();
let time_span = if end > 0 {
sorted_records[end - 1]
.timestamp_ms
.saturating_sub(start_time)
} else {
0
};
let total_count = group_records.len();
alerts.push(NPlusOneAlert::new(
&template,
&table,
total_count,
time_span,
));
break; }
start += 1;
}
}
alerts.sort_by_key(|a| std::cmp::Reverse(a.query_count));
alerts
}
#[derive(Debug, Clone, Default)]
pub struct NPlusOneDetector {
records: Vec<SqlQueryRecord>,
config: DetectionConfig,
next_query_index: u64,
}
impl NPlusOneDetector {
pub fn new(config: DetectionConfig) -> Self {
Self {
records: Vec::new(),
config,
next_query_index: 0,
}
}
pub fn record(&mut self, sql: &str, table: &str, timestamp_ms: u64) {
let record = SqlQueryRecord::new(sql, table, timestamp_ms, self.next_query_index);
self.next_query_index += 1;
self.records.push(record);
}
pub fn record_with_index(
&mut self,
sql: &str,
table: &str,
timestamp_ms: u64,
query_index: u64,
) {
let record = SqlQueryRecord::new(sql, table, timestamp_ms, query_index);
self.records.push(record);
if query_index >= self.next_query_index {
self.next_query_index = query_index + 1;
}
}
pub fn detect(&self) -> Vec<NPlusOneAlert> {
detect_n_plus_one(&self.records, &self.config)
}
pub fn clear(&mut self) {
self.records.clear();
self.next_query_index = 0;
}
pub fn record_count(&self) -> usize {
self.records.len()
}
pub fn config(&self) -> &DetectionConfig {
&self.config
}
pub fn set_config(&mut self, config: DetectionConfig) {
self.config = config;
}
pub fn records(&self) -> &[SqlQueryRecord] {
&self.records
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sql_query_record_new() {
let record =
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 1000, 0);
assert_eq!(record.sql, "SELECT * FROM orders WHERE user_id = 1");
assert_eq!(record.template, "SELECT * FROM orders WHERE user_id = ?");
assert_eq!(record.table, "orders");
assert_eq!(record.timestamp_ms, 1000);
assert_eq!(record.query_index, 0);
}
#[test]
fn test_sql_query_record_accessors() {
let record = SqlQueryRecord::new("SELECT * FROM users WHERE id = 5", "users", 2000, 3);
assert_eq!(record.sql(), "SELECT * FROM users WHERE id = 5");
assert_eq!(record.template(), "SELECT * FROM users WHERE id = ?");
assert_eq!(record.table(), "users");
assert_eq!(record.timestamp_ms(), 2000);
assert_eq!(record.query_index(), 3);
}
#[test]
fn test_sql_query_record_string_param() {
let record = SqlQueryRecord::new(
"SELECT * FROM users WHERE email = 'abc@x.com'",
"users",
1000,
0,
);
assert_eq!(record.template, "SELECT * FROM users WHERE email = ?");
}
#[test]
fn test_sql_query_record_multiple_params() {
let record = SqlQueryRecord::new(
"SELECT * FROM users WHERE id = 5 AND email = 'abc' AND age > 18",
"users",
1000,
0,
);
assert_eq!(
record.template,
"SELECT * FROM users WHERE id = ? AND email = ? AND age > ?"
);
}
#[test]
fn test_sql_query_record_in_clause() {
let record = SqlQueryRecord::new(
"SELECT * FROM orders WHERE user_id IN (1, 2, 3)",
"orders",
1000,
0,
);
assert_eq!(
record.template,
"SELECT * FROM orders WHERE user_id IN (?, ?, ?)"
);
}
#[test]
fn test_sql_query_record_clone_eq() {
let record1 = SqlQueryRecord::new("SELECT * FROM users WHERE id = 1", "users", 1000, 0);
let record2 = record1.clone();
assert_eq!(record1, record2);
}
#[test]
fn test_extract_template_numeric_param() {
assert_eq!(
extract_template("SELECT * FROM orders WHERE user_id = 1"),
"SELECT * FROM orders WHERE user_id = ?"
);
assert_eq!(
extract_template("SELECT * FROM orders WHERE user_id = 123"),
"SELECT * FROM orders WHERE user_id = ?"
);
}
#[test]
fn test_extract_template_string_param() {
assert_eq!(
extract_template("SELECT * FROM users WHERE email = 'abc'"),
"SELECT * FROM users WHERE email = ?"
);
assert_eq!(
extract_template("SELECT * FROM users WHERE name = 'John Doe'"),
"SELECT * FROM users WHERE name = ?"
);
}
#[test]
fn test_extract_template_multiple_params() {
assert_eq!(
extract_template("SELECT * FROM users WHERE id = 1 AND name = 'abc'"),
"SELECT * FROM users WHERE id = ? AND name = ?"
);
}
#[test]
fn test_extract_template_in_clause() {
assert_eq!(
extract_template("SELECT * FROM orders WHERE user_id IN (1, 2, 3)"),
"SELECT * FROM orders WHERE user_id IN (?, ?, ?)"
);
}
#[test]
fn test_extract_template_no_params() {
assert_eq!(
extract_template("SELECT * FROM users"),
"SELECT * FROM users"
);
}
#[test]
fn test_extract_template_float_param() {
assert_eq!(
extract_template("SELECT * FROM products WHERE price = 9.99"),
"SELECT * FROM products WHERE price = ?"
);
}
#[test]
fn test_extract_template_double_quoted_string() {
assert_eq!(
extract_template("SELECT * FROM users WHERE name = \"abc\""),
"SELECT * FROM users WHERE name = ?"
);
}
#[test]
fn test_extract_template_empty_string() {
assert_eq!(extract_template(""), "");
}
#[test]
fn test_detection_config_default() {
let config = DetectionConfig::default();
assert_eq!(config.threshold, 5);
assert_eq!(config.time_window_ms, 1000);
}
#[test]
fn test_detection_config_new() {
let config = DetectionConfig::new(10, 5000);
assert_eq!(config.threshold, 10);
assert_eq!(config.time_window_ms, 5000);
}
#[test]
fn test_detection_config_accessors() {
let config = DetectionConfig::new(8, 2000);
assert_eq!(config.threshold(), 8);
assert_eq!(config.time_window_ms(), 2000);
}
#[test]
fn test_detection_config_clone_eq() {
let config1 = DetectionConfig::new(5, 1000);
let config2 = config1.clone();
assert_eq!(config1, config2);
}
#[test]
fn test_n_plus_one_alert_new() {
let alert = NPlusOneAlert::new("SELECT * FROM orders WHERE user_id = ?", "orders", 10, 500);
assert_eq!(alert.template, "SELECT * FROM orders WHERE user_id = ?");
assert_eq!(alert.table, "orders");
assert_eq!(alert.query_count, 10);
assert_eq!(alert.time_span_ms, 500);
assert!(alert.suggestion.contains("with"));
assert!(alert.suggestion.contains("orders"));
assert!(alert.suggestion.contains("10"));
}
#[test]
fn test_n_plus_one_alert_accessors() {
let alert = NPlusOneAlert::new("SELECT * FROM users WHERE id = ?", "users", 8, 300);
assert_eq!(alert.template(), "SELECT * FROM users WHERE id = ?");
assert_eq!(alert.table(), "users");
assert_eq!(alert.query_count(), 8);
assert_eq!(alert.time_span_ms(), 300);
assert!(alert.suggestion().contains("with"));
}
#[test]
fn test_n_plus_one_alert_clone_eq() {
let alert1 = NPlusOneAlert::new("SELECT * FROM users WHERE id = ?", "users", 5, 100);
let alert2 = alert1.clone();
assert_eq!(alert1, alert2);
}
#[test]
fn test_suggest_with_usage_basic() {
let suggestion = suggest_with_usage("orders", 10);
assert!(suggestion.contains("with"));
assert!(suggestion.contains("orders"));
assert!(suggestion.contains("10"));
}
#[test]
fn test_suggest_with_usage_different_table() {
let suggestion = suggest_with_usage("users", 5);
assert!(suggestion.contains("users"));
assert!(suggestion.contains("5"));
}
#[test]
fn test_suggest_with_usage_count_zero() {
let suggestion = suggest_with_usage("orders", 0);
assert!(suggestion.contains("0"));
}
#[test]
fn test_suggest_with_usage_large_count() {
let suggestion = suggest_with_usage("orders", 1000);
assert!(suggestion.contains("1000"));
}
#[test]
fn test_detect_n_plus_one_no_alerts_under_threshold() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
];
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_detect_n_plus_one_alert_at_threshold() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
];
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].query_count, 5);
assert_eq!(alerts[0].table, "orders");
}
#[test]
fn test_detect_n_plus_one_alert_over_threshold() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 600, 5),
];
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].query_count, 6);
}
#[test]
fn test_detect_n_plus_one_multiple_templates() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 1",
"profiles",
600,
5,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 2",
"profiles",
700,
6,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 3",
"profiles",
800,
7,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 4",
"profiles",
900,
8,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 5",
"profiles",
1000,
9,
),
];
let config = DetectionConfig::new(5, 2000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 2);
let tables: Vec<&str> = alerts.iter().map(|a| a.table.as_str()).collect();
assert!(tables.contains(&"orders"));
assert!(tables.contains(&"profiles"));
}
#[test]
fn test_detect_n_plus_one_outside_time_window() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 0, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 500, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 1000, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 1500, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 2000, 4),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 2500, 5),
];
let config = DetectionConfig::new(5, 100);
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_detect_n_plus_one_empty_records() {
let records: Vec<SqlQueryRecord> = vec![];
let config = DetectionConfig::default();
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_detect_n_plus_one_different_tables_same_template() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
];
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].table, "orders");
}
#[test]
fn test_detect_n_plus_one_sorted_by_count_desc() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 2", "orders", 200, 1),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 3", "orders", 300, 2),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 4", "orders", 400, 3),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 5", "orders", 500, 4),
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 6", "orders", 600, 5),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 1",
"profiles",
700,
6,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 2",
"profiles",
800,
7,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 3",
"profiles",
900,
8,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 4",
"profiles",
1000,
9,
),
SqlQueryRecord::new(
"SELECT * FROM profiles WHERE user_id = 5",
"profiles",
1100,
10,
),
];
let config = DetectionConfig::new(5, 2000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 2);
assert_eq!(alerts[0].query_count, 6); assert_eq!(alerts[1].query_count, 5); }
#[test]
fn test_detector_default() {
let detector = NPlusOneDetector::default();
assert_eq!(detector.record_count(), 0);
assert_eq!(detector.config().threshold, 5);
assert_eq!(detector.config().time_window_ms, 1000);
}
#[test]
fn test_detector_new_with_config() {
let config = DetectionConfig::new(10, 5000);
let detector = NPlusOneDetector::new(config);
assert_eq!(detector.config().threshold, 10);
assert_eq!(detector.config().time_window_ms, 5000);
}
#[test]
fn test_detector_record_auto_index() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
assert_eq!(detector.record_count(), 2);
assert_eq!(detector.records()[0].query_index, 0);
assert_eq!(detector.records()[1].query_index, 1);
}
#[test]
fn test_detector_record_with_explicit_index() {
let mut detector = NPlusOneDetector::default();
detector.record_with_index("SELECT * FROM users WHERE id = 1", "users", 100, 5);
assert_eq!(detector.records()[0].query_index, 5);
detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
assert_eq!(detector.records()[1].query_index, 6);
}
#[test]
fn test_detector_detect_no_alerts() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM orders WHERE user_id = 1", "orders", 100);
detector.record("SELECT * FROM orders WHERE user_id = 2", "orders", 200);
let alerts = detector.detect();
assert!(alerts.is_empty());
}
#[test]
fn test_detector_detect_with_alerts() {
let mut detector = NPlusOneDetector::default();
for i in 1..=6 {
detector.record(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 100,
);
}
let alerts = detector.detect();
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].query_count, 6);
assert_eq!(alerts[0].table, "orders");
}
#[test]
fn test_detector_clear() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
assert_eq!(detector.record_count(), 1);
detector.clear();
assert_eq!(detector.record_count(), 0);
detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
assert_eq!(detector.records()[0].query_index, 0);
}
#[test]
fn test_detector_set_config() {
let mut detector = NPlusOneDetector::default();
assert_eq!(detector.config().threshold, 5);
detector.set_config(DetectionConfig::new(20, 10000));
assert_eq!(detector.config().threshold, 20);
assert_eq!(detector.config().time_window_ms, 10000);
}
#[test]
fn test_detector_records_accessor() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM users WHERE id = 1", "users", 100);
detector.record("SELECT * FROM users WHERE id = 2", "users", 200);
let records = detector.records();
assert_eq!(records.len(), 2);
assert_eq!(records[0].table, "users");
assert_eq!(records[1].table, "users");
}
#[test]
fn test_r5_php_n_plus_one_pattern_detection() {
let mut records = vec![SqlQueryRecord::new("SELECT * FROM users", "users", 0, 0)];
for i in 1..=6 {
records.push(SqlQueryRecord::new(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 100,
i,
));
}
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].table, "orders");
assert_eq!(alerts[0].query_count, 6);
}
#[test]
fn test_r5_php_with_avoids_n_plus_one() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM users", "users", 0, 0),
SqlQueryRecord::new(
"SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5, 6)",
"orders",
100,
1,
),
];
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_r5_php_eagerly_result_set_in_query_template() {
let sql = "SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5)";
let template = extract_template(sql);
assert_eq!(
template,
"SELECT * FROM orders WHERE user_id IN (?, ?, ?, ?, ?)"
);
}
#[test]
fn test_r5_php_single_query_no_n_plus_one() {
let records = vec![SqlQueryRecord::new(
"SELECT * FROM orders WHERE user_id = 1",
"orders",
100,
0,
)];
let config = DetectionConfig::default();
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_r5_php_belongs_to_n_plus_one_detection() {
let mut records = vec![SqlQueryRecord::new("SELECT * FROM orders", "orders", 0, 0)];
for i in 1..=6 {
records.push(SqlQueryRecord::new(
&format!("SELECT * FROM users WHERE id = {}", i),
"users",
i * 100,
i,
));
}
let config = DetectionConfig::new(5, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].table, "users");
assert_eq!(alerts[0].query_count, 6);
}
#[test]
fn test_r5_php_morph_to_n_plus_one_detection() {
let mut records = vec![SqlQueryRecord::new(
"SELECT * FROM comments",
"comments",
0,
0,
)];
for i in 1..=3 {
records.push(SqlQueryRecord::new(
&format!("SELECT * FROM posts WHERE id = {}", i),
"posts",
i * 100,
i,
));
}
for i in 1..=3 {
records.push(SqlQueryRecord::new(
&format!("SELECT * FROM videos WHERE id = {}", i),
"videos",
(i + 3) * 100,
i + 3,
));
}
let config = DetectionConfig::new(3, 1000);
let alerts = detect_n_plus_one(&records, &config);
assert_eq!(alerts.len(), 2);
let tables: Vec<&str> = alerts.iter().map(|a| a.table.as_str()).collect();
assert!(tables.contains(&"posts"));
assert!(tables.contains(&"videos"));
}
#[test]
fn test_r5_php_suggest_with_usage_format() {
let suggestion = suggest_with_usage("orders", 10);
assert!(suggestion.contains("with("));
assert!(suggestion.contains("orders"));
assert!(suggestion.contains("10"));
assert!(suggestion.contains("batch preloading"));
}
#[test]
fn test_r5_php_threshold_default_5() {
let config = DetectionConfig::default();
assert_eq!(config.threshold, 5);
}
#[test]
fn test_r5_php_time_window_default_1000ms() {
let config = DetectionConfig::default();
assert_eq!(config.time_window_ms, 1000);
}
#[test]
fn test_r5_php_different_query_no_n_plus_one() {
let records = vec![
SqlQueryRecord::new("SELECT * FROM orders WHERE user_id = 1", "orders", 100, 0),
SqlQueryRecord::new(
"SELECT * FROM orders WHERE user_id = 2 AND status = 1",
"orders",
200,
1,
),
SqlQueryRecord::new(
"SELECT * FROM orders WHERE user_id = 3 AND status = 2",
"orders",
300,
2,
),
];
let config = DetectionConfig::default();
let alerts = detect_n_plus_one(&records, &config);
assert!(alerts.is_empty());
}
#[test]
fn test_r5_php_detector_integration() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM users", "users", 0);
for i in 1..=6 {
detector.record(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 50,
);
}
let alerts = detector.detect();
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].table, "orders");
assert_eq!(alerts[0].query_count, 6);
assert!(alerts[0].suggestion.contains("with"));
}
#[test]
fn test_integration_detector_with_config_change() {
let mut detector = NPlusOneDetector::new(DetectionConfig::new(10, 1000));
for i in 1..=6 {
detector.record(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 100,
);
}
assert!(detector.detect().is_empty());
detector.set_config(DetectionConfig::new(5, 1000));
let alerts = detector.detect();
assert_eq!(alerts.len(), 1);
}
#[test]
fn test_integration_multiple_rounds() {
let mut detector = NPlusOneDetector::default();
for i in 1..=6 {
detector.record(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 100,
);
}
assert_eq!(detector.detect().len(), 1);
detector.clear();
assert_eq!(detector.record_count(), 0);
detector.record("SELECT * FROM users", "users", 0);
detector.record(
"SELECT * FROM orders WHERE user_id IN (1, 2, 3, 4, 5, 6)",
"orders",
100,
);
assert!(detector.detect().is_empty());
}
#[test]
fn test_integration_complex_scenario() {
let mut detector = NPlusOneDetector::default();
detector.record("SELECT * FROM users WHERE status = 1", "users", 0);
for i in 1..=5 {
detector.record(
&format!("SELECT * FROM orders WHERE user_id = {}", i),
"orders",
i * 100,
);
}
detector.record("SELECT * FROM profiles WHERE user_id = 1", "profiles", 600);
for i in 1..=7 {
detector.record(
&format!("SELECT * FROM comments WHERE post_id = {}", i),
"comments",
700 + i * 50,
);
}
let alerts = detector.detect();
assert_eq!(alerts.len(), 2);
assert_eq!(alerts[0].table, "comments");
assert_eq!(alerts[0].query_count, 7);
assert_eq!(alerts[1].table, "orders");
assert_eq!(alerts[1].query_count, 5);
}
}