Repository navigation
Expand file tree
/
Copy pathschema_node.go
More file actions
2331 lines (2211 loc) · 87.4 KB
/
Copy pathschema_node.go
File metadata and controls
2331 lines (2211 loc) · 87.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package avro
import (
"bytes"
"encoding"
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"reflect"
"slices"
"strconv"
"strings"
"unicode/utf8"
"unsafe"
)
// SchemaNode is a read-write representation of an Avro schema. You get one
// from a parsed schema via [Schema.Root], or you build one directly and
// convert it with [SchemaNode.Schema].
//
// The Type field determines which other fields are relevant:
//
// - Primitives (null, boolean, int, long, float, double, string, bytes):
// LogicalType, Precision, Scale, Props optional; other fields ignored.
// - record/error: Name, Fields required; Namespace, Doc, Props optional.
// - enum: Name, Symbols required; Namespace, Doc, Props optional.
// - array: Items required.
// - map: Values required.
// - fixed: Name, Size required; LogicalType, Precision, Scale, Namespace,
// Props optional.
// - union: Branches lists the member schemas.
//
// To reference a named type (record, enum, fixed) defined elsewhere in the
// schema, set Type to its full name (e.g. com.example.Address) and nothing
// else. In a [Schema.Root] tree, references also resolve outward. Converting
// *any* node with [SchemaNode.Schema] resolves names against the schema the
// tree came from, so a field type, union branch, or deeper node converts even
// when the definition lives outside the extracted node. A hand-built tree has
// no enclosing schema, so there you must define every referenced name within
// the tree you convert, or Schema returns an error.
type SchemaNode struct {
Type string // Avro type or named type reference
LogicalType string // e.g. date, timestamp-millis, decimal, uuid; empty if none (or if the value is not a string; see Props)
Name string // name for record, enum, fixed
// Namespace is the named type's resolved namespace. [Schema.Root] fills it
// for every named type, a child that inherits its enclosing namespace
// shows that namespace here, and "" always means the null namespace,
// never "inherit". [SchemaNode.Schema] emits a "namespace":"" escape when
// a null-namespace type sits inside a namespaced scope, so the distinction
// survives the round trip. A dotted Name takes precedence over this field.
Namespace string
Aliases []string // alternate names for named types (record, enum, fixed)
Doc string // documentation string
Fields []SchemaField // record fields
Items *SchemaNode // array element schema
Values *SchemaNode // map value schema
Branches []SchemaNode // union member schemas
Symbols []string // enum symbols
Size int // fixed byte size
EnumDefault string // default symbol for enum schema evolution
HasEnumDefault bool // true if an enum default is defined
// Precision and Scale are the decimal logical type's parameters. We set
// and validate them only when LogicalType is "decimal" on a bytes or
// fixed type. Anywhere else (no logical type, an unknown or non-decimal
// one, or a decimal on a type that does not support it) the attributes
// are plain metadata and appear in Props, as at the field level.
Precision int // decimal precision
Scale int // decimal scale
// Props holds custom (non-reserved) schema attributes: anything in the
// schema JSON that is not a standard Avro field (e.g. "com.example.tag").
// A reserved structural key on a kind that does not use it ("items" on
// an "int") also appears here when its body does not parse as a schema
// (a stray "items":3), and the matching structural field stays zero. A
// schema-shaped stray body instead appears as written on Items, Values,
// or Fields. A non-string logicalType likewise appears here verbatim,
// since only a string can name a logical type.
//
// Values use the natural Go types from JSON: string, bool, nil, []any,
// map[string]any, int64 for whole numbers, float64 for fractional. A
// number stays json.Number when neither fits: a whole number too large
// for int64, or a fractional literal over 1024 bytes, whose digits are
// kept verbatim rather than rounded. Whole-valued exponents collapse to
// int64 (1e3 reads as int64(1000)); exponents overflowing float64 give
// +/-Inf. math.NaN() re-reads as the string "NaN" after Schema()/Root(),
// because JSON has no NaN literal; +/-Inf round-trips as float64(+/-Inf).
//
// When you build a node by hand, a map key with a MarshalText method
// renders as that text whatever the key's kind, a float-kind key is an
// error, and invalid UTF-8 in a string or key becomes U+FFFD. None of
// this depends on the Go version. encoding/json changed all three
// between Go 1.26 and Go 1.27, so we name keys and replace bytes
// ourselves before it runs.
Props map[string]any
// refTarget is set only by [Schema.Root], on name-reference nodes, and
// points at the referenced definition inside the same tree. Schema emits
// that definition when the converted tree does not define the name
// itself, which is what lets a node extracted at any depth convert.
// Hand-built nodes leave it nil, copies carry it, and a node rebuilt
// field-by-field drops it.
refTarget *SchemaNode
// present records which attributes were written, which the fields alone
// cannot: a doc written as "" or an aliases written as [] is
// indistinguishable from one nobody wrote. It is unexported because a
// hand-built node has no empty doc to express. We consult it per
// attribute, since the authority differs: Apache Avro emits doc when
// non-null, so an empty doc survives, and aliases when non-empty, so an
// empty list is dropped. A structural key on a kind that does not bind it
// survives as written, since nothing else carries it.
present presenceSet
// refNS is the namespace scope refTarget was resolved in. We record it
// alongside the stamp because the two are only meaningful together.
// Whether Type still names the target is a question about the scope you
// wrote the reference in, and an extracted node is re-rooted at the null
// namespace, losing it. Set and read only with refTarget
// (nodeRefTargetAgrees); a nil stamp means the value is unused.
refNS string
}
// SchemaField represents a field in an Avro record schema.
type SchemaField struct {
Name string // field name
Type SchemaNode // field schema
// Default is the field's default value, present when HasDefault is
// true. The Go type matches the schema:
//
// - int schemas give int32, long schemas give int64. We reject
// out-of-range defaults at parse.
// - float schemas give float32, double float64. Overflows narrow to
// +/-Inf; NaN, +/-Inf, and a float-syntax "-0.0" round-trip. An
// integer-syntax "-0" is the sign-less integer 0 and reads as +0.0
// (matching Java and fastavro), though the wire encoder writes -0.0
// for it.
// - string and enum schemas give string.
// - bytes and fixed give []byte, already decoded from the JSON spec's
// codepoint-per-byte form.
// - record, array, and map give map[string]any or []any, each leaf
// following these same rules.
//
// Union defaults pick the first branch that accepts the value, and the Go
// type tells you which: ["float","int"] with default 42 gives float32(42).
//
// Unlike Props, a numeric Default is never json.Number: we reject defaults
// that do not fit the declared type.
Default any
HasDefault bool // true if a default value is defined in the schema
Aliases []string // field aliases for schema evolution
Order string // sort order: "ascending" (default), "descending", or "ignore"
Doc string // documentation string
// docSet is the field-level twin of SchemaNode's present: a field's
// "doc" written as the empty string is a written doc, and Apache Avro
// emits it.
docSet bool
// Props holds custom (non-reserved) field properties; numbers decode as
// in [SchemaNode.Props]. A field-level "logicalType", "precision", and
// "scale" appear here as written even when we lift them onto the
// field's type for encoding and decoding. An unused precision or scale
// is an ordinary property whatever its JSON shape; we only validate the
// pair when a decimal logicalType on a bytes or fixed field uses it.
Props map[string]any
}
// Schema parses the SchemaNode into a [*Schema] you can encode and decode
// with. We return an error if the node is invalid.
//
// Named types appearing multiple times are deduplicated by fullname: the
// first occurrence emits the definition, later ones emit the fullname as a
// reference. Two types sharing a short name across namespaces are distinct
// and both emit definitions.
//
// A node extracted from a [Schema.Root] tree may reference definitions living
// elsewhere in the enclosing schema: an earlier field, a prior [SchemaCache]
// parse, or the enclosing type itself for a recursive schema. Those resolve
// automatically. We emit the definition at the reference's first occurrence,
// so the result needs neither the enclosing schema nor any cache. A name the
// tree defines itself wins over the enclosing schema's definition. Custom
// properties on a wrapped reference move onto the emitted definition, while
// reserved usage-site attributes (doc, namespace) do not survive. Hand-built
// nodes have no enclosing schema, so there a reference the tree does not
// define is an error.
//
// opts pass through to the internal [Parse]. If you parsed the original schema
// with [SchemaOpt]s that change what Parse accepts or wires ([WithLaxNames],
// [CustomType] registrations), pass the same opts here. Otherwise the rebuilt
// schema fails to parse or silently lacks the custom wiring.
func (n *SchemaNode) Schema(opts ...SchemaOpt) (*Schema, error) {
d := &deduper{
defined: make(map[string]*SchemaNode),
visited: make(map[*SchemaNode]struct{}),
}
tree := n.toJSONDedup(d)
if d.err != nil {
return nil, d.err
}
b, err := marshalSchemaTree(tree)
if err != nil {
return nil, fmt.Errorf("avro: marshaling schema node: %w", err)
}
return Parse(string(b), opts...)
}
// ExpandReferences returns a copy of n's tree with every name reference
// replaced by the definition it names, so each occurrence of a repeated named
// type carries the full body rather than only the first. n is not modified.
//
// A reference resolves the way [SchemaNode.Schema] resolves it, so a subtree
// extracted from a [Schema.Root] tree expands even when the definition lives
// outside it.
//
// Some references stay as they are. A name that closes a cycle never expands,
// since expanding a recursive definition does not terminate. A reference that
// carries attributes of its own ({"type":"Inner","doc":"x"}) stays, because a
// definition cannot hold a second doc. And if the fully expanded tree would
// exceed an internal ceiling, nothing expands at all, since a chain of
// definitions each naming the previous twice doubles per level; we return an
// unexpanded copy rather than a partial one.
//
// [SchemaNode.Schema] collapses repeats back to references on emit, so
// n.ExpandReferences().Schema() and n.Schema() produce the same schema. It
// spells a collapsed repeat by fullname, so a reference you wrote as an
// in-scope short name comes back qualified.
func (n *SchemaNode) ExpandReferences() *SchemaNode {
if n == nil {
return nil
}
x := &expander{
table: map[string]*SchemaNode{},
cyclic: map[string]bool{},
onPath: map[string]bool{},
done: map[string]bool{},
sizes: map[string]int{},
}
collectNamedTypes(n, x.table)
x.markCycles(n, "", 0)
x.expand = x.sizeOf(n, "", 0) <= maxExpandedNodes
out := x.copy(n, "", 0, true)
return &out
}
// maxExpandedNodes bounds the node count a fully expanded tree may reach. It
// sits well below [maxSchemaJSONNodes] for two reasons. The unit is heavier: a
// SchemaNode is a few hundred bytes of Go struct where a JSON node is one map
// entry. And the ceiling has to leave a result you can actually hold. It is
// still far above any real schema, whose expanded node count is in the
// hundreds.
const maxExpandedNodes = 1 << 18
// expander carries one ExpandReferences run. The three passes are separate
// because each needs the previous one's answer for the whole tree: cycles
// decide which references may be followed, that set decides a name's
// expanded size, and that total decides whether anything expands. Each pass
// is linear in the tree.
type expander struct {
table map[string]*SchemaNode // fullname -> definition
cyclic map[string]bool // names that reach themselves; never expanded
onPath map[string]bool // markCycles: names open on the current path
done map[string]bool // markCycles: names already walked
sizes map[string]int // fullname -> expanded node count, saturating
expand bool // false when the full expansion is over the ceiling
}
// resolve returns the definition n names and the fullname to key it by, or nil
// when n is not a bare reference to anything. n's own tree wins, then the
// [Schema.Root] stamp, the order [SchemaNode.Schema] splices in, so the two
// paths cannot bind one reference to different definitions. A stamped target
// from outside the tree joins the table, so every later pass sees it.
func (x *expander) resolve(n *SchemaNode, ns string) (*SchemaNode, string) {
if !nodeCarriesOnlyType(n) {
return nil, ""
}
t := lookupNameRef(n, x.table, ns)
if t == nil {
if !nodeRefTargetAgrees(n) {
return nil, ""
}
t = n.refTarget
}
fn := nodeFullname(t)
if fn == "" {
return nil, ""
}
if _, have := x.table[fn]; !have {
x.table[fn] = t
}
return t, fn
}
// markCycles fills x.cyclic with a name from every reference cycle, by DFS
// over the name graph: a reference to a name already open on the path is a
// back edge, and its head goes in the set. Cutting the heads cuts every
// cycle, so the follow in copy terminates. The set can be a superset of
// "on a cycle" in exotic graphs, which only leaves a reference unexpanded.
func (x *expander) markCycles(n *SchemaNode, ns string, depth int) {
if n == nil || depth > maxSchemaJSONDepth {
return
}
if t, fn := x.resolve(n, ns); t != nil {
switch {
case x.onPath[fn]:
x.cyclic[fn] = true
case !x.done[fn]:
x.onPath[fn] = true
x.markCycles(t, "", depth+1) // a definition opens its own scope
delete(x.onPath, fn)
x.done[fn] = true
}
return
}
child := nsForChildren(n, ns)
if n.Type == "array" {
x.markCycles(n.Items, child, depth+1)
}
if n.Type == "map" {
x.markCycles(n.Values, child, depth+1)
}
if isRecordKind(n.Type) {
for i := range n.Fields {
x.markCycles(&n.Fields[i].Type, child, depth+1)
}
}
for i := range n.Branches {
x.markCycles(&n.Branches[i], child, depth+1)
}
}
// sizeOf reports how many nodes n expands to, saturating at one past the
// ceiling so a doubling chain cannot overflow the sum. A reference to a cyclic
// name counts as the one node it stays.
func (x *expander) sizeOf(n *SchemaNode, ns string, depth int) int {
if n == nil || depth > maxSchemaJSONDepth {
return 0
}
if t, fn := x.resolve(n, ns); t != nil {
if x.cyclic[fn] {
return 1
}
if v, ok := x.sizes[fn]; ok {
return v
}
// The non-cyclic names form a DAG, so one memo per name is exact and
// the walk stays linear however many references reach it.
v := x.sizeOf(t, "", depth+1)
x.sizes[fn] = v
return v
}
total := 1
child := nsForChildren(n, ns)
add := func(v int) {
total = min(total+v, maxExpandedNodes+1)
}
if n.Type == "array" {
add(x.sizeOf(n.Items, child, depth+1))
}
if n.Type == "map" {
add(x.sizeOf(n.Values, child, depth+1))
}
if isRecordKind(n.Type) {
for i := range n.Fields {
add(x.sizeOf(&n.Fields[i].Type, child, depth+1))
}
}
for i := range n.Branches {
add(x.sizeOf(&n.Branches[i], child, depth+1))
}
return total
}
// copy deep-copies n, replacing it with the definition it names when n is a
// bare reference to a name the two verdicts admit.
//
// We clear follow for a definition-shaped body sitting at a stray key,
// matching stampNameRefs: the parser binds no names there, so nothing in it is
// a reference. We still copy such a body, we just do not read it for names.
func (x *expander) copy(n *SchemaNode, ns string, depth int, follow bool) SchemaNode {
if follow && x.expand && depth <= maxSchemaJSONDepth {
if t, fn := x.resolve(n, ns); t != nil && !x.cyclic[fn] {
return x.copy(t, "", depth, true)
}
}
out := *n // struct copy; carries the hidden Root stamps, as an extraction does
out.Aliases = slices.Clone(n.Aliases)
out.Symbols = slices.Clone(n.Symbols)
out.Props = maps.Clone(n.Props)
// Depth stops the descent, not just the follow: a tree this deep is past
// what [SchemaNode.Schema] accepts anyway, and the alternative is the stack.
if depth > maxSchemaJSONDepth {
return out
}
child := nsForChildren(n, ns)
if n.Items != nil {
items := x.copy(n.Items, child, depth+1, follow && n.Type == "array")
out.Items = &items
}
if n.Values != nil {
values := x.copy(n.Values, child, depth+1, follow && n.Type == "map")
out.Values = &values
}
if len(n.Fields) > 0 {
out.Fields = slices.Clone(n.Fields)
for i := range out.Fields {
out.Fields[i].Aliases = slices.Clone(n.Fields[i].Aliases)
out.Fields[i].Props = maps.Clone(n.Fields[i].Props)
out.Fields[i].Type = x.copy(&n.Fields[i].Type, child, depth+1, follow && isRecordKind(n.Type))
}
}
if len(n.Branches) > 0 {
out.Branches = slices.Clone(n.Branches)
for i := range out.Branches {
// Branches stay followable on every kind: no JSON key routes a
// stray there, so only genuine union parsing populates them.
out.Branches[i] = x.copy(&n.Branches[i], child, depth+1, follow)
}
}
return out
}
// deduper tracks named type definitions during toJSONDedup and records
// conflicting redefinitions. It also detects cycles introduced via
// *SchemaNode Items/Values pointers, the only way a SchemaNode tree can have
// true cycles, since Fields and Branches are value slices.
type deduper struct {
defined map[string]*SchemaNode // fullname -> first definition's node
visited map[*SchemaNode]struct{} // seen *SchemaNode pointers (cycle detection)
err error // first conflict or cycle encountered
// localNames holds the fullname of every named type defined anywhere in
// the tree, collected up front (collectLocalNames). The refTarget splice
// consults it so a reference whose definition is present, before or after
// the reference and forward references included, stays as-written and
// binds locally on re-parse. Only a reference to a name the tree nowhere
// defines splices the stamped target in.
localNames map[string]bool
}
// Root returns a SchemaNode tree describing the parsed schema. We preserve all
// metadata: doc strings, namespaces, custom properties, numeric defaults. See
// [SchemaNode.Props] and [SchemaField.Default] for how values decode.
//
// Reserved Avro attribute names ("type", "name", "namespace", "doc",
// "aliases", ...) match only by their exact lowercase spelling, as in the Avro
// reference implementations. A case variant such as "Aliases" is an ordinary
// custom property: it never binds the attribute, and we report it verbatim in
// [SchemaNode.Props]. Parsing applies the same rule, so a schema whose only
// spelling of a structural key is a case variant ("ITEMS" on an array) fails
// Parse, because the structural attribute is absent.
//
// A field written in the flat goavro-style format (a bare complex type name
// with the kind's defining key, such as "symbols" or "items", alongside the
// field's own keys) appears as it parses: the field's type is the nested
// definition we lifted out (named after the field for record, error, enum,
// and fixed), and the keys we moved into the type appear on the type node
// rather than in [SchemaField.Props]. [SchemaNode.Schema] rebuilds the nested
// form, which parses identically.
//
// Every node converts back to a usable [*Schema] via [SchemaNode.Schema],
// name-reference nodes included: the tree carries the schema's named-type
// definitions, so any subtree you extract is self-contained.
//
// Root re-parses the JSON on each call. Cache the result if you access it
// repeatedly (e.g. in a per-message loop).
func (s *Schema) Root() *SchemaNode {
raw, err := unmarshalAnyPreservePrecision(s.full)
if err != nil {
panic("avro: Schema.Root: invalid stored JSON: " + err.Error())
}
// One shape memo for the whole walk: stray bodies nest, so re-validating
// each subtree once per enclosing level would be O(depth^2). The wire
// node tree rides alongside so each field default is read from the node
// the parse validated it against.
n := nodeFromJSON(raw, "", make(strayShapeMemo), s.node)
// We stamp each name-reference node with its resolved target so an
// extracted subtree converts even when the definition lives outside it.
table := map[string]*SchemaNode{}
collectNamedTypes(&n, table)
stampNameRefs(&n, table, "")
return &n
}
// toJSONDedup renders n with named types deduplicated: the first occurrence of
// a record, enum, or fixed emits the full definition, later ones emit the name
// as a reference.
func (n *SchemaNode) toJSONDedup(d *deduper) any {
b := newWalkBudget()
d.localNames = make(map[string]bool)
collectLocalNames(n, d.localNames, make(map[*SchemaNode]struct{}), 0)
return n.toJSONWalk(d.visited, d, "", 0, &b, false)
}
// jsonSerializableValue returns v with the Avro-JSON shape fixups applied,
// directly or under map[string]any / []any layers; applyJSONFixup documents
// what they are. We deep-copy a container only when a descendant needs
// converting, so the common case allocates nothing and we never mutate your
// SchemaNode storage.
func jsonSerializableValue(v any) any {
if !needsJSONFixup(v) {
return v
}
return applyJSONFixup(v)
}
func isNegativeZero(f float64) bool {
return f == 0 && math.Signbit(f)
}
func needsJSONFixup(v any) bool {
switch tv := v.(type) {
case float64:
return math.IsInf(tv, 0) || math.IsNaN(tv) || isNegativeZero(tv)
case float32:
return math.IsInf(float64(tv), 0) || math.IsNaN(float64(tv)) || isNegativeZero(float64(tv))
case []byte:
return true
case string:
return !utf8.ValidString(tv)
case map[string]any:
for k, val := range tv {
if !utf8.ValidString(k) || needsJSONFixup(val) {
return true
}
}
case []any:
return slices.ContainsFunc(tv, needsJSONFixup)
case nil, bool, json.Number, int, int32, int64:
default:
return needsJSONFixupKind(v)
}
return false
}
// validUTF8 replaces each invalid byte of s with U+FFFD. Every string and
// map key we canonicalize goes through it, so the emitter never sees an
// invalid byte and the two encoding/json implementations, which spell the
// replacement differently (v1 as a six-byte escape, v2 as the three raw
// bytes), have nothing left to disagree on. Parse applies the same
// replacement on the way in, so a Root tree never carries one either.
func validUTF8(s string) string {
return strings.ToValidUTF8(s, string(utf8.RuneError))
}
var jsonMarshalerType = reflect.TypeFor[json.Marshaler]()
// treeValueMarshalOpaque reports whether v defines its own JSON form: a
// MarshalJSON or MarshalText method, or json.Number. The fixups and the
// canonicalizing copies leave such values alone. The assertions use the
// value's own method set, as encoding/json does for an interface-carried
// value.
func treeValueMarshalOpaque(v any) bool {
switch v.(type) {
case json.Number, json.Marshaler, encoding.TextMarshaler:
return true
}
return false
}
// canonicalByteSliceKind reports whether t marshals as a raw byte string: a
// uint8-kind slice whose element supplies no marshal of its own. Mirrors
// encoding/json's byte-slice rule, which consults the element's *pointer*
// method set because slice elements are addressable.
func canonicalByteSliceKind(t reflect.Type) bool {
if t.Kind() != reflect.Slice || t.Elem().Kind() != reflect.Uint8 {
return false
}
p := reflect.PointerTo(t.Elem())
return !p.Implements(jsonMarshalerType) && !p.Implements(textMarshalerType)
}
// sliceElemMarshalPositionDependent reports whether boxing a t-typed
// slice/array element into an interface would change its marshal: a
// pointer-receiver-only marshaler is reachable from an addressable element in
// place but not from an interface-carried copy. Containers of such elements
// stay opaque rather than canonicalizing into a different []any.
func sliceElemMarshalPositionDependent(t reflect.Type) bool {
p := reflect.PointerTo(t)
if !p.Implements(jsonMarshalerType) && !p.Implements(textMarshalerType) {
return false
}
return !t.Implements(jsonMarshalerType) && !t.Implements(textMarshalerType)
}
// canonicalStringKeyMap reports whether t's keys canonicalize to plain strings:
// every string-kind key does, named as mapKeyName names it. A non-string-kind
// key's JSON name comes from its MarshalText or its integer formatting under
// both encoding/json implementations, so those maps stay marshal-opaque
// image-owners and are never rewritten.
func canonicalStringKeyMap(t reflect.Type) bool {
return t.Key().Kind() == reflect.String
}
// errBadMapKey is mapKeyName's verdict for a key kind with no JSON object
// name: float, bool, complex, array, a struct with no text method, or a nil
// interface.
var errBadMapKey = errors.New("avro: SchemaNode default/property value contains a map whose key type has no JSON object-key form (not a string kind, an integer kind, or a usable encoding.TextMarshaler)")
// mapKeyName returns the JSON object name we give map key k. The budget walk
// charges it and both canonicalizing copies emit it, so the name cannot
// depend on which encoding/json implementation the toolchain ships; v1 (Go
// 1.26) and v2 (Go 1.27) disagree on two arms. A key with a MarshalText
// method is named by that text whatever its kind (v2's rule; v1 skipped the
// method for string kinds). A nil pointer key is named "" without calling
// the method. A string kind is its raw string and an integer kind its
// decimal. Everything else has no name: a float has no single text form, so
// we keep v1's refusal where v2 formats one, and a nil interface key errors
// where encoding/json panics. The name comes back as written; the copies
// replace invalid UTF-8 in it.
func mapKeyName(k reflect.Value) (string, error) {
if k.CanInterface() {
if tm, ok := k.Interface().(encoding.TextMarshaler); ok {
if k.Kind() == reflect.Pointer && k.IsNil() {
return "", nil
}
out, err := tm.MarshalText()
if err != nil {
return "", fmt.Errorf("avro: SchemaNode default/property value map key MarshalText: %w", err)
}
return string(out), nil
}
}
switch k.Kind() {
case reflect.String:
return k.String(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(k.Int(), 10), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(k.Uint(), 10), nil
}
return "", errBadMapKey
}
// needsJSONFixupKind extends fixup detection to caller-typed values by
// reflect kind, so a named []byte or named float behaves like its canonical
// twin. Marshal-opaque values are exempt. The value-preserving fixups
// (+/-Inf, -0.0) apply to named float kinds, but the type-changing NaN to
// "NaN" stays canonical-only, so a named float NaN keeps json.Marshal's
// unsupported-value error.
func needsJSONFixupKind(v any) bool {
if treeValueMarshalOpaque(v) {
return false
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Float64, reflect.Float32:
f := rv.Float()
return math.IsInf(f, 0) || isNegativeZero(f)
case reflect.String:
return !utf8.ValidString(rv.String())
case reflect.Slice:
if canonicalByteSliceKind(rv.Type()) {
return true
}
fallthrough
case reflect.Array:
if sliceElemMarshalPositionDependent(rv.Type().Elem()) {
return false
}
for i := range rv.Len() {
if needsJSONFixup(rv.Index(i).Interface()) {
return true
}
}
case reflect.Map:
if !canonicalStringKeyMap(rv.Type()) {
return false
}
// Only the copy honors a MarshalText on the key type, so such a map
// always rebuilds. Left to json.Marshal, the name would depend on
// the toolchain.
if rv.Type().Key().Implements(textMarshalerType) {
return true
}
for it := rv.MapRange(); it.Next(); {
if !utf8.ValidString(it.Key().String()) || needsJSONFixup(it.Value().Interface()) {
return true
}
}
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return false
}
return needsJSONFixup(rv.Elem().Interface())
}
return false
}
// applyJSONFixup converts the four values encoding/json cannot round-trip
// through an Avro schema:
//
// - +/-Inf becomes json.Number("1e1000") or "-1e1000", which re-parses to
// Inf through parseFloatAcceptOverflow; Go's encoder rejects Inf outright.
// - NaN becomes the string "NaN", since JSON has no NaN literal. Re-parse
// restores NaN only for a float/double Default; in Props the string stays
// a string.
// - -0.0 becomes json.Number("-0.0"). Marshal renders "-0", integer syntax
// that re-parses as +0 and flips the rebuilt default's sign.
// - []byte becomes a codepoint-per-byte string. Marshal would base64 it and
// the Avro parser would read the base64 text back as raw bytes.
func applyJSONFixup(v any) any {
switch tv := v.(type) {
case float64:
if math.IsInf(tv, 1) {
return json.Number("1e1000")
}
if math.IsInf(tv, -1) {
return json.Number("-1e1000")
}
if math.IsNaN(tv) {
return "NaN"
}
if isNegativeZero(tv) {
return json.Number("-0.0")
}
return tv
case float32:
if math.IsInf(float64(tv), 1) {
return json.Number("1e1000")
}
if math.IsInf(float64(tv), -1) {
return json.Number("-1e1000")
}
if math.IsNaN(float64(tv)) {
return "NaN"
}
if isNegativeZero(float64(tv)) {
return json.Number("-0.0")
}
return tv
case []byte:
return bytesToAvroJSONString(tv)
case string:
return validUTF8(tv)
case map[string]any:
out := make(map[string]any, len(tv))
for k, val := range tv {
out[validUTF8(k)] = applyJSONFixup(val)
}
return out
case []any:
out := make([]any, len(tv))
for i, val := range tv {
out[i] = applyJSONFixup(val)
}
return out
}
return applyJSONFixupKind(v)
}
// applyJSONFixupKind is needsJSONFixupKind's conversion twin: it rebuilds the
// caller-typed value in canonical shape with the same fixups the exact-type
// arms apply, and leaves marshal-opaque values and the no-canonical-twin
// residuals untouched. A named float NaN falls through un-fixed
// (needsJSONFixupKind never selects it) so the marshal error stays loud.
func applyJSONFixupKind(v any) any {
if v == nil || treeValueMarshalOpaque(v) {
return v
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Float64, reflect.Float32:
f := rv.Float()
switch {
case math.IsInf(f, 1):
return json.Number("1e1000")
case math.IsInf(f, -1):
return json.Number("-1e1000")
case isNegativeZero(f):
return json.Number("-0.0")
}
return v
case reflect.String:
return validUTF8(rv.String())
case reflect.Slice:
if canonicalByteSliceKind(rv.Type()) {
b := make([]byte, rv.Len())
for i := range b {
b[i] = byte(rv.Index(i).Uint())
}
return bytesToAvroJSONString(b)
}
fallthrough
case reflect.Array:
if sliceElemMarshalPositionDependent(rv.Type().Elem()) {
return v
}
out := make([]any, rv.Len())
for i := range out {
out[i] = applyJSONFixup(rv.Index(i).Interface())
}
return out
case reflect.Map:
if !canonicalStringKeyMap(rv.Type()) {
return v
}
out := make(map[string]any, rv.Len())
for it := rv.MapRange(); it.Next(); {
name, err := mapKeyName(it.Key())
if err != nil {
// valueWalkLimit runs first and refuses every key it
// cannot name, so this arm never observes an error.
return v
}
out[validUTF8(name)] = applyJSONFixup(it.Value().Interface())
}
return out
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return nil
}
return applyJSONFixup(rv.Elem().Interface())
}
return v
}
// maxSchemaJSONNodes bounds the count of JSON nodes one SchemaNode-to-JSON
// walk emits; walkBudget explains why counting is necessary. Far above any
// real schema's node count, so a usable tree is never rejected.
const maxSchemaJSONNodes = 1 << 20
// maxSchemaJSONBytes bounds the total size of scalar payload one walk emits;
// see walkBudget. Far above any real schema's serialized size.
const maxSchemaJSONBytes = 1 << 26
// walkBudget is the per-walk resource budget threaded through toJSONWalk and
// valueWalkLimit. Both axes decrement across the whole walk, structural
// nodes and Props values and Defaults alike. Neither is redundant with the
// depth bound: nodes counts emitted JSON nodes, since a shared-reference
// DAG is tiny in memory yet fans out exponentially when serialized; bytes
// counts emitted scalar payload, since K nodes sharing one L-byte string are
// O(K+L) in memory and K*L in the output. An over-budget walk stops with a
// clean error rather than hanging.
type walkBudget struct {
nodes int
bytes int
// keyErr carries the map-key error behind a valueWalkBadMapKey verdict:
// a key kind with no JSON name, a MarshalText that failed, or two keys
// of one map that would share a name.
keyErr error
}
func newWalkBudget() walkBudget {
return walkBudget{nodes: maxSchemaJSONNodes, bytes: maxSchemaJSONBytes}
}
// takeNode charges one emitted node, reporting false when the node budget is
// already exhausted (this path never drives it below zero).
func (b *walkBudget) takeNode() bool {
if b.nodes <= 0 {
return false
}
b.nodes--
return true
}
// takeNodes charges n emitted nodes (a slice's elements). When n exceeds the
// remainder the budget is driven to zero and false is reported, so the caller
// truncates rather than handing json.Marshal a giant array.
func (b *walkBudget) takeNodes(n int) bool {
if n > b.nodes {
b.nodes = 0
return false
}
b.nodes -= n
return true
}
// takeBytes charges n emitted payload bytes. Over-budget drives it negative,
// so toJSONWalk's top-of-call check and valueWalkLimit both observe
// exhaustion, and reports false, keeping the payload away from json.Marshal.
func (b *walkBudget) takeBytes(n int) bool {
if n > b.bytes {
b.bytes = -1
return false
}
b.bytes -= n
return true
}
// emitString charges a structural scalar string's escaped bytes, the same
// charge the value walk makes for a string, and returns it for emission with
// any invalid UTF-8 replaced (validUTF8), or "" (recording the over-budget
// error) when the byte budget is exhausted, so json.Marshal never copies a
// payload past the bound.
func (b *walkBudget) emitString(d *deduper, s string) string {
if b.takeBytes(jsonEscapedLen(s, b.bytes)) {
return validUTF8(s)
}
d.fail(errSchemaTreeBytes())
return ""
}
// emitStrings charges a structural []string payload against both budgets and
// returns it, or an empty slice, recording the over-budget error, when
// either is exhausted. The truncation is deterministic so the dedup conflict
// comparison stays meaningful.
func (b *walkBudget) emitStrings(d *deduper, ss []string) []string {
if !b.takeNodes(len(ss)) {
d.fail(errSchemaTreeNodes())
return []string{}
}
total := 0
for _, s := range ss {
total += jsonEscapedLen(s, b.bytes)
if total > b.bytes {
break
}
}
if !b.takeBytes(total) {
d.fail(errSchemaTreeBytes())
return []string{}
}
// Your slice comes back as is unless an element carries invalid UTF-8,
// in which case we hand back a replaced copy rather than write into it.
for i, s := range ss {
if utf8.ValidString(s) {
continue
}
out := make([]string, len(ss))
copy(out, ss[:i])
for j := i; j < len(ss); j++ {
out[j] = validUTF8(ss[j])
}
return out
}
return ss
}
// fail records err as the deduper's first error. It is a no-op on the bare
// (d == nil) walk, which truncates over-budget output silently, and never
// overwrites an earlier error.
func (d *deduper) fail(err error) {
if d != nil && d.err == nil {
d.err = err
}
}
func errSchemaTreeNodes() error {
return fmt.Errorf("avro: SchemaNode tree expands to more than the supported %d nodes", maxSchemaJSONNodes)
}
func errSchemaTreeBytes() error {
return fmt.Errorf("avro: SchemaNode tree expands to more than the supported %d bytes", maxSchemaJSONBytes)
}
// valueWalkLimit result codes.
const (
valueWalkOK = iota // safe to hand to jsonSerializableValue / json.Marshal
valueWalkTooDeep // nests past the depth budget (stack-overflow risk)
valueWalkTooWide // expands to too many nodes (fan-out / json.Marshal cost)
valueWalkTooLarge // expands to too many payload bytes (json.Marshal output size)
valueWalkBadMapKey // a map key json.Marshal's key resolver cannot name
)
// marshalEmitLen reports how many bytes json.Marshal will emit for a value
// that defines its own JSON form, checking json.Marshaler then
// encoding.TextMarshaler in json.Marshal's order. json.Number is not here:
// the String arm charges it by content. Measuring costs one call to the
// caller's method, charged and dropped. A method returning an error is left
// uncharged, since json.Marshal will return that same error.
func marshalEmitLen(rv reflect.Value, limit int) (int, bool) {
if !rv.IsValid() || !rv.CanInterface() {
return 0, false
}
switch rv.Kind() {
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return 0, false // json.Marshal emits "null" without calling the method
}
}
switch m := rv.Interface().(type) {
case json.Marshaler:
out, err := m.MarshalJSON()
if err != nil {
return 0, false
}
return compactedEmitLen(out, limit), true
case encoding.TextMarshaler:
out, err := m.MarshalText()
if err != nil {
return 0, false
}
// Emitted as a quoted JSON string, through the same escaper.
return jsonEscapedLenBytes(out, limit) + 2, true
}
return 0, false
}
// jsonInvalidUTF8EmitLen is what marshalSchemaTree writes for one invalid