[#73523] Implement WorkPackage semantic ID allocation system

This commit is contained in:
Tomas Hykel
2026-04-01 17:25:19 +02:00
parent 8c0d0c28b1
commit c4debc8aaa
14 changed files with 934 additions and 0 deletions
+4
View File
@@ -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"
+1
View File
@@ -39,6 +39,7 @@ class Project < ApplicationRecord
include Projects::WorkPackageCustomFields
include Projects::CreationWizard
include Projects::Identifier
include Projects::SemanticIdentifier
include ::Scopes::Scoped
@@ -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
+1
View File
@@ -29,6 +29,7 @@
#++
class WorkPackage < ApplicationRecord
include WorkPackage::SemanticIdentifier
include WorkPackage::Validations
include WorkPackage::SchedulingRules
include WorkPackage::StatusTransitions
@@ -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
+45
View File
@@ -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
+8
View File
@@ -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"]
@@ -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
+3
View File
@@ -1622,6 +1622,9 @@ en:
activerecord:
attributes:
work_package_semantic_alias:
identifier: "Identifier"
work_package: "Work package"
jira_import:
projects: "Projects"
"import/jira":
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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