Skip to content

Commit

Permalink
Add really basic "OrderedMap" implementation for round-tripping JSON …
Browse files Browse the repository at this point in the history
…maps in a defined order
  • Loading branch information
tianon committed Dec 8, 2023
1 parent d307f68 commit 281698f
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 10 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./.go-env.sh go test -v -cover ./om
- name: Install Bashbrew
run: |
# not doing "uses: docker-library/bashbrew@xxx" because it'll build which is slow and we don't need more than just bashbrew here
Expand Down
19 changes: 10 additions & 9 deletions builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"sync"

"github.com/docker-library/meta-scripts/om"

c8derrdefs "github.com/containerd/containerd/errdefs"
"github.com/docker-library/bashbrew/registry"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
Expand All @@ -23,10 +25,10 @@ type MetaSource struct {
SourceID string `json:"sourceId"`
AllTags []string `json:"allTags"`
Arches map[string]struct {
Parents map[string]struct {
Parents om.OrderedMap[struct {
SourceID *string `json:"sourceId"`
Pin *string `json:"pin"`
}
}]
}
}

Expand All @@ -43,7 +45,7 @@ type RemoteResolvedFull struct {
type BuildIDParts struct {
SourceID string `json:"sourceId"`
Arch string `json:"arch"`
Parents map[string]string `json:"parents"`
Parents om.OrderedMap[string] `json:"parents"`
}

type MetaBuild struct {
Expand All @@ -52,7 +54,7 @@ type MetaBuild struct {
Img string `json:"img"`
Resolved *RemoteResolvedFull `json:"resolved"`
BuildIDParts
ResolvedParents map[string]RemoteResolvedFull `json:"resolvedParents"`
ResolvedParents om.OrderedMap[RemoteResolvedFull] `json:"resolvedParents"`
} `json:"build"`
Source json.RawMessage `json:"source"`
}
Expand Down Expand Up @@ -162,8 +164,6 @@ func main() {
decoder := json.NewDecoder(stdout)
for decoder.More() {
var build MetaBuild
build.Build.Parents = map[string]string{} // thanks Go (nil slice becomes null)
build.Build.ResolvedParents = map[string]RemoteResolvedFull{} // thanks Go (nil slice becomes null)

if err := decoder.Decode(&build.Source); err == io.EOF {
break
Expand All @@ -190,7 +190,8 @@ func main() {
outs <- outChan

sourceArchResolvedFunc := sync.OnceValue(func() *RemoteResolvedFull {
for from, parent := range source.Arches[build.Build.Arch].Parents {
for _, from := range source.Arches[build.Build.Arch].Parents.Keys() {
parent := source.Arches[build.Build.Arch].Parents.Get(from)
if from == "scratch" {
continue
}
Expand Down Expand Up @@ -219,8 +220,8 @@ func main() {
close(outChan)
return nil
}
build.Build.ResolvedParents[from] = *resolved
build.Build.Parents[from] = string(resolved.Manifest.Desc.Digest)
build.Build.ResolvedParents.Set(from, *resolved)
build.Build.Parents.Set(from, string(resolved.Manifest.Desc.Digest))
}

// buildId calculation
Expand Down
2 changes: 1 addition & 1 deletion builds.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export BASHBREW_STAGING_TEMPLATE

dir="$(dirname "$BASH_SOURCE")"
dir="$(readlink -ve "$dir")"
if [ "$dir/builds.go" -nt "$dir/builds" ] || [ "$dir/.go-env.sh" -nt "$dir/builds" ]; then
if [ "$dir/builds.go" -nt "$dir/builds" ] || [ "$dir/om/om.go" -nt "$dir/builds" ] || [ "$dir/.go-env.sh" -nt "$dir/builds" ]; then
{
echo "building '$dir/builds' from 'builds.go'"
"$dir/.go-env.sh" go build -v -o builds builds.go
Expand Down
97 changes: 97 additions & 0 deletions om/om.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package om

// https://github.com/golang/go/issues/27179

import (
"bytes"
"encoding/json"
"fmt"
)

// only supports string keys because JSON is the intended use case (and the JSON spec says only string keys are allowed)
type OrderedMap[T any] struct {
m map[string]T
keys []string
}

func (m OrderedMap[T]) Keys() []string {
return append([]string{}, m.keys...)
}

func (m OrderedMap[T]) Get(key string) T {
return m.m[key]
}

// TODO Has()? two-return form of Get? (we don't need either right now)

func (m *OrderedMap[T]) Set(key string, val T) { // TODO make this variadic so it can take an arbitrary number of pairs? (would be useful for tests, but we don't need something like that right now)
if m.m == nil || m.keys == nil {
m.m = map[string]T{}
m.keys = []string{}
}
if _, ok := m.m[key]; !ok {
m.keys = append(m.keys, key)
}
m.m[key] = val
}

func (m *OrderedMap[T]) UnmarshalJSON(b []byte) error {
dec := json.NewDecoder(bytes.NewReader(b))

// read opening {
if tok, err := dec.Token(); err != nil {
return err
} else if tok != json.Delim('{') {
return fmt.Errorf("expected '{', got %T: %#v", tok, tok)
}

for {
tok, err := dec.Token()
if err != nil {
return err
}
if tok == json.Delim('}') {
break
}
key, ok := tok.(string)
if !ok {
return fmt.Errorf("expected string key, got %T: %#v", tok, tok)
}
var val T
err = dec.Decode(&val)
if err != nil {
return err
}
m.Set(key, val)
}

if dec.More() {
return fmt.Errorf("unexpected extra content after closing '}'")
}

return nil
}

func (m OrderedMap[T]) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
if err := buf.WriteByte('{'); err != nil {
return nil, err
}
for i, key := range m.keys {
if i > 0 {
buf.WriteByte(',')
}
if err := enc.Encode(key); err != nil {
return nil, err
}
buf.WriteByte(':')
if err := enc.Encode(m.m[key]); err != nil {
return nil, err
}
}
if err := buf.WriteByte('}'); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
64 changes: 64 additions & 0 deletions om/om_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package om_test

import (
"encoding/json"
"testing"

"github.com/docker-library/meta-scripts/om"
)

func assert[V comparable](t *testing.T, v V, expected V) {
t.Helper()
if v != expected {
t.Fatalf("expected %v, got %v", expected, v)
}
}

func assertJSON[V any](t *testing.T, v V, expected string) {
t.Helper()
b, err := json.Marshal(v)
assert(t, err, nil)
assert(t, string(b), expected)
}

func TestOrderedMapSet(t *testing.T) {
var m om.OrderedMap[string]
assertJSON(t, m, `{}`)
m.Set("c", "a")
assert(t, m.Get("c"), "a")
assert(t, m.Get("b"), "")
assertJSON(t, m, `{"c":"a"}`)
m.Set("b", "b")
assertJSON(t, m, `{"c":"a","b":"b"}`)
m.Set("a", "c")
assertJSON(t, m, `{"c":"a","b":"b","a":"c"}`)
m.Set("c", "d")
assert(t, m.Get("c"), "d")
assertJSON(t, m, `{"c":"d","b":"b","a":"c"}`)
keys := m.Keys()
assert(t, len(keys), 3)
assert(t, keys[0], "c")
assert(t, keys[1], "b")
assert(t, keys[2], "a")
keys[0] = "d" // make sure the result of .Keys cannot modify the original
keys = m.Keys()
assert(t, keys[0], "c")
}

func TestOrderedMapUnmarshal(t *testing.T) {
var m om.OrderedMap[string]
assert(t, json.Unmarshal([]byte(`{}`), &m), nil)
assertJSON(t, m, `{}`)
assert(t, json.Unmarshal([]byte(`{ "foo" : "bar" }`), &m), nil)
assertJSON(t, m, `{"foo":"bar"}`)
assert(t, json.Unmarshal([]byte(`{ "baz" : "buzz" }`), &m), nil)
assertJSON(t, m, `{"foo":"bar","baz":"buzz"}`)
assert(t, json.Unmarshal([]byte(`{ "foo" : "foo" }`), &m), nil)
assertJSON(t, m, `{"foo":"foo","baz":"buzz"}`)
}

func TestOrderedMapUnmarshalDupes(t *testing.T) {
var m om.OrderedMap[string]
assert(t, json.Unmarshal([]byte(`{ "foo":"foo", "bar":"bar", "foo":"baz" }`), &m), nil)
assertJSON(t, m, `{"foo":"baz","bar":"bar"}`)
}

0 comments on commit 281698f

Please sign in to comment.