Skip to content

Commit 0cabb66

Browse files
deshsiddDivyansh Sharma
authored andcommitted
Add Semantic Version field type mapper (opensearch-project#18454)
* Add Semantic Version field type mapper and extensive unit tests Signed-off-by: Siddhant Deshmukh <[email protected]> * Refactor SemanticVersionFieldMapper for flexible index, store, and doc_values support Signed-off-by: Siddhant Deshmukh <[email protected]> --------- Signed-off-by: Siddhant Deshmukh <[email protected]>
1 parent 0fbf11d commit 0cabb66

File tree

8 files changed

+2282
-0
lines changed

8 files changed

+2282
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
2020
- Add functionality for plugins to inject QueryCollectorContext during QueryPhase ([#18637](https://github.com/opensearch-project/OpenSearch/pull/18637))
2121
- Add support for non-timing info in profiler ([#18460](https://github.com/opensearch-project/OpenSearch/issues/18460))
2222
- Extend Approximation Framework to other numeric types ([#18530](https://github.com/opensearch-project/OpenSearch/issues/18530))
23+
- Add Semantic Version field type mapper and extensive unit tests([#18454](https://github.com/opensearch-project/OpenSearch/pull/18454))
2324

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

0 commit comments

Comments
 (0)