Storage Index [WIP]
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id, UserName, Email string
|
||||
}
|
||||
|
||||
type TestPet struct {
|
||||
Id, Kind, Color, Name string
|
||||
}
|
||||
|
||||
var testData = map[string][]interface{}{
|
||||
"users": {
|
||||
User{Id: "abcdefg-123", UserName: "mikey", Email: "mikey@example.com"},
|
||||
User{Id: "hijklmn-456", UserName: "frank", Email: "frank@example.com"},
|
||||
User{Id: "ewf4ofk-555", UserName: "jacky", Email: "jacky@example.com"},
|
||||
User{Id: "rulan54-777", UserName: "jones", Email: "jones@example.com"},
|
||||
},
|
||||
"pets": {
|
||||
TestPet{Id: "rebef-123", Kind: "Dog", Color: "Brown", Name: "Waldo"},
|
||||
TestPet{Id: "wefwe-456", Kind: "Cat", Color: "White", Name: "Snowy"},
|
||||
TestPet{Id: "goefe-789", Kind: "Hog", Color: "Green", Name: "Dicky"},
|
||||
TestPet{Id: "xadaf-189", Kind: "Hog", Color: "Green", Name: "Ricky"},
|
||||
},
|
||||
}
|
||||
|
||||
func writeIndexTestData(t *testing.T, m map[string][]interface{}, pk string) string {
|
||||
rootDir := createTmpDir(t)
|
||||
for dirName := range m {
|
||||
fileTypePath := path.Join(rootDir, dirName)
|
||||
|
||||
if err := os.MkdirAll(fileTypePath, 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range m[dirName] {
|
||||
data, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pkVal := valueOf(u, pk)
|
||||
if err := ioutil.WriteFile(path.Join(fileTypePath, pkVal), data, 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
func createTmpDir(t *testing.T) string {
|
||||
name, err := ioutil.TempDir("/var/tmp", "testfiles-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type alreadyExistsErr struct {
|
||||
typeName, key, val string
|
||||
}
|
||||
|
||||
func (e *alreadyExistsErr) Error() string {
|
||||
return fmt.Sprintf("%s with %s=%s does already exist", e.typeName, e.key, e.val)
|
||||
}
|
||||
|
||||
func IsAlreadyExistsErr(e error) bool {
|
||||
_, ok := e.(*alreadyExistsErr)
|
||||
return ok
|
||||
}
|
||||
|
||||
type notFoundErr struct {
|
||||
typeName, key, val string
|
||||
}
|
||||
|
||||
func (e *notFoundErr) Error() string {
|
||||
return fmt.Sprintf("%s with %s=%s not found", e.typeName, e.key, e.val)
|
||||
}
|
||||
|
||||
func IsNotFoundErr(e error) bool {
|
||||
_, ok := e.(*notFoundErr)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package index provides symlink-based index for on-disk document-directories.
|
||||
package index
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"path"
|
||||
)
|
||||
|
||||
// Index is a facade to configure and query over multiple indices.
|
||||
type Index struct {
|
||||
config *Config
|
||||
indices indexMap
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataDir string
|
||||
IndexRootDirName string
|
||||
Log zerolog.Logger
|
||||
}
|
||||
|
||||
// Type can be implemented to create new index-strategies. See Unique for example.
|
||||
// Each index implementation is bound to one data-column (IndexBy) and a data-type (TypeName)
|
||||
type Type interface {
|
||||
Init() error
|
||||
Lookup(v string) ([]string, error)
|
||||
Add(id, v string) (string, error)
|
||||
Remove(id string, v string) error
|
||||
Update(id, oldV, newV string) error
|
||||
Search(pattern string) ([]string, error)
|
||||
IndexBy() string
|
||||
TypeName() string
|
||||
FilesDir() string
|
||||
}
|
||||
|
||||
func NewIndex(cfg *Config) *Index {
|
||||
return &Index{
|
||||
config: cfg,
|
||||
indices: indexMap{},
|
||||
}
|
||||
}
|
||||
|
||||
func (man Index) AddUniqueIndex(typeName, indexBy, entityDirName string) error {
|
||||
fullDataPath := path.Join(man.config.DataDir, entityDirName)
|
||||
indexPath := path.Join(man.config.DataDir, man.config.IndexRootDirName)
|
||||
|
||||
idx := NewUniqueIndex(typeName, indexBy, fullDataPath, indexPath)
|
||||
man.indices.addIndex(idx)
|
||||
|
||||
return idx.Init()
|
||||
}
|
||||
|
||||
func (man Index) AddNormalIndex(typeName, indexBy, entityDirName string) error {
|
||||
fullDataPath := path.Join(man.config.DataDir, entityDirName)
|
||||
indexPath := path.Join(man.config.DataDir, man.config.IndexRootDirName)
|
||||
|
||||
idx := NewNormalIndex(typeName, indexBy, fullDataPath, indexPath)
|
||||
man.indices.addIndex(idx)
|
||||
|
||||
return idx.Init()
|
||||
}
|
||||
|
||||
func (man Index) AddIndex(idx Type) error {
|
||||
man.indices.addIndex(idx)
|
||||
return idx.Init()
|
||||
}
|
||||
|
||||
// Add a new entry to the index
|
||||
func (man Index) Add(primaryKey string, entity interface{}) error {
|
||||
t, err := getType(entity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typeName := t.Type().Name()
|
||||
|
||||
if typeIndices, ok := man.indices[typeName]; ok {
|
||||
for _, fieldIndices := range typeIndices {
|
||||
for k := range fieldIndices {
|
||||
curIdx := fieldIndices[k]
|
||||
idxBy := curIdx.IndexBy()
|
||||
val := valueOf(entity, idxBy)
|
||||
_, err := curIdx.Add(primaryKey, val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find a entry by type,field and value.
|
||||
// // Find a User type by email
|
||||
// man.Find("User", "Email", "foo@example.com")
|
||||
func (man Index) Find(typeName, key, value string) (pk string, err error) {
|
||||
var res = []string{}
|
||||
if indices, ok := man.indices[typeName][key]; ok {
|
||||
for _, idx := range indices {
|
||||
if res, err = idx.Lookup(value); IsNotFoundErr(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path.Base(res[0]), err
|
||||
}
|
||||
|
||||
func (man Index) Delete(typeName, pk string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManagerQueryMultipleIndices(t *testing.T) {
|
||||
dataDir := writeIndexTestData(t, testData, "Id")
|
||||
man := NewIndex(&Config{
|
||||
DataDir: dataDir,
|
||||
IndexRootDirName: "index.disk",
|
||||
Log: zerolog.Logger{},
|
||||
})
|
||||
|
||||
err := man.AddUniqueIndex("User", "Email", "users")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = man.AddUniqueIndex("User", "UserName", "users")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = man.AddNormalIndex("TestPet", "Color", "pets")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = man.AddUniqueIndex("TestPet", "Name", "pets")
|
||||
assert.NoError(t, err)
|
||||
|
||||
for path := range testData {
|
||||
for _, entity := range testData[path] {
|
||||
err := man.Add(valueOf(entity, "Id"), entity)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
type test struct {
|
||||
typeName, key, value, wantRes string
|
||||
wantErr error
|
||||
}
|
||||
|
||||
tests := []test{
|
||||
{typeName: "User", key: "Email", value: "jacky@example.com", wantRes: "ewf4ofk-555"},
|
||||
{typeName: "User", key: "UserName", value: "jacky", wantRes: "ewf4ofk-555"},
|
||||
{typeName: "TestPet", key: "Color", value: "Brown", wantRes: "rebef-123"},
|
||||
{typeName: "TestPet", key: "Color", value: "Cyan", wantRes: "", wantErr: ¬FoundErr{}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
name := fmt.Sprintf("Query%sBy%s=%s", tc.typeName, tc.key, tc.value)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
pk, err := man.Find(tc.typeName, tc.key, tc.value)
|
||||
assert.Equal(t, tc.wantRes, pk)
|
||||
assert.IsType(t, tc.wantErr, err)
|
||||
})
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestManagerDelete(t *testing.T) {
|
||||
dataDir := writeIndexTestData(t, testData, "Id")
|
||||
man := NewIndex(&Config{
|
||||
DataDir: dataDir,
|
||||
IndexRootDirName: "index.disk",
|
||||
Log: zerolog.Logger{},
|
||||
})
|
||||
|
||||
err := man.AddUniqueIndex("User", "Email", "users")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = man.AddUniqueIndex("User", "UserName", "users")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = man.AddUniqueIndex("TestPet", "Name", "pets")
|
||||
assert.NoError(t, err)
|
||||
|
||||
for path := range testData {
|
||||
for _, entity := range testData[path] {
|
||||
err := man.Add(valueOf(entity, "Id"), entity)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = man.Delete("User", "hijklmn-456")
|
||||
_ = os.RemoveAll(dataDir)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package index
|
||||
|
||||
// indexMap stores the index layout at runtime.
|
||||
type indexMap map[tName]map[indexByKey][]Type
|
||||
|
||||
type tName = string
|
||||
type indexByKey = string
|
||||
|
||||
func (m indexMap) addIndex(idx Type) {
|
||||
typeName, indexBy := idx.TypeName(), idx.IndexBy()
|
||||
if _, ok := m[typeName]; !ok {
|
||||
m[typeName] = map[indexByKey][]Type{}
|
||||
}
|
||||
|
||||
m[typeName][indexBy] = append(m[typeName][indexBy], idx)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// NonUniqueIndex is able to index an document by a key which might contain non-unique values
|
||||
//
|
||||
// /var/tmp/testfiles-395764020/index.disk/PetByColor/
|
||||
// ├── Brown
|
||||
// │ └── rebef-123 -> /var/tmp/testfiles-395764020/pets/rebef-123
|
||||
// ├── Green
|
||||
// │ ├── goefe-789 -> /var/tmp/testfiles-395764020/pets/goefe-789
|
||||
// │ └── xadaf-189 -> /var/tmp/testfiles-395764020/pets/xadaf-189
|
||||
// └── White
|
||||
// └── wefwe-456 -> /var/tmp/testfiles-395764020/pets/wefwe-456
|
||||
type NonUniqueIndex struct {
|
||||
indexBy string
|
||||
typeName string
|
||||
filesDir string
|
||||
indexBaseDir string
|
||||
indexRootDir string
|
||||
}
|
||||
|
||||
// NewNormalIndex instantiates a new NonUniqueIndex instance. Init() should be
|
||||
// called afterward to ensure correct on-disk structure.
|
||||
func NewNormalIndex(typeName, indexBy, filesDir, indexBaseDir string) NonUniqueIndex {
|
||||
return NonUniqueIndex{
|
||||
indexBy: indexBy,
|
||||
typeName: typeName,
|
||||
filesDir: filesDir,
|
||||
indexBaseDir: indexBaseDir,
|
||||
indexRootDir: path.Join(indexBaseDir, fmt.Sprintf("%sBy%s", typeName, indexBy)),
|
||||
}
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Init() error {
|
||||
if _, err := os.Stat(idx.filesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(idx.indexRootDir, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Lookup(v string) ([]string, error) {
|
||||
searchPath := path.Join(idx.indexRootDir, v)
|
||||
fi, err := ioutil.ReadDir(searchPath)
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, ¬FoundErr{idx.typeName, idx.indexBy, v}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
var ids []string = nil
|
||||
for _, f := range fi {
|
||||
ids = append(ids, f.Name())
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []string{}, ¬FoundErr{idx.typeName, idx.indexBy, v}
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Add(id, v string) (string, error) {
|
||||
oldName := path.Join(idx.filesDir, id)
|
||||
newName := path.Join(idx.indexRootDir, v, id)
|
||||
|
||||
if err := os.MkdirAll(path.Join(idx.indexRootDir, v), 0777); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := os.Symlink(oldName, newName)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return "", &alreadyExistsErr{idx.typeName, idx.indexBy, v}
|
||||
}
|
||||
|
||||
return newName, err
|
||||
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Remove(id string, v string) error {
|
||||
res, err := filepath.Glob(path.Join(idx.indexRootDir, "/*/", id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range res {
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Update(id, oldV, newV string) (err error) {
|
||||
oldDir := path.Join(idx.indexRootDir, oldV)
|
||||
oldPath := path.Join(oldDir, id)
|
||||
newDir := path.Join(idx.indexRootDir, newV)
|
||||
newPath := path.Join(newDir, id)
|
||||
|
||||
if _, err = os.Stat(oldPath); os.IsNotExist(err) {
|
||||
return ¬FoundErr{idx.typeName, idx.indexBy, oldV}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(newDir, 0777); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Rename(oldPath, newPath); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
di, err := ioutil.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(di) == 0 {
|
||||
err = os.RemoveAll(oldDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) Search(pattern string) ([]string, error) {
|
||||
paths, err := filepath.Glob(path.Join(idx.indexRootDir, pattern, "*"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
return nil, ¬FoundErr{idx.typeName, idx.indexBy, pattern}
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) IndexBy() string {
|
||||
return idx.indexBy
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) TypeName() string {
|
||||
return idx.typeName
|
||||
}
|
||||
|
||||
func (idx NonUniqueIndex) FilesDir() string {
|
||||
return idx.filesDir
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNonUniqueIndexAdd(t *testing.T) {
|
||||
sut, dataPath := getNonUniqueIdxSut(t)
|
||||
|
||||
ids, err := sut.Lookup("Green")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, []string{"goefe-789", "xadaf-189"}, ids)
|
||||
|
||||
ids, err = sut.Lookup("White")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, []string{"wefwe-456"}, ids)
|
||||
|
||||
ids, err = sut.Lookup("Cyan")
|
||||
assert.Error(t, err)
|
||||
assert.EqualValues(t, []string{}, ids)
|
||||
|
||||
_ = os.RemoveAll(dataPath)
|
||||
|
||||
}
|
||||
|
||||
func TestNonUniqueIndexUpdate(t *testing.T) {
|
||||
sut, dataPath := getNonUniqueIdxSut(t)
|
||||
|
||||
err := sut.Update("goefe-789", "Green", "Black")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = sut.Update("xadaf-189", "Green", "Black")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.DirExists(t, path.Join(dataPath, "index.disk/PetByColor/Black"))
|
||||
assert.NoDirExists(t, path.Join(dataPath, "index.disk/PetByColor/Green"))
|
||||
|
||||
_ = os.RemoveAll(dataPath)
|
||||
}
|
||||
|
||||
func TestNonUniqueIndexDelete(t *testing.T) {
|
||||
sut, dataPath := getNonUniqueIdxSut(t)
|
||||
assert.FileExists(t, path.Join(dataPath, "index.disk/PetByColor/Green/goefe-789"))
|
||||
err := sut.Remove("goefe-789", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, path.Join(dataPath, "index.disk/PetByColor/Green/goefe-789"))
|
||||
_ = os.RemoveAll(dataPath)
|
||||
}
|
||||
|
||||
func TestNonUniqueIndexInit(t *testing.T) {
|
||||
dataDir := createTmpDir(t)
|
||||
indexRootDir := path.Join(dataDir, "index.disk")
|
||||
filesDir := path.Join(dataDir, "users")
|
||||
|
||||
uniq := NewNormalIndex("User", "DisplayName", filesDir, indexRootDir)
|
||||
assert.Error(t, uniq.Init(), "Init should return an error about missing files-dir")
|
||||
|
||||
if err := os.Mkdir(filesDir, 0777); err != nil {
|
||||
t.Fatalf("Could not create test data-dir %s", err)
|
||||
}
|
||||
|
||||
assert.NoError(t, uniq.Init(), "Init shouldn't return an error")
|
||||
assert.DirExists(t, indexRootDir)
|
||||
assert.DirExists(t, path.Join(indexRootDir, "UserByDisplayName"))
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestNonUniqueIndexSearch(t *testing.T) {
|
||||
sut, dataPath := getNonUniqueIdxSut(t)
|
||||
|
||||
res, err := sut.Search("Gr*")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, res, 2)
|
||||
|
||||
assert.Equal(t, "goefe-789", path.Base(res[0]))
|
||||
assert.Equal(t, "xadaf-189", path.Base(res[1]))
|
||||
|
||||
res, err = sut.Search("does-not-exist@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, ¬FoundErr{}, err)
|
||||
|
||||
_ = os.RemoveAll(dataPath)
|
||||
}
|
||||
|
||||
func getNonUniqueIdxSut(t *testing.T) (sut Type, dataPath string) {
|
||||
dataPath = writeIndexTestData(t, testData, "Id")
|
||||
sut = NewNormalIndex("Pet", "Color", path.Join(dataPath, "pets"), path.Join(dataPath, "index.disk"))
|
||||
err := sut.Init()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, u := range testData["pets"] {
|
||||
pkVal := valueOf(u, "Id")
|
||||
idxByVal := valueOf(u, "Color")
|
||||
_, err := sut.Add(pkVal, idxByVal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func getType(v interface{}) (reflect.Value, error) {
|
||||
rv := reflect.ValueOf(v)
|
||||
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if !rv.IsValid() {
|
||||
return reflect.Value{}, errors.New("failed to read value via reflection")
|
||||
}
|
||||
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
func valueOf(v interface{}, field string) string {
|
||||
r := reflect.ValueOf(v)
|
||||
f := reflect.Indirect(r).FieldByName(field)
|
||||
|
||||
return f.String()
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Unique ensures that only one document of the same type and key-value combination can exist in the index.
|
||||
//
|
||||
// Modeled by creating a index-folder per entity and key with symlinks which point to respective documents which contain
|
||||
// the link-filename as value.
|
||||
//
|
||||
// Directory Layout
|
||||
//
|
||||
// /var/data/index.disk/UniqueUserByEmail/
|
||||
// ├── jacky@example.com -> /var/data/users/ewf4ofk-555
|
||||
// ├── jones@example.com -> /var/data/users/rulan54-777
|
||||
// └── mikey@example.com -> /var/data/users/abcdefg-123
|
||||
//
|
||||
// Example user
|
||||
//
|
||||
// {
|
||||
// "Id": "ewf4ofk-555",
|
||||
// "UserName": "jacky",
|
||||
// "Email": "jacky@example.com"
|
||||
// }
|
||||
//
|
||||
type Unique struct {
|
||||
indexBy string
|
||||
typeName string
|
||||
filesDir string
|
||||
indexBaseDir string
|
||||
indexRootDir string
|
||||
}
|
||||
|
||||
// NewUniqueIndex instantiates a new UniqueIndex instance. Init() should be
|
||||
// called afterward to ensure correct on-disk structure.
|
||||
func NewUniqueIndex(typeName, indexBy, filesDir, indexBaseDir string) Unique {
|
||||
return Unique{
|
||||
indexBy: indexBy,
|
||||
typeName: typeName,
|
||||
filesDir: filesDir,
|
||||
indexBaseDir: indexBaseDir,
|
||||
indexRootDir: path.Join(indexBaseDir, fmt.Sprintf("Unique%sBy%s", typeName, indexBy)),
|
||||
}
|
||||
}
|
||||
|
||||
func (idx Unique) Init() error {
|
||||
if _, err := os.Stat(idx.filesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(idx.indexRootDir, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx Unique) Add(id, v string) (string, error) {
|
||||
oldName := path.Join(idx.filesDir, id)
|
||||
newName := path.Join(idx.indexRootDir, v)
|
||||
err := os.Symlink(oldName, newName)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return "", &alreadyExistsErr{idx.typeName, idx.indexBy, v}
|
||||
}
|
||||
|
||||
return newName, err
|
||||
}
|
||||
|
||||
func (idx Unique) Remove(id string, v string) (err error) {
|
||||
searchPath := path.Join(idx.indexRootDir, v)
|
||||
if err = isValidSymlink(searchPath); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return os.Remove(searchPath)
|
||||
}
|
||||
|
||||
func (idx Unique) Lookup(v string) (resultPath []string, err error) {
|
||||
searchPath := path.Join(idx.indexRootDir, v)
|
||||
if err = isValidSymlink(searchPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = ¬FoundErr{idx.typeName, idx.indexBy, v}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p, err := os.Readlink(searchPath)
|
||||
if err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return []string{p}, err
|
||||
|
||||
}
|
||||
|
||||
func (idx Unique) Update(id, oldV, newV string) (err error) {
|
||||
oldPath := path.Join(idx.indexRootDir, oldV)
|
||||
if err = isValidSymlink(oldPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ¬FoundErr{idx.typeName, idx.indexBy, oldV}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
newPath := path.Join(idx.indexRootDir, newV)
|
||||
if err = isValidSymlink(newPath); err == nil {
|
||||
return &alreadyExistsErr{idx.typeName, idx.indexBy, newV}
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
err = os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (idx Unique) Search(pattern string) ([]string, error) {
|
||||
paths, err := filepath.Glob(path.Join(idx.indexRootDir, pattern))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
return nil, ¬FoundErr{idx.typeName, idx.indexBy, pattern}
|
||||
}
|
||||
|
||||
res := make([]string, 0, 0)
|
||||
for _, p := range paths {
|
||||
if err := isValidSymlink(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = append(res, src)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (idx Unique) IndexBy() string {
|
||||
return idx.indexBy
|
||||
}
|
||||
|
||||
func (idx Unique) TypeName() string {
|
||||
return idx.typeName
|
||||
}
|
||||
|
||||
func (idx Unique) FilesDir() string {
|
||||
return idx.filesDir
|
||||
}
|
||||
|
||||
func isValidSymlink(path string) (err error) {
|
||||
var symInfo os.FileInfo
|
||||
if symInfo, err = os.Lstat(path); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if symInfo.Mode()&os.ModeSymlink == 0 {
|
||||
err = fmt.Errorf("%s is not a valid symlink (bug/corruption?)", path)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUniqueLookupSingleEntry(t *testing.T) {
|
||||
uniq, dataDir := getUniqueIdxSut(t)
|
||||
filesDir := path.Join(dataDir, "users")
|
||||
|
||||
t.Log("existing lookup")
|
||||
resultPath, err := uniq.Lookup("mikey@example.com")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{path.Join(filesDir, "abcdefg-123")}, resultPath)
|
||||
|
||||
t.Log("non-existing lookup")
|
||||
resultPath, err = uniq.Lookup("doesnotExists@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, ¬FoundErr{}, err)
|
||||
assert.Empty(t, resultPath)
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
|
||||
}
|
||||
|
||||
func TestUniqueUniqueConstraint(t *testing.T) {
|
||||
uniq, dataDir := getUniqueIdxSut(t)
|
||||
|
||||
_, err := uniq.Add("abcdefg-123", "mikey@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, &alreadyExistsErr{}, err)
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestUniqueRemove(t *testing.T) {
|
||||
uniq, dataDir := getUniqueIdxSut(t)
|
||||
|
||||
err := uniq.Remove("", "mikey@example.com")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = uniq.Lookup("mikey@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, ¬FoundErr{}, err)
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestUniqueUpdate(t *testing.T) {
|
||||
uniq, dataDir := getUniqueIdxSut(t)
|
||||
|
||||
t.Log("successful update")
|
||||
err := uniq.Update("", "mikey@example.com", "mikey2@example.com")
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Log("failed update because already exists")
|
||||
err = uniq.Update("", "frank@example.com", "mikey2@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, &alreadyExistsErr{}, err)
|
||||
|
||||
t.Log("failed update because not found")
|
||||
err = uniq.Update("", "notexist@example.com", "something2@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, ¬FoundErr{}, err)
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestUniqueInit(t *testing.T) {
|
||||
dataDir := createTmpDir(t)
|
||||
indexRootDir := path.Join(dataDir, "index.disk")
|
||||
filesDir := path.Join(dataDir, "users")
|
||||
|
||||
uniq := NewUniqueIndex("User", "Email", filesDir, indexRootDir)
|
||||
assert.Error(t, uniq.Init(), "Init should return an error about missing files-dir")
|
||||
|
||||
if err := os.Mkdir(filesDir, 0777); err != nil {
|
||||
t.Fatalf("Could not create test data-dir %s", err)
|
||||
}
|
||||
|
||||
assert.NoError(t, uniq.Init(), "Init shouldn't return an error")
|
||||
assert.DirExists(t, indexRootDir)
|
||||
assert.DirExists(t, path.Join(indexRootDir, "UniqueUserByEmail"))
|
||||
|
||||
_ = os.RemoveAll(dataDir)
|
||||
}
|
||||
|
||||
func TestUniqueIndexSearch(t *testing.T) {
|
||||
sut, dataPath := getUniqueIdxSut(t)
|
||||
|
||||
res, err := sut.Search("j*@example.com")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, res, 2)
|
||||
|
||||
assert.Equal(t, "ewf4ofk-555", path.Base(res[0]))
|
||||
assert.Equal(t, "rulan54-777", path.Base(res[1]))
|
||||
|
||||
res, err = sut.Search("does-not-exist@example.com")
|
||||
assert.Error(t, err)
|
||||
assert.IsType(t, ¬FoundErr{}, err)
|
||||
|
||||
_ = os.RemoveAll(dataPath)
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
assert.True(t, IsAlreadyExistsErr(&alreadyExistsErr{}))
|
||||
assert.True(t, IsNotFoundErr(¬FoundErr{}))
|
||||
}
|
||||
|
||||
func getUniqueIdxSut(t *testing.T) (sut Type, dataPath string) {
|
||||
dataPath = writeIndexTestData(t, testData, "Id")
|
||||
sut = NewUniqueIndex("User", "Email", path.Join(dataPath, "users"), path.Join(dataPath, "index.disk"))
|
||||
err := sut.Init()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, u := range testData["users"] {
|
||||
pkVal := valueOf(u, "Id")
|
||||
idxByVal := valueOf(u, "Email")
|
||||
_, err := sut.Add(pkVal, idxByVal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user