diff --git a/db/migrate/20260814120000_cascade_delete_work_package_semantic_aliases.rb b/db/migrate/20260814120000_cascade_delete_work_package_semantic_aliases.rb new file mode 100644 index 000000000000..994c0971f733 --- /dev/null +++ b/db/migrate/20260814120000_cascade_delete_work_package_semantic_aliases.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CascadeDeleteWorkPackageSemanticAliases < ActiveRecord::Migration[8.1] + # Deleting a project cascades to its work_packages at the database level + # (work_packages.project_id has ON DELETE CASCADE). That database-level cascade + # bypasses the ActiveRecord `dependent: :delete_all` on the semantic_aliases + # association, so the restricting foreign key from work_package_semantic_aliases + # to work_packages blocked the delete. Cascade the aliases too. + def up + remove_foreign_key :work_package_semantic_aliases, :work_packages + add_foreign_key :work_package_semantic_aliases, :work_packages, on_delete: :cascade + end + + def down + remove_foreign_key :work_package_semantic_aliases, :work_packages + add_foreign_key :work_package_semantic_aliases, :work_packages + end +end diff --git a/spec/models/work_package_semantic_alias_spec.rb b/spec/models/work_package_semantic_alias_spec.rb index ebbb4747948f..2cbeba7e2338 100644 --- a/spec/models/work_package_semantic_alias_spec.rb +++ b/spec/models/work_package_semantic_alias_spec.rb @@ -105,4 +105,35 @@ def alias_for(identifier) end end end + + describe "work_package foreign key on delete", + with_settings: { work_packages_identifier: Setting::WorkPackageIdentifier::SEMANTIC } do + shared_let(:user) { create(:admin) } + + # The path a user takes in the UI: Projects::DeleteProjectJob -> Projects::DeleteService. + it "deletes a parent project whose subproject holds a work package" do + parent_project = create(:project, identifier: "PARENT", name: "Parent") + child_project = create(:project, identifier: "CHILD", name: "Child", parent: parent_project) + child_work_package = create(:work_package, project: child_project, subject: "In the subproject") + + expect(described_class.where(work_package_id: child_work_package.id).pluck(:identifier)) + .to eq(["CHILD-1"]) + + service_call = Projects::DeleteService.new(user:, model: parent_project).call + + expect(service_call).to be_success + expect(Project.where(id: [parent_project.id, child_project.id])).to be_empty + end + + # Any caller that destroys a project without going through Projects::DeleteService, + # e.g. the console, seeders, or awesome_nested_set destroying descendants. + it "destroys a project holding a work package" do + project = create(:project, identifier: "SOLO", name: "Solo") + create(:work_package, project:, subject: "In the project") + + project.destroy! + + expect(Project.where(id: project.id)).to be_empty + end + end end