From 2f061b9a220718a64de466e88882a642193580cd Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 10 Sep 2025 08:16:46 +0200 Subject: [PATCH 01/23] chore: prepare claude loop --- .gitignore | 2 ++ PLAN.md | 42 ++++++++++++++++++++++++++++++++++++++++++ justfile | 3 +++ 3 files changed, 47 insertions(+) create mode 100644 PLAN.md diff --git a/.gitignore b/.gitignore index 34b9a8e48..1085c15ed 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ node_modules/ **/dist/ .claude-session-id + +squawk/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..e0699df61 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,42 @@ +The goal is to port all missing rules from Squawk to our analyser. + +Our analyser lives in the `pgt_analyser` crate. There is a `CONTRIBUTING.md` guide in that crate which explains how to add new rules. Please also read existing rules to see how it all works. + +Then, I want you to check the rules in the squawk project which I copied here for convenience. The rules are in `squawk/linter/src/rules`. The implementation should be very similar to what we have, and porting them straightforward. Here a few things to watch out for though: + +- although both libraries are using `libpg_query` to parse the SQL, the bindings can be different. Ours is in the `pgt_query` crate of you need a reference. The `protobuf.rs` file contains the full thing. +- the context for each rule is different, but you can get the same information out of it: +```rust +pub struct RuleContext<'a, R: Rule> { + // the ast of the target statement + stmt: &'a pgt_query::NodeEnum, + // options for that specific rule + options: &'a R::Options, + // the schema cache - also includes the postgres version + schema_cache: Option<&'a SchemaCache>, + // the file context which contains other statements in that file in case you need them + file_context: &'a AnalysedFileContext, +} +``` + +In squawk, you will see: +```rust + // all statements of that file -> our analyser goes statement by statement but has access to the files content via `file_context` + tree: &[RawStmt], + // the postgres version -> we store it in the schema cache + _pg_version: Option, + // for us, this is always true + _assume_in_transaction: bool, + +``` + +If you learn something new that might help in porting all the rules, please update this document. + +Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. + +TODO: + + +DONE: + + diff --git a/justfile b/justfile index 7e53a8a62..af1f8aaca 100644 --- a/justfile +++ b/justfile @@ -152,3 +152,6 @@ quick-modify: # just show-logs | bunyan show-logs: tail -f $(ls $PGT_LOG_PATH/server.log.* | sort -t- -k2,2 -k3,3 -k4,4 | tail -n 1) + +port-squawk: + unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions < PLAN.md From ea42c2d94676999b74e7ca690ab60dbcffb5d492 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 10 Sep 2025 22:35:14 +0200 Subject: [PATCH 02/23] progress --- PLAN.md | 30 +++++++++++++++++++++++++++++- justfile | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index e0699df61..450a92f8b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -32,11 +32,39 @@ In squawk, you will see: If you learn something new that might help in porting all the rules, please update this document. +LEARNINGS: + + Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. TODO: - +- adding_field_with_default +- adding_foreign_key_constraint +- adding_not_null_field +- adding_primary_key_constraint +- ban_char_field +- ban_concurrent_index_creation_in_transaction +- changing_column_type +- constraint_missing_not_valid +- disallow_unique_constraint +- prefer_big_int +- prefer_bigint_over_int +- prefer_bigint_over_smallint +- prefer_identity +- prefer_robust_stmts +- prefer_text_field +- prefer_timestamptz +- renaming_column +- renaming_table +- require_concurrent_index_creation +- require_concurrent_index_deletion +- transaction_nesting DONE: +- adding_required_field (already exists in pgt_analyser) +- ban_drop_column (already exists in pgt_analyser) +- ban_drop_database (already exists in pgt_analyser, as bad_drop_database in squawk) +- ban_drop_not_null (already exists in pgt_analyser) +- ban_drop_table (already exists in pgt_analyser) diff --git a/justfile b/justfile index af1f8aaca..77480f893 100644 --- a/justfile +++ b/justfile @@ -154,4 +154,4 @@ show-logs: tail -f $(ls $PGT_LOG_PATH/server.log.* | sort -t- -k2,2 -k3,3 -k4,4 | tail -n 1) port-squawk: - unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions < PLAN.md + unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions "please read PLAN.md and follow the instructions closely" From 6ae948382ac2bd4916e3b85eee80e992c65dcd16 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Thu, 11 Sep 2025 08:10:33 +0200 Subject: [PATCH 03/23] progress --- PLAN.md | 16 ++- crates/pgt_analyser/src/lint/safety.rs | 5 +- .../safety/adding_foreign_key_constraint.rs | 111 ++++++++++++++++++ .../src/lint/safety/adding_not_null_field.rs | 77 ++++++++++++ .../safety/adding_primary_key_constraint.rs | 106 +++++++++++++++++ crates/pgt_analyser/src/options.rs | 4 + .../addingForeignKeyConstraint/basic.sql | 4 + .../addingForeignKeyConstraint/basic.sql.snap | 21 ++++ .../specs/safety/addingNotNullField/basic.sql | 4 + .../safety/addingNotNullField/basic.sql.snap | 21 ++++ .../addingPrimaryKeyConstraint/basic.sql | 2 + .../addingPrimaryKeyConstraint/basic.sql.snap | 19 +++ .../serial_column.sql | 2 + .../using_index.sql | 3 + .../src/analyser/linter/rules.rs | 95 +++++++++++++-- .../src/categories.rs | 4 + .../pgt_schema_cache/src/queries/versions.sql | 3 +- crates/pgt_schema_cache/src/schema_cache.rs | 9 +- crates/pgt_schema_cache/src/versions.rs | 1 + docs/rule_sources.md | 4 + docs/rules.md | 4 + docs/rules/adding-foreign-key-constraint.md | 88 ++++++++++++++ docs/rules/adding-not-null-field.md | 71 +++++++++++ docs/rules/adding-primary-key-constraint.md | 85 ++++++++++++++ docs/schema.json | 44 +++++++ justfile | 18 ++- .../backend-jsonrpc/src/workspace.ts | 20 ++++ .../codegen/src/generate_new_analyser_rule.rs | 8 +- 28 files changed, 821 insertions(+), 28 deletions(-) create mode 100644 crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs create mode 100644 crates/pgt_analyser/src/lint/safety/adding_not_null_field.rs create mode 100644 crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs create mode 100644 crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql create mode 100644 docs/rules/adding-foreign-key-constraint.md create mode 100644 docs/rules/adding-not-null-field.md create mode 100644 docs/rules/adding-primary-key-constraint.md diff --git a/PLAN.md b/PLAN.md index 450a92f8b..1dcfa9a85 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,18 +30,23 @@ In squawk, you will see: ``` +Please always write idiomatic code! +Only add comments to explain WHY the code is doing something. DO NOT write comments to explain WHAT the code is doing. + If you learn something new that might help in porting all the rules, please update this document. LEARNINGS: - +- Use `cargo clippy` to check your code after writing it +- The `just new-lintrule` command expects severity to be "info", "warn", or "error" (not "warning") +- RuleDiagnostic methods: `detail(span, msg)` takes two parameters, `note(msg)` takes only one parameter +- To check Postgres version: access `ctx.schema_cache().is_some_and(|sc| sc.version.major_version)` which gives e.g. 17 +- NEVER skip anything, or use a subset of something. ALWAYS do the full thing. For example, copy the entire non-volatile functions list from Squawk, not just a subset. +- Remember to run `just gen-lint` after creating a new rule to generate all necessary files Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. TODO: - adding_field_with_default -- adding_foreign_key_constraint -- adding_not_null_field -- adding_primary_key_constraint - ban_char_field - ban_concurrent_index_creation_in_transaction - changing_column_type @@ -61,6 +66,9 @@ TODO: - transaction_nesting DONE: +- adding_foreign_key_constraint ✓ (ported from Squawk) +- adding_not_null_field ✓ (ported from Squawk) +- adding_primary_key_constraint ✓ (ported from Squawk) - adding_required_field (already exists in pgt_analyser) - ban_drop_column (already exists in pgt_analyser) - ban_drop_database (already exists in pgt_analyser, as bad_drop_database in squawk) diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index a2b72fceb..ed03bcc1b 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -1,10 +1,13 @@ //! Generated file, do not edit by hand, see `xtask/codegen` use pgt_analyse::declare_lint_group; +pub mod adding_foreign_key_constraint; +pub mod adding_not_null_field; +pub mod adding_primary_key_constraint; pub mod adding_required_field; pub mod ban_drop_column; pub mod ban_drop_database; pub mod ban_drop_not_null; pub mod ban_drop_table; pub mod ban_truncate_cascade; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_required_field :: AddingRequiredField , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade ,] } } +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade ,] } } diff --git a/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs new file mode 100644 index 000000000..e661192dd --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -0,0 +1,111 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes. + /// + /// Adding a foreign key constraint to an existing table can cause downtime by locking both tables while + /// verifying the constraint. PostgreSQL needs to check that all existing values in the referencing + /// column exist in the referenced table. + /// + /// Instead, add the constraint as NOT VALID in one transaction, then VALIDATE it in another transaction. + /// This approach only takes a SHARE UPDATE EXCLUSIVE lock when validating, allowing concurrent writes. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "emails" ADD COLUMN "user_id" INT REFERENCES "user" ("id"); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// -- First add the constraint as NOT VALID + /// ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id") NOT VALID; + /// -- Then validate it in a separate transaction + /// ALTER TABLE "email" VALIDATE CONSTRAINT "fk_user"; + /// ``` + /// + pub AddingForeignKeyConstraint { + version: "next", + name: "addingForeignKeyConstraint", + severity: Severity::Warning, + recommended: true, + sources: &[RuleSource::Squawk("adding-foreign-key-constraint")], + } +} + +impl Rule for AddingForeignKeyConstraint { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + match cmd.subtype() { + pgt_query::protobuf::AlterTableType::AtAddConstraint => { + if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { + if let pgt_query::NodeEnum::Constraint(constraint) = def { + // check if it's a foreign key constraint + if constraint.contype() + == pgt_query::protobuf::ConstrType::ConstrForeign + { + // it is okay if NOT VALID is specified (skip_validation = true) + if !constraint.skip_validation { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a foreign key constraint requires a table scan and locks on both tables." + }, + ).detail(None, "This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint.") + .note("Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction.")); + } + } + } + } + } + pgt_query::protobuf::AlterTableType::AtAddColumn => { + if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { + if let pgt_query::NodeEnum::ColumnDef(col_def) = def { + // check constraints on the column + for constraint in &col_def.constraints { + if let Some(pgt_query::NodeEnum::Constraint(constr)) = + &constraint.node + { + if constr.contype() + == pgt_query::protobuf::ConstrType::ConstrForeign + && !constr.skip_validation + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a column with a foreign key constraint requires a table scan and locks." + }, + ).detail(None, "Using REFERENCES when adding a column will block writes while verifying the constraint.") + .note("Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately.")); + } + } + } + } + } + } + _ => {} + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/adding_not_null_field.rs b/crates/pgt_analyser/src/lint/safety/adding_not_null_field.rs new file mode 100644 index 000000000..aa442088e --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/adding_not_null_field.rs @@ -0,0 +1,77 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Setting a column NOT NULL blocks reads while the table is scanned. + /// + /// In PostgreSQL versions before 11, adding a NOT NULL constraint to an existing column requires + /// a full table scan to verify that all existing rows satisfy the constraint. This operation + /// takes an ACCESS EXCLUSIVE lock, blocking all reads and writes. + /// + /// In PostgreSQL 11+, this operation is much faster as it can skip the full table scan for + /// newly added columns with default values. + /// + /// Instead of using SET NOT NULL, consider using a CHECK constraint with NOT VALID, then + /// validating it in a separate transaction. This allows reads and writes to continue. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// -- First add a CHECK constraint as NOT VALID + /// ALTER TABLE "core_recipe" ADD CONSTRAINT foo_not_null CHECK (foo IS NOT NULL) NOT VALID; + /// -- Then validate it in a separate transaction + /// ALTER TABLE "core_recipe" VALIDATE CONSTRAINT foo_not_null; + /// ``` + /// + pub AddingNotNullField { + version: "next", + name: "addingNotNullField", + severity: Severity::Warning, + recommended: true, + sources: &[RuleSource::Squawk("adding-not-null-field")], + } +} + +impl Rule for AddingNotNullField { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + // In Postgres 11+, this is less of a concern + if ctx + .schema_cache() + .is_some_and(|sc| sc.version.major_version.is_some_and(|v| v >= 11)) + { + return diagnostics; + } + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtSetNotNull { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Setting a column NOT NULL blocks reads while the table is scanned." + }, + ).detail(None, "This operation requires an ACCESS EXCLUSIVE lock and a full table scan to verify all rows.") + .note("Use a CHECK constraint with NOT VALID instead, then validate it in a separate transaction.")); + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs new file mode 100644 index 000000000..f81d0a0ca --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -0,0 +1,106 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Adding a primary key constraint results in locks and table rewrites. + /// + /// When you add a PRIMARY KEY constraint, PostgreSQL needs to scan the entire table + /// to verify uniqueness and build the underlying index. This requires an ACCESS EXCLUSIVE + /// lock which blocks all reads and writes. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE users ADD PRIMARY KEY (id); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// -- First, create a unique index concurrently + /// CREATE UNIQUE INDEX CONCURRENTLY items_pk ON items (id); + /// -- Then add the primary key using the index + /// ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; + /// ``` + /// + pub AddingPrimaryKeyConstraint { + version: "next", + name: "addingPrimaryKeyConstraint", + severity: Severity::Warning, + recommended: true, + sources: &[RuleSource::Squawk("adding-serial-primary-key-field")], + } +} + +impl Rule for AddingPrimaryKeyConstraint { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + match cmd.subtype() { + // Check for ADD CONSTRAINT PRIMARY KEY + pgt_query::protobuf::AlterTableType::AtAddConstraint => { + if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { + if let pgt_query::NodeEnum::Constraint(constraint) = def { + if constraint.contype() + == pgt_query::protobuf::ConstrType::ConstrPrimary + && constraint.indexname.is_empty() + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a PRIMARY KEY constraint results in locks and table rewrites." + }, + ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") + .note("Add the PRIMARY KEY constraint USING an index.")); + } + } + } + } + // Check for ADD COLUMN with PRIMARY KEY + pgt_query::protobuf::AlterTableType::AtAddColumn => { + if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { + if let pgt_query::NodeEnum::ColumnDef(col_def) = def { + for constraint in &col_def.constraints { + if let Some(pgt_query::NodeEnum::Constraint(constr)) = + &constraint.node + { + if constr.contype() + == pgt_query::protobuf::ConstrType::ConstrPrimary + && constr.indexname.is_empty() + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a PRIMARY KEY constraint results in locks and table rewrites." + }, + ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") + .note("Add the PRIMARY KEY constraint USING an index.")); + } + } + } + } + } + } + _ => {} + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index d893b84f4..65306275d 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -1,6 +1,10 @@ //! Generated file, do not edit by hand, see `xtask/codegen` use crate::lint; +pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as pgt_analyse :: Rule > :: Options ; +pub type AddingNotNullField = + ::Options; +pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as pgt_analyse :: Rule > :: Options ; pub type AddingRequiredField = ::Options; pub type BanDropColumn = diff --git a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql new file mode 100644 index 000000000..53c603dac --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql @@ -0,0 +1,4 @@ +-- https://postgrestools.com/analyser/safety/addingForeignKeyConstraint + +-- Should trigger: Adding constraint without NOT VALID +ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap new file mode 100644 index 000000000..0d26e8375 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- https://postgrestools.com/analyser/safety/addingForeignKeyConstraint + +-- Should trigger: Adding constraint without NOT VALID +ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); +``` + +# Diagnostics +lint/safety/addingForeignKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a foreign key constraint requires a table scan and locks on both tables. + + i This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint. + + i Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction. diff --git a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql new file mode 100644 index 000000000..46e0b209f --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql @@ -0,0 +1,4 @@ +-- https://postgrestools.com/analyser/safety/addingNotNullField + +-- Should trigger: Setting column NOT NULL (in Postgres < 11) +ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap new file mode 100644 index 000000000..cd58292a8 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- https://postgrestools.com/analyser/safety/addingNotNullField + +-- Should trigger: Setting column NOT NULL (in Postgres < 11) +ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; +``` + +# Diagnostics +lint/safety/addingNotNullField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Setting a column NOT NULL blocks reads while the table is scanned. + + i This operation requires an ACCESS EXCLUSIVE lock and a full table scan to verify all rows. + + i Use a CHECK constraint with NOT VALID instead, then validate it in a separate transaction. diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql new file mode 100644 index 000000000..e982e4cde --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/addingPrimaryKeyConstraint +ALTER TABLE users ADD PRIMARY KEY (id); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap new file mode 100644 index 000000000..1b756d6b8 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap @@ -0,0 +1,19 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +assertion_line: 52 +expression: snapshot +--- +# Input +``` +-- expect_only_lint/safety/addingPrimaryKeyConstraint +ALTER TABLE users ADD PRIMARY KEY (id); +``` + +# Diagnostics +lint/safety/addingPrimaryKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a PRIMARY KEY constraint results in locks and table rewrites. + + i Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads. + + i Add the PRIMARY KEY constraint USING an index. diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql new file mode 100644 index 000000000..9f2ddf945 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/addingPrimaryKeyConstraint +ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql new file mode 100644 index 000000000..5ccae3da9 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql @@ -0,0 +1,3 @@ +-- expect_only_lint/safety/addingPrimaryKeyConstraint +-- This should not trigger the rule - using an existing index +ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; \ No newline at end of file diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index d45199b07..2d03bdbbc 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -141,6 +141,17 @@ pub struct Safety { #[doc = r" It enables ALL rules for this group."] #[serde(skip_serializing_if = "Option::is_none")] pub all: Option, + #[doc = "Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes."] + #[serde(skip_serializing_if = "Option::is_none")] + pub adding_foreign_key_constraint: + Option>, + #[doc = "Setting a column NOT NULL blocks reads while the table is scanned."] + #[serde(skip_serializing_if = "Option::is_none")] + pub adding_not_null_field: Option>, + #[doc = "Adding a primary key constraint results in locks and table rewrites."] + #[serde(skip_serializing_if = "Option::is_none")] + pub adding_primary_key_constraint: + Option>, #[doc = "Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required."] #[serde(skip_serializing_if = "Option::is_none")] pub adding_required_field: @@ -164,6 +175,10 @@ pub struct Safety { impl Safety { const GROUP_NAME: &'static str = "safety"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + "addingFieldWithDefault", + "addingForeignKeyConstraint", + "addingNotNullField", + "addingPrimaryKeyConstraint", "addingRequiredField", "banDropColumn", "banDropDatabase", @@ -172,9 +187,13 @@ impl Safety { "banTruncateCascade", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -183,6 +202,10 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -199,68 +222,98 @@ impl Safety { } pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); + if let Some(rule) = self.adding_foreign_key_constraint.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.adding_not_null_field.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.adding_primary_key_constraint.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } if let Some(rule) = self.adding_required_field.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } if let Some(rule) = self.ban_truncate_cascade.as_ref() { if rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); + if let Some(rule) = self.adding_foreign_key_constraint.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + } + } + if let Some(rule) = self.adding_not_null_field.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + } + } + if let Some(rule) = self.adding_primary_key_constraint.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + } + } if let Some(rule) = self.adding_required_field.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } if let Some(rule) = self.ban_truncate_cascade.as_ref() { if rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } index_set @@ -292,6 +345,10 @@ impl Safety { } pub(crate) fn severity(rule_name: &str) -> Severity { match rule_name { + "addingFieldWithDefault" => Severity::Warning, + "addingForeignKeyConstraint" => Severity::Warning, + "addingNotNullField" => Severity::Warning, + "addingPrimaryKeyConstraint" => Severity::Warning, "addingRequiredField" => Severity::Error, "banDropColumn" => Severity::Warning, "banDropDatabase" => Severity::Warning, @@ -306,6 +363,18 @@ impl Safety { rule_name: &str, ) -> Option<(RulePlainConfiguration, Option)> { match rule_name { + "addingForeignKeyConstraint" => self + .adding_foreign_key_constraint + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "addingNotNullField" => self + .adding_not_null_field + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "addingPrimaryKeyConstraint" => self + .adding_primary_key_constraint + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "addingRequiredField" => self .adding_required_field .as_ref() diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index 14df90b9e..b62828fe3 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -13,6 +13,10 @@ // must be between `define_categories! {\n` and `\n ;\n`. define_categories! { + "lint/safety/addingFieldWithDefault": "https://pgtools.dev/latest/rules/adding-field-with-default", + "lint/safety/addingForeignKeyConstraint": "https://pgtools.dev/latest/rules/adding-foreign-key-constraint", + "lint/safety/addingNotNullField": "https://pgtools.dev/latest/rules/adding-not-null-field", + "lint/safety/addingPrimaryKeyConstraint": "https://pgtools.dev/latest/rules/adding-primary-key-constraint", "lint/safety/addingRequiredField": "https://pgtools.dev/latest/rules/adding-required-field", "lint/safety/banDropColumn": "https://pgtools.dev/latest/rules/ban-drop-column", "lint/safety/banDropDatabase": "https://pgtools.dev/latest/rules/ban-drop-database", diff --git a/crates/pgt_schema_cache/src/queries/versions.sql b/crates/pgt_schema_cache/src/queries/versions.sql index c756e9c57..898f223be 100644 --- a/crates/pgt_schema_cache/src/queries/versions.sql +++ b/crates/pgt_schema_cache/src/queries/versions.sql @@ -1,10 +1,11 @@ select version(), current_setting('server_version_num') :: int8 AS version_num, + current_setting('server_version_num') :: int8 / 10000 AS major_version, ( select count(*) :: int8 AS active_connections FROM pg_stat_activity ) AS active_connections, - current_setting('max_connections') :: int8 AS max_connections; \ No newline at end of file + current_setting('max_connections') :: int8 AS max_connections; diff --git a/crates/pgt_schema_cache/src/schema_cache.rs b/crates/pgt_schema_cache/src/schema_cache.rs index 24b20ccd8..66571792b 100644 --- a/crates/pgt_schema_cache/src/schema_cache.rs +++ b/crates/pgt_schema_cache/src/schema_cache.rs @@ -15,7 +15,7 @@ pub struct SchemaCache { pub tables: Vec, pub functions: Vec, pub types: Vec, - pub versions: Vec, + pub version: Version, pub columns: Vec, pub policies: Vec, pub extensions: Vec, @@ -49,12 +49,17 @@ impl SchemaCache { Extension::load(pool), )?; + let version = versions + .into_iter() + .next() + .expect("Expected at least one version row"); + Ok(SchemaCache { schemas, tables, functions, types, - versions, + version, columns, policies, triggers, diff --git a/crates/pgt_schema_cache/src/versions.rs b/crates/pgt_schema_cache/src/versions.rs index a4769c55a..d4c212995 100644 --- a/crates/pgt_schema_cache/src/versions.rs +++ b/crates/pgt_schema_cache/src/versions.rs @@ -6,6 +6,7 @@ use crate::schema_cache::SchemaCacheItem; pub struct Version { pub version: Option, pub version_num: Option, + pub major_version: Option, pub active_connections: Option, pub max_connections: Option, } diff --git a/docs/rule_sources.md b/docs/rule_sources.md index 679448cdc..7e2fec1fc 100644 --- a/docs/rule_sources.md +++ b/docs/rule_sources.md @@ -3,7 +3,11 @@ ### Squawk | Squawk Rule Name | Rule Name | | ---- | ---- | +| [adding-field-with-default](https://squawkhq.com/docs/adding-field-with-default) |[addingFieldWithDefault](../rules/adding-field-with-default) | +| [adding-foreign-key-constraint](https://squawkhq.com/docs/adding-foreign-key-constraint) |[addingForeignKeyConstraint](../rules/adding-foreign-key-constraint) | +| [adding-not-null-field](https://squawkhq.com/docs/adding-not-null-field) |[addingNotNullField](../rules/adding-not-null-field) | | [adding-required-field](https://squawkhq.com/docs/adding-required-field) |[addingRequiredField](../rules/adding-required-field) | +| [adding-serial-primary-key-field](https://squawkhq.com/docs/adding-serial-primary-key-field) |[addingPrimaryKeyConstraint](../rules/adding-primary-key-constraint) | | [ban-drop-column](https://squawkhq.com/docs/ban-drop-column) |[banDropColumn](../rules/ban-drop-column) | | [ban-drop-database](https://squawkhq.com/docs/ban-drop-database) |[banDropDatabase](../rules/ban-drop-database) | | [ban-drop-not-null](https://squawkhq.com/docs/ban-drop-not-null) |[banDropNotNull](../rules/ban-drop-not-null) | diff --git a/docs/rules.md b/docs/rules.md index d74b67e88..2a9a91655 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -12,6 +12,10 @@ Rules that detect potential safety issues in your code. | Rule name | Description | Properties | | --- | --- | --- | +| [addingFieldWithDefault](./adding-field-with-default) | Adding a field with a default value might lock the table. | ✅ | +| [addingForeignKeyConstraint](./adding-foreign-key-constraint) | Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes. | ✅ | +| [addingNotNullField](./adding-not-null-field) | Setting a column NOT NULL blocks reads while the table is scanned. | ✅ | +| [addingPrimaryKeyConstraint](./adding-primary-key-constraint) | Adding a primary key constraint results in locks and table rewrites. | ✅ | | [addingRequiredField](./adding-required-field) | Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required. | | | [banDropColumn](./ban-drop-column) | Dropping a column may break existing clients. | ✅ | | [banDropDatabase](./ban-drop-database) | Dropping a database may break existing clients (and everything else, really). | | diff --git a/docs/rules/adding-foreign-key-constraint.md b/docs/rules/adding-foreign-key-constraint.md new file mode 100644 index 000000000..19971ae3e --- /dev/null +++ b/docs/rules/adding-foreign-key-constraint.md @@ -0,0 +1,88 @@ +# addingForeignKeyConstraint +**Diagnostic Category: `lint/safety/addingForeignKeyConstraint`** + +**Since**: `vnext` + +> [!NOTE] +> This rule is recommended. A diagnostic error will appear when linting your code. + +**Sources**: +- Inspired from: squawk/adding-foreign-key-constraint + +## Description +Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes. + +Adding a foreign key constraint to an existing table can cause downtime by locking both tables while +verifying the constraint. PostgreSQL needs to check that all existing values in the referencing +column exist in the referenced table. + +Instead, add the constraint as NOT VALID in one transaction, then VALIDATE it in another transaction. +This approach only takes a SHARE UPDATE EXCLUSIVE lock when validating, allowing concurrent writes. + +## Examples + +### Invalid + +```sql +ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); +``` + +```sh +code-block.sql:1:1 lint/safety/addingForeignKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a foreign key constraint requires a table scan and locks on both tables. + + > 1 │ ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint. + + i Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction. + + +``` + +```sql +ALTER TABLE "emails" ADD COLUMN "user_id" INT REFERENCES "user" ("id"); +``` + +```sh +code-block.sql:1:1 lint/safety/addingForeignKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a column with a foreign key constraint requires a table scan and locks. + + > 1 │ ALTER TABLE "emails" ADD COLUMN "user_id" INT REFERENCES "user" ("id"); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Using REFERENCES when adding a column will block writes while verifying the constraint. + + i Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately. + + +``` + +### Valid + +```sql +-- First add the constraint as NOT VALID +ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id") NOT VALID; +-- Then validate it in a separate transaction +ALTER TABLE "email" VALIDATE CONSTRAINT "fk_user"; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "addingForeignKeyConstraint": "error" + } + } + } +} + +``` diff --git a/docs/rules/adding-not-null-field.md b/docs/rules/adding-not-null-field.md new file mode 100644 index 000000000..f4da097c9 --- /dev/null +++ b/docs/rules/adding-not-null-field.md @@ -0,0 +1,71 @@ +# addingNotNullField +**Diagnostic Category: `lint/safety/addingNotNullField`** + +**Since**: `vnext` + +> [!NOTE] +> This rule is recommended. A diagnostic error will appear when linting your code. + +**Sources**: +- Inspired from: squawk/adding-not-null-field + +## Description +Setting a column NOT NULL blocks reads while the table is scanned. + +In PostgreSQL versions before 11, adding a NOT NULL constraint to an existing column requires +a full table scan to verify that all existing rows satisfy the constraint. This operation +takes an ACCESS EXCLUSIVE lock, blocking all reads and writes. + +In PostgreSQL 11+, this operation is much faster as it can skip the full table scan for +newly added columns with default values. + +Instead of using SET NOT NULL, consider using a CHECK constraint with NOT VALID, then +validating it in a separate transaction. This allows reads and writes to continue. + +## Examples + +### Invalid + +```sql +ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; +``` + +```sh +code-block.sql:1:1 lint/safety/addingNotNullField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Setting a column NOT NULL blocks reads while the table is scanned. + + > 1 │ ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i This operation requires an ACCESS EXCLUSIVE lock and a full table scan to verify all rows. + + i Use a CHECK constraint with NOT VALID instead, then validate it in a separate transaction. + + +``` + +### Valid + +```sql +-- First add a CHECK constraint as NOT VALID +ALTER TABLE "core_recipe" ADD CONSTRAINT foo_not_null CHECK (foo IS NOT NULL) NOT VALID; +-- Then validate it in a separate transaction +ALTER TABLE "core_recipe" VALIDATE CONSTRAINT foo_not_null; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "addingNotNullField": "error" + } + } + } +} + +``` diff --git a/docs/rules/adding-primary-key-constraint.md b/docs/rules/adding-primary-key-constraint.md new file mode 100644 index 000000000..644788807 --- /dev/null +++ b/docs/rules/adding-primary-key-constraint.md @@ -0,0 +1,85 @@ +# addingPrimaryKeyConstraint +**Diagnostic Category: `lint/safety/addingPrimaryKeyConstraint`** + +**Since**: `vnext` + +> [!NOTE] +> This rule is recommended. A diagnostic error will appear when linting your code. + +**Sources**: +- Inspired from: squawk/adding-serial-primary-key-field + +## Description +Adding a primary key constraint results in locks and table rewrites. + +When you add a PRIMARY KEY constraint, PostgreSQL needs to scan the entire table +to verify uniqueness and build the underlying index. This requires an ACCESS EXCLUSIVE +lock which blocks all reads and writes. + +## Examples + +### Invalid + +```sql +ALTER TABLE users ADD PRIMARY KEY (id); +``` + +```sh +code-block.sql:1:1 lint/safety/addingPrimaryKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a PRIMARY KEY constraint results in locks and table rewrites. + + > 1 │ ALTER TABLE users ADD PRIMARY KEY (id); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads. + + i Add the PRIMARY KEY constraint USING an index. + + +``` + +```sql +ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; +``` + +```sh +code-block.sql:1:1 lint/safety/addingPrimaryKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a PRIMARY KEY constraint results in locks and table rewrites. + + > 1 │ ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads. + + i Add the PRIMARY KEY constraint USING an index. + + +``` + +### Valid + +```sql +-- First, create a unique index concurrently +CREATE UNIQUE INDEX CONCURRENTLY items_pk ON items (id); +-- Then add the primary key using the index +ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "addingPrimaryKeyConstraint": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index bf9482acc..7383e2512 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -356,6 +356,50 @@ "description": "A list of rules that belong to this group", "type": "object", "properties": { + "addingFieldWithDefault": { + "description": "Adding a field with a default value might lock the table.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "addingForeignKeyConstraint": { + "description": "Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "addingNotNullField": { + "description": "Setting a column NOT NULL blocks reads while the table is scanned.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "addingPrimaryKeyConstraint": { + "description": "Adding a primary key constraint results in locks and table rewrites.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "addingRequiredField": { "description": "Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required.", "anyOf": [ diff --git a/justfile b/justfile index 77480f893..2bfd46179 100644 --- a/justfile +++ b/justfile @@ -154,4 +154,20 @@ show-logs: tail -f $(ls $PGT_LOG_PATH/server.log.* | sort -t- -k2,2 -k3,3 -k4,4 | tail -n 1) port-squawk: - unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions "please read PLAN.md and follow the instructions closely" + unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions -p "please read PLAN.md and follow the instructions closely" + +port-squawk-loop: + #!/usr/bin/env bash + echo "Starting port-squawk loop until error..." + iteration=1 + while true; do + echo "$(date): Starting iteration $iteration..." + if just port-squawk; then + echo "$(date): Iteration $iteration completed successfully!" + iteration=$((iteration + 1)) + else + echo "$(date): Iteration $iteration failed - stopping loop" + break + fi + done + diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 6f7b36202..e693cc68c 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -63,6 +63,10 @@ export interface Advices { advices: Advice[]; } export type Category = + | "lint/safety/addingFieldWithDefault" + | "lint/safety/addingForeignKeyConstraint" + | "lint/safety/addingNotNullField" + | "lint/safety/addingPrimaryKeyConstraint" | "lint/safety/addingRequiredField" | "lint/safety/banDropColumn" | "lint/safety/banDropDatabase" @@ -413,6 +417,22 @@ export type VcsClientKind = "git"; * A list of rules that belong to this group */ export interface Safety { + /** + * Adding a field with a default value might lock the table. + */ + addingFieldWithDefault?: RuleConfiguration_for_Null; + /** + * Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes. + */ + addingForeignKeyConstraint?: RuleConfiguration_for_Null; + /** + * Setting a column NOT NULL blocks reads while the table is scanned. + */ + addingNotNullField?: RuleConfiguration_for_Null; + /** + * Adding a primary key constraint results in locks and table rewrites. + */ + addingPrimaryKeyConstraint?: RuleConfiguration_for_Null; /** * Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required. */ diff --git a/xtask/codegen/src/generate_new_analyser_rule.rs b/xtask/codegen/src/generate_new_analyser_rule.rs index 4c4bcc696..514886a71 100644 --- a/xtask/codegen/src/generate_new_analyser_rule.rs +++ b/xtask/codegen/src/generate_new_analyser_rule.rs @@ -41,7 +41,7 @@ fn generate_rule_template( format!( r#"use pgt_analyse::{{ - AnalysedFileContext, context::RuleContext, {macro_name}, Rule, RuleDiagnostic, + AnalysedFileContext, context::RuleContext, {macro_name}, Rule, RuleDiagnostic, }}; use pgt_console::markup; use pgt_diagnostics::Severity; @@ -79,11 +79,7 @@ use pgt_schema_cache::SchemaCache; impl Rule for {rule_name_upper_camel} {{ type Options = (); - fn run( - ctx: &RuleContext - _file_context: &AnalysedFileContext, - _schema_cache: Option<&SchemaCache>, - ) -> Vec {{ + fn run(ctx: &RuleContext) -> Vec {{ Vec::new() }} }} From d7cf7e2a431451884c3d879dba3f150b80fcf0d3 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Thu, 11 Sep 2025 18:18:48 +0200 Subject: [PATCH 04/23] progress --- PLAN.md | 4 +- .../non_volatile_built_in_functions.txt | 2963 +++++++++++++++++ crates/pgt_analyser/src/lint/safety.rs | 4 +- .../lint/safety/adding_field_with_default.rs | 183 + .../src/lint/safety/ban_char_field.rs | 121 + crates/pgt_analyser/src/options.rs | 3 + .../safety/addingFieldWithDefault/basic.sql | 2 + .../tests/specs/safety/banCharField/basic.sql | 2 + .../src/analyser/linter/rules.rs | 62 +- .../src/categories.rs | 1 + docs/rule_sources.md | 1 + docs/rules.md | 3 +- docs/rules/adding-field-with-default.md | 69 + docs/rules/ban-char-field.md | 72 + docs/schema.json | 13 +- .../backend-jsonrpc/src/workspace.ts | 7 +- 16 files changed, 3492 insertions(+), 18 deletions(-) create mode 100644 crates/pgt_analyser/resources/non_volatile_built_in_functions.txt create mode 100644 crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs create mode 100644 crates/pgt_analyser/src/lint/safety/ban_char_field.rs create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql create mode 100644 docs/rules/adding-field-with-default.md create mode 100644 docs/rules/ban-char-field.md diff --git a/PLAN.md b/PLAN.md index 1dcfa9a85..2062825b6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,8 +46,6 @@ LEARNINGS: Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. TODO: -- adding_field_with_default -- ban_char_field - ban_concurrent_index_creation_in_transaction - changing_column_type - constraint_missing_not_valid @@ -66,10 +64,12 @@ TODO: - transaction_nesting DONE: +- adding_field_with_default ✓ (ported from Squawk) - adding_foreign_key_constraint ✓ (ported from Squawk) - adding_not_null_field ✓ (ported from Squawk) - adding_primary_key_constraint ✓ (ported from Squawk) - adding_required_field (already exists in pgt_analyser) +- ban_char_field ✓ (ported from Squawk) - ban_drop_column (already exists in pgt_analyser) - ban_drop_database (already exists in pgt_analyser, as bad_drop_database in squawk) - ban_drop_not_null (already exists in pgt_analyser) diff --git a/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt b/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt new file mode 100644 index 000000000..cc13e6d7f --- /dev/null +++ b/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt @@ -0,0 +1,2963 @@ +boolin +boolout +byteain +byteaout +charin +charout +namein +nameout +int2in +int2out +int2vectorin +int2vectorout +int4in +int4out +regprocin +regprocout +to_regproc +to_regprocedure +textin +textout +tidin +tidout +xidin +xidout +xid8in +xid8out +xid8recv +xid8send +cidin +cidout +oidvectorin +oidvectorout +boollt +boolgt +booleq +chareq +nameeq +int2eq +int2lt +int4eq +int4lt +texteq +starts_with +xideq +xidneq +xid8eq +xid8ne +xid8lt +xid8gt +xid8le +xid8ge +xid8cmp +xid +cideq +charne +charlt +charle +chargt +charge +int4 +char +nameregexeq +nameregexne +textregexeq +textregexne +textregexeq_support +textlen +textcat +boolne +version +pg_ddl_command_in +pg_ddl_command_out +pg_ddl_command_recv +pg_ddl_command_send +eqsel +neqsel +scalarltsel +scalargtsel +eqjoinsel +neqjoinsel +scalarltjoinsel +scalargtjoinsel +scalarlesel +scalargesel +scalarlejoinsel +scalargejoinsel +unknownin +unknownout +box_above_eq +box_below_eq +point_in +point_out +lseg_in +lseg_out +path_in +path_out +box_in +box_out +box_overlap +box_ge +box_gt +box_eq +box_lt +box_le +point_above +point_left +point_right +point_below +point_eq +on_pb +on_ppath +box_center +areasel +areajoinsel +int4mul +int4ne +int2ne +int2gt +int4gt +int2le +int4le +int4ge +int2ge +int2mul +int2div +int4div +int2mod +int4mod +textne +int24eq +int42eq +int24lt +int42lt +int24gt +int42gt +int24ne +int42ne +int24le +int42le +int24ge +int42ge +int24mul +int42mul +int24div +int42div +int2pl +int4pl +int24pl +int42pl +int2mi +int4mi +int24mi +int42mi +oideq +oidne +box_same +box_contain +box_left +box_overleft +box_overright +box_right +box_contained +box_contain_pt +pg_node_tree_in +pg_node_tree_out +pg_node_tree_recv +pg_node_tree_send +float4in +float4out +float4mul +float4div +float4pl +float4mi +float4um +float4abs +float4_accum +float4larger +float4smaller +int4um +int2um +float8in +float8out +float8mul +float8div +float8pl +float8mi +float8um +float8abs +float8_accum +float8_combine +float8larger +float8smaller +lseg_center +path_center +poly_center +dround +dtrunc +ceil +ceiling +floor +sign +dsqrt +dcbrt +dpow +dexp +dlog1 +float8 +float4 +int2 +int2 +line_distance +nameeqtext +namelttext +nameletext +namegetext +namegttext +namenetext +btnametextcmp +texteqname +textltname +textlename +textgename +textgtname +textnename +bttextnamecmp +nameconcatoid +inter_sl +inter_lb +float48mul +float48div +float48pl +float48mi +float84mul +float84div +float84pl +float84mi +float4eq +float4ne +float4lt +float4le +float4gt +float4ge +float8eq +float8ne +float8lt +float8le +float8gt +float8ge +float48eq +float48ne +float48lt +float48le +float48gt +float48ge +float84eq +float84ne +float84lt +float84le +float84gt +float84ge +width_bucket +float8 +float4 +int4 +int2 +float8 +int4 +float4 +int4 +pg_indexam_has_property +pg_index_has_property +pg_index_column_has_property +pg_indexam_progress_phasename +poly_same +poly_contain +poly_left +poly_overleft +poly_overright +poly_right +poly_contained +poly_overlap +poly_in +poly_out +btint2cmp +btint2sortsupport +btint4cmp +btint4sortsupport +btint8cmp +btint8sortsupport +btfloat4cmp +btfloat4sortsupport +btfloat8cmp +btfloat8sortsupport +btoidcmp +btoidsortsupport +btoidvectorcmp +btcharcmp +btnamecmp +btnamesortsupport +bttextcmp +bttextsortsupport +btvarstrequalimage +cash_cmp +btarraycmp +in_range +in_range +in_range +in_range +in_range +in_range +in_range +in_range +in_range +in_range +lseg_distance +lseg_interpt +dist_ps +dist_sp +dist_pb +dist_bp +dist_sb +dist_bs +close_ps +close_pb +close_sb +on_ps +path_distance +dist_ppath +dist_pathp +on_sb +inter_sb +text +text +name +bpchar +name +hashint2 +hashint2extended +hashint4 +hashint4extended +hashint8 +hashint8extended +hashfloat4 +hashfloat4extended +hashfloat8 +hashfloat8extended +hashoid +hashoidextended +hashchar +hashcharextended +hashname +hashnameextended +hashtext +hashtextextended +hashvarlena +hashvarlenaextended +hashoidvector +hashoidvectorextended +hash_aclitem +hash_aclitem_extended +hashmacaddr +hashmacaddrextended +hashinet +hashinetextended +hash_numeric +hash_numeric_extended +hashmacaddr8 +hashmacaddr8extended +num_nulls +num_nonnulls +text_larger +text_smaller +int8in +int8out +int8um +int8pl +int8mi +int8mul +int8div +int8eq +int8ne +int8lt +int8gt +int8le +int8ge +int84eq +int84ne +int84lt +int84gt +int84le +int84ge +int4 +int8 +float8 +int8 +hash_array +hash_array_extended +float4 +int8 +int2 +int8 +namelt +namele +namegt +namege +namene +bpchar +varchar_support +varchar +oidvectorne +oidvectorlt +oidvectorle +oidvectoreq +oidvectorge +oidvectorgt +getpgusername +oidlt +oidle +octet_length +get_byte +set_byte +get_bit +set_bit +overlay +overlay +bit_count +dist_pl +dist_lp +dist_lb +dist_bl +dist_sl +dist_ls +dist_cpoly +dist_polyc +poly_distance +dist_ppoly +dist_polyp +dist_cpoint +text_lt +text_le +text_gt +text_ge +current_user +session_user +array_eq +array_ne +array_lt +array_gt +array_le +array_ge +array_dims +array_ndims +array_in +array_out +array_lower +array_upper +array_length +cardinality +array_append +array_prepend +array_cat +string_to_array +string_to_array +string_to_table +string_to_table +array_to_string +array_to_string +array_larger +array_smaller +array_position +array_position +array_positions +generate_subscripts +generate_subscripts +array_fill +array_fill +unnest +array_unnest_support +array_remove +array_replace +array_agg_transfn +array_agg_finalfn +array_agg +array_agg_array_transfn +array_agg_array_finalfn +array_agg +width_bucket +trim_array +array_typanalyze +arraycontsel +arraycontjoinsel +int4inc +int4larger +int4smaller +int2larger +int2smaller +cash_mul_flt4 +cash_div_flt4 +flt4_mul_cash +position +textlike +textlike_support +textnlike +int48eq +int48ne +int48lt +int48gt +int48le +int48ge +namelike +namenlike +bpchar +current_database +int8_mul_cash +int4_mul_cash +int2_mul_cash +cash_mul_int8 +cash_div_int8 +cash_mul_int4 +cash_div_int4 +cash_mul_int2 +cash_div_int2 +cash_in +cash_out +cash_eq +cash_ne +cash_lt +cash_le +cash_gt +cash_ge +cash_pl +cash_mi +cash_mul_flt8 +cash_div_flt8 +cashlarger +cashsmaller +flt8_mul_cash +cash_words +cash_div_cash +numeric +money +money +money +mod +mod +int8mod +mod +gcd +gcd +lcm +lcm +char +text +on_pl +on_sl +close_pl +close_sl +close_lb +path_inter +area +width +height +box_distance +area +box_intersect +bound_box +diagonal +path_n_lt +path_n_gt +path_n_eq +path_n_le +path_n_ge +path_length +point_ne +point_vert +point_horiz +point_distance +slope +lseg +lseg_intersect +lseg_parallel +lseg_perp +lseg_vertical +lseg_horizontal +lseg_eq +timezone +aclitemin +aclitemout +aclinsert +aclremove +aclcontains +aclitemeq +makeaclitem +acldefault +aclexplode +bpcharin +bpcharout +bpchartypmodin +bpchartypmodout +varcharin +varcharout +varchartypmodin +varchartypmodout +bpchareq +bpcharlt +bpcharle +bpchargt +bpcharge +bpcharne +bpchar_larger +bpchar_smaller +bpcharcmp +bpchar_sortsupport +hashbpchar +hashbpcharextended +format_type +date_in +date_out +date_eq +date_lt +date_le +date_gt +date_ge +date_ne +date_cmp +date_sortsupport +in_range +time_lt +time_le +time_gt +time_ge +time_ne +time_cmp +date_larger +date_smaller +date_mi +date_pli +date_mii +time_in +time_out +timetypmodin +timetypmodout +time_eq +circle_add_pt +circle_sub_pt +circle_mul_pt +circle_div_pt +timestamptz_in +timestamptz_out +timestamptztypmodin +timestamptztypmodout +timestamptz_eq +timestamptz_ne +timestamptz_lt +timestamptz_le +timestamptz_ge +timestamptz_gt +to_timestamp +timezone +interval_in +interval_out +intervaltypmodin +intervaltypmodout +interval_eq +interval_ne +interval_lt +interval_le +interval_ge +interval_gt +interval_um +interval_pl +interval_mi +date_part +extract +date_part +extract +timestamptz +justify_interval +justify_hours +justify_days +date +age +mxid_age +timestamptz_mi +timestamptz_pl_interval +timestamptz_mi_interval +timestamptz_smaller +timestamptz_larger +interval_smaller +interval_larger +age +interval_support +interval +date_trunc +date_trunc +date_trunc +int8inc +int8dec +int8inc_any +int8dec_any +int8abs +int8larger +int8smaller +texticregexeq +texticregexeq_support +texticregexne +nameicregexeq +nameicregexne +int4abs +int2abs +overlaps +datetime_pl +date_part +extract +int84pl +int84mi +int84mul +int84div +int48pl +int48mi +int48mul +int48div +int82pl +int82mi +int82mul +int82div +int28pl +int28mi +int28mul +int28div +oid +int8 +tideq +tidne +tidgt +tidlt +tidge +tidle +bttidcmp +tidlarger +tidsmaller +hashtid +hashtidextended +datetimetz_pl +now +transaction_timestamp +statement_timestamp +positionsel +positionjoinsel +contsel +contjoinsel +overlaps +overlaps +timestamp_in +timestamp_out +timestamptypmodin +timestamptypmodout +timestamptz_cmp +interval_cmp +time +length +length +xideqint4 +xidneqint4 +interval_div +dlog10 +log +log10 +ln +round +trunc +sqrt +cbrt +pow +power +exp +oidvectortypes +timetz_in +timetz_out +timetztypmodin +timetztypmodout +timetz_eq +timetz_ne +obj_description +timetz_lt +timetz_le +timetz_ge +timetz_gt +timetz_cmp +timestamptz +character_length +character_length +interval +char_length +octet_length +octet_length +time_larger +time_smaller +timetz_larger +timetz_smaller +char_length +extract +date_part +extract +timetz +isfinite +isfinite +isfinite +factorial +abs +abs +abs +abs +abs +name +varchar +current_schema +current_schemas +overlay +overlay +isvertical +ishorizontal +isparallel +isperp +isvertical +ishorizontal +isparallel +isperp +isvertical +ishorizontal +point +time +box +box_add +box_sub +box_mul +box_div +poly_contain_pt +pt_contained_poly +isclosed +isopen +path_npoints +pclose +popen +path_add +path_add_pt +path_sub_pt +path_mul_pt +path_div_pt +point +point_add +point_sub +point_mul +point_div +poly_npoints +box +path +polygon +polygon +circle_in +circle_out +circle_same +circle_contain +circle_left +circle_overleft +circle_overright +circle_right +circle_contained +circle_overlap +circle_below +circle_above +circle_eq +circle_ne +circle_lt +circle_gt +circle_le +circle_ge +area +diameter +radius +circle_distance +circle_center +circle +circle +polygon +dist_pc +circle_contain_pt +pt_contained_circle +box +circle +box +lseg_ne +lseg_lt +lseg_le +lseg_gt +lseg_ge +lseg_length +close_ls +close_lseg +line_in +line_out +line_eq +line +line_interpt +line_intersect +line_parallel +line_perp +line_vertical +line_horizontal +length +length +point +point +point +point +lseg +center +center +npoints +npoints +bit_in +bit_out +bittypmodin +bittypmodout +like +notlike +like +notlike +pg_sequence_parameters +varbit_in +varbit_out +varbittypmodin +varbittypmodout +biteq +bitne +bitge +bitgt +bitle +bitlt +bitcmp +asin +acos +atan +atan2 +sin +cos +tan +cot +asind +acosd +atand +atan2d +sind +cosd +tand +cotd +degrees +radians +pi +sinh +cosh +tanh +asinh +acosh +atanh +interval_mul +ascii +chr +repeat +similar_escape +similar_to_escape +similar_to_escape +mul_d_interval +bpcharlike +bpcharnlike +texticlike +texticlike_support +texticnlike +nameiclike +nameicnlike +like_escape +bpcharicregexeq +bpcharicregexne +bpcharregexeq +bpcharregexne +bpchariclike +bpcharicnlike +strpos +lower +upper +initcap +lpad +rpad +ltrim +rtrim +substr +translate +ltrim +rtrim +substr +btrim +btrim +substring +substring +replace +regexp_replace +regexp_replace +regexp_match +regexp_match +regexp_matches +regexp_matches +split_part +regexp_split_to_table +regexp_split_to_table +regexp_split_to_array +regexp_split_to_array +to_hex +to_hex +getdatabaseencoding +pg_client_encoding +length +convert_from +convert_to +convert +pg_char_to_encoding +pg_encoding_to_char +pg_encoding_max_length +oidgt +oidge +pg_get_ruledef +pg_get_viewdef +pg_get_viewdef +pg_get_userbyid +pg_get_indexdef +pg_get_statisticsobjdef +pg_get_statisticsobjdef_columns +pg_get_statisticsobjdef_expressions +pg_get_partkeydef +pg_get_partition_constraintdef +pg_get_triggerdef +pg_get_constraintdef +pg_get_expr +pg_get_serial_sequence +pg_get_functiondef +pg_get_function_arguments +pg_get_function_identity_arguments +pg_get_function_result +pg_get_function_arg_default +pg_get_function_sqlbody +pg_get_keywords +pg_get_catalog_foreign_keys +pg_options_to_table +pg_typeof +pg_collation_for +pg_relation_is_updatable +pg_column_is_updatable +pg_get_replica_identity_index +varbiteq +varbitne +varbitge +varbitgt +varbitle +varbitlt +varbitcmp +bitand +bitor +bitxor +bitnot +bitshiftleft +bitshiftright +bitcat +substring +length +octet_length +bit +int4 +bit +varbit_support +varbit +position +substring +overlay +overlay +get_bit +set_bit +bit_count +macaddr_in +macaddr_out +trunc +macaddr_eq +macaddr_lt +macaddr_le +macaddr_gt +macaddr_ge +macaddr_ne +macaddr_cmp +macaddr_not +macaddr_and +macaddr_or +macaddr_sortsupport +macaddr8_in +macaddr8_out +trunc +macaddr8_eq +macaddr8_lt +macaddr8_le +macaddr8_gt +macaddr8_ge +macaddr8_ne +macaddr8_cmp +macaddr8_not +macaddr8_and +macaddr8_or +macaddr8 +macaddr +macaddr8_set7bit +inet_in +inet_out +cidr_in +cidr_out +network_eq +network_lt +network_le +network_gt +network_ge +network_ne +network_larger +network_smaller +network_cmp +network_sub +network_subeq +network_sup +network_supeq +network_subset_support +network_overlap +network_sortsupport +abbrev +abbrev +set_masklen +set_masklen +family +network +netmask +masklen +broadcast +host +text +hostmask +cidr +inet_client_addr +inet_client_port +inet_server_addr +inet_server_port +inetnot +inetand +inetor +inetpl +inetmi_int8 +inetmi +inet_same_family +inet_merge +inet_gist_consistent +inet_gist_union +inet_gist_compress +inet_gist_fetch +inet_gist_penalty +inet_gist_picksplit +inet_gist_same +inet_spg_config +inet_spg_choose +inet_spg_picksplit +inet_spg_inner_consistent +inet_spg_leaf_consistent +networksel +networkjoinsel +time_mi_time +boolle +boolge +btboolcmp +time_hash +time_hash_extended +timetz_hash +timetz_hash_extended +interval_hash +interval_hash_extended +numeric_in +numeric_out +numerictypmodin +numerictypmodout +numeric_support +numeric +numeric_abs +abs +sign +round +trunc +ceil +ceiling +floor +numeric_eq +numeric_ne +numeric_gt +numeric_ge +numeric_lt +numeric_le +numeric_add +numeric_sub +numeric_mul +numeric_div +mod +numeric_mod +gcd +lcm +sqrt +numeric_sqrt +exp +numeric_exp +ln +numeric_ln +log +numeric_log +pow +power +numeric_power +scale +min_scale +trim_scale +numeric +numeric +numeric +int4 +float4 +float8 +div +numeric_div_trunc +width_bucket +time_pl_interval +time_mi_interval +timetz_pl_interval +timetz_mi_interval +numeric_inc +numeric_smaller +numeric_larger +numeric_cmp +numeric_sortsupport +numeric_uminus +int8 +numeric +numeric +int2 +pg_lsn +bool +numeric +int2 +int4 +int8 +float4 +float8 +to_char +to_char +to_char +to_char +to_char +to_char +to_number +to_timestamp +to_date +to_char +quote_ident +quote_literal +quote_literal +quote_nullable +quote_nullable +oidin +oidout +concat +concat_ws +left +right +reverse +format +format +iclikesel +icnlikesel +iclikejoinsel +icnlikejoinsel +regexeqsel +likesel +icregexeqsel +regexnesel +nlikesel +icregexnesel +regexeqjoinsel +likejoinsel +icregexeqjoinsel +regexnejoinsel +nlikejoinsel +icregexnejoinsel +prefixsel +prefixjoinsel +float8_avg +float8_var_pop +float8_var_samp +float8_stddev_pop +float8_stddev_samp +numeric_accum +numeric_combine +numeric_avg_accum +numeric_avg_combine +numeric_avg_serialize +numeric_avg_deserialize +numeric_serialize +numeric_deserialize +numeric_accum_inv +int2_accum +int4_accum +int8_accum +numeric_poly_combine +numeric_poly_serialize +numeric_poly_deserialize +int8_avg_accum +int2_accum_inv +int4_accum_inv +int8_accum_inv +int8_avg_accum_inv +int8_avg_combine +int8_avg_serialize +int8_avg_deserialize +int4_avg_combine +numeric_sum +numeric_avg +numeric_var_pop +numeric_var_samp +numeric_stddev_pop +numeric_stddev_samp +int2_sum +int4_sum +int8_sum +numeric_poly_sum +numeric_poly_avg +numeric_poly_var_pop +numeric_poly_var_samp +numeric_poly_stddev_pop +numeric_poly_stddev_samp +interval_accum +interval_combine +interval_accum_inv +interval_avg +int2_avg_accum +int4_avg_accum +int2_avg_accum_inv +int4_avg_accum_inv +int8_avg +int2int4_sum +int8inc_float8_float8 +float8_regr_accum +float8_regr_combine +float8_regr_sxx +float8_regr_syy +float8_regr_sxy +float8_regr_avgx +float8_regr_avgy +float8_regr_r2 +float8_regr_slope +float8_regr_intercept +float8_covar_pop +float8_covar_samp +float8_corr +string_agg_transfn +string_agg_finalfn +string_agg +bytea_string_agg_transfn +bytea_string_agg_finalfn +string_agg +to_ascii +to_ascii +to_ascii +int28eq +int28ne +int28lt +int28gt +int28le +int28ge +int82eq +int82ne +int82lt +int82gt +int82le +int82ge +int2and +int2or +int2xor +int2not +int2shl +int2shr +int4and +int4or +int4xor +int4not +int4shl +int4shr +int8and +int8or +int8xor +int8not +int8shl +int8shr +int8up +int2up +int4up +float4up +float8up +numeric_uplus +has_table_privilege +has_table_privilege +has_table_privilege +has_table_privilege +has_table_privilege +has_table_privilege +has_sequence_privilege +has_sequence_privilege +has_sequence_privilege +has_sequence_privilege +has_sequence_privilege +has_sequence_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_column_privilege +has_any_column_privilege +has_any_column_privilege +has_any_column_privilege +has_any_column_privilege +has_any_column_privilege +has_any_column_privilege +pg_ndistinct_in +pg_ndistinct_out +pg_ndistinct_recv +pg_ndistinct_send +pg_dependencies_in +pg_dependencies_out +pg_dependencies_recv +pg_dependencies_send +pg_mcv_list_in +pg_mcv_list_out +pg_mcv_list_recv +pg_mcv_list_send +pg_mcv_list_items +pg_stat_get_numscans +pg_stat_get_tuples_returned +pg_stat_get_tuples_fetched +pg_stat_get_tuples_inserted +pg_stat_get_tuples_updated +pg_stat_get_tuples_deleted +pg_stat_get_tuples_hot_updated +pg_stat_get_live_tuples +pg_stat_get_dead_tuples +pg_stat_get_mod_since_analyze +pg_stat_get_ins_since_vacuum +pg_stat_get_blocks_fetched +pg_stat_get_blocks_hit +pg_stat_get_last_vacuum_time +pg_stat_get_last_autovacuum_time +pg_stat_get_last_analyze_time +pg_stat_get_last_autoanalyze_time +pg_stat_get_vacuum_count +pg_stat_get_autovacuum_count +pg_stat_get_analyze_count +pg_stat_get_autoanalyze_count +pg_stat_get_backend_idset +pg_stat_get_db_tuples_deleted +pg_stat_get_db_conflict_tablespace +pg_stat_get_db_conflict_lock +pg_stat_get_db_conflict_snapshot +pg_stat_get_activity +pg_stat_get_progress_info +pg_stat_get_wal_senders +pg_stat_get_wal_receiver +pg_stat_get_replication_slot +pg_stat_get_subscription +pg_backend_pid +pg_stat_get_backend_pid +pg_stat_get_backend_dbid +pg_stat_get_backend_userid +pg_stat_get_backend_activity +pg_stat_get_backend_wait_event_type +pg_stat_get_backend_wait_event +pg_stat_get_backend_activity_start +pg_stat_get_backend_xact_start +pg_stat_get_backend_start +pg_stat_get_backend_client_addr +pg_stat_get_backend_client_port +pg_stat_get_db_numbackends +pg_stat_get_db_xact_commit +pg_stat_get_db_xact_rollback +pg_stat_get_db_blocks_fetched +pg_stat_get_db_blocks_hit +pg_stat_get_db_tuples_returned +pg_stat_get_db_tuples_fetched +pg_stat_get_db_tuples_inserted +pg_stat_get_db_tuples_updated +pg_stat_get_db_conflict_bufferpin +pg_stat_get_db_conflict_startup_deadlock +pg_stat_get_db_conflict_all +pg_stat_get_db_deadlocks +pg_stat_get_db_checksum_failures +pg_stat_get_db_checksum_last_failure +pg_stat_get_db_stat_reset_time +pg_stat_get_db_temp_files +pg_stat_get_db_temp_bytes +pg_stat_get_db_blk_read_time +pg_stat_get_db_blk_write_time +pg_stat_get_db_session_time +pg_stat_get_db_active_time +pg_stat_get_db_idle_in_transaction_time +pg_stat_get_db_sessions +pg_stat_get_db_sessions_abandoned +pg_stat_get_db_sessions_fatal +pg_stat_get_db_sessions_killed +pg_stat_get_archiver +pg_stat_get_bgwriter_timed_checkpoints +pg_stat_get_bgwriter_requested_checkpoints +pg_stat_get_bgwriter_buf_written_checkpoints +pg_stat_get_bgwriter_buf_written_clean +pg_stat_get_bgwriter_maxwritten_clean +pg_stat_get_bgwriter_stat_reset_time +pg_stat_get_checkpoint_write_time +pg_stat_get_checkpoint_sync_time +pg_stat_get_buf_written_backend +pg_stat_get_buf_fsync_backend +pg_stat_get_buf_alloc +pg_stat_get_wal +pg_stat_get_slru +pg_stat_get_function_calls +pg_stat_get_function_total_time +pg_stat_get_function_self_time +pg_stat_get_snapshot_timestamp +pg_trigger_depth +pg_tablespace_location +encode +decode +byteaeq +bytealt +byteale +byteagt +byteage +byteane +byteacmp +bytea_sortsupport +timestamp_support +time_support +timestamp +oidlarger +oidsmaller +timestamptz +time +timetz +textanycat +anytextcat +bytealike +byteanlike +like +notlike +like_escape +length +byteacat +substring +substring +substr +substr +position +btrim +ltrim +rtrim +time +date_trunc +date_bin +date_bin +date_part +extract +timestamp +timestamp +timestamp +timestamptz +date +timestamp_mi +timestamp_pl_interval +timestamp_mi_interval +timestamp_smaller +timestamp_larger +timezone +timestamp_hash +timestamp_hash_extended +overlaps +timestamp_cmp +timestamp_sortsupport +in_range +in_range +in_range +in_range +in_range +time +timetz +isfinite +to_char +timestamp_eq +timestamp_ne +timestamp_lt +timestamp_le +timestamp_ge +timestamp_gt +age +timezone +timezone +date_pl_interval +date_mi_interval +substring +bit +int8 +current_setting +current_setting +pg_show_all_settings +pg_describe_object +pg_identify_object +pg_identify_object_as_address +pg_get_object_address +pg_table_is_visible +pg_type_is_visible +pg_function_is_visible +pg_operator_is_visible +pg_opclass_is_visible +pg_opfamily_is_visible +pg_conversion_is_visible +pg_statistics_obj_is_visible +pg_ts_parser_is_visible +pg_ts_dict_is_visible +pg_ts_template_is_visible +pg_ts_config_is_visible +pg_collation_is_visible +pg_my_temp_schema +pg_is_other_temp_schema +pg_backup_start_time +pg_walfile_name_offset +pg_walfile_name +pg_wal_lsn_diff +text +avg +avg +avg +avg +avg +avg +avg +sum +sum +sum +sum +sum +sum +sum +sum +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +max +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +min +count +count +var_pop +var_pop +var_pop +var_pop +var_pop +var_pop +var_samp +var_samp +var_samp +var_samp +var_samp +var_samp +variance +variance +variance +variance +variance +variance +stddev_pop +stddev_pop +stddev_pop +stddev_pop +stddev_pop +stddev_pop +stddev_samp +stddev_samp +stddev_samp +stddev_samp +stddev_samp +stddev_samp +stddev +stddev +stddev +stddev +stddev +stddev +regr_count +regr_sxx +regr_syy +regr_sxy +regr_avgx +regr_avgy +regr_r2 +regr_slope +regr_intercept +covar_pop +covar_samp +corr +text_pattern_lt +text_pattern_le +text_pattern_ge +text_pattern_gt +bttext_pattern_cmp +bttext_pattern_sortsupport +bpchar_pattern_lt +bpchar_pattern_le +bpchar_pattern_ge +bpchar_pattern_gt +btbpchar_pattern_cmp +btbpchar_pattern_sortsupport +btint48cmp +btint84cmp +btint24cmp +btint42cmp +btint28cmp +btint82cmp +btfloat48cmp +btfloat84cmp +regprocedurein +regprocedureout +regoperin +regoperout +to_regoper +to_regoperator +regoperatorin +regoperatorout +regclassin +regclassout +to_regclass +regcollationin +regcollationout +to_regcollation +regtypein +regtypeout +to_regtype +regclass +regrolein +regroleout +to_regrole +regnamespacein +regnamespaceout +to_regnamespace +fmgr_internal_validator +fmgr_c_validator +fmgr_sql_validator +has_database_privilege +has_database_privilege +has_database_privilege +has_database_privilege +has_database_privilege +has_database_privilege +has_function_privilege +has_function_privilege +language_handler_in +has_function_privilege +has_function_privilege +has_function_privilege +has_function_privilege +has_language_privilege +has_language_privilege +has_language_privilege +has_language_privilege +has_language_privilege +has_language_privilege +has_schema_privilege +has_schema_privilege +has_schema_privilege +has_schema_privilege +has_schema_privilege +has_schema_privilege +has_tablespace_privilege +has_tablespace_privilege +has_tablespace_privilege +has_tablespace_privilege +has_tablespace_privilege +has_tablespace_privilege +has_foreign_data_wrapper_privilege +has_foreign_data_wrapper_privilege +has_foreign_data_wrapper_privilege +has_foreign_data_wrapper_privilege +has_foreign_data_wrapper_privilege +has_foreign_data_wrapper_privilege +has_server_privilege +has_server_privilege +has_server_privilege +has_server_privilege +has_server_privilege +has_server_privilege +has_type_privilege +has_type_privilege +has_type_privilege +has_type_privilege +has_type_privilege +has_type_privilege +pg_has_role +pg_has_role +pg_has_role +pg_has_role +pg_has_role +pg_has_role +pg_column_size +pg_column_compression +pg_size_pretty +pg_size_pretty +pg_size_bytes +pg_relation_filenode +pg_filenode_relation +pg_relation_filepath +postgresql_fdw_validator +record_in +record_out +cstring_in +cstring_out +any_in +any_out +anyarray_in +anyarray_out +void_in +void_out +trigger_in +trigger_out +event_trigger_in +event_trigger_out +language_handler_out +internal_in +internal_out +anyelement_in +anyelement_out +shell_in +shell_out +domain_in +domain_recv +anynonarray_in +anynonarray_out +fdw_handler_in +fdw_handler_out +index_am_handler_in +index_am_handler_out +tsm_handler_in +tsm_handler_out +table_am_handler_in +table_am_handler_out +anycompatible_in +anycompatible_out +anycompatiblearray_in +anycompatiblearray_out +anycompatiblearray_recv +anycompatiblearray_send +anycompatiblenonarray_in +anycompatiblenonarray_out +anycompatiblerange_in +anycompatiblerange_out +anycompatiblemultirange_in +anycompatiblemultirange_out +md5 +md5 +sha224 +sha256 +sha384 +sha512 +date_lt_timestamp +date_le_timestamp +date_eq_timestamp +date_gt_timestamp +date_ge_timestamp +date_ne_timestamp +date_cmp_timestamp +date_lt_timestamptz +date_le_timestamptz +date_eq_timestamptz +date_gt_timestamptz +date_ge_timestamptz +date_ne_timestamptz +date_cmp_timestamptz +timestamp_lt_date +timestamp_le_date +timestamp_eq_date +timestamp_gt_date +timestamp_ge_date +timestamp_ne_date +timestamp_cmp_date +timestamptz_lt_date +timestamptz_le_date +timestamptz_eq_date +timestamptz_gt_date +timestamptz_ge_date +timestamptz_ne_date +timestamptz_cmp_date +timestamp_lt_timestamptz +timestamp_le_timestamptz +timestamp_eq_timestamptz +timestamp_gt_timestamptz +timestamp_ge_timestamptz +timestamp_ne_timestamptz +timestamp_cmp_timestamptz +timestamptz_lt_timestamp +timestamptz_le_timestamp +timestamptz_eq_timestamp +timestamptz_gt_timestamp +timestamptz_ge_timestamp +timestamptz_ne_timestamp +timestamptz_cmp_timestamp +array_recv +array_send +record_recv +record_send +int2recv +int2send +int4recv +int4send +int8recv +int8send +int2vectorrecv +int2vectorsend +bytearecv +byteasend +textrecv +textsend +unknownrecv +unknownsend +oidrecv +oidsend +oidvectorrecv +oidvectorsend +namerecv +namesend +float4recv +float4send +float8recv +float8send +point_recv +point_send +bpcharrecv +bpcharsend +varcharrecv +varcharsend +charrecv +charsend +boolrecv +boolsend +tidrecv +tidsend +xidrecv +xidsend +cidrecv +cidsend +regprocrecv +regprocsend +regprocedurerecv +regproceduresend +regoperrecv +regopersend +regoperatorrecv +regoperatorsend +regclassrecv +regclasssend +regcollationrecv +regcollationsend +regtyperecv +regtypesend +regrolerecv +regrolesend +regnamespacerecv +regnamespacesend +bit_recv +bit_send +varbit_recv +varbit_send +numeric_recv +numeric_send +date_recv +date_send +time_recv +time_send +timetz_recv +timetz_send +timestamp_recv +timestamp_send +timestamptz_recv +timestamptz_send +interval_recv +interval_send +lseg_recv +lseg_send +path_recv +path_send +box_recv +box_send +poly_recv +poly_send +line_recv +line_send +circle_recv +circle_send +cash_recv +cash_send +macaddr_recv +macaddr_send +inet_recv +inet_send +cidr_recv +cidr_send +cstring_recv +cstring_send +anyarray_recv +anyarray_send +void_recv +void_send +macaddr8_recv +macaddr8_send +pg_get_ruledef +pg_get_viewdef +pg_get_viewdef +pg_get_viewdef +pg_get_indexdef +pg_get_constraintdef +pg_get_expr +pg_prepared_statement +pg_cursor +pg_timezone_abbrevs +pg_timezone_names +pg_get_triggerdef +pg_listening_channels +generate_series +generate_series +generate_series_int4_support +generate_series +generate_series +generate_series_int8_support +generate_series +generate_series +generate_series +generate_series +booland_statefunc +boolor_statefunc +bool_accum +bool_accum_inv +bool_alltrue +bool_anytrue +bool_and +bool_or +every +bit_and +bit_or +bit_xor +bit_and +bit_or +bit_xor +bit_and +bit_or +bit_xor +bit_and +bit_or +bit_xor +pg_tablespace_databases +bool +int4 +pg_postmaster_start_time +pg_conf_load_time +box_below +box_overbelow +box_overabove +box_above +poly_below +poly_overbelow +poly_overabove +poly_above +circle_overbelow +circle_overabove +gist_box_consistent +gist_box_penalty +gist_box_picksplit +gist_box_union +gist_box_same +gist_box_distance +gist_poly_consistent +gist_poly_compress +gist_circle_consistent +gist_circle_compress +gist_point_compress +gist_point_fetch +gist_point_consistent +gist_point_distance +gist_circle_distance +gist_poly_distance +gist_point_sortsupport +ginarrayextract +ginqueryarrayextract +ginarrayconsistent +pg_lsn_lt +ginarraytriconsistent +ginarrayextract +arrayoverlap +arraycontains +arraycontained +brin_minmax_opcinfo +brin_minmax_add_value +brin_minmax_consistent +brin_minmax_union +brin_minmax_multi_opcinfo +brin_minmax_multi_add_value +brin_minmax_multi_consistent +brin_minmax_multi_union +brin_minmax_multi_options +brin_minmax_multi_distance_int2 +brin_minmax_multi_distance_int4 +brin_minmax_multi_distance_int8 +brin_minmax_multi_distance_float4 +brin_minmax_multi_distance_float8 +brin_minmax_multi_distance_numeric +brin_minmax_multi_distance_tid +brin_minmax_multi_distance_uuid +brin_minmax_multi_distance_date +brin_minmax_multi_distance_time +brin_minmax_multi_distance_interval +brin_minmax_multi_distance_timetz +brin_minmax_multi_distance_pg_lsn +brin_minmax_multi_distance_macaddr +brin_minmax_multi_distance_macaddr8 +brin_minmax_multi_distance_inet +brin_minmax_multi_distance_timestamp +brin_inclusion_opcinfo +brin_inclusion_add_value +brin_inclusion_consistent +brin_inclusion_union +brin_bloom_opcinfo +brin_bloom_add_value +brin_bloom_consistent +brin_bloom_union +brin_bloom_options +xml_in +xml_out +xmlcomment +xml +xmlvalidate +xml_recv +xml_send +xmlconcat2 +xmlagg +text +table_to_xml +table_to_xmlschema +table_to_xml_and_xmlschema +schema_to_xml +schema_to_xmlschema +schema_to_xml_and_xmlschema +database_to_xml +database_to_xmlschema +database_to_xml_and_xmlschema +xpath +xmlexists +xpath_exists +xml_is_well_formed +xml_is_well_formed_document +xml_is_well_formed_content +json_in +json_out +json_recv +json_send +array_to_json +array_to_json +row_to_json +row_to_json +json_agg_transfn +json_agg_finalfn +json_agg +json_object_agg_transfn +json_object_agg_finalfn +json_object_agg +json_build_array +json_build_array +json_build_object +json_build_object +json_object +json_object +to_json +json_strip_nulls +json_object_field +json_object_field_text +json_array_element +json_array_element_text +json_extract_path +json_extract_path_text +json_array_elements +json_array_elements_text +json_array_length +json_object_keys +json_each +json_each_text +json_to_record +json_to_recordset +json_typeof +uuid_in +uuid_out +uuid_lt +uuid_le +uuid_eq +uuid_ge +uuid_gt +uuid_ne +uuid_cmp +uuid_sortsupport +uuid_recv +uuid_send +uuid_hash +uuid_hash_extended +pg_lsn_in +pg_lsn_out +pg_lsn_le +pg_lsn_eq +pg_lsn_ge +pg_lsn_gt +pg_lsn_ne +pg_lsn_mi +pg_lsn_recv +pg_lsn_send +pg_lsn_cmp +pg_lsn_hash +pg_lsn_hash_extended +pg_lsn_larger +pg_lsn_smaller +pg_lsn_pli +pg_lsn_mii +anyenum_in +anyenum_out +enum_in +enum_out +enum_eq +enum_ne +enum_lt +enum_gt +enum_le +enum_ge +enum_cmp +hashenum +hashenumextended +enum_smaller +enum_larger +max +min +enum_first +enum_last +enum_range +enum_range +enum_recv +enum_send +tsvectorin +tsvectorrecv +tsvectorout +tsvectorsend +tsqueryin +tsqueryrecv +tsqueryout +tsquerysend +gtsvectorin +gtsvectorout +tsvector_lt +tsvector_le +tsvector_eq +tsvector_ne +tsvector_ge +tsvector_gt +tsvector_cmp +length +strip +setweight +setweight +tsvector_concat +ts_delete +ts_delete +unnest +tsvector_to_array +array_to_tsvector +ts_filter +ts_match_vq +ts_match_qv +ts_match_tt +ts_match_tq +gtsvector_compress +gtsvector_decompress +gtsvector_picksplit +gtsvector_union +gtsvector_same +gtsvector_penalty +gtsvector_consistent +gtsvector_consistent +gtsvector_options +gin_extract_tsvector +gin_extract_tsquery +gin_tsquery_consistent +gin_tsquery_triconsistent +gin_cmp_tslexeme +gin_cmp_prefix +gin_extract_tsvector +gin_extract_tsquery +gin_tsquery_consistent +gin_extract_tsquery +gin_tsquery_consistent +tsquery_lt +tsquery_le +tsquery_eq +tsquery_ne +tsquery_ge +tsquery_gt +tsquery_cmp +tsquery_and +tsquery_or +tsquery_phrase +tsquery_phrase +tsquery_not +tsq_mcontains +tsq_mcontained +numnode +querytree +ts_rewrite +gtsquery_compress +gtsquery_picksplit +gtsquery_union +gtsquery_same +gtsquery_penalty +gtsquery_consistent +gtsquery_consistent +tsmatchsel +tsmatchjoinsel +ts_typanalyze +ts_rank +ts_rank +ts_rank +ts_rank +ts_rank_cd +ts_rank_cd +ts_rank_cd +ts_rank_cd +ts_token_type +ts_token_type +ts_parse +ts_parse +prsd_start +prsd_nexttoken +prsd_end +prsd_headline +prsd_lextype +ts_lexize +dsimple_init +dsimple_lexize +dsynonym_init +dsynonym_lexize +dispell_init +dispell_lexize +thesaurus_init +thesaurus_lexize +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +ts_headline +to_tsvector +to_tsquery +plainto_tsquery +phraseto_tsquery +websearch_to_tsquery +to_tsvector +to_tsquery +plainto_tsquery +phraseto_tsquery +websearch_to_tsquery +to_tsvector +jsonb_to_tsvector +to_tsvector +json_to_tsvector +to_tsvector +jsonb_to_tsvector +to_tsvector +json_to_tsvector +get_current_ts_config +regconfigin +regconfigout +regconfigrecv +regconfigsend +regdictionaryin +regdictionaryout +regdictionaryrecv +regdictionarysend +jsonb_in +jsonb_recv +jsonb_out +jsonb_send +jsonb_object +jsonb_object +to_jsonb +jsonb_agg_transfn +jsonb_agg_finalfn +jsonb_agg +jsonb_object_agg_transfn +jsonb_object_agg_finalfn +jsonb_object_agg +jsonb_build_array +jsonb_build_array +jsonb_build_object +jsonb_build_object +jsonb_strip_nulls +jsonb_object_field +jsonb_object_field_text +jsonb_array_element +jsonb_array_element_text +jsonb_extract_path +jsonb_extract_path_text +jsonb_array_elements +jsonb_array_elements_text +jsonb_array_length +jsonb_object_keys +jsonb_each +jsonb_each_text +jsonb_populate_record +jsonb_populate_recordset +jsonb_to_record +jsonb_to_recordset +jsonb_typeof +jsonb_ne +jsonb_lt +jsonb_gt +jsonb_le +jsonb_ge +jsonb_eq +jsonb_cmp +jsonb_hash +jsonb_hash_extended +jsonb_contains +jsonb_exists +jsonb_exists_any +jsonb_exists_all +jsonb_contained +gin_compare_jsonb +gin_extract_jsonb +gin_extract_jsonb_query +gin_consistent_jsonb +gin_triconsistent_jsonb +gin_extract_jsonb_path +gin_extract_jsonb_query_path +gin_consistent_jsonb_path +gin_triconsistent_jsonb_path +jsonb_concat +jsonb_delete +jsonb_delete +jsonb_delete +jsonb_delete_path +jsonb_pretty +jsonpath_in +jsonpath_recv +jsonpath_out +jsonpath_send +jsonb_insert +jsonb_path_query +jsonb_path_query_first +jsonb_path_query_array_tz +jsonb_path_exists_opr +jsonb_path_match_opr +txid_snapshot_in +txid_snapshot_out +txid_snapshot_recv +txid_snapshot_send +txid_current +txid_current_if_assigned +txid_current_snapshot +txid_snapshot_xmin +txid_snapshot_xmax +txid_snapshot_xip +txid_visible_in_snapshot +pg_snapshot_in +pg_snapshot_out +pg_snapshot_recv +pg_snapshot_send +pg_current_snapshot +pg_snapshot_xmin +pg_snapshot_xmax +pg_snapshot_xip +pg_visible_in_snapshot +pg_current_xact_id +pg_current_xact_id_if_assigned +record_eq +record_ne +record_lt +record_gt +record_le +record_ge +btrecordcmp +hash_record +hash_record_extended +record_image_eq +record_image_ne +record_image_lt +record_image_gt +record_image_le +record_image_ge +btrecordimagecmp +btequalimage +pg_available_extensions +pg_available_extension_versions +pg_extension_update_paths +row_number +rank +dense_rank +percent_rank +cume_dist +ntile +lag +lag +lag +lead +lead +lead +first_value +last_value +nth_value +anyrange_in +anyrange_out +range_in +range_out +range_recv +range_send +lower +upper +isempty +lower_inc +upper_inc +lower_inf +upper_inf +range_eq +range_ne +range_overlaps +range_contains_elem +range_contains +elem_contained_by_range +range_contained_by +range_adjacent +range_before +range_after +range_overleft +range_overright +range_union +range_merge +range_merge +range_intersect +range_minus +range_cmp +range_lt +range_le +range_ge +range_gt +range_gist_consistent +range_gist_union +range_gist_penalty +range_gist_picksplit +range_gist_same +multirange_gist_consistent +multirange_gist_compress +hash_range +hash_range_extended +range_typanalyze +rangesel +range_intersect_agg_transfn +range_intersect_agg +int4range_canonical +int8range_canonical +daterange_canonical +int4range_subdiff +int8range_subdiff +numrange_subdiff +daterange_subdiff +tsrange_subdiff +tstzrange_subdiff +int4range +int4range +numrange +numrange +tsrange +tsrange +tstzrange +tstzrange +daterange +daterange +int8range +int8range +anymultirange_in +anymultirange_out +multirange_in +multirange_out +multirange_recv +multirange_send +lower +upper +isempty +lower_inc +upper_inc +lower_inf +upper_inf +multirange_typanalyze +multirangesel +multirange_eq +multirange_ne +range_overlaps_multirange +multirange_overlaps_range +multirange_overlaps_multirange +multirange_contains_elem +multirange_contains_range +multirange_contains_multirange +elem_contained_by_multirange +range_contained_by_multirange +range_contains_multirange +multirange_contained_by_range +multirange_contained_by_multirange +range_adjacent_multirange +multirange_adjacent_multirange +multirange_adjacent_range +range_before_multirange +multirange_before_range +multirange_before_multirange +range_after_multirange +multirange_after_range +multirange_after_multirange +range_overleft_multirange +multirange_overleft_range +multirange_overleft_multirange +range_overright_multirange +multirange_overright_range +multirange_overright_multirange +multirange_union +multirange_minus +multirange_intersect +multirange_cmp +multirange_lt +multirange_le +multirange_ge +multirange_gt +hash_multirange +hash_multirange_extended +int4multirange +int4multirange +int4multirange +nummultirange +nummultirange +nummultirange +tsmultirange +tsmultirange +tsmultirange +tstzmultirange +tstzmultirange +spg_range_quad_inner_consistent +tstzmultirange +datemultirange +datemultirange +datemultirange +int8multirange +int8multirange +int8multirange +multirange +range_agg_transfn +range_agg_finalfn +range_agg +multirange_intersect_agg_transfn +range_intersect_agg +unnest +make_date +make_time +make_timestamp +make_timestamptz +make_timestamptz +spg_quad_config +spg_quad_choose +spg_quad_picksplit +spg_quad_inner_consistent +spg_quad_leaf_consistent +spg_kd_config +spg_kd_choose +spg_kd_picksplit +spg_kd_inner_consistent +spg_text_config +spg_text_choose +spg_text_picksplit +spg_text_inner_consistent +spg_text_leaf_consistent +spg_range_quad_config +spg_range_quad_choose +spg_range_quad_picksplit +spg_range_quad_leaf_consistent +spg_box_quad_config +spg_box_quad_choose +spg_box_quad_picksplit +spg_box_quad_inner_consistent +spg_box_quad_leaf_consistent +spg_bbox_quad_config +spg_poly_quad_compress +pg_get_replication_slots +pg_event_trigger_dropped_objects +pg_event_trigger_table_rewrite_oid +pg_event_trigger_table_rewrite_reason +pg_event_trigger_ddl_commands +ordered_set_transition +ordered_set_transition_multi +percentile_disc +percentile_disc_final +percentile_cont +percentile_cont_float8_final +percentile_cont +percentile_cont_interval_final +percentile_disc +percentile_disc_multi_final +percentile_cont +percentile_cont_float8_multi_final +percentile_cont +percentile_cont_interval_multi_final +mode +mode_final +rank +rank_final +percent_rank +percent_rank_final +cume_dist +cume_dist_final +dense_rank +dense_rank_final +koi8r_to_mic +mic_to_koi8r +iso_to_mic +mic_to_iso +win1251_to_mic +mic_to_win1251 +win866_to_mic +mic_to_win866 +koi8r_to_win1251 +win1251_to_koi8r +koi8r_to_win866 +win866_to_koi8r +win866_to_win1251 +win1251_to_win866 +iso_to_koi8r +koi8r_to_iso +iso_to_win1251 +win1251_to_iso +iso_to_win866 +win866_to_iso +euc_cn_to_mic +mic_to_euc_cn +euc_jp_to_sjis +sjis_to_euc_jp +euc_jp_to_mic +sjis_to_mic +mic_to_euc_jp +mic_to_sjis +euc_kr_to_mic +mic_to_euc_kr +euc_tw_to_big5 +big5_to_euc_tw +euc_tw_to_mic +big5_to_mic +mic_to_euc_tw +mic_to_big5 +latin2_to_mic +mic_to_latin2 +win1250_to_mic +mic_to_win1250 +latin2_to_win1250 +win1250_to_latin2 +latin1_to_mic +mic_to_latin1 +latin3_to_mic +mic_to_latin3 +latin4_to_mic +mic_to_latin4 +big5_to_utf8 +utf8_to_big5 +utf8_to_koi8r +koi8r_to_utf8 +utf8_to_koi8u +koi8u_to_utf8 +utf8_to_win +win_to_utf8 +euc_cn_to_utf8 +utf8_to_euc_cn +euc_jp_to_utf8 +utf8_to_euc_jp +euc_kr_to_utf8 +utf8_to_euc_kr +euc_tw_to_utf8 +utf8_to_euc_tw +gb18030_to_utf8 +utf8_to_gb18030 +gbk_to_utf8 +utf8_to_gbk +utf8_to_iso8859 +iso8859_to_utf8 +iso8859_1_to_utf8 +utf8_to_iso8859_1 +johab_to_utf8 +utf8_to_johab +sjis_to_utf8 +utf8_to_sjis +uhc_to_utf8 +utf8_to_uhc +euc_jis_2004_to_utf8 +utf8_to_euc_jis_2004 +shift_jis_2004_to_utf8 +utf8_to_shift_jis_2004 +euc_jis_2004_to_shift_jis_2004 +shift_jis_2004_to_euc_jis_2004 +matchingsel +matchingjoinsel +pg_replication_origin_oid +pg_get_publication_tables +pg_relation_is_publishable +row_security_active +row_security_active +array_subscript_handler +raw_array_subscript_handler +jsonb_subscript_handler +satisfies_hash_partition +pg_partition_root +unistr +brin_bloom_summary_in +brin_bloom_summary_out +brin_bloom_summary_recv +pg_config +brin_bloom_summary_send +brin_minmax_multi_summary_in +brin_minmax_multi_summary_out +brin_minmax_multi_summary_recv +brin_minmax_multi_summary_send +lpad +rpad +substring +bit_length +trunc +bit_length +bit_length +log +log10 +round +numeric_pl_pg_lsn +path_contain_pt +polygon +age +age +interval_pl_timetz +date_part +timestamptz +timedate_pl +timetzdate_pl +interval_pl_time +interval_pl_date +interval_pl_timestamp +interval_pl_timestamptz +integer_pl_date +overlaps +overlaps +overlaps +overlaps +overlaps +overlaps +overlaps +overlaps +overlaps +int8pl_inet +xpath +xpath_exists +obj_description +shobj_description +col_description +ts_debug +ts_debug +json_populate_record +json_populate_recordset +make_interval +jsonb_set +jsonb_set_lax +parse_ident +jsonb_path_exists +jsonb_path_match +jsonb_path_query_array +jsonb_path_exists_tz +jsonb_path_match_tz +jsonb_path_query_tz +jsonb_path_query_first_tz +normalize +is_normalized +_pg_expandarray +_pg_index_position +_pg_truetypid +_pg_truetypmod +_pg_char_max_length +_pg_char_octet_length +_pg_numeric_precision +_pg_numeric_precision_radix +_pg_numeric_scale +_pg_datetime_precision +_pg_interval_type diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index ed03bcc1b..e63c782b9 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -1,13 +1,15 @@ //! Generated file, do not edit by hand, see `xtask/codegen` use pgt_analyse::declare_lint_group; +pub mod adding_field_with_default; pub mod adding_foreign_key_constraint; pub mod adding_not_null_field; pub mod adding_primary_key_constraint; pub mod adding_required_field; +pub mod ban_char_field; pub mod ban_drop_column; pub mod ban_drop_database; pub mod ban_drop_not_null; pub mod ban_drop_table; pub mod ban_truncate_cascade; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade ,] } } +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade ,] } } diff --git a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs new file mode 100644 index 000000000..1e1febfd5 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs @@ -0,0 +1,183 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; +use std::collections::HashSet; + +declare_lint_rule! { + /// Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. + /// + /// In PostgreSQL versions before 11, adding a column with a DEFAULT value causes a full table rewrite, + /// which holds an ACCESS EXCLUSIVE lock on the table and blocks all reads and writes. + /// + /// In PostgreSQL 11+, this behavior was optimized for non-volatile defaults. However: + /// - Volatile default values (like random() or custom functions) still cause table rewrites + /// - Generated columns (GENERATED ALWAYS AS) always require table rewrites + /// - Non-volatile defaults are safe in PostgreSQL 11+ + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// ALTER TABLE "core_recipe" ADD COLUMN "foo" integer; + /// ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET DEFAULT 10; + /// -- Then backfill and add NOT NULL constraint if needed + /// ``` + /// + pub AddingFieldWithDefault { + version: "next", + name: "addingFieldWithDefault", + severity: Severity::Warning, + recommended: true, + sources: &[RuleSource::Squawk("adding-field-with-default")], + } +} + +// Generated via the following Postgres query: +// select proname from pg_proc where provolatile <> 'v'; +const NON_VOLATILE_BUILT_IN_FUNCTIONS: &str = + include_str!("../../../resources/non_volatile_built_in_functions.txt"); + +impl Rule for AddingFieldWithDefault { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + // Check PostgreSQL version - in 11+, non-volatile defaults are safe + let pg_version = ctx.schema_cache().and_then(|sc| sc.version.major_version); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + let has_default = col_def.constraints.iter().any(|constraint| { + if let Some(pgt_query::NodeEnum::Constraint(c)) = &constraint.node { + c.contype() == pgt_query::protobuf::ConstrType::ConstrDefault + } else { + false + } + }); + + let has_generated = col_def.constraints.iter().any(|constraint| { + if let Some(pgt_query::NodeEnum::Constraint(c)) = &constraint.node { + c.contype() == pgt_query::protobuf::ConstrType::ConstrGenerated + } else { + false + } + }); + + if has_generated { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a generated column requires a table rewrite." + }, + ) + .detail(None, "This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table.") + .note("Add the column as nullable, backfill existing rows, and add a trigger to update the column on write instead."), + ); + } else if has_default { + // For PG 11+, check if the default is volatile + if pg_version.is_some_and(|v| v >= 11) { + let non_volatile_funcs: HashSet<_> = + NON_VOLATILE_BUILT_IN_FUNCTIONS + .lines() + .map(|x| x.trim().to_lowercase()) + .filter(|x| !x.is_empty()) + .collect(); + + // Check if default is non-volatile + let is_safe_default = col_def.constraints.iter().any(|constraint| { + if let Some(pgt_query::NodeEnum::Constraint(c)) = &constraint.node { + if c.contype() == pgt_query::protobuf::ConstrType::ConstrDefault { + if let Some(raw_expr) = &c.raw_expr { + return is_safe_default_expr(&raw_expr.node.as_ref().map(|n| Box::new(n.clone())), &non_volatile_funcs); + } + } + } + false + }); + + if !is_safe_default { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a column with a volatile default value causes a table rewrite." + }, + ) + .detail(None, "Even in PostgreSQL 11+, volatile default values require a full table rewrite.") + .note("Add the column without a default, then set the default in a separate statement."), + ); + } + } else { + // Pre PG 11, all defaults cause rewrites + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a column with a DEFAULT value causes a table rewrite." + }, + ) + .detail(None, "This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table.") + .note("Add the column without a default, then set the default in a separate statement."), + ); + } + } + } + } + } + } + } + + diagnostics + } +} + +fn is_safe_default_expr( + expr: &Option>, + non_volatile_funcs: &HashSet, +) -> bool { + match expr { + Some(node) => match node.as_ref() { + // Constants are always safe + pgt_query::NodeEnum::AConst(_) => true, + // Type casts of constants are safe + pgt_query::NodeEnum::TypeCast(tc) => is_safe_default_expr( + &tc.arg.as_ref().and_then(|a| a.node.clone()).map(Box::new), + non_volatile_funcs, + ), + // Function calls might be safe if they're non-volatile and have no args + pgt_query::NodeEnum::FuncCall(fc) => { + // Must have no args + if !fc.args.is_empty() { + return false; + } + // Check if function is in non-volatile list + if let Some(first_name) = fc.funcname.first() { + if let Some(pgt_query::NodeEnum::String(s)) = &first_name.node { + return non_volatile_funcs.contains(&s.sval.to_lowercase()); + } + } + false + } + // Everything else is potentially unsafe + _ => false, + }, + None => false, + } +} diff --git a/crates/pgt_analyser/src/lint/safety/ban_char_field.rs b/crates/pgt_analyser/src/lint/safety/ban_char_field.rs new file mode 100644 index 000000000..1b41df753 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/ban_char_field.rs @@ -0,0 +1,121 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Using CHAR(n) or CHARACTER(n) types is discouraged. + /// + /// CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior + /// when comparing strings or concatenating values. They also waste storage space when values + /// are shorter than the declared length. + /// + /// Use VARCHAR or TEXT instead for variable-length character data. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE "core_bar" ( + /// "id" serial NOT NULL PRIMARY KEY, + /// "alpha" char(100) NOT NULL + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE "core_bar" ( + /// "id" serial NOT NULL PRIMARY KEY, + /// "alpha" varchar(100) NOT NULL + /// ); + /// ``` + /// + pub BanCharField { + version: "next", + name: "banCharField", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("ban-char-field")], + } +} + +impl Rule for BanCharField { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::CreateStmt(stmt) = &ctx.stmt() { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + // Check for "bpchar" (internal name for CHAR type) + // or "char" or "character" + let type_str = name.sval.to_lowercase(); + if type_str == "bpchar" + || type_str == "char" + || type_str == "character" + { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "CHAR type is discouraged due to space padding behavior." + }, + ) + .detail(None, "CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior.") + .note("Use VARCHAR or TEXT instead for variable-length character data."), + ); + } + } + } + } + } + } + } + + // Also check ALTER TABLE ADD COLUMN statements + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node + { + let type_str = name.sval.to_lowercase(); + if type_str == "bpchar" + || type_str == "char" + || type_str == "character" + { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "CHAR type is discouraged due to space padding behavior." + }, + ) + .detail(None, "CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior.") + .note("Use VARCHAR or TEXT instead for variable-length character data."), + ); + } + } + } + } + } + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index 65306275d..facf5405a 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -1,12 +1,15 @@ //! Generated file, do not edit by hand, see `xtask/codegen` use crate::lint; +pub type AddingFieldWithDefault = + ::Options; pub type AddingForeignKeyConstraint = < lint :: safety :: adding_foreign_key_constraint :: AddingForeignKeyConstraint as pgt_analyse :: Rule > :: Options ; pub type AddingNotNullField = ::Options; pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint as pgt_analyse :: Rule > :: Options ; pub type AddingRequiredField = ::Options; +pub type BanCharField = ::Options; pub type BanDropColumn = ::Options; pub type BanDropDatabase = diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql new file mode 100644 index 000000000..5207d268a --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/addingFieldWithDefault +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql new file mode 100644 index 000000000..73f60c160 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/banCharField +-- select 1; \ No newline at end of file diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index 2d03bdbbc..14a1b1885 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -141,6 +141,10 @@ pub struct Safety { #[doc = r" It enables ALL rules for this group."] #[serde(skip_serializing_if = "Option::is_none")] pub all: Option, + #[doc = "Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock."] + #[serde(skip_serializing_if = "Option::is_none")] + pub adding_field_with_default: + Option>, #[doc = "Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes."] #[serde(skip_serializing_if = "Option::is_none")] pub adding_foreign_key_constraint: @@ -156,6 +160,9 @@ pub struct Safety { #[serde(skip_serializing_if = "Option::is_none")] pub adding_required_field: Option>, + #[doc = "Using CHAR(n) or CHARACTER(n) types is discouraged."] + #[serde(skip_serializing_if = "Option::is_none")] + pub ban_char_field: Option>, #[doc = "Dropping a column may break existing clients."] #[serde(skip_serializing_if = "Option::is_none")] pub ban_drop_column: Option>, @@ -180,6 +187,7 @@ impl Safety { "addingNotNullField", "addingPrimaryKeyConstraint", "addingRequiredField", + "banCharField", "banDropColumn", "banDropDatabase", "banDropNotNull", @@ -191,9 +199,9 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -206,6 +214,7 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -222,6 +231,11 @@ impl Safety { } pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); + if let Some(rule) = self.adding_field_with_default.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } if let Some(rule) = self.adding_foreign_key_constraint.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); @@ -242,35 +256,45 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.ban_drop_column.as_ref() { + if let Some(rule) = self.ban_char_field.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.ban_drop_database.as_ref() { + if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.ban_drop_not_null.as_ref() { + if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.ban_drop_table.as_ref() { + if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } + if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); + if let Some(rule) = self.adding_field_with_default.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } if let Some(rule) = self.adding_foreign_key_constraint.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); @@ -291,31 +315,36 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.ban_drop_column.as_ref() { + if let Some(rule) = self.ban_char_field.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.ban_drop_database.as_ref() { + if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.ban_drop_not_null.as_ref() { + if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.ban_drop_table.as_ref() { + if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } + if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -350,6 +379,7 @@ impl Safety { "addingNotNullField" => Severity::Warning, "addingPrimaryKeyConstraint" => Severity::Warning, "addingRequiredField" => Severity::Error, + "banCharField" => Severity::Warning, "banDropColumn" => Severity::Warning, "banDropDatabase" => Severity::Warning, "banDropNotNull" => Severity::Warning, @@ -363,6 +393,10 @@ impl Safety { rule_name: &str, ) -> Option<(RulePlainConfiguration, Option)> { match rule_name { + "addingFieldWithDefault" => self + .adding_field_with_default + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "addingForeignKeyConstraint" => self .adding_foreign_key_constraint .as_ref() @@ -379,6 +413,10 @@ impl Safety { .adding_required_field .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "banCharField" => self + .ban_char_field + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "banDropColumn" => self .ban_drop_column .as_ref() diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index b62828fe3..e80238724 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -18,6 +18,7 @@ define_categories! { "lint/safety/addingNotNullField": "https://pgtools.dev/latest/rules/adding-not-null-field", "lint/safety/addingPrimaryKeyConstraint": "https://pgtools.dev/latest/rules/adding-primary-key-constraint", "lint/safety/addingRequiredField": "https://pgtools.dev/latest/rules/adding-required-field", + "lint/safety/banCharField": "https://pgtools.dev/latest/rules/ban-char-field", "lint/safety/banDropColumn": "https://pgtools.dev/latest/rules/ban-drop-column", "lint/safety/banDropDatabase": "https://pgtools.dev/latest/rules/ban-drop-database", "lint/safety/banDropNotNull": "https://pgtools.dev/latest/rules/ban-drop-not-null", diff --git a/docs/rule_sources.md b/docs/rule_sources.md index 7e2fec1fc..d730caf15 100644 --- a/docs/rule_sources.md +++ b/docs/rule_sources.md @@ -8,6 +8,7 @@ | [adding-not-null-field](https://squawkhq.com/docs/adding-not-null-field) |[addingNotNullField](../rules/adding-not-null-field) | | [adding-required-field](https://squawkhq.com/docs/adding-required-field) |[addingRequiredField](../rules/adding-required-field) | | [adding-serial-primary-key-field](https://squawkhq.com/docs/adding-serial-primary-key-field) |[addingPrimaryKeyConstraint](../rules/adding-primary-key-constraint) | +| [ban-char-field](https://squawkhq.com/docs/ban-char-field) |[banCharField](../rules/ban-char-field) | | [ban-drop-column](https://squawkhq.com/docs/ban-drop-column) |[banDropColumn](../rules/ban-drop-column) | | [ban-drop-database](https://squawkhq.com/docs/ban-drop-database) |[banDropDatabase](../rules/ban-drop-database) | | [ban-drop-not-null](https://squawkhq.com/docs/ban-drop-not-null) |[banDropNotNull](../rules/ban-drop-not-null) | diff --git a/docs/rules.md b/docs/rules.md index 2a9a91655..3c3a496bb 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -12,11 +12,12 @@ Rules that detect potential safety issues in your code. | Rule name | Description | Properties | | --- | --- | --- | -| [addingFieldWithDefault](./adding-field-with-default) | Adding a field with a default value might lock the table. | ✅ | +| [addingFieldWithDefault](./adding-field-with-default) | Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. | ✅ | | [addingForeignKeyConstraint](./adding-foreign-key-constraint) | Adding a foreign key constraint requires a table scan and a SHARE ROW EXCLUSIVE lock on both tables, which blocks writes. | ✅ | | [addingNotNullField](./adding-not-null-field) | Setting a column NOT NULL blocks reads while the table is scanned. | ✅ | | [addingPrimaryKeyConstraint](./adding-primary-key-constraint) | Adding a primary key constraint results in locks and table rewrites. | ✅ | | [addingRequiredField](./adding-required-field) | Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required. | | +| [banCharField](./ban-char-field) | Using CHAR(n) or CHARACTER(n) types is discouraged. | | | [banDropColumn](./ban-drop-column) | Dropping a column may break existing clients. | ✅ | | [banDropDatabase](./ban-drop-database) | Dropping a database may break existing clients (and everything else, really). | | | [banDropNotNull](./ban-drop-not-null) | Dropping a NOT NULL constraint may break existing clients. | ✅ | diff --git a/docs/rules/adding-field-with-default.md b/docs/rules/adding-field-with-default.md new file mode 100644 index 000000000..644ac91fc --- /dev/null +++ b/docs/rules/adding-field-with-default.md @@ -0,0 +1,69 @@ +# addingFieldWithDefault +**Diagnostic Category: `lint/safety/addingFieldWithDefault`** + +**Since**: `vnext` + +> [!NOTE] +> This rule is recommended. A diagnostic error will appear when linting your code. + +**Sources**: +- Inspired from: squawk/adding-field-with-default + +## Description +Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. + +In PostgreSQL versions before 11, adding a column with a DEFAULT value causes a full table rewrite, +which holds an ACCESS EXCLUSIVE lock on the table and blocks all reads and writes. + +In PostgreSQL 11+, this behavior was optimized for non-volatile defaults. However: + +- Volatile default values (like random() or custom functions) still cause table rewrites +- Generated columns (GENERATED ALWAYS AS) always require table rewrites +- Non-volatile defaults are safe in PostgreSQL 11+ + +## Examples + +### Invalid + +```sql +ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; +``` + +```sh +code-block.sql:1:1 lint/safety/addingFieldWithDefault ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a column with a DEFAULT value causes a table rewrite. + + > 1 │ ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table. + + i Add the column without a default, then set the default in a separate statement. + + +``` + +### Valid + +```sql +ALTER TABLE "core_recipe" ADD COLUMN "foo" integer; +ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET DEFAULT 10; +-- Then backfill and add NOT NULL constraint if needed +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "addingFieldWithDefault": "error" + } + } + } +} + +``` diff --git a/docs/rules/ban-char-field.md b/docs/rules/ban-char-field.md new file mode 100644 index 000000000..c9be7d962 --- /dev/null +++ b/docs/rules/ban-char-field.md @@ -0,0 +1,72 @@ +# banCharField +**Diagnostic Category: `lint/safety/banCharField`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/ban-char-field + +## Description +Using CHAR(n) or CHARACTER(n) types is discouraged. + +CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior +when comparing strings or concatenating values. They also waste storage space when values +are shorter than the declared length. + +Use VARCHAR or TEXT instead for variable-length character data. + +## Examples + +### Invalid + +```sql +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" char(100) NOT NULL +); +``` + +```sh +code-block.sql:1:1 lint/safety/banCharField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! CHAR type is discouraged due to space padding behavior. + + > 1 │ CREATE TABLE "core_bar" ( + │ ^^^^^^^^^^^^^^^^^^^^^^^^^ + > 2 │ "id" serial NOT NULL PRIMARY KEY, + > 3 │ "alpha" char(100) NOT NULL + > 4 │ ); + │ ^^ + 5 │ + + i CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior. + + i Use VARCHAR or TEXT instead for variable-length character data. + + +``` + +### Valid + +```sql +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" varchar(100) NOT NULL +); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "banCharField": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index 7383e2512..5930beb0f 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -357,7 +357,7 @@ "type": "object", "properties": { "addingFieldWithDefault": { - "description": "Adding a field with a default value might lock the table.", + "description": "Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock.", "anyOf": [ { "$ref": "#/definitions/RuleConfiguration" @@ -418,6 +418,17 @@ "null" ] }, + "banCharField": { + "description": "Using CHAR(n) or CHARACTER(n) types is discouraged.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "banDropColumn": { "description": "Dropping a column may break existing clients.", "anyOf": [ diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index e693cc68c..abd224aeb 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -68,6 +68,7 @@ export type Category = | "lint/safety/addingNotNullField" | "lint/safety/addingPrimaryKeyConstraint" | "lint/safety/addingRequiredField" + | "lint/safety/banCharField" | "lint/safety/banDropColumn" | "lint/safety/banDropDatabase" | "lint/safety/banDropNotNull" @@ -418,7 +419,7 @@ export type VcsClientKind = "git"; */ export interface Safety { /** - * Adding a field with a default value might lock the table. + * Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. */ addingFieldWithDefault?: RuleConfiguration_for_Null; /** @@ -441,6 +442,10 @@ export interface Safety { * It enables ALL rules for this group. */ all?: boolean; + /** + * Using CHAR(n) or CHARACTER(n) types is discouraged. + */ + banCharField?: RuleConfiguration_for_Null; /** * Dropping a column may break existing clients. */ From a89de2a0439e946ccea219789d44f6b4a47f7a7e Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 08:24:54 +0200 Subject: [PATCH 05/23] progress --- PLAN.md | 30 +++- .../pgt_analyse/src/analysed_file_context.rs | 22 ++- crates/pgt_analyse/src/context.rs | 2 +- crates/pgt_analyse/src/registry.rs | 2 +- crates/pgt_analyser/src/lib.rs | 30 ++-- crates/pgt_analyser/src/lint/safety.rs | 9 +- ...oncurrent_index_creation_in_transaction.rs | 53 ++++++ .../src/lint/safety/changing_column_type.rs | 55 ++++++ .../safety/constraint_missing_not_valid.rs | 74 ++++++++ .../src/lint/safety/prefer_big_int.rs | 127 +++++++++++++ .../src/lint/safety/prefer_bigint_over_int.rs | 127 +++++++++++++ .../safety/prefer_bigint_over_smallint.rs | 120 +++++++++++++ .../src/lint/safety/prefer_identity.rs | 119 ++++++++++++ crates/pgt_analyser/src/options.rs | 10 ++ .../serial_column.sql.snap | 19 ++ .../using_index.sql.snap | 11 ++ .../basic.sql | 2 + .../specs/safety/changingColumnType/basic.sql | 2 + .../constraintMissingNotValid/basic.sql | 2 + .../tests/specs/safety/preferBigInt/basic.sql | 4 + .../specs/safety/preferBigInt/basic.sql.snap | 22 +++ .../safety/preferBigintOverInt/basic.sql | 4 + .../safety/preferBigintOverInt/basic.sql.snap | 22 +++ .../safety/preferBigintOverSmallint/basic.sql | 4 + .../preferBigintOverSmallint/basic.sql.snap | 22 +++ .../safety/preferIdentity/alter_table.sql | 2 + .../specs/safety/preferIdentity/basic.sql | 4 + .../safety/preferIdentity/basic.sql.snap | 21 +++ .../specs/safety/preferIdentity/bigserial.sql | 4 + .../specs/safety/preferIdentity/valid.sql | 4 + .../src/analyser/linter/rules.rs | 170 ++++++++++++++++-- .../src/categories.rs | 7 + docs/rule_sources.md | 7 + docs/rules.md | 7 + ...oncurrent-index-creation-in-transaction.md | 54 ++++++ docs/rules/changing-column-type.md | 54 ++++++ docs/rules/constraint-missing-not-valid.md | 60 +++++++ docs/rules/prefer-big-int.md | 101 +++++++++++ docs/rules/prefer-bigint-over-int.md | 107 +++++++++++ docs/rules/prefer-bigint-over-smallint.md | 101 +++++++++++ docs/rules/prefer-identity.md | 102 +++++++++++ docs/schema.json | 79 +++++++- .../backend-jsonrpc/src/workspace.ts | 41 ++++- 43 files changed, 1774 insertions(+), 45 deletions(-) create mode 100644 crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs create mode 100644 crates/pgt_analyser/src/lint/safety/changing_column_type.rs create mode 100644 crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_big_int.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_identity.rs create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql create mode 100644 docs/rules/ban-concurrent-index-creation-in-transaction.md create mode 100644 docs/rules/changing-column-type.md create mode 100644 docs/rules/constraint-missing-not-valid.md create mode 100644 docs/rules/prefer-big-int.md create mode 100644 docs/rules/prefer-bigint-over-int.md create mode 100644 docs/rules/prefer-bigint-over-smallint.md create mode 100644 docs/rules/prefer-identity.md diff --git a/PLAN.md b/PLAN.md index 2062825b6..5a5b34e27 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,6 +17,15 @@ pub struct RuleContext<'a, R: Rule> { // the file context which contains other statements in that file in case you need them file_context: &'a AnalysedFileContext, } + +pub struct AnalysedFileContext<'a> { + // all other statements in this file + pub all_stmts: &'a Vec, + // total count of statements in this file + pub stmt_count: usize, + // all statements before the currently analysed one + pub previous_stmts: Vec<&'a pgt_query::NodeEnum>, +} ``` In squawk, you will see: @@ -41,20 +50,14 @@ LEARNINGS: - RuleDiagnostic methods: `detail(span, msg)` takes two parameters, `note(msg)` takes only one parameter - To check Postgres version: access `ctx.schema_cache().is_some_and(|sc| sc.version.major_version)` which gives e.g. 17 - NEVER skip anything, or use a subset of something. ALWAYS do the full thing. For example, copy the entire non-volatile functions list from Squawk, not just a subset. +- If you are missing features from our context to be able to properly implement a rule, DO NOT DO IT. Instead, add that rule to the NEEDS FEATURES list below. - Remember to run `just gen-lint` after creating a new rule to generate all necessary files Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. +NEEDS FEATURES: + TODO: -- ban_concurrent_index_creation_in_transaction -- changing_column_type -- constraint_missing_not_valid -- disallow_unique_constraint -- prefer_big_int -- prefer_bigint_over_int -- prefer_bigint_over_smallint -- prefer_identity -- prefer_robust_stmts - prefer_text_field - prefer_timestamptz - renaming_column @@ -62,6 +65,8 @@ TODO: - require_concurrent_index_creation - require_concurrent_index_deletion - transaction_nesting +- disallow_unique_constraint +- prefer_robust_stmts DONE: - adding_field_with_default ✓ (ported from Squawk) @@ -70,9 +75,16 @@ DONE: - adding_primary_key_constraint ✓ (ported from Squawk) - adding_required_field (already exists in pgt_analyser) - ban_char_field ✓ (ported from Squawk) +- ban_concurrent_index_creation_in_transaction ✓ (ported from Squawk) - ban_drop_column (already exists in pgt_analyser) +- changing_column_type ✓ (ported from Squawk) +- constraint_missing_not_valid ✓ (ported from Squawk) - ban_drop_database (already exists in pgt_analyser, as bad_drop_database in squawk) - ban_drop_not_null (already exists in pgt_analyser) - ban_drop_table (already exists in pgt_analyser) +- prefer_big_int ✓ (ported from Squawk) +- prefer_bigint_over_int ✓ (ported from Squawk) +- prefer_bigint_over_smallint ✓ (ported from Squawk) +- prefer_identity ✓ (ported from Squawk) diff --git a/crates/pgt_analyse/src/analysed_file_context.rs b/crates/pgt_analyse/src/analysed_file_context.rs index 82dc40711..c1ee39c62 100644 --- a/crates/pgt_analyse/src/analysed_file_context.rs +++ b/crates/pgt_analyse/src/analysed_file_context.rs @@ -1,7 +1,19 @@ -#[derive(Default)] -pub struct AnalysedFileContext {} +pub struct AnalysedFileContext<'a> { + pub all_stmts: &'a Vec, + pub stmt_count: usize, + pub previous_stmts: Vec<&'a pgt_query::NodeEnum>, +} + +impl<'a> AnalysedFileContext<'a> { + pub fn new(stmts: &'a Vec) -> Self { + Self { + all_stmts: stmts, + stmt_count: stmts.len(), + previous_stmts: Vec::new(), + } + } -impl AnalysedFileContext { - #[allow(unused)] - pub fn update_from(&mut self, stmt_root: &pgt_query::NodeEnum) {} + pub fn update_from(&mut self, stmt_root: &'a pgt_query::NodeEnum) { + self.previous_stmts.push(stmt_root); + } } diff --git a/crates/pgt_analyse/src/context.rs b/crates/pgt_analyse/src/context.rs index ddd5d28d5..17f47365a 100644 --- a/crates/pgt_analyse/src/context.rs +++ b/crates/pgt_analyse/src/context.rs @@ -10,7 +10,7 @@ pub struct RuleContext<'a, R: Rule> { stmt: &'a pgt_query::NodeEnum, options: &'a R::Options, schema_cache: Option<&'a SchemaCache>, - file_context: &'a AnalysedFileContext, + file_context: &'a AnalysedFileContext<'a>, } impl<'a, R> RuleContext<'a, R> diff --git a/crates/pgt_analyse/src/registry.rs b/crates/pgt_analyse/src/registry.rs index 45d2c2026..8da24dbc8 100644 --- a/crates/pgt_analyse/src/registry.rs +++ b/crates/pgt_analyse/src/registry.rs @@ -159,7 +159,7 @@ impl RuleRegistry { pub struct RegistryRuleParams<'a> { pub root: &'a pgt_query::NodeEnum, pub options: &'a AnalyserOptions, - pub analysed_file_context: &'a AnalysedFileContext, + pub analysed_file_context: &'a AnalysedFileContext<'a>, pub schema_cache: Option<&'a pgt_schema_cache::SchemaCache>, } diff --git a/crates/pgt_analyser/src/lib.rs b/crates/pgt_analyser/src/lib.rs index ccdc04208..a5635a0c0 100644 --- a/crates/pgt_analyser/src/lib.rs +++ b/crates/pgt_analyser/src/lib.rs @@ -62,25 +62,29 @@ impl<'a> Analyser<'a> { pub fn run(&self, params: AnalyserParams) -> Vec { let mut diagnostics = vec![]; - let mut file_context = AnalysedFileContext::default(); + let roots: Vec = params.stmts.iter().map(|s| s.root.clone()).collect(); + let mut file_context = AnalysedFileContext::new(&roots); + + for (i, stmt) in params.stmts.into_iter().enumerate() { + let stmt_diagnostics: Vec<_> = { + let rule_params = RegistryRuleParams { + root: &roots[i], + options: self.options, + analysed_file_context: &file_context, + schema_cache: params.schema_cache, + }; - for stmt in params.stmts { - let rule_params = RegistryRuleParams { - root: &stmt.root, - options: self.options, - analysed_file_context: &file_context, - schema_cache: params.schema_cache, - }; - - diagnostics.extend( self.registry .rules .iter() .flat_map(|rule| (rule.run)(&rule_params)) - .map(|r| r.span(stmt.range)), - ); + .map(|r| r.span(stmt.range)) + .collect() + }; // end immutable borrow + + diagnostics.extend(stmt_diagnostics); - file_context.update_from(&stmt.root); + file_context.update_from(&roots[i]); } diagnostics diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index e63c782b9..661832ad2 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -7,9 +7,16 @@ pub mod adding_not_null_field; pub mod adding_primary_key_constraint; pub mod adding_required_field; pub mod ban_char_field; +pub mod ban_concurrent_index_creation_in_transaction; pub mod ban_drop_column; pub mod ban_drop_database; pub mod ban_drop_not_null; pub mod ban_drop_table; pub mod ban_truncate_cascade; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade ,] } } +pub mod changing_column_type; +pub mod constraint_missing_not_valid; +pub mod prefer_big_int; +pub mod prefer_bigint_over_int; +pub mod prefer_bigint_over_smallint; +pub mod prefer_identity; +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , ] } } diff --git a/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs b/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs new file mode 100644 index 000000000..1f6450ccd --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs @@ -0,0 +1,53 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Concurrent index creation is not allowed within a transaction. + /// + /// `CREATE INDEX CONCURRENTLY` cannot be used within a transaction block. This will cause an error in Postgres. + /// + /// Migration tools usually run each migration in a transaction, so using `CREATE INDEX CONCURRENTLY` will fail in such tools. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); + /// ``` + /// + pub BanConcurrentIndexCreationInTransaction { + version: "next", + name: "banConcurrentIndexCreationInTransaction", + severity: Severity::Error, + recommended: true, + sources: &[RuleSource::Squawk("ban-concurrent-index-creation-in-transaction")], + } +} + +impl Rule for BanConcurrentIndexCreationInTransaction { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + // check if the current statement is CREATE INDEX CONCURRENTLY and there is at least one + // other statement in the same context (indicating a transaction block) + // + // since our analyser assumes we're always in a transaction context, we always flag concurrent indexes + if let pgt_query::NodeEnum::IndexStmt(stmt) = ctx.stmt() { + if stmt.concurrent && ctx.file_context().stmt_count > 1 { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "CREATE INDEX CONCURRENTLY cannot be used inside a transaction block." + } + ).detail(None, "Run CREATE INDEX CONCURRENTLY outside of a transaction. Migration tools usually run in transactions, so you may need to run this statement in its own migration or manually.")); + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/changing_column_type.rs b/crates/pgt_analyser/src/lint/safety/changing_column_type.rs new file mode 100644 index 000000000..d6d00559f --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/changing_column_type.rs @@ -0,0 +1,55 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Changing a column type may break existing clients. + /// + /// Changing a column's data type requires an exclusive lock on the table while the entire table is rewritten. + /// This can take a long time for large tables and will block reads and writes. + /// + /// Instead of changing the type directly, consider creating a new column with the desired type, + /// migrating the data, and then dropping the old column. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "core_recipe" ALTER COLUMN "edits" TYPE text USING "edits"::text; + /// ``` + /// + pub ChangingColumnType { + version: "next", + name: "changingColumnType", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("changing-column-type")], + } +} + +impl Rule for ChangingColumnType { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAlterColumnType { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Changing a column type requires a table rewrite and blocks reads and writes." + } + ).detail(None, "Consider creating a new column with the desired type, migrating data, and then dropping the old column.")); + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs new file mode 100644 index 000000000..de68caacf --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -0,0 +1,74 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Adding constraints without NOT VALID blocks all reads and writes. + /// + /// When adding a CHECK or FOREIGN KEY constraint, PostgreSQL must validate all existing rows, + /// which requires a full table scan. This blocks reads and writes for the duration. + /// + /// Instead, add the constraint with NOT VALID first, then VALIDATE CONSTRAINT in a separate + /// transaction. This allows reads and writes to continue while validation happens. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address) NOT VALID; + /// ``` + /// + pub ConstraintMissingNotValid { + version: "next", + name: "constraintMissingNotValid", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("constraint-missing-not-valid")], + } +} + +impl Rule for ConstraintMissingNotValid { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + // Check if we're adding a constraint + if let Some(pgt_query::NodeEnum::Constraint(constraint)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + // Skip if the constraint has NOT VALID + if constraint.initially_valid { + // Only warn for CHECK and FOREIGN KEY constraints + match constraint.contype() { + pgt_query::protobuf::ConstrType::ConstrCheck + | pgt_query::protobuf::ConstrType::ConstrForeign => { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a constraint without NOT VALID will block reads and writes while validating existing rows." + } + ).detail(None, "Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction.")); + } + _ => {} + } + } + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs b/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs new file mode 100644 index 000000000..a703bff19 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs @@ -0,0 +1,127 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer BIGINT over smaller integer types. + /// + /// Using smaller integer types like SMALLINT, INTEGER, or their aliases can lead to overflow + /// issues as your application grows. BIGINT provides a much larger range and helps avoid + /// future migration issues when values exceed the limits of smaller types. + /// + /// The storage difference between INTEGER (4 bytes) and BIGINT (8 bytes) is minimal on + /// modern systems, while the cost of migrating to a larger type later can be significant. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// id integer + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// id serial + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE users ( + /// id bigint + /// ); + /// ``` + /// + /// ```sql + /// CREATE TABLE users ( + /// id bigserial + /// ); + /// ``` + /// + pub PreferBigInt { + version: "next", + name: "preferBigInt", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-big-int")], + } +} + +impl Rule for PreferBigInt { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn + || cmd.subtype() + == pgt_query::protobuf::AlterTableType::AtAlterColumnType + { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + let type_name_lower = name.sval.to_lowercase(); + let is_small_int = matches!( + type_name_lower.as_str(), + "smallint" + | "integer" + | "int2" + | "int4" + | "serial" + | "serial2" + | "serial4" + | "smallserial" + ); + + if is_small_int { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Using smaller integer types can lead to overflow issues." + }, + ) + .detail(None, &format!("The '{}' type has a limited range that may be exceeded as your data grows.", name.sval)) + .note("Consider using BIGINT for integer columns to avoid future migration issues."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs new file mode 100644 index 000000000..b923449a4 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs @@ -0,0 +1,127 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer BIGINT over INT/INTEGER types. + /// + /// Using INTEGER (INT4) can lead to overflow issues, especially for ID columns. + /// While SMALLINT might be acceptable for certain use cases with known small ranges, + /// INTEGER often becomes a limiting factor as applications grow. + /// + /// The storage difference between INTEGER (4 bytes) and BIGINT (8 bytes) is minimal, + /// but the cost of migrating when you hit the 2.1 billion limit can be significant. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// id integer + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// id serial + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE users ( + /// id bigint + /// ); + /// ``` + /// + /// ```sql + /// CREATE TABLE users ( + /// id bigserial + /// ); + /// ``` + /// + /// ```sql + /// CREATE TABLE users ( + /// id smallint + /// ); + /// ``` + /// + pub PreferBigintOverInt { + version: "next", + name: "preferBigintOverInt", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-bigint-over-int")], + } +} + +impl Rule for PreferBigintOverInt { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn + || cmd.subtype() + == pgt_query::protobuf::AlterTableType::AtAlterColumnType + { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + let type_name_lower = name.sval.to_lowercase(); + // Only check for INT4/INTEGER types, not SMALLINT + let is_int4 = matches!( + type_name_lower.as_str(), + "integer" | "int4" | "serial" | "serial4" + ); + + if is_int4 { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "INTEGER type may lead to overflow issues." + }, + ) + .detail(None, "INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters.") + .note("Consider using BIGINT instead for better future-proofing."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs new file mode 100644 index 000000000..c2b2d7132 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs @@ -0,0 +1,120 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer BIGINT over SMALLINT types. + /// + /// SMALLINT has a very limited range (-32,768 to 32,767) that is easily exceeded. + /// Even for values that seem small initially, using SMALLINT can lead to problems + /// as your application grows. + /// + /// The storage savings of SMALLINT (2 bytes) vs BIGINT (8 bytes) are negligible + /// on modern systems, while the cost of migrating when you exceed the limit is high. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// age smallint + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE products ( + /// quantity smallserial + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE users ( + /// age integer + /// ); + /// ``` + /// + /// ```sql + /// CREATE TABLE products ( + /// quantity bigint + /// ); + /// ``` + /// + pub PreferBigintOverSmallint { + version: "next", + name: "preferBigintOverSmallint", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-bigint-over-smallint")], + } +} + +impl Rule for PreferBigintOverSmallint { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn + || cmd.subtype() + == pgt_query::protobuf::AlterTableType::AtAlterColumnType + { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + let type_name_lower = name.sval.to_lowercase(); + let is_smallint = matches!( + type_name_lower.as_str(), + "smallint" | "int2" | "smallserial" | "serial2" + ); + + if is_smallint { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "SMALLINT has a very limited range that is easily exceeded." + }, + ) + .detail(None, "SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient.") + .note("Consider using INTEGER or BIGINT for better range and future-proofing."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_identity.rs b/crates/pgt_analyser/src/lint/safety/prefer_identity.rs new file mode 100644 index 000000000..33c3195cd --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_identity.rs @@ -0,0 +1,119 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer using IDENTITY columns over serial columns. + /// + /// SERIAL types (serial, serial2, serial4, serial8, smallserial, bigserial) use sequences behind + /// the scenes but with some limitations. IDENTITY columns provide better control over sequence + /// behavior and are part of the SQL standard. + /// + /// IDENTITY columns offer clearer ownership semantics - the sequence is directly tied to the column + /// and will be automatically dropped when the column or table is dropped. They also provide better + /// control through GENERATED ALWAYS (prevents manual inserts) or GENERATED BY DEFAULT options. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// create table users ( + /// id serial + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// create table users ( + /// id bigserial + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// create table users ( + /// id bigint generated by default as identity primary key + /// ); + /// ``` + /// + /// ```sql + /// create table users ( + /// id bigint generated always as identity primary key + /// ); + /// ``` + /// + pub PreferIdentity { + version: "next", + name: "preferIdentity", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-identity")], + } +} + +impl Rule for PreferIdentity { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if matches!( + cmd.subtype(), + pgt_query::protobuf::AlterTableType::AtAddColumn + | pgt_query::protobuf::AlterTableType::AtAlterColumnType + ) { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + if matches!( + name.sval.as_str(), + "serial" | "serial2" | "serial4" | "serial8" | "smallserial" | "bigserial" + ) { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Prefer IDENTITY columns over SERIAL types." + }, + ) + .detail(None, format!("Column uses '{}' type which has limitations compared to IDENTITY columns.", name.sval)) + .note("Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index facf5405a..0f48d983d 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -10,6 +10,7 @@ pub type AddingPrimaryKeyConstraint = < lint :: safety :: adding_primary_key_con pub type AddingRequiredField = ::Options; pub type BanCharField = ::Options; +pub type BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as pgt_analyse :: Rule > :: Options ; pub type BanDropColumn = ::Options; pub type BanDropDatabase = @@ -19,3 +20,12 @@ pub type BanDropNotNull = pub type BanDropTable = ::Options; pub type BanTruncateCascade = ::Options; +pub type ChangingColumnType = + ::Options; +pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as pgt_analyse :: Rule > :: Options ; +pub type PreferBigInt = ::Options; +pub type PreferBigintOverInt = + ::Options; +pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as pgt_analyse :: Rule > :: Options ; +pub type PreferIdentity = + ::Options; diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap new file mode 100644 index 000000000..674effc58 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap @@ -0,0 +1,19 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/addingPrimaryKeyConstraint +ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; +``` + +# Diagnostics +lint/safety/addingPrimaryKeyConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a PRIMARY KEY constraint results in locks and table rewrites. + + i Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads. + + i Add the PRIMARY KEY constraint USING an index. diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap new file mode 100644 index 000000000..86b2431e4 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/addingPrimaryKeyConstraint +-- This should not trigger the rule - using an existing index +ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; +``` diff --git a/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql new file mode 100644 index 000000000..2d9f5daab --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/banConcurrentIndexCreationInTransaction +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql new file mode 100644 index 000000000..8b745d2c3 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/changingColumnType +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql new file mode 100644 index 000000000..6b7298a0a --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/constraintMissingNotValid +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql new file mode 100644 index 000000000..6e2b9ec1c --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql @@ -0,0 +1,4 @@ +-- expect_only_lint/safety/preferBigInt +CREATE TABLE users ( + id integer +); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap new file mode 100644 index 000000000..075396bad --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferBigInt +CREATE TABLE users ( + id integer +); + +``` + +# Diagnostics +lint/safety/preferBigInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Using smaller integer types can lead to overflow issues. + + i The 'int4' type has a limited range that may be exceeded as your data grows. + + i Consider using BIGINT for integer columns to avoid future migration issues. diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql new file mode 100644 index 000000000..e17e80904 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql @@ -0,0 +1,4 @@ +-- expect_only_lint/safety/preferBigintOverInt +CREATE TABLE users ( + id integer +); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap new file mode 100644 index 000000000..6f7337a61 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferBigintOverInt +CREATE TABLE users ( + id integer +); + +``` + +# Diagnostics +lint/safety/preferBigintOverInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × INTEGER type may lead to overflow issues. + + i INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters. + + i Consider using BIGINT instead for better future-proofing. diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql new file mode 100644 index 000000000..af434bf1a --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql @@ -0,0 +1,4 @@ +-- expect_only_lint/safety/preferBigintOverSmallint +CREATE TABLE users ( + age smallint +); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap new file mode 100644 index 000000000..2596df493 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferBigintOverSmallint +CREATE TABLE users ( + age smallint +); + +``` + +# Diagnostics +lint/safety/preferBigintOverSmallint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × SMALLINT has a very limited range that is easily exceeded. + + i SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient. + + i Consider using INTEGER or BIGINT for better range and future-proofing. diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql new file mode 100644 index 000000000..99760b9ea --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/preferIdentity +alter table test add column id serial; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql new file mode 100644 index 000000000..8316cb735 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql @@ -0,0 +1,4 @@ +-- expect_only_lint/safety/preferIdentity +create table users ( + id serial +); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap new file mode 100644 index 000000000..18993ef03 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferIdentity +create table users ( + id serial +); +``` + +# Diagnostics +lint/safety/preferIdentity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Prefer IDENTITY columns over SERIAL types. + + i Column uses 'serial' type which has limitations compared to IDENTITY columns. + + i Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead. diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql new file mode 100644 index 000000000..dc176d0cc --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql @@ -0,0 +1,4 @@ +-- expect_only_lint/safety/preferIdentity +create table users ( + id bigserial +); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql new file mode 100644 index 000000000..3749c9697 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql @@ -0,0 +1,4 @@ +-- expect_no_lint/safety/preferIdentity +create table users_valid ( + id bigint generated by default as identity primary key +); \ No newline at end of file diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index 14a1b1885..6f21caad9 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -163,6 +163,10 @@ pub struct Safety { #[doc = "Using CHAR(n) or CHARACTER(n) types is discouraged."] #[serde(skip_serializing_if = "Option::is_none")] pub ban_char_field: Option>, + #[doc = "Concurrent index creation is not allowed within a transaction."] + #[serde(skip_serializing_if = "Option::is_none")] + pub ban_concurrent_index_creation_in_transaction: + Option>, #[doc = "Dropping a column may break existing clients."] #[serde(skip_serializing_if = "Option::is_none")] pub ban_drop_column: Option>, @@ -178,6 +182,27 @@ pub struct Safety { #[doc = "Using TRUNCATE's CASCADE option will truncate any tables that are also foreign-keyed to the specified tables."] #[serde(skip_serializing_if = "Option::is_none")] pub ban_truncate_cascade: Option>, + #[doc = "Changing a column type may break existing clients."] + #[serde(skip_serializing_if = "Option::is_none")] + pub changing_column_type: Option>, + #[doc = "Adding constraints without NOT VALID blocks all reads and writes."] + #[serde(skip_serializing_if = "Option::is_none")] + pub constraint_missing_not_valid: + Option>, + #[doc = "Prefer BIGINT over smaller integer types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_big_int: Option>, + #[doc = "Prefer BIGINT over INT/INTEGER types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_bigint_over_int: + Option>, + #[doc = "Prefer BIGINT over SMALLINT types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_bigint_over_smallint: + Option>, + #[doc = "Prefer using IDENTITY columns over serial columns."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_identity: Option>, } impl Safety { const GROUP_NAME: &'static str = "safety"; @@ -188,11 +213,18 @@ impl Safety { "addingPrimaryKeyConstraint", "addingRequiredField", "banCharField", + "banConcurrentIndexCreationInTransaction", "banDropColumn", "banDropDatabase", "banDropNotNull", "banDropTable", "banTruncateCascade", + "changingColumnType", + "constraintMissingNotValid", + "preferBigInt", + "preferBigintOverInt", + "preferBigintOverSmallint", + "preferIdentity", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -200,8 +232,10 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -215,6 +249,15 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -261,31 +304,66 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.ban_drop_column.as_ref() { + if let Some(rule) = self.ban_concurrent_index_creation_in_transaction.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.ban_drop_database.as_ref() { + if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.ban_drop_not_null.as_ref() { + if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.ban_drop_table.as_ref() { + if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } + if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); + } + } + if let Some(rule) = self.changing_column_type.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); + } + } + if let Some(rule) = self.constraint_missing_not_valid.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); + } + } + if let Some(rule) = self.prefer_big_int.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); + } + } + if let Some(rule) = self.prefer_bigint_over_int.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); + } + } + if let Some(rule) = self.prefer_bigint_over_smallint.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); + } + } + if let Some(rule) = self.prefer_identity.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -320,31 +398,66 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.ban_drop_column.as_ref() { + if let Some(rule) = self.ban_concurrent_index_creation_in_transaction.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.ban_drop_database.as_ref() { + if let Some(rule) = self.ban_drop_column.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.ban_drop_not_null.as_ref() { + if let Some(rule) = self.ban_drop_database.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.ban_drop_table.as_ref() { + if let Some(rule) = self.ban_drop_not_null.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if let Some(rule) = self.ban_drop_table.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } + if let Some(rule) = self.ban_truncate_cascade.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); + } + } + if let Some(rule) = self.changing_column_type.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); + } + } + if let Some(rule) = self.constraint_missing_not_valid.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); + } + } + if let Some(rule) = self.prefer_big_int.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); + } + } + if let Some(rule) = self.prefer_bigint_over_int.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); + } + } + if let Some(rule) = self.prefer_bigint_over_smallint.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); + } + } + if let Some(rule) = self.prefer_identity.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -380,11 +493,18 @@ impl Safety { "addingPrimaryKeyConstraint" => Severity::Warning, "addingRequiredField" => Severity::Error, "banCharField" => Severity::Warning, + "banConcurrentIndexCreationInTransaction" => Severity::Error, "banDropColumn" => Severity::Warning, "banDropDatabase" => Severity::Warning, "banDropNotNull" => Severity::Warning, "banDropTable" => Severity::Warning, "banTruncateCascade" => Severity::Error, + "changingColumnType" => Severity::Warning, + "constraintMissingNotValid" => Severity::Warning, + "preferBigInt" => Severity::Warning, + "preferBigintOverInt" => Severity::Warning, + "preferBigintOverSmallint" => Severity::Warning, + "preferIdentity" => Severity::Warning, _ => unreachable!(), } } @@ -417,6 +537,10 @@ impl Safety { .ban_char_field .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "banConcurrentIndexCreationInTransaction" => self + .ban_concurrent_index_creation_in_transaction + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "banDropColumn" => self .ban_drop_column .as_ref() @@ -437,6 +561,30 @@ impl Safety { .ban_truncate_cascade .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "changingColumnType" => self + .changing_column_type + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "constraintMissingNotValid" => self + .constraint_missing_not_valid + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "preferBigInt" => self + .prefer_big_int + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "preferBigintOverInt" => self + .prefer_bigint_over_int + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "preferBigintOverSmallint" => self + .prefer_bigint_over_smallint + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "preferIdentity" => self + .prefer_identity + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), _ => None, } } diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index e80238724..7dc5c3475 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -19,11 +19,18 @@ define_categories! { "lint/safety/addingPrimaryKeyConstraint": "https://pgtools.dev/latest/rules/adding-primary-key-constraint", "lint/safety/addingRequiredField": "https://pgtools.dev/latest/rules/adding-required-field", "lint/safety/banCharField": "https://pgtools.dev/latest/rules/ban-char-field", + "lint/safety/banConcurrentIndexCreationInTransaction": "https://pgtools.dev/latest/rules/ban-concurrent-index-creation-in-transaction", "lint/safety/banDropColumn": "https://pgtools.dev/latest/rules/ban-drop-column", "lint/safety/banDropDatabase": "https://pgtools.dev/latest/rules/ban-drop-database", "lint/safety/banDropNotNull": "https://pgtools.dev/latest/rules/ban-drop-not-null", "lint/safety/banDropTable": "https://pgtools.dev/latest/rules/ban-drop-table", "lint/safety/banTruncateCascade": "https://pgtools.dev/latest/rules/ban-truncate-cascade", + "lint/safety/changingColumnType": "https://pgtools.dev/latest/rules/changing-column-type", + "lint/safety/constraintMissingNotValid": "https://pgtools.dev/latest/rules/constraint-missing-not-valid", + "lint/safety/preferBigInt": "https://pgtools.dev/latest/rules/prefer-big-int", + "lint/safety/preferBigintOverInt": "https://pgtools.dev/latest/rules/prefer-bigint-over-int", + "lint/safety/preferBigintOverSmallint": "https://pgtools.dev/latest/rules/prefer-bigint-over-smallint", + "lint/safety/preferIdentity": "https://pgtools.dev/latest/rules/prefer-identity", // end lint rules ; // General categories diff --git a/docs/rule_sources.md b/docs/rule_sources.md index d730caf15..80e4d3e6b 100644 --- a/docs/rule_sources.md +++ b/docs/rule_sources.md @@ -9,8 +9,15 @@ | [adding-required-field](https://squawkhq.com/docs/adding-required-field) |[addingRequiredField](../rules/adding-required-field) | | [adding-serial-primary-key-field](https://squawkhq.com/docs/adding-serial-primary-key-field) |[addingPrimaryKeyConstraint](../rules/adding-primary-key-constraint) | | [ban-char-field](https://squawkhq.com/docs/ban-char-field) |[banCharField](../rules/ban-char-field) | +| [ban-concurrent-index-creation-in-transaction](https://squawkhq.com/docs/ban-concurrent-index-creation-in-transaction) |[banConcurrentIndexCreationInTransaction](../rules/ban-concurrent-index-creation-in-transaction) | | [ban-drop-column](https://squawkhq.com/docs/ban-drop-column) |[banDropColumn](../rules/ban-drop-column) | | [ban-drop-database](https://squawkhq.com/docs/ban-drop-database) |[banDropDatabase](../rules/ban-drop-database) | | [ban-drop-not-null](https://squawkhq.com/docs/ban-drop-not-null) |[banDropNotNull](../rules/ban-drop-not-null) | | [ban-drop-table](https://squawkhq.com/docs/ban-drop-table) |[banDropTable](../rules/ban-drop-table) | | [ban-truncate-cascade](https://squawkhq.com/docs/ban-truncate-cascade) |[banTruncateCascade](../rules/ban-truncate-cascade) | +| [changing-column-type](https://squawkhq.com/docs/changing-column-type) |[changingColumnType](../rules/changing-column-type) | +| [constraint-missing-not-valid](https://squawkhq.com/docs/constraint-missing-not-valid) |[constraintMissingNotValid](../rules/constraint-missing-not-valid) | +| [prefer-big-int](https://squawkhq.com/docs/prefer-big-int) |[preferBigInt](../rules/prefer-big-int) | +| [prefer-bigint-over-int](https://squawkhq.com/docs/prefer-bigint-over-int) |[preferBigintOverInt](../rules/prefer-bigint-over-int) | +| [prefer-bigint-over-smallint](https://squawkhq.com/docs/prefer-bigint-over-smallint) |[preferBigintOverSmallint](../rules/prefer-bigint-over-smallint) | +| [prefer-identity](https://squawkhq.com/docs/prefer-identity) |[preferIdentity](../rules/prefer-identity) | diff --git a/docs/rules.md b/docs/rules.md index 3c3a496bb..bdf47f646 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -18,11 +18,18 @@ Rules that detect potential safety issues in your code. | [addingPrimaryKeyConstraint](./adding-primary-key-constraint) | Adding a primary key constraint results in locks and table rewrites. | ✅ | | [addingRequiredField](./adding-required-field) | Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required. | | | [banCharField](./ban-char-field) | Using CHAR(n) or CHARACTER(n) types is discouraged. | | +| [banConcurrentIndexCreationInTransaction](./ban-concurrent-index-creation-in-transaction) | Concurrent index creation is not allowed within a transaction. | ✅ | | [banDropColumn](./ban-drop-column) | Dropping a column may break existing clients. | ✅ | | [banDropDatabase](./ban-drop-database) | Dropping a database may break existing clients (and everything else, really). | | | [banDropNotNull](./ban-drop-not-null) | Dropping a NOT NULL constraint may break existing clients. | ✅ | | [banDropTable](./ban-drop-table) | Dropping a table may break existing clients. | ✅ | | [banTruncateCascade](./ban-truncate-cascade) | Using `TRUNCATE`'s `CASCADE` option will truncate any tables that are also foreign-keyed to the specified tables. | | +| [changingColumnType](./changing-column-type) | Changing a column type may break existing clients. | | +| [constraintMissingNotValid](./constraint-missing-not-valid) | Adding constraints without NOT VALID blocks all reads and writes. | | +| [preferBigInt](./prefer-big-int) | Prefer BIGINT over smaller integer types. | | +| [preferBigintOverInt](./prefer-bigint-over-int) | Prefer BIGINT over INT/INTEGER types. | | +| [preferBigintOverSmallint](./prefer-bigint-over-smallint) | Prefer BIGINT over SMALLINT types. | | +| [preferIdentity](./prefer-identity) | Prefer using IDENTITY columns over serial columns. | | [//]: # (END RULES_INDEX) diff --git a/docs/rules/ban-concurrent-index-creation-in-transaction.md b/docs/rules/ban-concurrent-index-creation-in-transaction.md new file mode 100644 index 000000000..66e2df3d6 --- /dev/null +++ b/docs/rules/ban-concurrent-index-creation-in-transaction.md @@ -0,0 +1,54 @@ +# banConcurrentIndexCreationInTransaction +**Diagnostic Category: `lint/safety/banConcurrentIndexCreationInTransaction`** + +**Since**: `vnext` + +> [!NOTE] +> This rule is recommended. A diagnostic error will appear when linting your code. + +**Sources**: +- Inspired from: squawk/ban-concurrent-index-creation-in-transaction + +## Description +Concurrent index creation is not allowed within a transaction. + +`CREATE INDEX CONCURRENTLY` cannot be used within a transaction block. This will cause an error in Postgres. + +Migration tools usually run each migration in a transaction, so using `CREATE INDEX CONCURRENTLY` will fail in such tools. + +## Examples + +### Invalid + +```sql +CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); +``` + +```sh +code-block.sql:1:1 lint/safety/banConcurrentIndexCreationInTransaction ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × CREATE INDEX CONCURRENTLY cannot be used inside a transaction block. + + > 1 │ CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Run CREATE INDEX CONCURRENTLY outside of a transaction. Migration tools usually run in transactions, so you may need to run this statement manually. + + +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "banConcurrentIndexCreationInTransaction": "error" + } + } + } +} + +``` diff --git a/docs/rules/changing-column-type.md b/docs/rules/changing-column-type.md new file mode 100644 index 000000000..ac5d2cd84 --- /dev/null +++ b/docs/rules/changing-column-type.md @@ -0,0 +1,54 @@ +# changingColumnType +**Diagnostic Category: `lint/safety/changingColumnType`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/changing-column-type + +## Description +Changing a column type may break existing clients. + +Changing a column's data type requires an exclusive lock on the table while the entire table is rewritten. +This can take a long time for large tables and will block reads and writes. + +Instead of changing the type directly, consider creating a new column with the desired type, +migrating the data, and then dropping the old column. + +## Examples + +### Invalid + +```sql +ALTER TABLE "core_recipe" ALTER COLUMN "edits" TYPE text USING "edits"::text; +``` + +```sh +code-block.sql:1:1 lint/safety/changingColumnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Changing a column type requires a table rewrite and blocks reads and writes. + + > 1 │ ALTER TABLE "core_recipe" ALTER COLUMN "edits" TYPE text USING "edits"::text; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Consider creating a new column with the desired type, migrating data, and then dropping the old column. + + +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "changingColumnType": "error" + } + } + } +} + +``` diff --git a/docs/rules/constraint-missing-not-valid.md b/docs/rules/constraint-missing-not-valid.md new file mode 100644 index 000000000..a22ed85a2 --- /dev/null +++ b/docs/rules/constraint-missing-not-valid.md @@ -0,0 +1,60 @@ +# constraintMissingNotValid +**Diagnostic Category: `lint/safety/constraintMissingNotValid`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/constraint-missing-not-valid + +## Description +Adding constraints without NOT VALID blocks all reads and writes. + +When adding a CHECK or FOREIGN KEY constraint, PostgreSQL must validate all existing rows, +which requires a full table scan. This blocks reads and writes for the duration. + +Instead, add the constraint with NOT VALID first, then VALIDATE CONSTRAINT in a separate +transaction. This allows reads and writes to continue while validation happens. + +## Examples + +### Invalid + +```sql +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address); +``` + +```sh +code-block.sql:1:1 lint/safety/constraintMissingNotValid ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Adding a constraint without NOT VALID will block reads and writes while validating existing rows. + + > 1 │ ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction. + + +``` + +### Valid + +```sql +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address) NOT VALID; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "constraintMissingNotValid": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-big-int.md b/docs/rules/prefer-big-int.md new file mode 100644 index 000000000..238808d0f --- /dev/null +++ b/docs/rules/prefer-big-int.md @@ -0,0 +1,101 @@ +# preferBigInt +**Diagnostic Category: `lint/safety/preferBigInt`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-big-int + +## Description +Prefer BIGINT over smaller integer types. + +Using smaller integer types like SMALLINT, INTEGER, or their aliases can lead to overflow +issues as your application grows. BIGINT provides a much larger range and helps avoid +future migration issues when values exceed the limits of smaller types. + +The storage difference between INTEGER (4 bytes) and BIGINT (8 bytes) is minimal on +modern systems, while the cost of migrating to a larger type later can be significant. + +## Examples + +### Invalid + +```sql +CREATE TABLE users ( + id integer +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Using smaller integer types can lead to overflow issues. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id integer + > 3 │ ); + │ ^^ + 4 │ + + i The 'int4' type has a limited range that may be exceeded as your data grows. + + i Consider using BIGINT for integer columns to avoid future migration issues. + + +``` + +```sql +CREATE TABLE users ( + id serial +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Using smaller integer types can lead to overflow issues. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id serial + > 3 │ ); + │ ^^ + 4 │ + + i The 'serial' type has a limited range that may be exceeded as your data grows. + + i Consider using BIGINT for integer columns to avoid future migration issues. + + +``` + +### Valid + +```sql +CREATE TABLE users ( + id bigint +); +``` + +```sql +CREATE TABLE users ( + id bigserial +); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferBigInt": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-bigint-over-int.md b/docs/rules/prefer-bigint-over-int.md new file mode 100644 index 000000000..88c45972a --- /dev/null +++ b/docs/rules/prefer-bigint-over-int.md @@ -0,0 +1,107 @@ +# preferBigintOverInt +**Diagnostic Category: `lint/safety/preferBigintOverInt`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-bigint-over-int + +## Description +Prefer BIGINT over INT/INTEGER types. + +Using INTEGER (INT4) can lead to overflow issues, especially for ID columns. +While SMALLINT might be acceptable for certain use cases with known small ranges, +INTEGER often becomes a limiting factor as applications grow. + +The storage difference between INTEGER (4 bytes) and BIGINT (8 bytes) is minimal, +but the cost of migrating when you hit the 2.1 billion limit can be significant. + +## Examples + +### Invalid + +```sql +CREATE TABLE users ( + id integer +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigintOverInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! INTEGER type may lead to overflow issues. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id integer + > 3 │ ); + │ ^^ + 4 │ + + i INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters. + + i Consider using BIGINT instead for better future-proofing. + + +``` + +```sql +CREATE TABLE users ( + id serial +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigintOverInt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! INTEGER type may lead to overflow issues. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id serial + > 3 │ ); + │ ^^ + 4 │ + + i INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters. + + i Consider using BIGINT instead for better future-proofing. + + +``` + +### Valid + +```sql +CREATE TABLE users ( + id bigint +); +``` + +```sql +CREATE TABLE users ( + id bigserial +); +``` + +```sql +CREATE TABLE users ( + id smallint +); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferBigintOverInt": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-bigint-over-smallint.md b/docs/rules/prefer-bigint-over-smallint.md new file mode 100644 index 000000000..4688874b4 --- /dev/null +++ b/docs/rules/prefer-bigint-over-smallint.md @@ -0,0 +1,101 @@ +# preferBigintOverSmallint +**Diagnostic Category: `lint/safety/preferBigintOverSmallint`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-bigint-over-smallint + +## Description +Prefer BIGINT over SMALLINT types. + +SMALLINT has a very limited range (-32,768 to 32,767) that is easily exceeded. +Even for values that seem small initially, using SMALLINT can lead to problems +as your application grows. + +The storage savings of SMALLINT (2 bytes) vs BIGINT (8 bytes) are negligible +on modern systems, while the cost of migrating when you exceed the limit is high. + +## Examples + +### Invalid + +```sql +CREATE TABLE users ( + age smallint +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigintOverSmallint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! SMALLINT has a very limited range that is easily exceeded. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ age smallint + > 3 │ ); + │ ^^ + 4 │ + + i SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient. + + i Consider using INTEGER or BIGINT for better range and future-proofing. + + +``` + +```sql +CREATE TABLE products ( + quantity smallserial +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferBigintOverSmallint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! SMALLINT has a very limited range that is easily exceeded. + + > 1 │ CREATE TABLE products ( + │ ^^^^^^^^^^^^^^^^^^^^^^^ + > 2 │ quantity smallserial + > 3 │ ); + │ ^^ + 4 │ + + i SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient. + + i Consider using INTEGER or BIGINT for better range and future-proofing. + + +``` + +### Valid + +```sql +CREATE TABLE users ( + age integer +); +``` + +```sql +CREATE TABLE products ( + quantity bigint +); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferBigintOverSmallint": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-identity.md b/docs/rules/prefer-identity.md new file mode 100644 index 000000000..956a8fcb4 --- /dev/null +++ b/docs/rules/prefer-identity.md @@ -0,0 +1,102 @@ +# preferIdentity +**Diagnostic Category: `lint/safety/preferIdentity`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-identity + +## Description +Prefer using IDENTITY columns over serial columns. + +SERIAL types (serial, serial2, serial4, serial8, smallserial, bigserial) use sequences behind +the scenes but with some limitations. IDENTITY columns provide better control over sequence +behavior and are part of the SQL standard. + +IDENTITY columns offer clearer ownership semantics - the sequence is directly tied to the column +and will be automatically dropped when the column or table is dropped. They also provide better +control through GENERATED ALWAYS (prevents manual inserts) or GENERATED BY DEFAULT options. + +## Examples + +### Invalid + +```sql +create table users ( + id serial +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferIdentity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer IDENTITY columns over SERIAL types. + + > 1 │ create table users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id serial + > 3 │ ); + │ ^^ + 4 │ + + i Column uses 'serial' type which has limitations compared to IDENTITY columns. + + i Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead. + + +``` + +```sql +create table users ( + id bigserial +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferIdentity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer IDENTITY columns over SERIAL types. + + > 1 │ create table users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ id bigserial + > 3 │ ); + │ ^^ + 4 │ + + i Column uses 'bigserial' type which has limitations compared to IDENTITY columns. + + i Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead. + + +``` + +### Valid + +```sql +create table users ( + id bigint generated by default as identity primary key +); +``` + +```sql +create table users ( + id bigint generated always as identity primary key +); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferIdentity": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index 5930beb0f..0224f0497 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -429,6 +429,17 @@ } ] }, + "banConcurrentIndexCreationInTransaction": { + "description": "Concurrent index creation is not allowed within a transaction.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "banDropColumn": { "description": "Dropping a column may break existing clients.", "anyOf": [ @@ -484,6 +495,72 @@ } ] }, + "changingColumnType": { + "description": "Changing a column type may break existing clients.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "constraintMissingNotValid": { + "description": "Adding constraints without NOT VALID blocks all reads and writes.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "preferBigInt": { + "description": "Prefer BIGINT over smaller integer types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "preferBigintOverInt": { + "description": "Prefer BIGINT over INT/INTEGER types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "preferBigintOverSmallint": { + "description": "Prefer BIGINT over SMALLINT types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "preferIdentity": { + "description": "Prefer using IDENTITY columns over serial columns.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "recommended": { "description": "It enables the recommended rules for this group", "type": [ @@ -584,4 +661,4 @@ "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index abd224aeb..7f3c9beed 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -69,11 +69,18 @@ export type Category = | "lint/safety/addingPrimaryKeyConstraint" | "lint/safety/addingRequiredField" | "lint/safety/banCharField" + | "lint/safety/banConcurrentIndexCreationInTransaction" | "lint/safety/banDropColumn" | "lint/safety/banDropDatabase" | "lint/safety/banDropNotNull" | "lint/safety/banDropTable" | "lint/safety/banTruncateCascade" + | "lint/safety/changingColumnType" + | "lint/safety/constraintMissingNotValid" + | "lint/safety/preferBigInt" + | "lint/safety/preferBigintOverInt" + | "lint/safety/preferBigintOverSmallint" + | "lint/safety/preferIdentity" | "stdin" | "check" | "configuration" @@ -105,7 +112,7 @@ export type DiagnosticTags = DiagnosticTag[]; /** * Serializable representation of a [Diagnostic](super::Diagnostic) advice -See the [Visitor] trait for additional documentation on all the supported advice types. +See the [Visitor] trait for additional documentation on all the supported advice types. */ export type Advice = | { log: [LogCategory, MarkupBuf] } @@ -210,7 +217,7 @@ export interface CompletionItem { /** * The text that the editor should fill in. If `None`, the `label` should be used. Tables, for example, might have different completion_texts: -label: "users", description: "Schema: auth", completion_text: "auth.users". +label: "users", description: "Schema: auth", completion_text: "auth.users". */ export interface CompletionText { is_snippet: boolean; @@ -394,7 +401,7 @@ export interface PartialVcsConfiguration { /** * The folder where we should check for VCS files. By default, we will use the same folder where `postgrestools.jsonc` was found. -If we can't find the configuration, it will attempt to use the current working directory. If no current working directory can't be found, we won't use the VCS integration, and a diagnostic will be emitted +If we can't find the configuration, it will attempt to use the current working directory. If no current working directory can't be found, we won't use the VCS integration, and a diagnostic will be emitted */ root?: string; /** @@ -446,6 +453,10 @@ export interface Safety { * Using CHAR(n) or CHARACTER(n) types is discouraged. */ banCharField?: RuleConfiguration_for_Null; + /** + * Concurrent index creation is not allowed within a transaction. + */ + banConcurrentIndexCreationInTransaction?: RuleConfiguration_for_Null; /** * Dropping a column may break existing clients. */ @@ -466,6 +477,30 @@ export interface Safety { * Using TRUNCATE's CASCADE option will truncate any tables that are also foreign-keyed to the specified tables. */ banTruncateCascade?: RuleConfiguration_for_Null; + /** + * Changing a column type may break existing clients. + */ + changingColumnType?: RuleConfiguration_for_Null; + /** + * Adding constraints without NOT VALID blocks all reads and writes. + */ + constraintMissingNotValid?: RuleConfiguration_for_Null; + /** + * Prefer BIGINT over smaller integer types. + */ + preferBigInt?: RuleConfiguration_for_Null; + /** + * Prefer BIGINT over INT/INTEGER types. + */ + preferBigintOverInt?: RuleConfiguration_for_Null; + /** + * Prefer BIGINT over SMALLINT types. + */ + preferBigintOverSmallint?: RuleConfiguration_for_Null; + /** + * Prefer using IDENTITY columns over serial columns. + */ + preferIdentity?: RuleConfiguration_for_Null; /** * It enables the recommended rules for this group */ From 6895399a8db6d5b10978736c7bef4bb355fa8e75 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 10:06:52 +0200 Subject: [PATCH 06/23] progress --- PLAN.md | 8 +- crates/pgt_analyser/src/lint/safety.rs | 5 +- .../lint/safety/disallow_unique_constraint.rs | 129 ++++++++++++++++++ .../src/lint/safety/prefer_text_field.rs | 108 +++++++++++++++ .../src/lint/safety/prefer_timestamptz.rs | 122 +++++++++++++++++ crates/pgt_analyser/src/options.rs | 5 + .../src/analyser/linter/rules.rs | 60 +++++++- .../src/categories.rs | 3 + docs/rule_sources.md | 3 + docs/rules.md | 3 + ...oncurrent-index-creation-in-transaction.md | 11 -- docs/rules/disallow-unique-constraint.md | 78 +++++++++++ docs/rules/prefer-text-field.md | 88 ++++++++++++ docs/rules/prefer-timestamptz.md | 123 +++++++++++++++++ docs/schema.json | 35 ++++- .../backend-jsonrpc/src/workspace.ts | 21 ++- 16 files changed, 781 insertions(+), 21 deletions(-) create mode 100644 crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_text_field.rs create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_timestamptz.rs create mode 100644 docs/rules/disallow-unique-constraint.md create mode 100644 docs/rules/prefer-text-field.md create mode 100644 docs/rules/prefer-timestamptz.md diff --git a/PLAN.md b/PLAN.md index 5a5b34e27..805c546ca 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,7 +50,7 @@ LEARNINGS: - RuleDiagnostic methods: `detail(span, msg)` takes two parameters, `note(msg)` takes only one parameter - To check Postgres version: access `ctx.schema_cache().is_some_and(|sc| sc.version.major_version)` which gives e.g. 17 - NEVER skip anything, or use a subset of something. ALWAYS do the full thing. For example, copy the entire non-volatile functions list from Squawk, not just a subset. -- If you are missing features from our context to be able to properly implement a rule, DO NOT DO IT. Instead, add that rule to the NEEDS FEATURES list below. +- If you are missing features from our rule context to be able to properly implement a rule, DO NOT DO IT. Instead, add that rule to the NEEDS FEATURES list below. The node enum is generated from the same source as it is in squawk, so they have feature parity. - Remember to run `just gen-lint` after creating a new rule to generate all necessary files Please update the list below with the rules that we need to migrate, and the ones that are already migrated. Keep the list up-to-date. @@ -58,14 +58,11 @@ Please update the list below with the rules that we need to migrate, and the one NEEDS FEATURES: TODO: -- prefer_text_field -- prefer_timestamptz - renaming_column - renaming_table - require_concurrent_index_creation - require_concurrent_index_deletion - transaction_nesting -- disallow_unique_constraint - prefer_robust_stmts DONE: @@ -86,5 +83,8 @@ DONE: - prefer_bigint_over_int ✓ (ported from Squawk) - prefer_bigint_over_smallint ✓ (ported from Squawk) - prefer_identity ✓ (ported from Squawk) +- prefer_text_field ✓ (ported from Squawk) +- prefer_timestamptz ✓ (ported from Squawk) +- disallow_unique_constraint ✓ (ported from Squawk) diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index 661832ad2..ec18ed42d 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -15,8 +15,11 @@ pub mod ban_drop_table; pub mod ban_truncate_cascade; pub mod changing_column_type; pub mod constraint_missing_not_valid; +pub mod disallow_unique_constraint; pub mod prefer_big_int; pub mod prefer_bigint_over_int; pub mod prefer_bigint_over_smallint; pub mod prefer_identity; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , ] } } +pub mod prefer_text_field; +pub mod prefer_timestamptz; +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz ,] } } diff --git a/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs new file mode 100644 index 000000000..ec3aa7e17 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -0,0 +1,129 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Disallow adding a UNIQUE constraint without using an existing index. + /// + /// Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock, which blocks all reads and + /// writes to the table. Instead, create a unique index concurrently and then add the + /// constraint using that index. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE table_name ADD CONSTRAINT field_name_constraint UNIQUE (field_name); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE foo ADD COLUMN bar text UNIQUE; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE UNIQUE INDEX CONCURRENTLY dist_id_temp_idx ON distributors (dist_id); + /// ALTER TABLE distributors DROP CONSTRAINT distributors_pkey, + /// ADD CONSTRAINT distributors_pkey PRIMARY KEY USING INDEX dist_id_temp_idx; + /// ``` + /// + pub DisallowUniqueConstraint { + version: "next", + name: "disallowUniqueConstraint", + severity: Severity::Error, + recommended: false, + sources: &[RuleSource::Squawk("disallow-unique-constraint")], + } +} + +impl Rule for DisallowUniqueConstraint { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + // Check if this table was created in the same transaction + let table_name = stmt.relation.as_ref().map(|r| &r.relname); + + // Look for tables created in previous statements of this file + let table_created_in_transaction = if let Some(table_name) = table_name { + ctx.file_context().previous_stmts.iter().any(|prev_stmt| { + if let pgt_query::NodeEnum::CreateStmt(create) = prev_stmt { + create + .relation + .as_ref() + .map_or(false, |r| &r.relname == table_name) + } else { + false + } + }) + } else { + false + }; + + if !table_created_in_transaction { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + match cmd.subtype() { + pgt_query::protobuf::AlterTableType::AtAddConstraint => { + if let Some(pgt_query::NodeEnum::Constraint(constraint)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + // Check if it's a unique constraint without an existing index + if constraint.contype() + == pgt_query::protobuf::ConstrType::ConstrUnique + && constraint.indexname.is_empty() + { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock." + }, + ) + .note("Create a unique index CONCURRENTLY and then add the constraint using that index."), + ); + } + } + } + pgt_query::protobuf::AlterTableType::AtAddColumn => { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + // Check for inline unique constraints + for constraint in &col_def.constraints { + if let Some(pgt_query::NodeEnum::Constraint(constr)) = + &constraint.node + { + if constr.contype() + == pgt_query::protobuf::ConstrType::ConstrUnique + { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock." + }, + ) + .note("Create a unique index CONCURRENTLY and then add the constraint using that index."), + ); + } + } + } + } + } + _ => {} + } + } + } + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_text_field.rs b/crates/pgt_analyser/src/lint/safety/prefer_text_field.rs new file mode 100644 index 000000000..46f5bc757 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_text_field.rs @@ -0,0 +1,108 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer using TEXT over VARCHAR(n) types. + /// + /// Changing the size of a VARCHAR field requires an ACCESS EXCLUSIVE lock, which blocks all + /// reads and writes to the table. It's easier to update a check constraint on a TEXT field + /// than a VARCHAR() size since the check constraint can use NOT VALID with a separate + /// VALIDATE call. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE "core_bar" ( + /// "id" serial NOT NULL PRIMARY KEY, + /// "alpha" varchar(100) NOT NULL + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE "core_bar" ALTER COLUMN "kind" TYPE varchar(1000) USING "kind"::varchar(1000); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE "core_bar" ( + /// "id" serial NOT NULL PRIMARY KEY, + /// "bravo" text NOT NULL + /// ); + /// ALTER TABLE "core_bar" ADD CONSTRAINT "text_size" CHECK (LENGTH("bravo") <= 100); + /// ``` + /// + pub PreferTextField { + version: "next", + name: "preferTextField", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-text-field")], + } +} + +impl Rule for PreferTextField { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + match cmd.subtype() { + pgt_query::protobuf::AlterTableType::AtAddColumn + | pgt_query::protobuf::AlterTableType::AtAlterColumnType => { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + _ => {} + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + // Check if it's varchar with a size limit + if name.sval.to_lowercase() == "varchar" && !type_name.typmods.is_empty() { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Changing the size of a varchar field requires an ACCESS EXCLUSIVE lock." + }, + ) + .note("Use a text field with a check constraint."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_timestamptz.rs b/crates/pgt_analyser/src/lint/safety/prefer_timestamptz.rs new file mode 100644 index 000000000..fb34e61c4 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_timestamptz.rs @@ -0,0 +1,122 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer TIMESTAMPTZ over TIMESTAMP types. + /// + /// Using TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones. + /// TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) stores timestamps with time zone information, + /// making it safer for applications that handle multiple time zones or need to track + /// when events occurred in absolute time. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE app.users ( + /// created_ts timestamp + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE app.accounts ( + /// created_ts timestamp without time zone + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE app.users ALTER COLUMN created_ts TYPE timestamp; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE app.users ( + /// created_ts timestamptz + /// ); + /// ``` + /// + /// ```sql + /// CREATE TABLE app.accounts ( + /// created_ts timestamp with time zone + /// ); + /// ``` + /// + /// ```sql + /// ALTER TABLE app.users ALTER COLUMN created_ts TYPE timestamptz; + /// ``` + /// + pub PreferTimestamptz { + version: "next", + name: "preferTimestamptz", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-timestamptz")], + } +} + +impl Rule for PreferTimestamptz { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + match cmd.subtype() { + pgt_query::protobuf::AlterTableType::AtAddColumn + | pgt_query::protobuf::AlterTableType::AtAlterColumnType => { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + _ => {} + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + if let Some(type_name) = &col_def.type_name { + if let Some(last_name) = type_name.names.last() { + if let Some(pgt_query::NodeEnum::String(name)) = &last_name.node { + // Check for "timestamp" (without timezone) + if name.sval.to_lowercase() == "timestamp" { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Prefer TIMESTAMPTZ over TIMESTAMP for better timezone handling." + }, + ) + .detail(None, "TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones.") + .note("Use TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) instead."), + ); + } + } + } + } +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index 0f48d983d..362882c10 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -23,9 +23,14 @@ pub type BanTruncateCascade = pub type ChangingColumnType = ::Options; pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as pgt_analyse :: Rule > :: Options ; +pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as pgt_analyse :: Rule > :: Options ; pub type PreferBigInt = ::Options; pub type PreferBigintOverInt = ::Options; pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as pgt_analyse :: Rule > :: Options ; pub type PreferIdentity = ::Options; +pub type PreferTextField = + ::Options; +pub type PreferTimestamptz = + ::Options; diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index 6f21caad9..0fea18efa 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -189,6 +189,10 @@ pub struct Safety { #[serde(skip_serializing_if = "Option::is_none")] pub constraint_missing_not_valid: Option>, + #[doc = "Disallow adding a UNIQUE constraint without using an existing index."] + #[serde(skip_serializing_if = "Option::is_none")] + pub disallow_unique_constraint: + Option>, #[doc = "Prefer BIGINT over smaller integer types."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_big_int: Option>, @@ -203,6 +207,12 @@ pub struct Safety { #[doc = "Prefer using IDENTITY columns over serial columns."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_identity: Option>, + #[doc = "Prefer using TEXT over VARCHAR(n) types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_text_field: Option>, + #[doc = "Prefer TIMESTAMPTZ over TIMESTAMP types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_timestamptz: Option>, } impl Safety { const GROUP_NAME: &'static str = "safety"; @@ -221,10 +231,13 @@ impl Safety { "banTruncateCascade", "changingColumnType", "constraintMissingNotValid", + "disallowUniqueConstraint", "preferBigInt", "preferBigintOverInt", "preferBigintOverSmallint", "preferIdentity", + "preferTextField", + "preferTimestamptz", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -235,7 +248,6 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -258,6 +270,7 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -344,6 +357,11 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } + if let Some(rule) = self.disallow_unique_constraint.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); + } + } if let Some(rule) = self.prefer_big_int.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); @@ -364,6 +382,16 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } + if let Some(rule) = self.prefer_text_field.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); + } + } + if let Some(rule) = self.prefer_timestamptz.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -438,6 +466,11 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } + if let Some(rule) = self.disallow_unique_constraint.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); + } + } if let Some(rule) = self.prefer_big_int.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); @@ -458,6 +491,16 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } + if let Some(rule) = self.prefer_text_field.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); + } + } + if let Some(rule) = self.prefer_timestamptz.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -501,10 +544,13 @@ impl Safety { "banTruncateCascade" => Severity::Error, "changingColumnType" => Severity::Warning, "constraintMissingNotValid" => Severity::Warning, + "disallowUniqueConstraint" => Severity::Error, "preferBigInt" => Severity::Warning, "preferBigintOverInt" => Severity::Warning, "preferBigintOverSmallint" => Severity::Warning, "preferIdentity" => Severity::Warning, + "preferTextField" => Severity::Warning, + "preferTimestamptz" => Severity::Warning, _ => unreachable!(), } } @@ -569,6 +615,10 @@ impl Safety { .constraint_missing_not_valid .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "disallowUniqueConstraint" => self + .disallow_unique_constraint + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "preferBigInt" => self .prefer_big_int .as_ref() @@ -585,6 +635,14 @@ impl Safety { .prefer_identity .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "preferTextField" => self + .prefer_text_field + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "preferTimestamptz" => self + .prefer_timestamptz + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), _ => None, } } diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index 7dc5c3475..12324908b 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -31,6 +31,9 @@ define_categories! { "lint/safety/preferBigintOverInt": "https://pgtools.dev/latest/rules/prefer-bigint-over-int", "lint/safety/preferBigintOverSmallint": "https://pgtools.dev/latest/rules/prefer-bigint-over-smallint", "lint/safety/preferIdentity": "https://pgtools.dev/latest/rules/prefer-identity", + "lint/safety/preferTextField": "https://pgtools.dev/latest/rules/prefer-text-field", + "lint/safety/preferTimestamptz": "https://pgtools.dev/latest/rules/prefer-timestamptz", + "lint/safety/disallowUniqueConstraint": "https://pgtools.dev/latest/rules/disallow-unique-constraint", // end lint rules ; // General categories diff --git a/docs/rule_sources.md b/docs/rule_sources.md index 80e4d3e6b..21acb8732 100644 --- a/docs/rule_sources.md +++ b/docs/rule_sources.md @@ -17,7 +17,10 @@ | [ban-truncate-cascade](https://squawkhq.com/docs/ban-truncate-cascade) |[banTruncateCascade](../rules/ban-truncate-cascade) | | [changing-column-type](https://squawkhq.com/docs/changing-column-type) |[changingColumnType](../rules/changing-column-type) | | [constraint-missing-not-valid](https://squawkhq.com/docs/constraint-missing-not-valid) |[constraintMissingNotValid](../rules/constraint-missing-not-valid) | +| [disallow-unique-constraint](https://squawkhq.com/docs/disallow-unique-constraint) |[disallowUniqueConstraint](../rules/disallow-unique-constraint) | | [prefer-big-int](https://squawkhq.com/docs/prefer-big-int) |[preferBigInt](../rules/prefer-big-int) | | [prefer-bigint-over-int](https://squawkhq.com/docs/prefer-bigint-over-int) |[preferBigintOverInt](../rules/prefer-bigint-over-int) | | [prefer-bigint-over-smallint](https://squawkhq.com/docs/prefer-bigint-over-smallint) |[preferBigintOverSmallint](../rules/prefer-bigint-over-smallint) | | [prefer-identity](https://squawkhq.com/docs/prefer-identity) |[preferIdentity](../rules/prefer-identity) | +| [prefer-text-field](https://squawkhq.com/docs/prefer-text-field) |[preferTextField](../rules/prefer-text-field) | +| [prefer-timestamptz](https://squawkhq.com/docs/prefer-timestamptz) |[preferTimestamptz](../rules/prefer-timestamptz) | diff --git a/docs/rules.md b/docs/rules.md index bdf47f646..2ee240e36 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -26,10 +26,13 @@ Rules that detect potential safety issues in your code. | [banTruncateCascade](./ban-truncate-cascade) | Using `TRUNCATE`'s `CASCADE` option will truncate any tables that are also foreign-keyed to the specified tables. | | | [changingColumnType](./changing-column-type) | Changing a column type may break existing clients. | | | [constraintMissingNotValid](./constraint-missing-not-valid) | Adding constraints without NOT VALID blocks all reads and writes. | | +| [disallowUniqueConstraint](./disallow-unique-constraint) | Disallow adding a UNIQUE constraint without using an existing index. | | | [preferBigInt](./prefer-big-int) | Prefer BIGINT over smaller integer types. | | | [preferBigintOverInt](./prefer-bigint-over-int) | Prefer BIGINT over INT/INTEGER types. | | | [preferBigintOverSmallint](./prefer-bigint-over-smallint) | Prefer BIGINT over SMALLINT types. | | | [preferIdentity](./prefer-identity) | Prefer using IDENTITY columns over serial columns. | | +| [preferTextField](./prefer-text-field) | Prefer using TEXT over VARCHAR(n) types. | | +| [preferTimestamptz](./prefer-timestamptz) | Prefer TIMESTAMPTZ over TIMESTAMP types. | | [//]: # (END RULES_INDEX) diff --git a/docs/rules/ban-concurrent-index-creation-in-transaction.md b/docs/rules/ban-concurrent-index-creation-in-transaction.md index 66e2df3d6..8e7e10c34 100644 --- a/docs/rules/ban-concurrent-index-creation-in-transaction.md +++ b/docs/rules/ban-concurrent-index-creation-in-transaction.md @@ -25,17 +25,6 @@ CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); ``` ```sh -code-block.sql:1:1 lint/safety/banConcurrentIndexCreationInTransaction ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - × CREATE INDEX CONCURRENTLY cannot be used inside a transaction block. - - > 1 │ CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 2 │ - - i Run CREATE INDEX CONCURRENTLY outside of a transaction. Migration tools usually run in transactions, so you may need to run this statement manually. - - ``` ## How to configure diff --git a/docs/rules/disallow-unique-constraint.md b/docs/rules/disallow-unique-constraint.md new file mode 100644 index 000000000..acf81bf4f --- /dev/null +++ b/docs/rules/disallow-unique-constraint.md @@ -0,0 +1,78 @@ +# disallowUniqueConstraint +**Diagnostic Category: `lint/safety/disallowUniqueConstraint`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/disallow-unique-constraint + +## Description +Disallow adding a UNIQUE constraint without using an existing index. + +Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock, which blocks all reads and +writes to the table. Instead, create a unique index concurrently and then add the +constraint using that index. + +## Examples + +### Invalid + +```sql +ALTER TABLE table_name ADD CONSTRAINT field_name_constraint UNIQUE (field_name); +``` + +```sh +code-block.sql:1:1 lint/safety/disallowUniqueConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock. + + > 1 │ ALTER TABLE table_name ADD CONSTRAINT field_name_constraint UNIQUE (field_name); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Create a unique index CONCURRENTLY and then add the constraint using that index. + + +``` + +```sql +ALTER TABLE foo ADD COLUMN bar text UNIQUE; +``` + +```sh +code-block.sql:1:1 lint/safety/disallowUniqueConstraint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a UNIQUE constraint requires an ACCESS EXCLUSIVE lock. + + > 1 │ ALTER TABLE foo ADD COLUMN bar text UNIQUE; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Create a unique index CONCURRENTLY and then add the constraint using that index. + + +``` + +### Valid + +```sql +CREATE UNIQUE INDEX CONCURRENTLY dist_id_temp_idx ON distributors (dist_id); +ALTER TABLE distributors DROP CONSTRAINT distributors_pkey, +ADD CONSTRAINT distributors_pkey PRIMARY KEY USING INDEX dist_id_temp_idx; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "disallowUniqueConstraint": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-text-field.md b/docs/rules/prefer-text-field.md new file mode 100644 index 000000000..7f8831580 --- /dev/null +++ b/docs/rules/prefer-text-field.md @@ -0,0 +1,88 @@ +# preferTextField +**Diagnostic Category: `lint/safety/preferTextField`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-text-field + +## Description +Prefer using TEXT over VARCHAR(n) types. + +Changing the size of a VARCHAR field requires an ACCESS EXCLUSIVE lock, which blocks all +reads and writes to the table. It's easier to update a check constraint on a TEXT field +than a VARCHAR() size since the check constraint can use NOT VALID with a separate +VALIDATE call. + +## Examples + +### Invalid + +```sql +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" varchar(100) NOT NULL +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferTextField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Changing the size of a varchar field requires an ACCESS EXCLUSIVE lock. + + > 1 │ CREATE TABLE "core_bar" ( + │ ^^^^^^^^^^^^^^^^^^^^^^^^^ + > 2 │ "id" serial NOT NULL PRIMARY KEY, + > 3 │ "alpha" varchar(100) NOT NULL + > 4 │ ); + │ ^^ + 5 │ + + i Use a text field with a check constraint. + + +``` + +```sql +ALTER TABLE "core_bar" ALTER COLUMN "kind" TYPE varchar(1000) USING "kind"::varchar(1000); +``` + +```sh +code-block.sql:1:1 lint/safety/preferTextField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Changing the size of a varchar field requires an ACCESS EXCLUSIVE lock. + + > 1 │ ALTER TABLE "core_bar" ALTER COLUMN "kind" TYPE varchar(1000) USING "kind"::varchar(1000); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Use a text field with a check constraint. + + +``` + +### Valid + +```sql +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "bravo" text NOT NULL +); +ALTER TABLE "core_bar" ADD CONSTRAINT "text_size" CHECK (LENGTH("bravo") <= 100); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferTextField": "error" + } + } + } +} + +``` diff --git a/docs/rules/prefer-timestamptz.md b/docs/rules/prefer-timestamptz.md new file mode 100644 index 000000000..b53962342 --- /dev/null +++ b/docs/rules/prefer-timestamptz.md @@ -0,0 +1,123 @@ +# preferTimestamptz +**Diagnostic Category: `lint/safety/preferTimestamptz`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-timestamptz + +## Description +Prefer TIMESTAMPTZ over TIMESTAMP types. + +Using TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones. +TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) stores timestamps with time zone information, +making it safer for applications that handle multiple time zones or need to track +when events occurred in absolute time. + +## Examples + +### Invalid + +```sql +CREATE TABLE app.users ( + created_ts timestamp +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferTimestamptz ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer TIMESTAMPTZ over TIMESTAMP for better timezone handling. + + > 1 │ CREATE TABLE app.users ( + │ ^^^^^^^^^^^^^^^^^^^^^^^^ + > 2 │ created_ts timestamp + > 3 │ ); + │ ^^ + 4 │ + + i TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones. + + i Use TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) instead. + + +``` + +```sql +CREATE TABLE app.accounts ( + created_ts timestamp without time zone +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferTimestamptz ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer TIMESTAMPTZ over TIMESTAMP for better timezone handling. + + > 1 │ CREATE TABLE app.accounts ( + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + > 2 │ created_ts timestamp without time zone + > 3 │ ); + │ ^^ + 4 │ + + i TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones. + + i Use TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) instead. + + +``` + +```sql +ALTER TABLE app.users ALTER COLUMN created_ts TYPE timestamp; +``` + +```sh +code-block.sql:1:1 lint/safety/preferTimestamptz ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer TIMESTAMPTZ over TIMESTAMP for better timezone handling. + + > 1 │ ALTER TABLE app.users ALTER COLUMN created_ts TYPE timestamp; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i TIMESTAMP WITHOUT TIME ZONE can lead to issues when dealing with time zones. + + i Use TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) instead. + + +``` + +### Valid + +```sql +CREATE TABLE app.users ( + created_ts timestamptz +); +``` + +```sql +CREATE TABLE app.accounts ( + created_ts timestamp with time zone +); +``` + +```sql +ALTER TABLE app.users ALTER COLUMN created_ts TYPE timestamptz; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferTimestamptz": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index 0224f0497..9a2d0e8a1 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -517,6 +517,17 @@ } ] }, + "disallowUniqueConstraint": { + "description": "Disallow adding a UNIQUE constraint without using an existing index.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "preferBigInt": { "description": "Prefer BIGINT over smaller integer types.", "anyOf": [ @@ -561,6 +572,28 @@ } ] }, + "preferTextField": { + "description": "Prefer using TEXT over VARCHAR(n) types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "preferTimestamptz": { + "description": "Prefer TIMESTAMPTZ over TIMESTAMP types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "recommended": { "description": "It enables the recommended rules for this group", "type": [ @@ -661,4 +694,4 @@ "additionalProperties": false } } -} +} \ No newline at end of file diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 7f3c9beed..d9925f64d 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -81,6 +81,9 @@ export type Category = | "lint/safety/preferBigintOverInt" | "lint/safety/preferBigintOverSmallint" | "lint/safety/preferIdentity" + | "lint/safety/preferTextField" + | "lint/safety/preferTimestamptz" + | "lint/safety/disallowUniqueConstraint" | "stdin" | "check" | "configuration" @@ -112,7 +115,7 @@ export type DiagnosticTags = DiagnosticTag[]; /** * Serializable representation of a [Diagnostic](super::Diagnostic) advice -See the [Visitor] trait for additional documentation on all the supported advice types. +See the [Visitor] trait for additional documentation on all the supported advice types. */ export type Advice = | { log: [LogCategory, MarkupBuf] } @@ -217,7 +220,7 @@ export interface CompletionItem { /** * The text that the editor should fill in. If `None`, the `label` should be used. Tables, for example, might have different completion_texts: -label: "users", description: "Schema: auth", completion_text: "auth.users". +label: "users", description: "Schema: auth", completion_text: "auth.users". */ export interface CompletionText { is_snippet: boolean; @@ -401,7 +404,7 @@ export interface PartialVcsConfiguration { /** * The folder where we should check for VCS files. By default, we will use the same folder where `postgrestools.jsonc` was found. -If we can't find the configuration, it will attempt to use the current working directory. If no current working directory can't be found, we won't use the VCS integration, and a diagnostic will be emitted +If we can't find the configuration, it will attempt to use the current working directory. If no current working directory can't be found, we won't use the VCS integration, and a diagnostic will be emitted */ root?: string; /** @@ -485,6 +488,10 @@ export interface Safety { * Adding constraints without NOT VALID blocks all reads and writes. */ constraintMissingNotValid?: RuleConfiguration_for_Null; + /** + * Disallow adding a UNIQUE constraint without using an existing index. + */ + disallowUniqueConstraint?: RuleConfiguration_for_Null; /** * Prefer BIGINT over smaller integer types. */ @@ -501,6 +508,14 @@ export interface Safety { * Prefer using IDENTITY columns over serial columns. */ preferIdentity?: RuleConfiguration_for_Null; + /** + * Prefer using TEXT over VARCHAR(n) types. + */ + preferTextField?: RuleConfiguration_for_Null; + /** + * Prefer TIMESTAMPTZ over TIMESTAMP types. + */ + preferTimestamptz?: RuleConfiguration_for_Null; /** * It enables the recommended rules for this group */ From f0d85657745d91dba86e1bf50989508f7043a7cc Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 10:44:07 +0200 Subject: [PATCH 07/23] progress --- PLAN.md | 12 +- crates/pgt_analyser/src/lint/safety.rs | 8 +- .../src/lint/safety/prefer_robust_stmts.rs | 111 +++++++++++++++ .../src/lint/safety/renaming_column.rs | 49 +++++++ .../src/lint/safety/renaming_table.rs | 49 +++++++ .../require_concurrent_index_creation.rs | 83 +++++++++++ .../require_concurrent_index_deletion.rs | 57 ++++++++ .../src/lint/safety/transaction_nesting.rs | 101 ++++++++++++++ crates/pgt_analyser/src/options.rs | 10 ++ .../specs/safety/preferRobustStmts/basic.sql | 2 + .../specs/safety/renamingColumn/basic.sql | 2 + .../specs/safety/renamingTable/basic.sql | 2 + .../requireConcurrentIndexCreation/basic.sql | 2 + .../requireConcurrentIndexDeletion/basic.sql | 2 + .../specs/safety/transactionNesting/basic.sql | 2 + .../src/analyser/linter/rules.rs | 130 +++++++++++++++++- .../src/categories.rs | 8 +- docs/rule_sources.md | 6 + docs/rules.md | 6 + docs/rules/prefer-robust-stmts.md | 58 ++++++++ docs/rules/renaming-column.md | 52 +++++++ docs/rules/renaming-table.md | 52 +++++++ .../require-concurrent-index-creation.md | 58 ++++++++ .../require-concurrent-index-deletion.md | 58 ++++++++ docs/rules/transaction-nesting.md | 79 +++++++++++ docs/schema.json | 66 +++++++++ .../backend-jsonrpc/src/workspace.ts | 32 ++++- 27 files changed, 1084 insertions(+), 13 deletions(-) create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs create mode 100644 crates/pgt_analyser/src/lint/safety/renaming_column.rs create mode 100644 crates/pgt_analyser/src/lint/safety/renaming_table.rs create mode 100644 crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs create mode 100644 crates/pgt_analyser/src/lint/safety/require_concurrent_index_deletion.rs create mode 100644 crates/pgt_analyser/src/lint/safety/transaction_nesting.rs create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql create mode 100644 docs/rules/prefer-robust-stmts.md create mode 100644 docs/rules/renaming-column.md create mode 100644 docs/rules/renaming-table.md create mode 100644 docs/rules/require-concurrent-index-creation.md create mode 100644 docs/rules/require-concurrent-index-deletion.md create mode 100644 docs/rules/transaction-nesting.md diff --git a/PLAN.md b/PLAN.md index 805c546ca..a97da059d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -58,12 +58,6 @@ Please update the list below with the rules that we need to migrate, and the one NEEDS FEATURES: TODO: -- renaming_column -- renaming_table -- require_concurrent_index_creation -- require_concurrent_index_deletion -- transaction_nesting -- prefer_robust_stmts DONE: - adding_field_with_default ✓ (ported from Squawk) @@ -86,5 +80,11 @@ DONE: - prefer_text_field ✓ (ported from Squawk) - prefer_timestamptz ✓ (ported from Squawk) - disallow_unique_constraint ✓ (ported from Squawk) +- renaming_column ✓ (ported from Squawk) +- renaming_table ✓ (ported from Squawk) +- require_concurrent_index_creation ✓ (ported from Squawk) +- require_concurrent_index_deletion ✓ (ported from Squawk) +- transaction_nesting ✓ (ported from Squawk) +- prefer_robust_stmts ✓ (ported from Squawk - simplified version) diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index ec18ed42d..a5e7185b1 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -20,6 +20,12 @@ pub mod prefer_big_int; pub mod prefer_bigint_over_int; pub mod prefer_bigint_over_smallint; pub mod prefer_identity; +pub mod prefer_robust_stmts; pub mod prefer_text_field; pub mod prefer_timestamptz; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz ,] } } +pub mod renaming_column; +pub mod renaming_table; +pub mod require_concurrent_index_creation; +pub mod require_concurrent_index_deletion; +pub mod transaction_nesting; +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_robust_stmts :: PreferRobustStmts , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz , self :: renaming_column :: RenamingColumn , self :: renaming_table :: RenamingTable , self :: require_concurrent_index_creation :: RequireConcurrentIndexCreation , self :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion , self :: transaction_nesting :: TransactionNesting ,] } } diff --git a/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs b/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs new file mode 100644 index 000000000..57f06a9c4 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs @@ -0,0 +1,111 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer statements with guards for robustness in migrations. + /// + /// When running migrations outside of transactions (e.g., CREATE INDEX CONCURRENTLY), + /// statements should be made robust by using guards like IF NOT EXISTS or IF EXISTS. + /// This allows migrations to be safely re-run if they fail partway through. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE INDEX CONCURRENTLY users_email_idx ON users (email); + /// ``` + /// + /// ```sql,expect_diagnostic + /// DROP INDEX CONCURRENTLY users_email_idx; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_idx ON users (email); + /// ``` + /// + /// ```sql + /// DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; + /// ``` + /// + pub PreferRobustStmts { + version: "next", + name: "preferRobustStmts", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("prefer-robust-stmts")], + } +} + +impl Rule for PreferRobustStmts { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + // Skip if we only have one statement in the file + if ctx.file_context().stmt_count <= 1 { + return diagnostics; + } + + // Since we assume we're always in a transaction, we only check for + // statements that explicitly run outside transactions + match &ctx.stmt() { + pgt_query::NodeEnum::IndexStmt(stmt) => { + // Concurrent index creation runs outside transaction + if stmt.concurrent { + // Check for unnamed index + if stmt.idxname.is_empty() { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Concurrent index should have an explicit name." + }, + ).detail(None, "Use an explicit name for a concurrently created index to make migrations more robust.")); + } + // Check for IF NOT EXISTS + if !stmt.if_not_exists { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Concurrent index creation should use IF NOT EXISTS." + }, + ) + .detail( + None, + "Add IF NOT EXISTS to make the migration re-runnable if it fails.", + ), + ); + } + } + } + pgt_query::NodeEnum::DropStmt(stmt) => { + // Concurrent drop runs outside transaction + if stmt.concurrent && !stmt.missing_ok { + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Concurrent drop should use IF EXISTS." + }, + ) + .detail( + None, + "Add IF EXISTS to make the migration re-runnable if it fails.", + ), + ); + } + } + _ => {} + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/renaming_column.rs b/crates/pgt_analyser/src/lint/safety/renaming_column.rs new file mode 100644 index 000000000..8ab079e37 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/renaming_column.rs @@ -0,0 +1,49 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Renaming columns may break existing queries and application code. + /// + /// Renaming a column that is being used by an existing application or query can cause unexpected downtime. + /// Consider creating a new column instead and migrating the data, then dropping the old column after ensuring + /// no dependencies exist. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE users RENAME COLUMN email TO email_address; + /// ``` + /// + pub RenamingColumn { + version: "next", + name: "renamingColumn", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("renaming-column")], + } +} + +impl Rule for RenamingColumn { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { + if stmt.rename_type() == pgt_query::protobuf::ObjectType::ObjectColumn { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Renaming a column may break existing clients." + }, + ).detail(None, "Consider creating a new column with the desired name and migrating data instead.")); + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/renaming_table.rs b/crates/pgt_analyser/src/lint/safety/renaming_table.rs new file mode 100644 index 000000000..072ef3dd8 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/renaming_table.rs @@ -0,0 +1,49 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Renaming tables may break existing queries and application code. + /// + /// Renaming a table that is being referenced by existing applications, views, functions, or foreign keys + /// can cause unexpected downtime. Consider creating a view with the old table name pointing to the new table, + /// or carefully coordinate the rename with application deployments. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE users RENAME TO app_users; + /// ``` + /// + pub RenamingTable { + version: "next", + name: "renamingTable", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("renaming-table")], + } +} + +impl Rule for RenamingTable { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { + if stmt.rename_type() == pgt_query::protobuf::ObjectType::ObjectTable { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Renaming a table may break existing clients." + }, + ).detail(None, "Consider creating a view with the old table name instead, or coordinate the rename carefully with application deployments.")); + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs new file mode 100644 index 000000000..ba152d139 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -0,0 +1,83 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Creating indexes non-concurrently can lock the table for writes. + /// + /// When creating an index on an existing table, using CREATE INDEX without CONCURRENTLY will lock the table + /// against writes for the duration of the index build. This can cause downtime in production systems. + /// Use CREATE INDEX CONCURRENTLY to build the index without blocking concurrent operations. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE INDEX users_email_idx ON users (email); + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE INDEX CONCURRENTLY users_email_idx ON users (email); + /// ``` + /// + pub RequireConcurrentIndexCreation { + version: "next", + name: "requireConcurrentIndexCreation", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("require-concurrent-index-creation")], + } +} + +impl Rule for RequireConcurrentIndexCreation { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::IndexStmt(stmt) = &ctx.stmt() { + if !stmt.concurrent { + // Check if this table was created in the same transaction/file + let table_name = stmt + .relation + .as_ref() + .map(|r| r.relname.as_str()) + .unwrap_or(""); + + if !table_name.is_empty() + && !is_table_created_in_file(ctx.file_context(), table_name) + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Creating an index non-concurrently blocks writes to the table." + }, + ).detail(None, "Use CREATE INDEX CONCURRENTLY to avoid blocking concurrent operations on the table.")); + } + } + } + + diagnostics + } +} + +fn is_table_created_in_file( + file_context: &pgt_analyse::AnalysedFileContext, + table_name: &str, +) -> bool { + // Check all statements in the file to see if this table was created + for stmt in file_context.all_stmts { + if let pgt_query::NodeEnum::CreateStmt(create_stmt) = stmt { + if let Some(relation) = &create_stmt.relation { + if relation.relname == table_name { + return true; + } + } + } + } + false +} diff --git a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_deletion.rs b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_deletion.rs new file mode 100644 index 000000000..63f70de72 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_deletion.rs @@ -0,0 +1,57 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Dropping indexes non-concurrently can lock the table for reads. + /// + /// When dropping an index, using DROP INDEX without CONCURRENTLY will lock the table + /// preventing reads and writes for the duration of the drop. This can cause downtime in production systems. + /// Use DROP INDEX CONCURRENTLY to drop the index without blocking concurrent operations. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// DROP INDEX IF EXISTS users_email_idx; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; + /// ``` + /// + pub RequireConcurrentIndexDeletion { + version: "next", + name: "requireConcurrentIndexDeletion", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("require-concurrent-index-deletion")], + } +} + +impl Rule for RequireConcurrentIndexDeletion { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::DropStmt(stmt) = &ctx.stmt() { + if !stmt.concurrent + && stmt.remove_type() == pgt_query::protobuf::ObjectType::ObjectIndex + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Dropping an index non-concurrently blocks reads and writes to the table." + }, + ).detail(None, "Use DROP INDEX CONCURRENTLY to avoid blocking concurrent operations on the table.")); + } + } + + diagnostics + } +} diff --git a/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs b/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs new file mode 100644 index 000000000..8061ff380 --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs @@ -0,0 +1,101 @@ +use pgt_analyse::{ + AnalysedFileContext, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule, +}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Detects problematic transaction nesting that could lead to unexpected behavior. + /// + /// Transaction nesting issues occur when trying to start a transaction within an existing transaction, + /// or trying to commit/rollback when not in a transaction. This can lead to unexpected behavior + /// or errors in database migrations. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// BEGIN; + /// -- Migration tools already manage transactions + /// SELECT 1; + /// ``` + /// + /// ```sql,expect_diagnostic + /// SELECT 1; + /// COMMIT; -- No transaction to commit + /// ``` + /// + pub TransactionNesting { + version: "next", + name: "transactionNesting", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Squawk("transaction-nesting")], + } +} + +impl Rule for TransactionNesting { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pgt_query::NodeEnum::TransactionStmt(stmt) = &ctx.stmt() { + match stmt.kind() { + pgt_query::protobuf::TransactionStmtKind::TransStmtBegin + | pgt_query::protobuf::TransactionStmtKind::TransStmtStart => { + // Check if there's already a BEGIN in previous statements + if has_transaction_start_before(ctx.file_context()) { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Nested transaction detected." + }, + ).detail(None, "Starting a transaction when already in a transaction can cause issues.")); + } + // Always warn about BEGIN/START since we assume we're in a transaction + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Transaction already managed by migration tool." + }, + ).detail(None, "Migration tools manage transactions automatically. Remove explicit transaction control.") + .note("Put migration statements in separate files to have them be in separate transactions.")); + } + pgt_query::protobuf::TransactionStmtKind::TransStmtCommit + | pgt_query::protobuf::TransactionStmtKind::TransStmtRollback => { + // Always warn about COMMIT/ROLLBACK since we assume we're in a transaction + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Attempting to end transaction managed by migration tool." + }, + ).detail(None, "Migration tools manage transactions automatically. Remove explicit transaction control.") + .note("Put migration statements in separate files to have them be in separate transactions.")); + } + _ => {} + } + } + + diagnostics + } +} + +fn has_transaction_start_before(file_context: &AnalysedFileContext) -> bool { + for stmt in &file_context.previous_stmts { + if let pgt_query::NodeEnum::TransactionStmt(tx_stmt) = stmt { + match tx_stmt.kind() { + pgt_query::protobuf::TransactionStmtKind::TransStmtBegin + | pgt_query::protobuf::TransactionStmtKind::TransStmtStart => return true, + pgt_query::protobuf::TransactionStmtKind::TransStmtCommit + | pgt_query::protobuf::TransactionStmtKind::TransStmtRollback => return false, + _ => {} + } + } + } + false +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index 362882c10..d24d471b9 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -30,7 +30,17 @@ pub type PreferBigintOverInt = pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as pgt_analyse :: Rule > :: Options ; pub type PreferIdentity = ::Options; +pub type PreferRobustStmts = + ::Options; pub type PreferTextField = ::Options; pub type PreferTimestamptz = ::Options; +pub type RenamingColumn = + ::Options; +pub type RenamingTable = + ::Options; +pub type RequireConcurrentIndexCreation = < lint :: safety :: require_concurrent_index_creation :: RequireConcurrentIndexCreation as pgt_analyse :: Rule > :: Options ; +pub type RequireConcurrentIndexDeletion = < lint :: safety :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion as pgt_analyse :: Rule > :: Options ; +pub type TransactionNesting = + ::Options; diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql new file mode 100644 index 000000000..6064619ba --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/preferRobustStmts +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql new file mode 100644 index 000000000..0293c1d89 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/renamingColumn +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql new file mode 100644 index 000000000..bf1a6a309 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/renamingTable +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql new file mode 100644 index 000000000..38c57f21f --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/requireConcurrentIndexCreation +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql new file mode 100644 index 000000000..c72b371b8 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/requireConcurrentIndexDeletion +-- select 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql new file mode 100644 index 000000000..a108338bb --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/transactionNesting +-- select 1; \ No newline at end of file diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index 0fea18efa..adaa657d7 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -207,12 +207,32 @@ pub struct Safety { #[doc = "Prefer using IDENTITY columns over serial columns."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_identity: Option>, + #[doc = "Prefer statements with guards for robustness in migrations."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_robust_stmts: Option>, #[doc = "Prefer using TEXT over VARCHAR(n) types."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_text_field: Option>, #[doc = "Prefer TIMESTAMPTZ over TIMESTAMP types."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_timestamptz: Option>, + #[doc = "Renaming columns may break existing queries and application code."] + #[serde(skip_serializing_if = "Option::is_none")] + pub renaming_column: Option>, + #[doc = "Renaming tables may break existing queries and application code."] + #[serde(skip_serializing_if = "Option::is_none")] + pub renaming_table: Option>, + #[doc = "Creating indexes non-concurrently can lock the table for writes."] + #[serde(skip_serializing_if = "Option::is_none")] + pub require_concurrent_index_creation: + Option>, + #[doc = "Dropping indexes non-concurrently can lock the table for reads."] + #[serde(skip_serializing_if = "Option::is_none")] + pub require_concurrent_index_deletion: + Option>, + #[doc = "Detects problematic transaction nesting that could lead to unexpected behavior."] + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_nesting: Option>, } impl Safety { const GROUP_NAME: &'static str = "safety"; @@ -236,8 +256,14 @@ impl Safety { "preferBigintOverInt", "preferBigintOverSmallint", "preferIdentity", + "preferRobustStmts", "preferTextField", "preferTimestamptz", + "renamingColumn", + "renamingTable", + "requireConcurrentIndexCreation", + "requireConcurrentIndexDeletion", + "transactionNesting", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -271,6 +297,12 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -382,16 +414,46 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.prefer_text_field.as_ref() { + if let Some(rule) = self.prefer_robust_stmts.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.prefer_timestamptz.as_ref() { + if let Some(rule) = self.prefer_text_field.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } + if let Some(rule) = self.prefer_timestamptz.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); + } + } + if let Some(rule) = self.renaming_column.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); + } + } + if let Some(rule) = self.renaming_table.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); + } + } + if let Some(rule) = self.require_concurrent_index_creation.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); + } + } + if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); + } + } + if let Some(rule) = self.transaction_nesting.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -491,16 +553,46 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.prefer_text_field.as_ref() { + if let Some(rule) = self.prefer_robust_stmts.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.prefer_timestamptz.as_ref() { + if let Some(rule) = self.prefer_text_field.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } + if let Some(rule) = self.prefer_timestamptz.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); + } + } + if let Some(rule) = self.renaming_column.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); + } + } + if let Some(rule) = self.renaming_table.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); + } + } + if let Some(rule) = self.require_concurrent_index_creation.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); + } + } + if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); + } + } + if let Some(rule) = self.transaction_nesting.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -549,8 +641,14 @@ impl Safety { "preferBigintOverInt" => Severity::Warning, "preferBigintOverSmallint" => Severity::Warning, "preferIdentity" => Severity::Warning, + "preferRobustStmts" => Severity::Warning, "preferTextField" => Severity::Warning, "preferTimestamptz" => Severity::Warning, + "renamingColumn" => Severity::Warning, + "renamingTable" => Severity::Warning, + "requireConcurrentIndexCreation" => Severity::Warning, + "requireConcurrentIndexDeletion" => Severity::Warning, + "transactionNesting" => Severity::Warning, _ => unreachable!(), } } @@ -635,6 +733,10 @@ impl Safety { .prefer_identity .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "preferRobustStmts" => self + .prefer_robust_stmts + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "preferTextField" => self .prefer_text_field .as_ref() @@ -643,6 +745,26 @@ impl Safety { .prefer_timestamptz .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "renamingColumn" => self + .renaming_column + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "renamingTable" => self + .renaming_table + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "requireConcurrentIndexCreation" => self + .require_concurrent_index_creation + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "requireConcurrentIndexDeletion" => self + .require_concurrent_index_deletion + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "transactionNesting" => self + .transaction_nesting + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), _ => None, } } diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index 12324908b..e48252bb5 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -27,13 +27,19 @@ define_categories! { "lint/safety/banTruncateCascade": "https://pgtools.dev/latest/rules/ban-truncate-cascade", "lint/safety/changingColumnType": "https://pgtools.dev/latest/rules/changing-column-type", "lint/safety/constraintMissingNotValid": "https://pgtools.dev/latest/rules/constraint-missing-not-valid", + "lint/safety/disallowUniqueConstraint": "https://pgtools.dev/latest/rules/disallow-unique-constraint", "lint/safety/preferBigInt": "https://pgtools.dev/latest/rules/prefer-big-int", "lint/safety/preferBigintOverInt": "https://pgtools.dev/latest/rules/prefer-bigint-over-int", "lint/safety/preferBigintOverSmallint": "https://pgtools.dev/latest/rules/prefer-bigint-over-smallint", "lint/safety/preferIdentity": "https://pgtools.dev/latest/rules/prefer-identity", + "lint/safety/preferRobustStmts": "https://pgtools.dev/latest/rules/prefer-robust-stmts", "lint/safety/preferTextField": "https://pgtools.dev/latest/rules/prefer-text-field", "lint/safety/preferTimestamptz": "https://pgtools.dev/latest/rules/prefer-timestamptz", - "lint/safety/disallowUniqueConstraint": "https://pgtools.dev/latest/rules/disallow-unique-constraint", + "lint/safety/renamingColumn": "https://pgtools.dev/latest/rules/renaming-column", + "lint/safety/renamingTable": "https://pgtools.dev/latest/rules/renaming-table", + "lint/safety/requireConcurrentIndexCreation": "https://pgtools.dev/latest/rules/require-concurrent-index-creation", + "lint/safety/requireConcurrentIndexDeletion": "https://pgtools.dev/latest/rules/require-concurrent-index-deletion", + "lint/safety/transactionNesting": "https://pgtools.dev/latest/rules/transaction-nesting", // end lint rules ; // General categories diff --git a/docs/rule_sources.md b/docs/rule_sources.md index 21acb8732..09a0aeb2f 100644 --- a/docs/rule_sources.md +++ b/docs/rule_sources.md @@ -22,5 +22,11 @@ | [prefer-bigint-over-int](https://squawkhq.com/docs/prefer-bigint-over-int) |[preferBigintOverInt](../rules/prefer-bigint-over-int) | | [prefer-bigint-over-smallint](https://squawkhq.com/docs/prefer-bigint-over-smallint) |[preferBigintOverSmallint](../rules/prefer-bigint-over-smallint) | | [prefer-identity](https://squawkhq.com/docs/prefer-identity) |[preferIdentity](../rules/prefer-identity) | +| [prefer-robust-stmts](https://squawkhq.com/docs/prefer-robust-stmts) |[preferRobustStmts](../rules/prefer-robust-stmts) | | [prefer-text-field](https://squawkhq.com/docs/prefer-text-field) |[preferTextField](../rules/prefer-text-field) | | [prefer-timestamptz](https://squawkhq.com/docs/prefer-timestamptz) |[preferTimestamptz](../rules/prefer-timestamptz) | +| [renaming-column](https://squawkhq.com/docs/renaming-column) |[renamingColumn](../rules/renaming-column) | +| [renaming-table](https://squawkhq.com/docs/renaming-table) |[renamingTable](../rules/renaming-table) | +| [require-concurrent-index-creation](https://squawkhq.com/docs/require-concurrent-index-creation) |[requireConcurrentIndexCreation](../rules/require-concurrent-index-creation) | +| [require-concurrent-index-deletion](https://squawkhq.com/docs/require-concurrent-index-deletion) |[requireConcurrentIndexDeletion](../rules/require-concurrent-index-deletion) | +| [transaction-nesting](https://squawkhq.com/docs/transaction-nesting) |[transactionNesting](../rules/transaction-nesting) | diff --git a/docs/rules.md b/docs/rules.md index 2ee240e36..d69451053 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -31,8 +31,14 @@ Rules that detect potential safety issues in your code. | [preferBigintOverInt](./prefer-bigint-over-int) | Prefer BIGINT over INT/INTEGER types. | | | [preferBigintOverSmallint](./prefer-bigint-over-smallint) | Prefer BIGINT over SMALLINT types. | | | [preferIdentity](./prefer-identity) | Prefer using IDENTITY columns over serial columns. | | +| [preferRobustStmts](./prefer-robust-stmts) | Prefer statements with guards for robustness in migrations. | | | [preferTextField](./prefer-text-field) | Prefer using TEXT over VARCHAR(n) types. | | | [preferTimestamptz](./prefer-timestamptz) | Prefer TIMESTAMPTZ over TIMESTAMP types. | | +| [renamingColumn](./renaming-column) | Renaming columns may break existing queries and application code. | | +| [renamingTable](./renaming-table) | Renaming tables may break existing queries and application code. | | +| [requireConcurrentIndexCreation](./require-concurrent-index-creation) | Creating indexes non-concurrently can lock the table for writes. | | +| [requireConcurrentIndexDeletion](./require-concurrent-index-deletion) | Dropping indexes non-concurrently can lock the table for reads. | | +| [transactionNesting](./transaction-nesting) | Detects problematic transaction nesting that could lead to unexpected behavior. | | [//]: # (END RULES_INDEX) diff --git a/docs/rules/prefer-robust-stmts.md b/docs/rules/prefer-robust-stmts.md new file mode 100644 index 000000000..c0e2b9d54 --- /dev/null +++ b/docs/rules/prefer-robust-stmts.md @@ -0,0 +1,58 @@ +# preferRobustStmts +**Diagnostic Category: `lint/safety/preferRobustStmts`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/prefer-robust-stmts + +## Description +Prefer statements with guards for robustness in migrations. + +When running migrations outside of transactions (e.g., CREATE INDEX CONCURRENTLY), +statements should be made robust by using guards like IF NOT EXISTS or IF EXISTS. +This allows migrations to be safely re-run if they fail partway through. + +## Examples + +### Invalid + +```sql +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); +``` + +```sh +``` + +```sql +DROP INDEX CONCURRENTLY users_email_idx; +``` + +```sh +``` + +### Valid + +```sql +CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_idx ON users (email); +``` + +```sql +DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferRobustStmts": "error" + } + } + } +} + +``` diff --git a/docs/rules/renaming-column.md b/docs/rules/renaming-column.md new file mode 100644 index 000000000..a43661f54 --- /dev/null +++ b/docs/rules/renaming-column.md @@ -0,0 +1,52 @@ +# renamingColumn +**Diagnostic Category: `lint/safety/renamingColumn`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/renaming-column + +## Description +Renaming columns may break existing queries and application code. + +Renaming a column that is being used by an existing application or query can cause unexpected downtime. +Consider creating a new column instead and migrating the data, then dropping the old column after ensuring +no dependencies exist. + +## Examples + +### Invalid + +```sql +ALTER TABLE users RENAME COLUMN email TO email_address; +``` + +```sh +code-block.sql:1:1 lint/safety/renamingColumn ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Renaming a column may break existing clients. + + > 1 │ ALTER TABLE users RENAME COLUMN email TO email_address; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Consider creating a new column with the desired name and migrating data instead. + + +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "renamingColumn": "error" + } + } + } +} + +``` diff --git a/docs/rules/renaming-table.md b/docs/rules/renaming-table.md new file mode 100644 index 000000000..44c03116b --- /dev/null +++ b/docs/rules/renaming-table.md @@ -0,0 +1,52 @@ +# renamingTable +**Diagnostic Category: `lint/safety/renamingTable`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/renaming-table + +## Description +Renaming tables may break existing queries and application code. + +Renaming a table that is being referenced by existing applications, views, functions, or foreign keys +can cause unexpected downtime. Consider creating a view with the old table name pointing to the new table, +or carefully coordinate the rename with application deployments. + +## Examples + +### Invalid + +```sql +ALTER TABLE users RENAME TO app_users; +``` + +```sh +code-block.sql:1:1 lint/safety/renamingTable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Renaming a table may break existing clients. + + > 1 │ ALTER TABLE users RENAME TO app_users; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Consider creating a view with the old table name instead, or coordinate the rename carefully with application deployments. + + +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "renamingTable": "error" + } + } + } +} + +``` diff --git a/docs/rules/require-concurrent-index-creation.md b/docs/rules/require-concurrent-index-creation.md new file mode 100644 index 000000000..aaacb50fd --- /dev/null +++ b/docs/rules/require-concurrent-index-creation.md @@ -0,0 +1,58 @@ +# requireConcurrentIndexCreation +**Diagnostic Category: `lint/safety/requireConcurrentIndexCreation`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/require-concurrent-index-creation + +## Description +Creating indexes non-concurrently can lock the table for writes. + +When creating an index on an existing table, using CREATE INDEX without CONCURRENTLY will lock the table +against writes for the duration of the index build. This can cause downtime in production systems. +Use CREATE INDEX CONCURRENTLY to build the index without blocking concurrent operations. + +## Examples + +### Invalid + +```sql +CREATE INDEX users_email_idx ON users (email); +``` + +```sh +code-block.sql:1:1 lint/safety/requireConcurrentIndexCreation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Creating an index non-concurrently blocks writes to the table. + + > 1 │ CREATE INDEX users_email_idx ON users (email); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Use CREATE INDEX CONCURRENTLY to avoid blocking concurrent operations on the table. + + +``` + +### Valid + +```sql +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "requireConcurrentIndexCreation": "error" + } + } + } +} + +``` diff --git a/docs/rules/require-concurrent-index-deletion.md b/docs/rules/require-concurrent-index-deletion.md new file mode 100644 index 000000000..c919177f1 --- /dev/null +++ b/docs/rules/require-concurrent-index-deletion.md @@ -0,0 +1,58 @@ +# requireConcurrentIndexDeletion +**Diagnostic Category: `lint/safety/requireConcurrentIndexDeletion`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/require-concurrent-index-deletion + +## Description +Dropping indexes non-concurrently can lock the table for reads. + +When dropping an index, using DROP INDEX without CONCURRENTLY will lock the table +preventing reads and writes for the duration of the drop. This can cause downtime in production systems. +Use DROP INDEX CONCURRENTLY to drop the index without blocking concurrent operations. + +## Examples + +### Invalid + +```sql +DROP INDEX IF EXISTS users_email_idx; +``` + +```sh +code-block.sql:1:1 lint/safety/requireConcurrentIndexDeletion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Dropping an index non-concurrently blocks reads and writes to the table. + + > 1 │ DROP INDEX IF EXISTS users_email_idx; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Use DROP INDEX CONCURRENTLY to avoid blocking concurrent operations on the table. + + +``` + +### Valid + +```sql +DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "requireConcurrentIndexDeletion": "error" + } + } + } +} + +``` diff --git a/docs/rules/transaction-nesting.md b/docs/rules/transaction-nesting.md new file mode 100644 index 000000000..7f3b25ab0 --- /dev/null +++ b/docs/rules/transaction-nesting.md @@ -0,0 +1,79 @@ +# transactionNesting +**Diagnostic Category: `lint/safety/transactionNesting`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: squawk/transaction-nesting + +## Description +Detects problematic transaction nesting that could lead to unexpected behavior. + +Transaction nesting issues occur when trying to start a transaction within an existing transaction, +or trying to commit/rollback when not in a transaction. This can lead to unexpected behavior +or errors in database migrations. + +## Examples + +### Invalid + +```sql +BEGIN; +-- Migration tools already manage transactions +SELECT 1; +``` + +```sh +code-block.sql:1:1 lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Transaction already managed by migration tool. + + > 1 │ BEGIN; + │ ^^^^^^ + 2 │ -- Migration tools already manage transactions + 3 │ SELECT 1; + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. + + +``` + +```sql +SELECT 1; +COMMIT; -- No transaction to commit +``` + +```sh +code-block.sql:2:1 lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Attempting to end transaction managed by migration tool. + + 1 │ SELECT 1; + > 2 │ COMMIT; -- No transaction to commit + │ ^^^^^^^ + 3 │ + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. + + +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "transactionNesting": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index 9a2d0e8a1..359e58b01 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -572,6 +572,17 @@ } ] }, + "preferRobustStmts": { + "description": "Prefer statements with guards for robustness in migrations.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "preferTextField": { "description": "Prefer using TEXT over VARCHAR(n) types.", "anyOf": [ @@ -600,6 +611,61 @@ "boolean", "null" ] + }, + "renamingColumn": { + "description": "Renaming columns may break existing queries and application code.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "renamingTable": { + "description": "Renaming tables may break existing queries and application code.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "requireConcurrentIndexCreation": { + "description": "Creating indexes non-concurrently can lock the table for writes.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "requireConcurrentIndexDeletion": { + "description": "Dropping indexes non-concurrently can lock the table for reads.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "transactionNesting": { + "description": "Detects problematic transaction nesting that could lead to unexpected behavior.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index d9925f64d..64cd10939 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -77,13 +77,19 @@ export type Category = | "lint/safety/banTruncateCascade" | "lint/safety/changingColumnType" | "lint/safety/constraintMissingNotValid" + | "lint/safety/disallowUniqueConstraint" | "lint/safety/preferBigInt" | "lint/safety/preferBigintOverInt" | "lint/safety/preferBigintOverSmallint" | "lint/safety/preferIdentity" + | "lint/safety/preferRobustStmts" | "lint/safety/preferTextField" | "lint/safety/preferTimestamptz" - | "lint/safety/disallowUniqueConstraint" + | "lint/safety/renamingColumn" + | "lint/safety/renamingTable" + | "lint/safety/requireConcurrentIndexCreation" + | "lint/safety/requireConcurrentIndexDeletion" + | "lint/safety/transactionNesting" | "stdin" | "check" | "configuration" @@ -508,6 +514,10 @@ export interface Safety { * Prefer using IDENTITY columns over serial columns. */ preferIdentity?: RuleConfiguration_for_Null; + /** + * Prefer statements with guards for robustness in migrations. + */ + preferRobustStmts?: RuleConfiguration_for_Null; /** * Prefer using TEXT over VARCHAR(n) types. */ @@ -520,6 +530,26 @@ export interface Safety { * It enables the recommended rules for this group */ recommended?: boolean; + /** + * Renaming columns may break existing queries and application code. + */ + renamingColumn?: RuleConfiguration_for_Null; + /** + * Renaming tables may break existing queries and application code. + */ + renamingTable?: RuleConfiguration_for_Null; + /** + * Creating indexes non-concurrently can lock the table for writes. + */ + requireConcurrentIndexCreation?: RuleConfiguration_for_Null; + /** + * Dropping indexes non-concurrently can lock the table for reads. + */ + requireConcurrentIndexDeletion?: RuleConfiguration_for_Null; + /** + * Detects problematic transaction nesting that could lead to unexpected behavior. + */ + transactionNesting?: RuleConfiguration_for_Null; } export type RuleConfiguration_for_Null = | RulePlainConfiguration From afbe1c2a275efdc8cdae2f4cfb42dd3334a4586a Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 10:49:49 +0200 Subject: [PATCH 08/23] progress --- .../safety/adding_foreign_key_constraint.rs | 72 +++++++++---------- .../safety/adding_primary_key_constraint.rs | 66 ++++++++--------- .../lint/safety/disallow_unique_constraint.rs | 2 +- .../src/lint/safety/prefer_big_int.rs | 2 +- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs index e661192dd..06a91e5e4 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -53,48 +53,48 @@ impl Rule for AddingForeignKeyConstraint { if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { match cmd.subtype() { pgt_query::protobuf::AlterTableType::AtAddConstraint => { - if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if let pgt_query::NodeEnum::Constraint(constraint) = def { - // check if it's a foreign key constraint - if constraint.contype() - == pgt_query::protobuf::ConstrType::ConstrForeign - { - // it is okay if NOT VALID is specified (skip_validation = true) - if !constraint.skip_validation { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a foreign key constraint requires a table scan and locks on both tables." - }, - ).detail(None, "This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint.") - .note("Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction.")); - } + if let Some(pgt_query::NodeEnum::Constraint(constraint)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + // check if it's a foreign key constraint + if constraint.contype() + == pgt_query::protobuf::ConstrType::ConstrForeign + { + // it is okay if NOT VALID is specified (skip_validation = true) + if !constraint.skip_validation { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a foreign key constraint requires a table scan and locks on both tables." + }, + ).detail(None, "This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint.") + .note("Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction.")); } } } } pgt_query::protobuf::AlterTableType::AtAddColumn => { - if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if let pgt_query::NodeEnum::ColumnDef(col_def) = def { - // check constraints on the column - for constraint in &col_def.constraints { - if let Some(pgt_query::NodeEnum::Constraint(constr)) = - &constraint.node + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + // check constraints on the column + for constraint in &col_def.constraints { + if let Some(pgt_query::NodeEnum::Constraint(constr)) = + &constraint.node + { + if constr.contype() + == pgt_query::protobuf::ConstrType::ConstrForeign + && !constr.skip_validation { - if constr.contype() - == pgt_query::protobuf::ConstrType::ConstrForeign - && !constr.skip_validation - { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a column with a foreign key constraint requires a table scan and locks." - }, - ).detail(None, "Using REFERENCES when adding a column will block writes while verifying the constraint.") - .note("Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately.")); - } + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a column with a foreign key constraint requires a table scan and locks." + }, + ).detail(None, "Using REFERENCES when adding a column will block writes while verifying the constraint.") + .note("Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately.")); } } } diff --git a/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs index f81d0a0ca..2bfd940bd 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -51,45 +51,45 @@ impl Rule for AddingPrimaryKeyConstraint { match cmd.subtype() { // Check for ADD CONSTRAINT PRIMARY KEY pgt_query::protobuf::AlterTableType::AtAddConstraint => { - if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if let pgt_query::NodeEnum::Constraint(constraint) = def { - if constraint.contype() - == pgt_query::protobuf::ConstrType::ConstrPrimary - && constraint.indexname.is_empty() - { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a PRIMARY KEY constraint results in locks and table rewrites." - }, - ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") - .note("Add the PRIMARY KEY constraint USING an index.")); - } + if let Some(pgt_query::NodeEnum::Constraint(constraint)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + if constraint.contype() + == pgt_query::protobuf::ConstrType::ConstrPrimary + && constraint.indexname.is_empty() + { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a PRIMARY KEY constraint results in locks and table rewrites." + }, + ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") + .note("Add the PRIMARY KEY constraint USING an index.")); } } } // Check for ADD COLUMN with PRIMARY KEY pgt_query::protobuf::AlterTableType::AtAddColumn => { - if let Some(def) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if let pgt_query::NodeEnum::ColumnDef(col_def) = def { - for constraint in &col_def.constraints { - if let Some(pgt_query::NodeEnum::Constraint(constr)) = - &constraint.node + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + for constraint in &col_def.constraints { + if let Some(pgt_query::NodeEnum::Constraint(constr)) = + &constraint.node + { + if constr.contype() + == pgt_query::protobuf::ConstrType::ConstrPrimary + && constr.indexname.is_empty() { - if constr.contype() - == pgt_query::protobuf::ConstrType::ConstrPrimary - && constr.indexname.is_empty() - { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a PRIMARY KEY constraint results in locks and table rewrites." - }, - ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") - .note("Add the PRIMARY KEY constraint USING an index.")); - } + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a PRIMARY KEY constraint results in locks and table rewrites." + }, + ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") + .note("Add the PRIMARY KEY constraint USING an index.")); } } } diff --git a/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs index ec3aa7e17..5310faad0 100644 --- a/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -55,7 +55,7 @@ impl Rule for DisallowUniqueConstraint { create .relation .as_ref() - .map_or(false, |r| &r.relname == table_name) + .is_some_and(|r| &r.relname == table_name) } else { false } diff --git a/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs b/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs index a703bff19..b44cb72fb 100644 --- a/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs +++ b/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs @@ -117,7 +117,7 @@ fn check_column_def( "Using smaller integer types can lead to overflow issues." }, ) - .detail(None, &format!("The '{}' type has a limited range that may be exceeded as your data grows.", name.sval)) + .detail(None, format!("The '{}' type has a limited range that may be exceeded as your data grows.", name.sval)) .note("Consider using BIGINT for integer columns to avoid future migration issues."), ); } From e3b4eaf351ddce412193d7706f36f87e87e41a33 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 10:50:24 +0200 Subject: [PATCH 09/23] progress --- PLAN.md => agentic/port_squawk_rules.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename PLAN.md => agentic/port_squawk_rules.md (100%) diff --git a/PLAN.md b/agentic/port_squawk_rules.md similarity index 100% rename from PLAN.md rename to agentic/port_squawk_rules.md From 96162ccc6507d18ba6572b989a430e03a5819c40 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 12 Sep 2025 10:52:10 +0200 Subject: [PATCH 10/23] progress --- justfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/justfile b/justfile index 2bfd46179..d4b5b1fca 100644 --- a/justfile +++ b/justfile @@ -153,16 +153,16 @@ quick-modify: show-logs: tail -f $(ls $PGT_LOG_PATH/server.log.* | sort -t- -k2,2 -k3,3 -k4,4 | tail -n 1) -port-squawk: - unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions -p "please read PLAN.md and follow the instructions closely" +agentic name: + unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions -p "please read agentic/{{name}}.md and follow the instructions closely" -port-squawk-loop: +agentic-loop name: #!/usr/bin/env bash - echo "Starting port-squawk loop until error..." + echo "Starting agentic loop until error..." iteration=1 while true; do echo "$(date): Starting iteration $iteration..." - if just port-squawk; then + if just agentic {{name}}; then echo "$(date): Iteration $iteration completed successfully!" iteration=$((iteration + 1)) else From 04e3f0f2b1b1a11c168ebac3e9713abf32d3b3da Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 07:55:15 +0200 Subject: [PATCH 11/23] fix: use schema cache for function votality --- Cargo.lock | 1 + crates/pgt_analyser/Cargo.toml | 1 + .../non_volatile_built_in_functions.txt | 2963 ----------------- .../lint/safety/adding_field_with_default.rs | 56 +- 4 files changed, 33 insertions(+), 2988 deletions(-) delete mode 100644 crates/pgt_analyser/resources/non_volatile_built_in_functions.txt diff --git a/Cargo.lock b/Cargo.lock index 386a1c52a..779f85f46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2758,6 +2758,7 @@ dependencies = [ "pgt_console", "pgt_diagnostics", "pgt_query", + "pgt_query_ext", "pgt_schema_cache", "pgt_test_macros", "pgt_text_size", diff --git a/crates/pgt_analyser/Cargo.toml b/crates/pgt_analyser/Cargo.toml index 0cf7a3342..cfeb4ca4a 100644 --- a/crates/pgt_analyser/Cargo.toml +++ b/crates/pgt_analyser/Cargo.toml @@ -16,6 +16,7 @@ pgt_analyse = { workspace = true } pgt_console = { workspace = true } pgt_diagnostics = { workspace = true } pgt_query = { workspace = true } +pgt_query_ext = { workspace = true } pgt_schema_cache = { workspace = true } pgt_text_size = { workspace = true } serde = { workspace = true } diff --git a/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt b/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt deleted file mode 100644 index cc13e6d7f..000000000 --- a/crates/pgt_analyser/resources/non_volatile_built_in_functions.txt +++ /dev/null @@ -1,2963 +0,0 @@ -boolin -boolout -byteain -byteaout -charin -charout -namein -nameout -int2in -int2out -int2vectorin -int2vectorout -int4in -int4out -regprocin -regprocout -to_regproc -to_regprocedure -textin -textout -tidin -tidout -xidin -xidout -xid8in -xid8out -xid8recv -xid8send -cidin -cidout -oidvectorin -oidvectorout -boollt -boolgt -booleq -chareq -nameeq -int2eq -int2lt -int4eq -int4lt -texteq -starts_with -xideq -xidneq -xid8eq -xid8ne -xid8lt -xid8gt -xid8le -xid8ge -xid8cmp -xid -cideq -charne -charlt -charle -chargt -charge -int4 -char -nameregexeq -nameregexne -textregexeq -textregexne -textregexeq_support -textlen -textcat -boolne -version -pg_ddl_command_in -pg_ddl_command_out -pg_ddl_command_recv -pg_ddl_command_send -eqsel -neqsel -scalarltsel -scalargtsel -eqjoinsel -neqjoinsel -scalarltjoinsel -scalargtjoinsel -scalarlesel -scalargesel -scalarlejoinsel -scalargejoinsel -unknownin -unknownout -box_above_eq -box_below_eq -point_in -point_out -lseg_in -lseg_out -path_in -path_out -box_in -box_out -box_overlap -box_ge -box_gt -box_eq -box_lt -box_le -point_above -point_left -point_right -point_below -point_eq -on_pb -on_ppath -box_center -areasel -areajoinsel -int4mul -int4ne -int2ne -int2gt -int4gt -int2le -int4le -int4ge -int2ge -int2mul -int2div -int4div -int2mod -int4mod -textne -int24eq -int42eq -int24lt -int42lt -int24gt -int42gt -int24ne -int42ne -int24le -int42le -int24ge -int42ge -int24mul -int42mul -int24div -int42div -int2pl -int4pl -int24pl -int42pl -int2mi -int4mi -int24mi -int42mi -oideq -oidne -box_same -box_contain -box_left -box_overleft -box_overright -box_right -box_contained -box_contain_pt -pg_node_tree_in -pg_node_tree_out -pg_node_tree_recv -pg_node_tree_send -float4in -float4out -float4mul -float4div -float4pl -float4mi -float4um -float4abs -float4_accum -float4larger -float4smaller -int4um -int2um -float8in -float8out -float8mul -float8div -float8pl -float8mi -float8um -float8abs -float8_accum -float8_combine -float8larger -float8smaller -lseg_center -path_center -poly_center -dround -dtrunc -ceil -ceiling -floor -sign -dsqrt -dcbrt -dpow -dexp -dlog1 -float8 -float4 -int2 -int2 -line_distance -nameeqtext -namelttext -nameletext -namegetext -namegttext -namenetext -btnametextcmp -texteqname -textltname -textlename -textgename -textgtname -textnename -bttextnamecmp -nameconcatoid -inter_sl -inter_lb -float48mul -float48div -float48pl -float48mi -float84mul -float84div -float84pl -float84mi -float4eq -float4ne -float4lt -float4le -float4gt -float4ge -float8eq -float8ne -float8lt -float8le -float8gt -float8ge -float48eq -float48ne -float48lt -float48le -float48gt -float48ge -float84eq -float84ne -float84lt -float84le -float84gt -float84ge -width_bucket -float8 -float4 -int4 -int2 -float8 -int4 -float4 -int4 -pg_indexam_has_property -pg_index_has_property -pg_index_column_has_property -pg_indexam_progress_phasename -poly_same -poly_contain -poly_left -poly_overleft -poly_overright -poly_right -poly_contained -poly_overlap -poly_in -poly_out -btint2cmp -btint2sortsupport -btint4cmp -btint4sortsupport -btint8cmp -btint8sortsupport -btfloat4cmp -btfloat4sortsupport -btfloat8cmp -btfloat8sortsupport -btoidcmp -btoidsortsupport -btoidvectorcmp -btcharcmp -btnamecmp -btnamesortsupport -bttextcmp -bttextsortsupport -btvarstrequalimage -cash_cmp -btarraycmp -in_range -in_range -in_range -in_range -in_range -in_range -in_range -in_range -in_range -in_range -lseg_distance -lseg_interpt -dist_ps -dist_sp -dist_pb -dist_bp -dist_sb -dist_bs -close_ps -close_pb -close_sb -on_ps -path_distance -dist_ppath -dist_pathp -on_sb -inter_sb -text -text -name -bpchar -name -hashint2 -hashint2extended -hashint4 -hashint4extended -hashint8 -hashint8extended -hashfloat4 -hashfloat4extended -hashfloat8 -hashfloat8extended -hashoid -hashoidextended -hashchar -hashcharextended -hashname -hashnameextended -hashtext -hashtextextended -hashvarlena -hashvarlenaextended -hashoidvector -hashoidvectorextended -hash_aclitem -hash_aclitem_extended -hashmacaddr -hashmacaddrextended -hashinet -hashinetextended -hash_numeric -hash_numeric_extended -hashmacaddr8 -hashmacaddr8extended -num_nulls -num_nonnulls -text_larger -text_smaller -int8in -int8out -int8um -int8pl -int8mi -int8mul -int8div -int8eq -int8ne -int8lt -int8gt -int8le -int8ge -int84eq -int84ne -int84lt -int84gt -int84le -int84ge -int4 -int8 -float8 -int8 -hash_array -hash_array_extended -float4 -int8 -int2 -int8 -namelt -namele -namegt -namege -namene -bpchar -varchar_support -varchar -oidvectorne -oidvectorlt -oidvectorle -oidvectoreq -oidvectorge -oidvectorgt -getpgusername -oidlt -oidle -octet_length -get_byte -set_byte -get_bit -set_bit -overlay -overlay -bit_count -dist_pl -dist_lp -dist_lb -dist_bl -dist_sl -dist_ls -dist_cpoly -dist_polyc -poly_distance -dist_ppoly -dist_polyp -dist_cpoint -text_lt -text_le -text_gt -text_ge -current_user -session_user -array_eq -array_ne -array_lt -array_gt -array_le -array_ge -array_dims -array_ndims -array_in -array_out -array_lower -array_upper -array_length -cardinality -array_append -array_prepend -array_cat -string_to_array -string_to_array -string_to_table -string_to_table -array_to_string -array_to_string -array_larger -array_smaller -array_position -array_position -array_positions -generate_subscripts -generate_subscripts -array_fill -array_fill -unnest -array_unnest_support -array_remove -array_replace -array_agg_transfn -array_agg_finalfn -array_agg -array_agg_array_transfn -array_agg_array_finalfn -array_agg -width_bucket -trim_array -array_typanalyze -arraycontsel -arraycontjoinsel -int4inc -int4larger -int4smaller -int2larger -int2smaller -cash_mul_flt4 -cash_div_flt4 -flt4_mul_cash -position -textlike -textlike_support -textnlike -int48eq -int48ne -int48lt -int48gt -int48le -int48ge -namelike -namenlike -bpchar -current_database -int8_mul_cash -int4_mul_cash -int2_mul_cash -cash_mul_int8 -cash_div_int8 -cash_mul_int4 -cash_div_int4 -cash_mul_int2 -cash_div_int2 -cash_in -cash_out -cash_eq -cash_ne -cash_lt -cash_le -cash_gt -cash_ge -cash_pl -cash_mi -cash_mul_flt8 -cash_div_flt8 -cashlarger -cashsmaller -flt8_mul_cash -cash_words -cash_div_cash -numeric -money -money -money -mod -mod -int8mod -mod -gcd -gcd -lcm -lcm -char -text -on_pl -on_sl -close_pl -close_sl -close_lb -path_inter -area -width -height -box_distance -area -box_intersect -bound_box -diagonal -path_n_lt -path_n_gt -path_n_eq -path_n_le -path_n_ge -path_length -point_ne -point_vert -point_horiz -point_distance -slope -lseg -lseg_intersect -lseg_parallel -lseg_perp -lseg_vertical -lseg_horizontal -lseg_eq -timezone -aclitemin -aclitemout -aclinsert -aclremove -aclcontains -aclitemeq -makeaclitem -acldefault -aclexplode -bpcharin -bpcharout -bpchartypmodin -bpchartypmodout -varcharin -varcharout -varchartypmodin -varchartypmodout -bpchareq -bpcharlt -bpcharle -bpchargt -bpcharge -bpcharne -bpchar_larger -bpchar_smaller -bpcharcmp -bpchar_sortsupport -hashbpchar -hashbpcharextended -format_type -date_in -date_out -date_eq -date_lt -date_le -date_gt -date_ge -date_ne -date_cmp -date_sortsupport -in_range -time_lt -time_le -time_gt -time_ge -time_ne -time_cmp -date_larger -date_smaller -date_mi -date_pli -date_mii -time_in -time_out -timetypmodin -timetypmodout -time_eq -circle_add_pt -circle_sub_pt -circle_mul_pt -circle_div_pt -timestamptz_in -timestamptz_out -timestamptztypmodin -timestamptztypmodout -timestamptz_eq -timestamptz_ne -timestamptz_lt -timestamptz_le -timestamptz_ge -timestamptz_gt -to_timestamp -timezone -interval_in -interval_out -intervaltypmodin -intervaltypmodout -interval_eq -interval_ne -interval_lt -interval_le -interval_ge -interval_gt -interval_um -interval_pl -interval_mi -date_part -extract -date_part -extract -timestamptz -justify_interval -justify_hours -justify_days -date -age -mxid_age -timestamptz_mi -timestamptz_pl_interval -timestamptz_mi_interval -timestamptz_smaller -timestamptz_larger -interval_smaller -interval_larger -age -interval_support -interval -date_trunc -date_trunc -date_trunc -int8inc -int8dec -int8inc_any -int8dec_any -int8abs -int8larger -int8smaller -texticregexeq -texticregexeq_support -texticregexne -nameicregexeq -nameicregexne -int4abs -int2abs -overlaps -datetime_pl -date_part -extract -int84pl -int84mi -int84mul -int84div -int48pl -int48mi -int48mul -int48div -int82pl -int82mi -int82mul -int82div -int28pl -int28mi -int28mul -int28div -oid -int8 -tideq -tidne -tidgt -tidlt -tidge -tidle -bttidcmp -tidlarger -tidsmaller -hashtid -hashtidextended -datetimetz_pl -now -transaction_timestamp -statement_timestamp -positionsel -positionjoinsel -contsel -contjoinsel -overlaps -overlaps -timestamp_in -timestamp_out -timestamptypmodin -timestamptypmodout -timestamptz_cmp -interval_cmp -time -length -length -xideqint4 -xidneqint4 -interval_div -dlog10 -log -log10 -ln -round -trunc -sqrt -cbrt -pow -power -exp -oidvectortypes -timetz_in -timetz_out -timetztypmodin -timetztypmodout -timetz_eq -timetz_ne -obj_description -timetz_lt -timetz_le -timetz_ge -timetz_gt -timetz_cmp -timestamptz -character_length -character_length -interval -char_length -octet_length -octet_length -time_larger -time_smaller -timetz_larger -timetz_smaller -char_length -extract -date_part -extract -timetz -isfinite -isfinite -isfinite -factorial -abs -abs -abs -abs -abs -name -varchar -current_schema -current_schemas -overlay -overlay -isvertical -ishorizontal -isparallel -isperp -isvertical -ishorizontal -isparallel -isperp -isvertical -ishorizontal -point -time -box -box_add -box_sub -box_mul -box_div -poly_contain_pt -pt_contained_poly -isclosed -isopen -path_npoints -pclose -popen -path_add -path_add_pt -path_sub_pt -path_mul_pt -path_div_pt -point -point_add -point_sub -point_mul -point_div -poly_npoints -box -path -polygon -polygon -circle_in -circle_out -circle_same -circle_contain -circle_left -circle_overleft -circle_overright -circle_right -circle_contained -circle_overlap -circle_below -circle_above -circle_eq -circle_ne -circle_lt -circle_gt -circle_le -circle_ge -area -diameter -radius -circle_distance -circle_center -circle -circle -polygon -dist_pc -circle_contain_pt -pt_contained_circle -box -circle -box -lseg_ne -lseg_lt -lseg_le -lseg_gt -lseg_ge -lseg_length -close_ls -close_lseg -line_in -line_out -line_eq -line -line_interpt -line_intersect -line_parallel -line_perp -line_vertical -line_horizontal -length -length -point -point -point -point -lseg -center -center -npoints -npoints -bit_in -bit_out -bittypmodin -bittypmodout -like -notlike -like -notlike -pg_sequence_parameters -varbit_in -varbit_out -varbittypmodin -varbittypmodout -biteq -bitne -bitge -bitgt -bitle -bitlt -bitcmp -asin -acos -atan -atan2 -sin -cos -tan -cot -asind -acosd -atand -atan2d -sind -cosd -tand -cotd -degrees -radians -pi -sinh -cosh -tanh -asinh -acosh -atanh -interval_mul -ascii -chr -repeat -similar_escape -similar_to_escape -similar_to_escape -mul_d_interval -bpcharlike -bpcharnlike -texticlike -texticlike_support -texticnlike -nameiclike -nameicnlike -like_escape -bpcharicregexeq -bpcharicregexne -bpcharregexeq -bpcharregexne -bpchariclike -bpcharicnlike -strpos -lower -upper -initcap -lpad -rpad -ltrim -rtrim -substr -translate -ltrim -rtrim -substr -btrim -btrim -substring -substring -replace -regexp_replace -regexp_replace -regexp_match -regexp_match -regexp_matches -regexp_matches -split_part -regexp_split_to_table -regexp_split_to_table -regexp_split_to_array -regexp_split_to_array -to_hex -to_hex -getdatabaseencoding -pg_client_encoding -length -convert_from -convert_to -convert -pg_char_to_encoding -pg_encoding_to_char -pg_encoding_max_length -oidgt -oidge -pg_get_ruledef -pg_get_viewdef -pg_get_viewdef -pg_get_userbyid -pg_get_indexdef -pg_get_statisticsobjdef -pg_get_statisticsobjdef_columns -pg_get_statisticsobjdef_expressions -pg_get_partkeydef -pg_get_partition_constraintdef -pg_get_triggerdef -pg_get_constraintdef -pg_get_expr -pg_get_serial_sequence -pg_get_functiondef -pg_get_function_arguments -pg_get_function_identity_arguments -pg_get_function_result -pg_get_function_arg_default -pg_get_function_sqlbody -pg_get_keywords -pg_get_catalog_foreign_keys -pg_options_to_table -pg_typeof -pg_collation_for -pg_relation_is_updatable -pg_column_is_updatable -pg_get_replica_identity_index -varbiteq -varbitne -varbitge -varbitgt -varbitle -varbitlt -varbitcmp -bitand -bitor -bitxor -bitnot -bitshiftleft -bitshiftright -bitcat -substring -length -octet_length -bit -int4 -bit -varbit_support -varbit -position -substring -overlay -overlay -get_bit -set_bit -bit_count -macaddr_in -macaddr_out -trunc -macaddr_eq -macaddr_lt -macaddr_le -macaddr_gt -macaddr_ge -macaddr_ne -macaddr_cmp -macaddr_not -macaddr_and -macaddr_or -macaddr_sortsupport -macaddr8_in -macaddr8_out -trunc -macaddr8_eq -macaddr8_lt -macaddr8_le -macaddr8_gt -macaddr8_ge -macaddr8_ne -macaddr8_cmp -macaddr8_not -macaddr8_and -macaddr8_or -macaddr8 -macaddr -macaddr8_set7bit -inet_in -inet_out -cidr_in -cidr_out -network_eq -network_lt -network_le -network_gt -network_ge -network_ne -network_larger -network_smaller -network_cmp -network_sub -network_subeq -network_sup -network_supeq -network_subset_support -network_overlap -network_sortsupport -abbrev -abbrev -set_masklen -set_masklen -family -network -netmask -masklen -broadcast -host -text -hostmask -cidr -inet_client_addr -inet_client_port -inet_server_addr -inet_server_port -inetnot -inetand -inetor -inetpl -inetmi_int8 -inetmi -inet_same_family -inet_merge -inet_gist_consistent -inet_gist_union -inet_gist_compress -inet_gist_fetch -inet_gist_penalty -inet_gist_picksplit -inet_gist_same -inet_spg_config -inet_spg_choose -inet_spg_picksplit -inet_spg_inner_consistent -inet_spg_leaf_consistent -networksel -networkjoinsel -time_mi_time -boolle -boolge -btboolcmp -time_hash -time_hash_extended -timetz_hash -timetz_hash_extended -interval_hash -interval_hash_extended -numeric_in -numeric_out -numerictypmodin -numerictypmodout -numeric_support -numeric -numeric_abs -abs -sign -round -trunc -ceil -ceiling -floor -numeric_eq -numeric_ne -numeric_gt -numeric_ge -numeric_lt -numeric_le -numeric_add -numeric_sub -numeric_mul -numeric_div -mod -numeric_mod -gcd -lcm -sqrt -numeric_sqrt -exp -numeric_exp -ln -numeric_ln -log -numeric_log -pow -power -numeric_power -scale -min_scale -trim_scale -numeric -numeric -numeric -int4 -float4 -float8 -div -numeric_div_trunc -width_bucket -time_pl_interval -time_mi_interval -timetz_pl_interval -timetz_mi_interval -numeric_inc -numeric_smaller -numeric_larger -numeric_cmp -numeric_sortsupport -numeric_uminus -int8 -numeric -numeric -int2 -pg_lsn -bool -numeric -int2 -int4 -int8 -float4 -float8 -to_char -to_char -to_char -to_char -to_char -to_char -to_number -to_timestamp -to_date -to_char -quote_ident -quote_literal -quote_literal -quote_nullable -quote_nullable -oidin -oidout -concat -concat_ws -left -right -reverse -format -format -iclikesel -icnlikesel -iclikejoinsel -icnlikejoinsel -regexeqsel -likesel -icregexeqsel -regexnesel -nlikesel -icregexnesel -regexeqjoinsel -likejoinsel -icregexeqjoinsel -regexnejoinsel -nlikejoinsel -icregexnejoinsel -prefixsel -prefixjoinsel -float8_avg -float8_var_pop -float8_var_samp -float8_stddev_pop -float8_stddev_samp -numeric_accum -numeric_combine -numeric_avg_accum -numeric_avg_combine -numeric_avg_serialize -numeric_avg_deserialize -numeric_serialize -numeric_deserialize -numeric_accum_inv -int2_accum -int4_accum -int8_accum -numeric_poly_combine -numeric_poly_serialize -numeric_poly_deserialize -int8_avg_accum -int2_accum_inv -int4_accum_inv -int8_accum_inv -int8_avg_accum_inv -int8_avg_combine -int8_avg_serialize -int8_avg_deserialize -int4_avg_combine -numeric_sum -numeric_avg -numeric_var_pop -numeric_var_samp -numeric_stddev_pop -numeric_stddev_samp -int2_sum -int4_sum -int8_sum -numeric_poly_sum -numeric_poly_avg -numeric_poly_var_pop -numeric_poly_var_samp -numeric_poly_stddev_pop -numeric_poly_stddev_samp -interval_accum -interval_combine -interval_accum_inv -interval_avg -int2_avg_accum -int4_avg_accum -int2_avg_accum_inv -int4_avg_accum_inv -int8_avg -int2int4_sum -int8inc_float8_float8 -float8_regr_accum -float8_regr_combine -float8_regr_sxx -float8_regr_syy -float8_regr_sxy -float8_regr_avgx -float8_regr_avgy -float8_regr_r2 -float8_regr_slope -float8_regr_intercept -float8_covar_pop -float8_covar_samp -float8_corr -string_agg_transfn -string_agg_finalfn -string_agg -bytea_string_agg_transfn -bytea_string_agg_finalfn -string_agg -to_ascii -to_ascii -to_ascii -int28eq -int28ne -int28lt -int28gt -int28le -int28ge -int82eq -int82ne -int82lt -int82gt -int82le -int82ge -int2and -int2or -int2xor -int2not -int2shl -int2shr -int4and -int4or -int4xor -int4not -int4shl -int4shr -int8and -int8or -int8xor -int8not -int8shl -int8shr -int8up -int2up -int4up -float4up -float8up -numeric_uplus -has_table_privilege -has_table_privilege -has_table_privilege -has_table_privilege -has_table_privilege -has_table_privilege -has_sequence_privilege -has_sequence_privilege -has_sequence_privilege -has_sequence_privilege -has_sequence_privilege -has_sequence_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_column_privilege -has_any_column_privilege -has_any_column_privilege -has_any_column_privilege -has_any_column_privilege -has_any_column_privilege -has_any_column_privilege -pg_ndistinct_in -pg_ndistinct_out -pg_ndistinct_recv -pg_ndistinct_send -pg_dependencies_in -pg_dependencies_out -pg_dependencies_recv -pg_dependencies_send -pg_mcv_list_in -pg_mcv_list_out -pg_mcv_list_recv -pg_mcv_list_send -pg_mcv_list_items -pg_stat_get_numscans -pg_stat_get_tuples_returned -pg_stat_get_tuples_fetched -pg_stat_get_tuples_inserted -pg_stat_get_tuples_updated -pg_stat_get_tuples_deleted -pg_stat_get_tuples_hot_updated -pg_stat_get_live_tuples -pg_stat_get_dead_tuples -pg_stat_get_mod_since_analyze -pg_stat_get_ins_since_vacuum -pg_stat_get_blocks_fetched -pg_stat_get_blocks_hit -pg_stat_get_last_vacuum_time -pg_stat_get_last_autovacuum_time -pg_stat_get_last_analyze_time -pg_stat_get_last_autoanalyze_time -pg_stat_get_vacuum_count -pg_stat_get_autovacuum_count -pg_stat_get_analyze_count -pg_stat_get_autoanalyze_count -pg_stat_get_backend_idset -pg_stat_get_db_tuples_deleted -pg_stat_get_db_conflict_tablespace -pg_stat_get_db_conflict_lock -pg_stat_get_db_conflict_snapshot -pg_stat_get_activity -pg_stat_get_progress_info -pg_stat_get_wal_senders -pg_stat_get_wal_receiver -pg_stat_get_replication_slot -pg_stat_get_subscription -pg_backend_pid -pg_stat_get_backend_pid -pg_stat_get_backend_dbid -pg_stat_get_backend_userid -pg_stat_get_backend_activity -pg_stat_get_backend_wait_event_type -pg_stat_get_backend_wait_event -pg_stat_get_backend_activity_start -pg_stat_get_backend_xact_start -pg_stat_get_backend_start -pg_stat_get_backend_client_addr -pg_stat_get_backend_client_port -pg_stat_get_db_numbackends -pg_stat_get_db_xact_commit -pg_stat_get_db_xact_rollback -pg_stat_get_db_blocks_fetched -pg_stat_get_db_blocks_hit -pg_stat_get_db_tuples_returned -pg_stat_get_db_tuples_fetched -pg_stat_get_db_tuples_inserted -pg_stat_get_db_tuples_updated -pg_stat_get_db_conflict_bufferpin -pg_stat_get_db_conflict_startup_deadlock -pg_stat_get_db_conflict_all -pg_stat_get_db_deadlocks -pg_stat_get_db_checksum_failures -pg_stat_get_db_checksum_last_failure -pg_stat_get_db_stat_reset_time -pg_stat_get_db_temp_files -pg_stat_get_db_temp_bytes -pg_stat_get_db_blk_read_time -pg_stat_get_db_blk_write_time -pg_stat_get_db_session_time -pg_stat_get_db_active_time -pg_stat_get_db_idle_in_transaction_time -pg_stat_get_db_sessions -pg_stat_get_db_sessions_abandoned -pg_stat_get_db_sessions_fatal -pg_stat_get_db_sessions_killed -pg_stat_get_archiver -pg_stat_get_bgwriter_timed_checkpoints -pg_stat_get_bgwriter_requested_checkpoints -pg_stat_get_bgwriter_buf_written_checkpoints -pg_stat_get_bgwriter_buf_written_clean -pg_stat_get_bgwriter_maxwritten_clean -pg_stat_get_bgwriter_stat_reset_time -pg_stat_get_checkpoint_write_time -pg_stat_get_checkpoint_sync_time -pg_stat_get_buf_written_backend -pg_stat_get_buf_fsync_backend -pg_stat_get_buf_alloc -pg_stat_get_wal -pg_stat_get_slru -pg_stat_get_function_calls -pg_stat_get_function_total_time -pg_stat_get_function_self_time -pg_stat_get_snapshot_timestamp -pg_trigger_depth -pg_tablespace_location -encode -decode -byteaeq -bytealt -byteale -byteagt -byteage -byteane -byteacmp -bytea_sortsupport -timestamp_support -time_support -timestamp -oidlarger -oidsmaller -timestamptz -time -timetz -textanycat -anytextcat -bytealike -byteanlike -like -notlike -like_escape -length -byteacat -substring -substring -substr -substr -position -btrim -ltrim -rtrim -time -date_trunc -date_bin -date_bin -date_part -extract -timestamp -timestamp -timestamp -timestamptz -date -timestamp_mi -timestamp_pl_interval -timestamp_mi_interval -timestamp_smaller -timestamp_larger -timezone -timestamp_hash -timestamp_hash_extended -overlaps -timestamp_cmp -timestamp_sortsupport -in_range -in_range -in_range -in_range -in_range -time -timetz -isfinite -to_char -timestamp_eq -timestamp_ne -timestamp_lt -timestamp_le -timestamp_ge -timestamp_gt -age -timezone -timezone -date_pl_interval -date_mi_interval -substring -bit -int8 -current_setting -current_setting -pg_show_all_settings -pg_describe_object -pg_identify_object -pg_identify_object_as_address -pg_get_object_address -pg_table_is_visible -pg_type_is_visible -pg_function_is_visible -pg_operator_is_visible -pg_opclass_is_visible -pg_opfamily_is_visible -pg_conversion_is_visible -pg_statistics_obj_is_visible -pg_ts_parser_is_visible -pg_ts_dict_is_visible -pg_ts_template_is_visible -pg_ts_config_is_visible -pg_collation_is_visible -pg_my_temp_schema -pg_is_other_temp_schema -pg_backup_start_time -pg_walfile_name_offset -pg_walfile_name -pg_wal_lsn_diff -text -avg -avg -avg -avg -avg -avg -avg -sum -sum -sum -sum -sum -sum -sum -sum -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -max -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -min -count -count -var_pop -var_pop -var_pop -var_pop -var_pop -var_pop -var_samp -var_samp -var_samp -var_samp -var_samp -var_samp -variance -variance -variance -variance -variance -variance -stddev_pop -stddev_pop -stddev_pop -stddev_pop -stddev_pop -stddev_pop -stddev_samp -stddev_samp -stddev_samp -stddev_samp -stddev_samp -stddev_samp -stddev -stddev -stddev -stddev -stddev -stddev -regr_count -regr_sxx -regr_syy -regr_sxy -regr_avgx -regr_avgy -regr_r2 -regr_slope -regr_intercept -covar_pop -covar_samp -corr -text_pattern_lt -text_pattern_le -text_pattern_ge -text_pattern_gt -bttext_pattern_cmp -bttext_pattern_sortsupport -bpchar_pattern_lt -bpchar_pattern_le -bpchar_pattern_ge -bpchar_pattern_gt -btbpchar_pattern_cmp -btbpchar_pattern_sortsupport -btint48cmp -btint84cmp -btint24cmp -btint42cmp -btint28cmp -btint82cmp -btfloat48cmp -btfloat84cmp -regprocedurein -regprocedureout -regoperin -regoperout -to_regoper -to_regoperator -regoperatorin -regoperatorout -regclassin -regclassout -to_regclass -regcollationin -regcollationout -to_regcollation -regtypein -regtypeout -to_regtype -regclass -regrolein -regroleout -to_regrole -regnamespacein -regnamespaceout -to_regnamespace -fmgr_internal_validator -fmgr_c_validator -fmgr_sql_validator -has_database_privilege -has_database_privilege -has_database_privilege -has_database_privilege -has_database_privilege -has_database_privilege -has_function_privilege -has_function_privilege -language_handler_in -has_function_privilege -has_function_privilege -has_function_privilege -has_function_privilege -has_language_privilege -has_language_privilege -has_language_privilege -has_language_privilege -has_language_privilege -has_language_privilege -has_schema_privilege -has_schema_privilege -has_schema_privilege -has_schema_privilege -has_schema_privilege -has_schema_privilege -has_tablespace_privilege -has_tablespace_privilege -has_tablespace_privilege -has_tablespace_privilege -has_tablespace_privilege -has_tablespace_privilege -has_foreign_data_wrapper_privilege -has_foreign_data_wrapper_privilege -has_foreign_data_wrapper_privilege -has_foreign_data_wrapper_privilege -has_foreign_data_wrapper_privilege -has_foreign_data_wrapper_privilege -has_server_privilege -has_server_privilege -has_server_privilege -has_server_privilege -has_server_privilege -has_server_privilege -has_type_privilege -has_type_privilege -has_type_privilege -has_type_privilege -has_type_privilege -has_type_privilege -pg_has_role -pg_has_role -pg_has_role -pg_has_role -pg_has_role -pg_has_role -pg_column_size -pg_column_compression -pg_size_pretty -pg_size_pretty -pg_size_bytes -pg_relation_filenode -pg_filenode_relation -pg_relation_filepath -postgresql_fdw_validator -record_in -record_out -cstring_in -cstring_out -any_in -any_out -anyarray_in -anyarray_out -void_in -void_out -trigger_in -trigger_out -event_trigger_in -event_trigger_out -language_handler_out -internal_in -internal_out -anyelement_in -anyelement_out -shell_in -shell_out -domain_in -domain_recv -anynonarray_in -anynonarray_out -fdw_handler_in -fdw_handler_out -index_am_handler_in -index_am_handler_out -tsm_handler_in -tsm_handler_out -table_am_handler_in -table_am_handler_out -anycompatible_in -anycompatible_out -anycompatiblearray_in -anycompatiblearray_out -anycompatiblearray_recv -anycompatiblearray_send -anycompatiblenonarray_in -anycompatiblenonarray_out -anycompatiblerange_in -anycompatiblerange_out -anycompatiblemultirange_in -anycompatiblemultirange_out -md5 -md5 -sha224 -sha256 -sha384 -sha512 -date_lt_timestamp -date_le_timestamp -date_eq_timestamp -date_gt_timestamp -date_ge_timestamp -date_ne_timestamp -date_cmp_timestamp -date_lt_timestamptz -date_le_timestamptz -date_eq_timestamptz -date_gt_timestamptz -date_ge_timestamptz -date_ne_timestamptz -date_cmp_timestamptz -timestamp_lt_date -timestamp_le_date -timestamp_eq_date -timestamp_gt_date -timestamp_ge_date -timestamp_ne_date -timestamp_cmp_date -timestamptz_lt_date -timestamptz_le_date -timestamptz_eq_date -timestamptz_gt_date -timestamptz_ge_date -timestamptz_ne_date -timestamptz_cmp_date -timestamp_lt_timestamptz -timestamp_le_timestamptz -timestamp_eq_timestamptz -timestamp_gt_timestamptz -timestamp_ge_timestamptz -timestamp_ne_timestamptz -timestamp_cmp_timestamptz -timestamptz_lt_timestamp -timestamptz_le_timestamp -timestamptz_eq_timestamp -timestamptz_gt_timestamp -timestamptz_ge_timestamp -timestamptz_ne_timestamp -timestamptz_cmp_timestamp -array_recv -array_send -record_recv -record_send -int2recv -int2send -int4recv -int4send -int8recv -int8send -int2vectorrecv -int2vectorsend -bytearecv -byteasend -textrecv -textsend -unknownrecv -unknownsend -oidrecv -oidsend -oidvectorrecv -oidvectorsend -namerecv -namesend -float4recv -float4send -float8recv -float8send -point_recv -point_send -bpcharrecv -bpcharsend -varcharrecv -varcharsend -charrecv -charsend -boolrecv -boolsend -tidrecv -tidsend -xidrecv -xidsend -cidrecv -cidsend -regprocrecv -regprocsend -regprocedurerecv -regproceduresend -regoperrecv -regopersend -regoperatorrecv -regoperatorsend -regclassrecv -regclasssend -regcollationrecv -regcollationsend -regtyperecv -regtypesend -regrolerecv -regrolesend -regnamespacerecv -regnamespacesend -bit_recv -bit_send -varbit_recv -varbit_send -numeric_recv -numeric_send -date_recv -date_send -time_recv -time_send -timetz_recv -timetz_send -timestamp_recv -timestamp_send -timestamptz_recv -timestamptz_send -interval_recv -interval_send -lseg_recv -lseg_send -path_recv -path_send -box_recv -box_send -poly_recv -poly_send -line_recv -line_send -circle_recv -circle_send -cash_recv -cash_send -macaddr_recv -macaddr_send -inet_recv -inet_send -cidr_recv -cidr_send -cstring_recv -cstring_send -anyarray_recv -anyarray_send -void_recv -void_send -macaddr8_recv -macaddr8_send -pg_get_ruledef -pg_get_viewdef -pg_get_viewdef -pg_get_viewdef -pg_get_indexdef -pg_get_constraintdef -pg_get_expr -pg_prepared_statement -pg_cursor -pg_timezone_abbrevs -pg_timezone_names -pg_get_triggerdef -pg_listening_channels -generate_series -generate_series -generate_series_int4_support -generate_series -generate_series -generate_series_int8_support -generate_series -generate_series -generate_series -generate_series -booland_statefunc -boolor_statefunc -bool_accum -bool_accum_inv -bool_alltrue -bool_anytrue -bool_and -bool_or -every -bit_and -bit_or -bit_xor -bit_and -bit_or -bit_xor -bit_and -bit_or -bit_xor -bit_and -bit_or -bit_xor -pg_tablespace_databases -bool -int4 -pg_postmaster_start_time -pg_conf_load_time -box_below -box_overbelow -box_overabove -box_above -poly_below -poly_overbelow -poly_overabove -poly_above -circle_overbelow -circle_overabove -gist_box_consistent -gist_box_penalty -gist_box_picksplit -gist_box_union -gist_box_same -gist_box_distance -gist_poly_consistent -gist_poly_compress -gist_circle_consistent -gist_circle_compress -gist_point_compress -gist_point_fetch -gist_point_consistent -gist_point_distance -gist_circle_distance -gist_poly_distance -gist_point_sortsupport -ginarrayextract -ginqueryarrayextract -ginarrayconsistent -pg_lsn_lt -ginarraytriconsistent -ginarrayextract -arrayoverlap -arraycontains -arraycontained -brin_minmax_opcinfo -brin_minmax_add_value -brin_minmax_consistent -brin_minmax_union -brin_minmax_multi_opcinfo -brin_minmax_multi_add_value -brin_minmax_multi_consistent -brin_minmax_multi_union -brin_minmax_multi_options -brin_minmax_multi_distance_int2 -brin_minmax_multi_distance_int4 -brin_minmax_multi_distance_int8 -brin_minmax_multi_distance_float4 -brin_minmax_multi_distance_float8 -brin_minmax_multi_distance_numeric -brin_minmax_multi_distance_tid -brin_minmax_multi_distance_uuid -brin_minmax_multi_distance_date -brin_minmax_multi_distance_time -brin_minmax_multi_distance_interval -brin_minmax_multi_distance_timetz -brin_minmax_multi_distance_pg_lsn -brin_minmax_multi_distance_macaddr -brin_minmax_multi_distance_macaddr8 -brin_minmax_multi_distance_inet -brin_minmax_multi_distance_timestamp -brin_inclusion_opcinfo -brin_inclusion_add_value -brin_inclusion_consistent -brin_inclusion_union -brin_bloom_opcinfo -brin_bloom_add_value -brin_bloom_consistent -brin_bloom_union -brin_bloom_options -xml_in -xml_out -xmlcomment -xml -xmlvalidate -xml_recv -xml_send -xmlconcat2 -xmlagg -text -table_to_xml -table_to_xmlschema -table_to_xml_and_xmlschema -schema_to_xml -schema_to_xmlschema -schema_to_xml_and_xmlschema -database_to_xml -database_to_xmlschema -database_to_xml_and_xmlschema -xpath -xmlexists -xpath_exists -xml_is_well_formed -xml_is_well_formed_document -xml_is_well_formed_content -json_in -json_out -json_recv -json_send -array_to_json -array_to_json -row_to_json -row_to_json -json_agg_transfn -json_agg_finalfn -json_agg -json_object_agg_transfn -json_object_agg_finalfn -json_object_agg -json_build_array -json_build_array -json_build_object -json_build_object -json_object -json_object -to_json -json_strip_nulls -json_object_field -json_object_field_text -json_array_element -json_array_element_text -json_extract_path -json_extract_path_text -json_array_elements -json_array_elements_text -json_array_length -json_object_keys -json_each -json_each_text -json_to_record -json_to_recordset -json_typeof -uuid_in -uuid_out -uuid_lt -uuid_le -uuid_eq -uuid_ge -uuid_gt -uuid_ne -uuid_cmp -uuid_sortsupport -uuid_recv -uuid_send -uuid_hash -uuid_hash_extended -pg_lsn_in -pg_lsn_out -pg_lsn_le -pg_lsn_eq -pg_lsn_ge -pg_lsn_gt -pg_lsn_ne -pg_lsn_mi -pg_lsn_recv -pg_lsn_send -pg_lsn_cmp -pg_lsn_hash -pg_lsn_hash_extended -pg_lsn_larger -pg_lsn_smaller -pg_lsn_pli -pg_lsn_mii -anyenum_in -anyenum_out -enum_in -enum_out -enum_eq -enum_ne -enum_lt -enum_gt -enum_le -enum_ge -enum_cmp -hashenum -hashenumextended -enum_smaller -enum_larger -max -min -enum_first -enum_last -enum_range -enum_range -enum_recv -enum_send -tsvectorin -tsvectorrecv -tsvectorout -tsvectorsend -tsqueryin -tsqueryrecv -tsqueryout -tsquerysend -gtsvectorin -gtsvectorout -tsvector_lt -tsvector_le -tsvector_eq -tsvector_ne -tsvector_ge -tsvector_gt -tsvector_cmp -length -strip -setweight -setweight -tsvector_concat -ts_delete -ts_delete -unnest -tsvector_to_array -array_to_tsvector -ts_filter -ts_match_vq -ts_match_qv -ts_match_tt -ts_match_tq -gtsvector_compress -gtsvector_decompress -gtsvector_picksplit -gtsvector_union -gtsvector_same -gtsvector_penalty -gtsvector_consistent -gtsvector_consistent -gtsvector_options -gin_extract_tsvector -gin_extract_tsquery -gin_tsquery_consistent -gin_tsquery_triconsistent -gin_cmp_tslexeme -gin_cmp_prefix -gin_extract_tsvector -gin_extract_tsquery -gin_tsquery_consistent -gin_extract_tsquery -gin_tsquery_consistent -tsquery_lt -tsquery_le -tsquery_eq -tsquery_ne -tsquery_ge -tsquery_gt -tsquery_cmp -tsquery_and -tsquery_or -tsquery_phrase -tsquery_phrase -tsquery_not -tsq_mcontains -tsq_mcontained -numnode -querytree -ts_rewrite -gtsquery_compress -gtsquery_picksplit -gtsquery_union -gtsquery_same -gtsquery_penalty -gtsquery_consistent -gtsquery_consistent -tsmatchsel -tsmatchjoinsel -ts_typanalyze -ts_rank -ts_rank -ts_rank -ts_rank -ts_rank_cd -ts_rank_cd -ts_rank_cd -ts_rank_cd -ts_token_type -ts_token_type -ts_parse -ts_parse -prsd_start -prsd_nexttoken -prsd_end -prsd_headline -prsd_lextype -ts_lexize -dsimple_init -dsimple_lexize -dsynonym_init -dsynonym_lexize -dispell_init -dispell_lexize -thesaurus_init -thesaurus_lexize -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -ts_headline -to_tsvector -to_tsquery -plainto_tsquery -phraseto_tsquery -websearch_to_tsquery -to_tsvector -to_tsquery -plainto_tsquery -phraseto_tsquery -websearch_to_tsquery -to_tsvector -jsonb_to_tsvector -to_tsvector -json_to_tsvector -to_tsvector -jsonb_to_tsvector -to_tsvector -json_to_tsvector -get_current_ts_config -regconfigin -regconfigout -regconfigrecv -regconfigsend -regdictionaryin -regdictionaryout -regdictionaryrecv -regdictionarysend -jsonb_in -jsonb_recv -jsonb_out -jsonb_send -jsonb_object -jsonb_object -to_jsonb -jsonb_agg_transfn -jsonb_agg_finalfn -jsonb_agg -jsonb_object_agg_transfn -jsonb_object_agg_finalfn -jsonb_object_agg -jsonb_build_array -jsonb_build_array -jsonb_build_object -jsonb_build_object -jsonb_strip_nulls -jsonb_object_field -jsonb_object_field_text -jsonb_array_element -jsonb_array_element_text -jsonb_extract_path -jsonb_extract_path_text -jsonb_array_elements -jsonb_array_elements_text -jsonb_array_length -jsonb_object_keys -jsonb_each -jsonb_each_text -jsonb_populate_record -jsonb_populate_recordset -jsonb_to_record -jsonb_to_recordset -jsonb_typeof -jsonb_ne -jsonb_lt -jsonb_gt -jsonb_le -jsonb_ge -jsonb_eq -jsonb_cmp -jsonb_hash -jsonb_hash_extended -jsonb_contains -jsonb_exists -jsonb_exists_any -jsonb_exists_all -jsonb_contained -gin_compare_jsonb -gin_extract_jsonb -gin_extract_jsonb_query -gin_consistent_jsonb -gin_triconsistent_jsonb -gin_extract_jsonb_path -gin_extract_jsonb_query_path -gin_consistent_jsonb_path -gin_triconsistent_jsonb_path -jsonb_concat -jsonb_delete -jsonb_delete -jsonb_delete -jsonb_delete_path -jsonb_pretty -jsonpath_in -jsonpath_recv -jsonpath_out -jsonpath_send -jsonb_insert -jsonb_path_query -jsonb_path_query_first -jsonb_path_query_array_tz -jsonb_path_exists_opr -jsonb_path_match_opr -txid_snapshot_in -txid_snapshot_out -txid_snapshot_recv -txid_snapshot_send -txid_current -txid_current_if_assigned -txid_current_snapshot -txid_snapshot_xmin -txid_snapshot_xmax -txid_snapshot_xip -txid_visible_in_snapshot -pg_snapshot_in -pg_snapshot_out -pg_snapshot_recv -pg_snapshot_send -pg_current_snapshot -pg_snapshot_xmin -pg_snapshot_xmax -pg_snapshot_xip -pg_visible_in_snapshot -pg_current_xact_id -pg_current_xact_id_if_assigned -record_eq -record_ne -record_lt -record_gt -record_le -record_ge -btrecordcmp -hash_record -hash_record_extended -record_image_eq -record_image_ne -record_image_lt -record_image_gt -record_image_le -record_image_ge -btrecordimagecmp -btequalimage -pg_available_extensions -pg_available_extension_versions -pg_extension_update_paths -row_number -rank -dense_rank -percent_rank -cume_dist -ntile -lag -lag -lag -lead -lead -lead -first_value -last_value -nth_value -anyrange_in -anyrange_out -range_in -range_out -range_recv -range_send -lower -upper -isempty -lower_inc -upper_inc -lower_inf -upper_inf -range_eq -range_ne -range_overlaps -range_contains_elem -range_contains -elem_contained_by_range -range_contained_by -range_adjacent -range_before -range_after -range_overleft -range_overright -range_union -range_merge -range_merge -range_intersect -range_minus -range_cmp -range_lt -range_le -range_ge -range_gt -range_gist_consistent -range_gist_union -range_gist_penalty -range_gist_picksplit -range_gist_same -multirange_gist_consistent -multirange_gist_compress -hash_range -hash_range_extended -range_typanalyze -rangesel -range_intersect_agg_transfn -range_intersect_agg -int4range_canonical -int8range_canonical -daterange_canonical -int4range_subdiff -int8range_subdiff -numrange_subdiff -daterange_subdiff -tsrange_subdiff -tstzrange_subdiff -int4range -int4range -numrange -numrange -tsrange -tsrange -tstzrange -tstzrange -daterange -daterange -int8range -int8range -anymultirange_in -anymultirange_out -multirange_in -multirange_out -multirange_recv -multirange_send -lower -upper -isempty -lower_inc -upper_inc -lower_inf -upper_inf -multirange_typanalyze -multirangesel -multirange_eq -multirange_ne -range_overlaps_multirange -multirange_overlaps_range -multirange_overlaps_multirange -multirange_contains_elem -multirange_contains_range -multirange_contains_multirange -elem_contained_by_multirange -range_contained_by_multirange -range_contains_multirange -multirange_contained_by_range -multirange_contained_by_multirange -range_adjacent_multirange -multirange_adjacent_multirange -multirange_adjacent_range -range_before_multirange -multirange_before_range -multirange_before_multirange -range_after_multirange -multirange_after_range -multirange_after_multirange -range_overleft_multirange -multirange_overleft_range -multirange_overleft_multirange -range_overright_multirange -multirange_overright_range -multirange_overright_multirange -multirange_union -multirange_minus -multirange_intersect -multirange_cmp -multirange_lt -multirange_le -multirange_ge -multirange_gt -hash_multirange -hash_multirange_extended -int4multirange -int4multirange -int4multirange -nummultirange -nummultirange -nummultirange -tsmultirange -tsmultirange -tsmultirange -tstzmultirange -tstzmultirange -spg_range_quad_inner_consistent -tstzmultirange -datemultirange -datemultirange -datemultirange -int8multirange -int8multirange -int8multirange -multirange -range_agg_transfn -range_agg_finalfn -range_agg -multirange_intersect_agg_transfn -range_intersect_agg -unnest -make_date -make_time -make_timestamp -make_timestamptz -make_timestamptz -spg_quad_config -spg_quad_choose -spg_quad_picksplit -spg_quad_inner_consistent -spg_quad_leaf_consistent -spg_kd_config -spg_kd_choose -spg_kd_picksplit -spg_kd_inner_consistent -spg_text_config -spg_text_choose -spg_text_picksplit -spg_text_inner_consistent -spg_text_leaf_consistent -spg_range_quad_config -spg_range_quad_choose -spg_range_quad_picksplit -spg_range_quad_leaf_consistent -spg_box_quad_config -spg_box_quad_choose -spg_box_quad_picksplit -spg_box_quad_inner_consistent -spg_box_quad_leaf_consistent -spg_bbox_quad_config -spg_poly_quad_compress -pg_get_replication_slots -pg_event_trigger_dropped_objects -pg_event_trigger_table_rewrite_oid -pg_event_trigger_table_rewrite_reason -pg_event_trigger_ddl_commands -ordered_set_transition -ordered_set_transition_multi -percentile_disc -percentile_disc_final -percentile_cont -percentile_cont_float8_final -percentile_cont -percentile_cont_interval_final -percentile_disc -percentile_disc_multi_final -percentile_cont -percentile_cont_float8_multi_final -percentile_cont -percentile_cont_interval_multi_final -mode -mode_final -rank -rank_final -percent_rank -percent_rank_final -cume_dist -cume_dist_final -dense_rank -dense_rank_final -koi8r_to_mic -mic_to_koi8r -iso_to_mic -mic_to_iso -win1251_to_mic -mic_to_win1251 -win866_to_mic -mic_to_win866 -koi8r_to_win1251 -win1251_to_koi8r -koi8r_to_win866 -win866_to_koi8r -win866_to_win1251 -win1251_to_win866 -iso_to_koi8r -koi8r_to_iso -iso_to_win1251 -win1251_to_iso -iso_to_win866 -win866_to_iso -euc_cn_to_mic -mic_to_euc_cn -euc_jp_to_sjis -sjis_to_euc_jp -euc_jp_to_mic -sjis_to_mic -mic_to_euc_jp -mic_to_sjis -euc_kr_to_mic -mic_to_euc_kr -euc_tw_to_big5 -big5_to_euc_tw -euc_tw_to_mic -big5_to_mic -mic_to_euc_tw -mic_to_big5 -latin2_to_mic -mic_to_latin2 -win1250_to_mic -mic_to_win1250 -latin2_to_win1250 -win1250_to_latin2 -latin1_to_mic -mic_to_latin1 -latin3_to_mic -mic_to_latin3 -latin4_to_mic -mic_to_latin4 -big5_to_utf8 -utf8_to_big5 -utf8_to_koi8r -koi8r_to_utf8 -utf8_to_koi8u -koi8u_to_utf8 -utf8_to_win -win_to_utf8 -euc_cn_to_utf8 -utf8_to_euc_cn -euc_jp_to_utf8 -utf8_to_euc_jp -euc_kr_to_utf8 -utf8_to_euc_kr -euc_tw_to_utf8 -utf8_to_euc_tw -gb18030_to_utf8 -utf8_to_gb18030 -gbk_to_utf8 -utf8_to_gbk -utf8_to_iso8859 -iso8859_to_utf8 -iso8859_1_to_utf8 -utf8_to_iso8859_1 -johab_to_utf8 -utf8_to_johab -sjis_to_utf8 -utf8_to_sjis -uhc_to_utf8 -utf8_to_uhc -euc_jis_2004_to_utf8 -utf8_to_euc_jis_2004 -shift_jis_2004_to_utf8 -utf8_to_shift_jis_2004 -euc_jis_2004_to_shift_jis_2004 -shift_jis_2004_to_euc_jis_2004 -matchingsel -matchingjoinsel -pg_replication_origin_oid -pg_get_publication_tables -pg_relation_is_publishable -row_security_active -row_security_active -array_subscript_handler -raw_array_subscript_handler -jsonb_subscript_handler -satisfies_hash_partition -pg_partition_root -unistr -brin_bloom_summary_in -brin_bloom_summary_out -brin_bloom_summary_recv -pg_config -brin_bloom_summary_send -brin_minmax_multi_summary_in -brin_minmax_multi_summary_out -brin_minmax_multi_summary_recv -brin_minmax_multi_summary_send -lpad -rpad -substring -bit_length -trunc -bit_length -bit_length -log -log10 -round -numeric_pl_pg_lsn -path_contain_pt -polygon -age -age -interval_pl_timetz -date_part -timestamptz -timedate_pl -timetzdate_pl -interval_pl_time -interval_pl_date -interval_pl_timestamp -interval_pl_timestamptz -integer_pl_date -overlaps -overlaps -overlaps -overlaps -overlaps -overlaps -overlaps -overlaps -overlaps -int8pl_inet -xpath -xpath_exists -obj_description -shobj_description -col_description -ts_debug -ts_debug -json_populate_record -json_populate_recordset -make_interval -jsonb_set -jsonb_set_lax -parse_ident -jsonb_path_exists -jsonb_path_match -jsonb_path_query_array -jsonb_path_exists_tz -jsonb_path_match_tz -jsonb_path_query_tz -jsonb_path_query_first_tz -normalize -is_normalized -_pg_expandarray -_pg_index_position -_pg_truetypid -_pg_truetypmod -_pg_char_max_length -_pg_char_octet_length -_pg_numeric_precision -_pg_numeric_precision_radix -_pg_numeric_scale -_pg_datetime_precision -_pg_interval_type diff --git a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs index 1e1febfd5..7965c1676 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,4 @@ -use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_analyse::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; use pgt_console::markup; use pgt_diagnostics::Severity; use std::collections::HashSet; @@ -39,11 +39,6 @@ declare_lint_rule! { } } -// Generated via the following Postgres query: -// select proname from pg_proc where provolatile <> 'v'; -const NON_VOLATILE_BUILT_IN_FUNCTIONS: &str = - include_str!("../../../resources/non_volatile_built_in_functions.txt"); - impl Rule for AddingFieldWithDefault { type Options = (); @@ -91,19 +86,12 @@ impl Rule for AddingFieldWithDefault { } else if has_default { // For PG 11+, check if the default is volatile if pg_version.is_some_and(|v| v >= 11) { - let non_volatile_funcs: HashSet<_> = - NON_VOLATILE_BUILT_IN_FUNCTIONS - .lines() - .map(|x| x.trim().to_lowercase()) - .filter(|x| !x.is_empty()) - .collect(); - // Check if default is non-volatile let is_safe_default = col_def.constraints.iter().any(|constraint| { if let Some(pgt_query::NodeEnum::Constraint(c)) = &constraint.node { if c.contype() == pgt_query::protobuf::ConstrType::ConstrDefault { if let Some(raw_expr) = &c.raw_expr { - return is_safe_default_expr(&raw_expr.node.as_ref().map(|n| Box::new(n.clone())), &non_volatile_funcs); + return is_safe_default_expr(&raw_expr.node.as_ref().map(|n| Box::new(n.clone())), ctx.schema_cache()); } } } @@ -150,7 +138,7 @@ impl Rule for AddingFieldWithDefault { fn is_safe_default_expr( expr: &Option>, - non_volatile_funcs: &HashSet, + schema_cache: Option<&pgt_schema_cache::SchemaCache>, ) -> bool { match expr { Some(node) => match node.as_ref() { @@ -159,23 +147,41 @@ fn is_safe_default_expr( // Type casts of constants are safe pgt_query::NodeEnum::TypeCast(tc) => is_safe_default_expr( &tc.arg.as_ref().and_then(|a| a.node.clone()).map(Box::new), - non_volatile_funcs, + schema_cache, ), - // Function calls might be safe if they're non-volatile and have no args + // function calls might be safe if they are non-volatile and have no args pgt_query::NodeEnum::FuncCall(fc) => { - // Must have no args + // must have no args if !fc.args.is_empty() { return false; } - // Check if function is in non-volatile list - if let Some(first_name) = fc.funcname.first() { - if let Some(pgt_query::NodeEnum::String(s)) = &first_name.node { - return non_volatile_funcs.contains(&s.sval.to_lowercase()); + + let Some(sc) = schema_cache else { + return false; + }; + + let Some((schema, name)) = pgt_query_ext::utils::parse_name(&fc.funcname) else { + return false; + }; + + // check if function is a non-volatile function + sc.functions.iter().any(|f| { + // no args only + if !f.args.args.is_empty() { + return false; } - } - false + + // must be non-volatile + if f.behavior == pgt_schema_cache::Behavior::Volatile { + return false; + } + + // check name and schema (if given) + f.name.eq_ignore_ascii_case(&name) + && schema.as_ref().is_none_or(|s| &f.schema == s) + }) } - // Everything else is potentially unsafe + // everything else is potentially unsafe _ => false, }, None => false, From 64fa4d2bd693fd0f8007b78e02e3a669d41657a7 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 08:14:48 +0200 Subject: [PATCH 12/23] improve code style --- crates/pgt_analyser/Cargo.toml | 2 +- .../lint/safety/adding_field_with_default.rs | 2 +- .../safety/adding_foreign_key_constraint.rs | 66 ++++++++++------ .../safety/adding_primary_key_constraint.rs | 50 ++++++------ .../src/lint/safety/ban_char_field.rs | 77 ++++++++----------- 5 files changed, 101 insertions(+), 96 deletions(-) diff --git a/crates/pgt_analyser/Cargo.toml b/crates/pgt_analyser/Cargo.toml index cfeb4ca4a..4d9b4d90f 100644 --- a/crates/pgt_analyser/Cargo.toml +++ b/crates/pgt_analyser/Cargo.toml @@ -16,7 +16,7 @@ pgt_analyse = { workspace = true } pgt_console = { workspace = true } pgt_diagnostics = { workspace = true } pgt_query = { workspace = true } -pgt_query_ext = { workspace = true } +pgt_query_ext = { workspace = true } pgt_schema_cache = { workspace = true } pgt_text_size = { workspace = true } serde = { workspace = true } diff --git a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs index 7965c1676..05f2687a4 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,4 @@ -use pgt_analyse::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; use pgt_console::markup; use pgt_diagnostics::Severity; use std::collections::HashSet; diff --git a/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs index 06a91e5e4..81fecca56 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -56,21 +56,10 @@ impl Rule for AddingForeignKeyConstraint { if let Some(pgt_query::NodeEnum::Constraint(constraint)) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - // check if it's a foreign key constraint - if constraint.contype() - == pgt_query::protobuf::ConstrType::ConstrForeign + if let Some(diagnostic) = + check_foreign_key_constraint(constraint, false) { - // it is okay if NOT VALID is specified (skip_validation = true) - if !constraint.skip_validation { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a foreign key constraint requires a table scan and locks on both tables." - }, - ).detail(None, "This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint.") - .note("Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction.")); - } + diagnostics.push(diagnostic); } } } @@ -83,18 +72,10 @@ impl Rule for AddingForeignKeyConstraint { if let Some(pgt_query::NodeEnum::Constraint(constr)) = &constraint.node { - if constr.contype() - == pgt_query::protobuf::ConstrType::ConstrForeign - && !constr.skip_validation + if let Some(diagnostic) = + check_foreign_key_constraint(constr, true) { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a column with a foreign key constraint requires a table scan and locks." - }, - ).detail(None, "Using REFERENCES when adding a column will block writes while verifying the constraint.") - .note("Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately.")); + diagnostics.push(diagnostic); } } } @@ -109,3 +90,38 @@ impl Rule for AddingForeignKeyConstraint { diagnostics } } + +fn check_foreign_key_constraint( + constraint: &pgt_query::protobuf::Constraint, + is_column_constraint: bool, +) -> Option { + // Only check foreign key constraints + if constraint.contype() != pgt_query::protobuf::ConstrType::ConstrForeign { + return None; + } + + // NOT VALID constraints are safe + if constraint.skip_validation { + return None; + } + + let (message, detail, note) = if is_column_constraint { + ( + "Adding a column with a foreign key constraint requires a table scan and locks.", + "Using REFERENCES when adding a column will block writes while verifying the constraint.", + "Add the column without the constraint first, then add the constraint as NOT VALID and VALIDATE it separately.", + ) + } else { + ( + "Adding a foreign key constraint requires a table scan and locks on both tables.", + "This will block writes to both the referencing and referenced tables while PostgreSQL verifies the constraint.", + "Add the constraint as NOT VALID first, then VALIDATE it in a separate transaction.", + ) + }; + + Some( + RuleDiagnostic::new(rule_category!(), None, markup! { {message} }) + .detail(None, detail) + .note(note), + ) +} diff --git a/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs index 2bfd940bd..a4758a29f 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -54,18 +54,10 @@ impl Rule for AddingPrimaryKeyConstraint { if let Some(pgt_query::NodeEnum::Constraint(constraint)) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if constraint.contype() - == pgt_query::protobuf::ConstrType::ConstrPrimary - && constraint.indexname.is_empty() + if let Some(diagnostic) = + check_for_primary_key_constraint(constraint) { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a PRIMARY KEY constraint results in locks and table rewrites." - }, - ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") - .note("Add the PRIMARY KEY constraint USING an index.")); + diagnostics.push(diagnostic); } } } @@ -78,18 +70,10 @@ impl Rule for AddingPrimaryKeyConstraint { if let Some(pgt_query::NodeEnum::Constraint(constr)) = &constraint.node { - if constr.contype() - == pgt_query::protobuf::ConstrType::ConstrPrimary - && constr.indexname.is_empty() + if let Some(diagnostic) = + check_for_primary_key_constraint(constr) { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a PRIMARY KEY constraint results in locks and table rewrites." - }, - ).detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") - .note("Add the PRIMARY KEY constraint USING an index.")); + diagnostics.push(diagnostic); } } } @@ -104,3 +88,25 @@ impl Rule for AddingPrimaryKeyConstraint { diagnostics } } + +fn check_for_primary_key_constraint( + constraint: &pgt_query::protobuf::Constraint, +) -> Option { + if constraint.contype() == pgt_query::protobuf::ConstrType::ConstrPrimary + && constraint.indexname.is_empty() + { + Some( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a PRIMARY KEY constraint results in locks and table rewrites." + }, + ) + .detail(None, "Adding a PRIMARY KEY constraint requires an ACCESS EXCLUSIVE lock which blocks reads.") + .note("Add the PRIMARY KEY constraint USING an index."), + ) + } else { + None + } +} diff --git a/crates/pgt_analyser/src/lint/safety/ban_char_field.rs b/crates/pgt_analyser/src/lint/safety/ban_char_field.rs index 1b41df753..12e113b4a 100644 --- a/crates/pgt_analyser/src/lint/safety/ban_char_field.rs +++ b/crates/pgt_analyser/src/lint/safety/ban_char_field.rs @@ -49,30 +49,8 @@ impl Rule for BanCharField { if let pgt_query::NodeEnum::CreateStmt(stmt) = &ctx.stmt() { for table_elt in &stmt.table_elts { if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { - if let Some(type_name) = &col_def.type_name { - for name_node in &type_name.names { - if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { - // Check for "bpchar" (internal name for CHAR type) - // or "char" or "character" - let type_str = name.sval.to_lowercase(); - if type_str == "bpchar" - || type_str == "char" - || type_str == "character" - { - diagnostics.push( - RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "CHAR type is discouraged due to space padding behavior." - }, - ) - .detail(None, "CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior.") - .note("Use VARCHAR or TEXT instead for variable-length character data."), - ); - } - } - } + if let Some(diagnostic) = check_column_for_char_type(col_def) { + diagnostics.push(diagnostic); } } } @@ -86,29 +64,8 @@ impl Rule for BanCharField { if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &cmd.def.as_ref().and_then(|d| d.node.as_ref()) { - if let Some(type_name) = &col_def.type_name { - for name_node in &type_name.names { - if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node - { - let type_str = name.sval.to_lowercase(); - if type_str == "bpchar" - || type_str == "char" - || type_str == "character" - { - diagnostics.push( - RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "CHAR type is discouraged due to space padding behavior." - }, - ) - .detail(None, "CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior.") - .note("Use VARCHAR or TEXT instead for variable-length character data."), - ); - } - } - } + if let Some(diagnostic) = check_column_for_char_type(col_def) { + diagnostics.push(diagnostic); } } } @@ -119,3 +76,29 @@ impl Rule for BanCharField { diagnostics } } + +fn check_column_for_char_type(col_def: &pgt_query::protobuf::ColumnDef) -> Option { + if let Some(type_name) = &col_def.type_name { + for name_node in &type_name.names { + if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { + // Check for "bpchar" (internal name for CHAR type) + // or "char" or "character" + let type_str = name.sval.to_lowercase(); + if type_str == "bpchar" || type_str == "char" || type_str == "character" { + return Some( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "CHAR type is discouraged due to space padding behavior." + }, + ) + .detail(None, "CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior.") + .note("Use VARCHAR or TEXT instead for variable-length character data."), + ); + } + } + } + } + None +} From a01a9f13fed5cbf8dc81a1f3d284ed71c2f6981c Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 08:20:18 +0200 Subject: [PATCH 13/23] improve code style 2 --- .../safety/constraint_missing_not_valid.rs | 64 +++++++++++-------- .../src/lint/safety/prefer_bigint_over_int.rs | 50 ++++++++------- .../safety/prefer_bigint_over_smallint.rs | 48 +++++++------- .../src/lint/safety/prefer_identity.rs | 46 +++++++------ .../require_concurrent_index_creation.rs | 49 ++++++++------ 5 files changed, 146 insertions(+), 111 deletions(-) diff --git a/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs index de68caacf..bbfd7a7d1 100644 --- a/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs +++ b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -40,35 +40,47 @@ impl Rule for ConstraintMissingNotValid { fn run(ctx: &RuleContext) -> Vec { let mut diagnostics = Vec::new(); - if let pgt_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { - for cmd in &stmt.cmds { - if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { - // Check if we're adding a constraint - if let Some(pgt_query::NodeEnum::Constraint(constraint)) = - cmd.def.as_ref().and_then(|d| d.node.as_ref()) - { - // Skip if the constraint has NOT VALID - if constraint.initially_valid { - // Only warn for CHECK and FOREIGN KEY constraints - match constraint.contype() { - pgt_query::protobuf::ConstrType::ConstrCheck - | pgt_query::protobuf::ConstrType::ConstrForeign => { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Adding a constraint without NOT VALID will block reads and writes while validating existing rows." - } - ).detail(None, "Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction.")); - } - _ => {} - } - } - } - } + let pgt_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() else { + return diagnostics; + }; + + for cmd in &stmt.cmds { + let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node else { + continue; + }; + + let Some(pgt_query::NodeEnum::Constraint(constraint)) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) else { + continue; + }; + + if let Some(diagnostic) = check_constraint_needs_not_valid(constraint) { + diagnostics.push(diagnostic); } } diagnostics } } + +fn check_constraint_needs_not_valid(constraint: &pgt_query::protobuf::Constraint) -> Option { + // Skip if the constraint has NOT VALID + if !constraint.initially_valid { + return None; + } + + // Only warn for CHECK and FOREIGN KEY constraints + match constraint.contype() { + pgt_query::protobuf::ConstrType::ConstrCheck + | pgt_query::protobuf::ConstrType::ConstrForeign => Some( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Adding a constraint without NOT VALID will block reads and writes while validating existing rows." + } + ) + .detail(None, "Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction.") + ), + _ => None, + } +} diff --git a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs index b923449a4..755b8f9b8 100644 --- a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs +++ b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_int.rs @@ -98,30 +98,34 @@ fn check_column_def( diagnostics: &mut Vec, col_def: &pgt_query::protobuf::ColumnDef, ) { - if let Some(type_name) = &col_def.type_name { - for name_node in &type_name.names { - if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { - let type_name_lower = name.sval.to_lowercase(); - // Only check for INT4/INTEGER types, not SMALLINT - let is_int4 = matches!( - type_name_lower.as_str(), - "integer" | "int4" | "serial" | "serial4" - ); + let Some(type_name) = &col_def.type_name else { + return; + }; - if is_int4 { - diagnostics.push( - RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "INTEGER type may lead to overflow issues." - }, - ) - .detail(None, "INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters.") - .note("Consider using BIGINT instead for better future-proofing."), - ); - } - } + for name_node in &type_name.names { + let Some(pgt_query::NodeEnum::String(name)) = &name_node.node else { + continue; + }; + + let type_name_lower = name.sval.to_lowercase(); + // Only check for INT4/INTEGER types, not SMALLINT + if !matches!( + type_name_lower.as_str(), + "integer" | "int4" | "serial" | "serial4" + ) { + continue; } + + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "INTEGER type may lead to overflow issues." + }, + ) + .detail(None, "INTEGER has a maximum value of 2,147,483,647 which can be exceeded by ID columns and counters.") + .note("Consider using BIGINT instead for better future-proofing."), + ); } } diff --git a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs index c2b2d7132..cffcc5b74 100644 --- a/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs +++ b/crates/pgt_analyser/src/lint/safety/prefer_bigint_over_smallint.rs @@ -92,29 +92,33 @@ fn check_column_def( diagnostics: &mut Vec, col_def: &pgt_query::protobuf::ColumnDef, ) { - if let Some(type_name) = &col_def.type_name { - for name_node in &type_name.names { - if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { - let type_name_lower = name.sval.to_lowercase(); - let is_smallint = matches!( - type_name_lower.as_str(), - "smallint" | "int2" | "smallserial" | "serial2" - ); + let Some(type_name) = &col_def.type_name else { + return; + }; - if is_smallint { - diagnostics.push( - RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "SMALLINT has a very limited range that is easily exceeded." - }, - ) - .detail(None, "SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient.") - .note("Consider using INTEGER or BIGINT for better range and future-proofing."), - ); - } - } + for name_node in &type_name.names { + let Some(pgt_query::NodeEnum::String(name)) = &name_node.node else { + continue; + }; + + let type_name_lower = name.sval.to_lowercase(); + if !matches!( + type_name_lower.as_str(), + "smallint" | "int2" | "smallserial" | "serial2" + ) { + continue; } + + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "SMALLINT has a very limited range that is easily exceeded." + }, + ) + .detail(None, "SMALLINT can only store values from -32,768 to 32,767. This range is often insufficient.") + .note("Consider using INTEGER or BIGINT for better range and future-proofing."), + ); } } diff --git a/crates/pgt_analyser/src/lint/safety/prefer_identity.rs b/crates/pgt_analyser/src/lint/safety/prefer_identity.rs index 33c3195cd..4abf4c4d1 100644 --- a/crates/pgt_analyser/src/lint/safety/prefer_identity.rs +++ b/crates/pgt_analyser/src/lint/safety/prefer_identity.rs @@ -94,26 +94,32 @@ fn check_column_def( diagnostics: &mut Vec, col_def: &pgt_query::protobuf::ColumnDef, ) { - if let Some(type_name) = &col_def.type_name { - for name_node in &type_name.names { - if let Some(pgt_query::NodeEnum::String(name)) = &name_node.node { - if matches!( - name.sval.as_str(), - "serial" | "serial2" | "serial4" | "serial8" | "smallserial" | "bigserial" - ) { - diagnostics.push( - RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Prefer IDENTITY columns over SERIAL types." - }, - ) - .detail(None, format!("Column uses '{}' type which has limitations compared to IDENTITY columns.", name.sval)) - .note("Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead."), - ); - } - } + let Some(type_name) = &col_def.type_name else { + return; + }; + + for name_node in &type_name.names { + let Some(pgt_query::NodeEnum::String(name)) = &name_node.node else { + continue; + }; + + if !matches!( + name.sval.as_str(), + "serial" | "serial2" | "serial4" | "serial8" | "smallserial" | "bigserial" + ) { + continue; } + + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Prefer IDENTITY columns over SERIAL types." + }, + ) + .detail(None, format!("Column uses '{}' type which has limitations compared to IDENTITY columns.", name.sval)) + .note("Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead."), + ); } } diff --git a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs index ba152d139..4226fbb92 100644 --- a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs +++ b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -38,29 +38,38 @@ impl Rule for RequireConcurrentIndexCreation { fn run(ctx: &RuleContext) -> Vec { let mut diagnostics = Vec::new(); - if let pgt_query::NodeEnum::IndexStmt(stmt) = &ctx.stmt() { - if !stmt.concurrent { - // Check if this table was created in the same transaction/file - let table_name = stmt - .relation - .as_ref() - .map(|r| r.relname.as_str()) - .unwrap_or(""); + let pgt_query::NodeEnum::IndexStmt(stmt) = &ctx.stmt() else { + return diagnostics; + }; - if !table_name.is_empty() - && !is_table_created_in_file(ctx.file_context(), table_name) - { - diagnostics.push(RuleDiagnostic::new( - rule_category!(), - None, - markup! { - "Creating an index non-concurrently blocks writes to the table." - }, - ).detail(None, "Use CREATE INDEX CONCURRENTLY to avoid blocking concurrent operations on the table.")); - } - } + // Concurrent indexes are safe + if stmt.concurrent { + return diagnostics; + } + + // Check if this table was created in the same transaction/file + let table_name = stmt + .relation + .as_ref() + .map(|r| r.relname.as_str()) + .unwrap_or(""); + + // Skip if table name is empty or table was created in the same file + if table_name.is_empty() || is_table_created_in_file(ctx.file_context(), table_name) { + return diagnostics; } + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Creating an index non-concurrently blocks writes to the table." + }, + ) + .detail(None, "Use CREATE INDEX CONCURRENTLY to avoid blocking concurrent operations on the table.") + ); + diagnostics } } From 51812ddd92e3efcb0f4a600d4ae8d6fbf1ccec84 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 08:22:23 +0200 Subject: [PATCH 14/23] chore: accept good snapshots --- .../lint/safety/adding_field_with_default.rs | 3 +-- .../preferIdentity/alter_table.sql.snap | 19 +++++++++++++++++ .../safety/preferIdentity/bigserial.sql.snap | 21 +++++++++++++++++++ .../safety/preferIdentity/valid.sql.snap | 12 +++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap diff --git a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs index 05f2687a4..e2a0376c7 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,7 +1,6 @@ -use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_analyse::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; use pgt_console::markup; use pgt_diagnostics::Severity; -use std::collections::HashSet; declare_lint_rule! { /// Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap new file mode 100644 index 000000000..926f95375 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap @@ -0,0 +1,19 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferIdentity +alter table test add column id serial; +``` + +# Diagnostics +lint/safety/preferIdentity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Prefer IDENTITY columns over SERIAL types. + + i Column uses 'serial' type which has limitations compared to IDENTITY columns. + + i Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead. diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap new file mode 100644 index 000000000..b83eaf6df --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_only_lint/safety/preferIdentity +create table users ( + id bigserial +); +``` + +# Diagnostics +lint/safety/preferIdentity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Prefer IDENTITY columns over SERIAL types. + + i Column uses 'bigserial' type which has limitations compared to IDENTITY columns. + + i Use 'bigint GENERATED BY DEFAULT AS IDENTITY' or 'bigint GENERATED ALWAYS AS IDENTITY' instead. diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap new file mode 100644 index 000000000..50170b982 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap @@ -0,0 +1,12 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_no_lint/safety/preferIdentity +create table users_valid ( + id bigint generated by default as identity primary key +); +``` From b7b7894f39f78a59b97b07c8a592f18f9bf2b103 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 09:35:24 +0200 Subject: [PATCH 15/23] tests make vrooooom --- Cargo.lock | 1 + crates/pgt_analyser/Cargo.toml | 9 ++- .../lint/safety/adding_field_with_default.rs | 2 +- .../safety/constraint_missing_not_valid.rs | 8 +- .../src/lint/safety/prefer_robust_stmts.rs | 5 -- crates/pgt_analyser/tests/rules_tests.rs | 77 +++++++++++++------ .../safety/addingFieldWithDefault/basic.sql | 4 +- .../addingFieldWithDefault/basic.sql.snap | 19 +++++ .../generated_column.sql | 2 + .../generated_column.sql.snap | 20 +++++ .../addingFieldWithDefault/no_default.sql | 3 + .../no_default.sql.snap | 11 +++ .../non_volatile_default.sql | 3 + .../non_volatile_default.sql.snap | 21 +++++ .../volatile_default.sql | 2 + .../volatile_default.sql.snap | 20 +++++ .../addingForeignKeyConstraint/basic.sql | 6 +- .../addingForeignKeyConstraint/basic.sql.snap | 5 +- .../specs/safety/addingNotNullField/basic.sql | 6 +- .../safety/addingNotNullField/basic.sql.snap | 5 +- .../addingPrimaryKeyConstraint/basic.sql | 2 +- .../addingPrimaryKeyConstraint/basic.sql.snap | 4 +- .../serial_column.sql | 2 +- .../serial_column.sql.snap | 2 +- .../using_index.sql | 2 +- .../using_index.sql.snap | 2 +- .../safety/addingRequiredField/basic.sql | 2 +- .../safety/addingRequiredField/basic.sql.snap | 3 +- .../specs/safety/banCharField/alter_table.sql | 2 + .../safety/banCharField/alter_table.sql.snap | 20 +++++ .../tests/specs/safety/banCharField/basic.sql | 7 +- .../specs/safety/banCharField/basic.sql.snap | 22 ++++++ .../specs/safety/banCharField/bpchar.sql | 4 + .../specs/safety/banCharField/bpchar.sql.snap | 22 ++++++ .../safety/banCharField/varchar_valid.sql | 5 ++ .../banCharField/varchar_valid.sql.snap | 14 ++++ .../basic.sql | 5 +- .../basic.sql.snap | 18 +++++ .../specs/safety/banDropColumn/basic.sql | 2 +- .../specs/safety/banDropColumn/basic.sql.snap | 3 +- .../specs/safety/banDropDatabase/basic.sql | 2 +- .../safety/banDropDatabase/basic.sql.snap | 3 +- .../specs/safety/banDropNotNull/basic.sql | 2 +- .../safety/banDropNotNull/basic.sql.snap | 3 +- .../tests/specs/safety/banDropTable/basic.sql | 2 +- .../specs/safety/banDropTable/basic.sql.snap | 3 +- .../specs/safety/banTruncateCascade/basic.sql | 2 +- .../safety/banTruncateCascade/basic.sql.snap | 3 +- .../specs/safety/changingColumnType/basic.sql | 4 +- .../safety/changingColumnType/basic.sql.snap | 17 ++++ .../constraintMissingNotValid/basic.sql | 4 +- .../constraintMissingNotValid/basic.sql.snap | 17 ++++ .../check_constraint.sql | 2 + .../check_constraint.sql.snap | 18 +++++ .../with_not_valid.sql | 3 + .../with_not_valid.sql.snap | 11 +++ .../tests/specs/safety/preferBigInt/basic.sql | 2 +- .../specs/safety/preferBigInt/basic.sql.snap | 2 +- .../safety/preferBigintOverInt/basic.sql | 2 +- .../safety/preferBigintOverInt/basic.sql.snap | 2 +- .../safety/preferBigintOverSmallint/basic.sql | 2 +- .../preferBigintOverSmallint/basic.sql.snap | 2 +- .../safety/preferIdentity/alter_table.sql | 2 +- .../preferIdentity/alter_table.sql.snap | 2 +- .../specs/safety/preferIdentity/basic.sql | 2 +- .../safety/preferIdentity/basic.sql.snap | 2 +- .../specs/safety/preferIdentity/bigserial.sql | 2 +- .../safety/preferIdentity/bigserial.sql.snap | 2 +- .../specs/safety/preferIdentity/valid.sql | 4 +- .../safety/preferIdentity/valid.sql.snap | 3 +- .../specs/safety/preferRobustStmts/basic.sql | 5 +- .../safety/preferRobustStmts/basic.sql.snap | 18 +++++ .../drop_without_if_exists.sql | 2 + .../drop_without_if_exists.sql.snap | 18 +++++ .../preferRobustStmts/robust_statements.sql | 4 + .../robust_statements.sql.snap | 12 +++ .../specs/safety/renamingColumn/basic.sql | 4 +- .../safety/renamingColumn/basic.sql.snap | 17 ++++ .../specs/safety/renamingTable/basic.sql | 4 +- .../specs/safety/renamingTable/basic.sql.snap | 17 ++++ .../requireConcurrentIndexCreation/basic.sql | 4 +- .../basic.sql.snap | 17 ++++ .../concurrent_valid.sql | 3 + .../concurrent_valid.sql.snap | 11 +++ .../new_table_valid.sql | 4 + .../new_table_valid.sql.snap | 12 +++ .../requireConcurrentIndexDeletion/basic.sql | 4 +- .../basic.sql.snap | 17 ++++ .../concurrent_valid.sql | 3 + .../concurrent_valid.sql.snap | 11 +++ .../specs/safety/transactionNesting/basic.sql | 4 +- .../safety/transactionNesting/basic.sql.snap | 19 +++++ .../begin_commit_combined.sql | 5 ++ .../begin_commit_combined.sql.snap | 33 ++++++++ .../safety/transactionNesting/commit.sql | 3 + .../safety/transactionNesting/commit.sql.snap | 21 +++++ .../safety/transactionNesting/rollback.sql | 3 + .../transactionNesting/rollback.sql.snap | 21 +++++ 98 files changed, 688 insertions(+), 107 deletions(-) create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql.snap create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql create mode 100644 crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql.snap diff --git a/Cargo.lock b/Cargo.lock index 779f85f46..66c2a19f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2760,6 +2760,7 @@ dependencies = [ "pgt_query", "pgt_query_ext", "pgt_schema_cache", + "pgt_statement_splitter", "pgt_test_macros", "pgt_text_size", "serde", diff --git a/crates/pgt_analyser/Cargo.toml b/crates/pgt_analyser/Cargo.toml index 4d9b4d90f..b00c9939c 100644 --- a/crates/pgt_analyser/Cargo.toml +++ b/crates/pgt_analyser/Cargo.toml @@ -22,7 +22,8 @@ pgt_text_size = { workspace = true } serde = { workspace = true } [dev-dependencies] -insta = { version = "1.42.1" } -pgt_diagnostics = { workspace = true } -pgt_test_macros = { workspace = true } -termcolor = { workspace = true } +insta = { version = "1.42.1" } +pgt_diagnostics = { workspace = true } +pgt_statement_splitter = { workspace = true } +pgt_test_macros = { workspace = true } +termcolor = { workspace = true } diff --git a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs index e2a0376c7..7782689f6 100644 --- a/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgt_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,4 @@ -use pgt_analyse::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; use pgt_console::markup; use pgt_diagnostics::Severity; diff --git a/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs index bbfd7a7d1..18a75e8c6 100644 --- a/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs +++ b/crates/pgt_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -49,7 +49,9 @@ impl Rule for ConstraintMissingNotValid { continue; }; - let Some(pgt_query::NodeEnum::Constraint(constraint)) = cmd.def.as_ref().and_then(|d| d.node.as_ref()) else { + let Some(pgt_query::NodeEnum::Constraint(constraint)) = + cmd.def.as_ref().and_then(|d| d.node.as_ref()) + else { continue; }; @@ -62,7 +64,9 @@ impl Rule for ConstraintMissingNotValid { } } -fn check_constraint_needs_not_valid(constraint: &pgt_query::protobuf::Constraint) -> Option { +fn check_constraint_needs_not_valid( + constraint: &pgt_query::protobuf::Constraint, +) -> Option { // Skip if the constraint has NOT VALID if !constraint.initially_valid { return None; diff --git a/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs b/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs index 57f06a9c4..3f0b66f0f 100644 --- a/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs +++ b/crates/pgt_analyser/src/lint/safety/prefer_robust_stmts.rs @@ -46,11 +46,6 @@ impl Rule for PreferRobustStmts { fn run(ctx: &RuleContext) -> Vec { let mut diagnostics = Vec::new(); - // Skip if we only have one statement in the file - if ctx.file_context().stmt_count <= 1 { - return diagnostics; - } - // Since we assume we're always in a transaction, we only check for // statements that explicitly run outside transactions match &ctx.stmt() { diff --git a/crates/pgt_analyser/tests/rules_tests.rs b/crates/pgt_analyser/tests/rules_tests.rs index d8e5b0ef1..5334246b8 100644 --- a/crates/pgt_analyser/tests/rules_tests.rs +++ b/crates/pgt_analyser/tests/rules_tests.rs @@ -1,5 +1,5 @@ use core::slice; -use std::{fmt::Write, fs::read_to_string, path::Path}; +use std::{collections::HashMap, fmt::Write, fs::read_to_string, path::Path}; use pgt_analyse::{AnalyserOptions, AnalysisFilter, RuleDiagnostic, RuleFilter}; use pgt_analyser::{AnalysableStatement, Analyser, AnalyserConfig, AnalyserParams}; @@ -25,20 +25,30 @@ fn rule_test(full_path: &'static str, _: &str, _: &str) { let query = read_to_string(full_path).unwrap_or_else(|_| panic!("Failed to read file: {} ", full_path)); - let ast = pgt_query::parse(&query).expect("failed to parse SQL"); let options = AnalyserOptions::default(); let analyser = Analyser::new(AnalyserConfig { options: &options, filter, }); - let stmt = AnalysableStatement { - root: ast.into_root().expect("Failed to convert AST to root node"), - range: pgt_text_size::TextRange::new(0.into(), u32::try_from(query.len()).unwrap().into()), - }; + let split = pgt_statement_splitter::split(&query); + + let stmts = split + .ranges + .iter() + .map(|r| { + let text = &query[*r]; + let ast = pgt_query::parse(text).expect("failed to parse SQL"); + + AnalysableStatement { + root: ast.into_root().expect("Failed to convert AST to root node"), + range: *r, + } + }) + .collect::>(); let results = analyser.run(AnalyserParams { - stmts: vec![stmt], + stmts, schema_cache: None, }); @@ -89,29 +99,45 @@ fn write_snapshot(snapshot: &mut String, query: &str, diagnostics: &[RuleDiagnos enum Expectation { NoDiagnostics, - AnyDiagnostics, - OnlyOne(String), + Diagnostics(Vec<(String, usize)>), } impl Expectation { fn from_file(content: &str) -> Self { + let mut multiple_of: HashMap<&str, i32> = HashMap::new(); for line in content.lines() { if line.contains("expect_no_diagnostics") { + if !multiple_of.is_empty() { + panic!( + "Cannot use both `expect_no_diagnostics` and `expect_` in the same test" + ); + } return Self::NoDiagnostics; } - if line.contains("expect_only_") { + if line.contains("expect_") && !line.contains("expect_no_diagnostics") { let kind = line .splitn(3, "_") .last() - .expect("Use pattern: `-- expect_only_`") + .expect("Use pattern: `-- expect_`") .trim(); - return Self::OnlyOne(kind.into()); + *multiple_of.entry(kind).or_insert(0) += 1; } } - Self::AnyDiagnostics + if !multiple_of.is_empty() { + return Self::Diagnostics( + multiple_of + .into_iter() + .map(|(k, v)| (k.into(), v as usize)) + .collect(), + ); + } + + panic!( + "No expectation found in the test file. Use `-- expect_no_diagnostics` or `-- expect_`" + ); } fn assert(&self, diagnostics: &[RuleDiagnostic]) { @@ -121,20 +147,21 @@ impl Expectation { panic!("This test should not have any diagnostics."); } } - Self::OnlyOne(category) => { - let found_kinds = diagnostics - .iter() - .map(|d| d.get_category_name()) - .collect::>() - .join(", "); - - if diagnostics.len() != 1 || diagnostics[0].get_category_name() != category { - panic!( - "This test should only have one diagnostic of kind: {category}\nReceived: {found_kinds}" - ); + Self::Diagnostics(expected) => { + let mut counts: HashMap<&str, usize> = HashMap::new(); + for diag in diagnostics { + *counts.entry(diag.get_category_name()).or_insert(0) += 1; + } + + for (kind, expected_count) in expected { + let actual_count = counts.get(kind.as_str()).copied().unwrap_or(0); + if actual_count != *expected_count { + panic!( + "Expected {expected_count} diagnostics of kind `{kind}`, but found {actual_count}." + ); + } } } - Self::AnyDiagnostics => {} } } } diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql index 5207d268a..f0ce47475 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/addingFieldWithDefault --- select 1; \ No newline at end of file +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql.snap new file mode 100644 index 000000000..e44d81243 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/basic.sql.snap @@ -0,0 +1,19 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; +``` + +# Diagnostics +lint/safety/addingFieldWithDefault ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a column with a DEFAULT value causes a table rewrite. + + i This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table. + + i Add the column without a default, then set the default in a separate statement. diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql new file mode 100644 index 000000000..108dba055 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql @@ -0,0 +1,2 @@ +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED; diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql.snap new file mode 100644 index 000000000..672f049eb --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/generated_column.sql.snap @@ -0,0 +1,20 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED; + +``` + +# Diagnostics +lint/safety/addingFieldWithDefault ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a generated column requires a table rewrite. + + i This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table. + + i Add the column as nullable, backfill existing rows, and add a trigger to update the column on write instead. diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql new file mode 100644 index 000000000..f79c4b884 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql @@ -0,0 +1,3 @@ +-- Test adding column without default (should be safe) +-- expect_no_diagnostics +ALTER TABLE users ADD COLUMN email text; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql.snap new file mode 100644 index 000000000..edce67ffb --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/no_default.sql.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test adding column without default (should be safe) +-- expect_no_diagnostics +ALTER TABLE users ADD COLUMN email text; +``` diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql new file mode 100644 index 000000000..f09c0738e --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql @@ -0,0 +1,3 @@ +-- Test non-volatile default values (should be safe in PG 11+, but we are passing no PG version info in the tests) +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN status text DEFAULT 'active'; diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql.snap new file mode 100644 index 000000000..bed0cd3c7 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/non_volatile_default.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test non-volatile default values (should be safe in PG 11+, but we are passing no PG version info in the tests) +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN status text DEFAULT 'active'; + +``` + +# Diagnostics +lint/safety/addingFieldWithDefault ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a column with a DEFAULT value causes a table rewrite. + + i This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table. + + i Add the column without a default, then set the default in a separate statement. diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql new file mode 100644 index 000000000..f8b8a12c8 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql @@ -0,0 +1,2 @@ +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN created_at timestamp DEFAULT now(); diff --git a/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql.snap new file mode 100644 index 000000000..b01006efa --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/addingFieldWithDefault/volatile_default.sql.snap @@ -0,0 +1,20 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/addingFieldWithDefault +ALTER TABLE users ADD COLUMN created_at timestamp DEFAULT now(); + +``` + +# Diagnostics +lint/safety/addingFieldWithDefault ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a column with a DEFAULT value causes a table rewrite. + + i This operation requires an ACCESS EXCLUSIVE lock and rewrites the entire table. + + i Add the column without a default, then set the default in a separate statement. diff --git a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql index 53c603dac..012107f85 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql @@ -1,4 +1,2 @@ --- https://postgrestools.com/analyser/safety/addingForeignKeyConstraint - --- Should trigger: Adding constraint without NOT VALID -ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); \ No newline at end of file +-- expect_lint/safety/addingForeignKeyConstraint +ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); diff --git a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap index 0d26e8375..3570851b8 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingForeignKeyConstraint/basic.sql.snap @@ -5,10 +5,9 @@ snapshot_kind: text --- # Input ``` --- https://postgrestools.com/analyser/safety/addingForeignKeyConstraint - --- Should trigger: Adding constraint without NOT VALID +-- expect_lint/safety/addingForeignKeyConstraint ALTER TABLE "email" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "user" ("id"); + ``` # Diagnostics diff --git a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql index 46e0b209f..e7abd37fe 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql @@ -1,4 +1,2 @@ --- https://postgrestools.com/analyser/safety/addingNotNullField - --- Should trigger: Setting column NOT NULL (in Postgres < 11) -ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; \ No newline at end of file +-- expect_lint/safety/addingNotNullField +ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; diff --git a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap index cd58292a8..7d27d88bd 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingNotNullField/basic.sql.snap @@ -5,10 +5,9 @@ snapshot_kind: text --- # Input ``` --- https://postgrestools.com/analyser/safety/addingNotNullField - --- Should trigger: Setting column NOT NULL (in Postgres < 11) +-- expect_lint/safety/addingNotNullField ALTER TABLE "core_recipe" ALTER COLUMN "foo" SET NOT NULL; + ``` # Diagnostics diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql index e982e4cde..bd85a464b 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_lint/safety/addingPrimaryKeyConstraint ALTER TABLE users ADD PRIMARY KEY (id); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap index 1b756d6b8..c5c19999e 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/basic.sql.snap @@ -1,11 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs -assertion_line: 52 expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_lint/safety/addingPrimaryKeyConstraint ALTER TABLE users ADD PRIMARY KEY (id); ``` diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql index 9f2ddf945..f48c44554 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_lint/safety/addingPrimaryKeyConstraint ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap index 674effc58..85fcb5f9b 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/serial_column.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_lint/safety/addingPrimaryKeyConstraint ALTER TABLE items ADD COLUMN id SERIAL PRIMARY KEY; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql index 5ccae3da9..ba5ad6d1d 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql @@ -1,3 +1,3 @@ --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_no_diagnostics -- This should not trigger the rule - using an existing index ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap index 86b2431e4..447fcc0f0 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingPrimaryKeyConstraint/using_index.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/addingPrimaryKeyConstraint +-- expect_no_diagnostics -- This should not trigger the rule - using an existing index ALTER TABLE items ADD CONSTRAINT items_pk PRIMARY KEY USING INDEX items_pk; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql b/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql index 836c295c1..8b152db93 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql @@ -1,3 +1,3 @@ --- expect_only_lint/safety/addingRequiredField +-- expect_lint/safety/addingRequiredField alter table test add column c int not null; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql.snap index 559dbf53c..13811f8d2 100644 --- a/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/addingRequiredField/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/addingRequiredField +-- expect_lint/safety/addingRequiredField alter table test add column c int not null; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql b/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql new file mode 100644 index 000000000..375e95d01 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql @@ -0,0 +1,2 @@ +-- expect_lint/safety/banCharField +ALTER TABLE users ADD COLUMN code character(10); diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql.snap b/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql.snap new file mode 100644 index 000000000..f4de4939b --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/alter_table.sql.snap @@ -0,0 +1,20 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/banCharField +ALTER TABLE users ADD COLUMN code character(10); + +``` + +# Diagnostics +lint/safety/banCharField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × CHAR type is discouraged due to space padding behavior. + + i CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior. + + i Use VARCHAR or TEXT instead for variable-length character data. diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql index 73f60c160..596db5eda 100644 --- a/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql @@ -1,2 +1,5 @@ --- expect_only_lint/safety/banCharField --- select 1; \ No newline at end of file +-- expect_lint/safety/banCharField +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" char(100) NOT NULL +); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql.snap new file mode 100644 index 000000000..f32d84c8d --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/basic.sql.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/banCharField +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" char(100) NOT NULL +); +``` + +# Diagnostics +lint/safety/banCharField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × CHAR type is discouraged due to space padding behavior. + + i CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior. + + i Use VARCHAR or TEXT instead for variable-length character data. diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql b/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql new file mode 100644 index 000000000..689ea0ddd --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql @@ -0,0 +1,4 @@ +-- expect_lint/safety/banCharField +CREATE TABLE test ( + code bpchar(5) +); diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql.snap b/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql.snap new file mode 100644 index 000000000..064962820 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/bpchar.sql.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/banCharField +CREATE TABLE test ( + code bpchar(5) +); + +``` + +# Diagnostics +lint/safety/banCharField ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × CHAR type is discouraged due to space padding behavior. + + i CHAR types are fixed-length and padded with spaces, which can lead to unexpected behavior. + + i Use VARCHAR or TEXT instead for variable-length character data. diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql b/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql new file mode 100644 index 000000000..27f1d9b01 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql @@ -0,0 +1,5 @@ +-- expect_no_diagnostics +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" varchar(100) NOT NULL +); diff --git a/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql.snap new file mode 100644 index 000000000..5a6aaeb76 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banCharField/varchar_valid.sql.snap @@ -0,0 +1,14 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_no_diagnostics +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" varchar(100) NOT NULL +); + +``` diff --git a/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql index 2d9f5daab..eeeddf954 100644 --- a/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql @@ -1,2 +1,3 @@ --- expect_only_lint/safety/banConcurrentIndexCreationInTransaction --- select 1; \ No newline at end of file +-- expect_lint/safety/banConcurrentIndexCreationInTransaction +CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); +SELECT 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql.snap new file mode 100644 index 000000000..5bb028b10 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/banConcurrentIndexCreationInTransaction/basic.sql.snap @@ -0,0 +1,18 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/banConcurrentIndexCreationInTransaction +CREATE INDEX CONCURRENTLY "field_name_idx" ON "table_name" ("field_name"); +SELECT 1; +``` + +# Diagnostics +lint/safety/banConcurrentIndexCreationInTransaction ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × CREATE INDEX CONCURRENTLY cannot be used inside a transaction block. + + i Run CREATE INDEX CONCURRENTLY outside of a transaction. Migration tools usually run in transactions, so you may need to run this statement in its own migration or manually. diff --git a/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql b/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql index 16d3b4769..6bff86142 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql @@ -1,3 +1,3 @@ --- expect_only_lint/safety/banDropColumn +-- expect_lint/safety/banDropColumn alter table test drop column id; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql.snap index 3fd80e190..9c6d2192d 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/banDropColumn/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/banDropColumn +-- expect_lint/safety/banDropColumn alter table test drop column id; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql b/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql index 0dc016524..8dc725c3e 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/banDropDatabase +-- expect_lint/safety/banDropDatabase drop database all_users; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql.snap index 90e35820c..f08e4c756 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/banDropDatabase/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/banDropDatabase +-- expect_lint/safety/banDropDatabase drop database all_users; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql b/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql index 1e1fc8796..0e0481466 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/banDropNotNull +-- expect_lint/safety/banDropNotNull alter table users alter column id drop not null; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql.snap index e5d552678..842280d1b 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/banDropNotNull/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/banDropNotNull +-- expect_lint/safety/banDropNotNull alter table users alter column id drop not null; diff --git a/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql b/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql index 16f6fd624..f3554e176 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/banDropTable +-- expect_lint/safety/banDropTable drop table test; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql.snap index 481b12234..779c2d8e5 100644 --- a/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/banDropTable/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/banDropTable +-- expect_lint/safety/banDropTable drop table test; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql b/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql index d17fed13b..68bbba294 100644 --- a/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/banTruncateCascade +-- expect_lint/safety/banTruncateCascade truncate a cascade; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql.snap index d214b978a..deef44849 100644 --- a/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/banTruncateCascade/basic.sql.snap @@ -1,10 +1,11 @@ --- source: crates/pgt_analyser/tests/rules_tests.rs expression: snapshot +snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/banTruncateCascade +-- expect_lint/safety/banTruncateCascade truncate a cascade; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql index 8b745d2c3..5e5c2fc38 100644 --- a/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/changingColumnType --- select 1; \ No newline at end of file +-- expect_lint/safety/changingColumnType +ALTER TABLE "core_recipe" ALTER COLUMN "edits" TYPE text USING "edits"::text; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql.snap new file mode 100644 index 000000000..5739621a7 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/changingColumnType/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/changingColumnType +ALTER TABLE "core_recipe" ALTER COLUMN "edits" TYPE text USING "edits"::text; +``` + +# Diagnostics +lint/safety/changingColumnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Changing a column type requires a table rewrite and blocks reads and writes. + + i Consider creating a new column with the desired type, migrating data, and then dropping the old column. diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql index 6b7298a0a..756bcbae6 100644 --- a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/constraintMissingNotValid --- select 1; \ No newline at end of file +-- expect_lint/safety/constraintMissingNotValid +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql.snap new file mode 100644 index 000000000..639d10d5f --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/constraintMissingNotValid +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address); +``` + +# Diagnostics +lint/safety/constraintMissingNotValid ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a constraint without NOT VALID will block reads and writes while validating existing rows. + + i Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction. diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql new file mode 100644 index 000000000..490ccdb30 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql @@ -0,0 +1,2 @@ +-- expect_lint/safety/constraintMissingNotValid +ALTER TABLE users ADD CONSTRAINT check_age CHECK (age >= 0); diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql.snap b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql.snap new file mode 100644 index 000000000..357b931cc --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/check_constraint.sql.snap @@ -0,0 +1,18 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/constraintMissingNotValid +ALTER TABLE users ADD CONSTRAINT check_age CHECK (age >= 0); + +``` + +# Diagnostics +lint/safety/constraintMissingNotValid ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Adding a constraint without NOT VALID will block reads and writes while validating existing rows. + + i Add the constraint as NOT VALID in one transaction, then run VALIDATE CONSTRAINT in a separate transaction. diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql new file mode 100644 index 000000000..b5bc40f65 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql @@ -0,0 +1,3 @@ +-- Test constraint with NOT VALID (should be safe) +-- expect_no_diagnostics +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address) NOT VALID; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql.snap new file mode 100644 index 000000000..6d7ed9df4 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/constraintMissingNotValid/with_not_valid.sql.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test constraint with NOT VALID (should be safe) +-- expect_no_diagnostics +ALTER TABLE distributors ADD CONSTRAINT distfk FOREIGN KEY (address) REFERENCES addresses (address) NOT VALID; +``` diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql index 6e2b9ec1c..4b04da62c 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/preferBigInt +-- expect_lint/safety/preferBigInt CREATE TABLE users ( id integer ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap index 075396bad..bb247c05e 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferBigInt/basic.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferBigInt +-- expect_lint/safety/preferBigInt CREATE TABLE users ( id integer ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql index e17e80904..03e5e8147 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/preferBigintOverInt +-- expect_lint/safety/preferBigintOverInt CREATE TABLE users ( id integer ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap index 6f7337a61..a1a407884 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverInt/basic.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferBigintOverInt +-- expect_lint/safety/preferBigintOverInt CREATE TABLE users ( id integer ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql index af434bf1a..2b1605ad3 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/preferBigintOverSmallint +-- expect_lint/safety/preferBigintOverSmallint CREATE TABLE users ( age smallint ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap index 2596df493..738fdbe37 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferBigintOverSmallint/basic.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferBigintOverSmallint +-- expect_lint/safety/preferBigintOverSmallint CREATE TABLE users ( age smallint ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql index 99760b9ea..f2f52c4b8 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity alter table test add column id serial; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap index 926f95375..924731c89 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/alter_table.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity alter table test add column id serial; ``` diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql index 8316cb735..ac2f5fdeb 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity create table users ( id serial ); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap index 18993ef03..adada49e3 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/basic.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity create table users ( id serial ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql index dc176d0cc..5f971cdf6 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql @@ -1,4 +1,4 @@ --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity create table users ( id bigserial ); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap index b83eaf6df..81eeaeb2c 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/bigserial.sql.snap @@ -5,7 +5,7 @@ snapshot_kind: text --- # Input ``` --- expect_only_lint/safety/preferIdentity +-- expect_lint/safety/preferIdentity create table users ( id bigserial ); diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql index 3749c9697..ec478fe43 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql @@ -1,4 +1,4 @@ --- expect_no_lint/safety/preferIdentity +-- expect_no_diagnostics create table users_valid ( id bigint generated by default as identity primary key -); \ No newline at end of file +); diff --git a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap index 50170b982..0b421140c 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap +++ b/crates/pgt_analyser/tests/specs/safety/preferIdentity/valid.sql.snap @@ -5,8 +5,9 @@ snapshot_kind: text --- # Input ``` --- expect_no_lint/safety/preferIdentity +-- expect_no_diagnostics create table users_valid ( id bigint generated by default as identity primary key ); + ``` diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql index 6064619ba..fd422f32a 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql @@ -1,2 +1,3 @@ --- expect_only_lint/safety/preferRobustStmts --- select 1; \ No newline at end of file +-- expect_lint/safety/preferRobustStmts +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); +SELECT 1; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql.snap new file mode 100644 index 000000000..5db56846d --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/basic.sql.snap @@ -0,0 +1,18 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/preferRobustStmts +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); +SELECT 1; +``` + +# Diagnostics +lint/safety/preferRobustStmts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Concurrent index creation should use IF NOT EXISTS. + + i Add IF NOT EXISTS to make the migration re-runnable if it fails. diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql new file mode 100644 index 000000000..36d4b1375 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql @@ -0,0 +1,2 @@ +-- expect_lint/safety/preferRobustStmts +DROP INDEX CONCURRENTLY users_email_idx; diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql.snap new file mode 100644 index 000000000..e03fbf715 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/drop_without_if_exists.sql.snap @@ -0,0 +1,18 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/preferRobustStmts +DROP INDEX CONCURRENTLY users_email_idx; + +``` + +# Diagnostics +lint/safety/preferRobustStmts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Concurrent drop should use IF EXISTS. + + i Add IF EXISTS to make the migration re-runnable if it fails. diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql new file mode 100644 index 000000000..e19139596 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql @@ -0,0 +1,4 @@ +-- Test proper robust statements (should be safe) +-- expect_no_diagnostics +CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_idx ON users (email); +DROP INDEX CONCURRENTLY IF EXISTS old_idx; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql.snap new file mode 100644 index 000000000..93f454af5 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferRobustStmts/robust_statements.sql.snap @@ -0,0 +1,12 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test proper robust statements (should be safe) +-- expect_no_diagnostics +CREATE INDEX CONCURRENTLY IF NOT EXISTS users_email_idx ON users (email); +DROP INDEX CONCURRENTLY IF EXISTS old_idx; +``` diff --git a/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql index 0293c1d89..a283d3bc7 100644 --- a/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/renamingColumn --- select 1; \ No newline at end of file +-- expect_lint/safety/renamingColumn +ALTER TABLE users RENAME COLUMN name TO full_name; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql.snap new file mode 100644 index 000000000..03fd3f807 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/renamingColumn/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/renamingColumn +ALTER TABLE users RENAME COLUMN name TO full_name; +``` + +# Diagnostics +lint/safety/renamingColumn ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Renaming a column may break existing clients. + + i Consider creating a new column with the desired name and migrating data instead. diff --git a/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql index bf1a6a309..b3df5c838 100644 --- a/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/renamingTable --- select 1; \ No newline at end of file +-- expect_lint/safety/renamingTable +ALTER TABLE users RENAME TO customers; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql.snap new file mode 100644 index 000000000..09cc0175e --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/renamingTable/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/renamingTable +ALTER TABLE users RENAME TO customers; +``` + +# Diagnostics +lint/safety/renamingTable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Renaming a table may break existing clients. + + i Consider creating a view with the old table name instead, or coordinate the rename carefully with application deployments. diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql index 38c57f21f..7ce423e29 100644 --- a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/requireConcurrentIndexCreation --- select 1; \ No newline at end of file +-- expect_lint/safety/requireConcurrentIndexCreation +CREATE INDEX users_email_idx ON users (email); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql.snap new file mode 100644 index 000000000..dad3c364b --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/requireConcurrentIndexCreation +CREATE INDEX users_email_idx ON users (email); +``` + +# Diagnostics +lint/safety/requireConcurrentIndexCreation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Creating an index non-concurrently blocks writes to the table. + + i Use CREATE INDEX CONCURRENTLY to avoid blocking concurrent operations on the table. diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql new file mode 100644 index 000000000..339f0eebb --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql @@ -0,0 +1,3 @@ +-- Test concurrent index creation (should be safe) +-- expect_no_diagnostics +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql.snap new file mode 100644 index 000000000..7ee0a6f4e --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/concurrent_valid.sql.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test concurrent index creation (should be safe) +-- expect_no_diagnostics +CREATE INDEX CONCURRENTLY users_email_idx ON users (email); +``` diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql new file mode 100644 index 000000000..8efaffb4b --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql @@ -0,0 +1,4 @@ +-- Test index on newly created table (should be safe) +-- expect_no_diagnostics +CREATE TABLE users (id serial, email text); +CREATE INDEX users_email_idx ON users (email); \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql.snap new file mode 100644 index 000000000..0ccdfe351 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexCreation/new_table_valid.sql.snap @@ -0,0 +1,12 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test index on newly created table (should be safe) +-- expect_no_diagnostics +CREATE TABLE users (id serial, email text); +CREATE INDEX users_email_idx ON users (email); +``` diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql index c72b371b8..7332b1e3d 100644 --- a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/requireConcurrentIndexDeletion --- select 1; \ No newline at end of file +-- expect_lint/safety/requireConcurrentIndexDeletion +DROP INDEX IF EXISTS users_email_idx; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql.snap new file mode 100644 index 000000000..4a6267f8f --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/basic.sql.snap @@ -0,0 +1,17 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/requireConcurrentIndexDeletion +DROP INDEX IF EXISTS users_email_idx; +``` + +# Diagnostics +lint/safety/requireConcurrentIndexDeletion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Dropping an index non-concurrently blocks reads and writes to the table. + + i Use DROP INDEX CONCURRENTLY to avoid blocking concurrent operations on the table. diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql new file mode 100644 index 000000000..5ad97524a --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql @@ -0,0 +1,3 @@ +-- Test concurrent index deletion (should be safe) +-- expect_no_diagnostics +DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql.snap b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql.snap new file mode 100644 index 000000000..77beb832f --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/requireConcurrentIndexDeletion/concurrent_valid.sql.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- Test concurrent index deletion (should be safe) +-- expect_no_diagnostics +DROP INDEX CONCURRENTLY IF EXISTS users_email_idx; +``` diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql index a108338bb..215917353 100644 --- a/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql @@ -1,2 +1,2 @@ --- expect_only_lint/safety/transactionNesting --- select 1; \ No newline at end of file +-- expect_lint/safety/transactionNesting +BEGIN; \ No newline at end of file diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql.snap new file mode 100644 index 000000000..4cc5e024e --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/basic.sql.snap @@ -0,0 +1,19 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/transactionNesting +BEGIN; +``` + +# Diagnostics +lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Transaction already managed by migration tool. + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql b/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql new file mode 100644 index 000000000..55b2148d2 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql @@ -0,0 +1,5 @@ +-- expect_lint/safety/transactionNesting +BEGIN; +SELECT 1; +-- expect_lint/safety/transactionNesting +COMMIT; diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql.snap b/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql.snap new file mode 100644 index 000000000..927cdcc4c --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/begin_commit_combined.sql.snap @@ -0,0 +1,33 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/transactionNesting +BEGIN; +SELECT 1; +-- expect_lint/safety/transactionNesting +COMMIT; + +``` + +# Diagnostics +lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Transaction already managed by migration tool. + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. + + + +lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Attempting to end transaction managed by migration tool. + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql b/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql new file mode 100644 index 000000000..def30f29a --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql @@ -0,0 +1,3 @@ +SELECT 1; +-- expect_lint/safety/transactionNesting +COMMIT; diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql.snap b/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql.snap new file mode 100644 index 000000000..46003d978 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/commit.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +SELECT 1; +-- expect_lint/safety/transactionNesting +COMMIT; + +``` + +# Diagnostics +lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Attempting to end transaction managed by migration tool. + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql b/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql new file mode 100644 index 000000000..00ee62293 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql @@ -0,0 +1,3 @@ +SELECT 1; +-- expect_lint/safety/transactionNesting +ROLLBACK; diff --git a/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql.snap b/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql.snap new file mode 100644 index 000000000..66d4ed21e --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/transactionNesting/rollback.sql.snap @@ -0,0 +1,21 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +SELECT 1; +-- expect_lint/safety/transactionNesting +ROLLBACK; + +``` + +# Diagnostics +lint/safety/transactionNesting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Attempting to end transaction managed by migration tool. + + i Migration tools manage transactions automatically. Remove explicit transaction control. + + i Put migration statements in separate files to have them be in separate transactions. From 1dc9dd0adde8713f10443a4fbba465562939b2f6 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 11:23:39 +0200 Subject: [PATCH 16/23] progress --- docs/reference/rules/prefer-robust-stmts.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/reference/rules/prefer-robust-stmts.md b/docs/reference/rules/prefer-robust-stmts.md index c0e2b9d54..059176fd4 100644 --- a/docs/reference/rules/prefer-robust-stmts.md +++ b/docs/reference/rules/prefer-robust-stmts.md @@ -23,6 +23,17 @@ CREATE INDEX CONCURRENTLY users_email_idx ON users (email); ``` ```sh +code-block.sql:1:1 lint/safety/preferRobustStmts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Concurrent index creation should use IF NOT EXISTS. + + > 1 │ CREATE INDEX CONCURRENTLY users_email_idx ON users (email); + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Add IF NOT EXISTS to make the migration re-runnable if it fails. + + ``` ```sql @@ -30,6 +41,17 @@ DROP INDEX CONCURRENTLY users_email_idx; ``` ```sh +code-block.sql:1:1 lint/safety/preferRobustStmts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Concurrent drop should use IF EXISTS. + + > 1 │ DROP INDEX CONCURRENTLY users_email_idx; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i Add IF EXISTS to make the migration re-runnable if it fails. + + ``` ### Valid From a81f5d2a81e9fad9f1bba809af3041bd85f1939b Mon Sep 17 00:00:00 2001 From: psteinroe Date: Tue, 16 Sep 2025 11:35:46 +0200 Subject: [PATCH 17/23] progress --- crates/pgt_lsp/tests/server.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pgt_lsp/tests/server.rs b/crates/pgt_lsp/tests/server.rs index 63953590b..1f972395b 100644 --- a/crates/pgt_lsp/tests/server.rs +++ b/crates/pgt_lsp/tests/server.rs @@ -1660,7 +1660,20 @@ ALTER TABLE ONLY "public"."campaign_contact_list" loop { match receiver.next().await { Some(ServerNotification::PublishDiagnostics(msg)) => { - if !msg.diagnostics.is_empty() { + if msg + .diagnostics + .iter() + .filter(|d| { + d.code.as_ref().is_none_or(|c| match c { + lsp::NumberOrString::Number(_) => true, + lsp::NumberOrString::String(s) => { + s != "lint/safety/addingForeignKeyConstraint" + } + }) + }) + .count() + > 0 + { return true; } } From 55474ebe9ed0765be7e6f966ea299bc899287496 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 17 Sep 2025 07:46:49 +0200 Subject: [PATCH 18/23] progress --- ...dd6c8db4718faaa1005845c8f8f14c5c78e76a258eb.json} | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) rename .sqlx/{query-d61f2f56ce777c99593df240b3a126cacb3c9ed5f915b7e98052d58df98d480b.json => query-332fd0e123d2e7cc1e9abdd6c8db4718faaa1005845c8f8f14c5c78e76a258eb.json} (56%) diff --git a/.sqlx/query-d61f2f56ce777c99593df240b3a126cacb3c9ed5f915b7e98052d58df98d480b.json b/.sqlx/query-332fd0e123d2e7cc1e9abdd6c8db4718faaa1005845c8f8f14c5c78e76a258eb.json similarity index 56% rename from .sqlx/query-d61f2f56ce777c99593df240b3a126cacb3c9ed5f915b7e98052d58df98d480b.json rename to .sqlx/query-332fd0e123d2e7cc1e9abdd6c8db4718faaa1005845c8f8f14c5c78e76a258eb.json index d1766e309..d5e72d910 100644 --- a/.sqlx/query-d61f2f56ce777c99593df240b3a126cacb3c9ed5f915b7e98052d58df98d480b.json +++ b/.sqlx/query-332fd0e123d2e7cc1e9abdd6c8db4718faaa1005845c8f8f14c5c78e76a258eb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n version(),\n current_setting('server_version_num') :: int8 AS version_num,\n (\n select\n count(*) :: int8 AS active_connections\n FROM\n pg_stat_activity\n ) AS active_connections,\n current_setting('max_connections') :: int8 AS max_connections;", + "query": "select\n version(),\n current_setting('server_version_num') :: int8 AS version_num,\n current_setting('server_version_num') :: int8 / 10000 AS major_version,\n (\n select\n count(*) :: int8 AS active_connections\n FROM\n pg_stat_activity\n ) AS active_connections,\n current_setting('max_connections') :: int8 AS max_connections;\n", "describe": { "columns": [ { @@ -15,11 +15,16 @@ }, { "ordinal": 2, - "name": "active_connections", + "name": "major_version", "type_info": "Int8" }, { "ordinal": 3, + "name": "active_connections", + "type_info": "Int8" + }, + { + "ordinal": 4, "name": "max_connections", "type_info": "Int8" } @@ -31,8 +36,9 @@ null, null, null, + null, null ] }, - "hash": "d61f2f56ce777c99593df240b3a126cacb3c9ed5f915b7e98052d58df98d480b" + "hash": "332fd0e123d2e7cc1e9abdd6c8db4718faaa1005845c8f8f14c5c78e76a258eb" } From 6a1ae046d5723f0687fd5395ba29f5595630dd4a Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 17 Sep 2025 08:28:26 +0200 Subject: [PATCH 19/23] progress --- agentic/port_squawk_rules.md | 1 + crates/pgt_analyse/src/rule.rs | 8 ++ crates/pgt_analyser/src/lint/safety.rs | 3 +- .../src/lint/safety/prefer_jsonb.rs | 130 ++++++++++++++++++ crates/pgt_analyser/src/options.rs | 1 + .../tests/specs/safety/preferJsonb/basic.sql | 2 + .../src/analyser/linter/rules.rs | 52 ++++--- .../src/categories.rs | 1 + docs/reference/rule_sources.md | 4 + docs/reference/rules.md | 1 + docs/reference/rules/prefer-jsonb.md | 125 +++++++++++++++++ docs/schema.json | 11 ++ .../backend-jsonrpc/src/workspace.ts | 5 + 13 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 crates/pgt_analyser/src/lint/safety/prefer_jsonb.rs create mode 100644 crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql create mode 100644 docs/reference/rules/prefer-jsonb.md diff --git a/agentic/port_squawk_rules.md b/agentic/port_squawk_rules.md index a97da059d..c2e1e681e 100644 --- a/agentic/port_squawk_rules.md +++ b/agentic/port_squawk_rules.md @@ -77,6 +77,7 @@ DONE: - prefer_bigint_over_int ✓ (ported from Squawk) - prefer_bigint_over_smallint ✓ (ported from Squawk) - prefer_identity ✓ (ported from Squawk) +- prefer_jsonb ✓ (new rule added) - prefer_text_field ✓ (ported from Squawk) - prefer_timestamptz ✓ (ported from Squawk) - disallow_unique_constraint ✓ (ported from Squawk) diff --git a/crates/pgt_analyse/src/rule.rs b/crates/pgt_analyse/src/rule.rs index 1760ce971..40f87ce02 100644 --- a/crates/pgt_analyse/src/rule.rs +++ b/crates/pgt_analyse/src/rule.rs @@ -291,6 +291,8 @@ impl RuleDiagnostic { pub enum RuleSource { /// Rules from [Squawk](https://squawkhq.com) Squawk(&'static str), + /// Rules from [Eugene](https://github.com/kaaveland/eugene) + Eugene(&'static str), } impl PartialEq for RuleSource { @@ -303,6 +305,7 @@ impl std::fmt::Display for RuleSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Squawk(_) => write!(f, "Squawk"), + Self::Eugene(_) => write!(f, "Eugene"), } } } @@ -325,18 +328,23 @@ impl RuleSource { pub fn as_rule_name(&self) -> &'static str { match self { Self::Squawk(rule_name) => rule_name, + Self::Eugene(rule_name) => rule_name, } } pub fn to_namespaced_rule_name(&self) -> String { match self { Self::Squawk(rule_name) => format!("squawk/{rule_name}"), + Self::Eugene(rule_name) => format!("eugene/{rule_name}"), } } pub fn to_rule_url(&self) -> String { match self { Self::Squawk(rule_name) => format!("https://squawkhq.com/docs/{rule_name}"), + Self::Eugene(rule_name) => { + format!("https://kaveland.no/eugene/hints/{rule_name}/index.html") + } } } diff --git a/crates/pgt_analyser/src/lint/safety.rs b/crates/pgt_analyser/src/lint/safety.rs index a5e7185b1..0ad0f2c56 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -20,6 +20,7 @@ pub mod prefer_big_int; pub mod prefer_bigint_over_int; pub mod prefer_bigint_over_smallint; pub mod prefer_identity; +pub mod prefer_jsonb; pub mod prefer_robust_stmts; pub mod prefer_text_field; pub mod prefer_timestamptz; @@ -28,4 +29,4 @@ pub mod renaming_table; pub mod require_concurrent_index_creation; pub mod require_concurrent_index_deletion; pub mod transaction_nesting; -declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_robust_stmts :: PreferRobustStmts , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz , self :: renaming_column :: RenamingColumn , self :: renaming_table :: RenamingTable , self :: require_concurrent_index_creation :: RequireConcurrentIndexCreation , self :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion , self :: transaction_nesting :: TransactionNesting ,] } } +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_jsonb :: PreferJsonb , self :: prefer_robust_stmts :: PreferRobustStmts , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz , self :: renaming_column :: RenamingColumn , self :: renaming_table :: RenamingTable , self :: require_concurrent_index_creation :: RequireConcurrentIndexCreation , self :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion , self :: transaction_nesting :: TransactionNesting ,] } } diff --git a/crates/pgt_analyser/src/lint/safety/prefer_jsonb.rs b/crates/pgt_analyser/src/lint/safety/prefer_jsonb.rs new file mode 100644 index 000000000..152795b6c --- /dev/null +++ b/crates/pgt_analyser/src/lint/safety/prefer_jsonb.rs @@ -0,0 +1,130 @@ +use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule}; +use pgt_console::markup; +use pgt_diagnostics::Severity; + +declare_lint_rule! { + /// Prefer JSONB over JSON types. + /// + /// JSONB is the binary JSON data type in PostgreSQL that is more efficient for most use cases. + /// Unlike JSON, JSONB stores data in a decomposed binary format which provides several advantages: + /// - Significantly faster query performance for operations like indexing and searching + /// - Support for indexing (GIN indexes) + /// - Duplicate keys are automatically removed + /// - Keys are sorted + /// + /// The only reasons to use JSON instead of JSONB are: + /// - You need to preserve exact input formatting (whitespace, key order) + /// - You need to preserve duplicate keys + /// - You have very specific performance requirements where JSON's lack of parsing overhead matters + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// CREATE TABLE users ( + /// data json + /// ); + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE users ADD COLUMN metadata json; + /// ``` + /// + /// ```sql,expect_diagnostic + /// ALTER TABLE users ALTER COLUMN data TYPE json; + /// ``` + /// + /// ### Valid + /// + /// ```sql + /// CREATE TABLE users ( + /// data jsonb + /// ); + /// ``` + /// + /// ```sql + /// ALTER TABLE users ADD COLUMN metadata jsonb; + /// ``` + /// + /// ```sql + /// ALTER TABLE users ALTER COLUMN data TYPE jsonb; + /// ``` + /// + pub PreferJsonb { + version: "next", + name: "preferJsonb", + severity: Severity::Warning, + recommended: false, + sources: &[RuleSource::Eugene("E3")], + } +} + +impl Rule for PreferJsonb { + type Options = (); + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + match &ctx.stmt() { + pgt_query::NodeEnum::CreateStmt(stmt) => { + for table_elt in &stmt.table_elts { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = &table_elt.node { + check_column_def(&mut diagnostics, col_def); + } + } + } + pgt_query::NodeEnum::AlterTableStmt(stmt) => { + for cmd in &stmt.cmds { + if let Some(pgt_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pgt_query::protobuf::AlterTableType::AtAddColumn + || cmd.subtype() + == pgt_query::protobuf::AlterTableType::AtAlterColumnType + { + if let Some(pgt_query::NodeEnum::ColumnDef(col_def)) = + &cmd.def.as_ref().and_then(|d| d.node.as_ref()) + { + check_column_def(&mut diagnostics, col_def); + } + } + } + } + } + _ => {} + } + + diagnostics + } +} + +fn check_column_def( + diagnostics: &mut Vec, + col_def: &pgt_query::protobuf::ColumnDef, +) { + let Some(type_name) = &col_def.type_name else { + return; + }; + + for name_node in &type_name.names { + let Some(pgt_query::NodeEnum::String(name)) = &name_node.node else { + continue; + }; + + let type_name_lower = name.sval.to_lowercase(); + if type_name_lower != "json" { + continue; + } + + diagnostics.push( + RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Prefer JSONB over JSON for better performance and functionality." + }, + ) + .detail(None, "JSON stores exact text representation while JSONB stores parsed binary format. JSONB is faster for queries, supports indexing, and removes duplicate keys.") + .note("Consider using JSONB instead unless you specifically need to preserve formatting or duplicate keys."), + ); + } +} diff --git a/crates/pgt_analyser/src/options.rs b/crates/pgt_analyser/src/options.rs index d24d471b9..693e20b4c 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -30,6 +30,7 @@ pub type PreferBigintOverInt = pub type PreferBigintOverSmallint = < lint :: safety :: prefer_bigint_over_smallint :: PreferBigintOverSmallint as pgt_analyse :: Rule > :: Options ; pub type PreferIdentity = ::Options; +pub type PreferJsonb = ::Options; pub type PreferRobustStmts = ::Options; pub type PreferTextField = diff --git a/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql new file mode 100644 index 000000000..d13389e30 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql @@ -0,0 +1,2 @@ +-- expect_only_lint/safety/preferJsonb +-- select 1; \ No newline at end of file diff --git a/crates/pgt_configuration/src/analyser/linter/rules.rs b/crates/pgt_configuration/src/analyser/linter/rules.rs index adaa657d7..8b23048fa 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -207,6 +207,9 @@ pub struct Safety { #[doc = "Prefer using IDENTITY columns over serial columns."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_identity: Option>, + #[doc = "Prefer JSONB over JSON types."] + #[serde(skip_serializing_if = "Option::is_none")] + pub prefer_jsonb: Option>, #[doc = "Prefer statements with guards for robustness in migrations."] #[serde(skip_serializing_if = "Option::is_none")] pub prefer_robust_stmts: Option>, @@ -256,6 +259,7 @@ impl Safety { "preferBigintOverInt", "preferBigintOverSmallint", "preferIdentity", + "preferJsonb", "preferRobustStmts", "preferTextField", "preferTimestamptz", @@ -303,6 +307,7 @@ impl Safety { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -414,46 +419,51 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.prefer_robust_stmts.as_ref() { + if let Some(rule) = self.prefer_jsonb.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.prefer_text_field.as_ref() { + if let Some(rule) = self.prefer_robust_stmts.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.prefer_timestamptz.as_ref() { + if let Some(rule) = self.prefer_text_field.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.renaming_column.as_ref() { + if let Some(rule) = self.prefer_timestamptz.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.renaming_table.as_ref() { + if let Some(rule) = self.renaming_column.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.require_concurrent_index_creation.as_ref() { + if let Some(rule) = self.renaming_table.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { + if let Some(rule) = self.require_concurrent_index_creation.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.transaction_nesting.as_ref() { + if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } + if let Some(rule) = self.transaction_nesting.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -553,46 +563,51 @@ impl Safety { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.prefer_robust_stmts.as_ref() { + if let Some(rule) = self.prefer_jsonb.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.prefer_text_field.as_ref() { + if let Some(rule) = self.prefer_robust_stmts.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.prefer_timestamptz.as_ref() { + if let Some(rule) = self.prefer_text_field.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.renaming_column.as_ref() { + if let Some(rule) = self.prefer_timestamptz.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.renaming_table.as_ref() { + if let Some(rule) = self.renaming_column.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.require_concurrent_index_creation.as_ref() { + if let Some(rule) = self.renaming_table.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { + if let Some(rule) = self.require_concurrent_index_creation.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.transaction_nesting.as_ref() { + if let Some(rule) = self.require_concurrent_index_deletion.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } + if let Some(rule) = self.transaction_nesting.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -641,6 +656,7 @@ impl Safety { "preferBigintOverInt" => Severity::Warning, "preferBigintOverSmallint" => Severity::Warning, "preferIdentity" => Severity::Warning, + "preferJsonb" => Severity::Warning, "preferRobustStmts" => Severity::Warning, "preferTextField" => Severity::Warning, "preferTimestamptz" => Severity::Warning, @@ -733,6 +749,10 @@ impl Safety { .prefer_identity .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "preferJsonb" => self + .prefer_jsonb + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "preferRobustStmts" => self .prefer_robust_stmts .as_ref() diff --git a/crates/pgt_diagnostics_categories/src/categories.rs b/crates/pgt_diagnostics_categories/src/categories.rs index e48252bb5..416416ef9 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -32,6 +32,7 @@ define_categories! { "lint/safety/preferBigintOverInt": "https://pgtools.dev/latest/rules/prefer-bigint-over-int", "lint/safety/preferBigintOverSmallint": "https://pgtools.dev/latest/rules/prefer-bigint-over-smallint", "lint/safety/preferIdentity": "https://pgtools.dev/latest/rules/prefer-identity", + "lint/safety/preferJsonb": "https://pgtools.dev/latest/rules/prefer-jsonb", "lint/safety/preferRobustStmts": "https://pgtools.dev/latest/rules/prefer-robust-stmts", "lint/safety/preferTextField": "https://pgtools.dev/latest/rules/prefer-text-field", "lint/safety/preferTimestamptz": "https://pgtools.dev/latest/rules/prefer-timestamptz", diff --git a/docs/reference/rule_sources.md b/docs/reference/rule_sources.md index 5ccaaf244..d69dd2b44 100644 --- a/docs/reference/rule_sources.md +++ b/docs/reference/rule_sources.md @@ -3,6 +3,10 @@ Many rules are inspired by or directly ported from other tools. This page lists ## Exclusive rules _No exclusive rules available._ ## Rules from other sources +### Eugene +| Eugene Rule Name | Rule Name | +| ---- | ---- | +| [prefer-jsonb](https://github.com/kaaveland/eugene/blob/main/docs/docs/prefer-jsonb.md) |[preferJsonb](../rules/prefer-jsonb) | ### Squawk | Squawk Rule Name | Rule Name | | ---- | ---- | diff --git a/docs/reference/rules.md b/docs/reference/rules.md index 88e309d7e..6a946ff62 100644 --- a/docs/reference/rules.md +++ b/docs/reference/rules.md @@ -31,6 +31,7 @@ Rules that detect potential safety issues in your code. | [preferBigintOverInt](./prefer-bigint-over-int) | Prefer BIGINT over INT/INTEGER types. | | | [preferBigintOverSmallint](./prefer-bigint-over-smallint) | Prefer BIGINT over SMALLINT types. | | | [preferIdentity](./prefer-identity) | Prefer using IDENTITY columns over serial columns. | | +| [preferJsonb](./prefer-jsonb) | Prefer JSONB over JSON types. | | | [preferRobustStmts](./prefer-robust-stmts) | Prefer statements with guards for robustness in migrations. | | | [preferTextField](./prefer-text-field) | Prefer using TEXT over VARCHAR(n) types. | | | [preferTimestamptz](./prefer-timestamptz) | Prefer TIMESTAMPTZ over TIMESTAMP types. | | diff --git a/docs/reference/rules/prefer-jsonb.md b/docs/reference/rules/prefer-jsonb.md new file mode 100644 index 000000000..edc214ee3 --- /dev/null +++ b/docs/reference/rules/prefer-jsonb.md @@ -0,0 +1,125 @@ +# preferJsonb +**Diagnostic Category: `lint/safety/preferJsonb`** + +**Since**: `vnext` + + +**Sources**: +- Inspired from: eugene/prefer-jsonb + +## Description +Prefer JSONB over JSON types. + +JSONB is the binary JSON data type in PostgreSQL that is more efficient for most use cases. +Unlike JSON, JSONB stores data in a decomposed binary format which provides several advantages: + +- Significantly faster query performance for operations like indexing and searching +- Support for indexing (GIN indexes) +- Duplicate keys are automatically removed +- Keys are sorted + +The only reasons to use JSON instead of JSONB are: + +- You need to preserve exact input formatting (whitespace, key order) +- You need to preserve duplicate keys +- You have very specific performance requirements where JSON's lack of parsing overhead matters + +## Examples + +### Invalid + +```sql +CREATE TABLE users ( + data json +); +``` + +```sh +code-block.sql:1:1 lint/safety/preferJsonb ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer JSONB over JSON for better performance and functionality. + + > 1 │ CREATE TABLE users ( + │ ^^^^^^^^^^^^^^^^^^^^ + > 2 │ data json + > 3 │ ); + │ ^^ + 4 │ + + i JSON stores exact text representation while JSONB stores parsed binary format. JSONB is faster for queries, supports indexing, and removes duplicate keys. + + i Consider using JSONB instead unless you specifically need to preserve formatting or duplicate keys. + + +``` + +```sql +ALTER TABLE users ADD COLUMN metadata json; +``` + +```sh +code-block.sql:1:1 lint/safety/preferJsonb ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer JSONB over JSON for better performance and functionality. + + > 1 │ ALTER TABLE users ADD COLUMN metadata json; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i JSON stores exact text representation while JSONB stores parsed binary format. JSONB is faster for queries, supports indexing, and removes duplicate keys. + + i Consider using JSONB instead unless you specifically need to preserve formatting or duplicate keys. + + +``` + +```sql +ALTER TABLE users ALTER COLUMN data TYPE json; +``` + +```sh +code-block.sql:1:1 lint/safety/preferJsonb ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Prefer JSONB over JSON for better performance and functionality. + + > 1 │ ALTER TABLE users ALTER COLUMN data TYPE json; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 │ + + i JSON stores exact text representation while JSONB stores parsed binary format. JSONB is faster for queries, supports indexing, and removes duplicate keys. + + i Consider using JSONB instead unless you specifically need to preserve formatting or duplicate keys. + + +``` + +### Valid + +```sql +CREATE TABLE users ( + data jsonb +); +``` + +```sql +ALTER TABLE users ADD COLUMN metadata jsonb; +``` + +```sql +ALTER TABLE users ALTER COLUMN data TYPE jsonb; +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "preferJsonb": "error" + } + } + } +} + +``` diff --git a/docs/schema.json b/docs/schema.json index 359e58b01..2d40cf9ed 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -572,6 +572,17 @@ } ] }, + "preferJsonb": { + "description": "Prefer JSONB over JSON types.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, "preferRobustStmts": { "description": "Prefer statements with guards for robustness in migrations.", "anyOf": [ diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 64cd10939..51a4f5663 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -82,6 +82,7 @@ export type Category = | "lint/safety/preferBigintOverInt" | "lint/safety/preferBigintOverSmallint" | "lint/safety/preferIdentity" + | "lint/safety/preferJsonb" | "lint/safety/preferRobustStmts" | "lint/safety/preferTextField" | "lint/safety/preferTimestamptz" @@ -514,6 +515,10 @@ export interface Safety { * Prefer using IDENTITY columns over serial columns. */ preferIdentity?: RuleConfiguration_for_Null; + /** + * Prefer JSONB over JSON types. + */ + preferJsonb?: RuleConfiguration_for_Null; /** * Prefer statements with guards for robustness in migrations. */ From ddbefc3f5b2e97516b3fd395fd56dbd8e92bcbb7 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 17 Sep 2025 08:40:59 +0200 Subject: [PATCH 20/23] progress --- docs/reference/rule_sources.md | 2 +- docs/reference/rules/prefer-jsonb.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/rule_sources.md b/docs/reference/rule_sources.md index d69dd2b44..d8aea6fc6 100644 --- a/docs/reference/rule_sources.md +++ b/docs/reference/rule_sources.md @@ -6,7 +6,7 @@ _No exclusive rules available._ ### Eugene | Eugene Rule Name | Rule Name | | ---- | ---- | -| [prefer-jsonb](https://github.com/kaaveland/eugene/blob/main/docs/docs/prefer-jsonb.md) |[preferJsonb](../rules/prefer-jsonb) | +| [E3](https://kaveland.no/eugene/hints/E3/index.html) |[preferJsonb](../rules/prefer-jsonb) | ### Squawk | Squawk Rule Name | Rule Name | | ---- | ---- | diff --git a/docs/reference/rules/prefer-jsonb.md b/docs/reference/rules/prefer-jsonb.md index edc214ee3..7a7bda21c 100644 --- a/docs/reference/rules/prefer-jsonb.md +++ b/docs/reference/rules/prefer-jsonb.md @@ -5,7 +5,7 @@ **Sources**: -- Inspired from: eugene/prefer-jsonb +- Inspired from: eugene/E3 ## Description Prefer JSONB over JSON types. From c6e6395e2daf56d385747f14bbf0e452d0297d16 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Wed, 17 Sep 2025 08:56:49 +0200 Subject: [PATCH 21/23] progress --- agentic/port_squawk_rules.md | 25 +++++++++++++++---- .../pgt_analyse/src/analysed_file_context.rs | 24 ++++++++++-------- crates/pgt_analyser/src/lib.rs | 2 +- ...oncurrent_index_creation_in_transaction.rs | 2 +- .../lint/safety/disallow_unique_constraint.rs | 2 +- .../require_concurrent_index_creation.rs | 2 +- .../src/lint/safety/transaction_nesting.rs | 2 +- 7 files changed, 39 insertions(+), 20 deletions(-) diff --git a/agentic/port_squawk_rules.md b/agentic/port_squawk_rules.md index c2e1e681e..5d5fb0147 100644 --- a/agentic/port_squawk_rules.md +++ b/agentic/port_squawk_rules.md @@ -18,13 +18,28 @@ pub struct RuleContext<'a, R: Rule> { file_context: &'a AnalysedFileContext, } + pub struct AnalysedFileContext<'a> { - // all other statements in this file - pub all_stmts: &'a Vec, - // total count of statements in this file - pub stmt_count: usize, + // all statements in this file + pub stmts: &'a Vec, + + pos: usize, +} + +impl<'a> AnalysedFileContext<'a> { + pub fn new(stmts: &'a Vec) -> Self { + Self { stmts, pos: 0 } + } + // all statements before the currently analysed one - pub previous_stmts: Vec<&'a pgt_query::NodeEnum>, + pub fn previous_stmts(&self) -> &[pgt_query::NodeEnum] { + &self.stmts[0..self.pos] + } + + // total count of statements in this file + pub fn stmt_count(&self) -> usize { + self.stmts.len() + } } ``` diff --git a/crates/pgt_analyse/src/analysed_file_context.rs b/crates/pgt_analyse/src/analysed_file_context.rs index c1ee39c62..8bf21d99c 100644 --- a/crates/pgt_analyse/src/analysed_file_context.rs +++ b/crates/pgt_analyse/src/analysed_file_context.rs @@ -1,19 +1,23 @@ pub struct AnalysedFileContext<'a> { - pub all_stmts: &'a Vec, - pub stmt_count: usize, - pub previous_stmts: Vec<&'a pgt_query::NodeEnum>, + pub stmts: &'a Vec, + + pos: usize, } impl<'a> AnalysedFileContext<'a> { pub fn new(stmts: &'a Vec) -> Self { - Self { - all_stmts: stmts, - stmt_count: stmts.len(), - previous_stmts: Vec::new(), - } + Self { stmts, pos: 0 } + } + + pub fn previous_stmts(&self) -> &[pgt_query::NodeEnum] { + &self.stmts[0..self.pos] + } + + pub fn stmt_count(&self) -> usize { + self.stmts.len() } - pub fn update_from(&mut self, stmt_root: &'a pgt_query::NodeEnum) { - self.previous_stmts.push(stmt_root); + pub fn next(&mut self) { + self.pos += 1; } } diff --git a/crates/pgt_analyser/src/lib.rs b/crates/pgt_analyser/src/lib.rs index a5635a0c0..f559090ef 100644 --- a/crates/pgt_analyser/src/lib.rs +++ b/crates/pgt_analyser/src/lib.rs @@ -84,7 +84,7 @@ impl<'a> Analyser<'a> { diagnostics.extend(stmt_diagnostics); - file_context.update_from(&roots[i]); + file_context.next(); } diagnostics diff --git a/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs b/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs index 1f6450ccd..565a7487f 100644 --- a/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs +++ b/crates/pgt_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs @@ -37,7 +37,7 @@ impl Rule for BanConcurrentIndexCreationInTransaction { // // since our analyser assumes we're always in a transaction context, we always flag concurrent indexes if let pgt_query::NodeEnum::IndexStmt(stmt) = ctx.stmt() { - if stmt.concurrent && ctx.file_context().stmt_count > 1 { + if stmt.concurrent && ctx.file_context().stmt_count() > 1 { diagnostics.push(RuleDiagnostic::new( rule_category!(), None, diff --git a/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs index 5310faad0..d72016f34 100644 --- a/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs +++ b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -50,7 +50,7 @@ impl Rule for DisallowUniqueConstraint { // Look for tables created in previous statements of this file let table_created_in_transaction = if let Some(table_name) = table_name { - ctx.file_context().previous_stmts.iter().any(|prev_stmt| { + ctx.file_context().previous_stmts().iter().any(|prev_stmt| { if let pgt_query::NodeEnum::CreateStmt(create) = prev_stmt { create .relation diff --git a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs index 4226fbb92..80a78a439 100644 --- a/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs +++ b/crates/pgt_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -79,7 +79,7 @@ fn is_table_created_in_file( table_name: &str, ) -> bool { // Check all statements in the file to see if this table was created - for stmt in file_context.all_stmts { + for stmt in file_context.stmts { if let pgt_query::NodeEnum::CreateStmt(create_stmt) = stmt { if let Some(relation) = &create_stmt.relation { if relation.relname == table_name { diff --git a/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs b/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs index 8061ff380..209d47314 100644 --- a/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs +++ b/crates/pgt_analyser/src/lint/safety/transaction_nesting.rs @@ -86,7 +86,7 @@ impl Rule for TransactionNesting { } fn has_transaction_start_before(file_context: &AnalysedFileContext) -> bool { - for stmt in &file_context.previous_stmts { + for stmt in file_context.previous_stmts() { if let pgt_query::NodeEnum::TransactionStmt(tx_stmt) = stmt { match tx_stmt.kind() { pgt_query::protobuf::TransactionStmtKind::TransStmtBegin From 93f13de29244d02f2e278d1de269b2df9fd1f743 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Thu, 18 Sep 2025 08:28:02 +0200 Subject: [PATCH 22/23] progress --- .../tests/specs/safety/preferJsonb/basic.sql | 7 ++++-- .../specs/safety/preferJsonb/basic.sql.snap | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql.snap diff --git a/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql index d13389e30..6297a91c1 100644 --- a/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql +++ b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql @@ -1,2 +1,5 @@ --- expect_only_lint/safety/preferJsonb --- select 1; \ No newline at end of file +-- expect_lint/safety/preferJsonb +CREATE TABLE users ( + id integer, + data json +); diff --git a/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql.snap b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql.snap new file mode 100644 index 000000000..76c43afc5 --- /dev/null +++ b/crates/pgt_analyser/tests/specs/safety/preferJsonb/basic.sql.snap @@ -0,0 +1,23 @@ +--- +source: crates/pgt_analyser/tests/rules_tests.rs +expression: snapshot +snapshot_kind: text +--- +# Input +``` +-- expect_lint/safety/preferJsonb +CREATE TABLE users ( + id integer, + data json +); + +``` + +# Diagnostics +lint/safety/preferJsonb ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Prefer JSONB over JSON for better performance and functionality. + + i JSON stores exact text representation while JSONB stores parsed binary format. JSONB is faster for queries, supports indexing, and removes duplicate keys. + + i Consider using JSONB instead unless you specifically need to preserve formatting or duplicate keys. From 6bec474135a7bd59fb00bec922af0532c86e4ef4 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Thu, 18 Sep 2025 17:42:37 +0200 Subject: [PATCH 23/23] fix: comment just commands out --- justfile | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/justfile b/justfile index 07cd191da..a98f47113 100644 --- a/justfile +++ b/justfile @@ -153,21 +153,23 @@ quick-modify: show-logs: tail -f $(ls $PGT_LOG_PATH/server.log.* | sort -t- -k2,2 -k3,3 -k4,4 | tail -n 1) -agentic name: - unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions -p "please read agentic/{{name}}.md and follow the instructions closely" - -agentic-loop name: - #!/usr/bin/env bash - echo "Starting agentic loop until error..." - iteration=1 - while true; do - echo "$(date): Starting iteration $iteration..." - if just agentic {{name}}; then - echo "$(date): Iteration $iteration completed successfully!" - iteration=$((iteration + 1)) - else - echo "$(date): Iteration $iteration failed - stopping loop" - break - fi - done +# Run a claude agent with the given agentic prompt file. +# Commented out by default to avoid accidental usage that may incur costs. +# agentic name: +# unset ANTHROPIC_API_KEY && claude --dangerously-skip-permissions -p "please read agentic/{{name}}.md and follow the instructions closely" +# +# agentic-loop name: +# #!/usr/bin/env bash +# echo "Starting agentic loop until error..." +# iteration=1 +# while true; do +# echo "$(date): Starting iteration $iteration..." +# if just agentic {{name}}; then +# echo "$(date): Iteration $iteration completed successfully!" +# iteration=$((iteration + 1)) +# else +# echo "$(date): Iteration $iteration failed - stopping loop" +# break +# fi +# done