diesel-guard 0.10.0

Linter for dangerous Postgres migration patterns in Diesel and SQLx. Prevents downtime caused by unsafe schema changes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Detection for short integer types (SMALLINT, INT) used in primary keys.
//!
//! This check identifies primary key columns that use SMALLINT or INT/INTEGER data types,
//! which risk ID exhaustion. SMALLINT maxes out at ~32,767 records, and INT at ~2.1 billion.
//!
//! While 2.1 billion seems large, active applications can exhaust this faster than expected,
//! especially with high-frequency inserts, soft deletes, or partitioned data.
//!
//! Changing the type later requires an ALTER COLUMN TYPE operation that triggers a full
//! table rewrite with an ACCESS EXCLUSIVE lock, blocking all operations.
//!
//! SERIAL is equivalent to INT with a sequence, so SERIAL PRIMARY KEY is correctly
//! flagged by this check. Prefer BIGINT GENERATED BY DEFAULT AS IDENTITY instead.

use crate::checks::pg_helpers::{
    ColumnDef, ConstrType, Constraint, NodeEnum, alter_table_cmds, cmd_def_as_column_def,
    cmd_def_as_constraint, column_has_constraint, column_type_name, for_each_column_def,
    is_short_integer, range_var_name,
};
use crate::checks::{Check, Config, MigrationContext};
use crate::violation::Violation;

const CONSTR_PRIMARY: i32 = ConstrType::ConstrPrimary as i32;

pub struct ShortIntegerPrimaryKeyCheck;

impl Check for ShortIntegerPrimaryKeyCheck {
    fn check(&self, node: &NodeEnum, _config: &Config, _ctx: &MigrationContext) -> Vec<Violation> {
        let mut violations = vec![];

        // Inline PRIMARY KEY on column definitions
        // (for_each_column_def handles both CreateStmt and AlterTableStmt)
        violations.extend(
            for_each_column_def(node)
                .into_iter()
                .filter(|(_, col)| column_has_constraint(col, CONSTR_PRIMARY))
                .filter_map(|(table, col)| check_column_type(&table, col)),
        );

        // Separate PRIMARY KEY constraints referencing column defs by name
        match node {
            NodeEnum::CreateStmt(create) => {
                let table_name = create
                    .relation
                    .as_ref()
                    .map(range_var_name)
                    .unwrap_or_default();

                let col_defs: Vec<&ColumnDef> = create
                    .table_elts
                    .iter()
                    .filter_map(|n| match &n.node {
                        Some(NodeEnum::ColumnDef(col)) => Some(col.as_ref()),
                        _ => None,
                    })
                    .collect();

                for elt in &create.table_elts {
                    if let Some(NodeEnum::Constraint(c)) = &elt.node
                        && c.contype == CONSTR_PRIMARY
                    {
                        violations.extend(check_pk_key_columns(&table_name, c, &col_defs));
                    }
                }
            }
            NodeEnum::AlterTableStmt(_) => {
                if let Some((table_name, cmds)) = alter_table_cmds(node) {
                    let col_defs: Vec<&ColumnDef> = cmds
                        .iter()
                        .filter_map(|cmd| cmd_def_as_column_def(cmd))
                        .collect();

                    if !col_defs.is_empty() {
                        for cmd in &cmds {
                            if let Some(c) = cmd_def_as_constraint(cmd)
                                && c.contype == CONSTR_PRIMARY
                            {
                                violations.extend(check_pk_key_columns(&table_name, c, &col_defs));
                            }
                        }
                    }
                }
            }
            _ => {}
        }

        violations
    }
}

/// Look up each constraint key column by name and check its type.
fn check_pk_key_columns(
    table: &str,
    constraint: &Constraint,
    col_defs: &[&ColumnDef],
) -> Vec<Violation> {
    constraint
        .keys
        .iter()
        .filter_map(|key| {
            let name = match &key.node {
                Some(NodeEnum::String(s)) => &s.sval,
                _ => return None,
            };
            let col = col_defs.iter().find(|cd| cd.colname == *name)?;
            check_column_type(table, col)
        })
        .collect()
}

/// Check if a column's type is a short integer and return a violation if so.
fn check_column_type(table_name: &str, col: &ColumnDef) -> Option<Violation> {
    let type_name = column_type_name(col);
    if !is_short_integer(&type_name) {
        return None;
    }

    let (display_name, limit) = short_integer_info(&type_name)?;
    Some(create_violation(
        table_name,
        &col.colname,
        display_name,
        limit,
    ))
}

/// Map pg_query internal type names to display names and limits.
fn short_integer_info(type_name: &str) -> Option<(&'static str, &'static str)> {
    match type_name {
        "int2" | "smallserial" => Some(("SMALLINT", "~32,767")),
        "int4" | "serial" => Some(("INT", "~2.1 billion")),
        _ => None,
    }
}

/// Create a violation for a short integer primary key
fn create_violation(
    table_name: &str,
    column_name: &str,
    type_name: &str,
    limit: &str,
) -> Violation {
    Violation::new(
        "PRIMARY KEY with short integer type",
        format!(
            "Using {type_name} for primary key column '{column_name}' on table '{table_name}' risks ID exhaustion at {limit} records. \
            {type_name} can be quickly exhausted in production applications. \
            Changing the type later requires an ALTER COLUMN TYPE operation that triggers a full table rewrite with an \
            ACCESS EXCLUSIVE lock, blocking all operations. Duration depends on table size."
        ),
        format!(
            r"Use BIGINT for primary keys to avoid ID exhaustion:

Instead of:
   CREATE TABLE {table_name} ({column_name} {type_name} PRIMARY KEY);

Use:
   CREATE TABLE {table_name} ({column_name} BIGINT PRIMARY KEY);

BIGINT provides 8 bytes (range: -9.2 quintillion to 9.2 quintillion), which is effectively unlimited
for auto-incrementing IDs. The minimal storage overhead (4 extra bytes per row) is negligible.

For auto-incrementing keys, prefer identity columns:
   {column_name} BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

If you must support PostgreSQL 9.x, use BIGSERIAL as the legacy alternative:
   {column_name} BIGSERIAL PRIMARY KEY

Note: If this is an intentionally small table (e.g., lookup table with <100 entries),
use 'safety-assured' to bypass this check."
        ),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        assert_allows, assert_detects_n_violations_any_containing, assert_detects_violation,
        assert_detects_violation_containing,
    };

    // === CREATE TABLE with inline PRIMARY KEY ===

    #[test]
    fn test_detects_create_table_int_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_create_table_integer_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INTEGER PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_create_table_smallint_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id SMALLINT PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_create_table_int2_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT2 PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_create_table_int4_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT4 PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    // === CREATE TABLE with separate PRIMARY KEY constraint ===

    #[test]
    fn test_detects_create_table_separate_pk_constraint() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT, name TEXT, PRIMARY KEY (id));",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_composite_primary_key_with_int() {
        assert_detects_violation_containing!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE events (tenant_id BIGINT, id INT, PRIMARY KEY (tenant_id, id));",
            "PRIMARY KEY with short integer type",
            "id",
            "INT"
        );
    }

    #[test]
    fn test_detects_multiple_short_int_columns_in_composite_pk() {
        assert_detects_n_violations_any_containing!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE data (tenant_id INT, user_id SMALLINT, PRIMARY KEY (tenant_id, user_id));",
            2,
            "tenant_id",
            "user_id"
        );
    }

    // === ALTER TABLE ADD COLUMN ===

    #[test]
    fn test_detects_alter_add_column_int_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN id INT PRIMARY KEY;",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_alter_add_column_smallint_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN id SMALLINT PRIMARY KEY;",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_serial_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id SERIAL PRIMARY KEY);",
            "PRIMARY KEY with short integer type"
        );
    }

    // === Safe cases (should not trigger) ===

    #[test]
    fn test_allows_bigint_primary_key() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id BIGINT PRIMARY KEY);"
        );
    }

    #[test]
    fn test_allows_int8_primary_key() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT8 PRIMARY KEY);"
        );
    }

    #[test]
    fn test_allows_bigserial_primary_key() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id BIGSERIAL PRIMARY KEY);"
        );
    }

    #[test]
    fn test_allows_uuid_primary_key() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id UUID PRIMARY KEY);"
        );
    }

    #[test]
    fn test_allows_int_column_without_primary_key() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id BIGINT PRIMARY KEY, age INT);"
        );
    }

    #[test]
    fn test_allows_int_unique_not_primary() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id BIGINT PRIMARY KEY, code INT UNIQUE);"
        );
    }

    #[test]
    fn test_allows_composite_pk_all_bigint() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE events (tenant_id BIGINT, id BIGINT, PRIMARY KEY (tenant_id, id));"
        );
    }

    #[test]
    fn test_ignores_other_statements() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users DROP COLUMN age;"
        );
    }

    #[test]
    fn test_ignores_alter_add_column_without_pk() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN age INT;"
        );
    }

    // === ALTER TABLE ADD CONSTRAINT ===

    #[test]
    fn test_detects_alter_add_constraint_primary_key() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN id INT, ADD CONSTRAINT pk_users PRIMARY KEY (id);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_alter_add_constraint_smallint_pk() {
        assert_detects_violation!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN id SMALLINT, ADD CONSTRAINT pk_users PRIMARY KEY (id);",
            "PRIMARY KEY with short integer type"
        );
    }

    #[test]
    fn test_detects_alter_add_constraint_composite_pk_with_int() {
        assert_detects_violation_containing!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE events ADD COLUMN tenant_id BIGINT, ADD COLUMN id INT, ADD CONSTRAINT pk_events PRIMARY KEY (tenant_id, id);",
            "PRIMARY KEY with short integer type",
            "id",
            "INT"
        );
    }

    #[test]
    fn test_allows_alter_add_constraint_bigint_pk() {
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD COLUMN id BIGINT, ADD CONSTRAINT pk_users PRIMARY KEY (id);"
        );
    }

    #[test]
    fn test_ignores_alter_add_constraint_on_existing_column() {
        // Can't detect type when column already exists (not added in same statement)
        assert_allows!(
            ShortIntegerPrimaryKeyCheck,
            "ALTER TABLE users ADD CONSTRAINT pk_users PRIMARY KEY (id);"
        );
    }

    // === Exhaustion limit messages ===

    #[test]
    fn test_smallint_shows_correct_limit() {
        assert_detects_violation_containing!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id SMALLINT PRIMARY KEY);",
            "PRIMARY KEY with short integer type",
            "~32,767"
        );
    }

    #[test]
    fn test_int_shows_correct_limit() {
        assert_detects_violation_containing!(
            ShortIntegerPrimaryKeyCheck,
            "CREATE TABLE users (id INT PRIMARY KEY);",
            "PRIMARY KEY with short integer type",
            "~2.1 billion"
        );
    }
}