use crate::checks::pg_helpers::{
ColumnDef, NodeEnum, alter_table_cmds, cmd_def_as_column_def, column_type_name,
for_each_column_def, is_char_type,
};
use crate::checks::{Check, Config, MigrationContext};
use crate::violation::Violation;
pub struct CharTypeCheck;
impl Check for CharTypeCheck {
fn check(&self, node: &NodeEnum, _config: &Config, _ctx: &MigrationContext) -> Vec<Violation> {
if let NodeEnum::CreateStmt(_) = node {
return for_each_column_def(node)
.into_iter()
.filter_map(|(table, col)| {
if !is_char_type(&column_type_name(col)) {
return None;
}
let length = get_char_length(col);
Some(create_create_table_violation(&table, &col.colname, &length))
})
.collect();
}
if let NodeEnum::AlterTableStmt(_) = node {
let Some((table_name, cmds)) = alter_table_cmds(node) else {
return vec![];
};
return cmds
.iter()
.filter_map(|cmd| {
let col = cmd_def_as_column_def(cmd)?;
if !is_char_type(&column_type_name(col)) {
return None;
}
let length = get_char_length(col);
Some(create_alter_table_violation(
&table_name,
&col.colname,
&length,
))
})
.collect();
}
vec![]
}
}
fn get_char_length(col: &ColumnDef) -> String {
col.type_name
.as_ref()
.and_then(|tn| tn.typmods.first())
.and_then(|n| match &n.node {
Some(NodeEnum::AConst(ac)) => match &ac.val {
Some(pg_query::protobuf::a_const::Val::Ival(i)) => Some(i.ival.to_string()),
_ => None,
},
_ => None,
})
.unwrap_or_else(|| "1".to_string())
}
fn create_alter_table_violation(table_name: &str, column_name: &str, length: &str) -> Violation {
Violation::new(
"ADD COLUMN with CHAR type",
format!(
"Column '{column_name}' uses CHAR({length}) which is fixed-length and padded with spaces. \
This wastes storage and can cause subtle bugs with string comparisons. \
This is a best practice warning (no locking impact)."
),
format!(
r"Use TEXT or VARCHAR instead of CHAR:
1. For variable-length strings (most cases):
ALTER TABLE {table_name} ADD COLUMN {column_name} TEXT;
2. If you need a length constraint:
ALTER TABLE {table_name} ADD COLUMN {column_name} VARCHAR({length});
-- Or use TEXT with a CHECK constraint:
ALTER TABLE {table_name} ADD COLUMN {column_name} TEXT CHECK (length({column_name}) <= {length});
CHAR is only appropriate for truly fixed-length codes (e.g., ISO country codes).
If this is intentional, use a safety-assured block:
-- safety-assured:start
ALTER TABLE {table_name} ADD COLUMN {column_name} CHAR({length});
-- safety-assured:end"
),
)
}
fn create_create_table_violation(table_name: &str, column_name: &str, length: &str) -> Violation {
Violation::new(
"CREATE TABLE with CHAR type",
format!(
"Column '{column_name}' uses CHAR({length}) which is fixed-length and padded with spaces. \
This wastes storage and can cause subtle bugs with string comparisons. \
This is a best practice warning (no locking impact)."
),
format!(
r"Use TEXT or VARCHAR instead of CHAR:
1. For variable-length strings (most cases):
CREATE TABLE {table_name} (
{column_name} TEXT
);
2. If you need a length constraint:
CREATE TABLE {table_name} (
{column_name} VARCHAR({length})
);
-- Or use TEXT with a CHECK constraint:
CREATE TABLE {table_name} (
{column_name} TEXT CHECK (length({column_name}) <= {length})
);
CHAR is only appropriate for truly fixed-length codes (e.g., ISO country codes).
If this is intentional, use a safety-assured block:
-- safety-assured:start
CREATE TABLE {table_name} (
{column_name} CHAR({length})
);
-- safety-assured:end"
),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
assert_allows, assert_detects_n_violations_any_containing, assert_detects_violation,
assert_detects_violation_containing,
};
#[test]
fn test_detects_char_column_alter_table() {
assert_detects_violation!(
CharTypeCheck,
"ALTER TABLE users ADD COLUMN country_code CHAR(2);",
"ADD COLUMN with CHAR type"
);
}
#[test]
fn test_detects_character_column_alter_table() {
assert_detects_violation!(
CharTypeCheck,
"ALTER TABLE users ADD COLUMN status CHARACTER(1);",
"ADD COLUMN with CHAR type"
);
}
#[test]
fn test_detects_char_column_create_table() {
assert_detects_violation!(
CharTypeCheck,
"CREATE TABLE users (id SERIAL PRIMARY KEY, country_code CHAR(2));",
"CREATE TABLE with CHAR type"
);
}
#[test]
fn test_detects_char_with_explicit_length() {
assert_detects_violation_containing!(
CharTypeCheck,
"ALTER TABLE products ADD COLUMN sku CHAR(10);",
"ADD COLUMN with CHAR type",
"CHAR(10)"
);
}
#[test]
fn test_detects_char_without_explicit_length() {
assert_detects_violation_containing!(
CharTypeCheck,
"ALTER TABLE flags ADD COLUMN flag CHAR;",
"ADD COLUMN with CHAR type",
"CHAR(1)"
);
}
#[test]
fn test_detects_multiple_char_columns() {
assert_detects_n_violations_any_containing!(
CharTypeCheck,
"CREATE TABLE locations (id SERIAL PRIMARY KEY, country CHAR(2), region CHAR(3));",
2,
"country",
"region"
);
}
#[test]
fn test_allows_varchar_column() {
assert_allows!(
CharTypeCheck,
"ALTER TABLE users ADD COLUMN name VARCHAR(255);"
);
}
#[test]
fn test_allows_text_column() {
assert_allows!(CharTypeCheck, "ALTER TABLE users ADD COLUMN bio TEXT;");
}
#[test]
fn test_allows_other_column_types() {
assert_allows!(CharTypeCheck, "ALTER TABLE users ADD COLUMN age INT;");
assert_allows!(
CharTypeCheck,
"ALTER TABLE users ADD COLUMN active BOOLEAN;"
);
assert_allows!(
CharTypeCheck,
"ALTER TABLE users ADD COLUMN created_at TIMESTAMP;"
);
}
#[test]
fn test_allows_create_table_without_char() {
assert_allows!(
CharTypeCheck,
"CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT, email VARCHAR(255));"
);
}
#[test]
fn test_ignores_other_alter_operations() {
assert_allows!(CharTypeCheck, "ALTER TABLE users DROP COLUMN old_field;");
}
#[test]
fn test_ignores_other_statements() {
assert_allows!(CharTypeCheck, "SELECT * FROM users;");
}
}