Blob Blame History Raw
// Package jsondb implements a simple database of JSON documents, backed by the
// file system.
//
// It supports two operations: Read() and Write(). Their signatures mirror
// those of json.Unmarshal() and json.Marshal():
//
//     err := db.Write("my-string", "octopus")
//
//     var v string
//     exists, err := db.Read("my-string", &v)
//
// The JSON documents are stored in a directory, in the form name.json (name as
// passed to Read() and Write()). Thus, names may only contain characters that
// may appear in filenames.

package jsondb

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"strings"
)

type JSONDatabase struct {
	dir  string
	perm os.FileMode
}

// Create a new JSONDatabase in `dir`. Each document that is saved to it will
// have a file mode of `perm`.
func New(dir string, perm os.FileMode) *JSONDatabase {
	return &JSONDatabase{dir, perm}
}

// Reads the value at `name`. `document` must be a type that is deserializable
// from the JSON document `name`, or nil to not deserialize at all. Returns
// false if a document with `name` does not exist.
func (db *JSONDatabase) Read(name string, document interface{}) (bool, error) {
	f, err := os.Open(path.Join(db.dir, name+".json"))
	if err != nil {
		if os.IsNotExist(err) {
			return false, nil
		}
		return false, fmt.Errorf("error accessing db file %s: %v", name, err)
	}
	defer f.Close()

	if document != nil {
		err = json.NewDecoder(f).Decode(&document)
		if err != nil {
			return false, fmt.Errorf("error reading db file %s: %v", name, err)
		}
	}

	return true, nil
}

// Returns a list of all documents' names.
func (db *JSONDatabase) List() ([]string, error) {
	f, err := os.Open(db.dir)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	infos, err := f.Readdir(-1)
	if err != nil {
		return nil, err
	}

	names := make([]string, len(infos))
	for i, info := range infos {
		names[i] = strings.TrimSuffix(info.Name(), ".json")
	}

	return names, nil
}

// Writes `document` to `name`, overwriting a previous document if it exists.
// `document` must be serializable to JSON.
func (db *JSONDatabase) Write(name string, document interface{}) error {
	return writeFileAtomically(db.dir, name+".json", db.perm, func(f *os.File) error {
		return json.NewEncoder(f).Encode(document)
	})
}

// writeFileAtomically writes data to `filename` in `directory` atomically, by
// first creating a temporary file in `directory` and only moving it when
// writing succeeded. `writer` gets passed the open file handle to write to and
// does not need to take care of closing it.
func writeFileAtomically(dir, filename string, mode os.FileMode, writer func(f *os.File) error) error {
	tmpfile, err := ioutil.TempFile(dir, filename+"-*.tmp")
	if err != nil {
		return err
	}

	// Remove `tmpfile` in each error case. We cannot use `defer` here,
	// because `tmpfile` shouldn't be removed when everything works: it
	// will be renamed to `filename`. Ignore errors from `os.Remove()`,
	// because the error relating to `tempfile` is more relevant.

	err = tmpfile.Chmod(mode)
	if err != nil {
		_ = os.Remove(tmpfile.Name())
		return fmt.Errorf("error setting permissions on %s: %v", tmpfile.Name(), err)
	}

	err = writer(tmpfile)
	if err != nil {
		_ = os.Remove(tmpfile.Name())
		return fmt.Errorf("error writing to %s: %v", tmpfile.Name(), err)
	}

	err = tmpfile.Close()
	if err != nil {
		_ = os.Remove(tmpfile.Name())
		return fmt.Errorf("error closing %s: %v", tmpfile.Name(), err)
	}

	err = os.Rename(tmpfile.Name(), path.Join(dir, filename))
	if err != nil {
		_ = os.Remove(tmpfile.Name())
		return fmt.Errorf("error moving %s to %s: %v", filepath.Base(tmpfile.Name()), filename, err)
	}

	return nil
}