Compare commits

..

10 Commits
v1.2.5 ... main

14 changed files with 361 additions and 20 deletions

6
.gitignore vendored
View File

@ -1 +1,5 @@
go.sum
.gemini/
.ai/
.geminiignore
.gemini
/CODE-FULL.md

View File

@ -1,5 +1,22 @@
# CHANGELOG
## [v1.2.9] - 2026-05-09
### Changed
- **移除第三方依赖**: 移除了对 `jsontag` 模块的依赖,统一使用标准库及自有基础设施对齐,增强了模块的独立性与长期稳定性。
## [v1.2.8] - 2026-05-05
### Fixed
- **JSON 解码器深度增强**: 修复了 `cast.UnmarshalJSON` 在反序列化到 `interface{}` 类型字段时会跳过对象和数组的重大缺陷。
- **高精度整数解析**: 优化了 JSON 解码过程中的数字处理逻辑。当目标为 `interface{}` 时,优先将整数解析为 `int64` 而非 `float64`彻底解决了纳秒级时间戳Nanosecond Timestamp和 64 位长 ID 的精度丢失问题。
- **接口兼容性**: 完善了对 `interface{}` 类型的自动类型识别,支持正确解析为 `map[string]any``[]any`
## [v1.2.6] - 2026-05-04
### Fixed
- **Map 深度合并修复**: 修复了在 `Convert``ToMap` 过程中,如果目标 Map 已存在该 Key其原有结构体/Map 值会被直接覆盖而非深度合并的问题。通过引入 `dst.MapIndex` 预读取与临时寻址变量,现已完美支持 Map 下非指针结构体的局部字段覆盖。
### Added
- **深度合并测试集**: 新增 `merge_test.go``complex_conversion_test.go`,覆盖了 Struct-to-Map, Map-to-Map, Slice-to-Map 等多种深度嵌套合并场景,确保配置覆盖逻辑的健壮性。
## [v1.2.3] - 2026-05-04
### Added
- **强大的时间解析引擎**: 移植 `time` 模块的核心算法至 `cast.ParseTime`支持时间戳秒至纳秒、RFC3339、JS 格式、紧凑格式20060102150405及中文日期解析。

View File

@ -77,13 +77,23 @@ list, _ := cast.ToSlice[int]([]string{"1", "2", "3"})
* `Ptr[T any](T) *T` —— 快速取指针。
* `ArrayToBoolMap[T comparable]([]T) map[T]bool` —— 快速构建索引 Map。
6. **基础转换 (直接调用,极致性能)**
6. **基础转换与时间 (直接调用,极致性能)**
* `Int`, `Int64`, `Uint`, `Uint64`, `Float`, `Float64`, `String`, `Bool`, `Duration`
* `RealValue(reflect.Value) reflect.Value` —— 递归获取指针或接口下的真实值。
* `ParseTime(any) time.Time` —— 强大的时间解析支持时间戳、RFC3339、JS 格式、紧凑格式及中文日期。
* `FormatTime(layout, any) string` —— 直观格式化(如 YYYY-MM-DD HH:mm:ss
* `AddTime(expr, any) time.Time` —— DSL 时间计算(如 +1Y-2M+3D
* `DescribeDuration(time.Duration) string` —— 将时长转化为自然语言描述(如 1h 5m
7. **时区支持**
7. **字符串与参数处理**
* `Split(s, sep string) []string` —— 增强型分割,自动 Trim 并过滤空字符串。
* `SplitArgs(string) []string` —— 命令行参数分割,支持引号与转义。
* `JoinArgs([]string, sep string) string` —— 命令行参数合并,自动处理引号与转义。
* `UniqueAppend(to []string, from ...any) []string` —— 去重追加。
* `GetLowerName(string) string` | `GetUpperName(string) string` —— 首字母大小写转换工具。
* `FixUpperCase([]byte, []string)` —— 针对 JSON Key 的特殊大小写修复工具。
8. **时区支持**
* `DefaultTimeZone` —— 全局默认时区上下文。
* `SetDefaultTimeZone(*time.Location)` —— 修改全局默认时区(影响所有 Convert 与 ToTime 操作)。
* `DefaultTimeZone.Now()` —— 获取时区上下文下的当前时间。

View File

@ -14,6 +14,7 @@
- **零分配摩擦匹配**: `normalizeEqual` 算法实现 0 内存分配的归一化 Key 匹配,支持 UTF-8。
- **智能 Slice 扩容**: 尊重预设 Capacity减少反序列化时的内存重分配。
- **FastDecoder**: 实现单路径流式解析支持嵌套指针、Slice 和 Map 的智能初始化。
- **深度合并支持**: 支持在 `Convert` 过程中对 Map 和 Struct 进行深度递归合并。即使目标 Map 存储的是非指针结构体,也能通过反射寻址实现局部字段覆盖,而不丢失未提及的默认值。
- **指针与接口**: `RealValue` 处理多级指针与接口解包。
- **实用工具**: `UniqueAppend` ($O(n)$ 去重)`If` (泛型三元)`SplitArgs` (支持引用格式)。

View File

@ -66,8 +66,8 @@ func BenchmarkString(b *testing.B) {
func BenchmarkJSON(b *testing.B) {
type User struct {
ID int `json:"id"`
Name string `json:"name"`
ID int
Name string
}
u := User{ID: 1, Name: "Benchmark User"}
for i := 0; i < b.N; i++ {
@ -140,8 +140,8 @@ func BenchmarkString_FastPath(b *testing.B) {
// 4. 测试 ToJSON 性能 (对比自定义逻辑与标准库)
func BenchmarkToJSON_SimpleStruct(b *testing.B) {
type User struct {
ID int `json:"id"`
Name string `json:"name"`
ID int
Name string
}
u := User{ID: 1, Name: "Benchmark User"}
b.ResetTimer()

62
cast.go
View File

@ -342,13 +342,30 @@ func recursiveMapToMap(src, dst reflect.Value) {
kt := dst.Type().Key()
vt := dst.Type().Elem()
newKey := reflect.New(kt).Elem()
newVal := reflect.New(vt).Elem()
iter := src.MapRange()
for iter.Next() {
newKey.Set(reflect.Zero(kt))
performRecursiveTo(iter.Key(), newKey)
newVal.Set(reflect.Zero(vt))
// 1. 创建一个【可寻址】的临时变量
newVal := reflect.New(vt).Elem()
// 2. 尝试从目标 Map 中获取已经存在的老值
existingVal := dst.MapIndex(newKey)
if existingVal.IsValid() {
// 如果老值存在,把它拷贝到我们刚才创建的临时变量里作“底本”
// 这样就能保留原本没被覆盖的字段
newVal.Set(existingVal)
} else {
// 如果老值不存在,就用零值兜底
newVal.Set(reflect.Zero(vt))
}
// 3. 对这个可寻址的临时变量进行深度合并
performRecursiveTo(iter.Value(), newVal)
// 4. 将合并后的全新值,重新塞回目标 Map
dst.SetMapIndex(newKey, newVal)
}
}
@ -357,7 +374,7 @@ func recursiveStructToMap(src, dst reflect.Value) {
kt := dst.Type().Key()
vt := dst.Type().Elem()
newKey := reflect.New(kt).Elem()
newVal := reflect.New(vt).Elem()
srcType := src.Type()
for i := 0; i < src.NumField(); i++ {
field := srcType.Field(i)
@ -370,8 +387,22 @@ func recursiveStructToMap(src, dst reflect.Value) {
}
newKey.Set(reflect.Zero(kt))
performRecursiveTo(reflect.ValueOf(GetLowerName(field.Name)), newKey)
newVal.Set(reflect.Zero(vt))
// 1. 创建一个【可寻址】的临时变量
newVal := reflect.New(vt).Elem()
// 2. 尝试获取老值
existingVal := dst.MapIndex(newKey)
if existingVal.IsValid() {
newVal.Set(existingVal)
} else {
newVal.Set(reflect.Zero(vt))
}
// 3. 合并
performRecursiveTo(src.Field(i), newVal)
// 4. 塞回
dst.SetMapIndex(newKey, newVal)
}
}
@ -380,11 +411,18 @@ func recursiveSliceToMap(src, dst reflect.Value) {
kt := dst.Type().Key()
vt := dst.Type().Elem()
newKey := reflect.New(kt).Elem()
newVal := reflect.New(vt).Elem()
for i := 0; i < src.Len(); i += 2 {
newKey.Set(reflect.Zero(kt))
performRecursiveTo(src.Index(i), newKey)
newVal.Set(reflect.Zero(vt))
newVal := reflect.New(vt).Elem()
existingVal := dst.MapIndex(newKey)
if existingVal.IsValid() {
newVal.Set(existingVal)
} else {
newVal.Set(reflect.Zero(vt))
}
if i+1 < src.Len() {
performRecursiveTo(src.Index(i+1), newVal)
}
@ -489,6 +527,12 @@ func isComplexValue(v any) bool {
}
func parseInt(s string) int64 {
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
i, err := strconv.ParseInt(s, 0, 64)
if err == nil {
return i
}
}
i, err := strconv.ParseInt(s, 10, 64)
if err == nil {
return i
@ -500,6 +544,12 @@ func parseInt(s string) int64 {
}
func parseUint(s string) uint64 {
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
i, err := strconv.ParseUint(s, 0, 64)
if err == nil {
return i
}
}
i, err := strconv.ParseUint(s, 10, 64)
if err == nil {
return i

View File

@ -55,7 +55,7 @@ func TestJSONToStruct(t *testing.T) {
func TestSpecialJSON(t *testing.T) {
// 关键测试:特殊 HTML 字符序列化不应被转义
type Content struct {
Text string `json:"text"`
Text string
}
c := Content{Text: "<a> & <b>"}

102
complex_conversion_test.go Normal file
View File

@ -0,0 +1,102 @@
package cast_test
import (
"testing"
"apigo.cc/go/cast"
)
type SubConfig struct {
Level int
Tag string
}
type MainConfig struct {
Name string
Sub SubConfig
Items []string
Options map[string]int
}
func TestDeepMergeComplex(t *testing.T) {
dst := MainConfig{
Name: "Base",
Sub: SubConfig{
Level: 1,
Tag: "original",
},
Items: []string{"a", "b"},
Options: map[string]int{
"debug": 1,
"trace": 0,
},
}
src := map[string]any{
"Sub": map[string]any{
"Level": 2,
},
"Options": map[string]any{
"trace": 1,
"new": 100,
},
}
cast.Convert(&dst, src)
if dst.Name != "Base" {
t.Errorf("Expected Name Base, got %s", dst.Name)
}
if dst.Sub.Level != 2 {
t.Errorf("Expected Sub.Level 2, got %d", dst.Sub.Level)
}
if dst.Sub.Tag != "original" {
t.Errorf("Expected Sub.Tag original, got %s", dst.Sub.Tag)
}
if len(dst.Items) != 2 {
t.Errorf("Expected Items length 2, got %d", len(dst.Items))
}
if dst.Options["debug"] != 1 {
t.Errorf("Expected Options.debug 1, got %d", dst.Options["debug"])
}
if dst.Options["trace"] != 1 {
t.Errorf("Expected Options.trace 1, got %d", dst.Options["trace"])
}
if dst.Options["new"] != 100 {
t.Errorf("Expected Options.new 100, got %d", dst.Options["new"])
}
}
func TestMapToMapMergeComplex(t *testing.T) {
dst := map[string]MainConfig{
"c1": {
Name: "Config1",
Sub: SubConfig{Level: 10},
},
}
src := map[string]any{
"c1": map[string]any{
"Sub": map[string]any{
"Tag": "updated",
},
},
"c2": map[string]any{
"Name": "Config2",
},
}
cast.Convert(&dst, src)
if dst["c1"].Name != "Config1" {
t.Errorf("Expected c1.Name Config1, got %s", dst["c1"].Name)
}
if dst["c1"].Sub.Level != 10 {
t.Errorf("Expected c1.Sub.Level 10, got %d", dst["c1"].Sub.Level)
}
if dst["c1"].Sub.Tag != "updated" {
t.Errorf("Expected c1.Sub.Tag updated, got %s", dst["c1"].Sub.Tag)
}
if dst["c2"].Name != "Config2" {
t.Errorf("Expected c2.Name Config2, got %s", dst["c2"].Name)
}
}

View File

@ -46,7 +46,7 @@ func TestToSlice(t *testing.T) {
func TestJSON(t *testing.T) {
type Config struct {
Port int `json:"port"`
Port int
}
data := `{"port": 8080}`

0
go.sum Normal file
View File

View File

@ -103,8 +103,24 @@ func (d *decoder) decodeValue(reflectValue reflect.Value, timeFormat string) err
switch char {
case '{':
if reflectValue.Kind() == reflect.Interface {
m := make(map[string]any)
if err := d.decodeObject(reflect.ValueOf(&m).Elem()); err != nil {
return err
}
reflectValue.Set(reflect.ValueOf(m))
return nil
}
return d.decodeObject(reflectValue)
case '[':
if reflectValue.Kind() == reflect.Interface {
var s []any
if err := d.decodeArray(reflect.ValueOf(&s).Elem()); err != nil {
return err
}
reflectValue.Set(reflect.ValueOf(s))
return nil
}
return d.decodeArray(reflectValue)
case '"':
str, err := d.parseString()
@ -123,6 +139,8 @@ func (d *decoder) decodeValue(reflectValue reflect.Value, timeFormat string) err
reflectValue.SetFloat(Float64(str))
case reflect.Bool:
reflectValue.SetBool(Bool(str))
case reflect.Interface:
reflectValue.Set(reflect.ValueOf(str))
default:
// 尝试将字符串解析为具体对象(比如内部又是 JSON
if strings.HasPrefix(str, "{") || strings.HasPrefix(str, "[") {
@ -148,7 +166,20 @@ func (d *decoder) decodeValue(reflectValue reflect.Value, timeFormat string) err
case reflect.String:
reflectValue.SetString(literal)
case reflect.Interface:
reflectValue.Set(reflect.ValueOf(literal))
// 优先作为数字处理以保留精度 (int64)
if literal == "true" {
reflectValue.Set(reflect.ValueOf(true))
} else if literal == "false" {
reflectValue.Set(reflect.ValueOf(false))
} else if literal == "null" {
reflectValue.Set(reflect.Zero(reflectValue.Type()))
} else if strings.Contains(literal, ".") || strings.Contains(literal, "e") || strings.Contains(literal, "E") {
reflectValue.Set(reflect.ValueOf(Float64(literal)))
} else if i, err := strconv.ParseInt(literal, 10, 64); err == nil {
reflectValue.Set(reflect.ValueOf(i))
} else {
reflectValue.Set(reflect.ValueOf(literal))
}
}
}
return nil
@ -414,7 +445,10 @@ func getDecoderFieldMap(reflectType reflect.Type) *decoderStructDescriptor {
// 1. Tag
tag := field.Tag.Get("json")
if tag != "" && tag != "-" {
if tag == "-" {
continue
}
if tag != "" {
parts := strings.Split(tag, ",")
tagName := parts[0]
for _, part := range parts {

View File

@ -217,9 +217,12 @@ func getEncoderStructDescriptor(reflectType reflect.Type) *encoderStructDescript
}
tag := field.Tag.Get("json")
if tag == "-" {
continue
}
fieldDesc.keepKey = strings.Contains(string(field.Tag), "keepKey")
if tag != "" && tag != "-" {
if tag != "" {
parts := strings.Split(tag, ",")
for _, part := range parts {
if strings.HasPrefix(part, "format=") {

119
merge_test.go Normal file
View File

@ -0,0 +1,119 @@
package cast_test
import (
"testing"
"apigo.cc/go/cast"
)
type DBConfig struct {
Host string
Port int
}
func TestMapStructMerge(t *testing.T) {
// Initial configuration with default values
dst := map[string]DBConfig{
"mysql": {Host: "localhost", Port: 3306},
}
// New data (e.g., from environment variables) that only overrides Host
src := map[string]any{
"mysql": map[string]any{
"Host": "127.0.0.1",
},
}
// Perform conversion/merge
cast.Convert(&dst, src)
// Verify results
mysql, ok := dst["mysql"]
if !ok {
t.Fatal("mysql config not found")
}
if mysql.Host != "127.0.0.1" {
t.Errorf("Expected Host 127.0.0.1, got %s", mysql.Host)
}
if mysql.Port != 3306 {
t.Errorf("Expected Port 3306, got %d", mysql.Port)
}
}
func TestMapStructPointerMerge(t *testing.T) {
// Initial configuration with default values (using pointers)
dst := map[string]*DBConfig{
"mysql": {Host: "localhost", Port: 3306},
}
// New data that only overrides Host
src := map[string]any{
"mysql": map[string]any{
"Host": "127.0.0.1",
},
}
// Perform conversion/merge
cast.Convert(&dst, src)
// Verify results
mysql, ok := dst["mysql"]
if !ok {
t.Fatal("mysql config not found")
}
if mysql.Host != "127.0.0.1" {
t.Errorf("Expected Host 127.0.0.1, got %s", mysql.Host)
}
if mysql.Port != 3306 {
t.Errorf("Expected Port 3306, got %d", mysql.Port)
}
}
func TestSliceToMapMerge(t *testing.T) {
dst := map[string]int{
"a": 1,
"b": 2,
}
src := []any{"b", 20, "c", 30}
cast.Convert(&dst, src)
if dst["a"] != 1 {
t.Errorf("Expected a=1, got %d", dst["a"])
}
if dst["b"] != 20 {
t.Errorf("Expected b=20, got %d", dst["b"])
}
if dst["c"] != 30 {
t.Errorf("Expected c=30, got %d", dst["c"])
}
}
func TestStructToMapMerge(t *testing.T) {
type Config struct {
Host string
Port int
}
dst := map[string]any{
"host": "localhost",
"port": 3306,
"user": "root",
}
// Note: Struct fields are always "present", so Port:0 will overwrite port:3306
src := Config{Host: "127.0.0.1", Port: 8080}
cast.Convert(&dst, src)
if dst["host"] != "127.0.0.1" {
t.Errorf("Expected host 127.0.0.1, got %v", dst["host"])
}
if cast.Int(dst["port"]) != 8080 {
t.Errorf("Expected port 8080, got %v", dst["port"])
}
if dst["user"] != "root" {
t.Errorf("Expected user root, got %v", dst["user"])
}
}

View File

@ -2,6 +2,7 @@ package cast_test
import (
"testing"
"apigo.cc/go/cast"
)
@ -28,7 +29,7 @@ func TestTo(t *testing.T) {
// JSON Auto conversion (Struct to String)
type User struct {
Name string `json:"name"`
Name string
}
u := User{Name: "Alice"}
js := cast.To[string](u)