diff --git a/grails-bootstrap/src/main/groovy/grails/plugins/VersionComparator.groovy b/grails-bootstrap/src/main/groovy/grails/plugins/VersionComparator.groovy
index ca8e8533979..30ca5da861f 100644
--- a/grails-bootstrap/src/main/groovy/grails/plugins/VersionComparator.groovy
+++ b/grails-bootstrap/src/main/groovy/grails/plugins/VersionComparator.groovy
@@ -18,79 +18,66 @@
*/
package grails.plugins
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
import groovy.transform.CompileStatic
/**
- * A comparator capable of sorting versions from from newest to oldest
+ * A comparator capable of sorting versions from newest to oldest.
+ *
+ *
Versions are compared by their numeric components first (major, minor, patch, ...),
+ * padding the shorter side with zeros so that {@code 7.0} and {@code 7.0.0} are equal.
+ * When the numeric components are equal the version qualifier is used as a tie-breaker
+ * following the same ordering as {@code org.grails.datastore.mapping.core.grailsversion.GrailsVersion}:
+ *
+ *
+ * 7.0.0-M1 < 7.0.0-M2 < 7.0.0-RC1 < 7.0.0-RC2 < 7.0.0-SNAPSHOT < 7.0.0
+ *
+ *
+ * In other words a milestone is older than a release candidate, a release candidate is
+ * older than a snapshot, and any qualified (pre-release) version is older than the final
+ * release of the same number. Both the dotted ({@code 7.0.0.M1}) and hyphenated
+ * ({@code 7.0.0-M1}) qualifier forms are treated as equivalent. Unrecognised qualifiers are
+ * treated as a final release to preserve backwards compatible behaviour.
*/
@CompileStatic
class VersionComparator implements Comparator {
+ private static final String SNAPSHOT = 'SNAPSHOT'
+ private static final String RELEASE_CANDIDATE = 'RC'
+ private static final String MILESTONE = 'M'
+
static private final List SNAPSHOT_SUFFIXES = ['-SNAPSHOT', '.BUILD-SNAPSHOT'].asImmutable()
+ private static final Pattern DIGITS = ~/\d+/
+ private static final Pattern NUMERIC_PREFIX = ~/(\d+)-(.+)/
+ private static final Pattern TRAILING_DIGITS = ~/(\d+)$/
+
+ private static final int TIER_FINAL = 4
+ private static final int TIER_SNAPSHOT = 3
+ private static final int TIER_RELEASE_CANDIDATE = 2
+ private static final int TIER_MILESTONE = 1
+
int compare(String o1, String o2) {
- int result = 0
- if (o1 == '*') {
- result = 1
- }
- else if (o2 == '*') {
- result = -1
+ String left = o1?.trim()
+ String right = o2?.trim()
+
+ if (left == '*') {
+ return 1
}
- else {
- def nums1
- try {
- def tokens = deSnapshot(o1).split(/\./)
- tokens = tokens.findAll { String it -> it.trim() ==~ /\d+/ }
- nums1 = tokens*.toInteger()
- }
- catch (NumberFormatException e) {
- throw new InvalidVersionException("Cannot compare versions, left side [$o1] is invalid: ${e.message}")
- }
- def nums2
- try {
- def tokens = deSnapshot(o2).split(/\./)
- tokens = tokens.findAll { String it -> it.trim() ==~ /\d+/ }
- nums2 = tokens*.toInteger()
- }
- catch (NumberFormatException e) {
- throw new InvalidVersionException("Cannot compare versions, right side [$o2] is invalid: ${e.message}")
- }
- boolean bigRight = nums2.size() > nums1.size()
- boolean bigLeft = nums1.size() > nums2.size()
- for (int i in 0.. i) {
- result = nums1[i].compareTo(nums2[i])
- if (result != 0) {
- break
- }
- if (i == (nums1.size() - 1) && bigRight) {
- if (nums2[i + 1] != 0)
- result = -1
- break
- }
- }
- else if (bigLeft) {
- if (nums1[i] != 0)
- result = 1
- break
- }
- }
+ if (right == '*') {
+ return -1
}
+ ParsedVersion v1 = parse(left)
+ ParsedVersion v2 = parse(right)
+
+ int result = compareNumbers(v1.numbers, v2.numbers)
if (result == 0) {
- // Versions are equal, but one may be a snapshot.
- // A snapshot version is considered less than a non snapshot version
- def o1IsSnapshot = isSnapshot(o1)
- def o2IsSnapshot = isSnapshot(o2)
-
- if (o1IsSnapshot && !o2IsSnapshot) {
- result = -1
- } else if (!o1IsSnapshot && o2IsSnapshot) {
- result = 1
- }
+ result = compareQualifiers(v1.qualifier, v2.qualifier)
}
-
- result
+ return result
}
boolean equals(obj) { false }
@@ -112,4 +99,97 @@ class VersionComparator implements Comparator {
protected boolean isSnapshot(String version) {
SNAPSHOT_SUFFIXES.any { String it -> version?.endsWith(it) }
}
+
+ /**
+ * Splits a version into its leading numeric components and an optional trailing qualifier.
+ * The first token that is not purely numeric ends the numeric section. A token of the form
+ * {@code -} (for example {@code 0-RC1} from {@code 7.0.0-RC1}) contributes
+ * its leading digits to the numeric section and the remainder becomes the qualifier.
+ */
+ private static ParsedVersion parse(String version) {
+ List numbers = []
+ String qualifier = null
+ if (version) {
+ for (String token : version.split(/\./)) {
+ if (DIGITS.matcher(token).matches()) {
+ numbers.add(Integer.parseInt(token))
+ continue
+ }
+ Matcher matcher = NUMERIC_PREFIX.matcher(token)
+ if (matcher.matches()) {
+ numbers.add(Integer.parseInt(matcher.group(1)))
+ qualifier = normalizeQualifier(matcher.group(2))
+ } else {
+ qualifier = normalizeQualifier(token)
+ }
+ break
+ }
+ }
+ return new ParsedVersion(numbers, qualifier)
+ }
+
+ private static int compareNumbers(List a, List b) {
+ int max = Math.max(a.size(), b.size())
+ for (int i = 0; i < max; i++) {
+ int left = i < a.size() ? a.get(i) : 0
+ int right = i < b.size() ? b.get(i) : 0
+ int result = Integer.compare(left, right)
+ if (result != 0) {
+ return result
+ }
+ }
+ return 0
+ }
+
+ private static int compareQualifiers(String q1, String q2) {
+ int tier = Integer.compare(qualifierTier(q1), qualifierTier(q2))
+ if (tier != 0) {
+ return tier
+ }
+ return Integer.compare(qualifierNumber(q1), qualifierNumber(q2))
+ }
+
+ private static String normalizeQualifier(String qualifier) {
+ if (qualifier == null) {
+ return null
+ }
+ String upper = qualifier.toUpperCase()
+ return upper.contains(SNAPSHOT) ? SNAPSHOT : upper
+ }
+
+ private static int qualifierTier(String qualifier) {
+ if (qualifier == null) {
+ return TIER_FINAL
+ }
+ if (qualifier.contains(SNAPSHOT)) {
+ return TIER_SNAPSHOT
+ }
+ if (qualifier.startsWith(RELEASE_CANDIDATE)) {
+ return TIER_RELEASE_CANDIDATE
+ }
+ if (qualifier.startsWith(MILESTONE)) {
+ return TIER_MILESTONE
+ }
+ return TIER_FINAL
+ }
+
+ private static int qualifierNumber(String qualifier) {
+ if (qualifier == null) {
+ return 0
+ }
+ Matcher matcher = TRAILING_DIGITS.matcher(qualifier)
+ return matcher.find() ? Integer.parseInt(matcher.group(1)) : 0
+ }
+
+ @CompileStatic
+ private static class ParsedVersion {
+
+ final List numbers
+ final String qualifier
+
+ ParsedVersion(List numbers, String qualifier) {
+ this.numbers = numbers
+ this.qualifier = qualifier
+ }
+ }
}
diff --git a/grails-core/src/test/groovy/grails/plugins/DefaultGrailsPluginManagerSpec.groovy b/grails-core/src/test/groovy/grails/plugins/DefaultGrailsPluginManagerSpec.groovy
index 3974ef75ff9..aeb9cd8c5f5 100644
--- a/grails-core/src/test/groovy/grails/plugins/DefaultGrailsPluginManagerSpec.groovy
+++ b/grails-core/src/test/groovy/grails/plugins/DefaultGrailsPluginManagerSpec.groovy
@@ -59,16 +59,28 @@ class DefaultGrailsPluginManagerSpec extends Specification {
compatible == expectedCompatible
where:
- grailsVersion | pluginGrailsVersion || expectedCompatible
- "1.0" | "3.3.1 > *" || false
- "2.5" | "3.0.1" || false
- "3.0.0" | "3.3.10 > *" || false
- "3.3.10" | "4.0.0 > *" || false
- "4.0.1" | "3.0.0.BUILD-SNAPSHOT > *" || true
- "4.0.1" | "4.0.1" || true
- "4.0.1" | "3.0.1" || false
- "4.0.1" | "3.3.1 > *" || true
- "4.0.1" | "3.3.10 > *" || true
+ grailsVersion | pluginGrailsVersion || expectedCompatible
+ "1.0" | "3.3.1 > *" || false
+ "2.5" | "3.0.1" || false
+ "3.0.0" | "3.3.10 > *" || false
+ "3.3.10" | "4.0.0 > *" || false
+ "4.0.1" | "3.0.0.BUILD-SNAPSHOT > *" || true
+ "4.0.1" | "4.0.1" || true
+ "4.0.1" | "3.0.1" || false
+ "4.0.1" | "3.3.1 > *" || true
+ "4.0.1" | "3.3.10 > *" || true
+
+ // Milestone, release candidate and snapshot versions on both the application and the plugin (#14058)
+ "7.0.0-M2" | "7.0.0-M1 > *" || true
+ "7.0.0-M1" | "7.0.0-M2 > *" || false
+ "7.0.0-RC1" | "7.0.0-M1 > *" || true
+ "7.0.0-M1" | "7.0.0-RC1 > *" || false
+ "7.0.0" | "7.0.0-RC1 > *" || true
+ "7.0.0-RC1" | "7.0.0 > *" || false
+ "7.0.0-SNAPSHOT" | "7.0.0-SNAPSHOT > *" || true
+ "7.0.5-M1" | "7.0.3 > *" || true
+ "7.0.0-M1" | "7.0.0-M1" || true
+ "7.0.0-M2" | "7.0.0-M1" || false
}
def stubGrailsApplicationWithVersion(def version) {
diff --git a/grails-core/src/test/groovy/grails/plugins/VersionComparatorSpec.groovy b/grails-core/src/test/groovy/grails/plugins/VersionComparatorSpec.groovy
index 18efd2cc857..a7f0fbe7519 100644
--- a/grails-core/src/test/groovy/grails/plugins/VersionComparatorSpec.groovy
+++ b/grails-core/src/test/groovy/grails/plugins/VersionComparatorSpec.groovy
@@ -44,5 +44,48 @@ class VersionComparatorSpec extends Specification {
"3.0.0" | "3.0.0" || 0
"4.0.1" | "3.1.110" || 1
"4.0.1" | "3.0.0.BUILD-SNAPSHOT" || 1
+
+ // A pre-release (milestone/rc/snapshot) is older than the final release of the same number
+ "7.0.0-M1" | "7.0.0" || -1
+ "7.0.0" | "7.0.0-M1" || 1
+ "7.0.0-RC1" | "7.0.0" || -1
+ "7.0.0-SNAPSHOT" | "7.0.0" || -1
+ "7.0.0" | "7.0.0-SNAPSHOT" || 1
+
+ // The numeric version is compared before the qualifier, so the patch number is never lost
+ "7.0.5-M1" | "7.0.0" || 1
+ "7.0.0" | "7.0.5-M1" || -1
+ "7.0.1-M1" | "7.0.0" || 1
+ "7.0.0-RC1" | "6.9.9" || 1
+ "6.9.9" | "7.0.0-RC1" || -1
+
+ // Milestones and release candidates are ordered by their number, numerically not lexically
+ "7.0.0-M1" | "7.0.0-M2" || -1
+ "7.0.0-M2" | "7.0.0-M1" || 1
+ "7.0.0-RC1" | "7.0.0-RC2" || -1
+ "7.0.0-RC10" | "7.0.0-RC2" || 1
+
+ // Qualifier tiers: milestone < release candidate < snapshot < final
+ "7.0.0-M2" | "7.0.0-RC1" || -1
+ "7.0.0-RC1" | "7.0.0-SNAPSHOT" || -1
+ "7.0.0-M9" | "7.0.0-SNAPSHOT" || -1
+
+ // The dotted and hyphenated qualifier forms are equivalent, and matching is case insensitive
+ "7.0.0.M1" | "7.0.0-M1" || 0
+ "7.0.0.RC1" | "7.0.0-RC1" || 0
+ "7.0.0.BUILD-SNAPSHOT" | "7.0.0-SNAPSHOT" || 0
+ "7.0.0-rc3" | "7.0.0-RC3" || 0
+ }
+
+ def "sorts mixed milestone, release candidate, snapshot and final versions from oldest to newest"() {
+ given:
+ def comparator = new VersionComparator()
+ def versions = ["7.0.0", "7.0.0-M1", "7.0.0-RC2", "7.0.0-SNAPSHOT", "7.0.0-M2", "7.0.0-RC1", "6.9.9"]
+
+ when:
+ def sorted = versions.sort(false, comparator)
+
+ then:
+ sorted == ["6.9.9", "7.0.0-M1", "7.0.0-M2", "7.0.0-RC1", "7.0.0-RC2", "7.0.0-SNAPSHOT", "7.0.0"]
}
}