Implement an in-memory databasewqa

This commit is contained in:
2021-06-19 17:47:36 -04:00
parent 9af3f78659
commit b66f5ebe67
14 changed files with 931 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
# ramdb
RamDB is an implementation of an in-memory database with a simple API for selecting and querying the database. It uses b-trees as the underlying storage mechanism which allows fast searches and mutations.
All database commands are safe for concurrent operations.
## Example
```go
// Create database and table.
db := ramdb.NewDatabase()
_ = db.CreateTable("hotdogs", "frank_id")
// Mm, hotdogs.
type HotDog struct {
FrankId string
Condiments []string
Brat bool
}
indog := HotDog{
FrankId: "1",
Condiments: []string{
"kraut",
"mustard",
},
Brat: true,
}
// Create a new record and insert.
rec, _ := ramdb.NewRecord("1", "frank_id", indog)
_ = db.From("hotdogs").Insert(rec)
// Query data out.
ro, _ := db.From("hotdogs").Get("frank_id", "1")
var outdog HotDog
_ = ro.Deserialize(&outdog)
fmt.Printf("%+v\n", outdog)
// &HotDog{"1" ["kraut", "mustard"] true}
```
+93
View File
@@ -0,0 +1,93 @@
package ramdb
import "github.com/google/btree"
// Get validates that the table and index for the given column exists and searches for the given key in the tree.
func (t *table) Get(column, key string) (r *record, err error) {
if !t.exists {
return nil, ErrNoTable
}
if !t.HasIndex(column) {
return nil, ErrNoIndex
}
return t.keyLookup(key, t.indexes[column])
}
// keyLookup tries to find the given key in the tree. It returns ErrNoRecord if not found.
func (t *table) keyLookup(key string, index *index) (r *record, err error) {
item := &record{id: keyHash(key)}
result := index.tree.Get(item)
if result == nil {
return nil, ErrNoRecord
}
return result.(*record), nil
}
// Select returns all of the records in the database sorted in ascending order by id.
func (t *table) Select(column string) (rr []*record, err error) {
if !t.exists {
return nil, ErrNoTable
}
if !t.HasIndex(column) {
return nil, ErrNoIndex
}
t.indexes[column].tree.Ascend(func(item btree.Item) bool {
r := item.(*record)
rr = append(rr, r)
return true
})
return
}
// Insert adds the record to the database. It returns ErrRecordExists if the record already exists. Insert is thread safe.
func (t *table) Insert(r *record) error {
if !t.exists {
return ErrNoTable
}
if !t.HasIndex(r.keyColumn) {
return ErrNoIndex
}
index := t.indexes[r.keyColumn]
if has := index.tree.Has(r); has {
return ErrRecordExists
}
t.mutex.Lock()
defer t.mutex.Unlock()
index.tree.ReplaceOrInsert(r)
return nil
}
// Delete removes the item from the database. It returns ErrNoRecord if the record does not exist and ErrNotDeleted if the removal fails. Delete is thread safe.
func (t *table) Delete(r *record) error {
if !t.exists {
return ErrNoTable
}
if !t.HasIndex(r.keyColumn) {
return ErrNoIndex
}
index := t.indexes[r.keyColumn]
if has := index.tree.Has(r); !has {
return ErrNoRecord
}
t.mutex.Lock()
defer t.mutex.Unlock()
index.tree.Delete(r)
return nil
}
+318
View File
@@ -0,0 +1,318 @@
package ramdb
import (
"fmt"
"sort"
"sync"
"testing"
"github.com/google/btree"
"github.com/stretchr/testify/assert"
)
func TestTable_Get(t *testing.T) {
tests := []struct {
test string
tableConfig func() *table
expectedRecord *record
expectedError error
}{
{
test: "it should return ErrNoTable if an invalid table is supplied",
tableConfig: func() *table {
return &table{}
},
expectedError: ErrNoTable,
},
{
test: "it should return ErrNoIndex if no index exists for column",
tableConfig: func() *table {
return &table{
exists: true,
}
},
expectedError: ErrNoIndex,
},
{
test: "it should return ErrNoRecord if no record was found for key",
tableConfig: func() *table {
return &table{
exists: true,
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
},
expectedError: ErrNoRecord,
},
{
test: "it should return a record when one is found",
tableConfig: func() *table {
tbl := &table{
exists: true,
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
rec, err := NewRecord("test_key", "test_column", struct{}{})
if err != nil {
t.Error(err)
}
tbl.indexes["test_column"].tree.ReplaceOrInsert(rec)
return tbl
},
expectedRecord: &record{
serialized: []uint8{0x7b, 0x7d},
key: "test_key",
keyColumn: "test_column",
id: 0x92488e1e3eeecdf9,
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
tbl := tc.tableConfig()
record, err := tbl.Get("test_column", "test_key")
assert.Equal(t, tc.expectedRecord, record)
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestTable_Select(t *testing.T) {
tests := []struct {
test string
tableConfig func() (*table, []*record)
expectedRecords []*record
expectedError error
}{
{
test: "it should return ErrNoTable if an invalid table is supplied",
tableConfig: func() (*table, []*record) {
return &table{
mutex: &sync.Mutex{},
}, nil
},
expectedError: ErrNoTable,
},
{
test: "it should return ErrNoIndex if no index exists for column",
tableConfig: func() (*table, []*record) {
return &table{
exists: true,
mutex: &sync.Mutex{},
indexes: make(map[string]*index),
}, nil
},
expectedError: ErrNoIndex,
},
{
test: "it should return all records in the database",
tableConfig: func() (*table, []*record) {
tbl := &table{
exists: true,
mutex: &sync.Mutex{},
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
expectedRecords := make([]*record, 0)
for i := 0; i < 10; i++ {
key := fmt.Sprintf("key-%d", i)
rec, err := NewRecord(key, "test_column", struct{}{})
if err != nil {
t.Error(err)
}
tbl.indexes["test_column"].tree.ReplaceOrInsert(rec)
expectedRecords = append(expectedRecords, rec)
}
sort.Slice(expectedRecords, func(a, b int) bool {
return expectedRecords[a].id < expectedRecords[b].id
})
return tbl, expectedRecords
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
tbl, expectedRecords := tc.tableConfig()
rr, err := tbl.Select("test_column")
assert.Equal(t, expectedRecords, rr)
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestTable_Insert(t *testing.T) {
tests := []struct {
test string
tableConfig func() *table
expectedError error
}{
{
test: "it should return ErrNoTable if an invalid table is supplied",
tableConfig: func() *table {
return &table{
mutex: &sync.Mutex{},
}
},
expectedError: ErrNoTable,
},
{
test: "it should return ErrNoIndex if no index exists for column",
tableConfig: func() *table {
return &table{
exists: true,
mutex: &sync.Mutex{},
indexes: make(map[string]*index),
}
},
expectedError: ErrNoIndex,
},
{
test: "it should return ErrRecordExists if a record with key exists",
tableConfig: func() *table {
tbl := &table{
exists: true,
mutex: &sync.Mutex{},
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
rec, err := NewRecord("test_key", "test_column", struct{}{})
if err != nil {
t.Error(err)
}
tbl.indexes["test_column"].tree.ReplaceOrInsert(rec)
return tbl
},
expectedError: ErrRecordExists,
},
{
test: "it should return no error if successful",
tableConfig: func() *table {
return &table{
exists: true,
mutex: &sync.Mutex{},
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
tbl := tc.tableConfig()
rec, err := NewRecord("test_key", "test_column", struct{}{})
if err != nil {
t.Error(err)
}
err = tbl.Insert(rec)
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestTable_Delete(t *testing.T) {
tests := []struct {
test string
tableConfig func() *table
expectedError error
}{
{
test: "it should return ErrNoTable if an invalid table is supplied",
tableConfig: func() *table {
return &table{
mutex: &sync.Mutex{},
}
},
expectedError: ErrNoTable,
},
{
test: "it should return ErrNoIndex if no index exists for column",
tableConfig: func() *table {
return &table{
exists: true,
mutex: &sync.Mutex{},
indexes: make(map[string]*index),
}
},
expectedError: ErrNoIndex,
},
{
test: "it should return ErrNoRecord if the record does not exist",
tableConfig: func() *table {
return &table{
exists: true,
mutex: &sync.Mutex{},
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
},
expectedError: ErrNoRecord,
},
{
test: "it should return no error if the item was deleted",
tableConfig: func() *table {
tbl := &table{
exists: true,
mutex: &sync.Mutex{},
indexes: map[string]*index{
"test_column": &index{
tree: btree.New(5),
},
},
}
rec, err := NewRecord("test_key", "test_column", struct{}{})
if err != nil {
t.Error(err)
}
tbl.indexes["test_column"].tree.ReplaceOrInsert(rec)
return tbl
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
tbl := tc.tableConfig()
rec, err := NewRecord("test_key", "test_column", struct{}{})
if err != nil {
t.Error(err)
}
err = tbl.Delete(rec)
assert.Equal(t, tc.expectedError, err)
})
}
}
+15
View File
@@ -0,0 +1,15 @@
package ramdb
import "errors"
var (
ErrNoTable = errors.New("table does not exist")
ErrTableExists = errors.New("table already exists")
ErrNoRecord = errors.New("record does not exist")
ErrRecordExists = errors.New("record already exists")
ErrNoIndex = errors.New("index does not exist")
ErrInvalidIndex = errors.New("invalid index column")
ErrIndexExists = errors.New("index already exists")
)
+9
View File
@@ -0,0 +1,9 @@
package ramdb
import "github.com/google/btree"
type index struct {
tree *btree.BTree
column string
table *table
}
+43
View File
@@ -0,0 +1,43 @@
package ramdb
type database struct {
tables map[string]*table
}
// NewDatabase initializes a new database with no tables.
func NewDatabase() *database {
return &database{
tables: make(map[string]*table),
}
}
// From selects a table for running commands.
func (db *database) From(tablename string) *table {
t, ok := db.tables[tablename]
if !ok {
return &table{}
}
return t
}
// CreateTable creates a new table in the database with indexes for each column specified.
func (db *database) CreateTable(tablename string, indexOnColumns ...string) error {
if _, found := db.tables[tablename]; found {
return ErrTableExists
}
tbl := &table{
exists: true,
}
for _, onColumn := range indexOnColumns {
err := tbl.CreateIndex(onColumn)
if err != nil {
return err
}
}
db.tables[tablename] = tbl
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package ramdb
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDatabase_From(t *testing.T) {
tests := []struct {
test string
expectFunc func(t *testing.T, db *database)
expectedExists bool
}{
{
test: "it should set exist false if table not found",
expectFunc: func(t *testing.T, db *database) {},
expectedExists: false,
},
{
test: "it should set exist true if table is found",
expectFunc: func(t *testing.T, db *database) {
db.CreateTable("test_table")
},
expectedExists: true,
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
db := NewDatabase()
tc.expectFunc(t, db)
table := db.From("test_table")
assert.Equal(t, table.exists, tc.expectedExists)
})
}
}
func TestDatabase_CreateTable(t *testing.T) {
tests := []struct {
test string
expectFunc func(t *testing.T, db *database)
expectedError error
}{
{
test: "it should error if the table already exists",
expectFunc: func(t *testing.T, db *database) {
db.CreateTable("test_table")
},
expectedError: ErrTableExists,
},
{
test: "it should not return an error on successful table creation",
expectFunc: func(t *testing.T, db *database) {},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
db := NewDatabase()
tc.expectFunc(t, db)
err := db.CreateTable("test_table")
assert.Equal(t, tc.expectedError, err)
})
}
}
+51
View File
@@ -0,0 +1,51 @@
package ramdb
import (
"crypto/sha256"
"encoding/binary"
"encoding/json"
"github.com/google/btree"
)
type record struct {
serialized []byte
keyColumn string
key string
id uint64
}
// NewRecord returns a pointer to a record populated with data, key, and a hash of the key used for ordering in the tree.
func NewRecord(key, keyColumn string, data interface{}) (*record, error) {
serialized, err := json.Marshal(data)
if err != nil {
return nil, err
}
var r record
r.serialized = serialized
r.keyColumn = keyColumn
r.key = key
r.id = keyHash(key)
return &r, nil
}
func keyHash(s string) uint64 {
h := sha256.New()
h.Write([]byte(s))
sum := h.Sum(nil)
return binary.BigEndian.Uint64(sum)
}
// Deserialize unmarshals the serialized data into `into`.
func (r *record) Deserialize(into interface{}) error {
err := json.Unmarshal(r.serialized, &into)
return err
}
// Less is used to order items and for looking up records in the tree.
func (r *record) Less(than btree.Item) bool {
re := than.(*record)
return r.id < re.id
}
+114
View File
@@ -0,0 +1,114 @@
package ramdb
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRecord_NewRecord(t *testing.T) {
tests := []struct {
test string
key string
keyColumn string
data interface{}
expectedRecord *record
expectedError string
}{
{
test: "it should error if json serialization fails",
key: "test-record",
keyColumn: "test-column",
data: map[string]interface{}{
"error": make(chan int),
},
expectedError: "json: unsupported type: chan int",
},
{
test: "it should return a valid record when successful",
key: "test-record",
keyColumn: "test-column",
data: struct{}{},
expectedRecord: &record{
serialized: []byte("{}"),
key: "test-record",
keyColumn: "test-column",
id: 0x267fc212f178ef79,
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
r, err := NewRecord(tc.key, tc.keyColumn, tc.data)
assert.Equal(t, tc.expectedRecord, r)
if tc.expectedError != "" {
assert.Equal(t, tc.expectedError, err.Error())
}
})
}
}
func TestRecord_keyHash(t *testing.T) {
tests := []struct {
test string
input string
output uint64
}{
{
test: "it should hash the input deterministically",
input: "test string",
output: 0xd5579c46dfcc7f18,
},
{
test: "it should produce an entirely different value with a small change to input",
input: "test string.",
output: 0x84083c0b244440c0,
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
result := keyHash(tc.input)
assert.Equal(t, tc.output, result)
})
}
}
func TestRecord_Deserialize(t *testing.T) {
tests := []struct {
test string
into struct{ Key string }
expectedInto struct{ Key string }
expectedError error
}{
{
test: "it should unmarshal record data into into",
into: struct {
Key string
}{},
expectedInto: struct {
Key string
}{
Key: "test string",
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
rec, err := NewRecord("", "", struct{ Key string }{Key: "test string"})
if err != nil {
t.Error(err)
}
into := tc.into
err = rec.Deserialize(&into)
assert.Equal(t, tc.expectedInto, into)
assert.Equal(t, tc.expectedError, err)
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package ramdb
import (
"sync"
"github.com/google/btree"
)
type table struct {
exists bool
mutex *sync.Mutex
indexes map[string]*index
}
// CreateIndex creates an index for onColumn.
func (t *table) CreateIndex(column string) error {
if column == "" {
return ErrInvalidIndex
}
if _, found := t.indexes[column]; found {
return ErrIndexExists
}
idx := &index{
tree: btree.New(5),
column: column,
table: t,
}
t.mutex.Lock()
defer t.mutex.Unlock()
t.indexes[column] = idx
return nil
}
// HasIndex returns true if an index exists for the column and false if it does not.
func (t *table) HasIndex(column string) bool {
if _, found := t.indexes[column]; found {
return true
}
return false
}
+82
View File
@@ -0,0 +1,82 @@
package ramdb
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTable_NewIndex(t *testing.T) {
tests := []struct {
test string
table table
onColumn string
expectedError error
}{
{
test: "it should return ErrInvalidIndex if onColumn is empty",
expectedError: ErrInvalidIndex,
},
{
test: "it should return ErrIndexExists if an index exists for onColumn",
table: table{indexes: map[string]*index{
"test_column": &index{},
}},
onColumn: "test_column",
expectedError: ErrIndexExists,
},
{
test: "it should create an index successfully",
table: table{
indexes: make(map[string]*index),
},
onColumn: "test_column",
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
err := tc.table.CreateIndex(tc.onColumn)
assert.Equal(t, tc.expectedError, err)
})
}
}
func TestTable_HasIndex(t *testing.T) {
tests := []struct {
test string
tableConfig func() *table
expectedHas bool
}{
{
test: "it should return true if index exists",
tableConfig: func() *table {
return &table{
indexes: map[string]*index{
"test_column": &index{},
},
}
},
expectedHas: true,
},
{
test: "it should return false if index does not exist",
tableConfig: func() *table {
return &table{
indexes: make(map[string]*index),
}
},
},
}
for _, tc := range tests {
t.Run(tc.test, func(t *testing.T) {
tbl := tc.tableConfig()
has := tbl.HasIndex("test_column")
assert.Equal(t, tc.expectedHas, has)
})
}
}