Skip to content

Commit 360e3ee

Browse files
authored
Ignore postgres version in adding-not-nullable-field (#412)
### Summary Hey there, I noticed in #301 that the logic for handling adding new fields as null was split out from [adding-not-nullable-field](https://squawkhq.com/docs/adding-not-nullable-field) into [adding-required-field](https://squawkhq.com/docs/adding-required-field). The logic for performing the pg version check wasn't removed however, so this causes the rule to fail to detect changing existing columns to be not null. All the logic for checking not null for adding cols was extracted to `adding-required-field` or covered by `adding-required-field`, so I think we can remove the version check altogether. ### Examples #301 has nice examples showing how the other rules cover the adding column with not null. I've included the part below to show what changes with this PR. Previously with --pg-version >=12: ```SQL ALTER TABLE foo ALTER COLUMN d SET NOT NULL; -- no warning, ``` This PR with --pg-version >=12: ```SQL ALTER TABLE foo ALTER COLUMN d SET NOT NULL; -- triggers adding-not-nullable-field ``` ### Documentation I updated `adding-not-nullable-field` to explicitly mention that it no longer covers adding cols, and added to the problem+solution sections as well. I think ideally it'd be best to maybe re-name this rule to clarify the fact that it only covers modifying existing cols now, but that may be hard to do --- Please let me know if this change may not be the best approach, or if there are any concerns!
1 parent d84da4f commit 360e3ee

File tree

3 files changed

+56
-91
lines changed

3 files changed

+56
-91
lines changed

docs/docs/adding-field-with-default.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ title: adding-field-with-default
77

88
Adding a field with a default can cause table rewrites, which will take an [`ACCESS EXCLUSIVE`](https://www.postgresql.org/docs/10/sql-altertable.html#SQL-ALTERTABLE-NOTES) lock on the table, blocking reads / writes while the statement is running.
99

10+
:::note Postgres Version
11+
1012
In Postgres version 11 and later, adding a field with a non-`VOLATILE` `DEFAULT` will not require a table rewrite. Adding a field with a [`VOLATILE` `DEFAULT` will cause a table rewrite](https://www.postgresql.org/docs/14/sql-altertable.html#SQL-ALTERTABLE-NOTES).
13+
:::
14+
1115

1216
## solutions
1317

docs/docs/adding-not-nullable-field.md

Lines changed: 45 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,16 @@ title: adding-not-nullable-field
55

66
Use a check constraint instead of setting a column as `NOT NULL`.
77

8-
:::note Postgres Version
9-
10-
In Postgres versions 11 of later, adding a non-null column with a default will complete without a table scan.
11-
:::
12-
138
## problem
149

15-
Adding a column as `NOT NULL` requires a table scan and the `ALTER TABLE` requires
16-
an `ACCESS EXCLUSIVE` lock. Reads and writes will be disabled while this statement is running.
17-
18-
## solutions
19-
20-
### adding a non-nullable column
21-
22-
Add a column as nullable and use a check constraint to verify integrity. The check constraint should be added as `NOT NULL` and then validated.
23-
24-
Instead of:
25-
26-
```sql
27-
ALTER TABLE "recipe" ADD COLUMN "view_count" integer DEFAULT 10 NOT NULL;
28-
```
10+
Adding a column as `NOT NULL` is no longer covered by this rule. See ["adding-required-field (set a non-volatile default)"](adding-required-field.md#set-a-non-volatile-default) for more information on how to add new columns with `NOT NULL`.
2911

30-
Use:
3112

32-
```sql
33-
ALTER TABLE "recipe" ADD COLUMN "view_count" integer DEFAULT 10;
34-
ALTER TABLE "recipe" ADD CONSTRAINT view_count_not_null
35-
CHECK ("view_count" IS NOT NULL) NOT VALID;
36-
-- backfill column so it's not null
37-
ALTER TABLE "recipe" VALIDATE CONSTRAINT view_count_not_null;
38-
```
13+
Modifying a column to be `NOT NULL` may fail if the column contains records with a `NULL` value, requiring a full table scan to check before executing. Old application code may also try to write `NULL` values to the table.
3914

40-
Add the column as nullable, add a check constraint as `NOT VALID` to verify new rows and updates are, backfill the column so it no longer contains null values, validate the constraint to verify existing rows are valid.
15+
`ALTER TABLE` also requires an `ACCESS EXCLUSIVE` lock which will disable reads and writes while this statement is running.
4116

42-
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`.
17+
## solutions
4318

4419
### setting an existing column as non-nullable
4520

@@ -54,11 +29,20 @@ Use:
5429
```sql
5530
ALTER TABLE "recipe" ADD CONSTRAINT view_count_not_null
5631
CHECK ("view_count" IS NOT NULL) NOT VALID;
57-
-- backfill column so it's not null
32+
33+
-- Backfill column so it's not null
34+
-- Update ...
35+
5836
ALTER TABLE "recipe" VALIDATE CONSTRAINT view_count_not_null;
59-
```
6037

61-
Add a check constraint as `NOT VALID` to verify new rows and updates are, backfill the column so it no longer contains null values, validate the constraint to verify existing rows are valid.
38+
-- If pg version >= 12, set not null without doing a table scan
39+
ALTER TABLE table_name ALTER COLUMN column_name SET NOT NULL;
40+
```
41+
For each step, note that:
42+
1. Adding the constraint acquires an `ACCESS EXCLUSIVE` lock, but this is done extremely quickly.
43+
2. You MUST backfill the column before validating the constraint
44+
3. Validating the constraint does require a full table scan, but only acquires a [`SHARE UPDATE EXCLUSIVE`](https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-VALIDATE-CONSTRAINT) lock which allows for normal operations to continue.
45+
4. If using postgres version 12 or later, it forgoes the full table scan by checking the existing constraint.
6246

6347
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`.
6448

@@ -68,56 +52,61 @@ See ["How not valid constraints work"](constraint-missing-not-valid.md#how-not-v
6852
Instead of:
6953

7054
```python
71-
# models.py
55+
# migrations/*.py
56+
from alembic import op
7257
import sqlalchemy as sa
7358

74-
class Recipe(BaseModel):
75-
view_count = sa.Column(sa.BigInteger, default=10, nullable=False)
76-
```
77-
78-
Use:
79-
80-
```python
81-
# models.py
82-
import sqlalchemy as sa
59+
def schema_upgrades():
60+
op.alter_column(
61+
"recipe",
62+
"view_count",
63+
existing_type=sa.BigInteger(),
64+
nullable=False,
65+
)
8366

84-
class Recipe(BaseModel):
85-
__table_args__ = (
86-
sa.CheckConstraint(
87-
"view_count IS NOT NULL",
88-
name="view_count_not_null",
89-
)
67+
def schema_downgrades():
68+
op.alter_column(
69+
"recipe",
70+
"view_count",
71+
existing_type=sa.BigInteger(),
72+
nullable=True,
9073
)
91-
view_count = sa.Column(sa.BigInteger, default=10, nullable=True)
9274
```
9375

94-
Create Alembic migration manually, because Alembic not creates migration for constraints automatically. See the related [GitHub Issue here](https://github.com/sqlalchemy/alembic/issues/508).
95-
9676
```python
9777
# migrations/*.py
9878
from alembic import op
9979
import sqlalchemy as sa
10080

10181
def schema_upgrades():
102-
op.add_column(
103-
"recipe",
104-
sa.Column("view_count", sa.BigInteger(), nullable=True),
105-
)
10682
op.create_check_constraint(
10783
constraint_name="view_count_not_null",
10884
table_name="recipe",
10985
condition="view_count IS NOT NULL",
11086
postgresql_not_valid=True,
11187
)
88+
# Backfill existing rows to get rid of any NULL values. You have have to split
89+
# this into two migrations
11290
op.execute(
11391
sa.text("ALTER TABLE recipe VALIDATE CONSTRAINT view_count_not_null"),
11492
)
93+
op.alter_column( # only include this step on pg version >= 12
94+
"recipe",
95+
"view_count",
96+
existing_type=sa.BigInteger(),
97+
nullable=False,
98+
)
11599

116100

117101
def schema_downgrades():
102+
op.alter_column( # only include this step on pg version >= 12
103+
"recipe",
104+
"view_count",
105+
existing_type=sa.BigInteger(),
106+
nullable=True,
107+
)
118108
op.drop_constraint(
119109
constraint_name="view_count_not_null",
120110
table_name="recipe",
121111
)
122-
op.drop_column("recipe", "view_count")
123112
```

linter/src/rules/adding_not_null_field.rs

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,10 @@ use squawk_parser::ast::{AlterTableCmds, AlterTableType, RawStmt, Stmt};
77
#[must_use]
88
pub fn adding_not_nullable_field(
99
tree: &[RawStmt],
10-
pg_version: Option<Version>,
10+
_pg_version: Option<Version>,
1111
_assume_in_transaction: bool,
1212
) -> Vec<RuleViolation> {
1313
let mut errs = vec![];
14-
if let Some(pg_version) = pg_version {
15-
let pg_11 = Version::new(11, Some(0), Some(0));
16-
if pg_version >= pg_11 {
17-
return errs;
18-
}
19-
}
2014

2115
for raw_stmt in tree {
2216
match &raw_stmt.stmt {
@@ -42,32 +36,23 @@ pub fn adding_not_nullable_field(
4236

4337
#[cfg(test)]
4438
mod test_rules {
45-
use std::str::FromStr;
46-
4739
use crate::{
4840
check_sql_with_rule,
49-
versions::Version,
5041
violations::{RuleViolation, RuleViolationKind},
5142
};
5243

53-
fn lint_sql(sql: &str, pg_version: Option<Version>) -> Vec<RuleViolation> {
54-
check_sql_with_rule(
55-
sql,
56-
&RuleViolationKind::AddingNotNullableField,
57-
pg_version,
58-
false,
59-
)
60-
.unwrap()
44+
fn lint_sql(sql: &str) -> Vec<RuleViolation> {
45+
check_sql_with_rule(sql, &RuleViolationKind::AddingNotNullableField, None, false).unwrap()
6146
}
6247

6348
use insta::assert_debug_snapshot;
6449

6550
#[test]
6651
fn set_not_null() {
67-
let sql = r#"
52+
let bad_sql = r#"
6853
ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL;
6954
"#;
70-
assert_debug_snapshot!(lint_sql(sql, None));
55+
assert_debug_snapshot!(lint_sql(bad_sql));
7156
}
7257

7358
#[test]
@@ -80,7 +65,7 @@ ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10 NOT NULL;
8065
ALTER TABLE "core_recipe" ALTER COLUMN "foo" DROP DEFAULT;
8166
COMMIT;
8267
"#;
83-
assert_debug_snapshot!(lint_sql(ok_sql, None));
68+
assert_debug_snapshot!(lint_sql(ok_sql));
8469
}
8570

8671
#[test]
@@ -89,19 +74,6 @@ COMMIT;
8974
-- This won't work if the table is populated, but that error is caught by adding-required-field.
9075
ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL;
9176
"#;
92-
assert_debug_snapshot!(lint_sql(ok_sql, None));
93-
}
94-
95-
#[test]
96-
fn adding_field_that_is_not_nullable_in_version_11() {
97-
let ok_sql = r#"
98-
BEGIN;
99-
--
100-
-- Add field foo to recipe
101-
--
102-
ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL DEFAULT 10;
103-
COMMIT;
104-
"#;
105-
assert_debug_snapshot!(lint_sql(ok_sql, Some(Version::from_str("11.0.0").unwrap()),));
77+
assert_debug_snapshot!(lint_sql(ok_sql));
10678
}
10779
}

0 commit comments

Comments
 (0)