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 all commits
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
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
104 changes: 95 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,46 @@ 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 fmt.Errorf("error marshalling data: %w", err)
}

err = os.WriteFile(db.config.Filename, content, 0755)
if err != nil {
return fmt.Errorf("error writing data to file %q: %w", db.config.Filename, err)
}

return nil
}
Loading