Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: build
run: docker compose run go-json

Expand All @@ -19,15 +19,15 @@ jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
go-version: [ "1.19", "1.20", "1.21" ]
go-version: [ "1.24", "oldstable", "stable" ]
runs-on: ${{ matrix.os }}
steps:
- name: setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: simple test
run: go test -v ./... -count=1
- name: test with GC pressure
Expand All @@ -42,11 +42,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: 'stable'
- name: lint
run: |
make lint
Expand All @@ -55,17 +55,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: 'stable'
- name: checkout ( feature )
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: run benchmark ( feature )
run: cd benchmarks && go test -bench GoJson | tee $HOME/new.txt
- name: install benchstat
run: go install golang.org/x/perf/cmd/benchstat@latest
- name: checkout ( master )
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
ref: master
- name: run benchmark ( master )
Expand All @@ -78,14 +78,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: setup Go
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: '1.21'
go-version: 'stable'
- name: measure coverage
run: make cover
- uses: codecov/codecov-action@v4
- uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
verbose: true
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module benchmark

go 1.19
go 1.24

require (
github.com/francoispqt/gojay v1.2.13
Expand Down
4 changes: 2 additions & 2 deletions encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,11 +991,11 @@ func TestMarshalerError(t *testing.T) {
want string
}{
{
json.NewMarshalerError(st, fmt.Errorf(errText), ""),
json.NewMarshalerError(st, fmt.Errorf("%s", errText), ""),
"json: error calling MarshalJSON for type " + st.String() + ": " + errText,
},
{
json.NewMarshalerError(st, fmt.Errorf(errText), "TestMarshalerError"),
json.NewMarshalerError(st, fmt.Errorf("%s", errText), "TestMarshalerError"),
"json: error calling TestMarshalerError for type " + st.String() + ": " + errText,
},
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/goccy/go-json

go 1.19
go 1.24
5 changes: 5 additions & 0 deletions internal/cmd/generator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ func (t OpType) FieldToOmitEmptyField() OpType {
})
}
}
opTypes = append(opTypes,
createOpType("StructHeadOmitZero", "StructField"),
createOpType("StructFieldOmitZero", "StructField"),
createOpType("StructPtrHeadOmitZero", "StructField"),
)
var b bytes.Buffer
if err := tmpl.Execute(&b, struct {
CodeTypes []string
Expand Down
73 changes: 73 additions & 0 deletions internal/cmd/generator/vm.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b
code = code.Next
store(ctxptr, code.Idx, p)
}
case encoder.OpStructPtrHeadOmitZero:
p := load(ctxptr, code.Idx)
if p == 0 {
if code.Flags&encoder.AnonymousHeadFlags == 0 {
b = appendNullComma(ctx, b)
}
code = code.End.Next
break
}
store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum))
fallthrough
case encoder.OpStructHeadOmitZero:
p := load(ctxptr, code.Idx)
if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) {
if code.Flags&encoder.AnonymousHeadFlags == 0 {
b = appendNullComma(ctx, b)
}
code = code.End.Next
break
}
if code.Flags&encoder.AnonymousHeadFlags == 0 {
b = appendStructHead(ctx, b)
}
if (code.Flags & encoder.IndirectFlags) != 0 {
// the slot holds the struct base
p += uintptr(code.Offset)
if encoder.IsZeroForOmitZero(code, p) {
code = code.NextField
} else {
b = appendStructKey(ctx, code, b)
if code.Type.Kind() == reflect.Map {
// map opcodes take the map value itself, not its address
p = ptrToPtr(p)
}
code = code.Next
store(ctxptr, code.Idx, p)
}
} else {
// direct interface: the slot holds the field value itself, so the
// slot is the field's storage for every kind but map
if code.Type.Kind() == reflect.Map {
if p == 0 {
code = code.NextField
} else {
b = appendStructKey(ctx, code, b)
code = code.Next
store(ctxptr, code.Idx, p)
}
} else {
p = ctxptr + uintptr(code.Idx)
if encoder.IsZeroForOmitZero(code, p) {
code = code.NextField
} else {
b = appendStructKey(ctx, code, b)
code = code.Next
store(ctxptr, code.Idx, p)
}
}
}
case encoder.OpStructPtrHeadInt:
if (code.Flags & encoder.IndirectFlags) != 0 {
p := load(ctxptr, code.Idx)
Expand Down Expand Up @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b
code = code.Next
store(ctxptr, code.Idx, p)
}
case encoder.OpStructFieldOmitZero:
p := load(ctxptr, code.Idx)
p += uintptr(code.Offset)
if encoder.IsZeroForOmitZero(code, p) {
code = code.NextField
} else {
b = appendStructKey(ctx, code, b)
if code.Type.Kind() == reflect.Map {
// map opcodes take the map value itself, not its address
p = ptrToPtr(p)
}
code = code.Next
store(ctxptr, code.Idx, p)
}
case encoder.OpStructFieldInt:
p := load(ctxptr, code.Idx)
b = appendStructKey(ctx, code, b)
Expand Down
13 changes: 9 additions & 4 deletions internal/encoder/code.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package encoder

import (
"fmt"
"reflect"
"unsafe"

Expand Down Expand Up @@ -633,6 +632,9 @@ func (c *StructFieldCode) getAnonymousStruct() *StructCode {
}

func optimizeStructHeader(code *Opcode, tag *runtime.StructTag) OpType {
if tag.IsOmitZero {
return OpStructHeadOmitZero
}
headType := code.ToHeaderType(tag.IsString)
if tag.IsOmitEmpty {
headType = headType.HeadToOmitEmptyHead()
Expand All @@ -641,6 +643,9 @@ func optimizeStructHeader(code *Opcode, tag *runtime.StructTag) OpType {
}

func optimizeStructField(code *Opcode, tag *runtime.StructTag) OpType {
if tag.IsOmitZero {
return OpStructFieldOmitZero
}
fieldType := code.ToFieldType(tag.IsString)
if tag.IsOmitEmpty {
fieldType = fieldType.FieldToOmitEmptyField()
Expand Down Expand Up @@ -714,9 +719,9 @@ func (c *StructFieldCode) addStructEndCode(ctx *compileContext, codes Opcodes) O
func (c *StructFieldCode) structKey(ctx *compileContext) string {
if ctx.escapeKey {
rctx := &RuntimeContext{Option: &Option{Flag: HTMLEscapeOption}}
return fmt.Sprintf(`%s:`, string(AppendString(rctx, []byte{}, c.key)))
return string(AppendString(rctx, []byte{}, c.key)) + ":"
}
return fmt.Sprintf(`"%s":`, c.key)
return `"` + c.key + `":`
}

func (c *StructFieldCode) flags() OpFlags {
Expand Down Expand Up @@ -777,7 +782,7 @@ func (c *StructFieldCode) ToOpcode(ctx *compileContext, isFirstField, isEndField
}
codes := c.fieldOpcodes(ctx, field, valueCodes)
if isEndField {
if isEnableStructEndOptimization(c.value) {
if !c.tag.IsOmitZero && isEnableStructEndOptimization(c.value) {
field.Op = field.Op.FieldToEnd()
} else {
codes = c.addStructEndCode(ctx, codes)
Expand Down
4 changes: 4 additions & 0 deletions internal/encoder/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func (t OpType) IsMultipleOpHead() bool {
return true
case OpStructHeadOmitEmptyStruct:
return true
case OpStructHeadOmitZero:
return true
case OpStructHeadSlicePtr:
return true
case OpStructHeadOmitEmptySlicePtr:
Expand Down Expand Up @@ -77,6 +79,8 @@ func (t OpType) IsMultipleOpField() bool {
return true
case OpStructFieldOmitEmptyStruct:
return true
case OpStructFieldOmitZero:
return true
case OpStructFieldSlicePtr:
return true
case OpStructFieldOmitEmptySlicePtr:
Expand Down
108 changes: 108 additions & 0 deletions internal/encoder/omitzero.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package encoder

import (
"reflect"
"unsafe"

"github.com/goccy/go-json/internal/runtime"
)

// IsZeroer is the interface used to check custom zero values.
type IsZeroer interface {
IsZero() bool
}

// IsZeroForOmitZero determines if a value should be omitted when using the omitzero tag.
// It checks if a value is zero (default/empty for its type).
//
// Values are read with the *(*T)(unsafe.Pointer(&ptr)) double-deref idiom used
// throughout the VM (see internal/encoder/vm/util.go): it never converts a
// uintptr to unsafe.Pointer directly, so it passes go vet's unsafeptr check
// and the race detector's checkptr validation.
func IsZeroForOmitZero(code *Opcode, ptr uintptr) bool {
if ptr == 0 {
return true
}

kind := code.Type.Kind()
switch kind {
// Pointer types: zero only if nil
case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Interface:
// These types use a pointer as their first field
p := **(**unsafe.Pointer)(unsafe.Pointer(&ptr))
return p == nil

// Boolean type
case reflect.Bool:
return !**(**bool)(unsafe.Pointer(&ptr))

// Signed integer types
case reflect.Int:
return **(**int)(unsafe.Pointer(&ptr)) == 0
case reflect.Int8:
return **(**int8)(unsafe.Pointer(&ptr)) == 0
case reflect.Int16:
return **(**int16)(unsafe.Pointer(&ptr)) == 0
case reflect.Int32:
return **(**int32)(unsafe.Pointer(&ptr)) == 0
case reflect.Int64:
return **(**int64)(unsafe.Pointer(&ptr)) == 0

// Unsigned integer types
case reflect.Uint:
return **(**uint)(unsafe.Pointer(&ptr)) == 0
case reflect.Uint8:
return **(**uint8)(unsafe.Pointer(&ptr)) == 0
case reflect.Uint16:
return **(**uint16)(unsafe.Pointer(&ptr)) == 0
case reflect.Uint32:
return **(**uint32)(unsafe.Pointer(&ptr)) == 0
case reflect.Uint64:
return **(**uint64)(unsafe.Pointer(&ptr)) == 0
case reflect.Uintptr:
return **(**uintptr)(unsafe.Pointer(&ptr)) == 0

// Floating point types
case reflect.Float32:
return **(**float32)(unsafe.Pointer(&ptr)) == 0
case reflect.Float64:
return **(**float64)(unsafe.Pointer(&ptr)) == 0

// Complex types
case reflect.Complex64:
return **(**complex64)(unsafe.Pointer(&ptr)) == 0
case reflect.Complex128:
return **(**complex128)(unsafe.Pointer(&ptr)) == 0

// String type
case reflect.String:
s := **(**string)(unsafe.Pointer(&ptr))
return len(s) == 0

// For complex types (Array, Struct), fall back to reflect, which honors
// a custom IsZero() method (e.g. time.Time)
default:
rt := runtime.RType2Type(code.Type)
rv := reflect.NewAt(rt, *(*unsafe.Pointer)(unsafe.Pointer(&ptr))).Elem()
return isZeroValue(rv)
}
}

// isZeroValue checks if a reflect.Value is zero, including support for custom IsZero methods
func isZeroValue(rv reflect.Value) bool {
// Check for custom IsZero implementation first
if rv.CanInterface() {
if iz, ok := rv.Interface().(IsZeroer); ok {
return iz.IsZero()
}
}

// Use reflect's built-in IsZero
// This correctly handles:
// - nil pointers (IsZero() == true)
// - non-nil pointers to zero values (IsZero() == false, since pointer is not nil)
// - nil slices/maps/channels (IsZero() == true)
// - empty non-nil slices/maps (IsZero() == false, since they're not nil)
// - zero primitives like 0, "", false (IsZero() == true)
return rv.IsZero()
}
Loading