From 4a8b7e5979fb48b74508d75cc530f577698302bc Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Sun, 2 Aug 2026 22:54:28 +0300 Subject: [PATCH] Expose AllowDuplicateKeys on DecodeOpts Passes through to the yaml3 decoder option of the same name. A repeated mapping key is an error by default, which is YAML's rule; enabling this makes the last occurrence win, which is what encoding/json does with a repeated JSON object name. It matters for a caller that decodes both YAML and JSON into the same types: without it, a document loads or fails depending only on which format it happens to be written in, since this package's yaml path rejects repeated keys while a direct json.Unmarshal accepts them. Tests assert the surviving value rather than only the absence of an error, and cover composition with origin tracking, since the option has to survive the JSON round trip this package performs and must not disable origin extraction. yaml3 is pinned to the commit carrying the option; repoint at the tag once it is cut. --- dupkeys_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- yaml.go | 8 +++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 dupkeys_test.go diff --git a/dupkeys_test.go b/dupkeys_test.go new file mode 100644 index 0000000..14f9eb1 --- /dev/null +++ b/dupkeys_test.go @@ -0,0 +1,56 @@ +package yaml_test + +import ( + "testing" + + "github.com/oasdiff/yaml" +) + +type dupSpec struct { + Info struct { + Title string `json:"title"` + Version string `json:"version"` + } `json:"info"` +} + +const dupSrc = "info:\n title: a\n title: b\n version: \"1\"\n" + +func TestDecodeOpts_DuplicateKeys(t *testing.T) { + var strict dupSpec + if _, err := yaml.Unmarshal([]byte(dupSrc), &strict, yaml.DecodeOpts{}); err == nil { + t.Fatal("default should reject a repeated key") + } + + var lenient dupSpec + if _, err := yaml.Unmarshal([]byte(dupSrc), &lenient, + yaml.DecodeOpts{AllowDuplicateKeys: true}); err != nil { + t.Fatalf("AllowDuplicateKeys should accept: %v", err) + } + // The last occurrence wins, as encoding/json does. Asserting the value, + // not just the absence of an error. + if lenient.Info.Title != "b" { + t.Fatalf("title = %q, want %q", lenient.Info.Title, "b") + } + if lenient.Info.Version != "1" { + t.Fatalf("sibling key clobbered: version = %q", lenient.Info.Version) + } +} + +// The option must survive the JSON round trip this package performs, and must +// compose with origin tracking rather than disabling it. +func TestDecodeOpts_DuplicateKeysWithOrigin(t *testing.T) { + var v dupSpec + tree, err := yaml.Unmarshal([]byte(dupSrc), &v, yaml.DecodeOpts{ + AllowDuplicateKeys: true, + Origin: yaml.OriginOpt{Enabled: true, File: "spec.yaml"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Info.Title != "b" { + t.Fatalf("title = %q, want %q", v.Info.Title, "b") + } + if tree == nil { + t.Fatal("origin tree should still be produced") + } +} diff --git a/go.mod b/go.mod index b094f80..8eb2f7b 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/oasdiff/yaml go 1.25 -require github.com/oasdiff/yaml3 v0.0.14 +require github.com/oasdiff/yaml3 v0.0.15-0.20260802195239-79d189934084 diff --git a/go.sum b/go.sum index 9e6d2c6..c5a4812 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= -github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/oasdiff/yaml3 v0.0.15-0.20260802195239-79d189934084 h1:0FFmaKhYkUtCiwh81RlE2WAmd8ga69YnnTOzJXHIKTE= +github.com/oasdiff/yaml3 v0.0.15-0.20260802195239-79d189934084/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/yaml.go b/yaml.go index 3ec5366..a767c8f 100644 --- a/yaml.go +++ b/yaml.go @@ -59,6 +59,13 @@ type DecodeOpts struct { // real-world specs that use date-shaped strings as keys. Explicit // "!!timestamp" tags in the source still resolve to time.Time. DisableTimestamps bool + // AllowDuplicateKeys makes a repeated mapping key take its last value + // instead of being an error. That is what encoding/json does with a + // repeated JSON object name, so it is the setting to use when the same + // types are decoded from both YAML and JSON and a document should not + // load or fail purely on account of its format. Default false: repeated + // keys are an error, which is YAML's rule. + AllowDuplicateKeys bool } // OriginTree holds __origin__ data extracted from a YAML-decoded map tree. @@ -87,6 +94,7 @@ type OriginTree struct { func Unmarshal(y []byte, o interface{}, decode DecodeOpts, opts ...JSONOpt) (*OriginTree, error) { dec := yaml.NewDecoder(bytes.NewReader(y)) dec.Origin(decode.Origin.Enabled, decode.Origin.File) + dec.AllowDuplicateKeys(decode.AllowDuplicateKeys) if decode.DisableTimestamps { dec.DisableTimestamps(true) }