Skip to content

Commit 14c9fa5

Browse files
committed
refactor: major performance refactor and API improvements
This commit delivers significant performance improvements through reflection caching, direct type switching, and global regex optimization. Additionally enhances error reporting and API consistency. Key changes: - Add struct field metadata caching system - Replace abstraction layers with raw switch statments - Move timezone regex to global scope - Implement field-specific error wrapping - Make Parse function public - Always initialize pointer fields Performance gains range from 2.5x to 10.3x speedup with substantial memory allocation reductions across all benchmark scenarios.
1 parent 7cf0aa8 commit 14c9fa5

14 files changed

+1037
-814
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ benchmark:
55
go test -bench=. -benchmem
66

77
test:
8-
go test -v -count=1 -race ./... -cover
8+
go test -v -count=1 -race . -cover
99

1010
test\:coverage:
11-
go test -v -count=1 -race ./... -coverprofile=$(COVERAGE_FILE)
11+
go test -v -count=1 -race . -coverprofile=$(COVERAGE_FILE)
1212

1313

1414
coverage-html:

README.md

Lines changed: 155 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@
33
[![Go Report Card](https://goreportcard.com/badge/github.com/prawirdani/qparser)](https://goreportcard.com/report/github.com/prawirdani/qparser)
44
![Build Status](https://github.com/prawirdani/qparser/actions/workflows/ci.yml/badge.svg)
55

6-
`qparser` is a simple package that help parse query parameters into struct in Go. It is inspired by [gorilla/schema](https://github.com/gorilla/schema) with main focus on query parameters. Built on top of Go stdlib, it uses custom struct tag `qp` to define the query parameter key .
6+
`qparser` is a simple package that helps parse query parameters into structs in Go. It is inspired by [gorilla/schema](https://github.com/gorilla/schema) with a main focus on query parameters. Built on top of Go stdlib, it uses a custom struct tag `qp` to define the query parameter key.
7+
8+
## Table of Contents
9+
- [Installation](#installation)
10+
- [Examples](#examples)
11+
- [Supported field types](#supported-field-types)
12+
- [Error Handling](#error-handling)
13+
- [Notes](#notes)
14+
- [Benchmarks](#benchmarks)
715

816
## Installation
917
```bash
1018
go get -u github.com/prawirdani/qparser@latest
1119
```
1220

13-
## Example
14-
Here's an example of how to use `qparser` to parse query parameters into struct.
21+
## Examples
22+
23+
### Parse from `net/http` request.
24+
Here's an example of how to use `qparser` to parse query parameters into struct from `net/http` request.
1525
```go
1626
// Representing basic pagination, /path?page=1&limit=5
1727
type Pagination struct {
@@ -31,6 +41,44 @@ func MyHandler(w http.ResponseWriter, r *http.Request) {
3141
}
3242
```
3343

44+
### Parse from URL
45+
You can also parse query parameters from URL string by calling the `ParseURL` function. Here's an example:
46+
```go
47+
48+
func main() {
49+
var pagination Pagination
50+
51+
url := "http://example.com/path?page=1&limit=5"
52+
53+
err := qparser.ParseURL(url, &pagination)
54+
if err != nil {
55+
// Handle Error
56+
}
57+
58+
// Do something with pagination
59+
}
60+
```
61+
62+
### Parse from url.Values
63+
You can also parse query parameters directly from `url.Values` by calling the `Parse` function. Here's an example:
64+
```go
65+
66+
func main() {
67+
var pagination Pagination
68+
69+
values := url.Values{
70+
"page": []string{"1"},
71+
"limit": []string{"5"},
72+
}
73+
74+
err := qparser.Parse(values, &pagination)
75+
if err != nil {
76+
// Handle Error
77+
}
78+
79+
// Do something with pagination
80+
}
81+
```
3482
### Multiple Values Query & Nested Struct
3583
To support multiple values for a single query parameter, use a slice type. For nested structs, utilize the qp tag within the fields of the nested struct to pass the query parameters. It's important to note that the parent struct containing the nested/child struct **should not have its own qp tag**. Here's an example:
3684
```go
@@ -65,44 +113,6 @@ There are three ways for the parser to handle multiple values query parameters:
65113

66114
Simply ensure that the qp tags are defined appropriately in your struct fields to map these parameters correctly.
67115

68-
### Parse from URL
69-
You can also parse query parameters from URL string by calling the `ParseURL` function. Here's an example:
70-
```go
71-
72-
func main() {
73-
var pagination Pagination
74-
75-
url := "http://example.com/path?page=1&limit=5"
76-
77-
err := qparser.ParseURL(url, &pagination)
78-
if err != nil {
79-
// Handle Error
80-
}
81-
82-
// Do something with pagination
83-
}
84-
```
85-
86-
## Notes
87-
- Empty query values are not validated by default. For custom validation (including empty value checks), you can create your own validator by creating a pointer/value receiver method on the struct or with help of a third party validator package like [go-playground/validator](https://github.com/go-playground/validator).
88-
- Missing query parameters:
89-
- Primitive type fields keep their zero values (e.g., `0` for int, `""` for string, `false` for bool)
90-
- Pointer fields are remain **nil** and slice are set to nil slice ([]).
91-
- A pointer nested struct will remain nil, **only if all the fields are missing**. If any field is present, the struct will be initialized and the missing fields will be set to their zero values.
92-
- For multiple values query parameters, same value will be appended to the slice. If you want to make sure each value in the slice is unique, you can create a pointer receiver method on the struct to remove the duplicates or sanitize the values.
93-
- The `qp` tag value is **case-sensitive** and must match the query parameter key exactly.
94-
95-
## Supported field types
96-
- String
97-
- Boolean
98-
- Integers (int, int8, int16, int32 and int64)
99-
- Unsigned Integers (uint, uint8, uint16, uint32 and uint64)
100-
- Floats (float64 and float32)
101-
- Slice of above types
102-
- Nested Struct
103-
- time.Time
104-
- A pointer to one of above
105-
106116
### Time Handling
107117
Supports time.Time, *time.Time, and type aliases. Handles a variety of standard time formats, both with and without timezone offsets, and supports nanosecond-level precision. Date formats follow the YYYY-MM-DD layout.
108118
<div align="center">
@@ -122,7 +132,8 @@ Supports time.Time, *time.Time, and type aliases. Handles a variety of standard
122132
| RFC3339 (Z or offset) |`2006-01-02T15:04:05Z07:00` |
123133
| RFC3339Nano (Z or offset, nanosecond prec) |`2006-01-02T15:04:05.999999999Z07:00` |
124134
| Space separator + TZ |`2006-01-02 15:04:05-07:00` |
125-
| Space separator + fractional + TZ |`2006-01-02 15:04:05.123456789 -07:00`|
135+
| Space separator + TZ (+ offset) |`2006-01-02 15:04:05+07:00` |
136+
| Space separator + fractional + TZ |`2006-01-02 15:04:05.123456789-07:00`|
126137

127138
</div>
128139

@@ -132,31 +143,125 @@ Supports time.Time, *time.Time, and type aliases. Handles a variety of standard
132143

133144
Example:
134145
```go
135-
type ReportSearchQuery struct {
146+
type ReportFilter struct {
136147
From time.Time `qp:"from"`
137148
To time.Time `qp:"to"`
138149
}
139150
func main() {
140-
var search ReportSearchQuery
151+
var filter ReportFilter
141152

142153
url := "http://example.com/reports?from=2025-07-01&to=2025-07-31"
143154

144-
err := qparser.ParseURL(url, &search)
155+
err := qparser.ParseURL(url, &filter)
145156
if err != nil {
146157
// Handle Error
147158
}
148-
// Do something with search
159+
// Do something with filter
149160
}
150161
```
151162

163+
## Supported field types
164+
- String
165+
- Boolean
166+
- Integers (int, int8, int16, int32 and int64)
167+
- Unsigned Integers (uint, uint8, uint16, uint32 and uint64)
168+
- Floats (float64 and float32)
169+
- Slice of above types
170+
- Nested Struct
171+
- time.Time
172+
- A pointer to one of above
173+
174+
175+
## Error Handling
176+
qparser provides detailed error information with field-specific context. Errors are wrapped with field names to help identify exactly which parameter failed to parse.
177+
178+
```go
179+
type UserFilter struct {
180+
Age int `qp:"age"`
181+
Active bool `qp:"active"`
182+
Name string `qp:"name"`
183+
}
184+
185+
func main() {
186+
var filter UserFilter
187+
188+
// Simulate invalid query parameters
189+
values := url.Values{
190+
"age": []string{"invalid_age"}, // Invalid integer
191+
"active": []string{"maybe"}, // Invalid boolean
192+
"name": []string{"John"}, // Valid
193+
}
194+
195+
err := qparser.Parse(values, &filter)
196+
if err != nil {
197+
// Check for specific error types
198+
var fieldErr *qparser.FieldError
199+
if errors.As(err, &fieldErr) {
200+
fmt.Printf("Field error in %q: %v\n", fieldErr.FieldName, fieldErr.Err)
201+
202+
// Handle specific error types
203+
switch {
204+
case errors.Is(fieldErr.Err, qparser.ErrInvalidValue):
205+
fmt.Printf("Invalid value format for field %s\n", fieldErr.FieldName)
206+
case errors.Is(fieldErr.Err, qparser.ErrOutOfRange):
207+
fmt.Printf("Value out of range for field %s\n", fieldErr.FieldName)
208+
case errors.Is(fieldErr.Err, qparser.ErrUnsupportedKind):
209+
fmt.Printf("Unsupported type for field %s\n", fieldErr.FieldName)
210+
}
211+
} else {
212+
fmt.Printf("General error: %v\n", err)
213+
}
214+
return
215+
}
216+
217+
fmt.Printf("Parsed filter: %+v\n", filter)
218+
}
219+
```
220+
221+
### Error Types
222+
223+
qparser defines several error types for different parsing scenarios:
224+
225+
- **`ErrInvalidValue`**: Value cannot be parsed as the target type (e.g., "abc" as integer)
226+
- **`ErrOutOfRange`**: Value is too large for the target numeric type (e.g., "999" as int8)
227+
- **`ErrUnsupportedKind`**: Target type is not supported by the parser
228+
- **`ErrUnexportedStruct`**: Struct contains unexported fields with `qp` tags
229+
230+
### FieldError Structure
231+
232+
The `FieldError` type provides both the field name and the underlying error:
233+
234+
```go
235+
type FieldError struct {
236+
FieldName string // Name of the field that failed
237+
Err error // Underlying error
238+
}
239+
```
240+
241+
This allows you to:
242+
- Identify exactly which field failed parsing
243+
- Access the specific error type for targeted error handling
244+
- Provide user-friendly error messages in your API responses
245+
246+
247+
## Notes
248+
- Empty query values are not validated by default. For custom validation (including empty value checks), implement your own validation method on the struct or use a third-party validator such as [go-playground/validator](https://github.com/go-playground/validator).
249+
- Missing query parameters:
250+
- Primitive fields keep their zero values (0, "", false, etc.).
251+
- Pointer fields are always initialized, even when the parameter is missing. They will contain the zero value of the underlying type.
252+
- Slice fields become an empty slice ([]T{}), not nil.
253+
- Pointer nested structs are also always initialized. Missing fields inside them receive their zero values.
254+
- For repeated query parameters, the value is appended to the slice every time. If you want deduplication or sanitization, implement a post-processing method on your struct.
255+
- The qp tag is case-sensitive and must match the query parameter key exactly.
256+
152257

153258
## Benchmarks
154259
```text
155260
goos: linux
156261
goarch: amd64
157262
cpu: Intel(R) Core(TM) i5-8259U CPU @ 2.30GHz
158-
Benchmark/Small-8 91339 13457 ns/op 4720 B/op 111 allocs/op
159-
Benchmark/Medium-8 69544 17252 ns/op 4784 B/op 113 allocs/op
160-
Benchmark/Large-8 63728 18126 ns/op 5240 B/op 128 allocs/op
161-
Benchmark/LargeWithDate-8 18294 67437 ns/op 36575 B/op 402 allocs/op
263+
Benchmark/Small-8 224376 4602 ns/op 2680 B/op 13 allocs/op
264+
Benchmark/Medium-8 246818 4851 ns/op 2720 B/op 13 allocs/op
265+
Benchmark/Large-8 173422 5867 ns/op 3056 B/op 21 allocs/op
266+
Benchmark/LargeWithDate-8 160401 6543 ns/op 3104 B/op 23 allocs/op
162267
```

benchmark_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func Benchmark(b *testing.B) {
4444
},
4545
fn: func(v url.Values) error {
4646
var f SmallFilter
47-
return parse(v, &f)
47+
return Parse(v, &f)
4848
},
4949
},
5050
{
@@ -59,7 +59,7 @@ func Benchmark(b *testing.B) {
5959
},
6060
fn: func(v url.Values) error {
6161
var f MediumFilter
62-
return parse(v, &f)
62+
return Parse(v, &f)
6363
},
6464
},
6565
{
@@ -77,7 +77,7 @@ func Benchmark(b *testing.B) {
7777
},
7878
fn: func(v url.Values) error {
7979
var f LargeFilter
80-
return parse(v, &f)
80+
return Parse(v, &f)
8181
},
8282
},
8383
{
@@ -97,7 +97,7 @@ func Benchmark(b *testing.B) {
9797
},
9898
fn: func(v url.Values) error {
9999
var f LargeFilter
100-
return parse(v, &f)
100+
return Parse(v, &f)
101101
},
102102
},
103103
}

cache.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package qparser
2+
3+
import (
4+
"reflect"
5+
"sync"
6+
"time"
7+
)
8+
9+
var (
10+
structCache = make(map[reflect.Type]*structInfo)
11+
cacheMutex sync.RWMutex
12+
)
13+
14+
type structInfo struct {
15+
name string
16+
fields []fieldInfo
17+
hasUnexportedWithTag bool
18+
}
19+
20+
type fieldInfo struct {
21+
name string
22+
tag string
23+
typ reflect.Type
24+
index []int
25+
isNested bool
26+
}
27+
28+
func getStructCache(rt reflect.Type) *structInfo {
29+
cacheMutex.RLock()
30+
if info, ok := structCache[rt]; ok {
31+
cacheMutex.RUnlock()
32+
return info
33+
}
34+
cacheMutex.RUnlock()
35+
36+
cacheMutex.Lock()
37+
defer cacheMutex.Unlock()
38+
39+
if info, ok := structCache[rt]; ok {
40+
return info
41+
}
42+
info := &structInfo{name: rt.Name()}
43+
44+
for i := 0; i < rt.NumField(); i++ {
45+
field := rt.Field(i)
46+
tag := field.Tag.Get("qp")
47+
if !field.IsExported() {
48+
if tag != "" {
49+
info.hasUnexportedWithTag = true
50+
}
51+
continue
52+
}
53+
if tag != "" {
54+
info.fields = append(info.fields, fieldInfo{
55+
name: field.Name,
56+
tag: tag,
57+
typ: field.Type,
58+
index: field.Index,
59+
isNested: false,
60+
})
61+
} else {
62+
// Check if this field is a nested struct (struct or pointer to struct)
63+
fieldType := field.Type
64+
if fieldType.Kind() == reflect.Ptr {
65+
fieldType = fieldType.Elem()
66+
}
67+
68+
if fieldType.Kind() == reflect.Struct && fieldType != reflect.TypeOf(time.Time{}) {
69+
info.fields = append(info.fields, fieldInfo{
70+
name: field.Name,
71+
tag: "",
72+
typ: field.Type, // Keep the original type (may be pointer)
73+
index: field.Index,
74+
isNested: true,
75+
})
76+
}
77+
}
78+
79+
}
80+
structCache[rt] = info
81+
return info
82+
}

0 commit comments

Comments
 (0)