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/agentic/port_squawk_rules.md b/agentic/port_squawk_rules.md new file mode 100644 index 000000000..a97da059d --- /dev/null +++ b/agentic/port_squawk_rules.md @@ -0,0 +1,90 @@ +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, +} + +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: +```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, + +``` + +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. +- 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. + +NEEDS FEATURES: + +TODO: + +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_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) +- 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_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/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/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 a2b72fceb..a5e7185b1 100644 --- a/crates/pgt_analyser/src/lint/safety.rs +++ b/crates/pgt_analyser/src/lint/safety.rs @@ -1,10 +1,31 @@ //! 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_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_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 ,] } } +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; +pub mod prefer_robust_stmts; +pub mod prefer_text_field; +pub mod prefer_timestamptz; +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/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/adding_foreign_key_constraint.rs b/crates/pgt_analyser/src/lint/safety/adding_foreign_key_constraint.rs new file mode 100644 index 000000000..06a91e5e4 --- /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(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(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 + { + 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..2bfd940bd --- /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(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(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() + { + 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/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/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/disallow_unique_constraint.rs b/crates/pgt_analyser/src/lint/safety/disallow_unique_constraint.rs new file mode 100644 index 000000000..5310faad0 --- /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() + .is_some_and(|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_big_int.rs b/crates/pgt_analyser/src/lint/safety/prefer_big_int.rs new file mode 100644 index 000000000..b44cb72fb --- /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/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/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/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 d893b84f4..d24d471b9 100644 --- a/crates/pgt_analyser/src/options.rs +++ b/crates/pgt_analyser/src/options.rs @@ -1,8 +1,16 @@ //! 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 BanConcurrentIndexCreationInTransaction = < lint :: safety :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction as pgt_analyse :: Rule > :: Options ; pub type BanDropColumn = ::Options; pub type BanDropDatabase = @@ -12,3 +20,27 @@ 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 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 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/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/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/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 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_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/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_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_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 d45199b07..adaa657d7 100644 --- a/crates/pgt_configuration/src/analyser/linter/rules.rs +++ b/crates/pgt_configuration/src/analyser/linter/rules.rs @@ -141,10 +141,32 @@ 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: + 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: Option>, + #[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>, @@ -160,21 +182,98 @@ 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 = "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>, + #[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>, + #[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"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + "addingFieldWithDefault", + "addingForeignKeyConstraint", + "addingNotNullField", + "addingPrimaryKeyConstraint", "addingRequiredField", + "banCharField", + "banConcurrentIndexCreationInTransaction", "banDropColumn", "banDropDatabase", "banDropNotNull", "banDropTable", "banTruncateCascade", + "changingColumnType", + "constraintMissingNotValid", + "disallowUniqueConstraint", + "preferBigInt", + "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]), 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[6]), + 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]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -183,6 +282,27 @@ 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]), + 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]), + 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 { @@ -199,70 +319,280 @@ impl Safety { } pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { let mut index_set = FxHashSet::default(); - if let Some(rule) = self.adding_required_field.as_ref() { + 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.ban_drop_column.as_ref() { + 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.ban_drop_database.as_ref() { + 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.ban_drop_not_null.as_ref() { + 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.ban_drop_table.as_ref() { + 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[4])); } } - if let Some(rule) = self.ban_truncate_cascade.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_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_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_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_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_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.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])); + } + } + 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])); + } + } + 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_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> { let mut index_set = FxHashSet::default(); - if let Some(rule) = self.adding_required_field.as_ref() { + 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.ban_drop_column.as_ref() { + 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.ban_drop_database.as_ref() { + 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.ban_drop_not_null.as_ref() { + 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.ban_drop_table.as_ref() { + 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[4])); } } - if let Some(rule) = self.ban_truncate_cascade.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_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_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_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_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_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.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])); + } + } + 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])); + } + } + 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_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"] @@ -292,12 +622,33 @@ 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, + "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, + "disallowUniqueConstraint" => Severity::Error, + "preferBigInt" => Severity::Warning, + "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!(), } } @@ -306,10 +657,34 @@ 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() + .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() .map(|conf| (conf.level(), conf.get_options())), + "banCharField" => self + .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() @@ -330,6 +705,66 @@ 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())), + "disallowUniqueConstraint" => self + .disallow_unique_constraint + .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())), + "preferRobustStmts" => self + .prefer_robust_stmts + .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())), + "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 14df90b9e..e48252bb5 100644 --- a/crates/pgt_diagnostics_categories/src/categories.rs +++ b/crates/pgt_diagnostics_categories/src/categories.rs @@ -13,12 +13,33 @@ // 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/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/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/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/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 2c791a532..029476ecb 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/reference/rule_sources.md b/docs/reference/rule_sources.md index 087555aa6..5ccaaf244 100644 --- a/docs/reference/rule_sources.md +++ b/docs/reference/rule_sources.md @@ -6,9 +6,30 @@ _No exclusive rules available._ ### 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-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) | +| [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-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/reference/rules.md b/docs/reference/rules.md index 2ff8361b7..88e309d7e 100644 --- a/docs/reference/rules.md +++ b/docs/reference/rules.md @@ -12,12 +12,33 @@ Rules that detect potential safety issues in your code. | Rule name | Description | Properties | | --- | --- | --- | +| [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. | | +| [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. | | +| [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. | | +| [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/reference/rules/adding-field-with-default.md b/docs/reference/rules/adding-field-with-default.md new file mode 100644 index 000000000..644ac91fc --- /dev/null +++ b/docs/reference/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/reference/rules/adding-foreign-key-constraint.md b/docs/reference/rules/adding-foreign-key-constraint.md new file mode 100644 index 000000000..19971ae3e --- /dev/null +++ b/docs/reference/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/reference/rules/adding-not-null-field.md b/docs/reference/rules/adding-not-null-field.md new file mode 100644 index 000000000..f4da097c9 --- /dev/null +++ b/docs/reference/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/reference/rules/adding-primary-key-constraint.md b/docs/reference/rules/adding-primary-key-constraint.md new file mode 100644 index 000000000..644788807 --- /dev/null +++ b/docs/reference/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/reference/rules/ban-char-field.md b/docs/reference/rules/ban-char-field.md new file mode 100644 index 000000000..c9be7d962 --- /dev/null +++ b/docs/reference/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/reference/rules/ban-concurrent-index-creation-in-transaction.md b/docs/reference/rules/ban-concurrent-index-creation-in-transaction.md new file mode 100644 index 000000000..8e7e10c34 --- /dev/null +++ b/docs/reference/rules/ban-concurrent-index-creation-in-transaction.md @@ -0,0 +1,43 @@ +# 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 +``` + +## How to configure +```json + +{ + "linter": { + "rules": { + "safety": { + "banConcurrentIndexCreationInTransaction": "error" + } + } + } +} + +``` diff --git a/docs/reference/rules/changing-column-type.md b/docs/reference/rules/changing-column-type.md new file mode 100644 index 000000000..ac5d2cd84 --- /dev/null +++ b/docs/reference/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/reference/rules/constraint-missing-not-valid.md b/docs/reference/rules/constraint-missing-not-valid.md new file mode 100644 index 000000000..a22ed85a2 --- /dev/null +++ b/docs/reference/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/reference/rules/disallow-unique-constraint.md b/docs/reference/rules/disallow-unique-constraint.md new file mode 100644 index 000000000..acf81bf4f --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-big-int.md b/docs/reference/rules/prefer-big-int.md new file mode 100644 index 000000000..238808d0f --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-bigint-over-int.md b/docs/reference/rules/prefer-bigint-over-int.md new file mode 100644 index 000000000..88c45972a --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-bigint-over-smallint.md b/docs/reference/rules/prefer-bigint-over-smallint.md new file mode 100644 index 000000000..4688874b4 --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-identity.md b/docs/reference/rules/prefer-identity.md new file mode 100644 index 000000000..956a8fcb4 --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-robust-stmts.md b/docs/reference/rules/prefer-robust-stmts.md new file mode 100644 index 000000000..c0e2b9d54 --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-text-field.md b/docs/reference/rules/prefer-text-field.md new file mode 100644 index 000000000..7f8831580 --- /dev/null +++ b/docs/reference/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/reference/rules/prefer-timestamptz.md b/docs/reference/rules/prefer-timestamptz.md new file mode 100644 index 000000000..b53962342 --- /dev/null +++ b/docs/reference/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/reference/rules/renaming-column.md b/docs/reference/rules/renaming-column.md new file mode 100644 index 000000000..a43661f54 --- /dev/null +++ b/docs/reference/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/reference/rules/renaming-table.md b/docs/reference/rules/renaming-table.md new file mode 100644 index 000000000..44c03116b --- /dev/null +++ b/docs/reference/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/reference/rules/require-concurrent-index-creation.md b/docs/reference/rules/require-concurrent-index-creation.md new file mode 100644 index 000000000..aaacb50fd --- /dev/null +++ b/docs/reference/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/reference/rules/require-concurrent-index-deletion.md b/docs/reference/rules/require-concurrent-index-deletion.md new file mode 100644 index 000000000..c919177f1 --- /dev/null +++ b/docs/reference/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/reference/rules/transaction-nesting.md b/docs/reference/rules/transaction-nesting.md new file mode 100644 index 000000000..7f3b25ab0 --- /dev/null +++ b/docs/reference/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 bf9482acc..359e58b01 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 column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock.", + "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": [ @@ -374,6 +418,28 @@ "null" ] }, + "banCharField": { + "description": "Using CHAR(n) or CHARACTER(n) types is discouraged.", + "anyOf": [ + { + "$ref": "#/definitions/RuleConfiguration" + }, + { + "type": "null" + } + ] + }, + "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": [ @@ -429,12 +495,177 @@ } ] }, + "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" + } + ] + }, + "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": [ + { + "$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" + } + ] + }, + "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": [ + { + "$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": [ "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/justfile b/justfile index 18b4ed35d..07cd191da 100644 --- a/justfile +++ b/justfile @@ -152,3 +152,22 @@ 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) + +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 + diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 6f7b36202..64cd10939 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -63,12 +63,33 @@ 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/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/disallowUniqueConstraint" + | "lint/safety/preferBigInt" + | "lint/safety/preferBigintOverInt" + | "lint/safety/preferBigintOverSmallint" + | "lint/safety/preferIdentity" + | "lint/safety/preferRobustStmts" + | "lint/safety/preferTextField" + | "lint/safety/preferTimestamptz" + | "lint/safety/renamingColumn" + | "lint/safety/renamingTable" + | "lint/safety/requireConcurrentIndexCreation" + | "lint/safety/requireConcurrentIndexDeletion" + | "lint/safety/transactionNesting" | "stdin" | "check" | "configuration" @@ -413,6 +434,22 @@ export type VcsClientKind = "git"; * A list of rules that belong to this group */ export interface Safety { + /** + * Adding a column with a DEFAULT value may lead to a table rewrite while holding an ACCESS EXCLUSIVE lock. + */ + 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. */ @@ -421,6 +458,14 @@ 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; + /** + * Concurrent index creation is not allowed within a transaction. + */ + banConcurrentIndexCreationInTransaction?: RuleConfiguration_for_Null; /** * Dropping a column may break existing clients. */ @@ -441,10 +486,70 @@ 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; + /** + * Disallow adding a UNIQUE constraint without using an existing index. + */ + disallowUniqueConstraint?: 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; + /** + * Prefer statements with guards for robustness in migrations. + */ + preferRobustStmts?: 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 */ 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 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() }} }}