diff --git a/Gemfile b/Gemfile index 9f5b1cbae..48491e265 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'coffee-script' gem 'amazing_print' # colourful output (suggested by rails_semantic_logger) gem 'rails_semantic_logger' # condense log lines: https://github.com/codebar/planner/issues/2339 +gem 'strong_migrations' gem 'acts-as-taggable-on' gem 'benchmark' # LOCKED: Added because of activesupport 7.0 diff --git a/Gemfile.lock b/Gemfile.lock index cec30e570..56633f47d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -572,6 +572,8 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stripe (9.0.0) + strong_migrations (2.8.0) + activerecord (>= 7.2) sysexits (1.2.0) temple (0.10.4) terser (1.2.8) @@ -709,6 +711,7 @@ DEPENDENCIES sprockets-rails stimulus-rails stripe + strong_migrations terser turbo-rails tzinfo-data @@ -932,6 +935,7 @@ CHECKSUMS ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stripe (9.0.0) sha256=71dda83b8428615c755b72b17469ad213e5ce6cf074aae2b45ce3ea1137abdde + strong_migrations (2.8.0) sha256=cb9c0f8160e60f3e9c0e76098d57a6f61825b9618e8eb41cebbd1c1079874439 sysexits (1.2.0) sha256=598241c4ae57baa403c125182dfdcc0d1ac4c0fb606dd47fbed57e4aaf795662 temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c terser (1.2.8) sha256=64931851d173bc5be0a90bd4570d751be5e83b2b063ccb750dbdc11a7b1d14db diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb new file mode 100644 index 000000000..0fe1533ee --- /dev/null +++ b/config/initializers/strong_migrations.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Analyze migrations for unsafe operations before they reach production. +# https://github.com/ankane/strong_migrations +StrongMigrations.start_after = 20260101000000 # rubocop:disable Style/NumericLiterals +StrongMigrations.target_postgresql_version = 16 diff --git a/db/migrate/20260211140256_add_unique_index_to_waiting_lists_invitation_id.rb b/db/migrate/20260211140256_add_unique_index_to_waiting_lists_invitation_id.rb index c7e1bb8d1..f5f2a8b6c 100644 --- a/db/migrate/20260211140256_add_unique_index_to_waiting_lists_invitation_id.rb +++ b/db/migrate/20260211140256_add_unique_index_to_waiting_lists_invitation_id.rb @@ -1,45 +1,49 @@ class AddUniqueIndexToWaitingListsInvitationId < ActiveRecord::Migration[8.1] def up # Clean up duplicate waiting list entries - duplicate_count = say_with_time "Cleaning up duplicate waiting list entries" do - duplicate_invitation_ids = WaitingList - .group(:invitation_id) - .having('COUNT(*) > 1') - .pluck(:invitation_id) - - duplicate_count = duplicate_invitation_ids.count - say "Found #{duplicate_count} invitation_ids with duplicates" - - if duplicate_count > 0 - # For each duplicate set, keep oldest and delete the rest - duplicate_invitation_ids.each do |invitation_id| - entries = WaitingList - .where(invitation_id: invitation_id) - .order(:created_at) - - # Get IDs to delete (all except first/oldest) - ids_to_delete = entries[1..].map(&:id) - deleted_count = ids_to_delete.size - - say " Invitation #{invitation_id}: deleting #{deleted_count} duplicate(s), keeping entry ##{entries.first.id}" - - # Use delete_all for performance and to avoid callbacks - WaitingList.where(id: ids_to_delete).delete_all + safety_assured do + duplicate_count = say_with_time "Cleaning up duplicate waiting list entries" do + duplicate_invitation_ids = WaitingList + .group(:invitation_id) + .having('COUNT(*) > 1') + .pluck(:invitation_id) + + duplicate_count = duplicate_invitation_ids.count + say "Found #{duplicate_count} invitation_ids with duplicates" + + if duplicate_count > 0 + # For each duplicate set, keep oldest and delete the rest + duplicate_invitation_ids.each do |invitation_id| + entries = WaitingList + .where(invitation_id: invitation_id) + .order(:created_at) + + # Get IDs to delete (all except first/oldest) + ids_to_delete = entries[1..].map(&:id) + deleted_count = ids_to_delete.size + + say " Invitation #{invitation_id}: deleting #{deleted_count} duplicate(s), keeping entry ##{entries.first.id}" + + # Use delete_all for performance and to avoid callbacks + WaitingList.where(id: ids_to_delete).delete_all + end end - end - duplicate_count + duplicate_count + end end # Add unique constraint (remove existing non-unique index first if it exists) say_with_time "Adding unique index on waiting_lists.invitation_id" do - begin - remove_index :waiting_lists, :invitation_id - rescue StandardError => e - say " Note: Could not remove existing index (#{e.message})" - end + safety_assured do + begin + remove_index :waiting_lists, :invitation_id + rescue StandardError => e + say " Note: Could not remove existing index (#{e.message})" + end - add_index :waiting_lists, :invitation_id, unique: true + add_index :waiting_lists, :invitation_id, unique: true + end end end diff --git a/db/migrate/20260224120000_add_indexes_for_invitation_queries.rb b/db/migrate/20260224120000_add_indexes_for_invitation_queries.rb index a0fefa66b..197410c45 100644 --- a/db/migrate/20260224120000_add_indexes_for_invitation_queries.rb +++ b/db/migrate/20260224120000_add_indexes_for_invitation_queries.rb @@ -1,12 +1,14 @@ class AddIndexesForInvitationQueries < ActiveRecord::Migration[8.1] def change - add_index :workshop_invitations, %i[member_id attending], name: 'index_workshop_invitations_member_attending' - add_index :workshop_invitations, %i[workshop_id attending], name: 'index_workshop_invitations_workshop_attending' + safety_assured do + add_index :workshop_invitations, %i[member_id attending], name: 'index_workshop_invitations_member_attending' + add_index :workshop_invitations, %i[workshop_id attending], name: 'index_workshop_invitations_workshop_attending' - add_index :meeting_invitations, %i[member_id attending], name: 'index_meeting_invitations_member_attending' - add_index :meeting_invitations, %i[meeting_id attending], name: 'index_meeting_invitations_meeting_attending' + add_index :meeting_invitations, %i[member_id attending], name: 'index_meeting_invitations_member_attending' + add_index :meeting_invitations, %i[meeting_id attending], name: 'index_meeting_invitations_meeting_attending' - add_index :invitations, %i[member_id attending], name: 'index_invitations_member_attending' - add_index :invitations, %i[event_id attending], name: 'index_invitations_event_attending' + add_index :invitations, %i[member_id attending], name: 'index_invitations_member_attending' + add_index :invitations, %i[event_id attending], name: 'index_invitations_event_attending' + end end end diff --git a/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb b/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb index 47f4f6ac3..74fe7d752 100644 --- a/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb +++ b/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb @@ -1,5 +1,7 @@ class AddIndexWorkshopSponsorsHost < ActiveRecord::Migration[8.1] def change - add_index :workshop_sponsors, %i[workshop_id host], name: 'index_workshop_sponsors_on_workshop_id_and_host' + safety_assured do + add_index :workshop_sponsors, %i[workshop_id host], name: 'index_workshop_sponsors_on_workshop_id_and_host' + end end end diff --git a/db/migrate/20260408220120_fix_duplicate_subscriptions_and_add_unique_index.rb b/db/migrate/20260408220120_fix_duplicate_subscriptions_and_add_unique_index.rb index e83cc074b..ddc885898 100644 --- a/db/migrate/20260408220120_fix_duplicate_subscriptions_and_add_unique_index.rb +++ b/db/migrate/20260408220120_fix_duplicate_subscriptions_and_add_unique_index.rb @@ -2,20 +2,22 @@ class FixDuplicateSubscriptionsAndAddUniqueIndex < ActiveRecord::Migration[8.1] disable_ddl_transaction! def up - execute <<~SQL - DELETE FROM subscriptions - WHERE id NOT IN ( - SELECT MIN(id) - FROM subscriptions - GROUP BY member_id, group_id - ) - AND (member_id, group_id) IN ( - SELECT member_id, group_id - FROM subscriptions - GROUP BY member_id, group_id - HAVING COUNT(*) > 1 - ) - SQL + safety_assured do + execute <<~SQL + DELETE FROM subscriptions + WHERE id NOT IN ( + SELECT MIN(id) + FROM subscriptions + GROUP BY member_id, group_id + ) + AND (member_id, group_id) IN ( + SELECT member_id, group_id + FROM subscriptions + GROUP BY member_id, group_id + HAVING COUNT(*) > 1 + ) + SQL + end add_index :subscriptions, %i[member_id group_id], diff --git a/db/migrate/20260615081352_backfill_sponsor_number_of_coaches.rb b/db/migrate/20260615081352_backfill_sponsor_number_of_coaches.rb index 14f664f11..856d50f6b 100644 --- a/db/migrate/20260615081352_backfill_sponsor_number_of_coaches.rb +++ b/db/migrate/20260615081352_backfill_sponsor_number_of_coaches.rb @@ -2,8 +2,10 @@ class BackfillSponsorNumberOfCoaches < ActiveRecord::Migration[8.1] # Backfill nil number_of_coaches using the same formula as Sponsor#coach_spots # to ensure existing records pass the new presence validation when edited. def up - Sponsor.where(number_of_coaches: nil) - .update_all("number_of_coaches = ROUND(seats / 2.0)") + safety_assured do + Sponsor.where(number_of_coaches: nil) + .update_all("number_of_coaches = ROUND(seats / 2.0)") + end end def down diff --git a/db/migrate/20260621050948_drop_meeting_talks.rb b/db/migrate/20260621050948_drop_meeting_talks.rb index 5aef7f225..baa7fe471 100644 --- a/db/migrate/20260621050948_drop_meeting_talks.rb +++ b/db/migrate/20260621050948_drop_meeting_talks.rb @@ -1,6 +1,8 @@ class DropMeetingTalks < ActiveRecord::Migration[8.1] def up - drop_table :meeting_talks + safety_assured do + drop_table :meeting_talks + end end def down