use std::collections::BTreeMap;
use pg_query::protobuf::RawStmt;
use crate::rules::{column_base_type, defined_columns};
pub(crate) const ID: &str = "forbidden-column-type";
pub(crate) const GUIDANCE: &str =
"Change the column to an allowed type, or remove this type from the [forbidden-types] section of \
your config.";
pub(crate) fn canonical_type(spelling: &str) -> Option<String> {
let sql = format!("CREATE TABLE _pgsafe_typecheck (c {spelling})");
let parsed = pg_query::parse(&sql).ok()?;
let node = parsed
.protobuf
.stmts
.first()?
.stmt
.as_ref()?
.node
.as_ref()?;
let col = defined_columns(node).into_iter().next()?;
column_base_type(col)
}
pub(crate) fn forbidden_violations(
stmts: &[RawStmt],
forbidden: &BTreeMap<String, String>,
) -> Vec<(usize, String)> {
let lookup: BTreeMap<String, (&str, &str)> = forbidden
.iter()
.filter_map(|(ty, repl)| canonical_type(ty).map(|c| (c, (ty.as_str(), repl.as_str()))))
.collect();
if lookup.is_empty() {
return Vec::new();
}
let mut out = 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;
};
for col in defined_columns(node) {
let Some(base) = column_base_type(col) else {
continue;
};
if let Some((spelling, replacement)) = lookup.get(&base) {
let message = if replacement.is_empty() {
format!(
"The column `{}` uses `{spelling}`, which your policy disallows.",
col.colname
)
} else {
format!(
"The column `{}` uses `{spelling}`, which your policy disallows; use \
`{replacement}` instead.",
col.colname
)
};
out.push((i, message));
}
}
}
out
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{canonical_type, forbidden_violations};
use crate::{lint_sql, LintOptions};
fn forbid(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn flagged(sql: &str, pairs: &[(&str, &str)]) -> Vec<String> {
forbidden_violations(
&pg_query::parse(sql).unwrap().protobuf.stmts,
&forbid(pairs),
)
.into_iter()
.map(|(_, m)| m)
.collect()
}
#[test]
fn canonical_type_normalizes_spellings() {
assert_eq!(canonical_type("char").as_deref(), Some("bpchar"));
assert_eq!(canonical_type("integer").as_deref(), Some("int4"));
assert_eq!(
canonical_type("timestamp with time zone").as_deref(),
Some("timestamptz")
);
}
#[test]
fn canonical_type_returns_none_on_unparseable() {
assert!(canonical_type("not a real type").is_none());
assert_eq!(canonical_type("notatype").as_deref(), Some("notatype"));
}
#[test]
fn unrecognized_type_is_inert() {
assert!(flagged("CREATE TABLE t (c text)", &[("notatype", "")]).is_empty());
}
#[test]
fn forbidden_type_is_flagged_with_replacement() {
let f = flagged(
"CREATE TABLE t (created timestamp)",
&[("timestamp", "timestamptz")],
);
assert_eq!(f.len(), 1);
assert!(f[0].contains("`timestamptz`"));
}
#[test]
fn distinct_canonical_type_is_not_flagged() {
assert!(flagged(
"CREATE TABLE t (created timestamptz)",
&[("timestamp", "timestamptz")]
)
.is_empty());
}
#[test]
fn spelling_matches_via_canonicalization() {
assert_eq!(
flagged("CREATE TABLE t (code char(10))", &[("char", "text")]).len(),
1
);
}
#[test]
fn add_column_forbidden_type_is_flagged() {
assert_eq!(
flagged("ALTER TABLE t ADD COLUMN c money", &[("money", "numeric")]).len(),
1
);
}
#[test]
fn allowed_type_is_silent() {
assert!(flagged(
"CREATE TABLE t (id bigint)",
&[("timestamp", "timestamptz")]
)
.is_empty());
}
#[test]
fn empty_config_does_no_work() {
assert!(flagged("CREATE TABLE t (created timestamp)", &[]).is_empty());
}
#[test]
fn empty_replacement_omits_suggestion() {
let f = flagged("CREATE TABLE t (c money)", &[("money", "")]);
assert_eq!(f.len(), 1);
assert!(!f[0].contains("use `"));
}
fn forbid_opts(pairs: &[(&str, &str)]) -> LintOptions {
LintOptions {
forbidden_column_types: forbid(pairs),
..LintOptions::default()
}
}
#[test]
fn silent_without_config() {
let f = lint_sql(
"CREATE TABLE t (created timestamp)",
&LintOptions::default(),
)
.unwrap();
assert!(f.iter().all(|f| f.rule_id != "forbidden-column-type"));
}
#[test]
fn fires_with_config() {
use crate::Severity;
let f = lint_sql(
"CREATE TABLE t (created timestamp)",
&forbid_opts(&[("timestamp", "timestamptz")]),
)
.unwrap();
let hit = f
.iter()
.find(|f| f.rule_id == "forbidden-column-type")
.expect("rule must fire when configured");
assert_eq!(hit.severity, Severity::Warning);
}
#[test]
fn severity_override_escalates_to_error() {
use crate::Severity;
let opts = LintOptions {
forbidden_column_types: forbid(&[("timestamp", "timestamptz")]),
severity_overrides: [("forbidden-column-type".to_string(), Severity::Error)]
.into_iter()
.collect(),
..LintOptions::default()
};
let f = lint_sql("CREATE TABLE t (created timestamp)", &opts).unwrap();
let hit = f
.iter()
.find(|f| f.rule_id == "forbidden-column-type")
.expect("rule must fire");
assert_eq!(hit.severity, Severity::Error);
}
#[test]
fn passes_on_allowed_type() {
let f = lint_sql(
"CREATE TABLE t (created timestamptz)",
&forbid_opts(&[("timestamp", "timestamptz")]),
)
.unwrap();
assert!(f.iter().all(|f| f.rule_id != "forbidden-column-type"));
}
#[test]
fn inline_suppressible() {
let sql = "-- pgsafe:ignore forbidden-column-type legacy column\n\
CREATE TABLE t (created timestamp)";
let f = lint_sql(sql, &forbid_opts(&[("timestamp", "timestamptz")])).unwrap();
let hit = f
.iter()
.find(|f| f.rule_id == "forbidden-column-type")
.expect("rule must fire");
assert!(hit.is_suppressed());
}
}