Skip to content

Commit 4af0ab3

Browse files
committed
Merge origin/7.0.x into 7.1.x
Assisted-by: opencode:gpt-5.5 oracle
2 parents a9490b2 + 5c21bd0 commit 4af0ab3

5 files changed

Lines changed: 260 additions & 61 deletions

File tree

.asf.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ github:
6464
wait_timer: 0
6565
description: "Grails - the Web Application Framework"
6666
homepage: https://grails.apache.org/
67+
rulesets:
68+
- name: "Default Branch Protection"
69+
type: branch
70+
branches:
71+
includes:
72+
- "~DEFAULT_BRANCH"
73+
- "*.x"
74+
excludes: []
75+
bypass_teams:
76+
- root
77+
restrict_deletion: true
78+
restrict_force_push: true
6779
notifications:
6880
# GitHub related
6981
commits: commits@grails.apache.org

dependencies.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ ext {
3737
'jna.version' : '5.17.0',
3838
'jquery.version' : '3.7.1',
3939
'objenesis.version' : '3.4',
40-
'spring-boot.version' : '3.5.14',
40+
'spring-boot.version' : '3.5.15',
4141
]
4242

4343
// Note: the name of the dependency must be the prefix of the property name so properties in the pom are resolved correctly
@@ -74,7 +74,7 @@ ext {
7474
'commons-lang3.version' : '3.20.0',
7575
'geb-spock.version' : '8.0.1',
7676
'groovy.version' : '4.0.32',
77-
'jackson.version' : '2.21.2',
77+
'jackson.version' : '2.21.4',
7878
'jquery.version' : '3.7.1',
7979
'hibernate-groovy-proxy.version': '1.1',
8080
'jakarta-servlet-api.version' : '6.1.0',

grails-bootstrap/src/main/groovy/grails/plugins/VersionComparator.groovy

Lines changed: 166 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,79 +18,79 @@
1818
*/
1919
package grails.plugins
2020

21+
import java.util.regex.Matcher
22+
import java.util.regex.Pattern
23+
2124
import groovy.transform.CompileStatic
2225

2326
/**
24-
* A comparator capable of sorting versions from from newest to oldest
27+
* A comparator that orders versions from oldest to newest, following the standard
28+
* {@link Comparator} contract (a negative result means the first version is older).
29+
*
30+
* <p>Versions are compared by their numeric components first (major, minor, patch, ...),
31+
* padding the shorter side with zeros so that {@code 7.0} and {@code 7.0.0} are equal.
32+
* When the numeric components are equal the version qualifier is used as a tie-breaker
33+
* following the same ordering as {@code org.grails.datastore.mapping.core.grailsversion.GrailsVersion}:</p>
34+
*
35+
* <pre>
36+
* 7.0.0-M1 &lt; 7.0.0-M2 &lt; 7.0.0-RC1 &lt; 7.0.0-RC2 &lt; 7.0.0-SNAPSHOT &lt; 7.0.0
37+
* </pre>
38+
*
39+
* <p>In other words a milestone is older than a release candidate, a release candidate is
40+
* older than a snapshot, and any qualified (pre-release) version is older than the final
41+
* release of the same number. Both the dotted ({@code 7.0.0.M1}) and hyphenated
42+
* ({@code 7.0.0-M1}) qualifier forms are treated as equivalent. Unrecognised qualifiers are
43+
* treated as a final release to preserve backwards compatible behaviour.</p>
2544
*/
2645
@CompileStatic
2746
class VersionComparator implements Comparator<String> {
2847

48+
private static final String SNAPSHOT = 'SNAPSHOT'
49+
private static final String RELEASE_CANDIDATE = 'RC'
50+
private static final String MILESTONE = 'M'
51+
2952
static private final List<String> SNAPSHOT_SUFFIXES = ['-SNAPSHOT', '.BUILD-SNAPSHOT'].asImmutable()
3053

54+
private static final Pattern DIGITS = ~/\d+/
55+
private static final Pattern NUMERIC_PREFIX = ~/(\d+)-(.+)/
56+
private static final Pattern TRAILING_DIGITS = ~/(\d+)$/
57+
58+
private static final int TIER_FINAL = 4
59+
private static final int TIER_SNAPSHOT = 3
60+
private static final int TIER_RELEASE_CANDIDATE = 2
61+
private static final int TIER_MILESTONE = 1
62+
3163
int compare(String o1, String o2) {
32-
int result = 0
33-
if (o1 == '*') {
34-
result = 1
35-
}
36-
else if (o2 == '*') {
37-
result = -1
38-
}
39-
else {
40-
def nums1
41-
try {
42-
def tokens = deSnapshot(o1).split(/\./)
43-
tokens = tokens.findAll { String it -> it.trim() ==~ /\d+/ }
44-
nums1 = tokens*.toInteger()
45-
}
46-
catch (NumberFormatException e) {
47-
throw new InvalidVersionException("Cannot compare versions, left side [$o1] is invalid: ${e.message}")
48-
}
49-
def nums2
50-
try {
51-
def tokens = deSnapshot(o2).split(/\./)
52-
tokens = tokens.findAll { String it -> it.trim() ==~ /\d+/ }
53-
nums2 = tokens*.toInteger()
54-
}
55-
catch (NumberFormatException e) {
56-
throw new InvalidVersionException("Cannot compare versions, right side [$o2] is invalid: ${e.message}")
57-
}
58-
boolean bigRight = nums2.size() > nums1.size()
59-
boolean bigLeft = nums1.size() > nums2.size()
60-
for (int i in 0..<nums1.size()) {
61-
if (nums2.size() > i) {
62-
result = nums1[i].compareTo(nums2[i])
63-
if (result != 0) {
64-
break
65-
}
66-
if (i == (nums1.size() - 1) && bigRight) {
67-
if (nums2[i + 1] != 0)
68-
result = -1
69-
break
70-
}
71-
}
72-
else if (bigLeft) {
73-
if (nums1[i] != 0)
74-
result = 1
75-
break
76-
}
77-
}
64+
String left = o1?.trim()
65+
String right = o2?.trim()
66+
67+
if (left == '*') {
68+
return 1
69+
}
70+
if (right == '*') {
71+
return -1
7872
}
7973

80-
if (result == 0) {
81-
// Versions are equal, but one may be a snapshot.
82-
// A snapshot version is considered less than a non snapshot version
83-
def o1IsSnapshot = isSnapshot(o1)
84-
def o2IsSnapshot = isSnapshot(o2)
85-
86-
if (o1IsSnapshot && !o2IsSnapshot) {
87-
result = -1
88-
} else if (!o1IsSnapshot && o2IsSnapshot) {
89-
result = 1
90-
}
74+
ParsedVersion v1
75+
try {
76+
v1 = parse(left)
77+
}
78+
catch (NumberFormatException e) {
79+
throw new InvalidVersionException("Cannot compare versions, left side [$o1] is invalid: ${e.message}")
80+
}
81+
ParsedVersion v2
82+
try {
83+
v2 = parse(right)
84+
}
85+
catch (NumberFormatException e) {
86+
throw new InvalidVersionException("Cannot compare versions, right side [$o2] is invalid: ${e.message}")
9187
}
9288

93-
result
89+
int result = compareNumbers(v1.numbers, v2.numbers)
90+
if (result == 0) {
91+
result = compareQualifiers(v1, v2)
92+
}
93+
return result
9494
}
9595

9696
boolean equals(obj) { false }
@@ -112,4 +112,111 @@ class VersionComparator implements Comparator<String> {
112112
protected boolean isSnapshot(String version) {
113113
SNAPSHOT_SUFFIXES.any { String it -> version?.endsWith(it) }
114114
}
115+
116+
/**
117+
* Splits a version into its leading numeric components and an optional trailing qualifier.
118+
* The first token that is not purely numeric ends the numeric section. A token of the form
119+
* {@code <digits>-<qualifier>} (for example {@code 0-RC1} from {@code 7.0.0-RC1}) contributes
120+
* its leading digits to the numeric section and the remainder becomes the qualifier.
121+
*
122+
* @throws NumberFormatException if a numeric component cannot be parsed as an {@code int};
123+
* callers are expected to translate this into an {@link InvalidVersionException}.
124+
*/
125+
private static ParsedVersion parse(String version) {
126+
List<Integer> numbers = []
127+
String qualifier = null
128+
if (version) {
129+
for (String token : version.split(/\./)) {
130+
if (DIGITS.matcher(token).matches()) {
131+
numbers.add(Integer.parseInt(token))
132+
continue
133+
}
134+
Matcher matcher = NUMERIC_PREFIX.matcher(token)
135+
if (matcher.matches()) {
136+
numbers.add(Integer.parseInt(matcher.group(1)))
137+
qualifier = normalizeQualifier(matcher.group(2))
138+
} else {
139+
qualifier = normalizeQualifier(token)
140+
}
141+
break
142+
}
143+
}
144+
int tier = qualifierTier(qualifier)
145+
// Only milestone and release candidate tiers use the trailing number for ordering.
146+
// Parsing it here (inside parse) keeps any overflow inside the caller's try/catch so it
147+
// is reported as an InvalidVersionException rather than a raw NumberFormatException.
148+
int number = (tier == TIER_MILESTONE || tier == TIER_RELEASE_CANDIDATE) ? qualifierNumber(qualifier) : 0
149+
return new ParsedVersion(numbers, qualifier, tier, number)
150+
}
151+
152+
private static int compareNumbers(List<Integer> a, List<Integer> b) {
153+
int max = Math.max(a.size(), b.size())
154+
for (int i = 0; i < max; i++) {
155+
int left = i < a.size() ? a.get(i) : 0
156+
int right = i < b.size() ? b.get(i) : 0
157+
int result = Integer.compare(left, right)
158+
if (result != 0) {
159+
return result
160+
}
161+
}
162+
return 0
163+
}
164+
165+
private static int compareQualifiers(ParsedVersion v1, ParsedVersion v2) {
166+
if (v1.qualifierTier != v2.qualifierTier) {
167+
return Integer.compare(v1.qualifierTier, v2.qualifierTier)
168+
}
169+
if (v1.qualifierTier == TIER_MILESTONE || v1.qualifierTier == TIER_RELEASE_CANDIDATE) {
170+
return Integer.compare(v1.qualifierNumber, v2.qualifierNumber)
171+
}
172+
return 0
173+
}
174+
175+
private static String normalizeQualifier(String qualifier) {
176+
if (qualifier == null) {
177+
return null
178+
}
179+
String upper = qualifier.toUpperCase()
180+
return upper.contains(SNAPSHOT) ? SNAPSHOT : upper
181+
}
182+
183+
private static int qualifierTier(String qualifier) {
184+
if (qualifier == null) {
185+
return TIER_FINAL
186+
}
187+
if (qualifier.contains(SNAPSHOT)) {
188+
return TIER_SNAPSHOT
189+
}
190+
if (qualifier.startsWith(RELEASE_CANDIDATE)) {
191+
return TIER_RELEASE_CANDIDATE
192+
}
193+
if (qualifier.startsWith(MILESTONE)) {
194+
return TIER_MILESTONE
195+
}
196+
return TIER_FINAL
197+
}
198+
199+
private static int qualifierNumber(String qualifier) {
200+
if (qualifier == null) {
201+
return 0
202+
}
203+
Matcher matcher = TRAILING_DIGITS.matcher(qualifier)
204+
return matcher.find() ? Integer.parseInt(matcher.group(1)) : 0
205+
}
206+
207+
@CompileStatic
208+
private static class ParsedVersion {
209+
210+
final List<Integer> numbers
211+
final String qualifier
212+
final int qualifierTier
213+
final int qualifierNumber
214+
215+
ParsedVersion(List<Integer> numbers, String qualifier, int qualifierTier, int qualifierNumber) {
216+
this.numbers = numbers
217+
this.qualifier = qualifier
218+
this.qualifierTier = qualifierTier
219+
this.qualifierNumber = qualifierNumber
220+
}
221+
}
115222
}

grails-core/src/test/groovy/grails/plugins/VersionComparatorSpec.groovy

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,75 @@ class VersionComparatorSpec extends Specification {
4444
"3.0.0" | "3.0.0" || 0
4545
"4.0.1" | "3.1.110" || 1
4646
"4.0.1" | "3.0.0.BUILD-SNAPSHOT" || 1
47+
48+
// A pre-release (milestone/rc/snapshot) is older than the final release of the same number
49+
"7.0.0-M1" | "7.0.0" || -1
50+
"7.0.0" | "7.0.0-M1" || 1
51+
"7.0.0-RC1" | "7.0.0" || -1
52+
"7.0.0-SNAPSHOT" | "7.0.0" || -1
53+
"7.0.0" | "7.0.0-SNAPSHOT" || 1
54+
55+
// The numeric version is compared before the qualifier, so the patch number is never lost
56+
"7.0.5-M1" | "7.0.0" || 1
57+
"7.0.0" | "7.0.5-M1" || -1
58+
"7.0.1-M1" | "7.0.0" || 1
59+
"7.0.0-RC1" | "6.9.9" || 1
60+
"6.9.9" | "7.0.0-RC1" || -1
61+
62+
// Milestones and release candidates are ordered by their number, numerically not lexically
63+
"7.0.0-M1" | "7.0.0-M2" || -1
64+
"7.0.0-M2" | "7.0.0-M1" || 1
65+
"7.0.0-RC1" | "7.0.0-RC2" || -1
66+
"7.0.0-RC10" | "7.0.0-RC2" || 1
67+
68+
// Qualifier tiers: milestone < release candidate < snapshot < final
69+
"7.0.0-M2" | "7.0.0-RC1" || -1
70+
"7.0.0-RC1" | "7.0.0-SNAPSHOT" || -1
71+
"7.0.0-M9" | "7.0.0-SNAPSHOT" || -1
72+
73+
// The dotted and hyphenated qualifier forms are equivalent, and matching is case insensitive
74+
"7.0.0.M1" | "7.0.0-M1" || 0
75+
"7.0.0.RC1" | "7.0.0-RC1" || 0
76+
"7.0.0.BUILD-SNAPSHOT" | "7.0.0-SNAPSHOT" || 0
77+
"7.0.0-rc3" | "7.0.0-RC3" || 0
78+
79+
// An unrecognised qualifier is treated as a final release, even when it has a trailing number
80+
"7.0.0-FOO2" | "7.0.0" || 0
81+
"7.0.0" | "7.0.0-FOO2" || 0
82+
"7.0.0-FOO" | "7.0.0" || 0
83+
}
84+
85+
def "sorts mixed milestone, release candidate, snapshot and final versions from oldest to newest"() {
86+
given:
87+
def comparator = new VersionComparator()
88+
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"]
89+
90+
when:
91+
def sorted = versions.sort(false, comparator)
92+
93+
then:
94+
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"]
95+
}
96+
97+
@Unroll
98+
def "throws an explicit InvalidVersionException when the #side version [#malformed] is malformed"() {
99+
given:
100+
def comparator = new VersionComparator()
101+
102+
when:
103+
comparator.compare(version1, version2)
104+
105+
then:
106+
InvalidVersionException e = thrown()
107+
e.message.startsWith("Cannot compare versions, $side side [$malformed] is invalid:")
108+
109+
where:
110+
side | version1 | version2 | malformed
111+
// Numeric component overflows int
112+
"left" | "99999999999999999.0.0" | "7.0.0" | "99999999999999999.0.0"
113+
"right" | "7.0.0" | "99999999999999999.0.0" | "99999999999999999.0.0"
114+
// Milestone/release candidate qualifier number overflows int
115+
"left" | "7.0.0-RC99999999999999999" | "7.0.0-RC1" | "7.0.0-RC99999999999999999"
116+
"right" | "7.0.0-M1" | "7.0.0-M99999999999999999" | "7.0.0-M99999999999999999"
47117
}
48118
}

grails-core/src/test/groovy/org/apache/grails/core/plugins/PluginUtilsSpec.groovy

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ class PluginUtilsSpec extends Specification {
4949
'4.0.1' | '3.0.1' || false
5050
'4.0.1' | '3.3.1 > *' || true
5151
'4.0.1' | '3.3.10 > *' || true
52+
'7.0.0-M2' | '7.0.0-M1 > *' || true
53+
'7.0.0-M1' | '7.0.0-M2 > *' || false
54+
'7.0.0-RC1' | '7.0.0-M1 > *' || true
55+
'7.0.0-M1' | '7.0.0-RC1 > *' || false
56+
'7.0.0' | '7.0.0-RC1 > *' || true
57+
'7.0.0-RC1' | '7.0.0 > *' || false
58+
'7.0.0-SNAPSHOT' | '7.0.0-SNAPSHOT > *' || true
59+
'7.0.5-M1' | '7.0.3 > *' || true
60+
'7.0.0-M1' | '7.0.0-M1' || true
61+
'7.0.0-M2' | '7.0.0-M1' || false
5262
}
5363

5464
def "isPluginVersionCompatible handles null grailsVersion"() {

0 commit comments

Comments
 (0)