Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 44 additions & 101 deletions api/src/main/java/run/halo/app/extension/index/KeyComparator.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,149 +23,92 @@ public int compare(@Nullable String a, @Nullable String b) {
private int compareStrings(String a, String b) {
int i = 0;
int j = 0;

while (i < a.length() && j < b.length()) {
char charA = a.charAt(i);
char charB = b.charAt(j);

if (Character.isDigit(charA) && Character.isDigit(charB)) {
// Both characters are digits, compare as numbers
int compareResult = compareNumbers(a, b, i, j);
int compareResult = compareNumberSegments(a, b, i, j);
if (compareResult != 0) {
return compareResult;
}

// Move indices past the compared number segments
i = moveIndexToNextNonDigit(a, i);
j = moveIndexToNextNonDigit(b, j);
i = skipNumberSegment(a, i);
j = skipNumberSegment(b, j);
} else if (charA == charB) {
// Characters are the same, continue
i++;
j++;
} else if (Character.isDigit(charA)) {
// If charA is digit and charB is not, digit comes first
return -1;
} else if (Character.isDigit(charB)) {
// If charB is digit and charA is not, digit comes first
return 1;
} else {
// Both are non-digits, compare directly
// Different characters, compare directly
return Character.compare(charA, charB);
}
}

// If one string is a prefix of the other, the shorter one comes first
return Integer.compare(a.length(), b.length());
}

private int compareNumbers(String a, String b, int startA, int startB) {
int i = startA;
int j = startB;

// Compare lengths of remaining digits
int lengthA = countDigits(a, i);
int lengthB = countDigits(b, j);
if (lengthA != lengthB) {
return Integer.compare(lengthA, lengthB);
}

// Compare digits one by one
for (int k = 0; k < lengthA && i < a.length() && j < b.length(); k++, i++, j++) {
char charA = a.charAt(i);
char charB = b.charAt(j);
if (charA != charB) {
return Character.compare(charA, charB);
}
}

// If both numbers have decimal points, compare decimal parts
boolean hasDecimalA = i < a.length() && a.charAt(i) == '.';
boolean hasDecimalB = j < b.length() && b.charAt(j) == '.';
if (hasDecimalA || hasDecimalB) {
return compareDecimalNumbers(a, b, i, j);
}
private int compareNumberSegments(String a, String b, int startA, int startB) {
// Extract pure number segments (no decimal handling)
String numA = extractNumberSegment(a, startA);
String numB = extractNumberSegment(b, startB);

return 0;
return compareIntegerSegments(numA, numB);
}

private int compareDecimalNumbers(String a, String b, int startA, int startB) {
// Find decimal point positions
int pointA = a.indexOf('.', startA);
int pointB = b.indexOf('.', startB);
private String extractNumberSegment(String s, int start) {
StringBuilder segment = new StringBuilder();
int i = start;

// Compare integer parts before the decimal point
int integerComparison = compareIntegerPart(a, b, startA, startB, pointA, pointB);
if (integerComparison != 0) {
return integerComparison;
// Only extract continuous digits
while (i < s.length() && Character.isDigit(s.charAt(i))) {
segment.append(s.charAt(i));
i++;
}

// Compare fractional parts after the decimal point
return compareFractionalPart(a, b, pointA + 1, pointB + 1);
return segment.toString();
}

private int compareIntegerPart(String a, String b, int startA, int startB, int pointA,
int pointB) {
int i = startA;
int j = startB;
private int compareIntegerSegments(String a, String b) {
// Remove leading zeros for comparison
String trimmedA = a.replaceFirst("^0+", "");
String trimmedB = b.replaceFirst("^0+", "");

int lengthA = pointA - i;
int lengthB = pointB - j;
if (lengthA != lengthB) {
return Integer.compare(lengthA, lengthB);
if (trimmedA.isEmpty()) {
trimmedA = "0";
}

while (i < pointA && j < pointB) {
char charA = a.charAt(i);
char charB = b.charAt(j);
if (charA != charB) {
return Character.compare(charA, charB);
}
i++;
j++;
if (trimmedB.isEmpty()) {
trimmedB = "0";
}

return 0;
}

private int compareFractionalPart(String a, String b, int i, int j) {
while (i < a.length() && j < b.length()
&& Character.isDigit(a.charAt(i)) && Character.isDigit(b.charAt(j))) {
if (a.charAt(i) != b.charAt(j)) {
return Character.compare(a.charAt(i), b.charAt(j));
}
i++;
j++;
// Compare by length first (longer number is larger)
if (trimmedA.length() != trimmedB.length()) {
return Integer.compare(trimmedA.length(), trimmedB.length());
}

// If one number has more digits left, and they're not all zeroes, it is larger
while (i < a.length() && Character.isDigit(a.charAt(i))) {
if (a.charAt(i) != '0') {
return 1;
}
i++;
}
while (j < b.length() && Character.isDigit(b.charAt(j))) {
if (b.charAt(j) != '0') {
return -1;
}
j++;
// Same length, compare lexicographically
int comparison = trimmedA.compareTo(trimmedB);
if (comparison != 0) {
return comparison;
}

return 0;
// If numbers are equal after removing leading zeros, compare original lengths
// The one with fewer leading zeros (shorter original string) should come first
return Integer.compare(a.length(), b.length());
}

private int countDigits(String s, int start) {
int count = 0;
while (start < s.length() && Character.isDigit(s.charAt(start))) {
count++;
start++;
}
return count;
}
private int skipNumberSegment(String s, int start) {
int i = start;

private int moveIndexToNextNonDigit(String s, int index) {
while (index < s.length() && (Character.isDigit(s.charAt(index))
|| s.charAt(index) == '.')) {
index++;
// Only skip continuous digits
while (i < s.length() && Character.isDigit(s.charAt(i))) {
i++;
}
return index;

return i;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,12 @@ public void sortingWithComplexStringsTest() {

@Test
public void sortingWithDecimalStringsTest() {
// After simplification, strings are sorted with numeric comparison for digit parts
List<String> strings =
Arrays.asList("1.2", "1.10", "1.1", "1.20", "1.02", "1.22", "1.001", "1.002");
strings.sort(comparator);
assertThat(strings).containsExactly("1.001", "1.002", "1.02", "1.1", "1.10", "1.2", "1.20",
// Order: integer part "1" is same, then compare after decimal point
assertThat(strings).containsExactly("1.1", "1.001", "1.2", "1.02", "1.002", "1.10", "1.20",
"1.22");
}

Expand Down Expand Up @@ -310,8 +312,9 @@ public void symmetricTest() {
assertThat(comparator.compare("123", "test")).isNegative();
assertThat(comparator.compare("test", "123")).isPositive();

assertThat(comparator.compare("1.023", "1.23")).isNegative();
assertThat(comparator.compare("1.23", "1.023")).isPositive();
// Note: After simplification, these use numeric comparison for digit parts
assertThat(comparator.compare("1.023", "1.23")).isPositive(); // "023" > "23" by length after numeric equality
assertThat(comparator.compare("1.23", "1.023")).isNegative();
}

@Test
Expand Down Expand Up @@ -502,4 +505,82 @@ public void complexMixedStringsTest() {
assertThat(comparator.compare("abc123xyz456", "abc123xyz457")).isLessThan(0);
}
}

@Nested
class FilePathComparisonTest {
@Test
public void shouldCorrectlyCompareFilePathsWithVersionNumbers() {
// The original issue: file paths with version numbers were incorrectly treated as equal
assertThat(comparator.compare("/upload/1.4.3.png", "/upload/1.4.6.png")).isNegative();
assertThat(comparator.compare("/upload/1.4.6.png", "/upload/1.4.3.png")).isPositive();
assertThat(comparator.compare("/upload/1.4.3.png", "/upload/1.4.3.png")).isZero();
}

@Test
public void shouldHandleDifferentVersionNumberLengths() {
// Test version numbers with different digit lengths
assertThat(comparator.compare("/upload/1.4.10.png", "/upload/1.4.2.png")).isPositive();
assertThat(comparator.compare("/upload/1.4.2.png", "/upload/1.4.10.png")).isNegative();

// Test with more complex version patterns
assertThat(comparator.compare("/files/app-1.2.3.jar", "/files/app-1.2.10.jar")).isNegative();
assertThat(comparator.compare("/files/app-1.10.3.jar", "/files/app-1.2.10.jar")).isPositive();
}

@Test
public void shouldHandleDecimalLikeStrings() {
// Note: After simplification, decimal-like strings are compared with numeric logic for digit parts
// This ensures no false equality issues while maintaining consistent ordering
assertThat(comparator.compare("1.023", "1.23")).isPositive(); // "023" > "23" by length after numeric equality
assertThat(comparator.compare("1.23", "1.023")).isNegative();
assertThat(comparator.compare("1.23", "1.23")).isZero();
}

@Test
public void shouldHandleVersionStrings() {
// Test version strings with prefixes
assertThat(comparator.compare("version-1.2.3", "version-1.2.4")).isNegative();
assertThat(comparator.compare("version-1.2.4", "version-1.2.3")).isPositive();
assertThat(comparator.compare("version-1.2.3", "version-1.2.3")).isZero();

// Test with different version formats
assertThat(comparator.compare("v1.2.3", "v1.2.4")).isNegative();
assertThat(comparator.compare("release-1.2.3", "release-1.2.4")).isNegative();
}

@Test
public void shouldHandleAllStringsProperly() {
// After simplification, all strings are handled consistently without special decimal logic
// This ensures no false equality issues while maintaining predictable ordering
assertThat(comparator.compare("file-1.2.3.txt", "file-1.2.4.txt")).isNegative();
assertThat(comparator.compare("image-1.4.3.png", "image-1.4.6.png")).isNegative();

// Decimal-like strings are compared with numeric logic for digit parts
assertThat(comparator.compare("price-1.23", "price-1.24")).isNegative();
assertThat(comparator.compare("ratio-0.75", "ratio-0.8")).isPositive(); // "75" > "8" numerically
}

@Test
public void shouldHandleComplexFilePaths() {
// Test more complex file path scenarios
assertThat(comparator.compare("/path/to/file-1.2.3.backup.zip", "/path/to/file-1.2.4.backup.zip"))
.isNegative();
assertThat(comparator.compare("/assets/js/app-1.0.0.min.js", "/assets/js/app-1.0.1.min.js"))
.isNegative();
assertThat(comparator.compare("/downloads/software-v2.1.3.exe", "/downloads/software-v2.1.4.exe"))
.isNegative();
}

@Test
public void shouldHandleEdgeCases() {
// Test edge cases that might have caused issues in the original implementation
assertThat(comparator.compare("1.2.3.4.5", "1.2.3.4.6")).isNegative();
assertThat(comparator.compare("a.1.2.3.b", "a.1.2.4.b")).isNegative();
assertThat(comparator.compare("test.1.2.3", "test.1.2.4")).isNegative();

// Test with mixed patterns
assertThat(comparator.compare("app-1.2.3-beta.1", "app-1.2.3-beta.2")).isNegative();
assertThat(comparator.compare("lib-1.2.3.final", "lib-1.2.4.final")).isNegative();
}
}
}
Loading
Loading