use super::gorm::{gorm_chain_has_step, gorm_chain_shape, gorm_chain_step};
use super::*;
pub(crate) const BINDING_LOCATION: &str = super::BINDING_LOCATION;
fn db_query_text_looks_write(query_text: &str) -> bool {
let normalized = query_text.to_ascii_uppercase();
normalized.contains("INSERT ")
|| normalized.contains("UPDATE ")
|| normalized.contains("DELETE ")
|| normalized.contains("REPLACE ")
}
fn db_query_text_looks_count(query_text: &str) -> bool {
let normalized = query_text.to_ascii_uppercase();
normalized.contains("COUNT(") || normalized.contains("COUNT (")
}
pub(super) fn sql_loop_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
if !has_sql_like_import(file) {
return Vec::new();
}
let go = function.go_evidence();
let mut findings = Vec::new();
for body_line in lines {
if body_line.in_loop
&& (body_line.text.contains(".Prepare(") || body_line.text.contains(".PrepareContext("))
{
findings.push(Finding {
rule_id: "prepare_inside_loop".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} prepares statements inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"Prepare-like call observed inside a loop at line {}",
body_line.line
),
"reusing a prepared statement outside the loop is usually cheaper than preparing per iteration"
.to_string(),
],
});
}
if body_line.in_loop
&& (body_line.text.contains(".Begin(") || body_line.text.contains(".BeginTx("))
{
findings.push(Finding {
rule_id: "tx_begin_per_item_loop".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} starts transactions inside a loop",
function.fingerprint.name
),
evidence: vec![
format!("Begin-like call observed inside a loop at line {}", body_line.line),
"wrapping the whole batch in one transaction is usually cheaper than beginning one per item"
.to_string(),
],
});
}
}
let has_bulk_write_escape = function.body_text.contains("CreateInBatches(")
|| function.body_text.contains("CopyFrom(")
|| function.body_text.contains("FindInBatches(");
if !has_bulk_write_escape {
for query_call in go.db_query_calls.iter().filter(|query_call| {
query_call.in_loop
&& matches!(query_call.method_name.as_str(), "Exec" | "ExecContext")
&& query_call
.query_text
.as_deref()
.is_some_and(db_query_text_looks_write)
}) {
findings.push(Finding {
rule_id: "exec_inside_loop_without_batch".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} executes per-row SQL writes inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"{} observed inside a loop at line {}",
query_call.method_name, query_call.line
),
format!(
"query text: {}",
query_call.query_text.as_deref().unwrap_or_default()
),
"a set-based write or batch path is usually cheaper than issuing one Exec per item"
.to_string(),
],
});
}
}
for query_call in go.db_query_calls.iter().filter(|query_call| {
query_call.in_loop
&& matches!(
query_call.method_name.as_str(),
"QueryRow" | "QueryRowContext"
)
}) {
findings.push(Finding {
rule_id: "queryrow_inside_loop_existence_check".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} performs QueryRow-style lookups inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"{} observed inside a loop at line {}",
query_call.method_name, query_call.line
),
query_call
.query_text
.as_ref()
.map(|query_text| format!("query text: {query_text}"))
.unwrap_or_else(|| {
"loop-local point lookups often want a bulk prefetch path instead"
.to_string()
}),
],
});
}
for query_call in go.db_query_calls.iter().filter(|query_call| {
query_call.in_loop
&& query_call
.query_text
.as_deref()
.is_some_and(db_query_text_looks_count)
}) {
findings.push(Finding {
rule_id: "count_inside_loop".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} executes COUNT-style SQL inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"{} observed inside a loop at line {}",
query_call.method_name, query_call.line
),
format!(
"query text: {}",
query_call.query_text.as_deref().unwrap_or_default()
),
],
});
}
findings
}
pub(super) fn nested_transaction_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let mut begin_count = 0usize;
for body_line in lines {
if body_line.text.contains(".Begin(") || body_line.text.contains(".BeginTx(") {
begin_count += 1;
if begin_count >= 2 {
findings.push(Finding {
rule_id: "nested_transaction_in_request_path".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} starts multiple transactions on a request path",
function.fingerprint.name
),
evidence: vec![
format!("second Begin-like call observed at line {}", body_line.line),
"nested or repeated transactional scopes often indicate missing batching intent".to_string(),
],
});
break;
}
}
}
findings
}
pub(super) fn select_or_get_inside_loop_findings(
file: &ParsedFile,
function: &ParsedFunction,
) -> Vec<Finding> {
let go = function.go_evidence();
let mut findings = Vec::new();
for chain in go.gorm_query_chains.iter().filter(|chain| {
chain.in_loop
&& matches!(chain.terminal_method.as_str(), "Find" | "First" | "Take")
&& !gorm_chain_has_step(chain, "Preload")
}) {
findings.push(Finding {
rule_id: "select_or_get_inside_loop_lookup".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: chain.line,
end_line: chain.line,
message: format!(
"function {} runs ORM lookups inside a loop",
function.fingerprint.name
),
evidence: vec![
format!("chain shape: {}", gorm_chain_shape(chain)),
"bulk prefetch with IN or Preload is usually cheaper than per-item lookups"
.to_string(),
],
});
}
for query_call in go.db_query_calls.iter().filter(|qc| {
qc.in_loop
&& matches!(
qc.method_name.as_str(),
"Query" | "QueryContext" | "Get" | "Select"
)
}) {
findings.push(Finding {
rule_id: "select_or_get_inside_loop_lookup".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} runs SQL lookups inside a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"{} observed inside a loop at line {}",
query_call.method_name, query_call.line
),
"bulk prefetch with IN clause is usually cheaper than per-item lookups".to_string(),
],
});
}
findings
}
pub(super) fn row_by_row_upsert_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let go = function.go_evidence();
for body_line in lines.iter().filter(|bl| bl.in_loop) {
if body_line.text.contains("ON CONFLICT") || body_line.text.contains("ON DUPLICATE") {
findings.push(Finding {
rule_id: "row_by_row_upsert_loop".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} executes upsert-style writes row by row",
function.fingerprint.name
),
evidence: vec![
format!(
"upsert query observed inside a loop at line {}",
body_line.line
),
"bulk conflict-handling inserts are usually cheaper than row-by-row upserts"
.to_string(),
],
});
break;
}
}
for query_call in go.db_query_calls.iter().filter(|qc| {
qc.in_loop
&& matches!(qc.method_name.as_str(), "Exec" | "ExecContext")
&& qc.query_text.as_deref().is_some_and(|qt| {
let upper = qt.to_ascii_uppercase();
upper.contains("ON CONFLICT") || upper.contains("ON DUPLICATE")
})
}) {
findings.push(Finding {
rule_id: "row_by_row_upsert_loop".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} executes upsert queries row by row in a loop",
function.fingerprint.name
),
evidence: vec![
format!(
"{} observed inside a loop at line {}",
query_call.method_name, query_call.line
),
"bulk conflict-handling is usually cheaper than per-row upserts".to_string(),
],
});
}
findings
}
pub(super) fn repeated_same_query_template_findings(
file: &ParsedFile,
function: &ParsedFunction,
) -> Vec<Finding> {
let go = function.go_evidence();
let mut query_groups = BTreeMap::<String, Vec<usize>>::new();
for query_call in go.db_query_calls.iter().filter(|qc| !qc.in_loop) {
if let Some(query_text) = &query_call.query_text {
let normalized = query_text.trim().to_ascii_uppercase();
if !normalized.is_empty() {
query_groups
.entry(normalized)
.or_default()
.push(query_call.line);
}
}
}
let mut findings = Vec::new();
for (_, query_lines) in query_groups {
if query_lines.len() < 2 {
continue;
}
findings.push(Finding {
rule_id: "repeated_same_query_template_same_function".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_lines[1],
end_line: query_lines[1],
message: format!(
"function {} executes the same query template multiple times",
function.fingerprint.name
),
evidence: vec![
format!("same query template observed at lines {}", join_lines(&query_lines)),
"caching or reusing query results is often cheaper than re-executing the same query".to_string(),
],
});
}
findings
}
pub(super) fn exists_via_count_star_findings(
file: &ParsedFile,
function: &ParsedFunction,
) -> Vec<Finding> {
let go = function.go_evidence();
let mut findings = Vec::new();
for query_call in go.db_query_calls.iter().filter(|qc| {
qc.query_text
.as_deref()
.is_some_and(db_query_text_looks_count)
}) {
let count_line = query_call.line;
let lines_after: Vec<_> = function
.body_text
.lines()
.enumerate()
.filter(|(offset, _)| function.body_start_line + offset > count_line)
.take(5)
.collect();
let only_boolean_use = lines_after.iter().any(|(_, line)| {
let trimmed = line.trim();
(trimmed.contains("> 0") || trimmed.contains("== 0") || trimmed.contains("!= 0"))
&& !trimmed.contains("range ")
});
if only_boolean_use {
findings.push(Finding {
rule_id: "exists_via_count_star".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: count_line,
end_line: count_line,
message: format!(
"function {} uses COUNT(*) for an existence check",
function.fingerprint.name
),
evidence: vec![
format!(
"COUNT query at line {} compared to zero afterwards",
count_line
),
"EXISTS or LIMIT 1 is usually cheaper when only a boolean answer is needed"
.to_string(),
],
});
}
}
for chain in go
.gorm_query_chains
.iter()
.filter(|c| c.terminal_method == "Count")
{
let count_line = chain.line;
let lines_after: Vec<_> = function
.body_text
.lines()
.enumerate()
.filter(|(offset, _)| function.body_start_line + offset > count_line)
.take(5)
.collect();
let only_boolean_use = lines_after.iter().any(|(_, line)| {
let trimmed = line.trim();
(trimmed.contains("> 0") || trimmed.contains("== 0") || trimmed.contains("!= 0"))
&& !trimmed.contains("range ")
});
if only_boolean_use {
findings.push(Finding {
rule_id: "exists_via_count_star".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: count_line,
end_line: count_line,
message: format!(
"function {} uses GORM Count for an existence check",
function.fingerprint.name
),
evidence: vec![
format!("chain shape: {}", gorm_chain_shape(chain)),
"EXISTS or LIMIT 1 with First is usually cheaper when only a boolean answer is needed".to_string(),
],
});
}
}
findings
}
pub(super) fn sql_query_shape_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
request_path: bool,
) -> Vec<Finding> {
if !request_path || !has_sql_like_import(file) {
return Vec::new();
}
let go = function.go_evidence();
let mut findings = Vec::new();
for query_call in go.db_query_calls.iter() {
if let Some(query_text) = &query_call.query_text {
let upper: String = query_text.to_ascii_uppercase();
if (upper.contains("LOWER(") || upper.contains("UPPER(") || upper.contains("COALESCE("))
&& upper.contains("WHERE")
{
findings.push(Finding {
rule_id: "lower_or_func_wrapped_indexed_column".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} uses function-wrapped columns in WHERE clauses",
function.fingerprint.name
),
evidence: vec![
format!("query at line {}: {}", query_call.line, query_text),
"LOWER/UPPER/COALESCE on indexed columns often defeats index usage"
.to_string(),
],
});
}
if (upper.contains("DATE(") || upper.contains("CAST(")) && upper.contains("WHERE") {
findings.push(Finding {
rule_id: "date_or_cast_wrapped_indexed_column".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} uses DATE/CAST on columns in WHERE clauses",
function.fingerprint.name
),
evidence: vec![
format!("query at line {}: {}", query_call.line, query_text),
"wrapping indexed columns in DATE/CAST often prevents index usage"
.to_string(),
],
});
}
if upper.contains("LIKE")
&& upper.contains("WHERE")
&& let Some(like_pos) = upper.find("LIKE")
{
let after_like = &upper[like_pos + 4..];
if after_like.trim_start().starts_with("'%")
|| after_like.trim_start().starts_with("\"%")
{
findings.push(Finding {
rule_id: "leading_wildcard_builder_chain".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} uses leading wildcard LIKE patterns",
function.fingerprint.name
),
evidence: vec![
format!("query at line {}: {}", query_call.line, query_text),
"leading wildcards prevent index usage; consider full-text search or trigram indexes".to_string(),
],
});
}
}
if upper.contains("ORDER BY")
&& (upper.contains("RAND()") || upper.contains("RANDOM()"))
{
findings.push(Finding {
rule_id: "order_by_random_request_path".to_string(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: query_call.line,
end_line: query_call.line,
message: format!(
"function {} orders by random in a SQL query on a request path",
function.fingerprint.name
),
evidence: vec![
format!("query at line {}: {}", query_call.line, query_text),
"random ordering requires a full table scan and sort".to_string(),
],
});
}
}
}
for body_line in lines {
if body_line.text.contains("LIKE") && body_line.text.contains("\"%") {
let already_found = findings
.iter()
.any(|f| f.rule_id == "leading_wildcard_builder_chain");
if !already_found {
let upper_text = body_line.text.to_ascii_uppercase();
if upper_text.contains("WHERE") || upper_text.contains("LIKE") {
let has_where_context = go
.gorm_query_chains
.iter()
.any(|c| gorm_chain_has_step(c, "Where"));
if has_where_context {
findings.push(Finding {
rule_id: "leading_wildcard_builder_chain".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} uses leading wildcard search patterns",
function.fingerprint.name
),
evidence: vec![
format!("leading wildcard observed at line {}", body_line.line),
"leading wildcards prevent index usage; consider full-text search or trigram indexes".to_string(),
],
});
}
}
}
}
}
for chain in go.gorm_query_chains.iter() {
if let Some(where_step) = gorm_chain_step(chain, "Where") {
let or_count = chain.steps.iter().filter(|s| s.method_name == "Or").count();
if or_count >= 3 {
findings.push(Finding {
rule_id: "many_column_or_filter_chain".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: where_step.line,
end_line: where_step.line,
message: format!(
"function {} builds a large OR filter chain on a request path",
function.fingerprint.name
),
evidence: vec![
format!("chain shape: {}", gorm_chain_shape(chain)),
format!("{} Or() clauses observed", or_count),
"large OR chains can prevent index usage; consider a different query strategy".to_string(),
],
});
}
}
}
findings
}
pub(super) fn unbounded_in_clause_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for body_line in lines {
let text_upper = body_line.text.to_ascii_uppercase();
if (text_upper.contains(" IN (") || text_upper.contains(" IN("))
&& (body_line.text.contains("...")
|| body_line.text.contains("ids)")
|| body_line.text.contains("items)"))
{
findings.push(Finding {
rule_id: "unbounded_in_clause_expansion".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} builds an IN clause from a request-driven collection",
function.fingerprint.name
),
evidence: vec![
format!("IN clause with collection expansion observed at line {}", body_line.line),
"unbounded IN clauses can cause query plan instability; consider batching or temp tables".to_string(),
],
});
break;
}
}
findings
}
pub(super) fn scan_into_map_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for body_line in lines {
if body_line.text.contains(".Scan(")
&& (body_line.text.contains("map[string]any")
|| body_line.text.contains("map[string]interface{}"))
{
findings.push(Finding {
rule_id: "scan_into_map_string_any_hot_path".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} scans rows into map[string]any",
function.fingerprint.name
),
evidence: vec![
format!("scan into dynamic map at line {}", body_line.line),
"typed struct destinations are usually faster and safer than dynamic map scanning".to_string(),
],
});
}
}
let has_dynamic_scan_binding = lines.iter().any(|bl| {
bl.text.contains("map[string]any{}")
|| bl.text.contains("map[string]interface{}{}")
|| bl.text.contains("make(map[string]any")
|| bl.text.contains("make(map[string]interface{}")
});
if has_dynamic_scan_binding {
for body_line in lines.iter().filter(|bl| bl.in_loop) {
if body_line.text.contains(".Scan(") || body_line.text.contains(".MapScan(") {
let already_found = findings.iter().any(|f| {
f.rule_id == "scan_into_map_string_any_hot_path"
&& f.start_line == body_line.line
});
if !already_found {
findings.push(Finding {
rule_id: "scan_into_map_string_any_hot_path".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} scans rows into dynamic maps in a loop",
function.fingerprint.name
),
evidence: vec![
format!("dynamic map scan observed inside a loop at line {}", body_line.line),
"typed struct destinations are usually faster and safer than dynamic map scanning".to_string(),
],
});
}
}
}
}
findings
}
pub(super) fn rows_to_struct_per_row_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for body_line in lines.iter().filter(|bl| bl.in_loop) {
if (body_line.text.contains("new(") || body_line.text.contains("&"))
&& (body_line.text.contains(".Scan(") || body_line.text.contains("rows.Next"))
{
let is_struct_alloc = body_line.text.contains("new(")
|| (body_line.text.contains("&") && body_line.text.contains("{}"));
if is_struct_alloc {
findings.push(Finding {
rule_id: "rows_to_struct_allocation_per_row_without_reuse".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} allocates a new struct for each row scan",
function.fingerprint.name
),
evidence: vec![
format!("per-row struct allocation observed at line {}", body_line.line),
"reusing a destination struct and copying per-row reduces allocation pressure".to_string(),
],
});
break;
}
}
}
findings
}
pub(super) fn sqlx_select_unbounded_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
request_path: bool,
) -> Vec<Finding> {
if !request_path {
return Vec::new();
}
let mut findings = Vec::new();
let has_sqlx = has_import_path(file, "github.com/jmoiron/sqlx");
if has_sqlx {
for body_line in lines {
if (body_line.text.contains(".Select(") || body_line.text.contains("sqlx.Select("))
&& !function.body_text.contains("LIMIT")
&& !function.body_text.contains("limit")
{
findings.push(Finding {
rule_id: "sqlx_select_large_slice_without_limit".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} uses sqlx.Select without a visible LIMIT on a request path",
function.fingerprint.name
),
evidence: vec![
format!(
"sqlx.Select observed at line {} without LIMIT in query",
body_line.line
),
"unbounded selects can materialize large slices; add LIMIT or pagination"
.to_string(),
],
});
break;
}
}
}
let has_pgx = has_import_path(file, "github.com/jackc/pgx/v5")
|| has_import_path(file, "github.com/jackc/pgx/v4");
if has_pgx {
for body_line in lines {
if body_line.text.contains("CollectRows(")
&& !function.body_text.contains("LIMIT")
&& !function.body_text.contains("limit")
{
findings.push(Finding {
rule_id: "pgx_collectrows_unbounded_materialization".to_string(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: body_line.line,
end_line: body_line.line,
message: format!(
"function {} uses pgx.CollectRows without a visible LIMIT on a request path",
function.fingerprint.name
),
evidence: vec![
format!("pgx.CollectRows observed at line {} without LIMIT in query", body_line.line),
"unbounded row collection can materialize very large slices; add LIMIT or cursor iteration".to_string(),
],
});
break;
}
}
}
findings
}