From c4debc8aaa19512fcc05d7bbfab8120d02ae6785 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 1 Apr 2026 17:25:19 +0200 Subject: [PATCH] [#73523] Implement `WorkPackage` semantic ID allocation system --- .rubocop.yml | 4 + app/models/project.rb | 1 + app/models/projects/semantic_identifier.rb | 97 ++++++++ app/models/work_package.rb | 1 + .../work_package/semantic_identifier.rb | 106 +++++++++ app/models/work_package_semantic_alias.rb | 45 ++++ app/services/projects/update_service.rb | 8 + app/services/work_packages/update_service.rb | 5 + config/locales/en.yml | 3 + ...100000_create_work_package_semantic_ids.rb | 70 ++++++ .../projects/semantic_identifier_spec.rb | 140 ++++++++++++ .../work_package/semantic_identifier_spec.rb | 163 ++++++++++++++ .../work_package_semantic_alias_spec.rb | 81 +++++++ .../semantic_ids/integration_spec.rb | 210 ++++++++++++++++++ 14 files changed, 934 insertions(+) create mode 100644 app/models/projects/semantic_identifier.rb create mode 100644 app/models/work_package/semantic_identifier.rb create mode 100644 app/models/work_package_semantic_alias.rb create mode 100644 db/migrate/20260330100000_create_work_package_semantic_ids.rb create mode 100644 spec/models/projects/semantic_identifier_spec.rb create mode 100644 spec/models/work_package/semantic_identifier_spec.rb create mode 100644 spec/models/work_package_semantic_alias_spec.rb create mode 100644 spec/services/work_packages/semantic_ids/integration_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 1815a2c11d1..1bd67f59709 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -169,6 +169,10 @@ Rails/ContentTag: # dynamic finders cop clashes with capybara ID cop Rails/DynamicFindBy: Enabled: true + AllowedMethods: + - find_by_id_or_identifier + - find_by_id_or_identifier! + - find_by_semantic_identifier Exclude: - "spec/features/**/*.rb" - "spec/support/**/*.rb" diff --git a/app/models/project.rb b/app/models/project.rb index 3a5aa7853fd..8d01e969e0c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -39,6 +39,7 @@ class Project < ApplicationRecord include Projects::WorkPackageCustomFields include Projects::CreationWizard include Projects::Identifier + include Projects::SemanticIdentifier include ::Scopes::Scoped diff --git a/app/models/projects/semantic_identifier.rb b/app/models/projects/semantic_identifier.rb new file mode 100644 index 00000000000..100f04de7f3 --- /dev/null +++ b/app/models/projects/semantic_identifier.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects::SemanticIdentifier + extend ActiveSupport::Concern + + # Atomically allocates the next sequence number for a work package in this project + # and returns it paired with the resulting semantic identifier (e.g. [42, "PROJ-42"]). + # Uses an advisory lock scoped to this project to serialize concurrent allocations + # without blocking unrelated project row writes. + def allocate_wp_semantic_identifier! + seq = OpenProject::Mutex.with_advisory_lock( + self.class, + "wp_sequence_#{id}" + ) do + self.class.connection.select_value(<<~SQL.squish) + UPDATE projects + SET wp_sequence_counter = wp_sequence_counter + 1 + WHERE id = #{self.class.connection.quote(id)} + RETURNING wp_sequence_counter + SQL + end + + [seq, "#{identifier}-#{seq}"] + end + + # Called after this project's identifier is renamed. Atomically: + # 1. Appends new-prefix aliases for every WP that ever carried an old-prefix alias. + # 2. Updates identifier on resident WPs to the new prefix. + def handle_semantic_rename(old_identifier, batch_size: 1000) + like_pattern = "#{self.class.sanitize_sql_like(old_identifier)}-%" + prefix = "#{old_identifier}-" + new_prefix = "#{identifier}-" + + WorkPackageSemanticAlias.transaction do + append_aliases_with_new_prefix(like_pattern:, prefix:, new_prefix:, batch_size:) + rewrite_semantic_ids(like_pattern:, prefix:, new_prefix:, batch_size:) + end + end + + private + + # For every alias row whose identifier starts with the old prefix, inserts a + # corresponding row with the new prefix. This covers WPs still in the project + # as well as any that have moved out but still carry old-prefix alias rows. + def append_aliases_with_new_prefix(like_pattern:, prefix:, new_prefix:, batch_size:) + WorkPackageSemanticAlias + .where("identifier LIKE ?", like_pattern) + .in_batches(of: batch_size) do |relation| + now = Time.current + WorkPackageSemanticAlias.connection.execute( + WorkPackageSemanticAlias.sanitize_sql([<<~SQL.squish, { prefix:, new_prefix:, now: }]) + INSERT INTO work_package_semantic_aliases (identifier, work_package_id, created_at, updated_at) + SELECT REPLACE(identifier, :prefix, :new_prefix), work_package_id, :now, :now + FROM (#{relation.to_sql}) AS batch + ON CONFLICT (identifier) DO NOTHING + SQL + ) + end + end + + # Updates the identifier column on all resident WPs to replace the old prefix with the new one. + def rewrite_semantic_ids(like_pattern:, prefix:, new_prefix:, batch_size:) + WorkPackage + .where("identifier LIKE ?", like_pattern) + .in_batches(of: batch_size) do |relation| + relation.update_all(["identifier = REPLACE(identifier, ?, ?)", prefix, new_prefix]) + end + end +end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index 93aa490b535..b80a5ef9427 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -29,6 +29,7 @@ #++ class WorkPackage < ApplicationRecord + include WorkPackage::SemanticIdentifier include WorkPackage::Validations include WorkPackage::SchedulingRules include WorkPackage::StatusTransitions diff --git a/app/models/work_package/semantic_identifier.rb b/app/models/work_package/semantic_identifier.rb new file mode 100644 index 00000000000..d3ad6fe93b1 --- /dev/null +++ b/app/models/work_package/semantic_identifier.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackage::SemanticIdentifier + extend ActiveSupport::Concern + + included do + has_many :semantic_aliases, + class_name: "WorkPackageSemanticAlias", + foreign_key: :work_package_id, + inverse_of: :work_package, + dependent: :delete_all + + after_create :allocate_and_register_semantic_id, if: -> { Setting::WorkPackageIdentifier.semantic? } + end + + class_methods do + def semantic_id?(identifier) + identifier.to_s.to_i.to_s != identifier.to_s + end + + # Resolves any identifier form to a WorkPackage. + # - Numeric string ("12345") → find by primary key + # - Semantic string ("PROJ-42") → lookup via work_packages table and alias table + # + # Returns nil on miss. + def find_by_id_or_identifier(identifier) + return find_by(id: identifier) unless semantic_id?(identifier) + + find_by_semantic_identifier(identifier) + end + + # Same as find_by_id_or_identifier but raises ActiveRecord::RecordNotFound on miss. + def find_by_id_or_identifier!(identifier) + find_by_id_or_identifier(identifier) || raise(ActiveRecord::RecordNotFound, "WorkPackage not found: #{identifier}") + end + + private + + def find_by_semantic_identifier(identifier) + wp = find_by(identifier:) + return wp if wp + + # Fallback: alias table lookup. The table holds every identifier a WP has ever been known by: + # Done via a single join to: + # * Respect any parent scoping (e.g. when called as WorkPackage.visible.find_by_semantic_identifier) + # * Reduce lookup to a single DB round trip + joins(:semantic_aliases).find_by(work_package_semantic_aliases: { identifier: }) + end + end + + # Allocates the next semantic identifier in the current project and assigns it to the WP. + # Also writes alias rows for every identifier the project has ever used (including "ghost" aliases). + # + # This should generally be run following project_id-mutating operations on WorkPackage records (like create or move). + def allocate_and_register_semantic_id + WorkPackageSemanticAlias.transaction do + sequence_number, identifier = project.allocate_wp_semantic_identifier! + # Re-map the semantic identifier to the new project + update_columns(sequence_number:, identifier:) + # Insert current, historical + ghost aliases for the new project + # Note: In case of WP move, the previous mapping for the old project is assumed + # to be present in the alias table already, ever since its prior create/move operation. + semantic_aliases.insert_all(alias_rows_for_sequence_number(sequence_number), + unique_by: :identifier) + end + end + + private + + # Builds alias rows for every identifier this project has ever used at the given sequence (including the current one). + # This also includes "ghost identifiers" -- i.e. those that weren't ever actually generated, but should work + # as a historical alias (e.g. OLDPROJ-42 should work even if WP #42 was created after rename to NEWPROJ) + def alias_rows_for_sequence_number(seq) + project.slugs + .pluck(:slug) + .map { |prefix| { identifier: "#{prefix}-#{seq}", work_package_id: id } } + end +end diff --git a/app/models/work_package_semantic_alias.rb b/app/models/work_package_semantic_alias.rb new file mode 100644 index 00000000000..e85e56cbad2 --- /dev/null +++ b/app/models/work_package_semantic_alias.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Maps a semantic identifier (e.g. "PROJ-42") to a work package. +# This acts as a registry of all semantic identifiers for a work package, +# including both the current identifier and any retired ones created by moves +# or project renames. The current identifier is also stored directly on +# work_packages.identifier for faster access. +# +# The write side of the registry lives in WorkPackage::SemanticIdentifier: +# wp.allocate_and_register_semantic_id # on WP project change (call post-save) +# project.handle_semantic_rename(old_identifier) # on project identifier change +class WorkPackageSemanticAlias < ApplicationRecord + belongs_to :work_package, inverse_of: :semantic_aliases + + validates :identifier, presence: true, uniqueness: true + validates :work_package, presence: true +end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 2c0b4678cff..d4cbacef8ed 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -57,6 +57,7 @@ module Projects ret = super touch_on_custom_values_update + update_semantic_ids_on_identifier_change if Setting::WorkPackageIdentifier.semantic? notify_on_identifier_renamed send_update_notification update_wp_versions_on_parent_change @@ -69,6 +70,13 @@ module Projects model.touch if only_custom_values_updated? end + def update_semantic_ids_on_identifier_change + return unless memoized_changes["identifier"] + + old_identifier = memoized_changes["identifier"].first + model.handle_semantic_rename(old_identifier) + end + def notify_on_identifier_renamed return unless memoized_changes["identifier"] diff --git a/app/services/work_packages/update_service.rb b/app/services/work_packages/update_service.rb index 89c4aa2da5d..833c2744447 100644 --- a/app/services/work_packages/update_service.rb +++ b/app/services/work_packages/update_service.rb @@ -102,12 +102,17 @@ class WorkPackages::UpdateService < BaseServices::Update delete_relations(moved_work_packages) move_time_entries(moved_work_packages, work_package.project_id) move_work_package_memberships(moved_work_packages, work_package.project_id) + update_semantic_ids(moved_work_packages) if Setting::WorkPackageIdentifier.semantic? end if work_package.saved_change_to_type_id? reset_custom_values(work_package) end end + def update_semantic_ids(work_packages) + work_packages.each(&:allocate_and_register_semantic_id) + end + def delete_relations(work_packages) unless Setting.cross_project_work_package_relations? Relation diff --git a/config/locales/en.yml b/config/locales/en.yml index 45078b588d0..20b473571db 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1622,6 +1622,9 @@ en: activerecord: attributes: + work_package_semantic_alias: + identifier: "Identifier" + work_package: "Work package" jira_import: projects: "Projects" "import/jira": diff --git a/db/migrate/20260330100000_create_work_package_semantic_ids.rb b/db/migrate/20260330100000_create_work_package_semantic_ids.rb new file mode 100644 index 00000000000..0b9111137cd --- /dev/null +++ b/db/migrate/20260330100000_create_work_package_semantic_ids.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateWorkPackageSemanticIds < ActiveRecord::Migration[8.1] + def up + # Atomic counter for per-project WP sequence allocation + add_column :projects, :wp_sequence_counter, :integer, default: 0, null: false, if_not_exists: true + + # Per-project sequence number for semantic identifiers (e.g. PROJ-42) + add_column :work_packages, :sequence_number, :integer, if_not_exists: true + # Current semantic identifier stored directly on the work package (e.g. "PROJ-42") + add_column :work_packages, :identifier, :string, if_not_exists: true + + create_table :work_package_semantic_aliases, if_not_exists: true do |t| + t.string :identifier, null: false + t.references :work_package, null: false, foreign_key: true + t.timestamps + end + + # Unique identifier across all WPs (past and present) + add_index :work_package_semantic_aliases, :identifier, unique: true, if_not_exists: true + + # Fast lookup and uniqueness of the current semantic identifier (partial: excludes pre-backfill NULLs) + add_index :work_packages, :identifier, + unique: true, + where: "identifier IS NOT NULL", + if_not_exists: true + + # Enforce uniqueness of sequence numbers within a project (partial: excludes pre-backfill NULLs) + add_index :work_packages, %i[project_id sequence_number], + unique: true, + where: "sequence_number IS NOT NULL", + if_not_exists: true + end + + def down + drop_table :work_package_semantic_aliases, if_exists: true + remove_index :work_packages, %i[project_id sequence_number], if_exists: true + remove_column :work_packages, :identifier, if_exists: true + remove_column :work_packages, :sequence_number, if_exists: true + remove_column :projects, :wp_sequence_counter, if_exists: true + end +end diff --git a/spec/models/projects/semantic_identifier_spec.rb b/spec/models/projects/semantic_identifier_spec.rb new file mode 100644 index 00000000000..a57defc8a9e --- /dev/null +++ b/spec/models/projects/semantic_identifier_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Projects::SemanticIdentifier, with_settings: { work_packages_identifier: "semantic" } do + describe "#allocate_wp_semantic_identifier!" do + let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) } + + it "returns the allocated sequence number and semantic identifier" do + seq, identifier = project.allocate_wp_semantic_identifier! + expect(seq).to eq(1) + expect(identifier).to eq("PROJ-1") + end + + it "increments the counter on each successive call" do + project.allocate_wp_semantic_identifier! + seq, identifier = project.allocate_wp_semantic_identifier! + expect(seq).to eq(2) + expect(identifier).to eq("PROJ-2") + end + + it "persists the updated counter to the database" do + project.allocate_wp_semantic_identifier! + expect(project.reload.wp_sequence_counter).to eq(1) + end + + it "uses the current project identifier as the prefix" do + project.update_columns(identifier: "NEWPROJ") + _, identifier = project.allocate_wp_semantic_identifier! + expect(identifier).to eq("NEWPROJ-1") + end + end + + describe "#handle_semantic_rename" do + let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) } + let(:target_project) { create(:project, identifier: "OTHER", wp_sequence_counter: 0) } + let(:wp1) { create(:work_package, project:) } + let(:wp2) { create(:work_package, project:) } + + before do + wp1 + wp2 + project.update_columns(identifier: "NEWPROJ") + end + + it "preserves old-prefix aliases for resident WPs" do + project.handle_semantic_rename("PROJ") + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-2")).to be_present + end + + it "adds new-prefix aliases for resident WPs" do + project.handle_semantic_rename("PROJ") + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-2")).to be_present + end + + it "updates identifier on resident WPs to the new prefix" do + project.handle_semantic_rename("PROJ") + expect(wp1.reload.identifier).to eq("NEWPROJ-1") + expect(wp2.reload.identifier).to eq("NEWPROJ-2") + end + + it "is idempotent (safe to run twice)" do + project.handle_semantic_rename("PROJ") + expect { project.handle_semantic_rename("PROJ") }.not_to raise_error + end + + context "when records span multiple batches" do + let(:wp3) { create(:work_package, project:) } + + before { wp3 } + + it "processes all aliases across batch boundaries" do + project.handle_semantic_rename("PROJ", batch_size: 2) + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-2")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-3")).to be_present + end + + it "rewrites all WP identifiers across batch boundaries" do + project.handle_semantic_rename("PROJ", batch_size: 2) + expect(wp1.reload.identifier).to eq("NEWPROJ-1") + expect(wp2.reload.identifier).to eq("NEWPROJ-2") + expect(wp3.reload.identifier).to eq("NEWPROJ-3") + end + end + + context "when a WP has previously moved out of the project" do + before do + # Move wp1 to OTHER properly so "PROJ-1" ends up as an alias + wp1.update_columns(project_id: target_project.id) + wp1.allocate_and_register_semantic_id + end + + it "appends a new-prefix alias derived from the old alias row" do + project.handle_semantic_rename("PROJ") + expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present + end + + it "preserves the original old-prefix alias" do + project.handle_semantic_rename("PROJ") + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present + end + + it "does not update identifier on the moved-away WP" do + project.handle_semantic_rename("PROJ") + expect(wp1.reload.identifier).to eq("OTHER-1") + end + end + end +end diff --git a/spec/models/work_package/semantic_identifier_spec.rb b/spec/models/work_package/semantic_identifier_spec.rb new file mode 100644 index 00000000000..4061a7436a7 --- /dev/null +++ b/spec/models/work_package/semantic_identifier_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe WorkPackage::SemanticIdentifier do + let(:project) { create(:project, identifier: "MYPROJ") } + # Creating a WP in alphanumeric mode auto-registers it: gets sequence_number 1 and entry "MYPROJ-1". + let(:work_package) { create(:work_package, project:) } + + before do + allow(Setting::WorkPackageIdentifier).to receive_messages(semantic?: true, classic?: false) + work_package + end + + describe "after_create registration" do + it "assigns a sequence number" do + expect(work_package.reload.sequence_number).to eq(1) + end + + it "sets identifier on the work package" do + expect(work_package.reload.identifier).to eq("MYPROJ-1") + end + + it "creates a registry entry for the initial identifier" do + expect(work_package.semantic_aliases.pluck(:identifier)).to contain_exactly("MYPROJ-1") + end + + it "increments the counter for each successive WP" do + wp2 = create(:work_package, project:) + expect(wp2.reload.sequence_number).to eq(2) + expect(wp2.reload.identifier).to eq("MYPROJ-2") + end + end + + describe "WorkPackage.find_by_id_or_identifier" do + context "with a numeric param" do + it "finds by primary key" do + expect(WorkPackage.find_by_id_or_identifier(work_package.id.to_s)).to eq(work_package) + end + + it "returns nil for unknown id" do + expect(WorkPackage.find_by_id_or_identifier("9999999")).to be_nil + end + end + + context "with a semantic param" do + context "when the identifier matches work_packages.identifier (fast path)" do + it "finds directly via identifier without hitting the alias table" do + expect(WorkPackage.find_by_id_or_identifier("MYPROJ-1")).to eq(work_package) + end + + it "returns nil when no WP has that identifier and no alias or fallback matches" do + expect(WorkPackage.find_by_id_or_identifier("MYPROJ-999")).to be_nil + end + end + + context "when the identifier is a historic alias (alias table path)" do + it "resolves historic entries via the alias registry" do + WorkPackageSemanticAlias.create!(identifier: "OLDPROJ-1", work_package:) + expect(WorkPackage.find_by_id_or_identifier("OLDPROJ-1")).to eq(work_package) + end + + it "resolves when identifier differs but an alias row exists" do + work_package.update_columns(identifier: "OTHER-99") + expect(WorkPackage.find_by_id_or_identifier("MYPROJ-1")).to eq(work_package) + end + end + + it "returns nil for unknown sequence" do + expect(WorkPackage.find_by_id_or_identifier("MYPROJ-999")).to be_nil + end + + it "returns nil for unknown project prefix" do + expect(WorkPackage.find_by_id_or_identifier("NOPE-1")).to be_nil + end + + it "returns nil for an unparseable string" do + expect(WorkPackage.find_by_id_or_identifier("not-an-identifier!")).to be_nil + end + end + + context "with visibility scoping" do + let(:member_user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) } + let(:non_member_user) { create(:user) } + + it "returns the WP for a user who can see it" do + expect(WorkPackage.visible(member_user).find_by_id_or_identifier("MYPROJ-1")).to eq(work_package) + end + + it "returns nil for a user who cannot see it" do + expect(WorkPackage.visible(non_member_user).find_by_id_or_identifier("MYPROJ-1")).to be_nil + end + + it "also scopes numeric lookup" do + expect(WorkPackage.visible(non_member_user).find_by_id_or_identifier(work_package.id.to_s)).to be_nil + end + end + end + + describe "WorkPackage.find_by_id_or_identifier!" do + it "returns the work package when found" do + expect(WorkPackage.find_by_id_or_identifier!(work_package.id.to_s)).to eq(work_package) + end + + it "raises ActiveRecord::RecordNotFound when not found" do + expect { WorkPackage.find_by_id_or_identifier!("MYPROJ-999") } + .to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe "#allocate_and_register_semantic_id" do + let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) } + let(:target_project) { create(:project, identifier: "OTHER", wp_sequence_counter: 0) } + + before do + work_package.update_columns(project_id: target_project.id) + end + + it "preserves the old identifier as a historical alias (written at creation)" do + work_package.allocate_and_register_semantic_id + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present + end + + it "updates sequence_number and identifier to the target project's values" do + work_package.allocate_and_register_semantic_id + expect(work_package.reload.sequence_number).to eq(1) + expect(work_package.reload.identifier).to eq("OTHER-1") + end + + it "adds the new identifier to the alias table" do + work_package.allocate_and_register_semantic_id + expect(WorkPackageSemanticAlias.find_by(identifier: "OTHER-1")).to be_present + end + end +end diff --git a/spec/models/work_package_semantic_alias_spec.rb b/spec/models/work_package_semantic_alias_spec.rb new file mode 100644 index 00000000000..4d7f64a5572 --- /dev/null +++ b/spec/models/work_package_semantic_alias_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe WorkPackageSemanticAlias do + let(:work_package) { create(:work_package) } + + describe "validations" do + it "is valid with an identifier and work_package" do + record = described_class.new(identifier: "PROJ-1", work_package:) + expect(record).to be_valid + end + + it "requires identifier" do + record = described_class.new(work_package:) + expect(record).not_to be_valid + expect(record.errors[:identifier]).to be_present + end + + it "requires work_package" do + record = described_class.new(identifier: "PROJ-1") + expect(record).not_to be_valid + expect(record.errors[:work_package]).to be_present + end + + it "enforces identifier uniqueness" do + described_class.create!(identifier: "PROJ-1", work_package:) + duplicate = described_class.new(identifier: "PROJ-1", work_package:) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:identifier]).to be_present + end + end + + describe "associations" do + it "belongs to a work_package" do + record = described_class.create!(identifier: "PROJ-1", work_package:) + expect(record.work_package).to eq(work_package) + end + end + + describe WorkPackage do + describe "#semantic_aliases" do + let(:wp) { create(:work_package) } + + it "exposes all registry entries" do + entry1 = WorkPackageSemanticAlias.create!(identifier: "PROJ-1", work_package: wp) + entry2 = WorkPackageSemanticAlias.create!(identifier: "OLD-1", work_package: wp) + + expect(wp.semantic_aliases).to contain_exactly(entry1, entry2) + end + end + end +end diff --git a/spec/services/work_packages/semantic_ids/integration_spec.rb b/spec/services/work_packages/semantic_ids/integration_spec.rb new file mode 100644 index 00000000000..551d28496df --- /dev/null +++ b/spec/services/work_packages/semantic_ids/integration_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +# End-to-end tests verifying that the registry is maintained correctly through +# the full service stack: CreateService, UpdateService, and Projects::UpdateService. +RSpec.describe "SemanticIds registry integration", type: :model do + shared_let(:role) do + create(:project_role, + permissions: %i[view_work_packages add_work_packages edit_work_packages move_work_packages edit_project]) + end + shared_let(:user) { create(:user) } + + # Projects with uppercase identifiers require alphanumeric mode — stub before creating. + let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) } + let(:target_project) { create(:project, identifier: "DEST", wp_sequence_counter: 0) } + + before do + allow(Setting::WorkPackageIdentifier).to receive_messages(semantic?: true, classic?: false) + create(:member, principal: user, project:, roles: [role]) + create(:member, principal: user, project: target_project, roles: [role]) + login_as(user) + end + + describe "WP creation via CreateService" do + let(:attributes) do + { + subject: "A new task", + project:, + type: project.types.first, + status: create(:default_status), + priority: create(:default_priority) + } + end + + it "assigns a sequence number, sets identifier, and registers all project-prefix aliases" do + result = WorkPackages::CreateService.new(user:).call(**attributes) + expect(result).to be_success + + wp = result.result + expect(wp.sequence_number).to eq(1) + expect(wp.identifier).to eq("PROJ-1") + expect(WorkPackageSemanticAlias.find_by!(work_package: wp).identifier).to eq("PROJ-1") + end + + it "increments the counter with each new WP" do + 2.times { WorkPackages::CreateService.new(user:).call(**attributes) } + expect(project.reload.wp_sequence_counter).to eq(2) + expect(WorkPackageSemanticAlias.where("identifier LIKE 'PROJ-%'").count).to eq(2) + end + end + + describe "WP move via UpdateService" do + let!(:work_package) do + # after_create auto-registers as PROJ-1; rename entry to PROJ-5 to simulate an established WP + create(:work_package, project:).tap do |wp| + wp.update_columns(sequence_number: 5, identifier: "PROJ-5") + wp.semantic_aliases.update_all(identifier: "PROJ-5") + project.update_columns(wp_sequence_counter: 5) + end + end + + it "preserves the old identifier and appends a new one in the target project" do + WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project) + + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-5")).to be_present + expect(work_package.reload.identifier).to start_with("DEST-") + end + + it "old identifier still resolves to the WP" do + WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project) + expect(WorkPackage.find_by_id_or_identifier("PROJ-5")).to eq(work_package) + end + + it "new identifier also resolves to the WP" do + WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project) + expect(WorkPackage.find_by_id_or_identifier(work_package.reload.identifier)).to eq(work_package) + end + end + + describe "Project rename via Projects::UpdateService" do + # after_create auto-registers wp1 as "PROJ-1" (seq=1) and wp2 as "PROJ-2" (seq=2) + let!(:wp1) { create(:work_package, project:) } + let!(:wp2) { create(:work_package, project:) } + + it "updates identifier on WPs and inserts new-prefix aliases" do + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + + expect(wp1.reload.identifier).to eq("RENAMED-1") + expect(wp2.reload.identifier).to eq("RENAMED-2") + expect(WorkPackageSemanticAlias.find_by(identifier: "RENAMED-1")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "RENAMED-2")).to be_present + end + + it "preserves old-prefix entries for historic resolution" do + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present + expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-2")).to be_present + end + + it "old identifiers still resolve to the correct WPs" do + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + + expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1) + expect(WorkPackage.find_by_id_or_identifier("PROJ-2")).to eq(wp2) + end + + it "new identifiers resolve to the correct WPs" do + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + + expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1) + expect(WorkPackage.find_by_id_or_identifier("RENAMED-2")).to eq(wp2) + end + + it "old prefix resolves for WPs created after the rename" do + # wp3 is created after the rename; register_identifier inserts both RENAMED-3 + # (current prefix) and PROJ-3 (historical slug), so both resolve via the alias table. + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + wp3 = create(:work_package, project: project.reload) + + expect(WorkPackage.find_by_id_or_identifier("RENAMED-3")).to eq(wp3) + expect(WorkPackage.find_by_id_or_identifier("PROJ-3")).to eq(wp3) + end + end + + describe "rename + move combinations" do + let!(:wp1) { create(:work_package, project:) } # auto-registers as PROJ-1 + + it "move then rename: old WP identifier resolves under new project prefix" do + # WP moves to DEST first (retires PROJ-1, creates DEST-1) + WorkPackages::UpdateService.new(user:, model: wp1).call(project: target_project) + # PROJ is then renamed to RENAMED (bulk-inserts RENAMED-1 from the retired PROJ-1 row) + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + + expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1) + end + + it "rename then move: both old identifiers resolve after the WP moves" do + # PROJ renamed to RENAMED (appends RENAMED-1 registry row, updates identifier) + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + # WP moves to DEST (appends DEST-1 registry row, updates identifier) + WorkPackages::UpdateService.new(user:, model: wp1.reload).call(project: target_project) + + expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1) + expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1) + end + + it "rename then new WP then move: pre-rename identifier resolves via alias table" do + # PROJ renamed to RENAMED; wp1 gets alias PROJ-1, identifier becomes RENAMED-1 + Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED") + # wp2 is created in the now-RENAMED project; register_identifier inserts both + # RENAMED-2 (current prefix) and PROJ-2 (historical slug) into the alias table + wp2 = create(:work_package, project: project.reload) + # wp2 moves to DEST — old identifier RENAMED-2 kept as alias, gets DEST-1 + WorkPackages::UpdateService.new(user:, model: wp2).call(project: target_project) + + expect(WorkPackage.find_by_id_or_identifier("PROJ-2")).to eq(wp2) + end + end + + describe "multiple moves" do + let(:project_c) { create(:project, identifier: "PROJC", wp_sequence_counter: 0) } + let!(:wp1) { create(:work_package, project:) } # auto-registers as PROJ-1 + + before do + create(:member, principal: user, project: project_c, roles: [role]) + end + + it "all intermediate identifiers resolve after WP moves PROJ → DEST → PROJC" do + WorkPackages::UpdateService.new(user:, model: wp1).call(project: target_project) + dest_identifier = wp1.reload.identifier + + WorkPackages::UpdateService.new(user:, model: wp1.reload).call(project: project_c) + projc_identifier = wp1.reload.identifier + + expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1) + expect(WorkPackage.find_by_id_or_identifier(dest_identifier)).to eq(wp1) + expect(WorkPackage.find_by_id_or_identifier(projc_identifier)).to eq(wp1) + end + end +end