From da00471bdf0ef2f5475bc1d64882ac8112f1c362 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 19:45:57 +0700 Subject: [PATCH 1/9] update go doc --- doc.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/doc.go b/doc.go index 0044276..c93f280 100644 --- a/doc.go +++ b/doc.go @@ -13,12 +13,12 @@ Use `WithTagAlias()` func to register custom tag alias (default is `qs`) Encoder has `.Values()` and `Encode()` functions to encode structs into url.Values. Supported data types: - - all basic types (`bool`, `uint`, `string`, `float64`,...) - - struct - - slice/array - - pointer - - time.Time - - custom type + - all basic types (`bool`, `uint`, `string`, `float64`,...) + - struct + - slice/array + - pointer + - time.Time + - custom type Example @@ -107,7 +107,6 @@ Including the `bracket` option to signal that the multiple URL values should hav values, _ := encoder.Values(&Query{Tags: []string{"foo","bar"}}) fmt.Println(values.Encode()) //(unescaped) output: "tags[]=foo&tags[]=bar" - The `index` option will append an index number with brackets to value name type Query struct { @@ -117,7 +116,6 @@ The `index` option will append an index number with brackets to value name values, _ := encoder.Values(&Query{Tags: []string{"foo","bar"}}) fmt.Println(values.Encode()) //(unescaped) output: "tags[0]=foo&tags[1]=bar" - All nested structs are encoded including the parent value name with brackets for scoping. type User struct { @@ -138,6 +136,16 @@ All nested structs are encoded including the parent value name with brackets for values, _ := encoder.Values(querys) fmt.Println(values.Encode()) //(unescaped) output: "user[from]=1601623397728&user[verified]=true" +Add the `dot` option to a nested struct field to scope its children with dot +notation instead of brackets. The option applies only to the field it is +declared on; nest it again on a deeper struct field to keep using dots. + + type Query struct { + User User `qs:"user,dot"` + } + + values, _ := encoder.Values(querys) + fmt.Println(values.Encode()) //(unescaped) output: "user.from=1601623397728&user.verified=true" Custom type Implement `EncodeParam` to encode itself into query param. @@ -177,10 +185,9 @@ Implement `IsZero` to check whether an object is zero to determine whether it sh } fmt.Println(values.Encode()) //(unescaped) output: "user=sonhuynh" - Limitation - - `interface`, `[]interface`, `map` are not supported yet - - `struct`, `slice`/`array` multi-level nesting are limited - - no decoder yet + - `interface`, `[]interface`, `map` are not supported yet + - `struct`, `slice`/`array` multi-level nesting are limited + - no decoder yet */ package qs From 640a07d05854114a6c08a9735cad79bc07dec6d9 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 19:46:08 +0700 Subject: [PATCH 2/9] update example --- example/embed/embed.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/example/embed/embed.go b/example/embed/embed.go index d5ea89f..9f1eabb 100644 --- a/example/embed/embed.go +++ b/example/embed/embed.go @@ -2,8 +2,9 @@ package main import ( "fmt" - "github.com/sonh/qs" "time" + + "github.com/sonh/qs" ) type User struct { @@ -12,7 +13,9 @@ type User struct { } type Query struct { - User User `qs:"user"` + // The "dot" option encodes the nested struct using dot-notation keys + // (user.verified) instead of the default bracket scoping (user[verified]). + User User `qs:"user,dot"` } func main() { @@ -30,5 +33,5 @@ func main() { fmt.Println("failed") return } - fmt.Println(values.Encode()) + fmt.Println(values.Encode()) // (unescaped) output: "user.from=1601623397728&user.verified=true" } From 19d9777c3f904abd6e07427d8cb6ebf29beedc36 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 19:47:37 +0700 Subject: [PATCH 3/9] update README.md --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index b19cfb1..c32ac77 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,19 @@ values, _ := encoder.Values(query) fmt.Println(values.Encode()) //(unescaped) output: "user[from]=1601623397728&user[verified]=true" ``` +By default, it uses the brackets, add the `dot` option to +a nested struct field to scope its children with `.`: +```go +type Query struct { + User User `qs:"user,dot"` +} + +values, _ := encoder.Values(query) +fmt.Println(values.Encode()) //(unescaped) output: "user.from=1601623397728&user.verified=true" +``` +The `dot` option applies only to the field it is declared on; nest it again on a +deeper struct field to keep using dots (otherwise that level falls back to brackets). + ### Custom Type Implement funcs: * `EncodeParam` to encode itself into query param. From 485a83ed780a3424473f8e17a9b5e00647be9586 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 19:48:59 +0700 Subject: [PATCH 4/9] support dot notation for nested structs --- encode.go | 33 ++++++++++++++++++++------ encode_field.go | 10 ++++++-- encode_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/encode.go b/encode.go index bcccac9..7309b34 100644 --- a/encode.go +++ b/encode.go @@ -135,7 +135,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b if cachedFlds == nil { cachedFlds = make(cachedFields, 0, stTyp.NumField()) - e.structCaching(&cachedFlds, stVal, scope) + e.structCaching(&cachedFlds, stVal, scope, nestedFormatBracket) e.e.cache.Store(stTyp, cachedFlds) } @@ -196,7 +196,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b return nil } -func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope []byte) { +func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope []byte, notation nestedFormat) { structTyp := getType(stVal) @@ -217,10 +217,18 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope if string(scope) != "" { scopedName := strings.Builder{} - scopedName.Write(scope) - scopedName.WriteRune('[') - scopedName.Write(e.tags[0]) - scopedName.WriteRune(']') + switch notation { + case nestedFormatDot: + scopedName.Write(scope) + scopedName.WriteRune('.') + scopedName.Write(e.tags[0]) + default: + scopedName.Write(scope) + scopedName.WriteRune('[') + scopedName.Write(e.tags[0]) + scopedName.WriteRune(']') + } + e.tags[0] = e.tags[0][:0] e.tags[0] = append(e.tags[0], scopedName.String()...) } @@ -245,11 +253,13 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope // Clear and set new scope e.scope = e.scope[:0] e.scope = append(e.scope, e.tags[0]...) + // How this struct's children should be scoped under its name + childNotation := nestedFormatFromOptions(e.tags[1:]) // New embed field field := newEmbedField(fieldVal.NumField(), e.tags[0], e.tags[1:]) *fields = append(*fields, field) // Recursive - e.structCaching(&field.cachedFields, fieldVal, e.scope) + e.structCaching(&field.cachedFields, fieldVal, e.scope, childNotation) case reflect.Slice, reflect.Array: //Slice element type elemType := fieldTyp.Elem() @@ -277,6 +287,15 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope } } +func nestedFormatFromOptions(tagOptions [][]byte) nestedFormat { + for _, tagOption := range tagOptions { + if string(tagOption) == "dot" { + return nestedFormatDot + } + } + return nestedFormatBracket +} + func (e *encoder) getTagNameAndOpts(f reflect.StructField) { // Get tag by alias tag := f.Tag.Get(e.e.tagAlias) diff --git a/encode_field.go b/encode_field.go index dc38bd7..008f747 100644 --- a/encode_field.go +++ b/encode_field.go @@ -24,6 +24,13 @@ const ( arrayFormatIndex ) +type nestedFormat uint8 + +const ( + nestedFormatBracket nestedFormat = iota // user[from] + nestedFormatDot // user.from +) + // other fields implement baseField type baseField struct { name string @@ -237,7 +244,7 @@ func (e *encoder) newListField(elemTyp reflect.Type, tagName []byte, tagOptions } if field, ok := listField.cachedField.(*embedField); ok { - e.structCaching(&field.cachedFields, reflect.Zero(elemTyp), nil) + e.structCaching(&field.cachedFields, reflect.Zero(elemTyp), nil, nestedFormatBracket) } return listField @@ -307,7 +314,6 @@ func newMapField(keyType reflect.Type, valueType reflect.Type, tagName []byte, t return field } -// type boolField struct { *baseField useInt bool diff --git a/encode_test.go b/encode_test.go index 7acaec4..af4625d 100644 --- a/encode_test.go +++ b/encode_test.go @@ -897,6 +897,67 @@ func TestNestedStruct(t *testing.T) { } } +func TestNestedStructDotNotation(t *testing.T) { + t.Parallel() + encoder := NewEncoder() + + tm := time.Unix(600, 0) + + type Geo struct { + Lat string `qs:"lat"` + } + + type Addr struct { + City string `qs:"city"` + } + + type User struct { + Verified bool `qs:"verified"` + From time.Time `qs:"from,second"` + Name *string `qs:"name,omitempty"` + Addr Addr `qs:"addr,dot"` // dot cascades only because declared again + Geo Geo `qs:"geo"` // no dot -> brackets even under a dot parent + } + + s := struct { + User User `qs:"user,dot"` + UserPtr *User `qs:"user_ptr,dot"` + }{ + User: User{ + Verified: true, + From: tm, + Addr: Addr{City: "hcm"}, + Geo: Geo{Lat: "10.5"}, + }, + UserPtr: &User{ + Verified: false, + From: tm, + Addr: Addr{City: "hn"}, + Geo: Geo{Lat: "21.0"}, + }, + } + + values, err := encoder.Values(&s) + if err != nil { + t.Errorf("expected no error but got %v", err) + t.FailNow() + } + expected := url.Values{ + "user.verified": []string{"true"}, + "user.from": []string{"600"}, + "user.addr.city": []string{"hcm"}, + "user.geo[lat]": []string{"10.5"}, + "user_ptr.verified": []string{"false"}, + "user_ptr.from": []string{"600"}, + "user_ptr.addr.city": []string{"hn"}, + "user_ptr.geo[lat]": []string{"21.0"}, + } + if !reflect.DeepEqual(expected, values) { + t.Errorf("expected %v, got %v", expected, values) + t.FailNow() + } +} + func TestEncodeInterface(t *testing.T) { t.Parallel() encoder := NewEncoder() From 8671dffdacebe4bc10dcbdf06689098621e7151a Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 19:51:20 +0700 Subject: [PATCH 5/9] run test with -count=3 to avoid flaky failures --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 05dddcd..3786187 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -34,7 +34,7 @@ jobs: run: go build -v . - name: Test - run: go test -v -coverprofile=coverage.out . + run: go test -count=3 -v -coverprofile=coverage.out . - name: Codecov uses: codecov/codecov-action@v4 From 08a4b35bb3e42e19e78c999e6598609ea298d096 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 20:00:32 +0700 Subject: [PATCH 6/9] code cleanup --- encode.go | 6 +++--- encode_field.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/encode.go b/encode.go index 7309b34..b5e3906 100644 --- a/encode.go +++ b/encode.go @@ -135,7 +135,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b if cachedFlds == nil { cachedFlds = make(cachedFields, 0, stTyp.NumField()) - e.structCaching(&cachedFlds, stVal, scope, nestedFormatBracket) + e.structCaching(&cachedFlds, nestedFormatBracket, scope, stVal) e.e.cache.Store(stTyp, cachedFlds) } @@ -196,7 +196,7 @@ func (e *encoder) encodeStruct(stVal reflect.Value, values url.Values, scope []b return nil } -func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope []byte, notation nestedFormat) { +func (e *encoder) structCaching(fields *cachedFields, notation nestedFormat, scope []byte, stVal reflect.Value) { structTyp := getType(stVal) @@ -259,7 +259,7 @@ func (e *encoder) structCaching(fields *cachedFields, stVal reflect.Value, scope field := newEmbedField(fieldVal.NumField(), e.tags[0], e.tags[1:]) *fields = append(*fields, field) // Recursive - e.structCaching(&field.cachedFields, fieldVal, e.scope, childNotation) + e.structCaching(&field.cachedFields, childNotation, e.scope, fieldVal) case reflect.Slice, reflect.Array: //Slice element type elemType := fieldTyp.Elem() diff --git a/encode_field.go b/encode_field.go index 008f747..55183ed 100644 --- a/encode_field.go +++ b/encode_field.go @@ -244,7 +244,7 @@ func (e *encoder) newListField(elemTyp reflect.Type, tagName []byte, tagOptions } if field, ok := listField.cachedField.(*embedField); ok { - e.structCaching(&field.cachedFields, reflect.Zero(elemTyp), nil, nestedFormatBracket) + e.structCaching(&field.cachedFields, nestedFormatBracket, nil, reflect.Zero(elemTyp)) } return listField From 0e24db7668a48e46e28f5c5fe0e3ef3e1d5a56b2 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 20:04:09 +0700 Subject: [PATCH 7/9] improve CI --- .github/workflows/go.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3786187..8ea4f38 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,16 +22,23 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v4 - - name: Get dependencies + - name: Download dependencies + run: go mod download + + - name: Verify formatting run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 fi + - name: Vet + run: go vet ./... + - name: Build - run: go build -v . + run: go build -v ./... - name: Test run: go test -count=3 -v -coverprofile=coverage.out . From 414e20ce5a570c23461f33ec2dd63303f7b164dd Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 20:07:19 +0700 Subject: [PATCH 8/9] add more examples --- example/embed/embed.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/example/embed/embed.go b/example/embed/embed.go index 9f1eabb..11eb49e 100644 --- a/example/embed/embed.go +++ b/example/embed/embed.go @@ -8,8 +8,9 @@ import ( ) type User struct { - Verified bool `qs:"verified"` - From time.Time `qs:"from,millis"` + Verified bool `qs:"verified"` + From time.Time `qs:"from,millis"` + Roles map[string]int `qs:"roles"` } type Query struct { @@ -23,6 +24,10 @@ func main() { User: User{ Verified: true, From: time.Now(), + Roles: map[string]int{ + "admin": 1, + "member": 2, + }, }, } @@ -33,5 +38,7 @@ func main() { fmt.Println("failed") return } - fmt.Println(values.Encode()) // (unescaped) output: "user.from=1601623397728&user.verified=true" + // (unescaped) output: + // user.from=1601623397728&user.roles[admin]=1&user.roles[member]=2&user.verified=true + fmt.Println(values.Encode()) } From 3d80e6cb4a085a11e45e2a026322b4af3dc2fff2 Mon Sep 17 00:00:00 2001 From: sonh Date: Sat, 6 Jun 2026 20:12:23 +0700 Subject: [PATCH 9/9] fix parallel test --- encode_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/encode_test.go b/encode_test.go index af4625d..7db9165 100644 --- a/encode_test.go +++ b/encode_test.go @@ -200,7 +200,7 @@ func TestGetTag3(t *testing.T) { } func TestEncodeInvalidValue(t *testing.T) { - //t.Parallel() + t.Parallel() var ptr *string @@ -223,7 +223,9 @@ func TestEncodeInvalidValue(t *testing.T) { } for _, testCase := range testCases { + testCase := testCase t.Run(testCase.name, func(t *testing.T) { + t.Parallel() encoder := NewEncoder() _, err := encoder.Values(testCase.input) if err == nil {