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