From d3a4f705909ed6192bdb7a3a5aa8db4d0dfa9987 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Wed, 1 Apr 2026 11:49:56 -0400 Subject: [PATCH 1/3] linter: scope cross-migration SET NOT NULL suppression to validate+drop pairs Previously, an external VALIDATE CONSTRAINT (without a matching ADD CONSTRAINT in the same file) blanket-marked the entire table as safe, suppressing SET NOT NULL warnings for all columns. This caused false negatives when the validated constraint was unrelated to the column being tightened. Now, only constraints that are both validated AND dropped in the same file are treated as NOT NULL helpers. Each validate+drop pair can suppress one SET NOT NULL violation on that table. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/rules/adding_not_null_field.rs | 100 +++++++++++++++--- ...ross_migration_unrelated_validate_err.snap | 10 ++ ..._cross_migration_validate_no_drop_err.snap | 10 ++ 3 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_unrelated_validate_err.snap create mode 100644 crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_validate_no_drop_err.snap diff --git a/crates/squawk_linter/src/rules/adding_not_null_field.rs b/crates/squawk_linter/src/rules/adding_not_null_field.rs index 38307f75..d34adade 100644 --- a/crates/squawk_linter/src/rules/adding_not_null_field.rs +++ b/crates/squawk_linter/src/rules/adding_not_null_field.rs @@ -1,7 +1,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use squawk_syntax::{ - Parse, SourceFile, + Parse, SourceFile, SyntaxNode, ast::{self, AstNode}, identifier::Identifier, }; @@ -51,10 +51,13 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) let mut not_null_constraints: FxHashMap = FxHashMap::default(); let mut validated_not_null_columns: FxHashSet = FxHashSet::default(); - // Tables where VALIDATE CONSTRAINT was seen without a matching ADD CONSTRAINT - // in the same file (cross-migration pattern). - let mut tables_with_external_validated_constraints: FxHashSet = - FxHashSet::default(); + + // Cross-migration pattern tracking: VALIDATE CONSTRAINT without a matching + // ADD CONSTRAINT in the same file. We require a corresponding DROP CONSTRAINT + // to treat it as a NOT NULL helper (validate+drop pairing). + let mut external_validates: FxHashSet<(Identifier, Identifier)> = FxHashSet::default(); + let mut dropped_constraints: FxHashSet<(Identifier, Identifier)> = FxHashSet::default(); + let mut deferred_violations: Vec<(Identifier, SyntaxNode)> = Vec::new(); for stmt in file.stmts() { if let ast::Stmt::AlterTable(alter_table) = stmt { @@ -97,13 +100,19 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) { validated_not_null_columns.insert(table_column.clone()); } else { - // Cross-migration pattern: the ADD CONSTRAINT ... NOT VALID - // was in a previous migration file. Track the table so that - // a subsequent SET NOT NULL is considered safe. - tables_with_external_validated_constraints.insert(table.clone()); + external_validates.insert((table.clone(), constraint_name)); } } } + // Track DROP CONSTRAINT for cross-migration pairing + ast::AlterTableAction::DropConstraint(drop_constraint) if is_pg12_plus => { + if let Some(constraint_name) = drop_constraint + .name_ref() + .map(|x| Identifier::new(&x.text())) + { + dropped_constraints.insert((table.clone(), constraint_name)); + } + } // Step 3: Check that we're altering a validated constraint ast::AlterTableAction::AlterColumn(alter_column) => { let Some(ast::AlterColumnOption::SetNotNull(option)) = @@ -120,9 +129,13 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) table: table.clone(), column, }; - if validated_not_null_columns.contains(&table_column) - || tables_with_external_validated_constraints.contains(&table) - { + if validated_not_null_columns.contains(&table_column) { + continue; + } + // Defer if there are external validates on this table — + // we need to see the full file before deciding. + if external_validates.iter().any(|(t, _)| *t == table) { + deferred_violations.push((table.clone(), option.syntax().clone())); continue; } } @@ -142,6 +155,32 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) } } } + + // Resolve deferred violations: each validate+drop pair on a table can + // suppress one SET NOT NULL violation. + let mut resolved_per_table: FxHashMap = FxHashMap::default(); + for (table, constraint_name) in &external_validates { + if dropped_constraints.contains(&(table.clone(), constraint_name.clone())) { + *resolved_per_table.entry(table.clone()).or_default() += 1; + } + } + + for (table, node) in &deferred_violations { + if let Some(count) = resolved_per_table.get_mut(table) { + if *count > 0 { + *count -= 1; + continue; + } + } + ctx.report( + Violation::for_node( + Rule::AddingNotNullableField, + "Setting a column `NOT NULL` blocks reads while the table is scanned.".into(), + node, + ) + .help("Make the field nullable and use a `CHECK` constraint instead."), + ); + } } #[cfg(test)] @@ -315,7 +354,8 @@ ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; } // Cross-migration pattern: ADD CONSTRAINT was in a previous migration file, - // so only VALIDATE CONSTRAINT + SET NOT NULL appear in this file. + // so only VALIDATE CONSTRAINT + SET NOT NULL + DROP CONSTRAINT appear in this file. + // The validate+drop pairing signals it was a NOT NULL helper constraint. #[test] fn pg16_cross_migration_validate_then_set_not_null_ok() { let sql = r#" @@ -355,6 +395,40 @@ ALTER TABLE foo DROP CONSTRAINT foo_bar_not_null; ); } + // Validating an unrelated constraint should NOT suppress SET NOT NULL warnings. + #[test] + fn pg12_cross_migration_unrelated_validate_err() { + let sql = r#" +ALTER TABLE foo VALIDATE CONSTRAINT some_other_check; +ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; + "#; + assert_snapshot!(lint_errors_with( + sql, + LinterSettings { + pg_version: "12".parse().expect("Invalid PostgreSQL version"), + ..Default::default() + }, + Rule::AddingNotNullableField + )); + } + + // Validate without a corresponding DROP is not the helper pattern. + #[test] + fn pg12_cross_migration_validate_no_drop_err() { + let sql = r#" +ALTER TABLE foo VALIDATE CONSTRAINT bar_not_null; +ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; + "#; + assert_snapshot!(lint_errors_with( + sql, + LinterSettings { + pg_version: "12".parse().expect("Invalid PostgreSQL version"), + ..Default::default() + }, + Rule::AddingNotNullableField + )); + } + #[test] fn pg11_cross_migration_validate_then_set_not_null_err() { // PostgreSQL 11 doesn't support using CHECK constraint to skip table scan diff --git a/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_unrelated_validate_err.snap b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_unrelated_validate_err.snap new file mode 100644 index 00000000..34146dc5 --- /dev/null +++ b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_unrelated_validate_err.snap @@ -0,0 +1,10 @@ +--- +source: crates/squawk_linter/src/rules/adding_not_null_field.rs +expression: "lint_errors_with(sql, LinterSettings\n{\n pg_version: \"12\".parse().expect(\"Invalid PostgreSQL version\"),\n ..Default::default()\n}, Rule::AddingNotNullableField)" +--- +warning[adding-not-nullable-field]: Setting a column `NOT NULL` blocks reads while the table is scanned. + ╭▸ +3 │ ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; + │ ━━━━━━━━━━━━ + │ + ╰ help: Make the field nullable and use a `CHECK` constraint instead. diff --git a/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_validate_no_drop_err.snap b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_validate_no_drop_err.snap new file mode 100644 index 00000000..34146dc5 --- /dev/null +++ b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_validate_no_drop_err.snap @@ -0,0 +1,10 @@ +--- +source: crates/squawk_linter/src/rules/adding_not_null_field.rs +expression: "lint_errors_with(sql, LinterSettings\n{\n pg_version: \"12\".parse().expect(\"Invalid PostgreSQL version\"),\n ..Default::default()\n}, Rule::AddingNotNullableField)" +--- +warning[adding-not-nullable-field]: Setting a column `NOT NULL` blocks reads while the table is scanned. + ╭▸ +3 │ ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; + │ ━━━━━━━━━━━━ + │ + ╰ help: Make the field nullable and use a `CHECK` constraint instead. From c6275781a493cafc0a101cc2019225ec765f794b Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Fri, 1 May 2026 10:32:14 -0500 Subject: [PATCH 2/3] linter: refactor SET NOT NULL validation tracking into a struct Bundle the related hash sets/maps used for cross-migration NOT NULL suppression behind a NotNullValidation struct with named methods (record_validate, is_column_validated, has_external_validate_for, etc.) so callers don't have to know about the internal representation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/rules/adding_not_null_field.rs | 96 ++++++++++++------- 1 file changed, 63 insertions(+), 33 deletions(-) diff --git a/crates/squawk_linter/src/rules/adding_not_null_field.rs b/crates/squawk_linter/src/rules/adding_not_null_field.rs index d34adade..4cd7a081 100644 --- a/crates/squawk_linter/src/rules/adding_not_null_field.rs +++ b/crates/squawk_linter/src/rules/adding_not_null_field.rs @@ -14,6 +14,57 @@ struct TableColumn { column: Identifier, } +#[derive(Default)] +struct NotNullValidation { + not_null_constraints: FxHashMap, + validated_columns: FxHashSet, + external_validates: FxHashSet<(Identifier, Identifier)>, + dropped_constraints: FxHashSet<(Identifier, Identifier)>, +} + +impl NotNullValidation { + fn record_not_null_constraint(&mut self, name: Identifier, table_column: TableColumn) { + self.not_null_constraints.insert(name, table_column); + } + + fn record_validate(&mut self, table: Identifier, constraint: Identifier) { + if let Some(table_column) = self.not_null_constraints.get(&constraint) + && table_column.table == table + { + self.validated_columns.insert(table_column.clone()); + } else { + self.external_validates.insert((table, constraint)); + } + } + + fn record_drop(&mut self, table: Identifier, constraint: Identifier) { + self.dropped_constraints.insert((table, constraint)); + } + + fn is_column_validated(&self, table_column: &TableColumn) -> bool { + self.validated_columns.contains(table_column) + } + + fn has_external_validate_for(&self, table: &Identifier) -> bool { + self.external_validates.iter().any(|(t, _)| t == table) + } + + // Each external validate that is paired with a matching DROP CONSTRAINT in + // the same file can suppress one SET NOT NULL violation on that table. + fn resolved_per_table(&self) -> FxHashMap { + let mut counts: FxHashMap = FxHashMap::default(); + for (table, constraint) in &self.external_validates { + if self + .dropped_constraints + .contains(&(table.clone(), constraint.clone())) + { + *counts.entry(table.clone()).or_default() += 1; + } + } + counts + } +} + fn is_not_null_check(expr: &ast::Expr) -> Option { let ast::Expr::BinExpr(bin_expr) = expr else { return None; @@ -49,14 +100,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) let is_pg12_plus = ctx.settings.pg_version >= Version::new(12, None, None); - let mut not_null_constraints: FxHashMap = FxHashMap::default(); - let mut validated_not_null_columns: FxHashSet = FxHashSet::default(); - - // Cross-migration pattern tracking: VALIDATE CONSTRAINT without a matching - // ADD CONSTRAINT in the same file. We require a corresponding DROP CONSTRAINT - // to treat it as a NOT NULL helper (validate+drop pairing). - let mut external_validates: FxHashSet<(Identifier, Identifier)> = FxHashSet::default(); - let mut dropped_constraints: FxHashSet<(Identifier, Identifier)> = FxHashSet::default(); + let mut validation = NotNullValidation::default(); let mut deferred_violations: Vec<(Identifier, SyntaxNode)> = Vec::new(); for stmt in file.stmts() { @@ -78,7 +122,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) && let Some(expr) = check.expr() && let Some(column) = is_not_null_check(&expr) { - not_null_constraints.insert( + validation.record_not_null_constraint( Identifier::new(&constraint_name.text()), TableColumn { table: table.clone(), @@ -95,13 +139,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) .name_ref() .map(|x| Identifier::new(&x.text())) { - if let Some(table_column) = not_null_constraints.get(&constraint_name) - && table_column.table == table - { - validated_not_null_columns.insert(table_column.clone()); - } else { - external_validates.insert((table.clone(), constraint_name)); - } + validation.record_validate(table.clone(), constraint_name); } } // Track DROP CONSTRAINT for cross-migration pairing @@ -110,7 +148,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) .name_ref() .map(|x| Identifier::new(&x.text())) { - dropped_constraints.insert((table.clone(), constraint_name)); + validation.record_drop(table.clone(), constraint_name); } } // Step 3: Check that we're altering a validated constraint @@ -129,12 +167,12 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) table: table.clone(), column, }; - if validated_not_null_columns.contains(&table_column) { + if validation.is_column_validated(&table_column) { continue; } // Defer if there are external validates on this table — // we need to see the full file before deciding. - if external_validates.iter().any(|(t, _)| *t == table) { + if validation.has_external_validate_for(&table) { deferred_violations.push((table.clone(), option.syntax().clone())); continue; } @@ -156,21 +194,13 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) } } - // Resolve deferred violations: each validate+drop pair on a table can - // suppress one SET NOT NULL violation. - let mut resolved_per_table: FxHashMap = FxHashMap::default(); - for (table, constraint_name) in &external_validates { - if dropped_constraints.contains(&(table.clone(), constraint_name.clone())) { - *resolved_per_table.entry(table.clone()).or_default() += 1; - } - } - + let mut resolved_per_table = validation.resolved_per_table(); for (table, node) in &deferred_violations { - if let Some(count) = resolved_per_table.get_mut(table) { - if *count > 0 { - *count -= 1; - continue; - } + if let Some(count) = resolved_per_table.get_mut(table) + && *count > 0 + { + *count -= 1; + continue; } ctx.report( Violation::for_node( From 8976d6d031b37c490fa2006885a8c192e8997093 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Fri, 1 May 2026 11:13:05 -0500 Subject: [PATCH 3/3] wip --- .../src/rules/adding_not_null_field.rs | 113 ++++++++++++++++-- ...ed_constraint_matches_column_only_err.snap | 10 ++ crates/squawk_syntax/src/identifier.rs | 4 + docs/docs/adding-not-nullable-field.md | 8 ++ 4 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_named_constraint_matches_column_only_err.snap diff --git a/crates/squawk_linter/src/rules/adding_not_null_field.rs b/crates/squawk_linter/src/rules/adding_not_null_field.rs index 4cd7a081..b3a624aa 100644 --- a/crates/squawk_linter/src/rules/adding_not_null_field.rs +++ b/crates/squawk_linter/src/rules/adding_not_null_field.rs @@ -49,22 +49,68 @@ impl NotNullValidation { self.external_validates.iter().any(|(t, _)| t == table) } - // Each external validate that is paired with a matching DROP CONSTRAINT in - // the same file can suppress one SET NOT NULL violation on that table. - fn resolved_per_table(&self) -> FxHashMap { - let mut counts: FxHashMap = FxHashMap::default(); + // External validate+drop pairs suppress SET NOT NULL warnings. When the + // constraint name follows the documented `_not_null` / + // `__not_null` convention we infer the exact column; + // otherwise we fall back to a per-table count and pair by source order. + fn resolved_pairs(&self) -> ResolvedPairs { + let mut pairs = ResolvedPairs::default(); for (table, constraint) in &self.external_validates { - if self + if !self .dropped_constraints .contains(&(table.clone(), constraint.clone())) { - *counts.entry(table.clone()).or_default() += 1; + continue; + } + match infer_column_from_constraint_name(table, constraint) { + Some(column) => { + pairs.precise.insert(TableColumn { + table: table.clone(), + column, + }); + } + None => { + *pairs.generic_per_table.entry(table.clone()).or_default() += 1; + } } } - counts + pairs } } +#[derive(Default)] +struct ResolvedPairs { + precise: FxHashSet, + generic_per_table: FxHashMap, +} + +// Infer the column covered by a NOT NULL helper constraint from its name. +// Recognized forms (case-folded by Identifier): `_not_null` and +// `
__not_null`. Returns None for names that don't fit, in +// which case the caller falls back to a generic per-table count. +fn infer_column_from_constraint_name( + table: &Identifier, + constraint: &Identifier, +) -> Option { + let stem = constraint.as_str().strip_suffix("_not_null")?; + if stem.is_empty() { + return None; + } + let table_prefix = format!("{}_", table.as_str()); + if let Some(column) = stem.strip_prefix(&table_prefix) { + if column.is_empty() { + return None; + } + return Some(Identifier::new(column)); + } + // Bare `_not_null`: ambiguous when stem == table name (could be a + // table-level helper rather than a column-level one), so fall back. + if stem == table.as_str() { + return None; + } + Some(Identifier::new(stem)) +} + fn is_not_null_check(expr: &ast::Expr) -> Option { let ast::Expr::BinExpr(bin_expr) = expr else { return None; @@ -101,7 +147,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) let is_pg12_plus = ctx.settings.pg_version >= Version::new(12, None, None); let mut validation = NotNullValidation::default(); - let mut deferred_violations: Vec<(Identifier, SyntaxNode)> = Vec::new(); + let mut deferred_violations: Vec<(TableColumn, SyntaxNode)> = Vec::new(); for stmt in file.stmts() { if let ast::Stmt::AlterTable(alter_table) = stmt { @@ -173,7 +219,7 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) // Defer if there are external validates on this table — // we need to see the full file before deciding. if validation.has_external_validate_for(&table) { - deferred_violations.push((table.clone(), option.syntax().clone())); + deferred_violations.push((table_column, option.syntax().clone())); continue; } } @@ -194,9 +240,13 @@ pub(crate) fn adding_not_null_field(ctx: &mut Linter, parse: &Parse) } } - let mut resolved_per_table = validation.resolved_per_table(); - for (table, node) in &deferred_violations { - if let Some(count) = resolved_per_table.get_mut(table) + let resolved = validation.resolved_pairs(); + let mut generic_per_table = resolved.generic_per_table; + for (table_column, node) in &deferred_violations { + if resolved.precise.contains(table_column) { + continue; + } + if let Some(count) = generic_per_table.get_mut(&table_column.table) && *count > 0 { *count -= 1; @@ -442,6 +492,45 @@ ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; )); } + // When the constraint name follows the `_not_null` convention, the + // pair only suppresses the matching column — other SET NOT NULL statements + // on the same table still warn regardless of source order. + #[test] + fn pg12_cross_migration_named_constraint_matches_column_only_err() { + let sql = r#" +ALTER TABLE foo VALIDATE CONSTRAINT bar_not_null; +ALTER TABLE foo ALTER COLUMN baz SET NOT NULL; +ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; +ALTER TABLE foo DROP CONSTRAINT bar_not_null; + "#; + assert_snapshot!(lint_errors_with( + sql, + LinterSettings { + pg_version: "12".parse().expect("Invalid PostgreSQL version"), + ..Default::default() + }, + Rule::AddingNotNullableField + )); + } + + // `
__not_null` is also recognized by the inference. + #[test] + fn pg12_cross_migration_table_prefixed_constraint_ok() { + let sql = r#" +ALTER TABLE foo VALIDATE CONSTRAINT foo_bar_not_null; +ALTER TABLE foo ALTER COLUMN bar SET NOT NULL; +ALTER TABLE foo DROP CONSTRAINT foo_bar_not_null; + "#; + lint_ok_with( + sql, + LinterSettings { + pg_version: "12".parse().expect("Invalid PostgreSQL version"), + ..Default::default() + }, + Rule::AddingNotNullableField, + ); + } + // Validate without a corresponding DROP is not the helper pattern. #[test] fn pg12_cross_migration_validate_no_drop_err() { diff --git a/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_named_constraint_matches_column_only_err.snap b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_named_constraint_matches_column_only_err.snap new file mode 100644 index 00000000..eba95c7f --- /dev/null +++ b/crates/squawk_linter/src/rules/snapshots/squawk_linter__rules__adding_not_null_field__test__pg12_cross_migration_named_constraint_matches_column_only_err.snap @@ -0,0 +1,10 @@ +--- +source: crates/squawk_linter/src/rules/adding_not_null_field.rs +expression: "lint_errors_with(sql, LinterSettings\n{\n pg_version: \"12\".parse().expect(\"Invalid PostgreSQL version\"),\n ..Default::default()\n}, Rule::AddingNotNullableField)" +--- +warning[adding-not-nullable-field]: Setting a column `NOT NULL` blocks reads while the table is scanned. + ╭▸ +3 │ ALTER TABLE foo ALTER COLUMN baz SET NOT NULL; + │ ━━━━━━━━━━━━ + │ + ╰ help: Make the field nullable and use a `CHECK` constraint instead. diff --git a/crates/squawk_syntax/src/identifier.rs b/crates/squawk_syntax/src/identifier.rs index 0c3cd6fa..5c0a4997 100644 --- a/crates/squawk_syntax/src/identifier.rs +++ b/crates/squawk_syntax/src/identifier.rs @@ -15,6 +15,10 @@ impl Identifier { }; Identifier(normalized) } + + pub fn as_str(&self) -> &str { + &self.0 + } } #[cfg(test)] diff --git a/docs/docs/adding-not-nullable-field.md b/docs/docs/adding-not-nullable-field.md index b9cf8b4e..ab76e4c8 100644 --- a/docs/docs/adding-not-nullable-field.md +++ b/docs/docs/adding-not-nullable-field.md @@ -46,6 +46,14 @@ For each step, note that: See ["How not valid constraints work"](constraint-missing-not-valid.md#how-not-valid-validate-works) for more information on adding constraints as `NOT VALID`. +### cross-migration suppression and constraint naming + +When the `ADD CONSTRAINT ... NOT VALID` lives in an earlier migration file, the rule looks for a `VALIDATE CONSTRAINT` paired with a `DROP CONSTRAINT` of the same name in the migration that runs `SET NOT NULL` to recognize the safe pattern. + +To get column-precise suppression (so an unrelated `SET NOT NULL` on the same table still warns), name the helper constraint `_not_null` or `
__not_null` — for example `view_count_not_null` or `recipe_view_count_not_null`. With those names, the rule infers the exact column from the constraint name. + +If you use a different naming scheme, the rule falls back to a per-table count: each validate+drop pair on a table can suppress one `SET NOT NULL` on that table, paired in source order. + ## solution for alembic and sqlalchemy