Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e369227
Add case-insensitive project identifier storage
akabiru Mar 20, 2026
91362fb
Improve PreviewQuery error detection and update MCP descriptions
akabiru Mar 20, 2026
69818ad
Extract `ProblematicIdentifiers` from `PreviewQuery`
akabiru Mar 20, 2026
30f9c7c
Wire up reserved identifiers with FriendlyId slug history
akabiru Mar 20, 2026
6c84213
Simplify exclusion_set to plain Set, remove ExclusionSet class
akabiru Mar 20, 2026
ac50f72
Allow underscores in PreviewQuery identifier classification
akabiru Mar 23, 2026
87750c5
Revert slug query to plain equality in identifier_not_historically_re…
akabiru Mar 23, 2026
14a84b2
Fix error_too_long translation and tighten MCP search spec assertions
akabiru Mar 23, 2026
bfeee22
Remove case-transforming normalizes and parse_friendly_id override
akabiru Mar 23, 2026
f2f5caf
Revert MCP search tool case-insensitivity changes
akabiru Mar 23, 2026
2318522
Revert acts_as_url overrides (force_downcase, post_process)
akabiru Mar 23, 2026
5c7bed1
Apply underscore fix to ProblematicIdentifiers after rebase
akabiru Mar 23, 2026
2bf5740
Cleanup tests
akabiru Mar 23, 2026
d835427
Add shoulda-matcher assertions for identifier validations and indexes
akabiru Mar 23, 2026
674b5cd
Deduplicate case-colliding identifiers before adding unique index
akabiru Mar 23, 2026
db932c0
Harden deduplication against secondary collisions
akabiru Mar 24, 2026
2803d2d
Use case-insensitive comparison for reserved identifier exclusion
akabiru Mar 24, 2026
f0ff803
Cleanup tests and add lossy rollback note
akabiru Mar 24, 2026
4812974
Upcase exclusion set for case-insensitive slug matching
akabiru Mar 25, 2026
1648e53
Merge branch 'dev' into open-point/73149-how-do-we-handle-project-ide…
akabiru Mar 25, 2026
d801728
Extract identifier_numeric_format for symmetry with alphanumeric vali…
akabiru Mar 25, 2026
243c168
Fix error path in alphanumeric identifier API specs
akabiru Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 42 additions & 20 deletions app/models/projects/identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,22 @@ module Projects::Identifier
if: -> { Setting::WorkPackageIdentifier.alphanumeric? && identifier.blank? }

### ID validators
# Validators for the legacy underscored identifier format (e.g. "project_one")
# Shared validators for all identifier formats
validates :identifier,
presence: true,
uniqueness: { case_sensitive: true },
uniqueness: { case_sensitive: false },
length: { maximum: IDENTIFIER_MAX_LENGTH },
exclusion: RESERVED_IDENTIFIERS,
if: ->(p) { p.persisted? || p.identifier.present? }
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
validates :identifier,
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
if: ->(p) {
p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.numeric?
}

# Validators for the semantic identifier format
validates :identifier,
format: { with: /\A[A-Z]/, message: :must_start_with_letter },
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
validates :identifier,
format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters },
length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH },
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
# Validators for the numeric (legacy) identifier format (e.g. "my-project", "project_one")
validate :identifier_numeric_format,
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.numeric? }

# Validators for the semantic (alphanumeric) identifier format (e.g. "PROJ1")
validate :identifier_alphanumeric_format,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we already switch to semantic across the code? (We can still discuss the name further, but for now we should at least remain consistent.)

Of course this is probably better via a follow-up PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we haven't found naming scheme that would sit 100% well, but I would definitely at least get rid of "numeric/alphanumeric", since the "numeric" part does not really click within the context of this file.

Copy link
Member Author

@akabiru akabiru Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took the "numeric/alphanumeric" wording to be consistent with Setting::WorkPackageIdentifier.{numeric?,alphanumeric?}- which should make it easier to rename once we decide/adopt new names. I definitely prefer to keep the renaming isolated into a separate PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. We should try to be consistent somehow. But renaming the setting probably requires a database migration 😅 so maybe it's easier if we settle on numeric / alphanumeric even if I don't like it as much 🥲

if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }

validate :identifier_not_reserved, if: -> { identifier.present? }

# Complements the uniqueness validation above: once an identifier has been used by a
# project, it remains reserved for that project even after the project moves to a new
Expand Down Expand Up @@ -130,14 +124,42 @@ def validation_context

private

# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only
# as that would clash with the numeric id.
def identifier_numeric_format
unless identifier.match?(/\A(?!^\d+\z)[a-z0-9\-_]+\z/)
errors.add(:identifier, :invalid)
end
end

def identifier_alphanumeric_format
unless identifier.match?(/\A[A-Z]/)
errors.add(:identifier, :must_start_with_letter)
return
end

errors.add(:identifier, :no_special_characters) unless identifier.match?(/\A[A-Z][A-Z0-9_]*\z/)
if identifier.length > SEMANTIC_IDENTIFIER_MAX_LENGTH
errors.add(:identifier, :too_long, count: SEMANTIC_IDENTIFIER_MAX_LENGTH)
end
end

def identifier_not_reserved
if RESERVED_IDENTIFIERS.include?(identifier&.downcase)
errors.add(:identifier, :exclusion)
end
end

# Checks friendly_id_slugs for any project that previously used this identifier and
# has since changed it. It allows to switch back to an identifier the project itself
# has used before.
# has since changed it. It allows a project to switch back to an identifier it has
# used before. Uses LOWER() because slugs may be stored in a different case than the
# incoming identifier (e.g. old lowercase slug vs new uppercase alphanumeric identifier).
def identifier_not_historically_reserved
return if errors.any? { |error| error.attribute == :identifier && error.type == :taken }

already_existing = FriendlyId::Slug
.where(slug: identifier, sluggable_type: self.class.to_s)
.where("LOWER(slug) = LOWER(?)", identifier)
.where(sluggable_type: self.class.to_s)
.where.not(sluggable_id: id)
.exists?

Expand Down
56 changes: 18 additions & 38 deletions app/services/work_packages/identifier_autofix/preview_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,54 +35,34 @@ class PreviewQuery
DISPLAY_COUNT = 5

def call
total = problematic_scope.count
preview = problematic_scope
.select(:id, :name, :identifier)
.limit(DISPLAY_COUNT)
.to_a
analysis = ProblematicIdentifiers.new
total_count = analysis.count
projects_data = build_projects_data(analysis)

suggestions = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.call(
preview,
exclude: reserved_identifiers | in_use_identifiers
)

projects_data = suggestions.map do |entry|
entry.merge(error_reason: error_reason(entry[:current_identifier]))
end

Result.new(projects_data:, total_count: total)
Result.new(projects_data:, total_count:)
end

private

def problematic_scope
@problematic_scope ||= Project.where(
"length(identifier) > ? OR identifier ~ ?",
ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max],
"[^a-zA-Z0-9_]"
)
end

def error_reason(identifier)
if identifier.length > ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
:too_long
elsif identifier.match?(/[^a-zA-Z0-9_]/)
:special_characters
elsif in_use_identifiers.include?(identifier)
:in_use
elsif reserved_identifiers.include?(identifier)
:reserved
def build_projects_data(analysis)
generate_suggestions(analysis).map do |entry|
entry.merge(error_reason: analysis.error_reason(entry[:current_identifier]))
end
end

def in_use_identifiers
@in_use_identifiers ||= Project.where.not(id: problematic_scope.select(:id)).pluck(:identifier).to_set
def generate_suggestions(analysis)
ProjectIdentifierSuggestionGenerator.call(
preview_projects(analysis.scope),
exclude: analysis.exclusion_set.to_set(&:upcase)
)
end

def reserved_identifiers
# TODO: OldProjectIdentifier.pluck(:identifier).to_set
# once the OldProjectIdentifier model and migration are added.
Set.new
def preview_projects(scope)
scope
.select(:id, :name, :identifier)
.order(:id)
.limit(DISPLAY_COUNT)
.to_a
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 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 WorkPackages
module IdentifierAutofix
# Identifies projects whose identifiers violate the semantic identifier format
# and provides classification and exclusion sets for suggestion generation.
#
# For main use by admin UI preview and batch migration job.
#
# == Performance notes
#
# * +#exclusion_set+ loads all non-problematic identifiers and historical slugs
# into memory. Fine for a one-off admin migration; if this ever becomes a hot
# path, consider a DB-backed exclusion check instead.
#
# * The regex scope conditions (+identifier ~ ?+) and +UPPER(identifier)+ won't
# hit a regular index. If queries get slow on large tables, a functional index
# on +UPPER(identifier)+ or a +pg_trgm+ GIN index would help.
#
#
class ProblematicIdentifiers
# Priority-ordered format rules for identifier classification.
FORMAT_RULES = [
[:too_long, ->(id, max) { id.length > max }],
[:numerical, ->(id, _) { id.match?(/\A\d+\z/) }],
[:starts_with_number, ->(id, _) { id.match?(/\A\d/) }],
[:special_characters, ->(id, _) { id.match?(/[^a-zA-Z0-9_]/) }],
[:not_fully_uppercased, ->(id, _) { id != id.upcase }]
].freeze

def scope
@scope ||= exceeds_max_length
.or(contains_non_alphanumeric)
.or(starts_with_digit)
.or(not_fully_uppercased)
end

delegate :count, to: :scope

# Returns a symbol classifying why the identifier is problematic.
# Must handle all identifiers matched by #scope.
def error_reason(identifier)
format_error_reason(identifier) || collision_error_reason(identifier) || :unknown
end

# Returns a Set of identifiers that must not be suggested for new assignments.
# Combines currently active identifiers from non-problematic projects with
# historically reserved identifiers from FriendlyId slug history.
def exclusion_set
reserved_identifiers | in_use_identifiers
end

private

def exceeds_max_length = Project.where("length(identifier) > ?", max_identifier_length)
def contains_non_alphanumeric = Project.where("identifier ~ ?", "[^a-zA-Z0-9_]")
def starts_with_digit = Project.where("identifier ~ ?", "^[0-9]")
def not_fully_uppercased = Project.where("identifier != UPPER(identifier)")

def max_identifier_length = ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]

def format_error_reason(identifier)
FORMAT_RULES.each do |reason, check|
return reason if check.call(identifier, max_identifier_length)
end
nil
end

def collision_error_reason(identifier)
if in_use_identifiers.include?(identifier)
:in_use
elsif reserved_identifiers.include?(identifier)
:reserved
end
end

def in_use_identifiers
@in_use_identifiers ||= Project.where.not(id: scope.select(:id)).pluck(:identifier).to_set
end

def reserved_identifiers
@reserved_identifiers ||= FriendlyId::Slug
.where(sluggable_type: Project.name)
.where("LOWER(slug) NOT IN (SELECT LOWER(identifier) FROM projects)")
.pluck(:slug)
.to_set
end
end
end
end
6 changes: 5 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,14 @@ en:
label_autofixed_suggestion: Future identifier
label_example_work_package_id: Example work package ID
autofix_preview:
error_too_long: Has to be fewer than 5 characters
error_too_long: Has to be 10 characters or fewer
error_numerical: Cannot be purely numerical
error_starts_with_number: Cannot start with a number
error_special_characters: Special characters not allowed
error_not_fully_uppercased: Must be uppercase
error_in_use: Already in use as another project's active handle
error_reserved: Reserved by another project's handle history
error_unknown: Needs manual review
remaining_projects:
one: "... 1 more project"
other: "... %{count} more projects"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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 AddCaseInsensitiveUniquenessForProjectIdentifiers < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def up
deduplicate_case_colliding_identifiers
remove_index :projects, :identifier, unique: true, algorithm: :concurrently, if_exists: true
add_index :projects, "LOWER(identifier)",
unique: true,
name: "index_projects_on_lower_identifier",
algorithm: :concurrently,
if_not_exists: true
end

# Note: does not undo identifier renames from deduplication. Suffixed identifiers
# (e.g. "FOO_2") remain valid and unique under the restored case-sensitive index.
def down
remove_index :projects, name: "index_projects_on_lower_identifier", algorithm: :concurrently, if_exists: true
add_index :projects, :identifier, unique: true, algorithm: :concurrently
end

private

# Resolves any existing case-colliding identifiers (e.g. "Foo" and "foo") so that
# the unique LOWER(identifier) index can be created without violation errors.
# The oldest project (by id) keeps its identifier; duplicates get a "_N" suffix.
#
# The NOT EXISTS guard skips rows where the suffixed identifier would itself collide.
# In practice this is extremely unlikely (requires both case-colliding identifiers
# AND a pre-existing "_N" variant). If it occurs, the subsequent index creation
# will fail, surfacing the issue for manual resolution.
def deduplicate_case_colliding_identifiers
execute <<~SQL.squish
UPDATE projects SET identifier = projects.identifier || '_' || counter.rn
FROM (
SELECT id, row_number() OVER (PARTITION BY LOWER(identifier) ORDER BY id) AS rn
FROM projects
) AS counter
WHERE projects.id = counter.id AND counter.rn > 1
AND NOT EXISTS (
SELECT 1 FROM projects p2
WHERE LOWER(p2.identifier) = LOWER(projects.identifier || '_' || counter.rn)
);
SQL
end
end
2 changes: 1 addition & 1 deletion db/migrate/tables/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def self.table(migration) # rubocop:disable Metrics/AbcSize

t.index :lft, name: "index_projects_on_lft"
t.index :rgt, name: "index_projects_on_rgt"
t.index :identifier, unique: true
t.index "LOWER(identifier)", unique: true, name: "index_projects_on_lower_identifier"
t.index %i[lft rgt]
end
end
Expand Down
1 change: 1 addition & 0 deletions spec/features/projects/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@

fill_in "Name", with: "Flight Planning Algorithm"
find("body").click
expect(page).to have_field "Identifier", with: "FPA"

expect(page).to have_field "Identifier", with: "FPA"
fill_in "Identifier", with: "3INVALID"
Expand Down
1 change: 0 additions & 1 deletion spec/models/project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -606,5 +606,4 @@
end
end
end

end
Loading
Loading