Skip to content
This repository has been archived by the owner on Jul 8, 2024. It is now read-only.

Add Filename option to hashmap driver to save to local file #26

Merged
merged 2 commits into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Hord is designed to be a database-agnostic library that provides a common interf
| [BoltDB](https://github.com/etcd-io/bbolt) | ✅ | | |
| [Cassandra](https://cassandra.apache.org/) | ✅ | | [ScyllaDB](https://www.scylladb.com/), [YugabyteDB](https://www.yugabyte.com/), [Azure Cosmos DB](https://learn.microsoft.com/en-us/azure/cosmos-db/introduction) |
| [Couchbase](https://www.couchbase.com/) | Pending |||
| Hashmap | ✅ |||
| Hashmap | ✅ | Optionally allows storing to YAML or JSON file ||
| Mock | ✅ | Mock Database interactions within unit tests ||
| [NATS](https://nats.io/) | ✅ | Experimental ||
| [Redis](https://redis.io/) | ✅ || [Dragonfly](https://www.dragonflydb.io/), [KeyDB](https://docs.keydb.dev/) |
Expand Down
27 changes: 27 additions & 0 deletions drivers/hashmap/byte_slice.go
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that might be worth considering is base64 encoding values before writing the json/yaml. Since we accept a byte slice the value can be anything even things that break json or yaml formats.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comments! I will add more context to the errors that you mentioned and also add some additional tests.

The JSON marshal will automatically encode the byte slice into a base64 string. I think the YAML handles it by making a block scalar. I'll add some more table tests with more complex data to confirm these things. Then I can base64 encode the YAML if there are any issues.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point. I had forgotten that the JSON encoder will automatically base64 to encode a byte slice. But I agree, more tests will help ensure that everything stays the same as packages get updated and more changes are made.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package hashmap

import (
"fmt"

"gopkg.in/yaml.v3"
)

// ByteSlice is a struct that is used to implement custom YAML Marshal/Unmarshal because
// the default marshal for a []byte will write an array of integers
type ByteSlice []byte

// MarshalYAML simply casts the ByteSlice to a string
func (bs ByteSlice) MarshalYAML() (interface{}, error) {
return string(bs), nil
}

// UnmarshalYAML converts the YAML string back into ByteSlice
func (bs *ByteSlice) UnmarshalYAML(value *yaml.Node) error {
switch value.Tag {
case "!!str":
*bs = []byte(value.Value)
default:
return fmt.Errorf("expected string, but got %s", value.Tag)
}
return nil
}
43 changes: 43 additions & 0 deletions drivers/hashmap/byte_slice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package hashmap

import (
"testing"

"gopkg.in/yaml.v3"
)

func TestByteSliceMarshalYAML(t *testing.T) {
data := map[string]ByteSlice{
"key": []byte("value"),
}

dataBytes, err := yaml.Marshal(data)
if err != nil {
t.Errorf("unexpected error: %v", err)
}

expected := "key: value\n"
if string(dataBytes) != expected {
t.Errorf("expected %q but got: %v", expected, string(dataBytes))
}
}

func TestByteSliceUnmarshalYAML(t *testing.T) {
var data map[string]ByteSlice
err := yaml.Unmarshal([]byte(`key: value`), &data)
if err != nil {
t.Errorf("unexpected error: %v", err)
}

if string(data["key"]) != "value" {
t.Errorf("unexpected value: %v", string(data["key"]))
}
}

func TestByteSliceUnmarshalYAMLError(t *testing.T) {
var data map[string]ByteSlice
err := yaml.Unmarshal([]byte(`key: 1`), &data)
if err == nil || err.Error() != "expected string, but got !!int" {
t.Errorf("error did not match: %v", err)
}
}
9 changes: 9 additions & 0 deletions drivers/hashmap/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hashmap
import (
"context"
"fmt"
"os"
"testing"
"time"

Expand All @@ -12,6 +13,14 @@ import (
func TestInterfaceHappyPath(t *testing.T) {
cfgs := make(map[string]Config)
cfgs["Hashmap"] = Config{}
cfgs["HashmapWithJSONStorage"] = Config{
Filename: "testdata/common_test.json",
}
cfgs["HashmapWithYAMLStorage"] = Config{
Filename: "testdata/common_test.yml",
}
defer os.RemoveAll("testdata/common_test.json")
defer os.RemoveAll("testdata/common_test.yml")

// Loop through valid Configs and validate the driver adheres to the Hord interface
for name, cfg := range cfgs {
Expand Down
99 changes: 90 additions & 9 deletions drivers/hashmap/hashmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,83 @@ Hord provides a simple abstraction for working with the hashmap driver, with eas
package hashmap

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"

"github.com/madflojo/hord"
"gopkg.in/yaml.v3"
)

// Config represents the configuration for the hashmap database.
type Config struct{}
type Config struct {
// Filename is an optional parameter that accepts the path to a YAML or JSON file to read/write data
Filename string
}

// Database is an in-memory hashmap implementation of the hord.Database interface.
type Database struct {
sync.RWMutex

config Config

// data is used to store data in a simple map
data map[string][]byte
data map[string]ByteSlice
}

// Dial initializes and returns a new hashmap database instance.
func Dial(_ Config) (*Database, error) {
db := &Database{}
db.data = make(map[string][]byte)
func Dial(conf Config) (*Database, error) {
if conf.Filename != "" {
switch filepath.Ext(conf.Filename) {
case ".yaml", ".yml", ".json":
default:
return nil, errors.New("filename must have yaml, yml, or json extension")
}
}

db := &Database{config: conf}
db.data = make(map[string]ByteSlice)
return db, nil
}

// Setup sets up the hashmap database. This function does nothing for the hashmap driver.
// Setup sets up the hashmap database. If file storage is enabled, this will load from the file or create it if it does not exist.
func (db *Database) Setup() error {
if db.config.Filename == "" {
return nil
}

db.Lock()
defer db.Unlock()

// check file and create if it does not exist
file, err := os.OpenFile(db.config.Filename, os.O_RDONLY|os.O_CREATE, 0755)
if err != nil {
return fmt.Errorf("error checking file %q: %w", db.config.Filename, err)
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("unable to read local file: %w", err)
}

switch filepath.Ext(db.config.Filename) {
case ".json":
// json fails to read empty input
if len(data) != 0 {
err = json.Unmarshal(data, &db.data)
}
case ".yaml", ".yml":
err = yaml.Unmarshal(data, &db.data)
}
if err != nil {
return fmt.Errorf("unable to unmarshal data from file: %w", err)
}

return nil
}

Expand Down Expand Up @@ -123,7 +175,7 @@ func (db *Database) Set(key string, data []byte) error {
}

db.data[key] = data
return nil
return db.saveToLocalFile()
}

// Delete removes data from the hashmap database based on the provided key.
Expand All @@ -140,7 +192,7 @@ func (db *Database) Delete(key string) error {
}

delete(db.data, key)
return nil
return db.saveToLocalFile()
}

// Keys retrieves a list of keys stored in the hashmap database.
Expand All @@ -167,12 +219,41 @@ func (db *Database) HealthCheck() error {
return hord.ErrNoDial
}

if db.config.Filename != "" {
_, err := os.Stat(db.config.Filename)
if err != nil {
return fmt.Errorf("error checking if file exists: %w", err)
}
}

return nil
}

// Close closes the hashmap database connection and clears all stored data.
// Close closes the hashmap database connection and clears all stored data from memory (file remains if used).
func (db *Database) Close() {
db.Lock()
defer db.Unlock()
db.data = nil
}

// saveToLocalFile is a helper function for methods that change the data (Set, Delete) and should
// only be used after acquiring Write lock
func (db *Database) saveToLocalFile() error {
if db.config.Filename == "" {
return nil
}

var err error
var content []byte
switch filepath.Ext(db.config.Filename) {
case ".json":
content, err = json.Marshal(db.data)
case ".yaml", ".yml":
content, err = yaml.Marshal(db.data)
}
if err != nil {
return err
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a bit more context on this error before returning it?

}

return os.WriteFile(db.config.Filename, content, 0755)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with this, if it returns error a bit more context around it would be useful

}
123 changes: 123 additions & 0 deletions drivers/hashmap/hashmap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package hashmap

import (
"encoding/json"
"os"
"testing"
)

func TestSetupCreatesFileIfNotExist(t *testing.T) {
tests := []struct {
ext string
}{
{"yaml"},
{"yml"},
{"json"},
}

for _, tt := range tests {
t.Run(tt.ext, func(t *testing.T) {
filename := "testdata/empty_file." + tt.ext
defer os.RemoveAll(filename)

db, err := Dial(Config{Filename: filename})
if err != nil {
t.Errorf("unexpected error: %v", err)
}

err = db.Setup()
if err != nil {
t.Errorf("unexpected error: %v", err)
}

// make sure file exists
_, err = os.Stat(filename)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}

func TestDial(t *testing.T) {
t.Run("ErrorInvalidFilename", func(t *testing.T) {
_, err := Dial(Config{Filename: "testdata/empty_file.txt"})
if err == nil || err.Error() != "filename must have yaml, yml, or json extension" {
t.Errorf("error did not match: %v", err)
}
})
}

func TestSaveToLocalFileAfterSet(t *testing.T) {
t.Run("SuccessfullySaveJSON", func(t *testing.T) {
filename := "testdata/save_test.json"
defer os.RemoveAll(filename)

db, err := Dial(Config{Filename: filename})
if err != nil {
t.Errorf("unexpected error: %v", err)
}

err = db.Setup()
if err != nil {
t.Errorf("unexpected error: %v", err)
}

err = db.Set("key", []byte("value"))
if err != nil {
t.Errorf("unexpected error: %v", err)
}

db.Close()

data, err := os.ReadFile(filename)
if err != nil {
t.Errorf("unexpected error: %v", err)
}

var parsedData map[string]ByteSlice
err = json.Unmarshal(data, &parsedData)
if err != nil {
t.Errorf("unexpected error: %v", err)
}

result := string(parsedData["key"])
if result != "value" {
t.Errorf("unexpected value: %v", result)
}
})
}

func TestLoadingFromExistingFile(t *testing.T) {
tests := []struct {
extension string
}{
{"json"},
{"yaml"},
}

for _, tt := range tests {
t.Run(tt.extension, func(t *testing.T) {
filename := "testdata/load_data_test." + tt.extension

db, err := Dial(Config{Filename: filename})
if err != nil {
t.Errorf("unexpected error: %v", err)
}

err = db.Setup()
if err != nil {
t.Errorf("unexpected error: %v", err)
}

value, err := db.Get("key")
if err != nil {
t.Errorf("unexpected error: %v", err)
}

if string(value) != "value" {
t.Errorf("unexpected value: %s", string(value))
}
})
}
}
3 changes: 3 additions & 0 deletions drivers/hashmap/testdata/load_data_test.json
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we create a table test of valid and invalid file/contents combinations?

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"key": "dmFsdWU="
}
1 change: 1 addition & 0 deletions drivers/hashmap/testdata/load_data_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
key: value
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/gomodule/redigo v1.8.9
github.com/nats-io/nats.go v1.25.0
go.etcd.io/bbolt v1.3.7
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
Loading