Skip to content

Commit 069baff

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

File tree

8 files changed

+1941
-0
lines changed

8 files changed

+1941
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5252
- Approximation Framework Enhancement: Update the BKD traversal logic to improve the performance on skewed data ([#18439](https://github.com/opensearch-project/OpenSearch/issues/18439))
5353
- Support system generated ingest pipelines for bulk update operations ([#18277](https://github.com/opensearch-project/OpenSearch/pull/18277)))
5454
- Added FS Health Check Failure metric ([#18435](https://github.com/opensearch-project/OpenSearch/pull/18435))
55+
- Add Semantic Version field type mapper and extensive unit tests([#18454](https://github.com/opensearch-project/OpenSearch/pull/18454))
5556

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

0 commit comments

Comments
 (0)