use sqlparser::ast::{
BinaryOperator, Expr, GroupByExpr, OrderBy, OrderByExpr, OrderByKind, Select, Statement, Value,
};
use crate::ast::SqltStatement;
use crate::lint::ctx::LintCtx;
use crate::lint::diagnostic::Diagnostic;
use crate::lint::rule::{Category, Rule, RuleId, RuleMeta, Severity};
pub struct EqualsNull;
const META_EQ_NULL: RuleMeta = RuleMeta {
id: RuleId("SQLT0600"),
name: "equals-null",
category: Category::Correctness,
default_severity: Severity::Error,
default_enabled: true,
summary: "Comparing a column to NULL with `=` or `!=` always evaluates to UNKNOWN.",
explanation: "SQL three-valued logic: `col = NULL` is neither true nor false — it's UNKNOWN, \
so the row is filtered out. Use `IS NULL` or `IS NOT NULL` instead.",
};
impl Rule for EqualsNull {
fn meta(&self) -> &'static RuleMeta {
&META_EQ_NULL
}
fn check_expr(&self, expr: &Expr, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
if let Expr::BinaryOp { op, left, right } = expr
&& (matches!(op, BinaryOperator::Eq | BinaryOperator::NotEq))
&& (is_null_value(left) || is_null_value(right))
{
out.push(diagnostic(
&META_EQ_NULL,
ctx,
"comparison with NULL using `=` or `!=` always evaluates to UNKNOWN",
Some("use `IS NULL` or `IS NOT NULL` instead".into()),
expr_span(expr).unwrap_or(ctx.stmt_span),
));
}
}
}
pub struct UpdateWithoutWhere;
const META_UPDATE_NO_WHERE: RuleMeta = RuleMeta {
id: RuleId("SQLT0601"),
name: "update-without-where",
category: Category::Correctness,
default_severity: Severity::Error,
default_enabled: true,
summary: "`UPDATE` without a `WHERE` clause modifies every row in the table.",
explanation: "Almost always a mistake. If you really mean to touch every row, add `WHERE TRUE` \
or otherwise show your intent.",
};
impl Rule for UpdateWithoutWhere {
fn meta(&self) -> &'static RuleMeta {
&META_UPDATE_NO_WHERE
}
fn check_statement(&self, stmt: &SqltStatement, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
let SqltStatement::Std(boxed) = stmt else {
return;
};
if let Statement::Update {
selection: None, ..
} = &**boxed
{
out.push(diagnostic(
&META_UPDATE_NO_WHERE,
ctx,
"UPDATE without a WHERE clause modifies every row",
Some("add a WHERE clause, or `WHERE TRUE` to make the intent explicit".into()),
ctx.stmt_span,
));
}
}
}
pub struct DeleteWithoutWhere;
const META_DELETE_NO_WHERE: RuleMeta = RuleMeta {
id: RuleId("SQLT0602"),
name: "delete-without-where",
category: Category::Correctness,
default_severity: Severity::Error,
default_enabled: true,
summary: "`DELETE` without a `WHERE` clause removes every row in the table.",
explanation: "Almost always a mistake. Use `TRUNCATE` for intentional whole-table wipes, \
or add `WHERE TRUE` to show intent.",
};
impl Rule for DeleteWithoutWhere {
fn meta(&self) -> &'static RuleMeta {
&META_DELETE_NO_WHERE
}
fn check_statement(&self, stmt: &SqltStatement, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
let SqltStatement::Std(boxed) = stmt else {
return;
};
if let Statement::Delete(d) = &**boxed
&& d.selection.is_none()
{
out.push(diagnostic(
&META_DELETE_NO_WHERE,
ctx,
"DELETE without a WHERE clause removes every row",
Some(
"add a WHERE clause, or use TRUNCATE if you really want to wipe the table"
.into(),
),
ctx.stmt_span,
));
}
}
}
pub struct MixedAndOrNoParens;
const META_MIXED_AND_OR: RuleMeta = RuleMeta {
id: RuleId("SQLT0603"),
name: "mixed-and-or-no-parens",
category: Category::Correctness,
default_severity: Severity::Warning,
default_enabled: true,
summary: "An `OR` whose operand is an unparenthesized `AND` is a precedence trap.",
explanation: "`AND` binds tighter than `OR`, so `a OR b AND c` parses as `a OR (b AND c)`. \
Reviewers and reading tools commonly misread it. Parenthesize the AND-side \
explicitly to make precedence visible.",
};
impl Rule for MixedAndOrNoParens {
fn meta(&self) -> &'static RuleMeta {
&META_MIXED_AND_OR
}
fn check_expr(&self, expr: &Expr, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
if let Expr::BinaryOp {
op: BinaryOperator::Or,
left,
right,
} = expr
&& (is_unparen_and(left) || is_unparen_and(right))
{
out.push(diagnostic(
&META_MIXED_AND_OR,
ctx,
"mixed AND/OR without explicit parentheses",
Some("parenthesize the AND side to make precedence visible".into()),
expr_span(expr).unwrap_or(ctx.stmt_span),
));
}
}
}
pub struct OrderByPositional;
pub struct GroupByPositional;
const META_ORDER_POS: RuleMeta = RuleMeta {
id: RuleId("SQLT0604"),
name: "order-by-positional",
category: Category::Correctness,
default_severity: Severity::Info,
default_enabled: true,
summary: "`ORDER BY <number>` references a projection column by position.",
explanation: "Positional ORDER BY is brittle: any change to the SELECT list shifts the meaning. \
Refer to the column by name or alias instead.",
};
const META_GROUP_POS: RuleMeta = RuleMeta {
id: RuleId("SQLT0606"),
name: "group-by-positional",
category: Category::Correctness,
default_severity: Severity::Info,
default_enabled: true,
summary: "`GROUP BY <number>` references a projection column by position.",
explanation: "Positional GROUP BY is brittle: any change to the SELECT list shifts the meaning. \
Refer to the column by name or expression instead.",
};
impl Rule for OrderByPositional {
fn meta(&self) -> &'static RuleMeta {
&META_ORDER_POS
}
fn check_query(
&self,
query: &sqlparser::ast::Query,
_depth: usize,
ctx: &LintCtx,
out: &mut Vec<Diagnostic>,
) {
if let Some(OrderBy {
kind: OrderByKind::Expressions(exprs),
..
}) = &query.order_by
{
for OrderByExpr { expr, .. } in exprs {
if is_number_literal(expr) {
out.push(diagnostic(
&META_ORDER_POS,
ctx,
"ORDER BY references a projection column by position",
Some("use the column name or alias instead".into()),
expr_span(expr).unwrap_or(ctx.stmt_span),
));
}
}
}
}
}
impl Rule for GroupByPositional {
fn meta(&self) -> &'static RuleMeta {
&META_GROUP_POS
}
fn check_select(&self, select: &Select, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
for expr in exprs {
if is_number_literal(expr) {
out.push(diagnostic(
&META_GROUP_POS,
ctx,
"GROUP BY references a projection column by position",
Some("use the column name or expression instead".into()),
expr_span(expr).unwrap_or(ctx.stmt_span),
));
}
}
}
}
}
pub struct HavingWithoutGroupBy;
const META_HAVING_NO_GROUP: RuleMeta = RuleMeta {
id: RuleId("SQLT0605"),
name: "having-without-group-by",
category: Category::Correctness,
default_severity: Severity::Warning,
default_enabled: true,
summary: "`HAVING` without `GROUP BY` is almost always a mistake.",
explanation: "HAVING is meant to filter aggregated groups. Without GROUP BY there is exactly \
one implicit group, so HAVING usually means `WHERE`. Some dialects accept this; \
most readers will misread it.",
};
impl Rule for HavingWithoutGroupBy {
fn meta(&self) -> &'static RuleMeta {
&META_HAVING_NO_GROUP
}
fn check_select(&self, select: &Select, ctx: &LintCtx, out: &mut Vec<Diagnostic>) {
if select.having.is_none() {
return;
}
let no_group = matches!(
&select.group_by,
GroupByExpr::Expressions(v, _) if v.is_empty()
);
if no_group {
out.push(diagnostic(
&META_HAVING_NO_GROUP,
ctx,
"HAVING used without a GROUP BY clause",
Some("if you mean to filter rows, use WHERE; otherwise add a GROUP BY".into()),
ctx.stmt_span,
));
}
}
}
fn is_null_value(e: &Expr) -> bool {
matches!(e, Expr::Value(v) if matches!(v.value, Value::Null))
}
fn is_number_literal(e: &Expr) -> bool {
matches!(e, Expr::Value(v) if matches!(v.value, Value::Number(_, _)))
}
fn is_unparen_and(e: &Expr) -> bool {
matches!(
e,
Expr::BinaryOp {
op: BinaryOperator::And,
..
}
)
}
fn expr_span(e: &Expr) -> Option<sqlparser::tokenizer::Span> {
use sqlparser::ast::Spanned;
let s = e.span();
if s.start.line == 0 && s.start.column == 0 {
None
} else {
Some(s)
}
}
fn diagnostic(
meta: &'static RuleMeta,
ctx: &LintCtx,
msg: &str,
suggestion: Option<String>,
span: sqlparser::tokenizer::Span,
) -> Diagnostic {
Diagnostic {
rule: meta.id,
rule_name: meta.name,
category: meta.category,
severity: meta.default_severity,
message: msg.to_string(),
suggestion,
span,
stmt_index: ctx.stmt_index,
source_dialect: ctx.src,
target_dialect: ctx.dst,
}
}