Skip to content

Commit f3a2ac5

Browse files
authored
fix: gogen jsonschema number and nullable handling (#13)
1 parent d8688bc commit f3a2ac5

2 files changed

Lines changed: 138 additions & 7 deletions

File tree

gogen.go

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ func WithGoHeaderComment(text string) GoOption {
5252
// GoGenerator to render many schemas with the same options; option parsing
5353
// happens once at construction. It is the inverse of [Generator], which
5454
// produces a schema from a Go type.
55+
//
56+
// Nullability is presence: a scalar whose type union admits null
57+
// ("type": ["number", "null"]) — or the two-branch anyOf form
58+
// [WithGenerateNullablePointers] emits for a Go pointer — generates a
59+
// pointer field (*float64), so an absent or null value is distinguishable
60+
// from the zero value. Requiredness never strips the pointer (null is a
61+
// legal value); containers stay bare (nil is already their absent state).
5562
type GoGenerator struct {
5663
opts goOptions
5764
}
@@ -229,15 +236,22 @@ func (b *goBuilder) goType(sch map[string]any, hint string) (string, bool) {
229236
b.defineType(hint, stringOf(sch["description"]), sch)
230237
return hint, true
231238
}
239+
if inner, ok := nullableAnyOf(sch); ok {
240+
typ, _ := b.goType(inner, hint)
241+
if strings.HasPrefix(typ, "*") || typ == "any" {
242+
return typ, false
243+
}
244+
return "*" + typ, false
245+
}
232246
switch typeName(sch) {
233247
case "string":
234-
return "string", false
248+
return nullable(sch, "string"), false
235249
case "integer":
236-
return "int", false
250+
return nullable(sch, "int"), false
237251
case "number":
238-
return "float64", false
252+
return nullable(sch, "float64"), false
239253
case "boolean":
240-
return "bool", false
254+
return nullable(sch, "bool"), false
241255
case "array":
242256
return "[]" + b.elemType(sch, hint), false
243257
case "object":
@@ -347,7 +361,8 @@ func definitions(root map[string]any) map[string]any {
347361
}
348362

349363
// typeName returns the schema's "type" as a single name. A type array such
350-
// as ["string","null"] resolves to its sole non-null member; a union of
364+
// as ["string","null"] resolves to its sole non-null member (see nullableType
365+
// for how that nullability reaches the generated Go type); a union of
351366
// several concrete types resolves to the empty string (meaning "any").
352367
func typeName(sch map[string]any) string {
353368
switch t := sch["type"].(type) {
@@ -371,6 +386,48 @@ func typeName(sch map[string]any) string {
371386
}
372387
}
373388

389+
// nullableAnyOf detects the two-branch nullable union [WithGenerateNullablePointers]
390+
// emits for a Go pointer — anyOf: [{"type":"null"}, <inner>] in either order —
391+
// and returns the non-null branch. It is the round-trip inverse of that
392+
// option: a schema generated FROM *T regenerates *T.
393+
func nullableAnyOf(sch map[string]any) (map[string]any, bool) {
394+
anyOf, ok := sch["anyOf"].([]any)
395+
if !ok || len(anyOf) != 2 {
396+
return nil, false
397+
}
398+
isNull := func(v any) bool {
399+
m := mapOf(v)
400+
return len(m) == 1 && stringOf(m["type"]) == "null"
401+
}
402+
switch {
403+
case isNull(anyOf[0]):
404+
return mapOf(anyOf[1]), true
405+
case isNull(anyOf[1]):
406+
return mapOf(anyOf[0]), true
407+
}
408+
return nil, false
409+
}
410+
411+
// nullable wraps a scalar Go type in a pointer when the schema's type union
412+
// admits null — `"type": ["number", "null"]` generates *float64, so an absent
413+
// (or explicitly null) value is distinguishable from the zero value (the
414+
// anyOf form is nullableAnyOf's job). This is the presence-carrying mapping
415+
// for keywords whose zero is meaningful (a declared `minimum: 0` versus no
416+
// minimum at all). Containers are exempt: slices and maps already have an
417+
// absent state (nil).
418+
func nullable(sch map[string]any, goType string) string {
419+
types, ok := sch["type"].([]any)
420+
if !ok {
421+
return goType
422+
}
423+
for _, e := range types {
424+
if s, _ := e.(string); s == "null" {
425+
return "*" + goType
426+
}
427+
}
428+
return goType
429+
}
430+
374431
// enumType infers a Go type from an enum or const value when no explicit
375432
// type keyword is present. It returns the empty string when neither applies.
376433
func enumType(sch map[string]any) string {

gogen_test.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package jsonschema_test
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"go/ast"
67
"go/parser"
@@ -234,10 +235,28 @@ func TestGenerateGo_typeMatrix(t *testing.T) {
234235
{"const_string", `{"const":"x"}`, "string"},
235236
{"const_bool", `{"const":true}`, "bool"},
236237

237-
// Type unions.
238-
{"nullable_string", `{"type":["string","null"]}`, "string"},
238+
// Type unions. A scalar + null union is the presence-carrying mapping:
239+
// the pointer distinguishes "absent (or null)" from the zero value —
240+
// the contract keywords like a spec's `minimum` rely on (0 declared
241+
// vs no bound at all). Containers stay bare: nil is already absence.
242+
{"nullable_string", `{"type":["string","null"]}`, "*string"},
243+
{"nullable_integer", `{"type":["integer","null"]}`, "*int"},
244+
{"nullable_number", `{"type":["number","null"]}`, "*float64"},
245+
{"nullable_boolean", `{"type":["boolean","null"]}`, "*bool"},
246+
{"nullable_array", `{"type":["array","null"],"items":{"type":"string"}}`, "[]string"},
247+
{"nullable_items", `{"type":"array","items":{"type":["integer","null"]}}`, "[]*int"},
248+
{"nullable_map_value", `{"type":"object","additionalProperties":{"type":["string","null"]}}`, "map[string]*string"},
239249
{"union_multi", `{"type":["string","integer"]}`, "any"},
240250

251+
// The anyOf nullable form — what WithGenerateNullablePointers emits
252+
// for a Go pointer — round-trips back to the pointer, either order.
253+
{"anyof_null_number", `{"anyOf":[{"type":"null"},{"type":"number"}]}`, "*float64"},
254+
{"anyof_number_null", `{"anyOf":[{"type":"integer"},{"type":"null"}]}`, "*int"},
255+
{"anyof_null_ref", `{"anyOf":[{"type":"null"},{"$ref":"#/definitions/Ref"}]}`, "*Ref"},
256+
{"anyof_null_array", `{"anyOf":[{"type":"null"},{"type":"array","items":{"type":"string"}}]}`, "*[]string"},
257+
{"anyof_null_any", `{"anyOf":[{"type":"null"},{}]}`, "any"},
258+
{"anyof_three_way", `{"anyOf":[{"type":"null"},{"type":"string"},{"type":"integer"}]}`, "any"},
259+
241260
// Constructs with no faithful Go mapping: degrade to any.
242261
{"oneOf", `{"oneOf":[{"type":"string"},{"type":"integer"}]}`, "any"},
243262
{"anyOf", `{"anyOf":[{"type":"string"},{"type":"boolean"}]}`, "any"},
@@ -254,6 +273,61 @@ func TestGenerateGo_typeMatrix(t *testing.T) {
254273
}
255274
}
256275

276+
// TestGenerateGo_nullableScalars pins the presence-carrying contract around
277+
// requiredness: a nullable scalar is a pointer whether the field is required
278+
// or not (null is a legal VALUE — requiredness governs the json tag, never
279+
// strips the pointer), and the optionality rule for objects cannot
280+
// double-pointer it.
281+
func TestGenerateGo_nullableScalars(t *testing.T) {
282+
schema := []byte(`{
283+
"type": "object",
284+
"required": ["min"],
285+
"properties": {
286+
"min": {"type": ["number", "null"]},
287+
"max": {"type": ["number", "null"]}
288+
}
289+
}`)
290+
src, err := jsonschema.GenerateGo(schema, jsonschema.WithGoRootType("Root"))
291+
if err != nil {
292+
t.Fatalf("GenerateGo: %v", err)
293+
}
294+
if got := structFieldType(t, src, "Root", "Min"); got != "*float64" {
295+
t.Errorf("required nullable scalar = %q, want *float64", got)
296+
}
297+
if got := structFieldType(t, src, "Root", "Max"); got != "*float64" {
298+
t.Errorf("optional nullable scalar = %q, want *float64", got)
299+
}
300+
}
301+
302+
// TestGenerateGo_nullableRoundTrip pins the two directions against each
303+
// other: a Go pointer field, emitted as a schema under
304+
// WithGenerateNullablePointers (the anyOf:[null, T] shape), regenerates the
305+
// same pointer field through GenerateGo.
306+
func TestGenerateGo_nullableRoundTrip(t *testing.T) {
307+
type Bounds struct {
308+
Minimum *float64 `json:"minimum"`
309+
Label string `json:"label"`
310+
}
311+
schema, err := jsonschema.Generate(Bounds{}, jsonschema.WithGenerateNullablePointers(true))
312+
if err != nil {
313+
t.Fatalf("Generate: %v", err)
314+
}
315+
raw, err := json.Marshal(schema)
316+
if err != nil {
317+
t.Fatalf("marshal schema: %v", err)
318+
}
319+
src, err := jsonschema.GenerateGo(raw, jsonschema.WithGoRootType("Bounds"))
320+
if err != nil {
321+
t.Fatalf("GenerateGo: %v", err)
322+
}
323+
if got := structFieldType(t, src, "Bounds", "Minimum"); got != "*float64" {
324+
t.Errorf("round-tripped pointer = %q, want *float64", got)
325+
}
326+
if got := structFieldType(t, src, "Bounds", "Label"); got != "string" {
327+
t.Errorf("round-tripped value = %q, want string", got)
328+
}
329+
}
330+
257331
// TestGenerateGo_requiredFields verifies that required scalars are value
258332
// types without omitempty and that a required object reference is a value
259333
// (not a pointer).

0 commit comments

Comments
 (0)