From fa21d7d1d359d1d4152dddd5c835c645df13aafd Mon Sep 17 00:00:00 2001
From: Swell <5782559+sultan@users.noreply.github.com>
Date: Wed, 19 Nov 2025 21:19:41 +0100
Subject: [PATCH] [MNG-7559] Fix version comparison with case insensitive
lexical order
---
.../versioning/ComparableVersion.java | 114 +++++++++++-------
.../versioning/ComparableVersionTest.java | 39 ++++--
.../DefaultArtifactVersionTest.java | 2 +-
3 files changed, 97 insertions(+), 58 deletions(-)
diff --git a/compat/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java b/compat/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java
index df3c0345288f..bd75c2f12829 100644
--- a/compat/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java
+++ b/compat/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java
@@ -23,11 +23,12 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
-import java.util.Properties;
/**
*
@@ -41,20 +42,21 @@
* 1.0alpha1 => [1, [alpha, 1]]
*
Unlimited number of version components,
* Version components in the text can be digits or strings,
- * Strings are checked for well-known qualifiers, and the qualifier ordering is used for version ordering.
- * Well-known qualifiers (case-insensitive) are, in order from least to greatest:
- * alpha or a
- * beta or b
- * milestone or m
- * rc or cr
- * snapshot
- * ga or final
- * sp
- *
- * Unknown qualifiers are considered after known qualifiers,
- * with lexical order (case-insensitive in the English locale).
- * ga and final sort the same as not having a qualifier.
- *
+ *
+ * Following semver rules is encouraged, and some qualifiers are discouraged (case insensitive):
+ *
+ * - The usage of 'CR' qualifier is discouraged. Use 'RC' instead.
+ * - The usage of 'Final', 'GA', and 'Release' qualifiers is discouraged. Use no qualifier instead.
+ * - The usage of 'SP' qualifier is discouraged. Increment the patch version instead.
+ *
+ * String qualifiers are ordered lexically (case insensitive in the English locale), with the following exceptions:
+ *
+ * alpha = a < beta = b < milestone = m < rc = cr
+ * < snapshot < '' = final = ga = release < sp
+ * - Other qualifiers are ordered lexically (case insensitive in the English locale),
+ * and considered less than Snapshot and Release.
+ *
+ *
* A hyphen usually precedes a qualifier, and is always less important than digits/number. For example
* {@code 1.0.RC2 < 1.0-RC3 < 1.0.1}; but prefer {@code 1.0.0-RC2} over {@code 1.0.0.RC2}, and more
* generally: {@code 1.0.X2 < 1.0-X3 < 1.0.1} for any string {@code X}; but prefer {@code 1.0.0-X1}
@@ -295,41 +297,37 @@ public String toString() {
* Represents a string in the version item list, usually a qualifier.
*/
private static class StringItem implements Item {
- private static final List QUALIFIERS =
- Arrays.asList("alpha", "beta", "milestone", "rc", "snapshot", "", "sp");
- private static final List RELEASE_QUALIFIERS = Arrays.asList("ga", "final", "release");
+ private static final List QUALIFIERS = Arrays.asList("snapshot", "", "sp");
+ private static final List RELEASE_QUALIFIERS = Arrays.asList("final", "ga", "release");
- private static final Properties ALIASES = new Properties();
+ private static final Map ALIASES = new HashMap<>(4);
static {
ALIASES.put("cr", "rc");
+ ALIASES.put("final", "");
+ ALIASES.put("ga", "");
+ ALIASES.put("release", "");
}
/**
- * A comparable value for the empty-string qualifier. This one is used to determine if a given qualifier makes
+ * An index value for the empty-string qualifier. This one is used to determine if a given qualifier makes
* the version older than one without a qualifier, or more recent.
*/
- private static final String RELEASE_VERSION_INDEX = String.valueOf(QUALIFIERS.indexOf(""));
+ private static final int RELEASE_VERSION_INDEX = QUALIFIERS.indexOf("");
private final String value;
StringItem(String value, boolean followedByDigit) {
if (followedByDigit && value.length() == 1) {
// a1 = alpha-1, b1 = beta-1, m1 = milestone-1
- switch (value.charAt(0)) {
- case 'a':
- value = "alpha";
- break;
- case 'b':
- value = "beta";
- break;
- case 'm':
- value = "milestone";
- break;
- default:
- }
+ value = switch (value.charAt(0)) {
+ case 'a' -> "alpha";
+ case 'b' -> "beta";
+ case 'm' -> "milestone";
+ default -> value;
+ };
}
- this.value = ALIASES.getProperty(value, value);
+ this.value = ALIASES.getOrDefault(value, value);
}
@Override
@@ -351,27 +349,55 @@ public boolean isNull() {
*
* @param qualifier
* @return an equivalent value that can be used with lexical comparison
+ * @deprecated Use {@link #compareQualifiers(String, String)} instead
*/
+ @Deprecated
public static String comparableQualifier(String qualifier) {
+ // Just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for
+ // -1 or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first
+ // character, so this is still fast. If more characters are needed then it requires a lexical sort anyway.
+
if (RELEASE_QUALIFIERS.contains(qualifier)) {
- return String.valueOf(QUALIFIERS.indexOf(""));
+ qualifier = "";
}
- int i = QUALIFIERS.indexOf(qualifier);
+ int index = QUALIFIERS.indexOf(qualifier) + 1;
- // Just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for
- // -1
- // or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first
- // character,
- // so this is still fast. If more characters are needed then it requires a lexical sort anyway.
- return i == -1 ? (QUALIFIERS.size() + "-" + qualifier) : String.valueOf(i);
+ return index == 0 ? ("0-" + qualifier) : String.valueOf(index);
+ }
+
+ /**
+ * Compare the qualifiers of two artifact versions.
+ *
+ * @param qualifier1 qualifier of first artifact
+ * @param qualifier2 qualifier of second artifact
+ * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or
+ * greater than the second
+ */
+ public static int compareQualifiers(String qualifier1, String qualifier2) {
+ if (RELEASE_QUALIFIERS.contains(qualifier1)) {
+ qualifier1 = "";
+ }
+ if (RELEASE_QUALIFIERS.contains(qualifier2)) {
+ qualifier2 = "";
+ }
+ int i1 = QUALIFIERS.indexOf(qualifier1);
+ int i2 = QUALIFIERS.indexOf(qualifier2);
+
+ // if both pre-release, then use natural lexical ordering
+ if (i1 == -1 && i2 == -1) {
+ return qualifier1.compareTo(qualifier2);
+ }
+
+ // 'other qualifier' < 'snapshot' < '' < 'sp'
+ return Integer.compare(i1, i2);
}
@Override
public int compareTo(Item item) {
if (item == null) {
// 1-rc < 1, 1-ga > 1
- return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX);
+ return Integer.compare(QUALIFIERS.indexOf(value), RELEASE_VERSION_INDEX);
}
switch (item.getType()) {
case INT_ITEM:
@@ -380,7 +406,7 @@ public int compareTo(Item item) {
return -1; // 1.any < 1.1 ?
case STRING_ITEM:
- return comparableQualifier(value).compareTo(comparableQualifier(((StringItem) item).value));
+ return compareQualifiers(value, ((StringItem) item).value);
case COMBINATION_ITEM:
int result = this.compareTo(((CombinationItem) item).getStringPart());
diff --git a/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/ComparableVersionTest.java b/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/ComparableVersionTest.java
index 11561aa16689..daac9b5313ad 100644
--- a/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/ComparableVersionTest.java
+++ b/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/ComparableVersionTest.java
@@ -44,13 +44,16 @@ private ComparableVersion newComparable(String version) {
}
private static final String[] VERSIONS_QUALIFIER = {
+ "1-abc",
"1-alpha2snapshot",
"1-alpha2",
"1-alpha-123",
"1-beta-2",
"1-beta123",
+ "1-def",
"1-m2",
"1-m11",
+ "1-pom-1",
"1-rc",
"1-cr2",
"1-rc123",
@@ -59,9 +62,6 @@ private ComparableVersion newComparable(String version) {
"1-sp",
"1-sp2",
"1-sp123",
- "1-abc",
- "1-def",
- "1-pom-1",
"1-1-snapshot",
"1-1",
"1-2",
@@ -69,8 +69,8 @@ private ComparableVersion newComparable(String version) {
};
private static final String[] VERSIONS_NUMBER = {
- "2.0", "2.0.a", "2-1", "2.0.2", "2.0.123", "2.1.0", "2.1-a", "2.1b", "2.1-c", "2.1-1", "2.1.0.1", "2.2",
- "2.123", "11.a2", "11.a11", "11.b2", "11.b11", "11.m2", "11.m11", "11", "11.a", "11b", "11c", "11m"
+ "2.0.a", "2.0", "2-1", "2.0.2", "2.0.123", "2.1-a", "2.1b", "2.1-c", "2.1.0", "2.1-1", "2.1.0.1", "2.2",
+ "2.123", "11.a", "11.a2", "11.a11", "11b", "11.b2", "11.b11", "11c", "11m", "11.m2", "11.m11", "11"
};
private void checkVersionsOrder(String[] versions) {
@@ -212,7 +212,7 @@ void testVersionComparing() {
checkVersionsOrder("2.0-1", "2.0.1");
checkVersionsOrder("2.0.1-klm", "2.0.1-lmn");
- checkVersionsOrder("2.0.1", "2.0.1-xyz");
+ checkVersionsOrder("2.0.1-xyz", "2.0.1"); // now 2.0.1-xyz < 2.0.1 as of MNG-7559
checkVersionsOrder("2.0.1", "2.0.1-123");
checkVersionsOrder("2.0.1-xyz", "2.0.1-123");
@@ -328,13 +328,9 @@ void testLexicographicOrder() {
*/
@Test
void testMng5568() {
- String a = "6.1.0";
- String b = "6.1.0rc3";
- String c = "6.1H.5-beta"; // this is the unusual version string, with 'H' in the middle
-
- checkVersionsOrder(b, a); // classical
- checkVersionsOrder(b, c); // now b < c, but before MNG-5568, we had b > c
- checkVersionsOrder(a, c);
+ checkVersionsOrder("6.1H.5-beta", "6.1.0rc3"); // now H < RC as of MNG-7559
+ checkVersionsOrder("6.1.0rc3", "6.1.0"); // classical
+ checkVersionsOrder("6.1H.5-beta", "6.1.0"); // transitivity
}
/**
@@ -458,6 +454,23 @@ void testReuse() {
assertEquals(c1, c2, "reused instance should be equivalent to new instance");
}
+ /**
+ * Test MNG-7559 edge cases
+ * -pfd < final, ga, release
+ * 2.0.1.MR < 2.0.1
+ * 9.4.1.jre16 > 9.4.1.jre16-preview
+ */
+ @Test
+ void testMng7559() {
+ // checking general cases
+ checkVersionsOrder(new String[] {"ab", "alpha", "beta", "cd", "ea", "milestone", "mr", "pfd", "preview", "RC"});
+ // checking identified issues respect the general case
+ checkVersionsOrder("2.3-pfd", "2.3");
+ checkVersionsOrder("2.0.1.MR", "2.0.1");
+ checkVersionsOrder("9.4.1.jre16-preview", "9.4.1.jre16");
+ checkVersionsOrder("1-ga-1", "1-sp-1");
+ }
+
/**
* Test MNG-7644 edge cases
* 1.0.0.RC1 < 1.0.0-RC2 and more generally:
diff --git a/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/DefaultArtifactVersionTest.java b/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/DefaultArtifactVersionTest.java
index c1530cbe202d..dbd77ca42ff2 100644
--- a/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/DefaultArtifactVersionTest.java
+++ b/compat/maven-artifact/src/test/java/org/apache/maven/artifact/versioning/DefaultArtifactVersionTest.java
@@ -118,7 +118,7 @@ void testVersionComparing() {
assertVersionOlder("2.0-1", "2.0.1");
assertVersionOlder("2.0.1-klm", "2.0.1-lmn");
- assertVersionOlder("2.0.1", "2.0.1-xyz");
+ assertVersionOlder("2.0.1-xyz", "2.0.1"); // now 2.0.1-xyz < 2.0.1 as of MNG-7559
assertVersionOlder("2.0.1-xyz-1", "2.0.1-1-xyz");
assertVersionOlder("2.0.1", "2.0.1-123");