Skip to content

Commit f7bfb98

Browse files
committed
Add Semantic Version field type mapper and extensive unit tests
Signed-off-by: Siddhant Deshmukh <deshsid@amazon.com>
1 parent 6ad6f4e commit f7bfb98

File tree

7 files changed

+2110
-0
lines changed

7 files changed

+2110
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
4141
- Added File Cache Pinning ([#17617](https://github.com/opensearch-project/OpenSearch/issues/13648))
4242
- Support consumer reset in Resume API for pull-based ingestion. This PR includes a breaking change for the experimental pull-based ingestion feature. ([#18332](https://github.com/opensearch-project/OpenSearch/pull/18332))
4343
- Add FIPS build tooling ([#4254](https://github.com/opensearch-project/security/issues/4254))
44+
- Add Semantic Version field type mapper and extensive unit tests([#18454](https://github.com/opensearch-project/OpenSearch/pull/18454))
4445

4546
### Changed
4647
- Create generic DocRequest to better categorize ActionRequests ([#18269](https://github.com/opensearch-project/OpenSearch/pull/18269)))
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.index.mapper;
10+
11+
import java.util.regex.Matcher;
12+
import java.util.regex.Pattern;
13+
14+
/**
15+
* Represents a semantic version number (major.minor.patch-preRelease+build).
16+
* This class implements semantic versioning (SemVer) according to the specification at semver.org.
17+
* It provides methods to parse, compare, and manipulate semantic version numbers.
18+
* Primarily used in {@link SemanticVersionFieldMapper} for mapping and sorting purposes.
19+
*
20+
* @see <a href="https://semver.org/">Semantic Versioning 2.0.0</a>
21+
* @see <a href="https://github.com/opensearch-project/OpenSearch/issues/16814">OpenSearch github issue</a>
22+
*/
23+
public class SemanticVersion implements Comparable<SemanticVersion> {
24+
private final int major;
25+
private final int minor;
26+
private final int patch;
27+
private final String preRelease;
28+
private final String build;
29+
30+
public SemanticVersion(int major, int minor, int patch, String preRelease, String build) {
31+
if (major < 0 || minor < 0 || patch < 0) {
32+
throw new IllegalArgumentException("Version numbers cannot be negative");
33+
}
34+
this.major = major;
35+
this.minor = minor;
36+
this.patch = patch;
37+
this.preRelease = preRelease;
38+
this.build = build;
39+
}
40+
41+
public int getMajor() {
42+
return major;
43+
}
44+
45+
public int getMinor() {
46+
return minor;
47+
}
48+
49+
public int getPatch() {
50+
return patch;
51+
}
52+
53+
public String getPreRelease() {
54+
return preRelease;
55+
}
56+
57+
public String getBuild() {
58+
return build;
59+
}
60+
61+
public static SemanticVersion parse(String version) {
62+
if (version == null || version.isEmpty()) {
63+
throw new IllegalArgumentException("Version string cannot be null or empty");
64+
}
65+
66+
// Clean up the input string
67+
version = version.trim();
68+
version = version.replaceAll("\\[|\\]", ""); // Remove square brackets
69+
70+
// Handle encoded byte format
71+
if (version.matches(".*\\s+.*")) {
72+
version = version.replaceAll("\\s+", ".");
73+
}
74+
75+
Pattern pattern = Pattern.compile(
76+
"^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
77+
);
78+
79+
Matcher matcher = pattern.matcher(version);
80+
if (!matcher.matches()) {
81+
throw new IllegalArgumentException("Invalid semantic version format: [" + version + "]");
82+
}
83+
84+
try {
85+
return new SemanticVersion(
86+
Integer.parseInt(matcher.group(1)),
87+
Integer.parseInt(matcher.group(2)),
88+
Integer.parseInt(matcher.group(3)),
89+
matcher.group(4),
90+
matcher.group(5)
91+
);
92+
} catch (NumberFormatException e) {
93+
throw new IllegalArgumentException("Invalid version numbers in: " + version, e);
94+
}
95+
}
96+
97+
98+
/**
99+
* Returns a normalized string representation of the semantic version.
100+
* This format ensures proper lexicographical ordering of versions.
101+
* The format is:
102+
* - Major, minor, and patch numbers are padded to 20 digits
103+
* - Pre-release version is appended with a "-" prefix if present
104+
* - Build metadata is appended with a "+" prefix if present
105+
*
106+
* Example: "1.2.3-alpha+build.123" becomes "00000000000000000001.00000000000000000002.00000000000000000003-alpha+build.123"
107+
*
108+
* Note: Build metadata is included for completeness but does not affect version precedence
109+
* as per SemVer specification.
110+
*
111+
* @return normalized string representation of the version
112+
*/
113+
public String getNormalizedString() {
114+
StringBuilder sb = new StringBuilder();
115+
116+
// Pad numbers to 20 digits for consistent lexicographical sorting
117+
// This allows for very large version numbers while maintaining proper order
118+
sb.append(String.format("%020d", major))
119+
.append('.')
120+
.append(String.format("%020d", minor))
121+
.append('.')
122+
.append(String.format("%020d", patch));
123+
124+
// Add pre-release version if present
125+
// Pre-release versions have lower precedence than the associated normal version
126+
if (preRelease != null) {
127+
sb.append('-').append(preRelease);
128+
}
129+
130+
// Add build metadata if present
131+
// Note: Build metadata does not affect version precedence
132+
if (build != null) {
133+
sb.append('+').append(build);
134+
}
135+
136+
return sb.toString();
137+
}
138+
139+
/**
140+
* Returns a normalized comparable string representation of the semantic version.
141+
* <p>
142+
* The format zero-pads major, minor, and patch versions to 20 digits each,
143+
* separated by dots, to ensure correct lexical sorting of numeric components.
144+
* <p>
145+
* For pre-release versions, the pre-release label is appended with a leading
146+
* hyphen (`-`) in lowercase, preserving lexicographical order among pre-release
147+
* versions.
148+
* <p>
149+
* For stable releases (no pre-release), a tilde character (`~`) is appended,
150+
* which lexically sorts after any pre-release versions to ensure stable
151+
* releases are ordered last.
152+
* <p>
153+
* Ordering: 1.0.0-alpha &lt; 1.0.0-beta &lt; 1.0.0
154+
* <p>
155+
* Examples:
156+
* <ul>
157+
* <li>1.0.0 → 00000000000000000001.00000000000000000000.00000000000000000000~</li>
158+
* <li>1.0.0-alpha → 00000000000000000001.00000000000000000000.00000000000000000000-alpha</li>
159+
* <li>1.0.0-beta → 00000000000000000001.00000000000000000000.00000000000000000000-beta</li>
160+
* </ul>
161+
*
162+
* @return normalized string for lexicographical comparison of semantic versions
163+
*/
164+
public String getNormalizedComparableString() {
165+
StringBuilder sb = new StringBuilder();
166+
167+
// Zero-pad major, minor, patch
168+
sb.append(String.format("%020d", major)).append(".");
169+
sb.append(String.format("%020d", minor)).append(".");
170+
sb.append(String.format("%020d", patch));
171+
172+
if (preRelease == null || preRelease.isEmpty()) {
173+
// Stable release: append '~' to sort AFTER any pre-release
174+
sb.append("~");
175+
} else {
176+
// Pre-release: append '-' plus normalized pre-release string (lowercase, trimmed)
177+
sb.append("-").append(preRelease.trim().toLowerCase());
178+
}
179+
180+
return sb.toString();
181+
}
182+
183+
@Override
184+
public int compareTo(SemanticVersion other) {
185+
if (other == null) {
186+
return 1;
187+
}
188+
189+
int majorComparison = Integer.compare(this.major, other.major);
190+
if (majorComparison != 0) return majorComparison;
191+
192+
int minorComparison = Integer.compare(this.minor, other.minor);
193+
if (minorComparison != 0) return minorComparison;
194+
195+
int patchComparison = Integer.compare(this.patch, other.patch);
196+
if (patchComparison != 0) return patchComparison;
197+
198+
// Pre-release versions have lower precedence
199+
if (this.preRelease == null && other.preRelease != null) return 1;
200+
if (this.preRelease != null && other.preRelease == null) return -1;
201+
if (this.preRelease != null && other.preRelease != null) {
202+
return comparePreRelease(this.preRelease, other.preRelease);
203+
}
204+
205+
return 0;
206+
}
207+
208+
private int comparePreRelease(String pre1, String pre2) {
209+
String[] parts1 = pre1.split("\\.");
210+
String[] parts2 = pre2.split("\\.");
211+
212+
int length = Math.min(parts1.length, parts2.length);
213+
for (int i = 0; i < length; i++) {
214+
String part1 = parts1[i];
215+
String part2 = parts2[i];
216+
217+
boolean isNum1 = part1.matches("\\d+");
218+
boolean isNum2 = part2.matches("\\d+");
219+
220+
if (isNum1 && isNum2) {
221+
int num1 = Integer.parseInt(part1);
222+
int num2 = Integer.parseInt(part2);
223+
int comparison = Integer.compare(num1, num2);
224+
if (comparison != 0) return comparison;
225+
} else {
226+
int comparison = part1.compareTo(part2);
227+
if (comparison != 0) return comparison;
228+
}
229+
}
230+
231+
return Integer.compare(parts1.length, parts2.length);
232+
}
233+
234+
@Override
235+
public String toString() {
236+
StringBuilder sb = new StringBuilder();
237+
sb.append(major).append('.').append(minor).append('.').append(patch);
238+
if (preRelease != null) {
239+
sb.append('-').append(preRelease);
240+
}
241+
if (build != null) {
242+
sb.append('+').append(build);
243+
}
244+
return sb.toString();
245+
}
246+
}
247+

0 commit comments

Comments
 (0)