use std::collections::BTreeSet;
use crate::ast::protobuf::RawStmt;
use crate::ast::NodeEnum;
use crate::line_col;
use crate::suppression::StatementGeom;
use crate::{Finding, LintOptions, Location, Severity};
pub(crate) mod do_block;
pub(crate) mod enum_value;
pub(crate) mod fk_index;
pub(crate) mod forbid_nullable_fk;
pub(crate) mod forbidden_types;
pub(crate) mod identifier;
pub(crate) mod naming;
pub(crate) mod newtable;
pub(crate) mod plpgsql;
pub(crate) mod require_columns;
pub(crate) mod require_comment;
pub(crate) mod require_if_exists;
pub(crate) mod require_not_null;
pub(crate) mod require_pk;
pub(crate) mod timeout;
pub(crate) mod txn;
fn push_synthesized(
findings: &mut Vec<Finding>,
sql: &str,
geoms: &[StatementGeom],
rule_id: &str,
severity: Severity,
hits: impl IntoIterator<Item = (usize, String, String, Option<crate::fix::FixDraft>)>,
) {
for (i, message, guidance, draft) in hits {
debug_assert!(
i < geoms.len(),
"synthesized hit index {i} out of range for {rule_id}"
);
let g = &geoms[i];
let (line, column) = line_col(sql, g.start);
let fix = draft.as_ref().and_then(|d| {
let r = crate::fix::resolve(d, sql, g.start, g.end);
debug_assert!(
r.is_some() || d.may_legitimately_not_resolve(),
"rule {rule_id}: fix draft {:?} failed to resolve",
d.title
);
r
});
findings.push(Finding {
rule_id: rule_id.to_string(),
severity,
message,
guidance,
statement_index: i,
location: Location {
byte: u32::try_from(g.start).unwrap_or(u32::MAX),
line,
column,
},
snippet: sql.get(g.start..g.end).unwrap_or("").trim().to_string(),
suppression: None,
fix,
});
}
}
pub(crate) fn run_all(
sql: &str,
stmts: &[RawStmt],
geoms: &[StatementGeom],
options: &LintOptions,
mut findings: Vec<Finding>,
) -> (Vec<Finding>, BTreeSet<usize>) {
let rules = crate::rules::all_rules();
let mut hits = Vec::new();
let mut residue_do_indices: Vec<usize> = Vec::new();
for (i, raw) in stmts.iter().enumerate() {
let Some(node) = raw.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
continue;
};
if !matches!(node, NodeEnum::DoStmt(_)) {
continue;
}
let g = &geoms[i];
let (line, column) = line_col(sql, g.start);
let location = Location {
byte: u32::try_from(g.start).unwrap_or(u32::MAX),
line,
column,
};
let Some(do_sql) = sql.get(g.start..g.end) else {
residue_do_indices.push(i); continue;
};
let analysis = plpgsql::analyze_do_block(do_sql);
let mut block_residue = analysis.has_residue;
for embedded in &analysis.statements {
let Ok(embedded_parsed) = crate::ast::parse(embedded) else {
block_residue = true; continue;
};
for inner in &embedded_parsed.protobuf.stmts {
let Some(inner_node) = inner.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
block_residue = true; continue;
};
for rule in rules {
if options.disabled_rules.contains(rule.id()) {
continue;
}
rule.check(inner_node, &mut hits);
for h in hits.drain(..) {
findings.push(Finding {
rule_id: rule.id().to_string(),
severity: rule.severity(),
message: format!("Inside a DO block: {}", h.message),
guidance: h.guidance,
statement_index: i,
location,
snippet: embedded.trim().to_string(),
suppression: None,
fix: None,
});
}
}
}
}
if block_residue {
residue_do_indices.push(i);
}
}
if !options.disabled_rules.contains(timeout::ID) {
let timeout_indices =
timeout::require_timeout_indices(stmts, options.assume_in_transaction);
let timeout_fix = timeout_indices.first().and_then(|&first| {
let start = u32::try_from(geoms[first].prologue_anchor).ok()?;
Some(timeout::timeout_fix(start))
});
let mut timeout_fix_once = timeout_fix;
push_synthesized(
&mut findings,
sql,
geoms,
timeout::ID,
Severity::Warning,
timeout_indices.into_iter().map(|i| {
let fix = timeout_fix_once.take();
(
i,
timeout::MESSAGE.to_string(),
timeout::GUIDANCE.to_string(),
fix,
)
}),
);
}
let (mut findings, new_table_dropped) = newtable::drop_new_table_findings(stmts, findings);
newtable::escalate_pre_existing_attach(stmts, &mut findings);
if !options.disabled_rules.contains(txn::ID) {
push_synthesized(
&mut findings,
sql,
geoms,
txn::ID,
Severity::Error,
txn::concurrently_in_transaction_indices(stmts, options.assume_in_transaction)
.into_iter()
.map(|i| (i, txn::MESSAGE.to_string(), txn::GUIDANCE.to_string(), None)),
);
}
if !options.disabled_rules.contains(identifier::ID) {
push_synthesized(
&mut findings,
sql,
geoms,
identifier::ID,
Severity::Warning,
identifier::long_identifiers(sql).into_iter().filter_map(|(off, name, len)| {
let i = geoms.iter().position(|g| off >= g.start && off < g.end)?;
Some((
i,
format!(
"Identifier `{name}` is {len} bytes; PostgreSQL truncates identifiers to 63 \
bytes, so two names sharing a 63-byte prefix silently collide."
),
"Shorten the identifier to 63 bytes or fewer so PostgreSQL does not silently \
truncate it."
.to_string(),
None,
))
}),
);
}
if !options.disabled_rules.contains(fk_index::ID) {
push_synthesized(
&mut findings,
sql,
geoms,
fk_index::ID,
Severity::Warning,
fk_index::fk_without_index(stmts).into_iter().map(|(i, table, col)| {
(
i,
format!(
"Foreign key on `{table}` column `{col}` has no covering index; referential \
checks and ON DELETE/UPDATE actions on the parent scan and lock the child on \
every change."
),
format!(
"Add a covering index on the referencing column, e.g. \
`CREATE INDEX CONCURRENTLY ON {table} ({col});`."
),
None,
)
}),
);
}
if !options.disabled_rules.contains(enum_value::ID) {
push_synthesized(
&mut findings,
sql,
geoms,
enum_value::ID,
Severity::Warning,
enum_value::unsafe_enum_value_indices(sql, stmts, options.assume_in_transaction)
.into_iter()
.map(|i| {
(
i,
enum_value::MESSAGE.to_string(),
enum_value::GUIDANCE.to_string(),
None,
)
}),
);
}
if options.enabled_rules.contains(require_pk::ID)
&& !options.disabled_rules.contains(require_pk::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
require_pk::ID,
Severity::Warning,
require_pk::tables_without_primary_key(stmts)
.into_iter()
.map(|i| {
(
i,
require_pk::MESSAGE.to_string(),
require_pk::GUIDANCE.to_string(),
None,
)
}),
);
}
if options.enabled_rules.contains(require_not_null::ID)
&& !options.disabled_rules.contains(require_not_null::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
require_not_null::ID,
Severity::Warning,
require_not_null::nullable_columns(stmts)
.into_iter()
.map(|(i, message)| (i, message, require_not_null::GUIDANCE.to_string(), None)),
);
}
if !options.naming_patterns.is_empty() && !options.disabled_rules.contains(naming::ID) {
push_synthesized(
&mut findings,
sql,
geoms,
naming::ID,
Severity::Warning,
naming::naming_violations(stmts, &options.naming_patterns)
.into_iter()
.map(|(i, message)| (i, message, naming::GUIDANCE.to_string(), None)),
);
}
if !options.forbidden_column_types.is_empty()
&& !options.disabled_rules.contains(forbidden_types::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
forbidden_types::ID,
Severity::Warning,
forbidden_types::forbidden_violations(stmts, &options.forbidden_column_types, sql)
.into_iter()
.map(|(i, message, fix)| (i, message, forbidden_types::GUIDANCE.to_string(), fix)),
);
}
if options.enabled_rules.contains(require_if_exists::ID)
&& !options.disabled_rules.contains(require_if_exists::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
require_if_exists::ID,
Severity::Warning,
require_if_exists::missing_if_exists(stmts)
.into_iter()
.map(|(i, message, draft)| {
(i, message, require_if_exists::GUIDANCE.to_string(), draft)
}),
);
}
if options.enabled_rules.contains(do_block::ID)
&& !options.disabled_rules.contains(do_block::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
do_block::ID,
Severity::Warning,
residue_do_indices.iter().map(|&i| {
(
i,
do_block::MESSAGE.to_string(),
do_block::GUIDANCE.to_string(),
None,
)
}),
);
}
if options.enabled_rules.contains(require_comment::ID)
&& !options.disabled_rules.contains(require_comment::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
require_comment::ID,
Severity::Warning,
require_comment::missing_comments(stmts)
.into_iter()
.map(|(i, message)| (i, message, require_comment::GUIDANCE.to_string(), None)),
);
}
if !options.required_columns.is_empty() && !options.disabled_rules.contains(require_columns::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
require_columns::ID,
Severity::Warning,
require_columns::missing_required_columns(stmts, &options.required_columns)
.into_iter()
.map(|(i, message)| (i, message, require_columns::GUIDANCE.to_string(), None)),
);
}
if options.enabled_rules.contains(forbid_nullable_fk::ID)
&& !options.disabled_rules.contains(forbid_nullable_fk::ID)
{
push_synthesized(
&mut findings,
sql,
geoms,
forbid_nullable_fk::ID,
Severity::Warning,
forbid_nullable_fk::nullable_fk_columns(stmts)
.into_iter()
.map(|(i, message)| (i, message, forbid_nullable_fk::GUIDANCE.to_string(), None)),
);
}
(findings, new_table_dropped)
}