This repository has been archived by the owner on Jul 8, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 8
Add Filename option to hashmap driver to save to local file #26
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
} | ||
|
||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} | ||
}) | ||
} | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"key": "dmFsdWU=" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
key: value |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.