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
15 changes: 14 additions & 1 deletion pkgs/internals/DataProviders.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ func (m *MapDataProvider[T]) Get(key string) any {
if !ok {
return nil
}
// Present with nil emits the sentinel so pointer schemas can distinguish it
// from an absent key.
if any(v) == nil {
return ExplicitNullMarker()
}
// A typed nil pointer inside an interface like (*string) is not == nil but is
// still semantically an explicit null. Other nillable kinds are excluded, a
// nil slice is accepted as empty input by non-pointer schemas today.
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() {
return ExplicitNullMarker()
}
return v
}

Expand Down Expand Up @@ -136,7 +147,9 @@ func TryNewAnyDataProvider(val any) (DataProvider, error) {
if ok {
return dp, nil
}
if val == nil {
// Here we treat the sentinel and a nil (absence) as the same because we are

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this is right?

if

var userPtr *User

errs := userPtrSchema.Parse(data, &userPtr)

We should be setting the ptr to nil to keep the behaviour consistent. So

var userPtr *User = &User{}

errs := userPtrSchema.Parse(data, &userPtr) // where data = nil

// should result in userPtr == nil

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or am I missing something?

// at the top level, for individual fields we do a different behavior.
if IsExplicitNull(val) || val == nil {
return &EmptyDataProvider{Underlying: val}, nil
}
x := reflect.ValueOf(val)
Expand Down
22 changes: 21 additions & 1 deletion pkgs/internals/zeroValues.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,25 @@ func IsZeroValue(x any) bool {

// checks if the value is the zero value but only for parsing purposes (i.e the parse function)
func IsParseZeroValue(val any, ctx Ctx) bool {
return val == nil
if val == nil {
return true
}
return IsExplicitNull(val)
}

// ExplicitNull is a sentinel for "key present with nil value" (e.g. JSON null)
// as opposed to "key absent". MapDataProvider.Get collapses both to bare nil
// without it, so pointer schemas cannot distinguish the two cases.
type ExplicitNull struct{}

// Unexported so external packages cannot reassign the singleton.
var explicitNullMarker = &ExplicitNull{}

func ExplicitNullMarker() *ExplicitNull {
return explicitNullMarker
}

func IsExplicitNull(val any) bool {
_, ok := val.(*ExplicitNull)
return ok
}
16 changes: 15 additions & 1 deletion pointers.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,28 @@ func (v *PointerSchema) process(ctx *p.SchemaCtx) {
}
ctx.Data = val
}

_, isEmptyStruct := ctx.Data.(*p.EmptyDataProvider)
// End of messy code

isZero := p.IsParseZeroValue(ctx.Data, ctx) || isEmptyStruct
if isZero {
if v.required != nil {
// We set the destination type to the schema type because pointer doesn't have any issue messages. They pass through to the schema type
ctx.AddIssue(ctx.IssueFromTest(v.required, ctx.Data).SetDType(v.schema.getType()))
issueVal := ctx.Data
if p.IsExplicitNull(issueVal) {
// Sentinel is internal surface nil to callers.
issueVal = nil
}
ctx.AddIssue(ctx.IssueFromTest(v.required, issueVal).SetDType(v.schema.getType()))
return
}
// Explicit null clears the destination pointer bare nil is treated as "key
// absent" and leaves the existing value in place.
if p.IsExplicitNull(ctx.Data) {
rv := reflect.ValueOf(ctx.ValPtr)
destPtr := rv.Elem()
destPtr.Set(reflect.Zero(destPtr.Type()))
}
return
}
Expand Down
177 changes: 177 additions & 0 deletions pointers_nullable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package zog

import (
"testing"

p "github.com/Oudwins/zog/pkgs/internals"
"github.com/stretchr/testify/assert"
)

func nullableStrPtr(s string) *string { return &s }

func TestPtrNullable_AbsentKeyPreservesExistingValue(t *testing.T) {
type Req struct {
Tag *string
}
schema := Struct(Shape{
"tag": Ptr(String()),
})
out := Req{Tag: nullableStrPtr("keep-me")}
errs := schema.Parse(map[string]any{}, &out)
assert.Empty(t, errs)
assert.NotNil(t, out.Tag)
assert.Equal(t, "keep-me", *out.Tag)
}

func TestPtrNullable_ExplicitNullClearsPointer(t *testing.T) {
type Req struct {
Tag *string
}
schema := Struct(Shape{
"tag": Ptr(String()),
})
out := Req{Tag: nullableStrPtr("clear-me")}
errs := schema.Parse(map[string]any{"tag": nil}, &out)
assert.Empty(t, errs)
assert.Nil(t, out.Tag)
}

func TestPtrNullable_ConcreteValueOverwrites(t *testing.T) {
type Req struct {
Tag *string
}
schema := Struct(Shape{
"tag": Ptr(String()),
})
var out Req
errs := schema.Parse(map[string]any{"tag": "v"}, &out)
assert.Empty(t, errs)
assert.NotNil(t, out.Tag)
assert.Equal(t, "v", *out.Tag)
}

func TestPtrNullable_NestedStructPointer_NullClearsOuter(t *testing.T) {
type Inner struct {
V int
}
type Outer struct {
Inner *Inner
}
schema := Struct(Shape{
"inner": Ptr(Struct(Shape{
"v": Int(),
})),
})
out := Outer{Inner: &Inner{V: 42}}
errs := schema.Parse(map[string]any{"inner": nil}, &out)
assert.Empty(t, errs)
assert.Nil(t, out.Inner)
}

func TestPtrNullable_NestedBareStruct_NullBehavesAsEmpty(t *testing.T) {
type Inner struct {
V *int
}
type Outer struct {
Inner Inner
}
schema := Struct(Shape{
"inner": Struct(Shape{
"v": Ptr(Int()),
}),
})
v := 7
out := Outer{Inner: Inner{V: &v}}
errs := schema.Parse(map[string]any{"inner": nil}, &out)
assert.Empty(t, errs)
assert.NotNil(t, out.Inner.V)
assert.Equal(t, 7, *out.Inner.V)
}

func TestPtrNullable_DoublePointer_OuterNullable(t *testing.T) {
type Req struct {
V **string
}
schema := Struct(Shape{
"v": Ptr(Ptr(String())),
})
inner := "keep"
outer := &inner
out := Req{V: &outer}
errs := schema.Parse(map[string]any{"v": nil}, &out)
assert.Empty(t, errs)
assert.Nil(t, out.V)
}

func TestPtrNullable_SliceOfNullablePtr(t *testing.T) {
schema := Slice(Ptr(String()))
var out []*string
errs := schema.Parse([]any{"a", nil, "c"}, &out)
assert.Empty(t, errs)
assert.Len(t, out, 3)
assert.NotNil(t, out[0])
assert.Equal(t, "a", *out[0])
assert.Nil(t, out[1])
assert.NotNil(t, out[2])
assert.Equal(t, "c", *out[2])
}

func TestPtrNullable_TopLevelBareNilDoesNotClear(t *testing.T) {
schema := Ptr(String())
dest := nullableStrPtr("keep")
errs := schema.Parse(nil, &dest)
assert.Empty(t, errs)
assert.NotNil(t, dest)
assert.Equal(t, "keep", *dest)
}

func TestPtrNullable_IssueValueIsNotSentinel(t *testing.T) {
type Req struct {
Tag *string
}
schema := Struct(Shape{
"tag": Ptr(String()).NotNil(),
})
var out Req
errs := schema.Parse(map[string]any{"tag": nil}, &out)
assert.NotEmpty(t, errs)
assert.False(t, p.IsExplicitNull(errs[0].Value), "issue value must not be the internal sentinel")
}

func TestPtrNullable_MapDataProvider_EmitsSentinelForExplicitNull(t *testing.T) {
m := map[string]any{"present_null": nil, "present_val": "hello"}
dp := p.NewSafeMapDataProvider(m)
assert.Nil(t, dp.Get("absent_key"))
assert.True(t, p.IsExplicitNull(dp.Get("present_null")))
assert.Equal(t, "hello", dp.Get("present_val"))
}

func TestPtrNullable_MapDataProvider_TypedMap_NoSentinel(t *testing.T) {
m := map[string]string{"empty": ""}
dp := p.NewSafeMapDataProvider(m)
assert.Equal(t, "", dp.Get("empty"))
assert.Nil(t, dp.Get("missing"))
assert.False(t, p.IsExplicitNull(dp.Get("empty")))
assert.False(t, p.IsExplicitNull(dp.Get("missing")))
}

func TestPtrNullable_TypedNilInMapAnyValue_EmitsSentinel(t *testing.T) {
var typedNil *string
dp := p.NewSafeMapDataProvider(map[string]any{"tag": typedNil})
assert.True(t, p.IsExplicitNull(dp.Get("tag")))
}

func TestPtrNullable_TypedNilInMapAnyValue_ClearsPointer(t *testing.T) {
type Req struct {
Tag *string
}
schema := Struct(Shape{
"tag": Ptr(String()),
})
var typedNil *string
input := map[string]any{"tag": typedNil}
out := Req{Tag: nullableStrPtr("preexisting")}
errs := schema.Parse(input, &out)
assert.Empty(t, errs)
assert.Nil(t, out.Tag)
}
Loading