From b66f5ebe67248f86e8d1c207ebdf448854b97dc7 Mon Sep 17 00:00:00 2001 From: David Lick Date: Sat, 19 Jun 2021 17:47:36 -0400 Subject: [PATCH 1/2] Implement an in-memory databasewqa --- Makefile | 8 +- go.mod | 3 + go.sum | 37 +++++ pkg/ramdb/README.md | 44 +++++ pkg/ramdb/commands.go | 93 +++++++++++ pkg/ramdb/commands_test.go | 318 +++++++++++++++++++++++++++++++++++++ pkg/ramdb/errors.go | 15 ++ pkg/ramdb/indexes.go | 9 ++ pkg/ramdb/ram.go | 43 +++++ pkg/ramdb/ram_test.go | 70 ++++++++ pkg/ramdb/record.go | 51 ++++++ pkg/ramdb/record_test.go | 114 +++++++++++++ pkg/ramdb/tables.go | 45 ++++++ pkg/ramdb/tables_test.go | 82 ++++++++++ 14 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 pkg/ramdb/README.md create mode 100644 pkg/ramdb/commands.go create mode 100644 pkg/ramdb/commands_test.go create mode 100644 pkg/ramdb/errors.go create mode 100644 pkg/ramdb/indexes.go create mode 100644 pkg/ramdb/ram.go create mode 100644 pkg/ramdb/ram_test.go create mode 100644 pkg/ramdb/record.go create mode 100644 pkg/ramdb/record_test.go create mode 100644 pkg/ramdb/tables.go create mode 100644 pkg/ramdb/tables_test.go diff --git a/Makefile b/Makefile index 16844f2..c4177a9 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,12 @@ integration-test: all-test: go test ./... +test-coverage: + go test -cover ./... + +cover-report: + go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out + build: go build -o supermarket-api cmd/api/*.go @@ -17,4 +23,4 @@ run: go run cmd/api/*.go clean: - rm ./supermarket-api + rm ./supermarket-api ./coverage.out diff --git a/go.mod b/go.mod index cdcc367..968bc5c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.16 require ( github.com/go-chi/chi v1.5.4 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/btree v1.0.1 // indirect github.com/joho/godotenv v1.3.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect + github.com/stretchr/testify v1.7.0 // indirect ) diff --git a/go.sum b/go.sum index e4249aa..f48f12d 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,50 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/ramdb/README.md b/pkg/ramdb/README.md new file mode 100644 index 0000000..b35075b --- /dev/null +++ b/pkg/ramdb/README.md @@ -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} +``` diff --git a/pkg/ramdb/commands.go b/pkg/ramdb/commands.go new file mode 100644 index 0000000..7c1db15 --- /dev/null +++ b/pkg/ramdb/commands.go @@ -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 +} diff --git a/pkg/ramdb/commands_test.go b/pkg/ramdb/commands_test.go new file mode 100644 index 0000000..a589358 --- /dev/null +++ b/pkg/ramdb/commands_test.go @@ -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) + }) + } +} diff --git a/pkg/ramdb/errors.go b/pkg/ramdb/errors.go new file mode 100644 index 0000000..04bfcb8 --- /dev/null +++ b/pkg/ramdb/errors.go @@ -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") +) diff --git a/pkg/ramdb/indexes.go b/pkg/ramdb/indexes.go new file mode 100644 index 0000000..4615802 --- /dev/null +++ b/pkg/ramdb/indexes.go @@ -0,0 +1,9 @@ +package ramdb + +import "github.com/google/btree" + +type index struct { + tree *btree.BTree + column string + table *table +} diff --git a/pkg/ramdb/ram.go b/pkg/ramdb/ram.go new file mode 100644 index 0000000..ec7c064 --- /dev/null +++ b/pkg/ramdb/ram.go @@ -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 +} diff --git a/pkg/ramdb/ram_test.go b/pkg/ramdb/ram_test.go new file mode 100644 index 0000000..12746f0 --- /dev/null +++ b/pkg/ramdb/ram_test.go @@ -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) + }) + } +} diff --git a/pkg/ramdb/record.go b/pkg/ramdb/record.go new file mode 100644 index 0000000..aeb6c47 --- /dev/null +++ b/pkg/ramdb/record.go @@ -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 +} diff --git a/pkg/ramdb/record_test.go b/pkg/ramdb/record_test.go new file mode 100644 index 0000000..9bab553 --- /dev/null +++ b/pkg/ramdb/record_test.go @@ -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) + }) + } +} diff --git a/pkg/ramdb/tables.go b/pkg/ramdb/tables.go new file mode 100644 index 0000000..a42bfeb --- /dev/null +++ b/pkg/ramdb/tables.go @@ -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 +} diff --git a/pkg/ramdb/tables_test.go b/pkg/ramdb/tables_test.go new file mode 100644 index 0000000..8ad1031 --- /dev/null +++ b/pkg/ramdb/tables_test.go @@ -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) + }) + } +} -- 2.54.0 From e1568cd759882caf04bbf78f1b9c222d75d308f6 Mon Sep 17 00:00:00 2001 From: David Lick Date: Sat, 19 Jun 2021 17:55:17 -0400 Subject: [PATCH 2/2] Add mutex to table --- pkg/ramdb/tables_test.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/ramdb/tables_test.go b/pkg/ramdb/tables_test.go index 8ad1031..f6b46e9 100644 --- a/pkg/ramdb/tables_test.go +++ b/pkg/ramdb/tables_test.go @@ -1,42 +1,46 @@ package ramdb import ( + "sync" "testing" "github.com/stretchr/testify/assert" ) -func TestTable_NewIndex(t *testing.T) { +func TestTable_CreateIndex(t *testing.T) { tests := []struct { test string table table - onColumn string + column string expectedError error }{ { - test: "it should return ErrInvalidIndex if onColumn is empty", + test: "it should return ErrInvalidIndex if column 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", + test: "it should return ErrIndexExists if an index exists for column", + table: table{ + mutex: &sync.Mutex{}, + indexes: map[string]*index{ + "test_column": &index{}, + }}, + column: "test_column", expectedError: ErrIndexExists, }, { test: "it should create an index successfully", table: table{ + mutex: &sync.Mutex{}, indexes: make(map[string]*index), }, - onColumn: "test_column", + column: "test_column", }, } for _, tc := range tests { t.Run(tc.test, func(t *testing.T) { - err := tc.table.CreateIndex(tc.onColumn) + err := tc.table.CreateIndex(tc.column) assert.Equal(t, tc.expectedError, err) }) -- 2.54.0