diff --git a/go.mod b/go.mod
index 9126208..326f8f0 100644
--- a/go.mod
+++ b/go.mod
@@ -8,8 +8,8 @@ require (
github.com/gobwas/glob v0.2.3
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
- golang.org/x/crypto v0.38.0
- golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
+ golang.org/x/crypto v0.39.0
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
google.golang.org/protobuf v1.36.6
)
diff --git a/go.sum b/go.sum
index dd852ef..ea4b4ac 100644
--- a/go.sum
+++ b/go.sum
@@ -24,8 +24,12 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/password/go.sum b/password/go.sum
index c0d8114..e9812d1 100644
--- a/password/go.sum
+++ b/password/go.sum
@@ -1,5 +1,3 @@
-golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
-golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
diff --git a/query_parser/README.md b/query_parser/README.md
new file mode 100644
index 0000000..49f503f
--- /dev/null
+++ b/query_parser/README.md
@@ -0,0 +1,70 @@
+# 查询解析器
+
+## 排序规则
+
+排序操作本质上是`SQL`里面的`Order By`条件。
+
+| 序列 | 示例 | 备注 |
+|----|--------------------|--------------|
+| 升序 | `["type"]` | |
+| 降序 | `["-create_time"]` | 字段名前加`-`是为降序 |
+
+## 过滤规则
+
+过滤器操作本质上是`SQL`里面的`WHERE`条件。
+
+过滤器的规则,遵循了Python的ORM的规则,比如:
+
+- [Tortoise ORM Filtering](https://tortoise.github.io/query.html#filtering)。
+- [Django Field lookups](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#field-lookups)
+
+如果只是普通的查询,只需要传递`字段名`即可,但是如果需要一些特殊的查询,那么就需要加入`操作符`了。
+
+特殊查询的语法规则其实很简单,就是使用双下划线`__`分割字段名和操作符:
+
+```text
+{字段名}__{查找类型} : {值}
+{字段名}.{JSON字段名}__{查找类型} : {值}
+```
+
+| 查找类型 | 示例 | SQL | 备注 |
+|-------------|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
+| not | `{"name__not" : "tom"}` | `WHERE NOT ("name" = "tom")` | |
+| in | `{"name__in" : "[\"tom\", \"jimmy\"]"}` | `WHERE name IN ("tom", "jimmy")` | |
+| not_in | `{"name__not_in" : "[\"tom\", \"jimmy\"]"}` | `WHERE name NOT IN ("tom", "jimmy")` | |
+| gte | `{"create_time__gte" : "2023-10-25"}` | `WHERE "create_time" >= "2023-10-25"` | |
+| gt | `{"create_time__gt" : "2023-10-25"}` | `WHERE "create_time" > "2023-10-25"` | |
+| lte | `{"create_time__lte" : "2023-10-25"}` | `WHERE "create_time" <= "2023-10-25"` | |
+| lt | `{"create_time__lt" : "2023-10-25"}` | `WHERE "create_time" < "2023-10-25"` | |
+| range | `{"create_time__range" : "[\"2023-10-25\", \"2024-10-25\"]"}` | `WHERE "create_time" BETWEEN "2023-10-25" AND "2024-10-25"`
或
`WHERE "create_time" >= "2023-10-25" AND "create_time" <= "2024-10-25"` | 需要注意的是:
1. 有些数据库的BETWEEN实现的开闭区间可能不一样。
2. 日期`2005-01-01`会被隐式转换为:`2005-01-01 00:00:00`,两个日期一致就会导致查询不到数据。 |
+| isnull | `{"name__isnull" : "True"}` | `WHERE name IS NULL` | |
+| not_isnull | `{"name__not_isnull" : "False"}` | `WHERE name IS NOT NULL` | |
+| contains | `{"name__contains" : "L"}` | `WHERE name LIKE '%L%';` | |
+| icontains | `{"name__icontains" : "L"}` | `WHERE name ILIKE '%L%';` | |
+| startswith | `{"name__startswith" : "La"}` | `WHERE name LIKE 'La%';` | |
+| istartswith | `{"name__istartswith" : "La"}` | `WHERE name ILIKE 'La%';` | |
+| endswith | `{"name__endswith" : "a"}` | `WHERE name LIKE '%a';` | |
+| iendswith | `{"name__iendswith" : "a"}` | `WHERE name ILIKE '%a';` | |
+| exact | `{"name__exact" : "a"}` | `WHERE name LIKE 'a';` | |
+| iexact | `{"name__iexact" : "a"}` | `WHERE name ILIKE 'a';` | |
+| regex | `{"title__regex" : "^(An?\|The) +"}` | MySQL: `WHERE title REGEXP BINARY '^(An?\|The) +'`
Oracle: `WHERE REGEXP_LIKE(title, '^(An?\|The) +', 'c');`
PostgreSQL: `WHERE title ~ '^(An?\|The) +';`
SQLite: `WHERE title REGEXP '^(An?\|The) +';` | |
+| iregex | `{"title__iregex" : "^(an?\|the) +"}` | MySQL: `WHERE title REGEXP '^(an?\|the) +'`
Oracle: `WHERE REGEXP_LIKE(title, '^(an?\|the) +', 'i');`
PostgreSQL: `WHERE title ~* '^(an?\|the) +';`
SQLite: `WHERE title REGEXP '(?i)^(an?\|the) +';` | |
+| search | | | |
+
+以及将日期提取出来的查找类型:
+
+| 查找类型 | 示例 | SQL | 备注 |
+|--------------|--------------------------------------|---------------------------------------------------|----------------------|
+| date | `{"pub_date__date" : "2023-01-01"}` | `WHERE DATE(pub_date) = '2023-01-01'` | |
+| year | `{"pub_date__year" : "2023"}` | `WHERE EXTRACT('YEAR' FROM pub_date) = '2023'` | 哪一年 |
+| iso_year | `{"pub_date__iso_year" : "2023"}` | `WHERE EXTRACT('ISOYEAR' FROM pub_date) = '2023'` | ISO 8601 一年中的周数 |
+| month | `{"pub_date__month" : "12"}` | `WHERE EXTRACT('MONTH' FROM pub_date) = '12'` | 月份,1-12 |
+| day | `{"pub_date__day" : "3"}` | `WHERE EXTRACT('DAY' FROM pub_date) = '3'` | 该月的某天(1-31) |
+| week | `{"pub_date__week" : "7"}` | `WHERE EXTRACT('WEEK' FROM pub_date) = '7'` | ISO 8601 周编号 一年中的周数 |
+| week_day | `{"pub_date__week_day" : "tom"}` | `` | 星期几 |
+| iso_week_day | `{"pub_date__iso_week_day" : "tom"}` | `` | |
+| quarter | `{"pub_date__quarter" : "1"}` | `WHERE EXTRACT('QUARTER' FROM pub_date) = '1'` | 一年中的季度 |
+| time | `{"pub_date__time" : "12:59:59"}` | `` | |
+| hour | `{"pub_date__hour" : "12"}` | `WHERE EXTRACT('HOUR' FROM pub_date) = '12'` | 小时(0-23) |
+| minute | `{"pub_date__minute" : "59"}` | `WHERE EXTRACT('MINUTE' FROM pub_date) = '59'` | 分钟 (0-59) |
+| second | `{"pub_date__second" : "59"}` | `WHERE EXTRACT('SECOND' FROM pub_date) = '59'` | 秒 (0-59) |
diff --git a/query_parser/filter.go b/query_parser/filter.go
new file mode 100644
index 0000000..59b1205
--- /dev/null
+++ b/query_parser/filter.go
@@ -0,0 +1,126 @@
+package query_parser
+
+import (
+ "strings"
+
+ "github.com/go-kratos/kratos/v2/encoding"
+ _ "github.com/go-kratos/kratos/v2/encoding/json"
+
+ "github.com/tx7do/go-utils/stringcase"
+)
+
+type FilterOperator string
+
+const (
+ FilterNot = "not" // 不等于
+ FilterIn = "in" // 检查值是否在列表中
+ FilterNotIn = "not_in" // 不在列表中
+ FilterGTE = "gte" // 大于或等于传递的值
+ FilterGT = "gt" // 大于传递值
+ FilterLTE = "lte" // 小于或等于传递值
+ FilterLT = "lt" // 小于传递值
+ FilterRange = "range" // 是否介于和给定的两个值之间
+ FilterIsNull = "isnull" // 是否为空
+ FilterNotIsNull = "not_isnull" // 是否不为空
+ FilterContains = "contains" // 是否包含指定的子字符串
+ FilterInsensitiveContains = "icontains" // 不区分大小写,是否包含指定的子字符串
+ FilterStartsWith = "startswith" // 以值开头
+ FilterInsensitiveStartsWith = "istartswith" // 不区分大小写,以值开头
+ FilterEndsWith = "endswith" // 以值结尾
+ FilterInsensitiveEndsWith = "iendswith" // 不区分大小写,以值结尾
+ FilterExact = "exact" // 精确匹配
+ FilterInsensitiveExact = "iexact" // 不区分大小写,精确匹配
+ FilterRegex = "regex" // 正则表达式
+ FilterInsensitiveRegex = "iregex" // 不区分大小写,正则表达式
+ FilterSearch = "search" // 全文搜索
+)
+
+const (
+ DatePartDate = "date" // 日期
+ DatePartYear = "year" // 年
+ DatePartISOYear = "iso_year" // ISO8601 一年中的周数
+ DatePartQuarter = "quarter" // 季度
+ DatePartMonth = "month" // 月
+ DatePartWeek = "week" // ISO8601 周编号 一年中的周数
+ DatePartWeekDay = "week_day" // 星期几
+ DatePartISOWeekDay = "iso_week_day" // ISO8601 星期几
+ DatePartDay = "day" // 日
+ DatePartTime = "time" // 小时:分钟:秒
+ DatePartHour = "hour" // 小时
+ DatePartMinute = "minute" // 分钟
+ DatePartSecond = "second" // 秒
+ DatePartMicrosecond = "microsecond" // 微秒
+)
+
+const (
+ QueryDelimiter = "__" // 分隔符
+ JsonFieldDelimiter = "." // JSONB字段分隔符
+)
+
+type FilterHandler func(field, operator, value string)
+
+// ParseFilterJSONString 解析过滤条件的JSON字符串,调用处理函数
+func ParseFilterJSONString(query string, handler FilterHandler) error {
+ if query == "" {
+ return nil
+ }
+
+ codec := encoding.GetCodec("json")
+
+ var err error
+ queryMap := make(map[string]string)
+ if err = codec.Unmarshal([]byte(query), &queryMap); err == nil {
+ for k, v := range queryMap {
+ ParseFilterField(k, v, handler)
+ }
+ return nil
+ }
+
+ var queryMapArray []map[string]string
+ if err = codec.Unmarshal([]byte(query), &queryMapArray); err == nil {
+ for _, item := range queryMapArray {
+ for k, v := range item {
+ ParseFilterField(k, v, handler)
+ }
+ }
+ return nil
+ }
+
+ return err
+}
+
+// ParseFilterField 解析过滤条件字符串,调用处理函数
+func ParseFilterField(key, value string, handler FilterHandler) {
+ if key == "" || value == "" {
+ return // 没有过滤条件
+ }
+
+ // 处理字段和操作符
+ parts := SplitFieldAndOperator(key)
+ if len(parts) < 1 {
+ return // 无效的字段格式
+ }
+
+ field := strings.TrimSpace(parts[0])
+ if field == "" {
+ return
+ }
+ field = stringcase.ToSnakeCase(parts[0])
+
+ op := ""
+ if len(parts) > 1 {
+ op = parts[1]
+ }
+
+ handler(field, op, value)
+}
+
+// SplitFieldAndOperator 将字段字符串按分隔符分割成字段名和操作符
+func SplitFieldAndOperator(field string) []string {
+ return strings.Split(field, QueryDelimiter)
+}
+
+// SplitJSONField 将JSONB字段字符串按分隔符分割成多个字段
+func SplitJSONField(field string) []string {
+ return strings.Split(field, JsonFieldDelimiter)
+}
diff --git a/query_parser/filter_test.go b/query_parser/filter_test.go
new file mode 100644
index 0000000..32c8738
--- /dev/null
+++ b/query_parser/filter_test.go
@@ -0,0 +1,181 @@
+package query_parser
+
+import (
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestParseFilterJSONString(t *testing.T) {
+ var results []struct {
+ Field string
+ Operator string
+ Value string
+ }
+
+ handler := func(field, operator, value string) {
+ results = append(results, struct {
+ Field string
+ Operator string
+ Value string
+ }{Field: field, Operator: operator, Value: value})
+ }
+
+ // 测试解析单个过滤条件
+ results = nil
+ err := ParseFilterJSONString(`{"name__exact":"John"}`, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.Equal(t, "exact", results[0].Operator)
+ assert.Equal(t, "John", results[0].Value)
+
+ // 测试解析多个过滤条件
+ results = nil
+ err = ParseFilterJSONString(`[{"age__gte":"30"},{"status__exact":"active"}]`, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, len(results))
+ assert.Equal(t, "age", results[0].Field)
+ assert.Equal(t, "gte", results[0].Operator)
+ assert.Equal(t, "30", results[0].Value)
+ assert.Equal(t, "status", results[1].Field)
+ assert.Equal(t, "exact", results[1].Operator)
+ assert.Equal(t, "active", results[1].Value)
+
+ // 测试空字符串
+ results = nil
+ err = ParseFilterJSONString("", handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(results))
+
+ // 测试无效的JSON字符串
+ results = nil
+ err = ParseFilterJSONString(`invalid_json`, handler)
+ assert.Error(t, err)
+ assert.Equal(t, 0, len(results))
+
+ // 测试包含特殊字符的字段和值
+ results = nil
+ err = ParseFilterJSONString(`{"na!me__exact":"Jo@hn"}`, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "na!me", results[0].Field)
+ assert.Equal(t, "exact", results[0].Operator)
+ assert.Equal(t, "Jo@hn", results[0].Value)
+
+ // 测试包含特殊字符的多个过滤条件
+ results = nil
+ err = ParseFilterJSONString(`[{"ag#e__gte":"30"},{"sta$tus__exact":"ac^tive"}]`, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, len(results))
+ assert.Equal(t, "ag#e", results[0].Field)
+ assert.Equal(t, "gte", results[0].Operator)
+ assert.Equal(t, "30", results[0].Value)
+ assert.Equal(t, "sta$tus", results[1].Field)
+ assert.Equal(t, "exact", results[1].Operator)
+ assert.Equal(t, "ac^tive", results[1].Value)
+
+ // 测试包含特殊字符的无效 JSON 字符串
+ results = nil
+ err = ParseFilterJSONString(`{"na!me__exact":Jo@hn}`, handler)
+ assert.Error(t, err)
+ assert.Equal(t, 0, len(results))
+}
+
+func TestParseFilterField(t *testing.T) {
+ var results []struct {
+ Field string
+ Operator string
+ Value string
+ }
+
+ handler := func(field, operator, value string) {
+ results = append(results, struct {
+ Field string
+ Operator string
+ Value string
+ }{Field: field, Operator: operator, Value: value})
+ }
+
+ // 测试正常解析
+ results = nil
+ ParseFilterField("name__exact", "John", handler)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.Equal(t, "exact", results[0].Operator)
+ assert.Equal(t, "John", results[0].Value)
+
+ // 测试无操作符解析
+ results = nil
+ ParseFilterField("name", "John", handler)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.Equal(t, "", results[0].Operator)
+ assert.Equal(t, "John", results[0].Value)
+
+ // 测试空字段
+ results = nil
+ ParseFilterField("", "John", handler)
+ assert.Equal(t, 0, len(results))
+
+ // 测试空值
+ results = nil
+ ParseFilterField("name__exact", "", handler)
+ assert.Equal(t, 0, len(results))
+
+ // 测试无效字段格式
+ results = nil
+ ParseFilterField("__exact", "John", handler)
+ assert.Equal(t, 0, len(results))
+}
+
+func TestSplitFieldAndOperator(t *testing.T) {
+ // 测试正常分割
+ result := SplitFieldAndOperator("name__exact")
+ assert.Equal(t, 2, len(result))
+ assert.Equal(t, "name", result[0])
+ assert.Equal(t, "exact", result[1])
+
+ // 测试无操作符
+ result = SplitFieldAndOperator("name")
+ assert.Equal(t, 1, len(result))
+ assert.Equal(t, "name", result[0])
+
+ // 测试空字符串
+ result = SplitFieldAndOperator("")
+ assert.Equal(t, 1, len(result))
+ assert.Equal(t, "", result[0])
+
+ // 测试多个分隔符
+ result = SplitFieldAndOperator("name__exact__extra")
+ assert.Equal(t, 3, len(result))
+ assert.Equal(t, "name", result[0])
+ assert.Equal(t, "exact", result[1])
+ assert.Equal(t, "extra", result[2])
+}
+
+func TestSplitJSONField(t *testing.T) {
+ // 测试正常分割
+ result := SplitJSONField("user.address.city")
+ assert.Equal(t, 3, len(result))
+ assert.Equal(t, "user", result[0])
+ assert.Equal(t, "address", result[1])
+ assert.Equal(t, "city", result[2])
+
+ // 测试单个字段
+ result = SplitJSONField("user")
+ assert.Equal(t, 1, len(result))
+ assert.Equal(t, "user", result[0])
+
+ // 测试空字符串
+ result = SplitJSONField("")
+ assert.Equal(t, 1, len(result))
+ assert.Equal(t, "", result[0])
+
+ // 测试多个分隔符
+ result = SplitJSONField("user..address.city")
+ assert.Equal(t, 4, len(result))
+ assert.Equal(t, "user", result[0])
+ assert.Equal(t, "", result[1])
+ assert.Equal(t, "address", result[2])
+ assert.Equal(t, "city", result[3])
+}
diff --git a/query_parser/go.mod b/query_parser/go.mod
new file mode 100644
index 0000000..b6ece87
--- /dev/null
+++ b/query_parser/go.mod
@@ -0,0 +1,22 @@
+module github.com/tx7do/go-utils/query_parser
+
+go 1.23.0
+
+toolchain go1.23.2
+
+require (
+ github.com/go-kratos/kratos/v2 v2.8.4
+ github.com/stretchr/testify v1.10.0
+ github.com/tx7do/go-utils v1.1.28
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace github.com/tx7do/go-utils => ../
diff --git a/query_parser/orderby.go b/query_parser/orderby.go
new file mode 100644
index 0000000..b1e6275
--- /dev/null
+++ b/query_parser/orderby.go
@@ -0,0 +1,50 @@
+package query_parser
+
+import (
+ "strings"
+)
+
+type OrderByHandler func(field string, desc bool)
+
+// ParseOrderByString 解析排序字符串,调用处理函数。
+func ParseOrderByString(orderBy string, handler OrderByHandler) error {
+ if orderBy == "" {
+ return nil
+ }
+
+ parts := strings.Split(orderBy, ",")
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ ParseOrderByField(part, handler)
+ }
+
+ return nil
+}
+
+// ParseOrderByStrings 解析多个排序字符串,调用处理函数。
+func ParseOrderByStrings(orderBys []string, handler OrderByHandler) error {
+ for _, v := range orderBys {
+ if v == "" {
+ continue
+ }
+
+ ParseOrderByField(v, handler)
+ }
+ return nil
+}
+
+// ParseOrderByField 解析单个排序字段,调用处理函数。
+func ParseOrderByField(orderBy string, handler OrderByHandler) {
+ orderBy = strings.TrimSpace(orderBy)
+ if orderBy == "" {
+ return // 没有排序条件
+ }
+
+ if strings.HasPrefix(orderBy, "-") {
+ handler(orderBy[1:], true) // 降序
+ } else if strings.HasPrefix(orderBy, "+") {
+ handler(orderBy[1:], false) // 升序
+ } else {
+ handler(orderBy, false) // 升序
+ }
+}
diff --git a/query_parser/orderby_test.go b/query_parser/orderby_test.go
new file mode 100644
index 0000000..9713f3b
--- /dev/null
+++ b/query_parser/orderby_test.go
@@ -0,0 +1,126 @@
+package query_parser
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseOrderByString(t *testing.T) {
+ var results []struct {
+ Field string
+ Desc bool
+ }
+
+ handler := func(field string, desc bool) {
+ results = append(results, struct {
+ Field string
+ Desc bool
+ }{Field: field, Desc: desc})
+ }
+
+ // 测试正常解析
+ err := ParseOrderByString("name,-age,+created_at", handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.False(t, results[0].Desc)
+ assert.Equal(t, "age", results[1].Field)
+ assert.True(t, results[1].Desc)
+ assert.Equal(t, "created_at", results[2].Field)
+ assert.False(t, results[2].Desc)
+
+ // 测试空字符串
+ results = nil
+ err = ParseOrderByString("", handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(results))
+
+ // 测试只有空格的字符串
+ results = nil
+ err = ParseOrderByString(" ", handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(results))
+}
+
+func TestParseOrderByStrings(t *testing.T) {
+ var results []struct {
+ Field string
+ Desc bool
+ }
+
+ handler := func(field string, desc bool) {
+ results = append(results, struct {
+ Field string
+ Desc bool
+ }{Field: field, Desc: desc})
+ }
+
+ // 测试正常解析
+ err := ParseOrderByStrings([]string{"name", "-age", "+created_at"}, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.False(t, results[0].Desc)
+ assert.Equal(t, "age", results[1].Field)
+ assert.True(t, results[1].Desc)
+ assert.Equal(t, "created_at", results[2].Field)
+ assert.False(t, results[2].Desc)
+
+ // 测试空字符串数组
+ results = nil
+ err = ParseOrderByStrings([]string{}, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(results))
+
+ // 测试包含空字符串的数组
+ results = nil
+ err = ParseOrderByStrings([]string{"", " "}, handler)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, len(results))
+}
+
+func TestParseOrderByField(t *testing.T) {
+ var results []struct {
+ Field string
+ Desc bool
+ }
+
+ handler := func(field string, desc bool) {
+ results = append(results, struct {
+ Field string
+ Desc bool
+ }{Field: field, Desc: desc})
+ }
+
+ // 测试升序解析
+ results = nil
+ ParseOrderByField("name", handler)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "name", results[0].Field)
+ assert.False(t, results[0].Desc)
+
+ // 测试降序解析
+ results = nil
+ ParseOrderByField("-age", handler)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "age", results[0].Field)
+ assert.True(t, results[0].Desc)
+
+ // 测试带+的升序解析
+ results = nil
+ ParseOrderByField("+created_at", handler)
+ assert.Equal(t, 1, len(results))
+ assert.Equal(t, "created_at", results[0].Field)
+ assert.False(t, results[0].Desc)
+
+ // 测试空字符串
+ results = nil
+ ParseOrderByField("", handler)
+ assert.Equal(t, 0, len(results))
+
+ // 测试只有空格的字符串
+ results = nil
+ ParseOrderByField(" ", handler)
+ assert.Equal(t, 0, len(results))
+}
diff --git a/stringcase/camel_case.go b/stringcase/camel_case.go
new file mode 100644
index 0000000..ae16437
--- /dev/null
+++ b/stringcase/camel_case.go
@@ -0,0 +1,65 @@
+package stringcase
+
+import (
+ "strings"
+ "unicode"
+)
+
+func UpperCamelCase(input string) string {
+ return camelCase(input, true)
+}
+
+func LowerCamelCase(input string) string {
+ return camelCase(input, false)
+}
+
+// ToPascalCase 把字符转换为 帕斯卡命名/大驼峰命名法(CamelCase)
+func ToPascalCase(input string) string {
+ return camelCase(input, true)
+}
+
+func camelCase(input string, upper bool) string {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return input
+ }
+
+ // 分割字符串
+ words := Split(input)
+ if len(words) == 0 {
+ return ""
+ }
+
+ filteredWords := make([]string, 0, len(words))
+ for _, word := range words {
+ if strings.TrimSpace(word) != "" {
+ filteredWords = append(filteredWords, word)
+ }
+ }
+ words = filteredWords
+ if len(words) == 0 {
+ return ""
+ }
+
+ for i, word := range words {
+ if word == "" {
+ continue
+ }
+
+ runes := []rune(word)
+ if len(runes) > 0 {
+ if i == 0 && !upper {
+ runes[0] = unicode.ToLower(runes[0]) // LowerCamelCase首单词首字母小写
+ } else {
+ runes[0] = unicode.ToUpper(runes[0]) // UpperCamelCase或后续单词首字母大写
+ }
+ for j := 1; j < len(runes); j++ {
+ runes[j] = unicode.ToLower(runes[j]) // 其余字母统一小写
+ }
+ words[i] = string(runes)
+ }
+ }
+
+ // 合并结果
+ return strings.Join(words, "")
+}
diff --git a/stringcase/camel_case_test.go b/stringcase/camel_case_test.go
new file mode 100644
index 0000000..06cf124
--- /dev/null
+++ b/stringcase/camel_case_test.go
@@ -0,0 +1,111 @@
+package stringcase
+
+import (
+ "testing"
+)
+
+func TestUpperCamelCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"hello world", "HelloWorld"},
+ {"hello_world", "HelloWorld"},
+ {"hello-world", "HelloWorld"},
+ {"hello.world", "HelloWorld"},
+ {"helloWorld", "HelloWorld"},
+ {"HelloWorld", "HelloWorld"},
+ {"HTTPStatusCode", "HttpStatusCode"},
+ {"ParseURL.DoParse", "ParseUrlDoParse"},
+ {"ParseUrl.DoParse", "ParseUrlDoParse"},
+ {"parse_url.do_parse", "ParseUrlDoParse"},
+ {"convert space", "ConvertSpace"},
+ {"convert-dash", "ConvertDash"},
+ {"skip___multiple_underscores", "SkipMultipleUnderscores"},
+ {"skip multiple spaces", "SkipMultipleSpaces"},
+ {"skip---multiple-dashes", "SkipMultipleDashes"},
+ {"", ""},
+ {"a", "A"},
+ {"Z", "Z"},
+ {"special-characters_test", "SpecialCharactersTest"},
+ {"numbers123test", "Numbers123Test"},
+ {"hello world!", "HelloWorld"},
+ {"test@with#symbols", "TestWithSymbols"},
+ {"complexCase123!@#", "ComplexCase123"},
+
+ {"snake_case_string", "SnakeCaseString"},
+ {"kebab-case-string", "KebabCaseString"},
+ {"PascalCaseString", "PascalCaseString"},
+ {"camelCaseString", "CamelCaseString"},
+ {"HTTPRequest", "HttpRequest"},
+ {"user ID", "UserId"},
+ {"UserId", "UserId"},
+ {"userID", "UserId"},
+ {"UserID", "UserId"},
+ {"123NumberPrefix", "123NumberPrefix"},
+ {"__leading_underscores", "LeadingUnderscores"},
+ {"trailing_underscores__", "TrailingUnderscores"},
+ {"multiple___underscores", "MultipleUnderscores"},
+ {" spaces around ", "SpacesAround"},
+ }
+
+ for _, test := range tests {
+ result := UpperCamelCase(test.input)
+ if result != test.expected {
+ t.Errorf("UpperCamelCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestLowerCamelCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"hello world", "helloWorld"},
+ {"hello_world", "helloWorld"},
+ {"hello-world", "helloWorld"},
+ {"hello.world", "helloWorld"},
+ {"helloWorld", "helloWorld"},
+ {"HelloWorld", "helloWorld"},
+ {"HTTPStatusCode", "httpStatusCode"},
+ {"ParseURL.DoParse", "parseUrlDoParse"},
+ {"ParseUrl.DoParse", "parseUrlDoParse"},
+ {"parse_url.do_parse", "parseUrlDoParse"},
+ {"convert space", "convertSpace"},
+ {"convert-dash", "convertDash"},
+ {"skip___multiple_underscores", "skipMultipleUnderscores"},
+ {"skip multiple spaces", "skipMultipleSpaces"},
+ {"skip---multiple-dashes", "skipMultipleDashes"},
+ {"", ""},
+ {"a", "a"},
+ {"Z", "z"},
+ {"special-characters_test", "specialCharactersTest"},
+ {"numbers123test", "numbers123Test"},
+ {"hello world!", "helloWorld"},
+ {"test@with#symbols", "testWithSymbols"},
+ {"complexCase123!@#", "complexCase123"},
+
+ {"snake_case_string", "snakeCaseString"},
+ {"kebab-case-string", "kebabCaseString"},
+ {"PascalCaseString", "pascalCaseString"},
+ {"camelCaseString", "camelCaseString"},
+ {"HTTPRequest", "httpRequest"},
+ {"user ID", "userId"},
+ {"UserId", "userId"},
+ {"userID", "userId"},
+ {"UserID", "userId"},
+ {"123NumberPrefix", "123NumberPrefix"},
+ {"__leading_underscores", "leadingUnderscores"},
+ {"trailing_underscores__", "trailingUnderscores"},
+ {"multiple___underscores", "multipleUnderscores"},
+ {" spaces around ", "spacesAround"},
+ }
+
+ for _, test := range tests {
+ result := LowerCamelCase(test.input)
+ if result != test.expected {
+ t.Errorf("LowerCamelCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
diff --git a/stringcase/kebab_case.go b/stringcase/kebab_case.go
new file mode 100644
index 0000000..2ff9827
--- /dev/null
+++ b/stringcase/kebab_case.go
@@ -0,0 +1,9 @@
+package stringcase
+
+func KebabCase(s string) string {
+ return delimiterCase(s, '-', false)
+}
+
+func UpperKebabCase(s string) string {
+ return delimiterCase(s, '-', true)
+}
diff --git a/stringcase/kebab_case_test.go b/stringcase/kebab_case_test.go
new file mode 100644
index 0000000..a0a2cf9
--- /dev/null
+++ b/stringcase/kebab_case_test.go
@@ -0,0 +1,49 @@
+package stringcase
+
+import "testing"
+
+func TestKebabCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"HelloWorld", "hello-world"},
+ {"helloWorld", "hello-world"},
+ {"Hello World", "hello-world"},
+ {"hello world!", "hello-world"},
+ {"Numbers123Test", "numbers-123-test"},
+ {"", ""},
+ {"_", ""},
+ {"__Hello__World__", "hello-world"},
+ }
+
+ for _, test := range tests {
+ result := KebabCase(test.input)
+ if result != test.expected {
+ t.Errorf("KebabCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestUpperKebabCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"HelloWorld", "HELLO-WORLD"},
+ {"helloWorld", "HELLO-WORLD"},
+ {"Hello World", "HELLO-WORLD"},
+ {"hello world!", "HELLO-WORLD"},
+ {"Numbers123Test", "NUMBERS-123-TEST"},
+ {"", ""},
+ {"_", ""},
+ {"__Hello__World__", "HELLO-WORLD"},
+ }
+
+ for _, test := range tests {
+ result := UpperKebabCase(test.input)
+ if result != test.expected {
+ t.Errorf("UpperKebabCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
diff --git a/stringcase/snake_case.go b/stringcase/snake_case.go
new file mode 100644
index 0000000..75a6475
--- /dev/null
+++ b/stringcase/snake_case.go
@@ -0,0 +1,50 @@
+package stringcase
+
+import (
+ "strings"
+)
+
+// ToSnakeCase 把字符转换为 蛇形命名法(snake_case)
+func ToSnakeCase(input string) string {
+ return SnakeCase(input)
+}
+
+func SnakeCase(s string) string {
+ return delimiterCase(s, '_', false)
+}
+
+func UpperSnakeCase(s string) string {
+ return delimiterCase(s, '_', true)
+}
+
+func delimiterCase(input string, delimiter rune, upperCase bool) string {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return input
+ }
+
+ // 使用 Split 分割字符串
+ words := Split(input)
+ filteredWords := make([]string, 0, len(words))
+ for _, word := range words {
+ if strings.TrimSpace(word) != "" {
+ filteredWords = append(filteredWords, word)
+ }
+ }
+
+ adjustCase := toLower
+ if upperCase {
+ adjustCase = toUpper
+ }
+
+ for i, word := range filteredWords {
+ runes := []rune(word)
+ for j := 0; j < len(runes); j++ {
+ runes[j] = adjustCase(runes[j])
+ }
+ filteredWords[i] = string(runes)
+ }
+
+ // 使用分隔符连接结果
+ return strings.Join(filteredWords, string(delimiter))
+}
diff --git a/stringcase/snake_case_test.go b/stringcase/snake_case_test.go
new file mode 100644
index 0000000..ce8c057
--- /dev/null
+++ b/stringcase/snake_case_test.go
@@ -0,0 +1,85 @@
+package stringcase
+
+import (
+ "testing"
+)
+
+func TestToSnakeCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"snake_case", "snake_case"},
+ {"CamelCase", "camel_case"},
+ {"lowerCamelCase", "lower_camel_case"},
+ {"F", "f"},
+ {"Foo", "foo"},
+ {"FooB", "foo_b"},
+ {"FooID", "foo_id"},
+ {" FooBar\t", "foo_bar"},
+ {"HTTPStatusCode", "http_status_code"},
+ {"ParseURL.DoParse", "parse_url_do_parse"},
+ {"Convert Space", "convert_space"},
+ {"Convert-dash", "convert_dash"},
+ {"Skip___MultipleUnderscores", "skip_multiple_underscores"},
+ {"Skip MultipleSpaces", "skip_multiple_spaces"},
+ {"Skip---MultipleDashes", "skip_multiple_dashes"},
+ {"Hello World", "hello_world"},
+ {"Multiple Words Example", "multiple_words_example"},
+ {"", ""},
+ {"A", "a"},
+ {"z", "z"},
+ {"Special-Characters_Test", "special_characters_test"},
+ {"Numbers123Test", "numbers_123_test"},
+ {"Hello World!", "hello_world"},
+ {"Test@With#Symbols", "test_with_symbols"},
+ {"ComplexCase123!@#", "complex_case_123"},
+ }
+
+ for _, test := range tests {
+ result := ToSnakeCase(test.input)
+ if result != test.expected {
+ t.Errorf("ToSnakeCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestUpperSnakeCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"snake_case", "SNAKE_CASE"},
+ {"CamelCase", "CAMEL_CASE"},
+ {"lowerCamelCase", "LOWER_CAMEL_CASE"},
+ {"F", "F"},
+ {"Foo", "FOO"},
+ {"FooB", "FOO_B"},
+ {"FooID", "FOO_ID"},
+ {" FooBar\t", "FOO_BAR"},
+ {"HTTPStatusCode", "HTTP_STATUS_CODE"},
+ {"ParseURL.DoParse", "PARSE_URL_DO_PARSE"},
+ {"Convert Space", "CONVERT_SPACE"},
+ {"Convert-dash", "CONVERT_DASH"},
+ {"Skip___MultipleUnderscores", "SKIP_MULTIPLE_UNDERSCORES"},
+ {"Skip MultipleSpaces", "SKIP_MULTIPLE_SPACES"},
+ {"Skip---MultipleDashes", "SKIP_MULTIPLE_DASHES"},
+ {"Hello World", "HELLO_WORLD"},
+ {"Multiple Words Example", "MULTIPLE_WORDS_EXAMPLE"},
+ {"", ""},
+ {"A", "A"},
+ {"z", "Z"},
+ {"Special-Characters_Test", "SPECIAL_CHARACTERS_TEST"},
+ {"Numbers123Test", "NUMBERS_123_TEST"},
+ {"Hello World!", "HELLO_WORLD"},
+ {"Test@With#Symbols", "TEST_WITH_SYMBOLS"},
+ {"ComplexCase123!@#", "COMPLEX_CASE_123"},
+ }
+
+ for _, test := range tests {
+ result := UpperSnakeCase(test.input)
+ if result != test.expected {
+ t.Errorf("UpperSnakeCase(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
diff --git a/stringcase/split.go b/stringcase/split.go
new file mode 100644
index 0000000..5431bbc
--- /dev/null
+++ b/stringcase/split.go
@@ -0,0 +1,144 @@
+package stringcase
+
+import (
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+type runeInfo struct {
+ r rune
+}
+
+// Checks whether or not the rune represented by rInfo is a digit.
+func (rInfo *runeInfo) isDigit() bool {
+ return unicode.IsDigit(rInfo.r)
+}
+
+// Checks whether or not the rune represented by rInfo is an uppercase rune.
+func (rInfo *runeInfo) isUppercase() bool {
+ return unicode.IsUpper(rInfo.r)
+}
+
+// A reader designed for reading "CamelCase" strings.
+type rdr struct {
+ input string // The data this reader operates on.
+ pos int // The position of this reader.
+ hasNextRune bool // A flag indicating if there's a next rune.
+ rdRune runeInfo // Information about the last rune that was read.
+ nxtRune runeInfo // Information about the next rune that's about to be read.
+}
+
+// Read the next rune from r.
+func (r *rdr) readRune() {
+ r.rdRune = runeInfo{rune(r.input[r.pos])}
+ r.pos = r.pos + 1
+ r.hasNextRune = r.pos < len(r.input)
+
+ if r.hasNextRune {
+ r.nxtRune = runeInfo{rune(r.input[r.pos])}
+ }
+}
+
+// Undo the last rune from r.
+func (r *rdr) unreadRune() {
+ r.pos = r.pos - 1
+ r.nxtRune = r.rdRune
+ r.rdRune = runeInfo{rune(r.input[r.pos])}
+ r.hasNextRune = true // NOTE: An undo operation means that there will be always a next rune.
+}
+
+// Verify if the word that's currently read by r is a word that should NOT be split.
+// If noSplit contains a word that starts with the word that's currently read by r, this function returns true, false
+// otherwise.
+func (r *rdr) isNoSplitWord(sIdx int, noSplit []string) bool {
+ return ContainsFn(noSplit, r.input[sIdx:r.pos+1], func(got, want string) bool {
+ return strings.HasPrefix(got, want)
+ })
+}
+
+// Read the next part from r.
+// Each word in noSplit (if provided) is treated as a word that shouldn't be split.
+func (r *rdr) readNextPart(noSplit []string) string {
+ sIdx := r.pos
+
+ r.readRune()
+
+ if r.rdRune.isDigit() {
+ return r.readNumber(sIdx, noSplit)
+ }
+
+ return r.readWord(sIdx, noSplit)
+}
+
+// Read and return a number from r.
+func (r *rdr) readNumber(sIdx int, noSplit []string) string {
+ if r.hasNextRune && r.nxtRune.isDigit() {
+ for r.hasNextRune && (r.nxtRune.isDigit() || r.isNoSplitWord(sIdx, noSplit)) {
+ r.readRune()
+ }
+
+ return r.input[sIdx:r.pos]
+ }
+
+ return r.input[sIdx:r.pos]
+}
+
+// Read and return a word from r.
+func (r *rdr) readWord(sIdx int, noSplit []string) string {
+ if r.hasNextRune && r.nxtRune.isUppercase() {
+ for r.hasNextRune && (r.nxtRune.isUppercase() || r.isNoSplitWord(sIdx, noSplit)) {
+ r.readRune()
+ }
+
+ if r.hasNextRune && (!r.nxtRune.isUppercase() && !r.nxtRune.isDigit()) {
+ r.unreadRune()
+ }
+
+ return r.input[sIdx:r.pos]
+ }
+
+ for r.hasNextRune && (r.isNoSplitWord(sIdx, noSplit) || (!r.nxtRune.isUppercase() && !r.nxtRune.isDigit())) {
+ r.readRune()
+ }
+
+ return r.input[sIdx:r.pos]
+}
+
+// Split reads v treating it as a "CamelCase" and returns the different words.
+// If v isn't a valid UTF-8 string, or when v is an empty string, a slice with one element (v) is returned.
+// Each word in noSplit (if provided) is treated as a word that shouldn't be split.
+func Split(input string, noSplit ...string) []string {
+ if !utf8.ValidString(input) || len(input) == 0 {
+ return []string{input}
+ }
+
+ output := make([]string, 0)
+
+ inputs := SplitByNonAlphanumeric(input)
+ for _, v := range inputs {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ output = append(output, split(v, noSplit...)...)
+ }
+
+ return output
+}
+
+func split(input string, noSplit ...string) []string {
+ if !utf8.ValidString(input) || len(input) == 0 {
+ return []string{input}
+ }
+
+ vRdr := &rdr{input: input}
+ output := make([]string, 0)
+
+ for vRdr.pos < len(input) {
+ part := vRdr.readNextPart(noSplit)
+ output = append(output, part)
+ }
+
+ return output
+}
diff --git a/stringcase/split_test.go b/stringcase/split_test.go
new file mode 100644
index 0000000..4ed301f
--- /dev/null
+++ b/stringcase/split_test.go
@@ -0,0 +1,82 @@
+package stringcase
+
+import (
+ "testing"
+)
+
+func TestSplitSingle(t *testing.T) {
+ input := "URL.DoParse"
+ result := Split(input)
+ t.Logf("Split(%q) = %q;", input, result)
+ t.Log(input[:7])
+}
+
+func TestSplit(t *testing.T) {
+ tests := []struct {
+ input string
+ expected []string
+ }{
+ {"hello world", []string{"hello", "world"}},
+ {"hello_world", []string{"hello", "world"}},
+ {"hello-world", []string{"hello", "world"}},
+ {"hello.world", []string{"hello", "world"}},
+ {"helloWorld", []string{"hello", "World"}},
+ {"HelloWorld", []string{"Hello", "World"}},
+ {"HTTPStatusCode", []string{"HTTP", "Status", "Code"}},
+ {"ParseURLDoParse", []string{"Parse", "URL", "Do", "Parse"}},
+ {"ParseUrlDoParse", []string{"Parse", "Url", "Do", "Parse"}},
+ {"ParseUrl.DoParse", []string{"Parse", "Url", "Do", "Parse"}},
+ {"ParseURL.DoParse", []string{"Parse", "URL", "Do", "Parse"}},
+ {"ParseURL", []string{"Parse", "URL"}},
+ {"ParseURL.", []string{"Parse", "URL"}},
+ {"parse_url.do_parse", []string{"parse", "url", "do", "parse"}},
+ {"convert space", []string{"convert", "space"}},
+ {"convert-dash", []string{"convert", "dash"}},
+ {"skip___multiple_underscores", []string{"skip", "multiple", "underscores"}},
+ {"skip multiple spaces", []string{"skip", "multiple", "spaces"}},
+ {"skip---multiple-dashes", []string{"skip", "multiple", "dashes"}},
+ {"", []string{""}},
+ {"a", []string{"a"}},
+ {"Z", []string{"Z"}},
+ {"special-characters_test", []string{"special", "characters", "test"}},
+ {"numbers123test", []string{"numbers", "123", "test"}},
+ {"hello world!", []string{"hello", "world"}},
+ {"test@with#symbols", []string{"test", "with", "symbols"}},
+ {"complexCase123!@#", []string{"complex", "Case", "123"}},
+
+ {"snake_case_string", []string{"snake", "case", "string"}},
+ {"kebab-case-string", []string{"kebab", "case", "string"}},
+ {"PascalCaseString", []string{"Pascal", "Case", "String"}},
+ {"camelCaseString", []string{"camel", "Case", "String"}},
+ {"HTTPRequest", []string{"HTTP", "Request"}},
+ {"user ID", []string{"user", "ID"}},
+ {"UserId", []string{"User", "Id"}},
+ {"userID", []string{"user", "ID"}},
+ {"UserID", []string{"User", "ID"}},
+ {"123NumberPrefix", []string{"123", "Number", "Prefix"}},
+ {"__leading_underscores", []string{"leading", "underscores"}},
+ {"trailing_underscores__", []string{"trailing", "underscores"}},
+ {"multiple___underscores", []string{"multiple", "underscores"}},
+ {" spaces around ", []string{"spaces", "around"}},
+ }
+
+ for _, test := range tests {
+ result := Split(test.input)
+
+ if !compareStringSlices(result, test.expected) {
+ t.Errorf("Split(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
+
+func compareStringSlices(slice1, slice2 []string) bool {
+ if len(slice1) != len(slice2) {
+ return false
+ }
+ for i := range slice1 {
+ if slice1[i] != slice2[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/stringcase/stringcase.go b/stringcase/stringcase.go
deleted file mode 100644
index f9ac0c3..0000000
--- a/stringcase/stringcase.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package stringcase
-
-import (
- "strings"
- "unicode"
-)
-
-// ToSnakeCase 把字符转换为 蛇形命名法(snake_case)
-func ToSnakeCase(input string) string {
- if input == "" {
- return input
- }
- if len(input) == 1 {
- return strings.ToLower(input)
- }
-
- input = strings.Replace(input, " ", "", -1)
-
- source := []rune(input)
- dist := strings.Builder{}
- dist.Grow(len(input) + len(input)/3) // avoid reallocation memory, 33% ~ 50% is recommended
- skipNext := false
- for i := 0; i < len(source); i++ {
- cur := source[i]
- switch cur {
- case '-', '_':
- dist.WriteRune('_')
- skipNext = true
- continue
- }
- if unicode.IsLower(cur) || unicode.IsDigit(cur) {
- dist.WriteRune(cur)
- continue
- }
-
- if i == 0 {
- dist.WriteRune(unicode.ToLower(cur))
- continue
- }
-
- last := source[i-1]
- if (!unicode.IsLetter(last)) || unicode.IsLower(last) {
- if skipNext {
- skipNext = false
- } else {
- dist.WriteRune('_')
- }
- dist.WriteRune(unicode.ToLower(cur))
- continue
- }
- // last is upper case
- if i < len(source)-1 {
- next := source[i+1]
- if unicode.IsLower(next) {
- if skipNext {
- skipNext = false
- } else {
- dist.WriteRune('_')
- }
- dist.WriteRune(unicode.ToLower(cur))
- continue
- }
- }
- dist.WriteRune(unicode.ToLower(cur))
- }
-
- return dist.String()
-}
-
-// ToPascalCase 把字符转换为 帕斯卡命名/大驼峰命名法(CamelCase)
-func ToPascalCase(input string) string {
- return toCamelCase(input, true)
-}
-
-// ToLowCamelCase 把字符转换为 小驼峰命名法(lowerCamelCase)
-func ToLowCamelCase(input string) string {
- return toCamelCase(input, false)
-}
-
-func toCamelCase(s string, initCase bool) string {
- s = strings.TrimSpace(s)
- if s == "" {
- return s
- }
-
- var uppercaseAcronym = map[string]string{}
- if a, ok := uppercaseAcronym[s]; ok {
- s = a
- }
-
- n := strings.Builder{}
- n.Grow(len(s))
- capNext := initCase
- for i, v := range []byte(s) {
- vIsCap := v >= 'A' && v <= 'Z'
- vIsLow := v >= 'a' && v <= 'z'
- if capNext {
- if vIsLow {
- v += 'A'
- v -= 'a'
- }
- } else if i == 0 {
- if vIsCap {
- v += 'a'
- v -= 'A'
- }
- }
- if vIsCap || vIsLow {
- n.WriteByte(v)
- capNext = false
- } else if vIsNum := v >= '0' && v <= '9'; vIsNum {
- n.WriteByte(v)
- capNext = true
- } else {
- capNext = v == '_' || v == ' ' || v == '-' || v == '.'
- }
- }
- return n.String()
-}
diff --git a/stringcase/stringcase_test.go b/stringcase/stringcase_test.go
deleted file mode 100644
index dede096..0000000
--- a/stringcase/stringcase_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package stringcase
-
-import (
- "fmt"
- "testing"
-)
-
-func TestToLowCamelCase(t *testing.T) {
- fmt.Println(ToLowCamelCase("snake_case"))
- fmt.Println(ToLowCamelCase("CamelCase"))
- fmt.Println(ToLowCamelCase("lowerCamelCase"))
-}
-
-func TestToPascalCase(t *testing.T) {
- fmt.Println(ToPascalCase("snake_case"))
- fmt.Println(ToPascalCase("CamelCase"))
- fmt.Println(ToPascalCase("lowerCamelCase"))
-}
-
-func TestToSnakeCase(t *testing.T) {
- fmt.Println(ToSnakeCase("snake_case"))
- fmt.Println(ToSnakeCase("CamelCase"))
- fmt.Println(ToSnakeCase("lowerCamelCase"))
-}
diff --git a/stringcase/utils.go b/stringcase/utils.go
new file mode 100644
index 0000000..a1da727
--- /dev/null
+++ b/stringcase/utils.go
@@ -0,0 +1,132 @@
+package stringcase
+
+import (
+ "regexp"
+ "strings"
+ "unicode"
+)
+
+func isLower(ch rune) bool {
+ return ch >= 'a' && ch <= 'z'
+}
+
+func toLower(ch rune) rune {
+ if ch >= 'A' && ch <= 'Z' {
+ return ch + 32
+ }
+ return ch
+}
+
+func isUpper(ch rune) bool {
+ return ch >= 'A' && ch <= 'Z'
+}
+
+func toUpper(ch rune) rune {
+ if ch >= 'a' && ch <= 'z' {
+ return ch - 32
+ }
+ return ch
+}
+
+func isSpace(ch rune) bool {
+ return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
+}
+
+func isDigit(ch rune) bool {
+ return ch >= '0' && ch <= '9'
+}
+
+func isDelimiter(ch rune) bool {
+ return ch == '-' || ch == '_' || isSpace(ch)
+}
+
+type iterFunc func(prev, curr, next rune)
+
+func stringIter(s string, callback iterFunc) {
+ var prev rune
+ var curr rune
+ for _, next := range s {
+ if curr == 0 {
+ prev = curr
+ curr = next
+ continue
+ }
+
+ callback(prev, curr, next)
+
+ prev = curr
+ curr = next
+ }
+
+ if len(s) > 0 {
+ callback(prev, curr, 0)
+ }
+}
+
+func isAlpha(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
+
+func ReplaceNonAlphanumeric(s string, replacement string) string {
+ if replacement == "" {
+ replacement = "_"
+ }
+ // 使用正则表达式匹配非英文字母和数字的字符
+ re := regexp.MustCompile("[^a-zA-Z0-9]+")
+ // 替换为指定字符
+ return re.ReplaceAllString(s, replacement)
+}
+
+func SplitByNonAlphanumeric(input string) []string {
+ var builder strings.Builder
+ for _, r := range input {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ builder.WriteRune(r)
+ } else {
+ builder.WriteRune(' ') // 将非英文字符和数字的字符替换为空格
+ }
+ }
+ processedInput := builder.String()
+ return strings.Fields(processedInput) // 使用空格分割字符串
+}
+
+func SplitAndKeepDelimiters(input string) []string {
+ var result []string
+ var builder strings.Builder
+
+ for _, r := range input {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ builder.WriteRune(r)
+ } else {
+ if builder.Len() > 0 {
+ result = append(result, builder.String())
+ builder.Reset()
+ }
+ result = append(result, string(r)) // 保留分隔符
+ }
+ }
+
+ if builder.Len() > 0 {
+ result = append(result, builder.String())
+ }
+
+ return result
+}
+
+func ContainsFn[T any](slice []T, value T, predicate func(got, want T) bool) bool {
+ for _, item := range slice {
+ if predicate(item, value) {
+ return true
+ }
+ }
+ return false
+}
+
+func isUpperCaseWord(word string) bool {
+ for _, r := range word {
+ if !unicode.IsUpper(r) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/stringcase/utils_test.go b/stringcase/utils_test.go
new file mode 100644
index 0000000..7cdec56
--- /dev/null
+++ b/stringcase/utils_test.go
@@ -0,0 +1,52 @@
+package stringcase
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestSplitByNonAlphanumeric(t *testing.T) {
+ tests := []struct {
+ input string
+ expected []string
+ }{
+ {"hello-world", []string{"hello", "world"}},
+ {"hello_world", []string{"hello", "world"}},
+ {"hello.world", []string{"hello", "world"}},
+ {"hello world", []string{"hello", "world"}},
+ {"hello123world", []string{"hello123world"}},
+ {"hello123 world", []string{"hello123", "world"}},
+ {"hello-world_123", []string{"hello", "world", "123"}},
+ {"!hello@world#", []string{"hello", "world"}},
+ }
+
+ for _, test := range tests {
+ result := SplitByNonAlphanumeric(test.input)
+ if !reflect.DeepEqual(result, test.expected) {
+ t.Errorf("SplitByNonAlphanumeric(%q) = %v; expected %v", test.input, result, test.expected)
+ }
+ }
+}
+
+func TestSplitAndKeepDelimiters(t *testing.T) {
+ tests := []struct {
+ input string
+ expected []string
+ }{
+ {"hello-world", []string{"hello", "-", "world"}},
+ {"hello_world", []string{"hello", "_", "world"}},
+ {"hello.world", []string{"hello", ".", "world"}},
+ {"hello world", []string{"hello", " ", "world"}},
+ {"hello123world", []string{"hello123world"}},
+ {"hello123 world", []string{"hello123", " ", "world"}},
+ {"hello-world_123", []string{"hello", "-", "world", "_", "123"}},
+ {"!hello@world#", []string{"!", "hello", "@", "world", "#"}},
+ }
+
+ for _, test := range tests {
+ result := SplitAndKeepDelimiters(test.input)
+ if !reflect.DeepEqual(result, test.expected) {
+ t.Errorf("SplitAndKeepDelimiters(%q) = %v; expected %v", test.input, result, test.expected)
+ }
+ }
+}
diff --git a/tag.bat b/tag.bat
index 6078a50..c035d4e 100644
--- a/tag.bat
+++ b/tag.bat
@@ -1,4 +1,4 @@
-git tag v1.1.28
+git tag v1.1.29
git tag bank_card/v1.1.5
git tag geoip/v1.1.5
@@ -10,6 +10,7 @@ git tag slug/v0.0.1
git tag name_generator/v0.0.1
git tag mapper/v0.0.3
git tag password/v0.0.1
+git tag query_parser/v0.0.1
git tag entgo/v1.1.31
git tag gorm/v1.1.6