From 4710d08da5e6fbe1ed1fdc83007554fbf7adf769 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Wed, 5 Feb 2025 21:26:02 +0100 Subject: [PATCH 01/17] chore: add `p/jeronimoalbi/datastore` package --- examples/gno.land/p/jeronimoalbi/datastore/gno.mod | 1 + 1 file changed, 1 insertion(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/gno.mod diff --git a/examples/gno.land/p/jeronimoalbi/datastore/gno.mod b/examples/gno.land/p/jeronimoalbi/datastore/gno.mod new file mode 100644 index 00000000000..dad2cc4b1ac --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/gno.mod @@ -0,0 +1 @@ +module gno.land/p/jeronimoalbi/datastore From 18730d558974fd794b47062b7ca31ebc71727ec7 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Wed, 5 Feb 2025 21:27:33 +0100 Subject: [PATCH 02/17] feat: add schema definition support --- .../p/jeronimoalbi/datastore/schema.gno | 127 ++++++++++ .../jeronimoalbi/datastore/schema_options.gno | 32 +++ .../p/jeronimoalbi/datastore/schema_test.gno | 216 ++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/schema.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno new file mode 100644 index 00000000000..c123e54f106 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno @@ -0,0 +1,127 @@ +package datastore + +import ( + "encoding/binary" + "strings" + + "gno.land/p/demo/avl" + "gno.land/p/demo/avl/list" +) + +// TODO: Support versioning + +type Schema struct { + name string + strict bool + fields list.List // int(field index) -> string(field name) + defaults avl.Tree // string(field index) -> interface{} +} + +func NewSchema(name string, options ...SchemaOption) *Schema { + s := &Schema{name: name} + for _, apply := range options { + apply(s) + } + return s +} + +func (s Schema) Name() string { + return s.name +} + +func (s Schema) Fields() []string { + fields := make([]string, s.fields.Len()) + s.fields.ForEach(func(i int, v interface{}) bool { + fields[i] = v.(string) + return false + }) + return fields +} + +func (s Schema) Size() int { + return s.fields.Len() +} + +func (s Schema) IsStrict() bool { + return s.strict +} + +func (s Schema) HasField(name string) bool { + return s.GetFieldIndex(name) >= 0 +} + +func (s *Schema) AddField(name string, defaultValue interface{}) (index int, added bool) { + if s.HasField(name) { + return -1, false + } + + s.fields.Append(name) + index = s.fields.Len() - 1 + if defaultValue != nil { + key := castIntToKey(index) + s.defaults.Set(key, defaultValue) + } + return index, true +} + +func (s Schema) GetFieldIndex(name string) int { + index := -1 + s.fields.ForEach(func(i int, v interface{}) bool { + if name != v.(string) { + return false + } + + index = i + return true + }) + return index +} + +func (s Schema) GetFieldName(index int) (name string, found bool) { + v := s.fields.Get(index) + if v == nil { + return "", false + } + return v.(string), true +} + +func (s Schema) GetDefault(name string) (value interface{}, found bool) { + i := s.GetFieldIndex(name) + if i == -1 { + return nil, false + } + return s.GetDefaultByIndex(i) +} + +func (s Schema) GetDefaultByIndex(index int) (value interface{}, found bool) { + key := castIntToKey(index) + v, found := s.defaults.Get(key) + if !found { + return nil, false + } + + if fn, ok := v.(func() interface{}); ok { + return fn(), true + } + return v, true +} + +func (s *Schema) RenameField(name, newName string) (renamed bool) { + if s.HasField(newName) { + return false + } + + i := s.GetFieldIndex(name) + if i == -1 { + return false + } + + s.fields.Set(i, newName) + return true +} + +func castIntToKey(i int) string { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(i)) + return string(buf) +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno new file mode 100644 index 00000000000..99595f65eb8 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno @@ -0,0 +1,32 @@ +package datastore + +import "strings" + +type SchemaOption func(*Schema) + +func WithField(name string) SchemaOption { + return func(s *Schema) { + name = strings.TrimSpace(name) + if name != "" { + s.fields.Append(name) + } + } +} + +func WithDefaultField(name string, value interface{}) SchemaOption { + return func(s *Schema) { + name = strings.TrimSpace(name) + if name != "" { + s.fields.Append(name) + + key := castIntToKey(s.fields.Len() - 1) + s.defaults.Set(key, value) + } + } +} + +func Strict() SchemaOption { + return func(s *Schema) { + s.strict = true + } +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno new file mode 100644 index 00000000000..77ccface640 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno @@ -0,0 +1,216 @@ +package datastore + +import ( + "fmt" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestSchemaDefaults(t *testing.T) { + cases := []struct { + name string + options []SchemaOption + fields []string + strict bool + }{ + { + name: "default", + }, + { + name: "strict", + options: []SchemaOption{Strict()}, + strict: true, + }, + { + name: "with fields", + options: []SchemaOption{ + WithField("foo"), + WithField("bar"), + WithDefaultField("baz", 42), + }, + fields: []string{"foo", "bar", "baz"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Act + s := NewSchema("Foo", tc.options...) + + // Assert + uassert.Equal(t, "Foo", s.Name()) + uassert.Equal(t, tc.strict, s.IsStrict()) + urequire.Equal(t, len(tc.fields), s.Size(), "field count") + + for i, name := range s.Fields() { + uassert.Equal(t, tc.fields[i], name) + uassert.True(t, s.HasField(name)) + } + }) + } +} + +func TestSchemaAddField(t *testing.T) { + cases := []struct { + name string + options []SchemaOption + fieldName string + fieldIndex int + fields []string + success bool + }{ + { + name: "new only field", + fieldName: "foo", + fieldIndex: 0, + fields: []string{"foo"}, + success: true, + }, + { + name: "new existing fields", + options: []SchemaOption{ + WithField("foo"), + WithField("bar"), + }, + fieldName: "baz", + fieldIndex: 2, + fields: []string{"foo", "bar", "baz"}, + success: true, + }, + { + name: "duplicated field", + options: []SchemaOption{WithField("foo")}, + fieldName: "foo", + fieldIndex: -1, + fields: []string{"foo"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + s := NewSchema("Foo", tc.options...) + + // Act + index, added := s.AddField(tc.fieldName, nil) + + // Assert + if tc.success { + uassert.Equal(t, tc.fieldIndex, index) + uassert.True(t, added) + } else { + uassert.Equal(t, -1, index) + uassert.False(t, added) + } + + urequire.Equal(t, len(tc.fields), s.Size(), "field count") + + for i, name := range s.Fields() { + uassert.Equal(t, tc.fields[i], name) + uassert.True(t, s.HasField(name)) + } + }) + } +} + +func TestSchemaGetFieldIndex(t *testing.T) { + s := NewSchema("Foo") + s.AddField("foo", nil) + s.AddField("bar", nil) + s.AddField("baz", nil) + + uassert.Equal(t, 0, s.GetFieldIndex("foo")) + uassert.Equal(t, 1, s.GetFieldIndex("bar")) + uassert.Equal(t, 2, s.GetFieldIndex("baz")) + + uassert.Equal(t, -1, s.GetFieldIndex("")) + uassert.Equal(t, -1, s.GetFieldIndex("unknown")) +} + +func TestSchemaGetFieldName(t *testing.T) { + s := NewSchema("Foo") + s.AddField("foo", nil) + s.AddField("bar", nil) + s.AddField("baz", nil) + + name, found := s.GetFieldName(0) + uassert.Equal(t, "foo", name) + uassert.True(t, found) + + name, found = s.GetFieldName(1) + uassert.Equal(t, "bar", name) + uassert.True(t, found) + + name, found = s.GetFieldName(2) + uassert.Equal(t, "baz", name) + uassert.True(t, found) + + name, found = s.GetFieldName(404) + uassert.Equal(t, "", name) + uassert.False(t, found) +} + +func TestSchemaGetDefault(t *testing.T) { + s := NewSchema("Foo") + s.AddField("foo", nil) + s.AddField("bar", 42) + + _, found := s.GetDefault("foo") + uassert.False(t, found) + + v, found := s.GetDefault("bar") + uassert.True(t, found) + + got, ok := v.(int) + urequire.True(t, ok, "default field value") + uassert.Equal(t, 42, got) +} + +func TestSchemaGetDefaultByIndex(t *testing.T) { + s := NewSchema("Foo") + s.AddField("foo", nil) + s.AddField("bar", 42) + + _, found := s.GetDefaultByIndex(0) + uassert.False(t, found) + + _, found = s.GetDefaultByIndex(404) + uassert.False(t, found) + + v, found := s.GetDefaultByIndex(1) + uassert.True(t, found) + + got, ok := v.(int) + urequire.True(t, ok, "default field value") + uassert.Equal(t, 42, got) +} + +func TestSchemaRenameField(t *testing.T) { + s := NewSchema("Foo") + s.AddField("foo", nil) + s.AddField("bar", nil) + + renamed := s.RenameField("foo", "bar") + uassert.False(t, renamed) + + renamed = s.RenameField("", "baz") + uassert.False(t, renamed) + + renamed = s.RenameField("foo", "") + uassert.True(t, renamed) + + renamed = s.RenameField("", "foo") + uassert.True(t, renamed) + + renamed = s.RenameField("foo", "foobar") + uassert.True(t, renamed) + + urequire.Equal(t, 2, s.Size(), "field count") + fields := []string{"foobar", "bar"} + for i, name := range s.Fields() { + uassert.Equal(t, fields[i], name) + uassert.True(t, s.HasField(name)) + } +} From bcbb62a908c788e615f8c58ac5907d653d84dfb8 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Wed, 5 Feb 2025 21:29:23 +0100 Subject: [PATCH 03/17] chore: remove unused import --- examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno index 77ccface640..832956ead74 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno @@ -1,7 +1,6 @@ package datastore import ( - "fmt" "testing" "gno.land/p/demo/uassert" From 8aaf1d5907fd8ed17951e426786b4a701aaec257 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Wed, 5 Feb 2025 21:31:03 +0100 Subject: [PATCH 04/17] test: rename schema unit test --- examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno index 832956ead74..4b275d683c4 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno @@ -7,7 +7,7 @@ import ( "gno.land/p/demo/urequire" ) -func TestSchemaDefaults(t *testing.T) { +func TestSchemaNew(t *testing.T) { cases := []struct { name string options []SchemaOption From 3a111cbb9c620982a04a0ee23650bef2ae490d99 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 11:37:33 +0100 Subject: [PATCH 05/17] feat: add collection index wrapper This adds typing support for record collection indexing functions. It also enforces sparse indexes which are required to make sure they work when record fields are removed or renamed. --- .../p/jeronimoalbi/datastore/indexes.gno | 65 ++++++++++++ .../p/jeronimoalbi/datastore/indexes_test.gno | 99 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/indexes.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno b/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno new file mode 100644 index 00000000000..d2e36e2a33b --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno @@ -0,0 +1,65 @@ +package datastore + +import ( + "gno.land/p/moul/collection" +) + +const DefaultIndexOptions = collection.DefaultIndex | collection.SparseIndex + +type ( + IndexFn func(Record) string + + IndexMultiValueFn func(Record) []string + + Index struct { + name string + options collection.IndexOption + fn interface{} + } +) + +func NewIndex(name string, fn IndexFn) Index { + return Index{ + name: name, + options: DefaultIndexOptions, + fn: func(v interface{}) string { + return fn(v.(Record)) + }, + } +} + +func NewMultiValueIndex(name string, fn IndexMultiValueFn) Index { + return Index{ + name: name, + options: DefaultIndexOptions, + fn: func(v interface{}) []string { + return fn(v.(Record)) + }, + } +} + +func (idx Index) Name() string { + return idx.name +} + +func (idx Index) Options() collection.IndexOption { + return idx.options +} + +func (idx Index) Func() interface{} { + return idx.fn +} + +func (idx Index) Unique() Index { + if idx.options&collection.UniqueIndex == 0 { + idx.options |= collection.UniqueIndex + } + return idx +} + +func (idx Index) CaseInsensitive() Index { + if idx.options&collection.CaseInsensitiveIndex == 0 { + idx.options |= collection.CaseInsensitiveIndex + } + return idx +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno new file mode 100644 index 00000000000..98177ddbe43 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno @@ -0,0 +1,99 @@ +package datastore + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/moul/collection" +) + +func TestNewIndex(t *testing.T) { + cases := []struct { + name string + options collection.IndexOption + setup func(Index) Index + }{ + { + name: "default", + options: DefaultIndexOptions, + }, + { + name: "unique", + options: DefaultIndexOptions | collection.UniqueIndex, + setup: func(idx Index) Index { return idx.Unique() }, + }, + { + name: "case insensitive", + options: DefaultIndexOptions | collection.CaseInsensitiveIndex, + setup: func(idx Index) Index { return idx.CaseInsensitive() }, + }, + { + name: "unique case insensitive", + options: DefaultIndexOptions | collection.CaseInsensitiveIndex | collection.UniqueIndex, + setup: func(idx Index) Index { return idx.CaseInsensitive().Unique() }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + name := "foo" + idx := NewIndex(name, func(Record) string { return "" }) + + if tc.setup != nil { + idx = tc.setup(idx) + } + + uassert.Equal(t, name, idx.Name()) + uassert.Equal(t, uint64(tc.options), uint64(idx.Options())) + + _, ok := idx.Func().(func(interface{}) string) + uassert.True(t, ok) + }) + } +} + +func TestNewMultiIndex(t *testing.T) { + cases := []struct { + name string + options collection.IndexOption + setup func(Index) Index + }{ + { + name: "default", + options: DefaultIndexOptions, + }, + { + name: "unique", + options: DefaultIndexOptions | collection.UniqueIndex, + setup: func(idx Index) Index { return idx.Unique() }, + }, + { + name: "case insensitive", + options: DefaultIndexOptions | collection.CaseInsensitiveIndex, + setup: func(idx Index) Index { return idx.CaseInsensitive() }, + }, + { + name: "unique case insensitive", + options: DefaultIndexOptions | collection.CaseInsensitiveIndex | collection.UniqueIndex, + setup: func(idx Index) Index { return idx.CaseInsensitive().Unique() }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + name := "foo" + idx := NewMultiValueIndex(name, func(Record) []string { return nil }) + + if tc.setup != nil { + idx = tc.setup(idx) + } + + uassert.Equal(t, name, idx.Name()) + uassert.Equal(t, uint64(tc.options), uint64(idx.Options())) + + _, ok := idx.Func().(func(interface{}) []string) + uassert.True(t, ok) + }) + } +} From 96211aa7decadcbfcc1726d1cfed5a579abdd4e8 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 16:14:50 +0100 Subject: [PATCH 06/17] feat: add storage and record support --- .../p/jeronimoalbi/datastore/record.gno | 97 +++++++++++ .../p/jeronimoalbi/datastore/record_test.gno | 162 ++++++++++++++++++ .../p/jeronimoalbi/datastore/storage.gno | 101 +++++++++++ .../datastore/storage_options.gno | 24 +++ .../p/jeronimoalbi/datastore/storage_test.gno | 44 +++++ 5 files changed, 428 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/record.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/record_test.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/storage.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno new file mode 100644 index 00000000000..2c341bc0d0f --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -0,0 +1,97 @@ +package datastore + +import ( + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/moul/collection" +) + +var ErrUndefinedField = errors.New("undefined field") + +type ( + ReadOnlyRecord interface { + ID() uint64 + Type() string + Fields() []string + IsEmpty() bool + HasField(name string) bool + Get(field string) (value interface{}, found bool) + MustGet(field string) interface{} + } + + Record interface { + ReadOnlyRecord + + Set(field string, value interface{}) error + Save() bool + } +) + +type record struct { + id uint64 + schema *Schema + collection *collection.Collection + values avl.Tree // string(field index) -> interface{} +} + +func (r record) ID() uint64 { + return r.id +} + +func (r record) Type() string { + return r.schema.Name() +} + +func (r record) Fields() []string { + return r.schema.Fields() +} + +func (r record) IsEmpty() bool { + return r.values.Size() == 0 +} + +func (r record) HasField(name string) bool { + return r.schema.HasField(name) +} + +func (r *record) Set(field string, value interface{}) error { + i := r.schema.GetFieldIndex(field) + if i == -1 { + if r.schema.IsStrict() { + return ErrUndefinedField + } + + i, _ = r.schema.AddField(field, nil) + } + + key := castIntToKey(i) + r.values.Set(key, value) + return nil +} + +func (r record) Get(field string) (value interface{}, found bool) { + i := r.schema.GetFieldIndex(field) + if i == -1 { + return nil, false + } + + key := castIntToKey(i) + return r.values.Get(key) +} + +func (r record) MustGet(field string) interface{} { + v, found := r.Get(field) + if !found { + panic("field not found: " + field) + } + return v +} + +func (r *record) Save() bool { + if r.id == 0 { + r.id = r.collection.Set(r) + return r.id != 0 + } + return r.collection.Update(r.id, r) +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno new file mode 100644 index 00000000000..a1cec532d4c --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -0,0 +1,162 @@ +package datastore + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +var _ Record = (*record)(nil) + +func TestRecordDefaults(t *testing.T) { + storage := NewStorage("foo") + r := storage.NewRecord() + + uassert.Equal(t, uint64(0), r.ID()) + uassert.Equal(t, "Foo", r.Type()) + uassert.Equal(t, nil, r.Fields()) + uassert.True(t, r.IsEmpty()) +} + +func TestRecordHasField(t *testing.T) { + storage := NewStorage("foo") + r := storage.NewRecord() + + s := storage.Schema() + s.AddField("foo", nil) + + uassert.True(t, r.HasField("foo")) + uassert.False(t, r.HasField("undefined")) +} + +func TestRecordSet(t *testing.T) { + cases := []struct { + name string + options []SchemaOption + field string + fieldsCount int + value int + err error + }{ + { + name: "first new field", + field: "test", + fieldsCount: 1, + value: 42, + }, + { + name: "new extra field", + options: []SchemaOption{ + WithField("foo"), + WithField("bar"), + }, + field: "test", + fieldsCount: 3, + value: 42, + }, + { + name: "existing field", + options: []SchemaOption{ + WithField("test"), + }, + field: "test", + fieldsCount: 1, + value: 42, + }, + { + name: "undefined field", + options: []SchemaOption{Strict()}, + field: "test", + fieldsCount: 1, + value: 42, + err: ErrUndefinedField, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := NewSchema("Foo", tc.options...) + storage := NewStorage("foo", WithSchema(s)) + r := storage.NewRecord() + + err := r.Set(tc.field, tc.value) + + if tc.err != nil { + urequire.ErrorIs(t, err, tc.err) + return + } + + urequire.NoError(t, err) + uassert.True(t, r.HasField("test")) + uassert.False(t, r.IsEmpty()) + uassert.Equal(t, tc.fieldsCount, len(r.Fields())) + }) + } +} + +func TestRecordGet(t *testing.T) { + storage := NewStorage("foo") + r := storage.NewRecord() + r.Set("foo", "bar") + r.Set("test", 42) + + v, found := r.Get("test") + urequire.True(t, found, "get setted value") + + got, ok := v.(int) + urequire.True(t, ok, "setted value type") + uassert.Equal(t, 42, got) + + _, found = r.Get("unknown") + uassert.False(t, found) +} + +func TestRecordSave(t *testing.T) { + fieldName := "name" + nameIdx := NewIndex("name", func(r Record) string { + return r.MustGet(fieldName).(string) + }).Unique().CaseInsensitive() + + storage := NewStorage("foo", WithDefaultIndex(nameIdx)) + cases := []struct { + name string + id uint64 + fieldValue string + setup func(Storage) Record + }{ + { + name: "create first record", + id: 1, + fieldValue: "foo", + setup: func(s Storage) Record { return s.NewRecord() }, + }, + { + name: "create second record", + id: 2, + fieldValue: "bar", + setup: func(s Storage) Record { return s.NewRecord() }, + }, + { + name: "update second record", + id: 2, + fieldValue: "baz", + setup: func(s Storage) Record { return storage.GetFirst("bar") }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := tc.setup(storage) + r.Set(fieldName, tc.fieldValue) + + urequire.Equal(t, nil, storage.GetFirst(tc.fieldValue), "record not found") + urequire.True(t, r.Save(), "save success") + + r = storage.GetFirst(tc.fieldValue) + urequire.NotEqual(t, nil, r, "record found") + uassert.Equal(t, tc.id, r.ID()) + uassert.Equal(t, tc.fieldValue, r.MustGet(fieldName)) + }) + } +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno new file mode 100644 index 00000000000..953fe1d8656 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -0,0 +1,101 @@ +package datastore + +import ( + "strings" + + "gno.land/p/moul/collection" +) + +func NewStorage(name string, options ...StorageOption) Storage { + s := Storage{ + name: name, + currentIndex: collection.IDIndex, + collection: collection.New(), + schema: NewSchema(strings.Title(name)), + } + + for _, apply := range options { + apply(&s) + } + return s +} + +type Storage struct { + name string + currentIndex string + collection *collection.Collection + schema *Schema +} + +func (s Storage) Name() string { + return s.name +} + +func (s Storage) CurrentIndex() string { + return s.currentIndex +} + +func (s Storage) Collection() *collection.Collection { + return s.collection +} + +func (s Storage) Schema() *Schema { + return s.schema +} + +func (s Storage) NewRecord() Record { + r := &record{ + schema: s.schema, + collection: s.collection, + } + + // Assign default record values if the schema defines them + for i, name := range s.schema.Fields() { + if v, found := s.schema.GetDefaultByIndex(i); found { + r.Set(name, v) + } + } + return r +} + +func (s Storage) WithIndex(name string) Storage { + s.currentIndex = name + return s +} + +func (s Storage) ForEach(fn func(Record) bool) bool { + idx := s.collection.GetIndex(s.currentIndex) + if idx == nil { + return false + } + + return idx.Iterate("", "", func(_ string, v interface{}) bool { + return fn(v.(Record)) + }) +} + +func (s Storage) Get(key string) []Record { + var ( + records []Record + iter = s.collection.Get(s.currentIndex, key) + ) + for iter.Next() { + records = append(records, iter.Value().Obj.(Record)) + } + return records +} + +func (s Storage) GetFirst(key string) Record { + var ( + record Record + iter = s.collection.Get(s.currentIndex, key) + ) + if iter.Next() { + record = iter.Value().Obj.(Record) + } + return record +} + +func (s Storage) Delete(id uint64) bool { + return s.collection.Delete(id) +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno new file mode 100644 index 00000000000..1d4404a5663 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno @@ -0,0 +1,24 @@ +package datastore + +type StorageOption func(*Storage) + +func WithSchema(s *Schema) StorageOption { + return func(st *Storage) { + if s != nil { + st.schema = s + } + } +} + +func WithIndex(i Index) StorageOption { + return func(st *Storage) { + st.collection.AddIndex(i.name, i.fn, i.options) + } +} + +func WithDefaultIndex(i Index) StorageOption { + return func(st *Storage) { + st.currentIndex = i.Name() + st.collection.AddIndex(i.name, i.fn, i.options) + } +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno new file mode 100644 index 00000000000..903ea76b050 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -0,0 +1,44 @@ +package datastore + +import ( + "strings" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/moul/collection" +) + +func TestStorageDefaults(t *testing.T) { + name := "foo" + storage := NewStorage(name) + + uassert.Equal(t, name, storage.Name()) + uassert.Equal(t, collection.IDIndex, storage.CurrentIndex()) + uassert.NotEqual(t, nil, storage.Collection()) + + s := storage.Schema() + uassert.NotEqual(t, nil, s) + uassert.Equal(t, strings.Title(name), s.Name()) +} + +func TestStorageNewRecord(t *testing.T) { + field := "status" + defaultValue := "testing" + s := NewSchema("Foo", WithDefaultField(field, defaultValue)) + storage := NewStorage("foo", WithSchema(s)) + + r := storage.NewRecord() + urequire.NotEqual(t, nil, r, "new record is not nil") + uassert.Equal(t, uint64(0), r.ID()) + uassert.Equal(t, storage.Schema().Name(), r.Type()) + + v, found := r.Get(field) + urequire.True(t, found, "default value found") + + got, ok := v.(string) + urequire.True(t, ok, "default value type") + uassert.Equal(t, defaultValue, got) +} + +// TODO: Finish and improve Storage unit tests From 57343aa4dc785d2d8c49071a8981322832ddc60e Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 16:15:23 +0100 Subject: [PATCH 07/17] chore: remove unit test comments --- examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno index 4b275d683c4..f764d85dba2 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno @@ -35,10 +35,8 @@ func TestSchemaNew(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - // Act s := NewSchema("Foo", tc.options...) - // Assert uassert.Equal(t, "Foo", s.Name()) uassert.Equal(t, tc.strict, s.IsStrict()) urequire.Equal(t, len(tc.fields), s.Size(), "field count") @@ -89,13 +87,10 @@ func TestSchemaAddField(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - // Arrange s := NewSchema("Foo", tc.options...) - // Act index, added := s.AddField(tc.fieldName, nil) - // Assert if tc.success { uassert.Equal(t, tc.fieldIndex, index) uassert.True(t, added) From 305f7adb004569010650d42a66b54746e025cc0c Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 16:38:11 +0100 Subject: [PATCH 08/17] feat: support storage size --- .../p/jeronimoalbi/datastore/record_test.gno | 37 +++++++++++-------- .../p/jeronimoalbi/datastore/storage.gno | 4 ++ .../p/jeronimoalbi/datastore/storage_test.gno | 1 + 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno index a1cec532d4c..6084cb80299 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -120,28 +120,32 @@ func TestRecordSave(t *testing.T) { storage := NewStorage("foo", WithDefaultIndex(nameIdx)) cases := []struct { - name string - id uint64 - fieldValue string - setup func(Storage) Record + name string + id uint64 + fieldValue string + storageSize int + setup func(Storage) Record }{ { - name: "create first record", - id: 1, - fieldValue: "foo", - setup: func(s Storage) Record { return s.NewRecord() }, + name: "create first record", + id: 1, + fieldValue: "foo", + storageSize: 1, + setup: func(s Storage) Record { return s.NewRecord() }, }, { - name: "create second record", - id: 2, - fieldValue: "bar", - setup: func(s Storage) Record { return s.NewRecord() }, + name: "create second record", + id: 2, + fieldValue: "bar", + storageSize: 2, + setup: func(s Storage) Record { return s.NewRecord() }, }, { - name: "update second record", - id: 2, - fieldValue: "baz", - setup: func(s Storage) Record { return storage.GetFirst("bar") }, + name: "update second record", + id: 2, + fieldValue: "baz", + storageSize: 2, + setup: func(s Storage) Record { return storage.GetFirst("bar") }, }, } @@ -152,6 +156,7 @@ func TestRecordSave(t *testing.T) { urequire.Equal(t, nil, storage.GetFirst(tc.fieldValue), "record not found") urequire.True(t, r.Save(), "save success") + uassert.Equal(t, tc.storageSize, storage.Size()) r = storage.GetFirst(tc.fieldValue) urequire.NotEqual(t, nil, r, "record found") diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index 953fe1d8656..9a3489981a8 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -43,6 +43,10 @@ func (s Storage) Schema() *Schema { return s.schema } +func (s Storage) Size() int { + return s.collection.GetIndex(collection.IDIndex).Size() +} + func (s Storage) NewRecord() Record { r := &record{ schema: s.schema, diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno index 903ea76b050..0b1d1b5aeef 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -16,6 +16,7 @@ func TestStorageDefaults(t *testing.T) { uassert.Equal(t, name, storage.Name()) uassert.Equal(t, collection.IDIndex, storage.CurrentIndex()) uassert.NotEqual(t, nil, storage.Collection()) + uassert.Equal(t, 0, storage.Size()) s := storage.Schema() uassert.NotEqual(t, nil, s) From dfdeef5664c6d565db3b41419e74ca292f09f0e7 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 16:38:41 +0100 Subject: [PATCH 09/17] feat: add datastore support Datastore allows the creation of multiple collection based storages. --- .../p/jeronimoalbi/datastore/datastore.gno | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/datastore.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno new file mode 100644 index 00000000000..ba95c7d1ed7 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno @@ -0,0 +1,36 @@ +package datastore + +import ( + "errors" + + "gno.land/p/demo/avl" +) + +// TODO: Write unit test for Datastore + +var ErrStorageExists = errors.New("a storage with the same name exists") + +type Datastore struct { + storages avl.Tree // string(name) -> *Storage +} + +func (ds *Datastore) CreateStorage(name string, options ...StorageOption) *Storage { + if ds.storages.Has(name) { + return nil + } + + s := NewStorage(name, options...) + ds.storages.Set(name, &s) + return &s +} + +func (ds Datastore) HasStorage(name string) bool { + return ds.storages.Has(name) +} + +func (ds Datastore) GetStorage(name string) *Storage { + if v, found := ds.storages.Get(name); found { + return v.(*Storage) + } + return nil +} From 365b1251c398083f3cab41518491c6fd095ae6d4 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 17:01:40 +0100 Subject: [PATCH 10/17] chore: remove unused import --- examples/gno.land/p/jeronimoalbi/datastore/schema.gno | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno index c123e54f106..4cdc0694fe4 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno @@ -2,7 +2,6 @@ package datastore import ( "encoding/binary" - "strings" "gno.land/p/demo/avl" "gno.land/p/demo/avl/list" From cb6d763a85dad6e19d9699b2e39a7210ea3d9a70 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 17:04:35 +0100 Subject: [PATCH 11/17] chore: remove unused test import --- examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno index 98177ddbe43..8f90a82c85d 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno @@ -4,7 +4,6 @@ import ( "testing" "gno.land/p/demo/uassert" - "gno.land/p/demo/urequire" "gno.land/p/moul/collection" ) From 95cd2109e8c338feacacce0dc465f30092cab610 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Thu, 6 Feb 2025 18:07:21 +0100 Subject: [PATCH 12/17] feat: add key field to record to support search by ID --- .../gno.land/p/jeronimoalbi/datastore/record.gno | 6 ++++++ .../p/jeronimoalbi/datastore/record_test.gno | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno index 2c341bc0d0f..1c92a12ddf4 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -4,6 +4,7 @@ import ( "errors" "gno.land/p/demo/avl" + "gno.land/p/demo/seqid" "gno.land/p/moul/collection" ) @@ -12,6 +13,7 @@ var ErrUndefinedField = errors.New("undefined field") type ( ReadOnlyRecord interface { ID() uint64 + Key() string Type() string Fields() []string IsEmpty() bool @@ -39,6 +41,10 @@ func (r record) ID() uint64 { return r.id } +func (r record) Key() string { + return seqid.ID(r.id).String() +} + func (r record) Type() string { return r.schema.Name() } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno index 6084cb80299..d0e842e222d 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -14,6 +14,7 @@ func TestRecordDefaults(t *testing.T) { r := storage.NewRecord() uassert.Equal(t, uint64(0), r.ID()) + uassert.Equal(t, "0000000", r.Key()) uassert.Equal(t, "Foo", r.Type()) uassert.Equal(t, nil, r.Fields()) uassert.True(t, r.IsEmpty()) @@ -120,15 +121,16 @@ func TestRecordSave(t *testing.T) { storage := NewStorage("foo", WithDefaultIndex(nameIdx)) cases := []struct { - name string - id uint64 - fieldValue string - storageSize int - setup func(Storage) Record + name string + id uint64 + fieldValue, key string + storageSize int + setup func(Storage) Record }{ { name: "create first record", id: 1, + key: "0000001", fieldValue: "foo", storageSize: 1, setup: func(s Storage) Record { return s.NewRecord() }, @@ -136,6 +138,7 @@ func TestRecordSave(t *testing.T) { { name: "create second record", id: 2, + key: "0000002", fieldValue: "bar", storageSize: 2, setup: func(s Storage) Record { return s.NewRecord() }, @@ -143,6 +146,7 @@ func TestRecordSave(t *testing.T) { { name: "update second record", id: 2, + key: "0000002", fieldValue: "baz", storageSize: 2, setup: func(s Storage) Record { return storage.GetFirst("bar") }, @@ -161,6 +165,7 @@ func TestRecordSave(t *testing.T) { r = storage.GetFirst(tc.fieldValue) urequire.NotEqual(t, nil, r, "record found") uassert.Equal(t, tc.id, r.ID()) + uassert.Equal(t, tc.key, r.Key()) uassert.Equal(t, tc.fieldValue, r.MustGet(fieldName)) }) } From b12eb1cd237bc05212afba6dfa034935aa5dd9d2 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 7 Feb 2025 15:11:12 +0100 Subject: [PATCH 13/17] doc: add documentation to all types --- .../p/jeronimoalbi/datastore/datastore.gno | 18 +++++++ .../p/jeronimoalbi/datastore/indexes.gno | 35 ++++++++++++ .../p/jeronimoalbi/datastore/record.gno | 53 ++++++++++++++++--- .../p/jeronimoalbi/datastore/schema.gno | 23 ++++++++ .../jeronimoalbi/datastore/schema_options.gno | 9 ++++ .../p/jeronimoalbi/datastore/storage.gno | 42 +++++++++++++++ .../datastore/storage_options.gno | 6 +++ 7 files changed, 179 insertions(+), 7 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno index ba95c7d1ed7..0893ba11a1a 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno @@ -8,12 +8,27 @@ import ( // TODO: Write unit test for Datastore +// ErrStorageExists indicates that a storage exists with the same name. var ErrStorageExists = errors.New("a storage with the same name exists") +// Datastore is a store that can contain multiple named storages. +// A storage is a collection of records. +// +// Example usage: +// +// // Create an empty storage to store user records +// var db Datastore +// storage := db.CreateStorage("users") +// +// // Get a storage that has been created before +// // and search a user by record ID. +// storage = db.GetStorage("users") +// user := storage.GetFirst(user.Key()) type Datastore struct { storages avl.Tree // string(name) -> *Storage } +// CreateStorage creates a new named storage within the data store. func (ds *Datastore) CreateStorage(name string, options ...StorageOption) *Storage { if ds.storages.Has(name) { return nil @@ -24,10 +39,13 @@ func (ds *Datastore) CreateStorage(name string, options ...StorageOption) *Stora return &s } +// HasStorage checks if data store contains a storage with a specific name. func (ds Datastore) HasStorage(name string) bool { return ds.storages.Has(name) } +// GetStorage returns a storage that has been created with a specific name. +// It returns nil when a storage with the specified name is not found. func (ds Datastore) GetStorage(name string) *Storage { if v, found := ds.storages.Get(name); found { return v.(*Storage) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno b/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno index d2e36e2a33b..0bcba34ca96 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno @@ -4,13 +4,23 @@ import ( "gno.land/p/moul/collection" ) +// DefaultIndexOptions defines the default options for new indexes. const DefaultIndexOptions = collection.DefaultIndex | collection.SparseIndex type ( + // IndexFn defines a type for single value indexing functions. + // This type of function extracts a single string value from + // a record that is then used to index it. IndexFn func(Record) string + // IndexMultiValueFn defines a type for multi value indexing functions. + // This type of function extracts multiple string values from a + // record that are then used to index it. IndexMultiValueFn func(Record) []string + // Index defines a type for custom user defined storage indexes. + // Storages are by default indexed by the auto geneated record ID + // but can additionally be indexed by other custom record fields. Index struct { name string options collection.IndexOption @@ -18,6 +28,14 @@ type ( } ) +// NewIndex creates a new single value index. +// +// Usage example: +// +// // Index a User record by email +// idx := NewIndex("email", func(r Record) string { +// return r.MustGet("email").(string) +// }) func NewIndex(name string, fn IndexFn) Index { return Index{ name: name, @@ -28,6 +46,14 @@ func NewIndex(name string, fn IndexFn) Index { } } +// NewMultiValueIndex creates a new multi value index. +// +// Usage example: +// +// // Index a Post record by tag +// idx := NewMultiValueIndex("tag", func(r Record) []string { +// return r.MustGet("tags").([]string) +// }) func NewMultiValueIndex(name string, fn IndexMultiValueFn) Index { return Index{ name: name, @@ -38,18 +64,25 @@ func NewMultiValueIndex(name string, fn IndexMultiValueFn) Index { } } +// Name returns index's name. func (idx Index) Name() string { return idx.name } +// Options returns current index options. +// These options define the index behavior regarding case sensitivity and uniquenes. func (idx Index) Options() collection.IndexOption { return idx.options } +// Func returns the function that storage collections apply +// to each record to get the value to use for indexing it. func (idx Index) Func() interface{} { return idx.fn } +// Unique returns a copy of the index that indexes record values as unique values. +// Returned index contains previous options plus the unique one. func (idx Index) Unique() Index { if idx.options&collection.UniqueIndex == 0 { idx.options |= collection.UniqueIndex @@ -57,6 +90,8 @@ func (idx Index) Unique() Index { return idx } +// CaseInsensitive returns a copy of the index that indexes record values ignoring casing. +// Returned index contains previous options plus the case insensitivity one. func (idx Index) CaseInsensitive() Index { if idx.options&collection.CaseInsensitiveIndex == 0 { idx.options |= collection.CaseInsensitiveIndex diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno index 1c92a12ddf4..9a52dbab4d3 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -8,25 +8,50 @@ import ( "gno.land/p/moul/collection" ) +// ErrUndefinedField indicates that a field in not defined in a record's schema. var ErrUndefinedField = errors.New("undefined field") type ( + // Record stores values for one or more fields. + Record interface { + ReadOnlyRecord + + // Set assings a value to a record field. + // If the field doesn't exist it's created if the underlying schema allows it. + // Storage schema can optionally be strict in which case no new fields other than + // the ones that were previously defined are allowed. + Set(field string, value interface{}) error + + // Save assigns an ID to newly created records and update storage indexes. + Save() bool + } + + // ReadOnlyRecord defines an interface for read-only records. ReadOnlyRecord interface { + // ID returns record's ID ID() uint64 + + // Key returns a string representation of the record's ID. + // It's used to be able to search records within the ID index. Key() string + + // Type returns the record's type. Type() string + + // Fields returns the list of the record's field names. Fields() []string + + // IsEmpty checks if the record has no values. IsEmpty() bool + + // HasField checks if the record has a specific field. HasField(name string) bool - Get(field string) (value interface{}, found bool) - MustGet(field string) interface{} - } - Record interface { - ReadOnlyRecord + // Get returns the value of a record's field. + Get(field string) (value interface{}, found bool) - Set(field string, value interface{}) error - Save() bool + // MustGet returns the value of a record's field or panics when the field is not found. + MustGet(field string) interface{} } ) @@ -37,30 +62,41 @@ type record struct { values avl.Tree // string(field index) -> interface{} } +// ID returns record's ID func (r record) ID() uint64 { return r.id } +// Key returns a string representation of the record's ID. +// It's used to be able to search records within the ID index. func (r record) Key() string { return seqid.ID(r.id).String() } +// Type returns the record's type. func (r record) Type() string { return r.schema.Name() } +// Fields returns the list of the record's field names. func (r record) Fields() []string { return r.schema.Fields() } +// IsEmpty checks if the record has no values. func (r record) IsEmpty() bool { return r.values.Size() == 0 } +// HasField checks if the record has a specific field. func (r record) HasField(name string) bool { return r.schema.HasField(name) } +// Set assings a value to a record field. +// If the field doesn't exist it's created if the underlying schema allows it. +// Storage schema can optionally be strict in which case no new fields other than +// the ones that were previously defined are allowed. func (r *record) Set(field string, value interface{}) error { i := r.schema.GetFieldIndex(field) if i == -1 { @@ -76,6 +112,7 @@ func (r *record) Set(field string, value interface{}) error { return nil } +// Get returns the value of a record's field. func (r record) Get(field string) (value interface{}, found bool) { i := r.schema.GetFieldIndex(field) if i == -1 { @@ -86,6 +123,7 @@ func (r record) Get(field string) (value interface{}, found bool) { return r.values.Get(key) } +// MustGet returns the value of a record's field or panics when the field is not found. func (r record) MustGet(field string) interface{} { v, found := r.Get(field) if !found { @@ -94,6 +132,7 @@ func (r record) MustGet(field string) interface{} { return v } +// Save assigns an ID to newly created records and update storage indexes. func (r *record) Save() bool { if r.id == 0 { r.id = r.collection.Set(r) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno index 4cdc0694fe4..89abf2bc695 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno @@ -9,6 +9,9 @@ import ( // TODO: Support versioning +// Schema contains information about fields and default field values. +// It also offers the possibility to configure it as static to indicate +// that only configured fields should be allowed. type Schema struct { name string strict bool @@ -16,6 +19,7 @@ type Schema struct { defaults avl.Tree // string(field index) -> interface{} } +// NewSchema creates a new schema. func NewSchema(name string, options ...SchemaOption) *Schema { s := &Schema{name: name} for _, apply := range options { @@ -24,10 +28,12 @@ func NewSchema(name string, options ...SchemaOption) *Schema { return s } +// Name returns schema's name. func (s Schema) Name() string { return s.name } +// Fields returns the list field names that are defined in the schema. func (s Schema) Fields() []string { fields := make([]string, s.fields.Len()) s.fields.ForEach(func(i int, v interface{}) bool { @@ -37,18 +43,23 @@ func (s Schema) Fields() []string { return fields } +// Size returns the number of fields the schema has. func (s Schema) Size() int { return s.fields.Len() } +// IsStrict check if the schema is configured as a strict one. func (s Schema) IsStrict() bool { return s.strict } +// HasField check is a field has been defined in the schema. func (s Schema) HasField(name string) bool { return s.GetFieldIndex(name) >= 0 } +// AddField adds a new field to the schema. +// A default field value can be specified, otherwise `defaultValue` must be nil. func (s *Schema) AddField(name string, defaultValue interface{}) (index int, added bool) { if s.HasField(name) { return -1, false @@ -63,6 +74,14 @@ func (s *Schema) AddField(name string, defaultValue interface{}) (index int, add return index, true } +// GetFieldIndex returns the index number of a schema field. +// +// Field index indicates the order the field has within the schema. +// When defined fields are added they get an index starting from +// field index 0. +// +// Fields are internally referenced by index number instead of the name +// to be able to rename fields easily. func (s Schema) GetFieldIndex(name string) int { index := -1 s.fields.ForEach(func(i int, v interface{}) bool { @@ -76,6 +95,7 @@ func (s Schema) GetFieldIndex(name string) int { return index } +// GetFieldName returns the name of a field for a specific field index. func (s Schema) GetFieldName(index int) (name string, found bool) { v := s.fields.Get(index) if v == nil { @@ -84,6 +104,7 @@ func (s Schema) GetFieldName(index int) (name string, found bool) { return v.(string), true } +// GetDefault returns the default value for a field. func (s Schema) GetDefault(name string) (value interface{}, found bool) { i := s.GetFieldIndex(name) if i == -1 { @@ -92,6 +113,7 @@ func (s Schema) GetDefault(name string) (value interface{}, found bool) { return s.GetDefaultByIndex(i) } +// GetDefaultByIndex returns the default value for a field by it's index. func (s Schema) GetDefaultByIndex(index int) (value interface{}, found bool) { key := castIntToKey(index) v, found := s.defaults.Get(key) @@ -105,6 +127,7 @@ func (s Schema) GetDefaultByIndex(index int) (value interface{}, found bool) { return v, true } +// RenameField renames a field. func (s *Schema) RenameField(name, newName string) (renamed bool) { if s.HasField(newName) { return false diff --git a/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno index 99595f65eb8..78054b13255 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno @@ -2,8 +2,10 @@ package datastore import "strings" +// StorageOption configures schemas. type SchemaOption func(*Schema) +// WithField assign a new field to the schema definition. func WithField(name string) SchemaOption { return func(s *Schema) { name = strings.TrimSpace(name) @@ -13,6 +15,8 @@ func WithField(name string) SchemaOption { } } +// WithDefaultField assign a new field with a default value to the schema definition. +// Default value is assigned to newly created records asociated to to schema. func WithDefaultField(name string, value interface{}) SchemaOption { return func(s *Schema) { name = strings.TrimSpace(name) @@ -25,6 +29,11 @@ func WithDefaultField(name string, value interface{}) SchemaOption { } } +// Strict configures the schema as a strict one. +// By default schemas should allow the creation of any user defined field, +// making them strict limits the allowed record fields to the ones pre-defined +// in the schema. Fields are pre-defined using `WithField`, `WithDefaultField` +// or by calling `Schema.AddField()`. func Strict() SchemaOption { return func(s *Schema) { s.strict = true diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index 9a3489981a8..0ed50fe17b4 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -6,6 +6,7 @@ import ( "gno.land/p/moul/collection" ) +// NewStorage creates a new records storage. func NewStorage(name string, options ...StorageOption) Storage { s := Storage{ name: name, @@ -20,6 +21,16 @@ func NewStorage(name string, options ...StorageOption) Storage { return s } +// Storage stores a collection of records. +// +// By default it searches records by record ID but it allows +// using custom user defined indexes for other record fields. +// +// When a storage is created it defines a default schema that +// keeps track of record fields. Storage can be optionally +// created with a user defined schema in cases where the number +// of fields has to be pre-defined or when new records must have +// one or more fields initialized to default values. type Storage struct { name string currentIndex string @@ -27,26 +38,42 @@ type Storage struct { schema *Schema } +// Name returns storage's name. func (s Storage) Name() string { return s.name } +// CurrentIndex returns the name of the index that is used +// by default for search and iteration. func (s Storage) CurrentIndex() string { return s.currentIndex } +// Collection returns the undelying collection used by the +// storage to store all records. func (s Storage) Collection() *collection.Collection { return s.collection } +// Schema returns the schema being used to track record fields. func (s Storage) Schema() *Schema { return s.schema } +// Size returns the number of records that the storage have. func (s Storage) Size() int { return s.collection.GetIndex(collection.IDIndex).Size() } +// NewRecord creates a new storage record. +// +// If a custom schema with default field values is assigned to +// storage it's used to assign initial default values when new +// records are created. +// +// Creating a new record doesn't assign an ID to it, a new ID +// is generated and assigned to the record when it's saved for +// the first time. func (s Storage) NewRecord() Record { r := &record{ schema: s.schema, @@ -62,11 +89,22 @@ func (s Storage) NewRecord() Record { return r } +// WithIndex returns a copy of the storage with a different default index. +// +// Example usage: +// +// // Create a storage that index users by ID (default) and email +// storage := NewStorage("users", WithIndex(emailIdx)) +// +// // The "email" index has to be used instead of the default "ID" +// // index to search a user by email. +// user := storage.WithIndex("email").GetFirst("foo@bar.org") func (s Storage) WithIndex(name string) Storage { s.currentIndex = name return s } +// ForEach iterates each storage record using the current index. func (s Storage) ForEach(fn func(Record) bool) bool { idx := s.collection.GetIndex(s.currentIndex) if idx == nil { @@ -78,6 +116,7 @@ func (s Storage) ForEach(fn func(Record) bool) bool { }) } +// Get returns all records found for a key searched using current index. func (s Storage) Get(key string) []Record { var ( records []Record @@ -89,6 +128,8 @@ func (s Storage) Get(key string) []Record { return records } +// GetFirst returns the first record found for a key searched within current index. +// It returns nil when no records are found for the given key. func (s Storage) GetFirst(key string) Record { var ( record Record @@ -100,6 +141,7 @@ func (s Storage) GetFirst(key string) Record { return record } +// Delete deletes a record from the storage. func (s Storage) Delete(id uint64) bool { return s.collection.Delete(id) } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno index 1d4404a5663..7e3b9e82e33 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno @@ -1,7 +1,9 @@ package datastore +// StorageOption configures storages. type StorageOption func(*Storage) +// WithSchema assigns a schema to the storage. func WithSchema(s *Schema) StorageOption { return func(st *Storage) { if s != nil { @@ -10,12 +12,16 @@ func WithSchema(s *Schema) StorageOption { } } +// WithIndex assigns an index to the storage. func WithIndex(i Index) StorageOption { return func(st *Storage) { st.collection.AddIndex(i.name, i.fn, i.options) } } +// WithDefaultIndex assigns an index to the storage and makes it the default one. +// Default indexes are used by default by a storage without the need to use the +// `Storage.WithIndex()` method to use a different index when searching or iterating. func WithDefaultIndex(i Index) StorageOption { return func(st *Storage) { st.currentIndex = i.Name() From 8a3544b3bbf981095b16143dd4d309204ef4c045 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 7 Feb 2025 15:41:02 +0100 Subject: [PATCH 14/17] feat: add package documentation --- .../gno.land/p/jeronimoalbi/datastore/doc.gno | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/doc.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno new file mode 100644 index 00000000000..226caa4ddcb --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno @@ -0,0 +1,53 @@ +// Package datastore provides support to store multiple collections of records. +// +// It supports the definition of multiple storages, where each one is a collection +// of records. Records can have any number of user defined fields which are added +// dynamically when values are set on a record. These fields can also be renamed +// or removed. +// +// Storages have support for simple schemas that allows users to pre-define fields +// which can optionally have a default value also defined. Default values are +// assigned to new records on creation. +// +// User defined schemas can optionally be strict, which means that records from a +// storage using the schema can only assign values to the pre-defined set of fields. +// In which case, assigning a value to an unknown field would result on an error. +// +// Package also support the definition of custom record indexes. Indexes are used +// by storages to search and iterate records. +// The default index is the ID index but custom single and multi value indexes can +// be defined. +// +// Example usage: +// +// var db datastore.Datastore +// +// // Define a unique case insensitive index for user emails +// emailIdx := datastore.NewIndex("email", func(r datastore.Record) string { +// return r.MustGet("email").(string) +// }).Unique().CaseInsensitive() +// +// // Create a new storage for user records +// storage := db.CreateStorage("users", datastore.WithIndex(emailIdx)) +// +// // Add a user with a single "email" field +// user := storage.NewRecord() +// user.Set("email", "foo@bar.org") +// +// // Save to assing user ID and update indexes +// user.Save() +// +// // Find user by email +// user = storage.WithIndex("email").GetFirst("foo@bar.org") +// +// // Find user by ID +// user = storage.GetFirst(user.Key()) +// +// // Search user's profile by email in another existing storage +// storage = db.GetStorage("profiles") +// email := user.MustGet("email").(string) +// profile := storage.WithIndex("user").GetFirst(email) +// +// // Delete the profile from the storage and update indexes +// storage.Delete(profile.ID()) +package datastore From cd816de1145c8b68d28dd7b9381e39fb7af1a0d4 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 7 Feb 2025 19:33:19 +0100 Subject: [PATCH 15/17] feat: add iteration support to storage Added to support record pagination. Offset pagination is initially not possible because custom indexes can have multiple record ID values for a single key. --- .../p/jeronimoalbi/datastore/storage.gno | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index 0ed50fe17b4..7998fa39132 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -104,15 +104,65 @@ func (s Storage) WithIndex(name string) Storage { return s } -// ForEach iterates each storage record using the current index. -func (s Storage) ForEach(fn func(Record) bool) bool { +// Iterate iterates each storage record using the current index. +func (s Storage) Iterate(start, end string, fn func(Record) bool) bool { idx := s.collection.GetIndex(s.currentIndex) if idx == nil { return false } - return idx.Iterate("", "", func(_ string, v interface{}) bool { - return fn(v.(Record)) + // Actual record references are stored in the ID index + recordsIdx := s.collection.GetIndex(collection.IDIndex) + + return idx.Iterate(start, end, func(_ string, v interface{}) bool { + keys := castIfaceToRecordKeys(v) + if keys == nil { + // Skip unknown key formats + return false + } + + for _, k := range keys { + v, found := recordsIdx.Get(k) + if !found { + continue + } + + if fn(v.(Record)) { + return true + } + } + return false + }) +} + +// ReverseIterate iterates each storage record using the current index. +func (s Storage) ReverseIterate(start, end string, fn func(Record) bool) bool { + idx := s.collection.GetIndex(s.currentIndex) + if idx == nil { + return false + } + + // Actual record references are stored in the ID index + recordsIdx := s.collection.GetIndex(collection.IDIndex) + + return idx.ReverseIterate(start, end, func(_ string, v interface{}) bool { + keys := castIfaceToRecordKeys(v) + if keys == nil { + // Skip unknown key formats + return false + } + + for i := len(keys) - 1; i >= 0; i-- { + v, found := recordsIdx.Get(keys[i]) + if !found { + continue + } + + if fn(v.(Record)) { + return true + } + } + return false }) } @@ -145,3 +195,13 @@ func (s Storage) GetFirst(key string) Record { func (s Storage) Delete(id uint64) bool { return s.collection.Delete(id) } + +func castIfaceToRecordKeys(v interface{}) []string { + switch k := v.(type) { + case []string: + return k + case string: + return []string{k} + } + return nil +} From f4afd08ab64c3e1946d71faa0fb0891e0984078c Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 7 Feb 2025 19:35:34 +0100 Subject: [PATCH 16/17] test: add more storage unit tests --- .../p/jeronimoalbi/datastore/storage_test.gno | 291 +++++++++++++++++- 1 file changed, 290 insertions(+), 1 deletion(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno index 0b1d1b5aeef..921692d0e3a 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -42,4 +42,293 @@ func TestStorageNewRecord(t *testing.T) { uassert.Equal(t, defaultValue, got) } -// TODO: Finish and improve Storage unit tests +func TestStorageWithIndex(t *testing.T) { + index := "bar" + storage1 := NewStorage("foo") + storage2 := storage1.WithIndex(index) + + uassert.Equal(t, collection.IDIndex, storage1.CurrentIndex()) + uassert.Equal(t, storage1.Name(), storage2.Name()) + uassert.Equal(t, index, storage2.CurrentIndex()) +} + +func TestStorageIterate(t *testing.T) { + index := NewIndex("name", func(r Record) string { + if v, found := r.Get("name"); found { + return v.(string) + } + return "" + }) + + cases := []struct { + name string + recordIDs []uint64 + setup func(*Storage) + }{ + { + name: "single record", + recordIDs: []uint64{1}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "two records", + recordIDs: []uint64{1, 2}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + + r = s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "empty storage", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + storage := NewStorage("foo", WithDefaultIndex(index)) + if tc.setup != nil { + tc.setup(&storage) + } + + var records []Record + storage.Iterate("", "", func(r Record) bool { + records = append(records, r) + return false + }) + + urequire.Equal(t, len(tc.recordIDs), len(records), "results count") + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestStorageReverseIterate(t *testing.T) { + index := NewIndex("name", func(r Record) string { + if v, found := r.Get("name"); found { + return v.(string) + } + return "" + }) + + cases := []struct { + name string + recordIDs []uint64 + setup func(*Storage) + }{ + { + name: "single record", + recordIDs: []uint64{1}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "two records", + recordIDs: []uint64{2, 1}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + + r = s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "empty storage", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + storage := NewStorage("foo", WithDefaultIndex(index)) + if tc.setup != nil { + tc.setup(&storage) + } + + var records []Record + storage.ReverseIterate("", "", func(r Record) bool { + records = append(records, r) + return false + }) + + urequire.Equal(t, len(tc.recordIDs), len(records), "results count") + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestStorageGet(t *testing.T) { + index := NewIndex("name", func(r Record) string { + if v, found := r.Get("name"); found { + return v.(string) + } + return "" + }) + + cases := []struct { + name string + key string + recordIDs []uint64 + setup func(*Storage) + }{ + { + name: "single record", + key: "foobar", + recordIDs: []uint64{1}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "two records", + key: "foobar", + recordIDs: []uint64{1, 2}, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + + r = s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "no records found", + key: "unknown", + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "empty storage", + key: "unknown", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + storage := NewStorage("foo", WithDefaultIndex(index)) + if tc.setup != nil { + tc.setup(&storage) + } + + records := storage.Get(tc.key) + + urequire.Equal(t, len(tc.recordIDs), len(records), "results count") + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestStorageGetFirst(t *testing.T) { + index := NewIndex("name", func(r Record) string { + if v, found := r.Get("name"); found { + return v.(string) + } + return "" + }) + + cases := []struct { + name string + key string + recordID uint64 + setup func(*Storage) + }{ + { + name: "single record", + key: "foobar", + recordID: 1, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "two records", + key: "foobar", + recordID: 1, + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + + r = s.NewRecord() + r.Set("name", "foobar") + r.Save() + + r = s.NewRecord() + r.Set("name", "extra") + r.Save() + }, + }, + { + name: "record not found", + key: "unknown", + setup: func(s *Storage) { + r := s.NewRecord() + r.Set("name", "foobar") + r.Save() + }, + }, + { + name: "empty storage", + key: "foobar", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + storage := NewStorage("foo", WithDefaultIndex(index)) + if tc.setup != nil { + tc.setup(&storage) + } + + r := storage.GetFirst(tc.key) + + if tc.recordID == 0 { + urequire.Equal(t, nil, r, "record not found") + return + } + + urequire.NotEqual(t, nil, r, "record found") + uassert.Equal(t, tc.recordID, r.ID()) + }) + } +} + +func TestStorageDelete(t *testing.T) { + storage := NewStorage("foo") + r := storage.NewRecord() + r.Save() + + deleted := storage.Delete(r.ID()) + uassert.True(t, deleted) + + deleted = storage.Delete(r.ID()) + uassert.False(t, deleted) +} From 669a85e7a949c0b7c52491f7279a9a7bdf9613b1 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 7 Feb 2025 19:55:35 +0100 Subject: [PATCH 17/17] test: add data store unit tests --- .../jeronimoalbi/datastore/datastore_test.gno | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/datastore_test.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/datastore_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/datastore_test.gno new file mode 100644 index 00000000000..7d522145b36 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/datastore_test.gno @@ -0,0 +1,78 @@ +package datastore + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestDatastoreCreateStorage(t *testing.T) { + cases := []struct { + name string + storageName string + mustFail bool + setup func(*Datastore) + }{ + { + name: "success", + storageName: "users", + }, + { + name: "storage exists", + storageName: "users", + mustFail: true, + setup: func(db *Datastore) { + db.CreateStorage("users") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var db Datastore + if tc.setup != nil { + tc.setup(&db) + } + + storage := db.CreateStorage(tc.storageName) + + if tc.mustFail { + uassert.Equal(t, nil, storage) + return + } + + urequire.NotEqual(t, nil, storage, "storage created") + uassert.Equal(t, tc.storageName, storage.Name()) + uassert.True(t, db.HasStorage(tc.storageName)) + }) + } +} + +func TestDatastoreHasStorage(t *testing.T) { + var ( + db Datastore + name = "users" + ) + + uassert.False(t, db.HasStorage(name)) + + db.CreateStorage(name) + uassert.True(t, db.HasStorage(name)) +} + +func TestDatastoreGetStorage(t *testing.T) { + var ( + db Datastore + name = "users" + ) + + storage := db.GetStorage(name) + uassert.Equal(t, nil, storage) + + db.CreateStorage(name) + + storage = db.GetStorage(name) + urequire.NotEqual(t, nil, storage, "storage found") + uassert.Equal(t, name, storage.Name()) +}