From 940f74deb3b5610420f207a213e46b438ebff63a Mon Sep 17 00:00:00 2001 From: Vladyslav Fomenko Date: Thu, 11 Sep 2025 09:55:11 +0300 Subject: [PATCH] Add primary keys requirement rule --- docs/classification.md | 80 +++++++++++++++++++++++++++++++- migration_lint/sql/rules.py | 15 +++++- pyproject.toml | 2 +- tests/test_classify_statement.py | 45 +++++++++++++++++- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/docs/classification.md b/docs/classification.md index 5564a5a..7eb4689 100644 --- a/docs/classification.md +++ b/docs/classification.md @@ -125,9 +125,87 @@ Backward-compatible migration > **WARNING**: If there are foreign keys, table creation requires > `ShareRowExclusiveLock` on the child tables, so use `lock_timeout` > if the table to create contains foreign keys. `ADD FOREIGN KEY ... NOT VALID` -> does require the same lock, so it doesn’t make much sense +> does require the same lock, so it doesn't make much sense > to create foreign keys separately. +#### Primary Key Requirement + +**All newly created tables must have a primary key.** Tables without primary keys +are classified as **RESTRICTED** operations and will cause the linter to fail. + +**Valid approaches:** + +**Column-level primary key:** + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT +); +``` + +**Table-level primary key:** + +```sql +CREATE TABLE users ( + id INTEGER, + name TEXT, + PRIMARY KEY (id) +); +``` + +**Named constraint:** + +```sql +CREATE TABLE users ( + id INTEGER, + name TEXT, + CONSTRAINT pk_users PRIMARY KEY (id) +); +``` + +**Composite primary key:** + +```sql +CREATE TABLE user_roles ( + user_id INTEGER, + role_id INTEGER, + PRIMARY KEY (user_id, role_id) +); +``` + +**UUID primary key:** + +```sql +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +**Why primary keys are required:** + +* Enables logical replication +* Improves query performance and indexing +* Provides row uniqueness guarantees +* Required for many database tools and ORMs +* Facilitates data consistency and referential integrity + +**Exception cases:** + +If you have a legitimate use case for a table without a primary key (such as +temporary tables, log tables, or staging tables), you can ignore this specific +migration using the annotation: + +```sql +-- migration-lint:ignore +CREATE TABLE temp_data_load ( + raw_data TEXT, + imported_at TIMESTAMPTZ DEFAULT NOW() +); +``` + ### Drop Table Backward-incompatible migration diff --git a/migration_lint/sql/rules.py b/migration_lint/sql/rules.py index f5363c1..ff07e03 100644 --- a/migration_lint/sql/rules.py +++ b/migration_lint/sql/rules.py @@ -76,7 +76,13 @@ ), SegmentLocator(type="create_sequence_statement"), SegmentLocator(type="alter_sequence_statement"), - SegmentLocator(type="create_table_statement"), + SegmentLocator( + type="create_table_statement", + children=[ + KeywordLocator(raw="PRIMARY"), + KeywordLocator(raw="KEY"), + ], + ), SegmentLocator( type="alter_table_statement", children=[ @@ -358,6 +364,13 @@ KeywordLocator(raw="IDENTITY"), ], ), + SegmentLocator( + type="create_table_statement", + children=[ + KeywordLocator(raw="PRIMARY", inverted=True), + KeywordLocator(raw="KEY", inverted=True), + ], + ), ] DATA_MIGRATION_OPERATIONS = [ diff --git a/pyproject.toml b/pyproject.toml index 11d6138..237cb96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "migration-lint" -version = "0.2.12" +version = "0.2.13" description = "Tool for lint operations in DB migrations SQL" authors = ["Alexey Nikitenko "] readme = "README.md" diff --git a/tests/test_classify_statement.py b/tests/test_classify_statement.py index 277f3a6..61accaa 100644 --- a/tests/test_classify_statement.py +++ b/tests/test_classify_statement.py @@ -73,11 +73,13 @@ StatementType.BACKWARD_COMPATIBLE, ), ( - "ALTER TABLE t_name ADD CONSTRAINT name FOREIGN KEY (c_name) REFERENCES some_table (id);", + "ALTER TABLE t_name ADD CONSTRAINT name FOREIGN KEY (c_name) " + "REFERENCES some_table (id);", StatementType.RESTRICTED, ), ( - "ALTER TABLE t_name ADD CONSTRAINT name FOREIGN KEY (c_name) REFERENCES some_table (id) NOT VALID;", + "ALTER TABLE t_name ADD CONSTRAINT name FOREIGN KEY (c_name) " + "REFERENCES some_table (id) NOT VALID;", StatementType.BACKWARD_COMPATIBLE, ), ("UPDATE t_name SET col=0", StatementType.DATA_MIGRATION), @@ -148,6 +150,45 @@ "ALTER TABLE t_name ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY", StatementType.BACKWARD_COMPATIBLE, ), + # CREATE TABLE PRIMARY KEY tests + ( + "CREATE TABLE users (id integer, name text);", + StatementType.RESTRICTED, + ), + ( + "CREATE TABLE users (id integer PRIMARY KEY, name text);", + StatementType.BACKWARD_COMPATIBLE, + ), + ( + "CREATE TABLE users (id integer, name text, PRIMARY KEY (id));", + StatementType.BACKWARD_COMPATIBLE, + ), + ( + "CREATE TABLE users (id serial PRIMARY KEY, name text);", + StatementType.BACKWARD_COMPATIBLE, + ), + ( + "CREATE TABLE users (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), " + "name text);", + StatementType.BACKWARD_COMPATIBLE, + ), + ( + "CREATE TABLE users (id integer, name text, " + "CONSTRAINT pk_users PRIMARY KEY (id));", + StatementType.BACKWARD_COMPATIBLE, + ), + ( + "CREATE TABLE users (id integer NOT NULL, name text UNIQUE);", + StatementType.RESTRICTED, + ), + ( + "CREATE TABLE log_entries (timestamp timestamptz, message text);", + StatementType.RESTRICTED, + ), + ( + "CREATE TABLE users (primary_email text, secondary_email text);", + StatementType.RESTRICTED, + ), ], ) def test_classify_migration(statement: str, expected_type: StatementType):