walker: flag LIKE on a numeric column (ADR-0027 Amendment 1)

LIKE is a text-pattern match; against a numeric column (int,
real, decimal, serial) it runs but is almost never intended.
predicate_warnings now emits a WARNING for it, spanned at the
target column. New Type::is_numeric; catalog key
diagnostic.like_numeric; ADR-0027 gains "Amendment 1" and the
adr/README index line is updated per the index-upkeep rule.

bool and the text-/blob-backed types are deliberately not
flagged — see the amendment for the rationale.

3 walker tests (int, decimal NOT LIKE, text-column clean).
1108 passing, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 09:28:43 +00:00
parent 3912fb5a9b
commit 437b2f2e91
6 changed files with 139 additions and 5 deletions
+12
View File
@@ -101,6 +101,18 @@ impl Type {
]
}
/// True for the numeric types — `int`, `real`, `decimal`,
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed
/// types are not numeric. Used to flag a `LIKE` text-pattern
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
}
/// The user-facing type that an FK column should use to
/// reference a primary key of *this* type. For most types
/// the answer is the same type; for `serial` and `shortid`
+83 -3
View File
@@ -526,14 +526,54 @@ fn predicate_warnings(
}
}
}
// `LIKE` is inherently a text-pattern test; flagging a
// non-text target is a future model extension.
// `LIKE` is a text-pattern test; against a numeric
// column it runs but is almost never intended
// (ADR-0027, Amendment 1). The negation is irrelevant —
// `NOT LIKE` on a numeric column is just as dubious.
Predicate::Like { target, .. } => {
if let Some((message, span)) =
like_numeric_warning(target, columns)
{
out.push(warn(message, span));
}
}
// `IS [NOT] NULL` is the *correct* null test — never
// flagged.
Predicate::Like { .. } | Predicate::IsNull { .. } => {}
Predicate::IsNull { .. } => {}
}
}
/// A `LIKE` whose target is a numeric column: `LIKE` matches
/// text patterns, so a numeric target is almost certainly a
/// mistake (ADR-0027, Amendment 1). The message is paired with
/// the target column operand's span. `None` when the target is
/// a literal, an unknown column, or a non-numeric column.
fn like_numeric_warning(
target: &Operand,
columns: &[crate::completion::TableColumn],
) -> Option<(String, (usize, usize))> {
let Operand::Column { name, span } = target else {
return None;
};
let ty = columns
.iter()
.find(|tc| tc.name.eq_ignore_ascii_case(name))?
.user_type;
if !ty.is_numeric() {
return None;
}
Some((
crate::friendly::translate(
"diagnostic.like_numeric",
&[
("column", name as &dyn std::fmt::Display),
("type", &ty.keyword() as &dyn std::fmt::Display),
],
),
*span,
))
}
const fn is_null_literal(operand: &Operand) -> bool {
matches!(
operand,
@@ -1816,6 +1856,46 @@ mod tests {
assert_eq!(&input[s..e], "NoSuchCol");
}
// ---- LIKE on a numeric column (ADR-0027, Amendment 1) -----
#[test]
fn like_on_a_numeric_column_is_a_warning() {
// `LIKE` is a text-pattern match — against an int
// column it runs but is almost never intended.
let schema = schema_with("Customers", &[("Age", Type::Int)]);
let input = "delete from Customers where Age like '1%'";
let diags = diagnostics(input, &schema);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, super::Severity::Warning);
let (s, e) = diags[0].span;
assert_eq!(&input[s..e], "Age", "the span is the numeric column");
}
#[test]
fn not_like_on_a_numeric_column_is_also_a_warning() {
let schema = schema_with("Orders", &[("Total", Type::Decimal)]);
assert_eq!(
super::input_verdict(
"delete from Orders where Total not like '9%'",
Some(&schema),
),
Some(super::Severity::Warning),
);
}
#[test]
fn like_on_a_text_column_is_clean() {
// `LIKE 'A%'` on a text column is its intended use.
let schema = schema_with("Customers", &[("Name", Type::Text)]);
assert_eq!(
super::input_verdict(
"delete from Customers where Name like 'A%'",
Some(&schema),
),
None,
);
}
#[test]
fn walker_parses_insert_with_explicit_column_list() {
assert_eq!(