11package codec
22
33import (
4+ "bytes"
45 "fmt"
6+ "unsafe"
57
68 "github.com/mendixlabs/mxcli/modelsdk/element"
79 "github.com/mendixlabs/mxcli/modelsdk/mpr"
810 "go.mongodb.org/mongo-driver/bson"
11+ "go.mongodb.org/mongo-driver/x/bsonx/bsoncore"
912)
1013
1114// Encoder serializes Element trees back to BSON bytes.
@@ -29,9 +32,32 @@ func (e *Encoder) Encode(elem element.Element) ([]byte, error) {
2932
3033// rebuildEntry records which properties of an element need re-encoding.
3134type rebuildEntry struct {
32- wp element.WritableProperty
33- cp element.ChildProperty
34- clp element.ChildListProperty
35+ name string // property key; kept as string for stable ordering
36+ wp element.WritableProperty
37+ cp element.ChildProperty
38+ clp element.ChildListProperty
39+ }
40+
41+ // bytesOf returns a []byte view of s without copying.
42+ // Safe only for read-only use within the lifetime of s.
43+ func bytesOf (s string ) []byte {
44+ if s == "" {
45+ return nil
46+ }
47+ return unsafe .Slice (unsafe .StringData (s ), len (s ))
48+ }
49+
50+ // rawKeyOf returns the key bytes of a BSON element without allocating.
51+ // bsoncore.Element is a []byte; KeyBytes() slices into it.
52+ func rawKeyOf (re bsoncore.Element ) []byte { return re .KeyBytes () }
53+
54+ // stringOf converts a []byte to string without copying.
55+ // Safe only while the underlying []byte is alive.
56+ func stringOf (b []byte ) string {
57+ if len (b ) == 0 {
58+ return ""
59+ }
60+ return unsafe .String (unsafe .SliceData (b ), len (b ))
3561}
3662
3763// buildDoc constructs a bson.D for a dirty element.
@@ -43,9 +69,20 @@ type rebuildEntry struct {
4369// bson.RawValue (zero-alloc). Only fields that are actually dirty are decoded
4470// and re-encoded.
4571func (e * Encoder ) buildDoc (elem element.Element ) (bson.D , error ) {
46- // Build an index of properties that need rebuilding: dirty scalars,
47- // dirty children, or child lists with at least one dirty member.
48- rebuild := make (map [string ]* rebuildEntry , len (elem .Properties ()))
72+ // Determine cheaply whether any child element is dirty.
73+ // element.Base propagates child-dirty state up the container chain, so
74+ // a single IsChildDirty() call on the element avoids O(N) PartList scans
75+ // when no children are dirty (the common case for scalar-only edits).
76+ type childDirtyChecker interface { IsChildDirty () bool }
77+ childMightBeDirty := true
78+ if cd , ok := elem .(childDirtyChecker ); ok {
79+ childMightBeDirty = cd .IsChildDirty ()
80+ }
81+
82+ // Build a compact slice of properties that need rebuilding. Using a slice
83+ // (not a map) lets us do zero-alloc byte comparison with re.KeyBytes()
84+ // in the main loop below, avoiding the 21 string allocations from re.Key().
85+ var rebuild []rebuildEntry // typically 1–5 entries; stays on stack for small N
4986 for _ , prop := range elem .Properties () {
5087 wp , ok := prop .(element.WritableProperty )
5188 if ! ok {
@@ -55,7 +92,8 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
5592 clp , _ := prop .(element.ChildListProperty )
5693
5794 needsRebuild := wp .Dirty ()
58- if ! needsRebuild {
95+ if ! needsRebuild && childMightBeDirty {
96+ // Only pay the O(N) scan cost when the parent reports a dirty child.
5997 if cp != nil {
6098 ch := cp .ChildElement ()
6199 needsRebuild = ch != nil && ch .IsDirty ()
@@ -64,28 +102,40 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
64102 }
65103 }
66104 if needsRebuild {
67- rebuild [ prop .Name ()] = & rebuildEntry { wp , cp , clp }
105+ rebuild = append ( rebuild , rebuildEntry { prop .Name (), wp , cp , clp })
68106 }
69107 }
70108
109+ // findRebuild returns the index into rebuild for a BSON key, or -1.
110+ // Uses zero-alloc byte comparison instead of map[string] lookup.
111+ // O(M) where M = len(rebuild) ≤ dirty properties (1–5 typical).
112+ findRebuild := func (keyB []byte ) int {
113+ for i := range rebuild {
114+ if bytes .Equal (keyB , bytesOf (rebuild [i ].name )) {
115+ return i
116+ }
117+ }
118+ return - 1
119+ }
120+ // seen tracks which rebuild entries were found in the raw bytes.
121+ seen := make ([]bool , len (rebuild ))
122+
71123 raw := elem .Raw ()
72124
73125 // === New element (no raw bytes) ===
74- // Iterate elem.Properties() — not the rebuild map — to preserve stable
75- // field ordering. Go map iteration is non-deterministic; using the map
76- // as the source of ordering would produce different BSON byte sequences
77- // across runs (TestSerializeWorkflowActivityGen_RoundTripIsStable).
126+ // Iterate elem.Properties() (a slice) for stable field ordering — not the
127+ // rebuild slice, whose order varies with property registration.
78128 if raw == nil {
79129 doc := bson.D {
80130 {Key : "$ID" , Value : idToBinarySubtype0 (elem .ID ())},
81131 {Key : "$Type" , Value : elem .TypeName ()},
82132 }
83133 for _ , prop := range elem .Properties () {
84- rb , needsRebuild := rebuild [ prop .Name ()]
85- if ! needsRebuild {
134+ idx := findRebuild ( bytesOf ( prop .Name ()))
135+ if idx < 0 {
86136 continue
87137 }
88- val , err := e .encodeEntry (rb )
138+ val , err := e .encodeEntry (rebuild [ idx ] )
89139 if err != nil {
90140 return nil , err
91141 }
@@ -97,40 +147,48 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
97147 }
98148
99149 // === Existing element — iterate raw bytes, pass clean fields through ===
100- rawElems , err := bson .Raw (raw ).Elements ()
150+ // Uses bsoncore.Document.Elements() for access to KeyBytes() (zero-alloc
151+ // key reads). bsoncore.Value must be converted to bson.RawValue before
152+ // storing in bson.E, because bson.Marshal only has a codec for bson.RawValue.
153+ rawElems , err := bsoncore .Document (raw ).Elements ()
101154 if err != nil {
102155 return nil , fmt .Errorf ("read raw elements for %s: %w" , elem .TypeName (), err )
103156 }
104157
105158 doc := make (bson.D , 0 , len (rawElems ))
106159 for _ , re := range rawElems {
107- key := re .Key ()
108- rb , dirty := rebuild [key ]
109- if ! dirty {
110- // Clean field: pass through raw bytes without allocating any Go objects.
111- doc = append (doc , bson.E {Key : key , Value : re .Value ()})
160+ keyB := rawKeyOf (re )
161+ idx := findRebuild (keyB )
162+ if idx < 0 {
163+ // Clean field: zero-alloc key + raw value passthrough.
164+ // Convert bsoncore.Value → bson.RawValue so bson.Marshal uses
165+ // the registered RawValueEncodeValue codec (verbatim byte copy).
166+ cv := re .Value ()
167+ doc = append (doc , bson.E {
168+ Key : stringOf (keyB ),
169+ Value : bson.RawValue {Type : cv .Type , Value : cv .Data },
170+ })
112171 continue
113172 }
114173 // Dirty field: encode new value.
115- val , err := e .encodeEntry (rb )
174+ val , err := e .encodeEntry (rebuild [ idx ] )
116175 if err != nil {
117176 return nil , err
118177 }
119178 if val != nil {
120- doc = append (doc , bson.E {Key : key , Value : val })
179+ doc = append (doc , bson.E {Key : rebuild [ idx ]. name , Value : val })
121180 }
122- // Mark handled so we don't append it again below.
123- delete (rebuild , key )
181+ seen [idx ] = true
124182 }
125183
126184 // Append dirty fields that didn't exist in the raw bytes (new properties).
127- // Iterate Properties() for stable ordering — not the rebuild map .
185+ // Iterate Properties() for stable ordering.
128186 for _ , prop := range elem .Properties () {
129- rb , ok := rebuild [ prop .Name ()]
130- if ! ok {
187+ idx := findRebuild ( bytesOf ( prop .Name ()))
188+ if idx < 0 || seen [ idx ] {
131189 continue
132190 }
133- val , err := e .encodeEntry (rb )
191+ val , err := e .encodeEntry (rebuild [ idx ] )
134192 if err != nil {
135193 return nil , err
136194 }
@@ -143,7 +201,7 @@ func (e *Encoder) buildDoc(elem element.Element) (bson.D, error) {
143201}
144202
145203// encodeEntry produces the BSON value for a single dirty property.
146- func (e * Encoder ) encodeEntry (rb * rebuildEntry ) (any , error ) {
204+ func (e * Encoder ) encodeEntry (rb rebuildEntry ) (any , error ) {
147205 wp := rb .wp
148206
149207 // Child (Part) property.
0 commit comments