Skip to main content

sqrust_rules/lint/
create_table_without_primary_key.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{ColumnOption, Statement, TableConstraint};
3
4pub struct CreateTableWithoutPrimaryKey;
5
6impl Rule for CreateTableWithoutPrimaryKey {
7    fn name(&self) -> &'static str {
8        "Lint/CreateTableWithoutPrimaryKey"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        // Skip files that failed to parse — AST may be incomplete.
13        if !ctx.parse_errors.is_empty() {
14            return Vec::new();
15        }
16
17        let mut diags = Vec::new();
18        let source = &ctx.source;
19        let source_upper = source.to_uppercase();
20
21        for stmt in &ctx.statements {
22            if let Statement::CreateTable(create_table) = stmt {
23                let has_pk = has_primary_key(create_table);
24                if !has_pk {
25                    let (line, col) =
26                        find_keyword_position(source, &source_upper, "CREATE");
27                    diags.push(Diagnostic {
28                        rule: self.name(),
29                        message: "CREATE TABLE has no PRIMARY KEY defined".to_string(),
30                        line,
31                        col,
32                    });
33                }
34            }
35        }
36
37        diags
38    }
39}
40
41/// Returns true if the CREATE TABLE statement has a PRIMARY KEY defined, either as a
42/// column-level constraint (`id INT PRIMARY KEY`) or a table-level constraint
43/// (`PRIMARY KEY (id)`).
44fn has_primary_key(create_table: &sqlparser::ast::CreateTable) -> bool {
45    // Check column-level PRIMARY KEY.
46    for col in &create_table.columns {
47        for option_def in &col.options {
48            if let ColumnOption::Unique { is_primary, .. } = &option_def.option {
49                if *is_primary {
50                    return true;
51                }
52            }
53        }
54    }
55
56    // Check table-level PRIMARY KEY constraint.
57    for constraint in &create_table.constraints {
58        if matches!(constraint, TableConstraint::PrimaryKey { .. }) {
59            return true;
60        }
61    }
62
63    false
64}
65
66/// Finds the 1-indexed (line, col) of the first occurrence of `keyword` (already uppercased)
67/// in `source_upper` that has word boundaries on both sides.
68/// Falls back to (1, 1) if not found.
69fn find_keyword_position(source: &str, source_upper: &str, keyword: &str) -> (usize, usize) {
70    let kw_len = keyword.len();
71    let bytes = source_upper.as_bytes();
72    let text_len = bytes.len();
73
74    let mut search_from = 0usize;
75    while search_from < text_len {
76        let Some(rel) = source_upper[search_from..].find(keyword) else {
77            break;
78        };
79        let abs = search_from + rel;
80
81        let before_ok = abs == 0
82            || {
83                let b = bytes[abs - 1];
84                !b.is_ascii_alphanumeric() && b != b'_'
85            };
86        let after = abs + kw_len;
87        let after_ok = after >= text_len
88            || {
89                let b = bytes[after];
90                !b.is_ascii_alphanumeric() && b != b'_'
91            };
92
93        if before_ok && after_ok {
94            return offset_to_line_col(source, abs);
95        }
96        search_from = abs + 1;
97    }
98
99    (1, 1)
100}
101
102/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
103fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
104    let before = &source[..offset];
105    let line = before.chars().filter(|&c| c == '\n').count() + 1;
106    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
107    (line, col)
108}