Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ msgp/cover.out
.idea/
.vscode/
cover.out
.DS_Store

# locally built msgp binaries
msgp-bin
msgp/msgp
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ $(MGEN): ./msgp/defs_test.go

test: all
go test -covermode=atomic -coverprofile=cover.out ./...
cd tests && go test ./...

bench: all
go test -bench ./...
Expand Down
1 change: 1 addition & 0 deletions gen/elem.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ var primitives = map[string]Primitive{
"int64": Int64,
"bool": Bool,
"interface{}": Intf,
"any": Intf, // builtin alias for interface{} (Go 1.18+)
"time.Time": Time,
"msgp.Extension": Ext,
"error": Error,
Expand Down
50 changes: 45 additions & 5 deletions gen/testgen.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gen

import (
"go/ast"
"io"
"text/template"
)
Expand All @@ -25,32 +26,68 @@ type mtestGen struct {
w io.Writer
}

// mtestData is the data handed to the marshal-test template.
type mtestData struct {
TypeName string
// HasRequired is true when decoding the zero value of this type would trip
// a generated `required` field check and return an error. The zero-value
// round-trip assertions are relaxed in that case, since a zero value is by
// definition not a valid encoding of a type with required fields.
HasRequired bool
}

func (m *mtestGen) Execute(p Elem) ([]string, error) {
p = m.applyall(p)
if p != nil && !IsDangling(p) {
switch p.(type) {
case *Struct, *Array, *Slice, *Map:
return nil, marshalTestTempl.Execute(m.w, p)
return nil, marshalTestTempl.Execute(m.w, mtestData{
TypeName: p.TypeName(),
HasRequired: zeroValueHasRequired(p),
})
}
}
return nil, nil
}

func (m *mtestGen) Method() Method { return marshaltest }

// zeroValueHasRequired reports whether decoding the zero value of e would fail
// a generated `required` check. Only exported fields get checks. A zero slice
// or map is empty, so its elements (and their checks) are never decoded; a zero
// array decodes its zero elements, so its element type still matters.
func zeroValueHasRequired(e Elem) bool {
switch e := e.(type) {
case *Struct:
for i := range e.Fields {
if ast.IsExported(e.Fields[i].FieldName) && e.Fields[i].HasTagPart("required") {
return true
}
}
case *Array:
return zeroValueHasRequired(e.Els)
}
return false
}

func init() {
template.Must(marshalTestTempl.Parse(`func TestMarshalUnmarshal{{.TypeName}}(t *testing.T) {
partitiontest.PartitionTest(t)
v := {{.TypeName}}{}
bts := v.MarshalMsg(nil)
left, err := v.UnmarshalMsg(bts)
if err != nil {
{{if .HasRequired}} // The zero value omits its required field(s), so UnmarshalMsg is expected to
// reject it; only MarshalMsg and Skip are exercised against a zero value.
if err == nil {
t.Errorf("expected a missing-required-field error decoding a zero {{.TypeName}}")
}
{{else}} if err != nil {
t.Fatal(err)
}
if len(left) > 0 {
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
}

{{end}}
left, err = msgp.Skip(bts)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -92,11 +129,14 @@ func BenchmarkUnmarshal{{.TypeName}}(b *testing.B) {
b.SetBytes(int64(len(bts)))
b.ResetTimer()
for i:=0; i<b.N; i++ {
_, err := v.UnmarshalMsg(bts)
{{if .HasRequired}} // The zero value fails the required-field check; ignore the error so the
// benchmark still measures the decoding work.
_, _ = v.UnmarshalMsg(bts)
{{else}} _, err := v.UnmarshalMsg(bts)
if err != nil {
b.Fatal(err)
}
}
{{end}} }
}

`))
Expand Down
45 changes: 45 additions & 0 deletions gen/unmarshal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gen

import (
"fmt"
"go/ast"
"io"
"strconv"
Expand Down Expand Up @@ -135,9 +136,53 @@ func (u *unmarshalGen) gStruct(s *Struct) {
} else {
u.mapstruct(s)
}
u.required(s)
return
}

// required emits, for every exported field tagged with the `required` codec
// option, a check that the field holds a non-zero value after decoding. A
// required field that is still zero (because it was absent from the encoded
// object, or encoded as a zero value) is a decode error. This runs after the
// struct body has been decoded, so it applies uniformly to the map,
// struct-from-array, and tuple decode paths.
func (u *unmarshalGen) required(s *Struct) {
for i := range s.Fields {
if !u.p.ok() {
return
}
if !ast.IsExported(s.Fields[i].FieldName) {
continue
}
if !s.Fields[i].HasTagPart("required") {
continue
}
ize := s.Fields[i].FieldElem.IfZeroExpr()
if ize == "" {
// The check needs a zero-value comparison. Every named type has
// one (IfZeroExpr falls back to its generated MsgIsZero), so this
// only happens for an inline anonymous struct, which has no zero
// comparison and no MsgIsZero method. Unlike MsgIsZero (which can
// default to "true"), required has no safe fallback: defaulting to
// "always zero" would reject every value and make the type
// undecodable. Fail generation instead. PrintTo treats any recorded
// message as fatal.
u.msgs = append(u.msgs, fmt.Sprintf("Cannot enforce `required` on field %s of %s: type has no zero-value comparison", s.Fields[i].FieldName, s.TypeName()))
continue
}
// Use a plain errors.New rather than a dedicated msgp error type so the
// generated code depends only on symbols that already exist in the msgp
// library: a project can pick up `required` by updating the generator
// alone, without bumping its msgp library version. goimports adds the
// "errors" import to the generated file. The %q formats the message as a
// properly escaped Go string literal.
u.p.printf("\nif %s {", ize)
u.p.printf("\nerr = errors.New(%q)", "missing required field: "+s.Fields[i].FieldTag)
u.p.wrapErrCheck(u.ctx.ArgsStr())
u.p.printf("\n}")
}
}

func (u *unmarshalGen) tuple(s *Struct) {

// open block
Expand Down
18 changes: 18 additions & 0 deletions tests/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// This is a nested module so the msgp-generated *_gen_test.go files, which
// import go-algorand's protocol and partitiontest packages, can compile and run
// against local stubs. Keeping it separate from the root module means the root
// go.mod stays free of replace directives (which would break
// `go run github.com/algorand/msgp@version`) and free of a go-algorand
// dependency. Run these tests with: cd tests && go test ./...
module github.com/algorand/msgp/tests

go 1.23

require (
github.com/algorand/go-algorand v0.0.0-00010101000000-000000000000
github.com/algorand/msgp v0.0.0
)

replace github.com/algorand/msgp => ../

replace github.com/algorand/go-algorand => ./internal/algorandstub
8 changes: 8 additions & 0 deletions tests/internal/algorandstub/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Module algorandstub stands in for github.com/algorand/go-algorand so the
// msgp-generated *_gen_test.go files (which import that module's protocol and
// partitiontest packages) can compile and run inside this repo without taking
// a real dependency on go-algorand. It is wired in via a replace directive in
// ../../go.mod and is never published or imported by the msgp tool itself.
module github.com/algorand/go-algorand

go 1.23
13 changes: 13 additions & 0 deletions tests/internal/algorandstub/protocol/protocol.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Package protocol is a minimal stand-in for
// github.com/algorand/go-algorand/protocol, providing just enough surface for
// the generated TestRandomizedEncoding* functions to compile and run.
package protocol

import "testing"

// RunEncodingTest is a no-op stand-in for go-algorand's randomized encoding
// test helper. The real helper fuzzes random instances; reproducing that is out
// of scope here, and a zero/random value would also trip required-field checks.
// The generated round-trip coverage lives in TestMarshalUnmarshal* and the
// hand-written tests in this package.
func RunEncodingTest(t *testing.T, v interface{}) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Package partitiontest is a minimal stand-in for
// github.com/algorand/go-algorand/test/partitiontest so generated tests that
// call partitiontest.PartitionTest(t) compile and run in this repo.
package partitiontest

import "testing"

// PartitionTest is a no-op stand-in for go-algorand's test-partitioning helper.
func PartitionTest(t *testing.T) {}
65 changes: 65 additions & 0 deletions tests/required.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Package tests exercises the `required` codec struct-tag option, which makes
// the generated decoder reject any field that holds its zero value after
// decoding (whether the field was absent from the wire or encoded as zero).
package tests

//go:generate msgp

//msgp:tuple TupleRequired

// MapRequired covers the required option on a map-encoded struct. Its _struct
// tag carries no omitempty, so a zero required field is still written to the
// wire (the "present but zero" rejection path); a field that individually opts
// into omitempty is instead dropped when zero (the "absent from the wire"
// path).
type MapRequired struct {
_struct struct{} `codec:""`

// Required, not omitempty: a zero value is written to the wire and must
// still be rejected on decode.
ReqPlain string `codec:"reqplain,required"`

// Required and omitempty: a zero value is dropped by the encoder, leaving
// the field absent on decode, and must still be rejected.
ReqOmit int64 `codec:"reqomit,omitempty,required"`

// Not required: a zero value decodes without error.
Optional string `codec:"opt,omitempty"`
}

// MapRequiredOmitEmpty has the same shape as MapRequired but its _struct tag
// carries omitempty, so struct-level omitempty applies to every field. A zero
// required field is dropped from the wire entirely (rather than written as
// zero) and must still be rejected on decode.
type MapRequiredOmitEmpty struct {
_struct struct{} `codec:",omitempty"`

// Required with struct-level (not field-level) omitempty: a zero value is
// still dropped by the encoder, leaving the field absent on decode.
Req string `codec:"req,required"`

// Not required.
Optional string `codec:"opt"`
}

// Inner is a named struct used to exercise required on composite fields.
type Inner struct {
_struct struct{} `codec:",omitempty"`
X string `codec:"x,omitempty"`
}

// CompositeRequired covers required on a non-scalar field, whose zero check is
// derived from the type rather than a literal: a value struct is zero when all
// of its fields are zero.
type CompositeRequired struct {
_struct struct{} `codec:",omitempty"`

Nested Inner `codec:"nested,required"` // zero when Inner is zero (X == "")
}

// TupleRequired is an array-encoded struct (see the //msgp:tuple directive)
// confirming the required check also runs on the tuple decode path.
type TupleRequired struct {
A string `codec:"a,required"`
B int64 `codec:"b"`
}
Loading
Loading