Skip to content

Commit 33aae27

Browse files
committed
Preserve map ordering when transforming
This commit changes both JSONToYAML and YAMLToJSON to preserve the map key ordering when the document is transformed. This is done by reimplementing the library to use YAML v3's yaml.Node API.
1 parent 94af27c commit 33aae27

2 files changed

Lines changed: 163 additions & 58 deletions

File tree

yaml.go

Lines changed: 157 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"io"
1616
"reflect"
1717
"strconv"
18+
"strings"
1819

1920
"gopkg.in/yaml.v3"
2021
)
@@ -77,20 +78,78 @@ func jsonUnmarshal(r io.Reader, o interface{}, opts ...JSONOpt) error {
7778

7879
// JSONToYAML converts JSON to YAML.
7980
func JSONToYAML(j []byte) ([]byte, error) {
80-
// Convert the JSON to an object.
81-
var jsonObj interface{}
81+
var n yaml.Node
8282
// We are using yaml.Unmarshal here (instead of json.Unmarshal) because the
8383
// Go JSON library doesn't try to pick the right number type (int, float,
8484
// etc.) when unmarshalling to interface{}, it just picks float64
8585
// universally. go-yaml does go through the effort of picking the right
8686
// number type, so we can preserve number type throughout this process.
87-
err := yaml.Unmarshal(j, &jsonObj)
87+
err := yaml.Unmarshal(j, &n)
8888
if err != nil {
8989
return nil, err
9090
}
9191

92+
// Force yaml.Node to be marshaled as formatted YAML.
93+
enforceNodeStyle(&n)
94+
9295
// Marshal this object into YAML.
93-
return yaml.Marshal(jsonObj)
96+
return yaml.Marshal(&n)
97+
}
98+
99+
func enforceNodeStyle(n *yaml.Node) {
100+
if n == nil {
101+
return
102+
}
103+
104+
switch n.Kind {
105+
case yaml.SequenceNode, yaml.MappingNode:
106+
n.Style = yaml.LiteralStyle
107+
case yaml.ScalarNode:
108+
// Special case: if node is a string, then there are special styling
109+
// rules that we must abide by to conform to yaml.v3. Some of the logic
110+
// has been copied out, because the other way would've been to re-encode
111+
// the string which causes a ~2x performance hit!
112+
//
113+
// Ideally, we wouldn't need to copy this at all!
114+
// https://github.com/go-yaml/yaml/pull/574 implements a fix for this
115+
// issue that included code for the internal node() marshaling function,
116+
// except https://github.com/go-yaml/yaml/pull/583 was merged instead,
117+
// and it completely left out the fix for this issue!
118+
//
119+
// Instead of trying to make a pull request to a repository which hasn't
120+
// received any commit in over 2 years and has 125+ open pull requests,
121+
// I've decided to just copy the code here.
122+
//
123+
// There is one case that has been omitted from this code, though: the
124+
// code makes no attempt at checking for isBase64Float(). The README of
125+
// the YAML package says that it doesn't support this either (but it is
126+
// in the code).
127+
if n.ShortTag() == "!!str" {
128+
switch {
129+
case strings.Contains(n.Value, "\n"):
130+
n.Style = yaml.LiteralStyle
131+
case isOldBool(n.Value):
132+
n.Style = yaml.DoubleQuotedStyle
133+
default:
134+
n.Style = yaml.FlowStyle
135+
}
136+
}
137+
}
138+
139+
for _, c := range n.Content {
140+
enforceNodeStyle(c)
141+
}
142+
}
143+
144+
// isOldBool is copied from yaml.v3.
145+
func isOldBool(s string) (result bool) {
146+
switch s {
147+
case "y", "Y", "yes", "Yes", "YES", "on", "On", "ON",
148+
"n", "N", "no", "No", "NO", "off", "Off", "OFF":
149+
return true
150+
default:
151+
return false
152+
}
94153
}
95154

96155
// YAMLToJSON converts YAML to JSON. Since JSON is a subset of YAML,
@@ -109,9 +168,8 @@ func YAMLToJSON(y []byte) ([]byte, error) { //nolint:revive
109168
}
110169

111170
func yamlToJSON(dec *yaml.Decoder, jsonTarget *reflect.Value) ([]byte, error) {
112-
// Convert the YAML to an object.
113-
var yamlObj interface{}
114-
if err := dec.Decode(&yamlObj); err != nil {
171+
var n yaml.Node
172+
if err := dec.Decode(&n); err != nil {
115173
// Functionality changed in v3 which means we need to ignore EOF error.
116174
// See https://github.com/go-yaml/yaml/issues/639
117175
if !errors.Is(err, io.EOF) {
@@ -123,7 +181,7 @@ func yamlToJSON(dec *yaml.Decoder, jsonTarget *reflect.Value) ([]byte, error) {
123181
// can have non-string keys in YAML). So, convert the YAML-compatible object
124182
// to a JSON-compatible object, failing with an error if irrecoverable
125183
// incompatibilities happen along the way.
126-
jsonObj, err := convertToJSONableObject(yamlObj, jsonTarget)
184+
jsonObj, err := convertToJSONableObject(&n, jsonTarget)
127185
if err != nil {
128186
return nil, err
129187
}
@@ -132,7 +190,7 @@ func yamlToJSON(dec *yaml.Decoder, jsonTarget *reflect.Value) ([]byte, error) {
132190
return json.Marshal(jsonObj)
133191
}
134192

135-
func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (interface{}, error) { //nolint:gocyclo
193+
func convertToJSONableObject(n *yaml.Node, jsonTarget *reflect.Value) (json.RawMessage, error) { //nolint:gocyclo
136194
var err error
137195

138196
// Resolve jsonTarget to a concrete value (i.e. not a pointer or an
@@ -150,57 +208,52 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
150208
}
151209
}
152210

153-
// go-yaml v3 changed from v2 and now will provide map[string]interface{} by
154-
// default and map[interface{}]interface{} when none of the keys strings.
155-
// To get around this, we run a pre-loop to convert the map.
156-
// JSON only supports strings as keys, so we must convert.
157-
158-
switch typedYAMLObj := yamlObj.(type) {
159-
case map[interface{}]interface{}:
160-
// From my reading of go-yaml v2 (specifically the resolve function),
161-
// keys can only have the types string, int, int64, float64, binary
162-
// (unsupported), or null (unsupported).
163-
strMap := make(map[string]interface{})
164-
for k, v := range typedYAMLObj {
211+
switch n.Kind {
212+
case yaml.DocumentNode:
213+
return convertToJSONableObject(n.Content[0], jsonTarget)
214+
215+
case yaml.MappingNode:
216+
jsonMap := make(orderedMap, 0, len(n.Content)/2)
217+
keyNodes := make(map[string]*yaml.Node, len(n.Content)/2)
218+
for i := 0; i < len(n.Content); i += 2 {
219+
kNode := n.Content[i]
220+
vNode := n.Content[i+1]
221+
222+
var anyKey interface{}
223+
if err := kNode.Decode(&anyKey); err != nil {
224+
return nil, fmt.Errorf("error decoding yaml map key %s: %v", kNode.Tag, err)
225+
}
226+
165227
// Resolve the key to a string first.
166-
var keyString string
167-
switch typedKey := k.(type) {
228+
var key string
229+
switch typedKey := anyKey.(type) {
168230
case string:
169-
keyString = typedKey
231+
key = typedKey
170232
case int:
171-
keyString = strconv.Itoa(typedKey)
233+
key = strconv.Itoa(typedKey)
172234
case int64:
173235
// go-yaml will only return an int64 as a key if the system
174236
// architecture is 32-bit and the key's value is between 32-bit
175237
// and 64-bit. Otherwise the key type will simply be int.
176-
keyString = strconv.FormatInt(typedKey, 10)
238+
key = strconv.FormatInt(typedKey, 10)
177239
case float64:
178240
// Float64 is now supported in keys
179-
keyString = strconv.FormatFloat(typedKey, 'g', -1, 64)
241+
key = strconv.FormatFloat(typedKey, 'g', -1, 64)
180242
case bool:
181243
if typedKey {
182-
keyString = "true"
244+
key = "true"
183245
} else {
184-
keyString = "false"
246+
key = "false"
185247
}
186248
default:
187249
return nil, fmt.Errorf("unsupported map key of type: %s, key: %+#v, value: %+#v",
188-
reflect.TypeOf(k), k, v)
250+
reflect.TypeOf(kNode), kNode, vNode)
189251
}
190-
strMap[keyString] = v
191-
}
192-
// replace yamlObj with our new string map
193-
yamlObj = strMap
194-
}
195252

196-
// If yamlObj is a number or a boolean, check if jsonTarget is a string -
197-
// if so, coerce. Else return normal.
198-
// If yamlObj is a map or array, find the field that each key is
199-
// unmarshaling to, and when you recurse pass the reflect.Value for that
200-
// field back into this function.
201-
switch typedYAMLObj := yamlObj.(type) {
202-
case map[string]interface{}:
203-
for k, v := range typedYAMLObj {
253+
if otherNode, ok := keyNodes[key]; ok {
254+
return nil, fmt.Errorf("mapping key %q already defined at line %d", key, otherNode.Line)
255+
}
256+
keyNodes[key] = kNode
204257

205258
// jsonTarget should be a struct or a map. If it's a struct, find
206259
// the field it's going to map to and pass its reflect.Value. If
@@ -210,7 +263,7 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
210263
if jsonTarget != nil {
211264
t := *jsonTarget
212265
if t.Kind() == reflect.Struct {
213-
keyBytes := []byte(k)
266+
keyBytes := []byte(key)
214267
// Find the field that the JSON library would use.
215268
var f *field
216269
fields := cachedTypeFields(t.Type())
@@ -229,8 +282,7 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
229282
// Find the reflect.Value of the most preferential
230283
// struct field.
231284
jtf := t.Field(f.index[0])
232-
typedYAMLObj[k], err = convertToJSONableObject(v, &jtf)
233-
if err != nil {
285+
if err := jsonMap.AppendYAML(f.name, vNode, &jtf); err != nil {
234286
return nil, err
235287
}
236288
continue
@@ -239,20 +291,21 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
239291
// Create a zero value of the map's element type to use as
240292
// the JSON target.
241293
jtv := reflect.Zero(t.Type().Elem())
242-
typedYAMLObj[k], err = convertToJSONableObject(v, &jtv)
243-
if err != nil {
294+
if err := jsonMap.AppendYAML(key, vNode, &jtv); err != nil {
244295
return nil, err
245296
}
246297
continue
247298
}
248299
}
249-
typedYAMLObj[k], err = convertToJSONableObject(v, nil)
250-
if err != nil {
300+
301+
if err := jsonMap.AppendYAML(key, vNode, nil); err != nil {
251302
return nil, err
252303
}
253304
}
254-
return typedYAMLObj, nil
255-
case []interface{}:
305+
306+
return jsonMap.MarshalJSON()
307+
308+
case yaml.SequenceNode:
256309
// We need to recurse into arrays in case there are any
257310
// map[interface{}]interface{}'s inside and to convert any
258311
// numbers to strings.
@@ -272,22 +325,28 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
272325
}
273326

274327
// Make and use a new array.
275-
arr := make([]interface{}, len(typedYAMLObj))
276-
for i, v := range typedYAMLObj {
328+
arr := make([]json.RawMessage, len(n.Content))
329+
for i, v := range n.Content {
277330
arr[i], err = convertToJSONableObject(v, jsonSliceElemValue)
278331
if err != nil {
279332
return nil, err
280333
}
281334
}
282-
return arr, nil
335+
return json.Marshal(arr)
336+
283337
default:
338+
var rawObject interface{}
339+
if err := n.Decode(&rawObject); err != nil {
340+
return nil, fmt.Errorf("error decoding yaml object %s: %v", n.Tag, err)
341+
}
342+
284343
// If the target type is a string and the YAML type is a number,
285344
// convert the YAML type to a string.
286345
if jsonTarget != nil && (*jsonTarget).Kind() == reflect.String {
287346
// Based on my reading of go-yaml, it may return int, int64,
288347
// float64, or uint64.
289348
var s string
290-
switch typedVal := typedYAMLObj.(type) {
349+
switch typedVal := rawObject.(type) {
291350
case int:
292351
s = strconv.FormatInt(int64(typedVal), 10)
293352
case int64:
@@ -304,9 +363,49 @@ func convertToJSONableObject(yamlObj interface{}, jsonTarget *reflect.Value) (in
304363
}
305364
}
306365
if len(s) > 0 {
307-
yamlObj = interface{}(s)
366+
rawObject = s
308367
}
309368
}
310-
return yamlObj, nil
369+
370+
return json.Marshal(rawObject)
371+
}
372+
}
373+
374+
type orderedMap []orderedPair
375+
376+
type orderedPair struct {
377+
K string
378+
V interface{}
379+
}
380+
381+
func (m *orderedMap) AppendYAML(k string, v *yaml.Node, jsonTarget *reflect.Value) error {
382+
r, err := convertToJSONableObject(v, jsonTarget)
383+
if err != nil {
384+
return fmt.Errorf("%q: %w", k, err)
385+
}
386+
*m = append(*m, orderedPair{K: k, V: r})
387+
return nil
388+
}
389+
390+
func (m orderedMap) MarshalJSON() ([]byte, error) {
391+
var buf bytes.Buffer
392+
buf.WriteByte('{')
393+
for i, p := range m {
394+
if i > 0 {
395+
buf.WriteByte(',')
396+
}
397+
k, err := json.Marshal(p.K)
398+
if err != nil {
399+
return nil, fmt.Errorf("key %q error: %w", p.K, err)
400+
}
401+
buf.Write(k)
402+
buf.WriteByte(':')
403+
b, err := json.Marshal(p.V)
404+
if err != nil {
405+
return nil, fmt.Errorf("value %q error: %w", p.K, err)
406+
}
407+
buf.Write(b)
311408
}
409+
buf.WriteByte('}')
410+
return buf.Bytes(), nil
312411
}

yaml_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ func TestYAMLToJSON(t *testing.T) {
323323
"- t: null\n",
324324
`[{"t":null}]`,
325325
nil,
326+
}, {
327+
"obj:\n" +
328+
" z_hello: hello\n" +
329+
" a_world: world\n",
330+
`{"obj":{"z_hello":"hello","a_world":"world"}}`,
331+
nil,
326332
},
327333
}
328334

0 commit comments

Comments
 (0)