Skip to content

Commit 3474462

Browse files
feat(client): add escape hatch for null slice & maps
1 parent 329cd3e commit 3474462

File tree

7 files changed

+153
-121
lines changed

7 files changed

+153
-121
lines changed

internal/encoding/json/encode.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ type mapEncoder struct {
776776
}
777777

778778
func (me mapEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
779-
if v.IsNil() {
779+
if v.IsNil() /* EDIT(begin) */ || sentinel.IsValueNull(v) /* EDIT(end) */ {
780780
e.WriteString("null")
781781
return
782782
}
@@ -855,7 +855,7 @@ type sliceEncoder struct {
855855
}
856856

857857
func (se sliceEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
858-
if v.IsNil() {
858+
if v.IsNil() /* EDIT(begin) */ || sentinel.IsValueNull(v) /* EDIT(end) */ {
859859
e.WriteString("null")
860860
return
861861
}
@@ -916,14 +916,7 @@ type ptrEncoder struct {
916916
}
917917

918918
func (pe ptrEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
919-
// EDIT(begin)
920-
//
921-
// if v.IsNil() {
922-
// e.WriteString("null")
923-
// return
924-
// }
925-
926-
if v.IsNil() || sentinel.IsValueNullPtr(v) || sentinel.IsValueNullSlice(v) {
919+
if v.IsNil() {
927920
e.WriteString("null")
928921
return
929922
}

internal/encoding/json/sentinel/null.go

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,41 @@ import (
66
"sync"
77
)
88

9-
var nullPtrsCache sync.Map // map[reflect.Type]*T
10-
11-
func NullPtr[T any]() *T {
12-
t := shims.TypeFor[T]()
13-
ptr, loaded := nullPtrsCache.Load(t) // avoid premature allocation
14-
if !loaded {
15-
ptr, _ = nullPtrsCache.LoadOrStore(t, new(T))
16-
}
17-
return (ptr.(*T))
9+
type cacheEntry struct {
10+
x any
11+
ptr uintptr
12+
kind reflect.Kind
1813
}
1914

20-
var nullSlicesCache sync.Map // map[reflect.Type][]T
15+
var nullCache sync.Map // map[reflect.Type]cacheEntry
2116

22-
func NullSlice[T any]() []T {
17+
func NewNullSentinel[T any](mk func() T) T {
2318
t := shims.TypeFor[T]()
24-
slice, loaded := nullSlicesCache.Load(t) // avoid premature allocation
19+
entry, loaded := nullCache.Load(t) // avoid premature allocation
2520
if !loaded {
26-
slice, _ = nullSlicesCache.LoadOrStore(t, []T{})
21+
x := mk()
22+
ptr := reflect.ValueOf(x).Pointer()
23+
entry, _ = nullCache.LoadOrStore(t, cacheEntry{x, ptr, t.Kind()})
2724
}
28-
return slice.([]T)
25+
return entry.(cacheEntry).x.(T)
2926
}
3027

31-
func IsNullPtr[T any](ptr *T) bool {
32-
nullptr, ok := nullPtrsCache.Load(shims.TypeFor[T]())
33-
return ok && ptr == nullptr.(*T)
34-
}
35-
36-
func IsNullSlice[T any](slice []T) bool {
37-
nullSlice, ok := nullSlicesCache.Load(shims.TypeFor[T]())
38-
return ok && reflect.ValueOf(slice).Pointer() == reflect.ValueOf(nullSlice).Pointer()
39-
}
40-
41-
// internal only
42-
func IsValueNullPtr(v reflect.Value) bool {
43-
if v.Kind() != reflect.Ptr {
44-
return false
28+
// for internal use only
29+
func IsValueNull(v reflect.Value) bool {
30+
switch v.Kind() {
31+
case reflect.Map, reflect.Slice:
32+
null, ok := nullCache.Load(v.Type())
33+
return ok && v.Pointer() == null.(cacheEntry).ptr
4534
}
46-
nullptr, ok := nullPtrsCache.Load(v.Type().Elem())
47-
return ok && v.Pointer() == reflect.ValueOf(nullptr).Pointer()
35+
return false
4836
}
4937

50-
// internal only
51-
func IsValueNullSlice(v reflect.Value) bool {
52-
if v.Kind() != reflect.Slice {
53-
return false
38+
func IsNull[T any](v T) bool {
39+
t := shims.TypeFor[T]()
40+
switch t.Kind() {
41+
case reflect.Map, reflect.Slice:
42+
null, ok := nullCache.Load(t)
43+
return ok && reflect.ValueOf(v).Pointer() == null.(cacheEntry).ptr
5444
}
55-
nullSlice, ok := nullSlicesCache.Load(v.Type().Elem())
56-
return ok && v.Pointer() == reflect.ValueOf(nullSlice).Pointer()
45+
return false
5746
}

internal/encoding/json/sentinel/sentinel_test.go

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sentinel_test
22

33
import (
44
"github.com/openai/openai-go/internal/encoding/json/sentinel"
5+
"github.com/openai/openai-go/packages/param"
56
"reflect"
67
"slices"
78
"testing"
@@ -15,25 +16,19 @@ type Pair struct {
1516
func TestNullSlice(t *testing.T) {
1617
var nilSlice []int = nil
1718
var nonNilSlice []int = []int{1, 2, 3}
18-
var nullSlice []int = sentinel.NullSlice[int]()
19+
var nullSlice []int = param.NullSlice[[]int]()
1920

2021
cases := map[string]Pair{
21-
"nilSlice": {sentinel.IsNullSlice(nilSlice), false},
22-
"nullSlice": {sentinel.IsNullSlice(nullSlice), true},
23-
"newNullSlice": {sentinel.IsNullSlice(sentinel.NullSlice[int]()), true},
22+
"nilSlice": {sentinel.IsNull(nilSlice), false},
23+
"nullSlice": {sentinel.IsNull(nullSlice), true},
24+
"newNullSlice": {sentinel.IsNull(param.NullSlice[[]int]()), true},
2425
"lenNullSlice": {len(nullSlice) == 0, true},
25-
"nilSliceValue": {sentinel.IsValueNullSlice(reflect.ValueOf(nilSlice)), false},
26-
"nullSliceValue": {sentinel.IsValueNullSlice(reflect.ValueOf(nullSlice)), true},
26+
"nilSliceValue": {sentinel.IsValueNull(reflect.ValueOf(nilSlice)), false},
27+
"nullSliceValue": {sentinel.IsValueNull(reflect.ValueOf(nullSlice)), true},
2728
"compareSlices": {slices.Compare(nilSlice, nullSlice) == 0, true},
2829
"compareNonNilSlices": {slices.Compare(nonNilSlice, nullSlice) == 0, false},
2930
}
3031

31-
nilSlice = append(nullSlice, 12)
32-
cases["append_result"] = Pair{sentinel.IsNullSlice(nilSlice), false}
33-
cases["mutated_result"] = Pair{sentinel.IsNullSlice(nullSlice), true}
34-
cases["append_result_len"] = Pair{len(nilSlice) == 1, true}
35-
cases["append_null_slice_len"] = Pair{len(nullSlice) == 0, true}
36-
3732
for name, c := range cases {
3833
t.Run(name, func(t *testing.T) {
3934
got, want := c.got, c.want
@@ -44,37 +39,20 @@ func TestNullSlice(t *testing.T) {
4439
}
4540
}
4641

47-
func TestNullPtr(t *testing.T) {
48-
var s *string = nil
49-
var i *int = nil
50-
var slice *[]int = nil
51-
52-
var nullptrStr *string = sentinel.NullPtr[string]()
53-
var nullptrInt *int = sentinel.NullPtr[int]()
54-
var nullptrSlice *[]int = sentinel.NullPtr[[]int]()
55-
56-
if *nullptrStr != "" {
57-
t.Errorf("Failed to safely deref")
58-
}
59-
if *nullptrInt != 0 {
60-
t.Errorf("Failed to safely deref")
61-
}
62-
if len(*nullptrSlice) != 0 {
63-
t.Errorf("Failed to safely deref")
64-
}
42+
func TestNullMap(t *testing.T) {
43+
var nilMap map[string]int = nil
44+
var nonNilMap map[string]int = map[string]int{"a": 1, "b": 2}
45+
var nullMap map[string]int = param.NullMap[map[string]int]()
6546

6647
cases := map[string]Pair{
67-
"nilStr": {sentinel.IsNullPtr(s), false},
68-
"nullStr": {sentinel.IsNullPtr(nullptrStr), true},
69-
70-
"nilInt": {sentinel.IsNullPtr(i), false},
71-
"nullInt": {sentinel.IsNullPtr(nullptrInt), true},
72-
73-
"nilSlice": {sentinel.IsNullPtr(slice), false},
74-
"nullSlice": {sentinel.IsNullPtr(nullptrSlice), true},
75-
76-
"nilValuePtr": {sentinel.IsValueNullPtr(reflect.ValueOf(i)), false},
77-
"nullValuePtr": {sentinel.IsValueNullPtr(reflect.ValueOf(nullptrInt)), true},
48+
"nilMap": {sentinel.IsNull(nilMap), false},
49+
"nullMap": {sentinel.IsNull(nullMap), true},
50+
"newNullMap": {sentinel.IsNull(param.NullMap[map[string]int]()), true},
51+
"lenNullMap": {len(nullMap) == 0, true},
52+
"nilMapValue": {sentinel.IsValueNull(reflect.ValueOf(nilMap)), false},
53+
"nullMapValue": {sentinel.IsValueNull(reflect.ValueOf(nullMap)), true},
54+
"compareMaps": {reflect.DeepEqual(nilMap, nullMap), false},
55+
"compareNonNilMaps": {reflect.DeepEqual(nonNilMap, nullMap), false},
7856
}
7957

8058
for name, test := range cases {
@@ -86,3 +64,28 @@ func TestNullPtr(t *testing.T) {
8664
})
8765
}
8866
}
67+
68+
func TestIsNullRepeated(t *testing.T) {
69+
// Test for slices
70+
nullSlice1 := param.NullSlice[[]int]()
71+
nullSlice2 := param.NullSlice[[]int]()
72+
if !sentinel.IsNull(nullSlice1) {
73+
t.Errorf("IsNull(nullSlice1) = false, want true")
74+
}
75+
if !sentinel.IsNull(nullSlice2) {
76+
t.Errorf("IsNull(nullSlice2) = false, want true")
77+
}
78+
if !sentinel.IsNull(nullSlice1) || !sentinel.IsNull(nullSlice2) {
79+
t.Errorf("IsNull should return true for all NullSlice instances")
80+
}
81+
82+
// Test for maps
83+
nullMap1 := param.NullMap[map[string]int]()
84+
nullMap2 := param.NullMap[map[string]int]()
85+
if !sentinel.IsNull(nullMap1) {
86+
t.Errorf("IsNull(nullMap1) = false, want true")
87+
}
88+
if !sentinel.IsNull(nullMap2) {
89+
t.Errorf("IsNull(nullMap2) = false, want true")
90+
}
91+
}

internal/paramutil/sentinel.go

Lines changed: 0 additions & 31 deletions
This file was deleted.

packages/param/null.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package param
2+
3+
import "github.com/openai/openai-go/internal/encoding/json/sentinel"
4+
5+
// NullMap returns a non-nil map with a length of 0.
6+
// When used with [MarshalObject] or [MarshalUnion], it will be marshaled as null.
7+
//
8+
// It is unspecified behavior to mutate the slice returned by [NullSlice].
9+
func NullMap[MapT ~map[string]T, T any]() MapT {
10+
return sentinel.NewNullSentinel(func() MapT { return make(MapT, 1) })
11+
}
12+
13+
// NullSlice returns a non-nil slice with a length of 0.
14+
// When used with [MarshalObject] or [MarshalUnion], it will be marshaled as null.
15+
//
16+
// It is unspecified behavior to mutate the slice returned by [NullSlice].
17+
func NullSlice[SliceT ~[]T, T any]() SliceT {
18+
return sentinel.NewNullSentinel(func() SliceT { return make(SliceT, 0, 1) })
19+
}

packages/param/null_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package param_test
2+
3+
import (
4+
"encoding/json"
5+
"github.com/openai/openai-go/packages/param"
6+
"testing"
7+
)
8+
9+
type Nullables struct {
10+
Slice []int `json:"slice,omitzero"`
11+
Map map[string]int `json:"map,omitzero"`
12+
param.APIObject
13+
}
14+
15+
func (n Nullables) MarshalJSON() ([]byte, error) {
16+
type shadow Nullables
17+
return param.MarshalObject(n, (*shadow)(&n))
18+
}
19+
20+
func TestNullMarshal(t *testing.T) {
21+
bytes, err := json.Marshal(Nullables{})
22+
if err != nil {
23+
t.Fatalf("json error %v", err.Error())
24+
}
25+
if string(bytes) != `{}` {
26+
t.Fatalf("expected empty object, got %s", string(bytes))
27+
}
28+
29+
obj := Nullables{
30+
Slice: param.NullSlice[[]int](),
31+
Map: param.NullMap[map[string]int](),
32+
}
33+
bytes, err = json.Marshal(obj)
34+
35+
if !param.IsNull(obj.Slice) {
36+
t.Fatal("failed null check")
37+
}
38+
if !param.IsNull(obj.Map) {
39+
t.Fatal("failed null check")
40+
}
41+
42+
if err != nil {
43+
t.Fatalf("json error %v", err.Error())
44+
}
45+
exp := `{"slice":null,"map":null}`
46+
if string(bytes) != exp {
47+
t.Fatalf("expected %s, got %s", exp, string(bytes))
48+
}
49+
}

packages/param/param.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package param
22

33
import (
44
"encoding/json"
5+
"github.com/openai/openai-go/internal/encoding/json/sentinel"
56
"reflect"
67
)
78

@@ -58,12 +59,21 @@ func IsOmitted(v any) bool {
5859

5960
// IsNull returns true if v was set to the JSON value null.
6061
//
61-
// To set a param to null use [NullStruct] or [Null]
62+
// To set a param to null use [NullStruct], [Null], [NullMap], or [NullSlice]
6263
// depending on the type of v.
6364
//
6465
// IsNull returns false if the value is omitted.
65-
func IsNull(v ParamNullable) bool {
66-
return v.null()
66+
func IsNull[T any](v T) bool {
67+
if nullable, ok := any(v).(ParamNullable); ok {
68+
return nullable.null()
69+
}
70+
71+
switch reflect.TypeOf(v).Kind() {
72+
case reflect.Slice, reflect.Map:
73+
return sentinel.IsNull(v)
74+
}
75+
76+
return false
6777
}
6878

6979
// ParamNullable encapsulates all structs in parameters,

0 commit comments

Comments
 (0)