Skip to content

Commit c678f95

Browse files
committed
added natural string sort for in memory database
Signed-off-by: Jeeva Kandasamy <jkandasa@gmail.com>
1 parent 69c3588 commit c678f95

File tree

2 files changed

+187
-2
lines changed

2 files changed

+187
-2
lines changed

pkg/utils/filter_sort/utils_sort.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"reflect"
55
"sort"
66
"time"
7+
"unicode"
78

89
storageTY "github.com/mycontroller-org/server/v2/plugin/database/storage/types"
910
)
@@ -40,6 +41,46 @@ func Sort(entities []interface{}, pagination *storageTY.Pagination) ([]interface
4041
return entities, entitiesCount
4142
}
4243

44+
// naturalStringLess compares strings using natural sort order
45+
// Numbers within strings are compared numerically
46+
func naturalStringLess(a, b string) bool {
47+
aRunes := []rune(a)
48+
bRunes := []rune(b)
49+
i, j := 0, 0
50+
51+
for i < len(aRunes) && j < len(bRunes) {
52+
// Check if both are digits
53+
if unicode.IsDigit(aRunes[i]) && unicode.IsDigit(bRunes[j]) {
54+
// Extract numbers
55+
aNum, aEnd := extractNumber(aRunes, i)
56+
bNum, bEnd := extractNumber(bRunes, j)
57+
58+
if aNum != bNum {
59+
return aNum < bNum
60+
}
61+
i = aEnd
62+
j = bEnd
63+
} else {
64+
if aRunes[i] != bRunes[j] {
65+
return aRunes[i] < bRunes[j]
66+
}
67+
i++
68+
j++
69+
}
70+
}
71+
return len(aRunes) < len(bRunes)
72+
}
73+
74+
// extractNumber extracts a number from rune slice starting at pos
75+
func extractNumber(runes []rune, pos int) (int, int) {
76+
num := 0
77+
for pos < len(runes) && unicode.IsDigit(runes[pos]) {
78+
num = num*10 + int(runes[pos]-'0')
79+
pos++
80+
}
81+
return num, pos
82+
}
83+
4384
// GetSortByKeyPath returns the slice in order
4485
func GetSortByKeyPath(keyPath, orderBy string, data []interface{}) []interface{} {
4586
sort.Slice(data, func(a, b int) bool {
@@ -64,9 +105,9 @@ func GetSortByKeyPath(keyPath, orderBy string, data []interface{}) []interface{}
64105
return false
65106
}
66107
if orderBy == storageTY.SortByASC {
67-
return aFinalValue < bFinalValue
108+
return naturalStringLess(aFinalValue, bFinalValue)
68109
}
69-
return aFinalValue > bFinalValue
110+
return naturalStringLess(bFinalValue, aFinalValue)
70111

71112
case reflect.Int:
72113
aFinalValue, aOK := aValue.(int)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package helper
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestNaturalStringLess(t *testing.T) {
10+
testCases := []struct {
11+
name string
12+
a string
13+
b string
14+
expected bool
15+
}{
16+
{
17+
name: "simple numeric comparison",
18+
a: "file2",
19+
b: "file10",
20+
expected: true,
21+
},
22+
{
23+
name: "simple numeric comparison reversed",
24+
a: "file10",
25+
b: "file2",
26+
expected: false,
27+
},
28+
{
29+
name: "equal strings",
30+
a: "file1",
31+
b: "file1",
32+
expected: false,
33+
},
34+
{
35+
name: "no numbers - alphabetical",
36+
a: "apple",
37+
b: "banana",
38+
expected: true,
39+
},
40+
{
41+
name: "no numbers - alphabetical reversed",
42+
a: "banana",
43+
b: "apple",
44+
expected: false,
45+
},
46+
{
47+
name: "multiple digits",
48+
a: "item100",
49+
b: "item99",
50+
expected: false,
51+
},
52+
{
53+
name: "multiple numbers in string",
54+
a: "version2.10.5",
55+
b: "version2.9.5",
56+
expected: false,
57+
},
58+
{
59+
name: "prefix with numbers",
60+
a: "10file",
61+
b: "2file",
62+
expected: false,
63+
},
64+
{
65+
name: "mixed alphanumeric",
66+
a: "test1abc2",
67+
b: "test1abc10",
68+
expected: true,
69+
},
70+
{
71+
name: "one with number one without",
72+
a: "file",
73+
b: "file2",
74+
expected: true,
75+
},
76+
{
77+
name: "different lengths same prefix",
78+
a: "test",
79+
b: "test123",
80+
expected: true,
81+
},
82+
{
83+
name: "zero padding",
84+
a: "file001",
85+
b: "file2",
86+
expected: true,
87+
},
88+
{
89+
name: "large numbers",
90+
a: "item999",
91+
b: "item1000",
92+
expected: true,
93+
},
94+
{
95+
name: "unicode characters",
96+
a: "文件2",
97+
b: "文件10",
98+
expected: true,
99+
},
100+
{
101+
name: "spaces in string",
102+
a: "file 2",
103+
b: "file 10",
104+
expected: true,
105+
},
106+
{
107+
name: "dash separator with numbers",
108+
a: "file-2",
109+
b: "file-10",
110+
expected: true, // compares 'file-' prefix, then 2 < 10 numerically
111+
},
112+
{
113+
name: "empty strings",
114+
a: "",
115+
b: "file",
116+
expected: true,
117+
},
118+
{
119+
name: "both empty strings",
120+
a: "",
121+
b: "",
122+
expected: false,
123+
},
124+
{
125+
name: "only numbers",
126+
a: "123",
127+
b: "45",
128+
expected: false,
129+
},
130+
{
131+
name: "special characters",
132+
a: "file#2",
133+
b: "file#10",
134+
expected: true,
135+
},
136+
}
137+
138+
for _, tc := range testCases {
139+
t.Run(tc.name, func(t *testing.T) {
140+
result := naturalStringLess(tc.a, tc.b)
141+
assert.Equal(t, tc.expected, result, "naturalStringLess(%q, %q) should return %v", tc.a, tc.b, tc.expected)
142+
})
143+
}
144+
}

0 commit comments

Comments
 (0)