From 70d92894f129e6a9b4796942be4e6d9fac632dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Parada?= Date: Sat, 10 Feb 2024 16:50:47 -0300 Subject: [PATCH] initial implementation --- LICENSE | 15 +++++ README.md | 39 +++++++++++++ go.mod | 13 +++++ go.sum | 10 ++++ json.go | 131 ++++++++++++++++++++++++++++++++++++++++++++ json_test.go | 36 ++++++++++++ ordered_map.go | 92 +++++++++++++++++++++++++++++++ ordered_map_test.go | 100 +++++++++++++++++++++++++++++++++ yaml.go | 77 ++++++++++++++++++++++++++ yaml_test.go | 35 ++++++++++++ 10 files changed, 548 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 json.go create mode 100644 json_test.go create mode 100644 ordered_map.go create mode 100644 ordered_map_test.go create mode 100644 yaml.go create mode 100644 yaml_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af3b701 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2024 Nicolás Parada + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..82b5ff3 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +[![Go Reference](https://pkg.go.dev/badge/github.com/nicolasparada/go-ordered-map.svg)](https://pkg.go.dev/github.com/nicolasparada/go-ordered-map) + +# Golang Ordered Map + +Golang Ordered Map is a `map` data structure that maintains the order of the keys. +It also supports JSON and YAML marshalling. + +## Installation + +```bash +go get github.com/nicolasparada/go-ordered-map +``` + +## Usage + +```go +package main + +import ( + orderedmap "github.com/nicolasparada/go-ordered-map" +) + +func main() { + data := []byte(`{ "name": "John", "age": 30, "active": true }`) + + var unordered map[string]any{} + if err := json.Unmarshal(data, &unordered); err != nil { + panic(err) + } + + var ordered orderedmap.OrderedMap[string, any] + if err := json.Unmarshal(data, &ordered); err != nil { + panic(err) + } + + json.NewEncoder(os.Stdout).Encode(unordered) // will print in undefined order + json.NewEncoder(os.Stdout).Encode(ordered) // will always print: {"name":"John","age":30,"active":true} +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2abde35 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/nicolasparada/go-ordered-map + +go 1.22.0 + +require ( + github.com/alecthomas/assert/v2 v2.5.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/alecthomas/repr v0.3.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32fcaee --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/alecthomas/assert/v2 v2.5.0 h1:OJKYg53BQx06/bMRBSPDCO49CbCDNiUQXwdoNrt6x5w= +github.com/alecthomas/assert/v2 v2.5.0/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM= +github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8= +github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/json.go b/json.go new file mode 100644 index 0000000..19faeb3 --- /dev/null +++ b/json.go @@ -0,0 +1,131 @@ +package orderedmap + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +// MarshalJSON implements json.Marshaler interface +// to marshall a sorted list of key-value pairs into an object. +func (o OrderedMap[K, V]) MarshalJSON() ([]byte, error) { + if o == nil { + return []byte(`null`), nil + } + + if len(o) == 0 { + return []byte(`{}`), nil + } + + var buf bytes.Buffer + if err := buf.WriteByte('{'); err != nil { + return nil, err + } + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + for i, p := range o { + if i != 0 { + if err := buf.WriteByte(','); err != nil { + return nil, err + } + } + + if err := enc.Encode(p.Key); err != nil { + return nil, err + } + + if err := buf.WriteByte(':'); err != nil { + return nil, err + } + + if err := enc.Encode(p.Val); err != nil { + return nil, err + } + } + + if err := buf.WriteByte('}'); err != nil { + return nil, err + } + + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + +// UnmarshalJSON implements json.Unmarshaler interface +// to unmarshal an object into a sorted list of key-value pairs. +func (o *OrderedMap[K, V]) UnmarshalJSON(data []byte) error { + var m map[K]V + err := json.Unmarshal(data, &m) + if err != nil { + return err + } + + dec := json.NewDecoder(bytes.NewReader(data)) + t, err := dec.Token() + if err != nil { + return err + } + + if t != json.Delim('{') { + return errors.New("expected start of object") + } + + for { + t, err := dec.Token() + if err != nil { + return err + } + + if t == json.Delim('}') { + break + } + + key, ok := t.(K) + if !ok { + return fmt.Errorf("expected object key to be %T, got %T", key, t) + } + + *o = append(*o, Pair[K, V]{ + Key: key, + Val: m[key], + }) + + // ignored value + if err := skipJSONValue(dec); err != nil { + if errors.Is(err, io.EOF) { + break + } + + return err + } + } + + return nil +} + +var errJSONEnd = errors.New("invalid end of json array or object") + +func skipJSONValue(dec *json.Decoder) error { + t, err := dec.Token() + if err != nil { + return err + } + + switch t { + case json.Delim('['), json.Delim('{'): + for { + if err := skipJSONValue(dec); err != nil { + if errors.Is(err, errJSONEnd) { + break + } + return err + } + } + case json.Delim(']'), json.Delim('}'): + return errJSONEnd + } + + return nil +} diff --git a/json_test.go b/json_test.go new file mode 100644 index 0000000..3248192 --- /dev/null +++ b/json_test.go @@ -0,0 +1,36 @@ +package orderedmap + +import ( + "encoding/json" + "testing" + "time" + + "github.com/alecthomas/assert/v2" +) + +func TestOrderedMap_MarshalJSON(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + got, err := json.Marshal(OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + {"active", true}, + {"last_access_time", now}, + }) + assert.NoError(t, err) + assert.Equal(t, `{"name":"John","age":30,"active":true,"last_access_time":"`+now.Format(time.RFC3339Nano)+`"}`, string(got)) +} + +func TestOrderedMap_UnmarshalJSON(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + nowStr := now.Format(time.RFC3339Nano) + var got OrderedMap[string, any] + err := json.Unmarshal([]byte(`{"name":"John","age":30,"active":true,"last_access_time":"`+nowStr+`"}`), &got) + assert.NoError(t, err) + + assert.Equal(t, OrderedMap[string, any]{ + {"name", "John"}, + {"age", float64(30)}, // json always unmarshals numbers as float64 + {"active", true}, + {"last_access_time", nowStr}, // json doesn't detect time.Time + }, got) +} diff --git a/ordered_map.go b/ordered_map.go new file mode 100644 index 0000000..07bb676 --- /dev/null +++ b/ordered_map.go @@ -0,0 +1,92 @@ +package orderedmap + +type OrderedMap[K comparable, V any] []Pair[K, V] + +type Pair[K comparable, V any] struct { + Key K + Val V +} + +func New[K comparable, V any]() OrderedMap[K, V] { + return OrderedMap[K, V]{} +} + +func (m OrderedMap[K, V]) Has(key K) bool { + _, ok := m.Get(key) + return ok +} + +func (m OrderedMap[K, V]) Get(key K) (V, bool) { + for _, p := range m { + if p.Key == key { + return p.Val, true + } + } + var zero V + return zero, false +} + +func (m *OrderedMap[K, V]) Set(key K, val V) { + if m == nil { + *m = OrderedMap[K, V]{} + } + + for i, p := range *m { + if p.Key == key { + (*m)[i].Val = val + return + } + } + *m = append(*m, Pair[K, V]{key, val}) +} + +func (m *OrderedMap[K, V]) Delete(key K) { + if m == nil || len(*m) == 0 { + return + } + + for i, p := range *m { + if p.Key == key { + *m = append((*m)[:i], (*m)[i+1:]...) + return + } + } +} + +func (m OrderedMap[K, V]) Keys() []K { + if m == nil { + return nil + } + keys := make([]K, len(m)) + for i, p := range m { + keys[i] = p.Key + } + return keys +} + +func (m OrderedMap[K, V]) Values() []V { + if m == nil { + return nil + } + values := make([]V, len(m)) + for i, p := range m { + values[i] = p.Val + } + return values +} + +func (m *OrderedMap[K, V]) Clear() { + if m == nil || len(*m) == 0 { + return + } + + *m = []Pair[K, V]{} +} + +func (m OrderedMap[K, V]) Copy() OrderedMap[K, V] { + if m == nil { + return nil + } + + return append([]Pair[K, V]{}, m...) +} diff --git a/ordered_map_test.go b/ordered_map_test.go new file mode 100644 index 0000000..5af912e --- /dev/null +++ b/ordered_map_test.go @@ -0,0 +1,100 @@ +package orderedmap + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestOrderedMap_Has(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + assert.True(t, m.Has("name")) + assert.True(t, m.Has("age")) + assert.False(t, m.Has("active")) +} + +func TestOrderedMap_Get(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + { + v, ok := m.Get("name") + assert.True(t, ok) + assert.Equal(t, "John", v) + } + { + v, ok := m.Get("age") + assert.True(t, ok) + assert.Equal(t, 30, v) + } + { + _, ok := m.Get("active") + assert.False(t, ok) + } +} + +func TestOrderedMap_Set(t *testing.T) { + m := OrderedMap[string, any]{} + m.Set("name", "John") + m.Set("age", 30) + + assert.Equal(t, OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + }, m) +} + +func TestOrderedMap_Delete(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + + m.Delete("name") + + assert.Equal(t, OrderedMap[string, any]{ + {"age", 30}, + }, m) +} + +func TestOrderedMap_Keys(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + assert.Equal(t, []string{"name", "age"}, m.Keys()) +} + +func TestOrderedMap_Values(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + assert.Equal(t, []any{"John", 30}, m.Values()) +} + +func TestOrderedMap_Clear(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + m.Clear() + assert.Equal(t, OrderedMap[string, any]{}, m) +} + +func TestOrderedMap_Copy(t *testing.T) { + m := OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + } + + c := m.Copy() + assert.Equal(t, m, c) + + c.Set("name", "Jane") + assert.NotEqual(t, m, c) +} diff --git a/yaml.go b/yaml.go new file mode 100644 index 0000000..ffc7f0a --- /dev/null +++ b/yaml.go @@ -0,0 +1,77 @@ +package orderedmap + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// MarshalYAML implements yaml.Marshaler interface +// to marshall a sorted list of properties into an object. +func (pp OrderedMap[K, V]) MarshalYAML() (any, error) { + if pp == nil { + return nil, nil + } + + if len(pp) == 0 { + return []any{}, nil + } + + node := &yaml.Node{ + Kind: yaml.MappingNode, + } + + for _, p := range pp { + valueNode := &yaml.Node{} + if err := valueNode.Encode(p.Val); err != nil { + return nil, fmt.Errorf("yaml encode property value: %w", err) + } + + node.Content = append(node.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: keyAsString[K](p.Key), + }, valueNode) + } + + return node, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler interface +// to unmarshal an object into a sorted list of properties. +func (pp *OrderedMap[K, V]) UnmarshalYAML(node *yaml.Node) error { + d := len(node.Content) + if d%2 != 0 { + return fmt.Errorf("expected even items for key-value") + } + + for i := 0; i < d; i += 2 { + var pair Pair[K, V] + + keyNode := node.Content[i] + if err := keyNode.Decode(&pair.Key); err != nil { + return fmt.Errorf("yaml decode property key: %w", err) + } + + valueNode := node.Content[i+1] + if err := valueNode.Decode(&pair.Val); err != nil { + return fmt.Errorf("yaml decode property value: %w", err) + } + + *pp = append(*pp, pair) + } + + return nil +} + +func keyAsString[K comparable](key K) string { + switch key := any(key).(type) { + case string: + return key + case []byte: + return string(key) + case fmt.Stringer: + return key.String() + default: + return fmt.Sprintf("%v", key) + } +} diff --git a/yaml_test.go b/yaml_test.go new file mode 100644 index 0000000..18e0029 --- /dev/null +++ b/yaml_test.go @@ -0,0 +1,35 @@ +package orderedmap + +import ( + "testing" + "time" + + "github.com/alecthomas/assert/v2" + "gopkg.in/yaml.v3" +) + +func TestOrderedMap_MarshalYAML(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + got, err := yaml.Marshal(OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + {"active", true}, + {"last_access_time", now}, + }) + assert.NoError(t, err) + assert.Equal(t, "name: John\nage: 30\nactive: true\nlast_access_time: "+now.Format(time.RFC3339Nano)+"\n", string(got)) +} + +func TestOrderedMap_UnmarshalYAML(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + var got OrderedMap[string, any] + err := yaml.Unmarshal([]byte("name: John\nage: 30\nactive: true\nlast_access_time: "+now.Format(time.RFC3339Nano)+"\n"), &got) + assert.NoError(t, err) + + assert.Equal(t, OrderedMap[string, any]{ + {"name", "John"}, + {"age", 30}, + {"active", true}, + {"last_access_time", now}, + }, got) +}