From 4710d08da5e6fbe1ed1fdc83007554fbf7adf769 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Wed, 5 Feb 2025 21:26:02 +0100 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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()) +} From b217868c7e1251f829553002acdbb1367c769ea3 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sun, 16 Feb 2025 21:50:54 +0100 Subject: [PATCH 18/27] refactor: simplify storage API to get/query records A `Query` methods was added to deal with record queries. Recordset type was introduced for query results to protect users from potentially get a large list of records. Recordsets require users to iterate each record. Users can still get the underlying collection for more advance cases. Queries can have an offset and size which allows pagination of records. --- .../p/jeronimoalbi/datastore/datastore.gno | 6 +- .../gno.land/p/jeronimoalbi/datastore/doc.gno | 13 +- .../p/jeronimoalbi/datastore/query.gno | 43 ++++++ .../jeronimoalbi/datastore/query_options.gno | 57 +++++++ .../p/jeronimoalbi/datastore/record.gno | 73 +++++++++ .../p/jeronimoalbi/datastore/record_test.gno | 28 ++-- .../p/jeronimoalbi/datastore/storage.gno | 146 +++++++----------- .../datastore/storage_options.gno | 10 -- .../p/jeronimoalbi/datastore/storage_test.gno | 11 -- 9 files changed, 253 insertions(+), 134 deletions(-) create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/query.gno create mode 100644 examples/gno.land/p/jeronimoalbi/datastore/query_options.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno index 0893ba11a1a..eacce319d49 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno @@ -6,8 +6,6 @@ import ( "gno.land/p/demo/avl" ) -// 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") @@ -21,9 +19,7 @@ var ErrStorageExists = errors.New("a storage with the same name exists") // 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()) +// storage = db.GetStorage("profiles") type Datastore struct { storages avl.Tree // string(name) -> *Storage } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno index 226caa4ddcb..0cbda41f3e8 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno @@ -20,11 +20,13 @@ // // Example usage: // +// import "gno.land/p/moul/collection" +// // 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) +// return r.MustGet("email").(string) // }).Unique().CaseInsensitive() // // // Create a new storage for user records @@ -38,15 +40,18 @@ // user.Save() // // // Find user by email -// user = storage.WithIndex("email").GetFirst("foo@bar.org") +// user, _ = storage.GetRecord("email", "foo@bar.org") // // // Find user by ID -// user = storage.GetFirst(user.Key()) +// user, _ = storage.GetRecord(collection.IDIndex, 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) +// profile, found := storage.GetRecord("user", email) +// if !found { +// panic("profile not found") +// } // // // Delete the profile from the storage and update indexes // storage.Delete(profile.ID()) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/query.gno b/examples/gno.land/p/jeronimoalbi/datastore/query.gno new file mode 100644 index 00000000000..85d2e55fe55 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/query.gno @@ -0,0 +1,43 @@ +package datastore + +import ( + "gno.land/p/moul/collection" +) + +var defaultQuery = Query{indexName: collection.IDIndex} + +// Query contains arguments for querying a storage. +type Query struct { + offset int + size int + indexName string + indexKey string +} + +// Offset returns the position of the first record to return. +// The minimum offset value is 0. +func (q Query) Offset() int { + return q.offset +} + +// Size returns the maximum number of records a query returns. +func (q Query) Size() int { + return q.size +} + +// IndexName returns the name of the storage index being used for the query. +func (q Query) IndexName() string { + return q.indexName +} + +// IndexKey return the index key value to locate the records. +// An empty string is returned when all indexed records match the query. +func (q Query) IndexKey() string { + return q.indexKey +} + +// IsEmpty checks if the query is empty. +// Empty queries return no records. +func (q Query) IsEmpty() bool { + return q.indexName == "" +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno new file mode 100644 index 00000000000..c48ac07dfae --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno @@ -0,0 +1,57 @@ +package datastore + +import ( + "errors" + "strings" + + "gno.land/p/moul/collection" +) + +var ( + ErrEmptyQueryIndexName = errors.New("query index name is empty") + ErrInvalidQueryOffset = errors.New("minimum allowed query offset is 0") + ErrInvalidQuerySize = errors.New("minimum allowed query size is 1") +) + +// QueryOption configures queries. +type QueryOption func(*Query) error + +// WithOffset assigns the offset or position of the first record that query must return. +// The minimum allowed offset is 0. +func WithOffset(offset int) QueryOption { + return func(q *Query) error { + if offset < 0 { + return ErrInvalidQueryOffset + } + + q.offset = offset + return nil + } +} + +// WithSize assigns the maximum number of records that query can return. +// The minimum allowed size is 1. +func WithSize(size int) QueryOption { + return func(q *Query) error { + if size < 1 { + return ErrInvalidQuerySize + } + + q.size = size + return nil + } +} + +// UseIndex assigns the index that the query must use to get the records. +// Using an index requires a key value to locate the records within the index. +func UseIndex(name, key string) QueryOption { + return func(q *Query) error { + q.indexName = strings.TrimSpace(name) + if q.indexName == "" { + return ErrEmptyQueryIndexName + } + + q.indexKey = key + return nil + } +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno index 9a52dbab4d3..5aed2eb9e78 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -53,6 +53,21 @@ type ( // MustGet returns the value of a record's field or panics when the field is not found. MustGet(field string) interface{} } + + // RecordIterFn defines a type for record iteration functions. + RecordIterFn func(Record) (stop bool) + + // Recordset defines an interface that allows iterating multiple records. + Recordset interface { + // Iterate iterates records in order. + Iterate(fn RecordIterFn) (stopped bool) + + // ReverseIterate iterates records in reverse order. + ReverseIterate(fn RecordIterFn) (stopped bool) + + // Size returns the number of records in the recordset. + Size() int + } ) type record struct { @@ -140,3 +155,61 @@ func (r *record) Save() bool { } return r.collection.Update(r.id, r) } + +type recordset struct { + query Query + records avl.ITree + keys []string +} + +// Iterate iterates records in order. +func (rs recordset) Iterate(fn RecordIterFn) (stopped bool) { + if rs.isUsingCustomIndex() { + for _, k := range rs.keys { + v, found := rs.records.Get(k) + if !found { + continue + } + + if fn(v.(Record)) { + return true + } + } + } + + return rs.records.Iterate("", "", func(_ string, v interface{}) bool { + return fn(v.(Record)) + }) +} + +// ReverseIterate iterates records in reverse order. +func (rs recordset) ReverseIterate(fn RecordIterFn) (stopped bool) { + if rs.isUsingCustomIndex() { + for i := len(rs.keys) - 1; i >= 0; i-- { + v, found := rs.records.Get(rs.keys[i]) + if !found { + continue + } + + if fn(v.(Record)) { + return true + } + } + } + + return rs.records.ReverseIterate("", "", func(_ string, v interface{}) bool { + return fn(v.(Record)) + }) +} + +// Size returns the number of records in the recordset. +func (rs recordset) Size() int { + if rs.isUsingCustomIndex() { + return len(rs.keys) + } + return rs.records.Size() +} + +func (rs recordset) isUsingCustomIndex() bool { + return rs.query.IndexName() != collection.IDIndex +} diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno index d0e842e222d..68e211d4396 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -7,7 +7,10 @@ import ( "gno.land/p/demo/urequire" ) -var _ Record = (*record)(nil) +var ( + _ Record = (*record)(nil) + _ Recordset = (*recordset)(nil) +) func TestRecordDefaults(t *testing.T) { storage := NewStorage("foo") @@ -114,12 +117,11 @@ func TestRecordGet(t *testing.T) { } func TestRecordSave(t *testing.T) { - fieldName := "name" - nameIdx := NewIndex("name", func(r Record) string { - return r.MustGet(fieldName).(string) + index := NewIndex("name", func(r Record) string { + return r.MustGet("name").(string) }).Unique().CaseInsensitive() - storage := NewStorage("foo", WithDefaultIndex(nameIdx)) + storage := NewStorage("foo", WithIndex(index)) cases := []struct { name string id uint64 @@ -149,24 +151,28 @@ func TestRecordSave(t *testing.T) { key: "0000002", fieldValue: "baz", storageSize: 2, - setup: func(s Storage) Record { return storage.GetFirst("bar") }, + setup: func(s Storage) Record { + r, _ := storage.GetRecord(index.Name(), "bar") + return r + }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := tc.setup(storage) - r.Set(fieldName, tc.fieldValue) + r.Set("name", tc.fieldValue) - urequire.Equal(t, nil, storage.GetFirst(tc.fieldValue), "record not found") + _, found := storage.GetRecord(index.Name(), tc.fieldValue) + urequire.False(t, found, "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") + r, found = storage.GetRecord(index.Name(), tc.fieldValue) + urequire.True(t, found, "record found") uassert.Equal(t, tc.id, r.ID()) uassert.Equal(t, tc.key, r.Key()) - uassert.Equal(t, tc.fieldValue, r.MustGet(fieldName)) + uassert.Equal(t, tc.fieldValue, r.MustGet("name")) }) } } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index 7998fa39132..f1b7a63c49a 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -1,6 +1,7 @@ package datastore import ( + "errors" "strings" "gno.land/p/moul/collection" @@ -9,10 +10,9 @@ import ( // NewStorage creates a new records storage. func NewStorage(name string, options ...StorageOption) Storage { s := Storage{ - name: name, - currentIndex: collection.IDIndex, - collection: collection.New(), - schema: NewSchema(strings.Title(name)), + name: name, + collection: collection.New(), + schema: NewSchema(strings.Title(name)), } for _, apply := range options { @@ -32,10 +32,9 @@ func NewStorage(name string, options ...StorageOption) Storage { // 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 - collection *collection.Collection - schema *Schema + name string + collection *collection.Collection + schema *Schema } // Name returns storage's name. @@ -43,12 +42,6 @@ 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 { @@ -89,106 +82,73 @@ func (s Storage) NewRecord() Record { return r } -// WithIndex returns a copy of the storage with a different default index. +// Query returns a recordset that matches the query parameters. +// By default query selects records using the ID 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 -} - -// 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 +// // Get 50 records starting from the one at position 100 +// var records []Record +// storage.Query( +// ithOffset(100), +// ithSize(50), +// ).Iterate(func (r Record) bool { +// records = append(records, r) +// return false +// }) +func (s Storage) Query(options ...QueryOption) (Recordset, error) { + // Initialize records's query + rs := recordset{query: defaultQuery} + for _, apply := range options { + if err := apply(&rs.query); err != nil { + return nil, err + } } - // 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 + // When a custom index is used get the keys of the records that match + if name := rs.query.IndexName(); name != collection.IDIndex { + idx := s.collection.GetIndex(name) + if idx == nil { + return nil, errors.New("storage index for query not found: " + name) } - for _, k := range keys { - v, found := recordsIdx.Get(k) - if !found { - continue - } - - if fn(v.(Record)) { - return true + if v, found := idx.Get(rs.query.IndexKey()); found { + rs.keys = castIfaceToRecordKeys(v) + if rs.keys == nil { + return nil, errors.New("unexpected storage index key format") } } - 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 - }) + rs.records = s.collection.GetIndex(collection.IDIndex) + return rs, nil } -// Get returns all records found for a key searched using current index. -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)) +// MustQuery returns a recordset that matches the query parameters or panics on error. +// By default query selects records using the ID index. +func (s Storage) MustQuery(options ...QueryOption) Recordset { + rs, err := s.Query(options...) + if err != nil { + panic(err) } - return records + return rs } -// 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 { +// GetRecord returns the first record found for a key within a storage index. +// +// This is a convenience method to get a single record independently of the index +// type. A multi index will always return the first record value for the specified +// key in this case. To get multiple records create a query using a custom index +// and key vale or use the underlying storage collection. +func (s Storage) GetRecord(indexName, indexKey string) (_ Record, found bool) { var ( record Record - iter = s.collection.Get(s.currentIndex, key) + iter = s.collection.Get(indexName, indexKey) ) if iter.Next() { - record = iter.Value().Obj.(Record) + return iter.Value().Obj.(Record), true } - return record + return nil, false } // Delete deletes a record from the storage. diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno index 7e3b9e82e33..73ab525eb67 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno @@ -18,13 +18,3 @@ func WithIndex(i Index) StorageOption { 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() - 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 index 921692d0e3a..8a584536b24 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -14,7 +14,6 @@ func TestStorageDefaults(t *testing.T) { storage := NewStorage(name) 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()) @@ -42,16 +41,6 @@ func TestStorageNewRecord(t *testing.T) { uassert.Equal(t, defaultValue, got) } -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 { From a16b21d331a72636bf7a4adf226416afe73229b1 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sun, 16 Feb 2025 22:01:47 +0100 Subject: [PATCH 19/27] chore: remove unused import --- examples/gno.land/p/jeronimoalbi/datastore/query_options.gno | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno b/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno index c48ac07dfae..54aaae06a61 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/query_options.gno @@ -3,8 +3,6 @@ package datastore import ( "errors" "strings" - - "gno.land/p/moul/collection" ) var ( From 72e1746f142a4aa75ea672e71c6a372ad18843a3 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 21 Feb 2025 19:22:01 +0100 Subject: [PATCH 20/27] chore: rename `Storage.GetRecord` to `Storage.Get` --- examples/gno.land/p/jeronimoalbi/datastore/doc.gno | 6 +++--- .../p/jeronimoalbi/datastore/record_test.gno | 6 +++--- .../gno.land/p/jeronimoalbi/datastore/storage.gno | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno index 0cbda41f3e8..ad588a9fb69 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno @@ -40,15 +40,15 @@ // user.Save() // // // Find user by email -// user, _ = storage.GetRecord("email", "foo@bar.org") +// user, _ = storage.Get("email", "foo@bar.org") // // // Find user by ID -// user, _ = storage.GetRecord(collection.IDIndex, user.Key()) +// user, _ = storage.Get(collection.IDIndex, user.Key()) // // // Search user's profile by email in another existing storage // storage = db.GetStorage("profiles") // email := user.MustGet("email").(string) -// profile, found := storage.GetRecord("user", email) +// profile, found := storage.Get("user", email) // if !found { // panic("profile not found") // } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno index 68e211d4396..450dcf18f17 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -152,7 +152,7 @@ func TestRecordSave(t *testing.T) { fieldValue: "baz", storageSize: 2, setup: func(s Storage) Record { - r, _ := storage.GetRecord(index.Name(), "bar") + r, _ := storage.Get(index.Name(), "bar") return r }, }, @@ -163,12 +163,12 @@ func TestRecordSave(t *testing.T) { r := tc.setup(storage) r.Set("name", tc.fieldValue) - _, found := storage.GetRecord(index.Name(), tc.fieldValue) + _, found := storage.Get(index.Name(), tc.fieldValue) urequire.False(t, found, "record not found") urequire.True(t, r.Save(), "save success") uassert.Equal(t, tc.storageSize, storage.Size()) - r, found = storage.GetRecord(index.Name(), tc.fieldValue) + r, found = storage.Get(index.Name(), tc.fieldValue) urequire.True(t, found, "record found") uassert.Equal(t, tc.id, r.ID()) uassert.Equal(t, tc.key, r.Key()) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index f1b7a63c49a..5cbffec97b3 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -134,13 +134,13 @@ func (s Storage) MustQuery(options ...QueryOption) Recordset { return rs } -// GetRecord returns the first record found for a key within a storage index. +// Get returns the first record found for a key within a storage index. // -// This is a convenience method to get a single record independently of the index -// type. A multi index will always return the first record value for the specified -// key in this case. To get multiple records create a query using a custom index -// and key vale or use the underlying storage collection. -func (s Storage) GetRecord(indexName, indexKey string) (_ Record, found bool) { +// This is a convenience method to get a single record. A multi index will +// always return the first record value for the specified key in this case. +// To get multiple records create a query using a custom index and key value +// or use the underlying storage collection. +func (s Storage) Get(indexName, indexKey string) (_ Record, found bool) { var ( record Record iter = s.collection.Get(indexName, indexKey) From 41709a1c7a184b2d245557a2b2fe56643d62995e Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Fri, 21 Feb 2025 20:05:09 +0100 Subject: [PATCH 21/27] test: add unit tests for `Recordset` --- .../p/jeronimoalbi/datastore/record_test.gno | 165 +++++++++++++++++- 1 file changed, 162 insertions(+), 3 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno index 450dcf18f17..1efd8236857 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -13,9 +13,13 @@ var ( ) func TestRecordDefaults(t *testing.T) { + // Arrange storage := NewStorage("foo") + + // Act r := storage.NewRecord() + // Assert uassert.Equal(t, uint64(0), r.ID()) uassert.Equal(t, "0000000", r.Key()) uassert.Equal(t, "Foo", r.Type()) @@ -25,10 +29,9 @@ func TestRecordDefaults(t *testing.T) { func TestRecordHasField(t *testing.T) { storage := NewStorage("foo") - r := storage.NewRecord() + storage.Schema().AddField("foo", nil) - s := storage.Schema() - s.AddField("foo", nil) + r := storage.NewRecord() uassert.True(t, r.HasField("foo")) uassert.False(t, r.HasField("undefined")) @@ -80,12 +83,15 @@ func TestRecordSet(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + // Arrange s := NewSchema("Foo", tc.options...) storage := NewStorage("foo", WithSchema(s)) r := storage.NewRecord() + // Act err := r.Set(tc.field, tc.value) + // Assert if tc.err != nil { urequire.ErrorIs(t, err, tc.err) return @@ -176,3 +182,156 @@ func TestRecordSave(t *testing.T) { }) } } + +func TestRecordsetIterate(t *testing.T) { + cases := []struct { + name string + recordIDs []uint64 + setup func(*Storage) + }{ + { + name: "single record", + recordIDs: []uint64{1}, + setup: func(s *Storage) { + s.NewRecord().Save() + }, + }, + { + name: "two records", + recordIDs: []uint64{1, 2}, + setup: func(s *Storage) { + s.NewRecord().Save() + s.NewRecord().Save() + }, + }, + { + name: "empty recordset", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + storage := NewStorage("foo") + if tc.setup != nil { + tc.setup(&storage) + } + + var ( + records []Record + rs = storage.MustQuery() + ) + + // Act + rs.Iterate(func(r Record) bool { + records = append(records, r) + return false + }) + + // Assert + urequire.Equal(t, len(tc.recordIDs), len(records), "results count") + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestRecordsetReverseIterate(t *testing.T) { + cases := []struct { + name string + recordIDs []uint64 + setup func(*Storage) + }{ + { + name: "single record", + recordIDs: []uint64{1}, + setup: func(s *Storage) { + s.NewRecord().Save() + }, + }, + { + name: "two records", + recordIDs: []uint64{2, 1}, + setup: func(s *Storage) { + s.NewRecord().Save() + s.NewRecord().Save() + }, + }, + { + name: "empty recordser", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + storage := NewStorage("foo") + if tc.setup != nil { + tc.setup(&storage) + } + + var ( + records []Record + rs = storage.MustQuery() + ) + + // Act + rs.ReverseIterate(func(r Record) bool { + records = append(records, r) + return false + }) + + // Assert + urequire.Equal(t, len(tc.recordIDs), len(records), "results count") + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestRecordsetSize(t *testing.T) { + cases := []struct { + name string + size int + setup func(*Storage) + }{ + { + name: "single record", + size: 1, + setup: func(s *Storage) { + s.NewRecord().Save() + }, + }, + { + name: "two records", + size: 2, + setup: func(s *Storage) { + s.NewRecord().Save() + s.NewRecord().Save() + }, + }, + { + name: "empty recordser", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + storage := NewStorage("foo") + if tc.setup != nil { + tc.setup(&storage) + } + + rs := storage.MustQuery() + + // Act + size := rs.Size() + + // Assert + uassert.Equal(t, tc.size, size) + }) + } +} From 43cbdebf28f1a0f9866f4cec798e4d556f3acdcb Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 22:44:46 +0100 Subject: [PATCH 22/27] chore: add warning paragraph to package documentation --- .../gno.land/p/jeronimoalbi/datastore/doc.gno | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno index ad588a9fb69..1ceea2e2b9a 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno @@ -18,9 +18,13 @@ // The default index is the ID index but custom single and multi value indexes can // be defined. // -// Example usage: +// WARNING: Using this package to store your realm data must be carefully considered. +// The fact that record fields are not strictly typed and can be renamed or removed +// could lead to issues if not careful when coding your realm(s). So it's recommended +// that you consider other alternatives first, like alternative patterns or solutions +// provided by the blockchain to deal with data, types and data migration for example. // -// import "gno.land/p/moul/collection" +// Example usage: // // var db datastore.Datastore // @@ -39,11 +43,11 @@ // // Save to assing user ID and update indexes // user.Save() // -// // Find user by email -// user, _ = storage.Get("email", "foo@bar.org") +// // Find user by email using the custom index +// user, _ = storage.Get(emailIdx.Name(), "foo@bar.org") // // // Find user by ID -// user, _ = storage.Get(collection.IDIndex, user.Key()) +// user, _ = storage.GetByID(user.ID()) // // // Search user's profile by email in another existing storage // storage = db.GetStorage("profiles") @@ -55,4 +59,12 @@ // // // Delete the profile from the storage and update indexes // storage.Delete(profile.ID()) +// +// Example query usage: +// +// ... TODO +// +// Example query using a custom index usage: +// +// ... TODO package datastore From 633524290a3cf4cb2c1e387c9645b2e68b8e14ca Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 22:46:06 +0100 Subject: [PATCH 23/27] feat: improve storage functionality and add query unit tests --- .../p/jeronimoalbi/datastore/record.gno | 18 +- .../p/jeronimoalbi/datastore/storage.gno | 105 +++++- .../p/jeronimoalbi/datastore/storage_test.gno | 315 ++++++++++-------- 3 files changed, 285 insertions(+), 153 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno index 5aed2eb9e78..1661f9fed0f 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -160,6 +160,7 @@ type recordset struct { query Query records avl.ITree keys []string + size int } // Iterate iterates records in order. @@ -175,9 +176,13 @@ func (rs recordset) Iterate(fn RecordIterFn) (stopped bool) { return true } } + + return false } - return rs.records.Iterate("", "", func(_ string, v interface{}) bool { + offset := rs.query.Offset() + count := rs.query.Size() + return rs.records.IterateByOffset(offset, count, func(_ string, v interface{}) bool { return fn(v.(Record)) }) } @@ -195,19 +200,20 @@ func (rs recordset) ReverseIterate(fn RecordIterFn) (stopped bool) { return true } } + + return false } - return rs.records.ReverseIterate("", "", func(_ string, v interface{}) bool { + offset := rs.query.Offset() + size := rs.query.Size() + return rs.records.ReverseIterateByOffset(offset, size, func(_ string, v interface{}) bool { return fn(v.(Record)) }) } // Size returns the number of records in the recordset. func (rs recordset) Size() int { - if rs.isUsingCustomIndex() { - return len(rs.keys) - } - return rs.records.Size() + return rs.size } func (rs recordset) isUsingCustomIndex() bool { diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index 5cbffec97b3..bbd918ccc4c 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -4,6 +4,7 @@ import ( "errors" "strings" + "gno.land/p/demo/seqid" "gno.land/p/moul/collection" ) @@ -88,44 +89,88 @@ func (s Storage) NewRecord() Record { // Example usage: // // // Get 50 records starting from the one at position 100 +// rs, _ := storage.Query( +// WithOffset(100), +// WithSize(50), +// ) +// +// // Iterate records to create a new slice // var records []Record -// storage.Query( -// ithOffset(100), -// ithSize(50), -// ).Iterate(func (r Record) bool { +// rs.Iterate(func (r Record) bool { // records = append(records, r) // return false // }) func (s Storage) Query(options ...QueryOption) (Recordset, error) { - // Initialize records's query - rs := recordset{query: defaultQuery} + // Initialize the recordset for the query + rs := recordset{ + query: defaultQuery, + records: s.collection.GetIndex(collection.IDIndex), + } + for _, apply := range options { if err := apply(&rs.query); err != nil { return nil, err } } - // When a custom index is used get the keys of the records that match - if name := rs.query.IndexName(); name != collection.IDIndex { - idx := s.collection.GetIndex(name) - if idx == nil { - return nil, errors.New("storage index for query not found: " + name) + indexName := rs.query.IndexName() + if indexName != collection.IDIndex { + // When using a custom index get the keys to get records from the ID index + keys, err := s.getIndexRecordsKeys(indexName, rs.query.IndexKey()) + if err != nil { + return nil, err + } + + // Adjust the number of keys to match available query options + if offset := rs.query.Offset(); offset > 0 { + if offset > len(keys) { + keys = nil + } else { + keys = keys[offset:] + } + } + + if size := rs.query.Size(); size > 0 && size < len(keys) { + keys = keys[:size] } - if v, found := idx.Get(rs.query.IndexKey()); found { - rs.keys = castIfaceToRecordKeys(v) - if rs.keys == nil { - return nil, errors.New("unexpected storage index key format") + rs.keys = keys + rs.size = len(keys) + } else { + // When using the default ID index init size with the total number of records + rs.size = rs.records.Size() + + // Adjust recordset size to match available query options + if offset := rs.query.Offset(); offset > 0 { + if offset > rs.size { + rs.size = 0 + } else { + rs.size -= offset } } + + if size := rs.query.Size(); size > 0 && size < rs.size { + rs.size = size + } } - rs.records = s.collection.GetIndex(collection.IDIndex) return rs, nil } // MustQuery returns a recordset that matches the query parameters or panics on error. // By default query selects records using the ID index. +// +// Example usage: +// +// // Get 50 records starting from the one at position 100 +// var records []Record +// storage.MustQuery( +// WithOffset(100), +// WithSize(50), +// ).Iterate(func (r Record) bool { +// records = append(records, r) +// return false +// }) func (s Storage) MustQuery(options ...QueryOption) Recordset { rs, err := s.Query(options...) if err != nil { @@ -151,11 +196,39 @@ func (s Storage) Get(indexName, indexKey string) (_ Record, found bool) { return nil, false } +// GetByID returns a record whose ID matches the specified ID. +func (s Storage) GetByID(id uint64) (_ Record, found bool) { + var ( + record Record + iter = s.collection.Get(collection.IDIndex, seqid.ID(id).String()) + ) + if iter.Next() { + return iter.Value().Obj.(Record), true + } + return nil, false +} + // Delete deletes a record from the storage. func (s Storage) Delete(id uint64) bool { return s.collection.Delete(id) } +func (s Storage) getIndexRecordsKeys(indexName, indexKey string) ([]string, error) { + idx := s.collection.GetIndex(indexName) + if idx == nil { + return nil, errors.New("storage index for query not found: " + indexName) + } + + var keys []string + if v, found := idx.Get(indexKey); found { + keys = castIfaceToRecordKeys(v) + if keys == nil { + return nil, errors.New("unexpected storage index key format") + } + } + return keys, nil +} + func castIfaceToRecordKeys(v interface{}) []string { switch k := v.(type) { case []string: diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno index 8a584536b24..6c03374a229 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -41,124 +41,184 @@ func TestStorageNewRecord(t *testing.T) { uassert.Equal(t, defaultValue, got) } -func TestStorageIterate(t *testing.T) { - index := NewIndex("name", func(r Record) string { - if v, found := r.Get("name"); found { +func TestStorageQuery(t *testing.T) { + index := NewIndex("tag", func(r Record) string { + if v, found := r.Get("tag"); found { return v.(string) } return "" }) cases := []struct { - name string - recordIDs []uint64 - setup func(*Storage) + name string + options []QueryOption + results []uint64 + setup func() *Storage + errMsg string }{ { - name: "single record", - recordIDs: []uint64{1}, - setup: func(s *Storage) { - r := s.NewRecord() - r.Set("name", "foobar") - r.Save() + name: "default query", + results: []uint64{1, 2}, + setup: func() *Storage { + s := NewStorage("foo") + s.NewRecord().Save() + s.NewRecord().Save() + return &s }, }, { - name: "two records", - recordIDs: []uint64{1, 2}, - setup: func(s *Storage) { + name: "with size", + results: []uint64{1}, + options: []QueryOption{WithSize(1)}, + setup: func() *Storage { + s := NewStorage("foo") + s.NewRecord().Save() + s.NewRecord().Save() + return &s + }, + }, + { + name: "with offset", + results: []uint64{2}, + options: []QueryOption{WithOffset(1)}, + setup: func() *Storage { + s := NewStorage("foo") + s.NewRecord().Save() + s.NewRecord().Save() + return &s + }, + }, + { + name: "with offset overflow", + options: []QueryOption{WithOffset(4)}, + setup: func() *Storage { + s := NewStorage("foo") + s.NewRecord().Save() + return &s + }, + }, + { + name: "with size and offset", + results: []uint64{2, 3}, + options: []QueryOption{WithSize(2), WithOffset(1)}, + setup: func() *Storage { + s := NewStorage("foo") + s.NewRecord().Save() + s.NewRecord().Save() + s.NewRecord().Save() + s.NewRecord().Save() + return &s + }, + }, + { + name: "custom index", + options: []QueryOption{UseIndex("tag", "A")}, + results: []uint64{1, 3}, + setup: func() *Storage { + s := NewStorage("foo", WithIndex(index)) + r := s.NewRecord() - r.Set("name", "foobar") + r.Set("tag", "A") r.Save() r = s.NewRecord() - r.Set("name", "foobar") + r.Set("tag", "B") r.Save() + + r = s.NewRecord() + r.Set("tag", "A") + r.Save() + + return &s }, }, { - 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) - } + name: "custom index with offset", + options: []QueryOption{UseIndex("tag", "B"), WithOffset(1)}, + results: []uint64{3, 4}, + setup: func() *Storage { + s := NewStorage("foo", WithIndex(index)) - var records []Record - storage.Iterate("", "", func(r Record) bool { - records = append(records, r) - return false - }) + r := s.NewRecord() + r.Set("tag", "B") + r.Save() - urequire.Equal(t, len(tc.recordIDs), len(records), "results count") - for i, r := range records { - uassert.Equal(t, tc.recordIDs[i], r.ID()) - } - }) - } -} + r = s.NewRecord() + r.Set("tag", "A") + r.Save() -func TestStorageReverseIterate(t *testing.T) { - index := NewIndex("name", func(r Record) string { - if v, found := r.Get("name"); found { - return v.(string) - } - return "" - }) + r = s.NewRecord() + r.Set("tag", "B") + r.Save() - 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 = s.NewRecord() + r.Set("tag", "B") r.Save() + + return &s }, }, { - name: "two records", - recordIDs: []uint64{2, 1}, - setup: func(s *Storage) { + name: "custom index with offset and size", + options: []QueryOption{UseIndex("tag", "B"), WithOffset(1), WithSize(1)}, + results: []uint64{3}, + setup: func() *Storage { + s := NewStorage("foo", WithIndex(index)) + r := s.NewRecord() - r.Set("name", "foobar") + r.Set("tag", "B") r.Save() r = s.NewRecord() - r.Set("name", "foobar") + r.Set("tag", "A") r.Save() + + r = s.NewRecord() + r.Set("tag", "B") + r.Save() + + r = s.NewRecord() + r.Set("tag", "B") + r.Save() + + return &s }, }, { - name: "empty storage", + name: "custom index not found", + options: []QueryOption{UseIndex("foo", "B")}, + setup: func() *Storage { + s := NewStorage("foo") + return &s + }, + errMsg: "storage index for query not found: foo", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - storage := NewStorage("foo", WithDefaultIndex(index)) - if tc.setup != nil { - tc.setup(&storage) + // Arrange + storage := tc.setup() + + // Act + rs, err := storage.Query(tc.options...) + + // Assert + if tc.errMsg != "" { + uassert.ErrorContains(t, err, tc.errMsg, "expect error") + return } - var records []Record - storage.ReverseIterate("", "", func(r Record) bool { - records = append(records, r) + urequire.NoError(t, err, "expect no error") + urequire.NotEqual(t, nil, rs, "new record is not nil") + urequire.Equal(t, len(tc.results), rs.Size(), "expect query results count to match") + + var i int + rs.Iterate(func(r Record) bool { + urequire.Equal(t, tc.results[i], r.ID(), "expect result IDs to match") + i++ 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()) - } }) } } @@ -172,15 +232,15 @@ func TestStorageGet(t *testing.T) { }) cases := []struct { - name string - key string - recordIDs []uint64 - setup func(*Storage) + name string + key string + recordID uint64 + setup func(*Storage) }{ { - name: "single record", - key: "foobar", - recordIDs: []uint64{1}, + name: "single record", + key: "foobar", + recordID: 1, setup: func(s *Storage) { r := s.NewRecord() r.Set("name", "foobar") @@ -188,9 +248,9 @@ func TestStorageGet(t *testing.T) { }, }, { - name: "two records", - key: "foobar", - recordIDs: []uint64{1, 2}, + name: "two records", + key: "foobar", + recordID: 1, setup: func(s *Storage) { r := s.NewRecord() r.Set("name", "foobar") @@ -199,10 +259,14 @@ func TestStorageGet(t *testing.T) { r = s.NewRecord() r.Set("name", "foobar") r.Save() + + r = s.NewRecord() + r.Set("name", "extra") + r.Save() }, }, { - name: "no records found", + name: "record not found", key: "unknown", setup: func(s *Storage) { r := s.NewRecord() @@ -212,100 +276,89 @@ func TestStorageGet(t *testing.T) { }, { name: "empty storage", - key: "unknown", + key: "foobar", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - storage := NewStorage("foo", WithDefaultIndex(index)) + storage := NewStorage("foo", WithIndex(index)) if tc.setup != nil { tc.setup(&storage) } - records := storage.Get(tc.key) + r, found := storage.Get(index.Name(), 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()) + if tc.recordID == 0 { + uassert.Equal(t, nil, r, "expect no record") + uassert.False(t, found, "expect record not found") + return } + + uassert.True(t, found, "expect record found") + urequire.NotEqual(t, nil, r, "expect record to be found") + uassert.Equal(t, tc.recordID, r.ID(), "expect ID to match") }) } } -func TestStorageGetFirst(t *testing.T) { - index := NewIndex("name", func(r Record) string { - if v, found := r.Get("name"); found { - return v.(string) - } - return "" - }) - +func TestStorageGetByID(t *testing.T) { cases := []struct { name string - key string recordID uint64 + found bool setup func(*Storage) }{ { name: "single record", - key: "foobar", recordID: 1, + found: true, setup: func(s *Storage) { - r := s.NewRecord() - r.Set("name", "foobar") - r.Save() + s.NewRecord().Save() }, }, { - name: "two records", - key: "foobar", - recordID: 1, + name: "multiple records", + recordID: 2, + found: true, 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() + s.NewRecord().Save() + s.NewRecord().Save() + s.NewRecord().Save() }, }, { - name: "record not found", - key: "unknown", + name: "record not found", + recordID: 3, setup: func(s *Storage) { - r := s.NewRecord() - r.Set("name", "foobar") - r.Save() + s.NewRecord().Save() + s.NewRecord().Save() }, }, { - name: "empty storage", - key: "foobar", + name: "empty storage", + recordID: 1, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - storage := NewStorage("foo", WithDefaultIndex(index)) + storage := NewStorage("foo") if tc.setup != nil { tc.setup(&storage) } - r := storage.GetFirst(tc.key) + r, found := storage.GetByID(tc.recordID) - if tc.recordID == 0 { - urequire.Equal(t, nil, r, "record not found") + if !tc.found { + uassert.Equal(t, nil, r, "expect no record") + uassert.False(t, found, "expect record not found") return } - urequire.NotEqual(t, nil, r, "record found") - uassert.Equal(t, tc.recordID, r.ID()) + uassert.True(t, found, "expect record found") + urequire.NotEqual(t, nil, r, "expect record to be found") + uassert.Equal(t, tc.recordID, r.ID(), "expect ID to match") }) } } From 0eab92ab5ae8624473e1f57ae29aeeaff7ce484c Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 22:58:07 +0100 Subject: [PATCH 24/27] docs: add query examples to package documentation --- .../gno.land/p/jeronimoalbi/datastore/doc.gno | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno index 1ceea2e2b9a..613c7bd7e35 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/doc.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/doc.gno @@ -62,9 +62,37 @@ // // Example query usage: // -// ... TODO +// var db datastore.Datastore +// +// // Create a query with a custom offset and size +// storage := db.GetStorage("users") +// recordset, err := storage.Query(datastore.WithOffset(100), datastore.WithSize(50)) +// if err != nil { +// panic(err) +// } +// +// // Get all query results +// var records []Record +// recordset.Iterate(func(r datastore.Record) bool { +// records = append(records, r) +// return false +// }) // // Example query using a custom index usage: // -// ... TODO +// var db datastore.Datastore +// +// // Create a query to get records using a custom pre-defined index +// storage := db.GetStorage("posts") +// recordset, err := storage.Query(datastore.UseIndex("tags", "tagname")) +// if err != nil { +// panic(err) +// } +// +// // Get all query results +// var records []Record +// recordset.Iterate(func(r datastore.Record) bool { +// records = append(records, r) +// return false +// }) package datastore From b3909fea8596ad21841db620ea4a079ab48de7e7 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 23:06:03 +0100 Subject: [PATCH 25/27] test: fix record iteration tests --- .../gno.land/p/jeronimoalbi/datastore/record.gno | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/record.gno b/examples/gno.land/p/jeronimoalbi/datastore/record.gno index 1661f9fed0f..47539bdb759 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/record.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -182,6 +182,10 @@ func (rs recordset) Iterate(fn RecordIterFn) (stopped bool) { offset := rs.query.Offset() count := rs.query.Size() + if count == 0 { + count = rs.records.Size() + } + return rs.records.IterateByOffset(offset, count, func(_ string, v interface{}) bool { return fn(v.(Record)) }) @@ -205,8 +209,12 @@ func (rs recordset) ReverseIterate(fn RecordIterFn) (stopped bool) { } offset := rs.query.Offset() - size := rs.query.Size() - return rs.records.ReverseIterateByOffset(offset, size, func(_ string, v interface{}) bool { + count := rs.query.Size() + if count == 0 { + count = rs.records.Size() + } + + return rs.records.ReverseIterateByOffset(offset, count, func(_ string, v interface{}) bool { return fn(v.(Record)) }) } From 6b3dc946edd459bd1f4773d3970bdf0015b119a5 Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 23:12:17 +0100 Subject: [PATCH 26/27] fix: correct code issues --- examples/gno.land/p/jeronimoalbi/datastore/storage.gno | 10 ++-------- .../gno.land/p/jeronimoalbi/datastore/storage_test.gno | 1 - 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno index bbd918ccc4c..7f5e941650c 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -186,10 +186,7 @@ func (s Storage) MustQuery(options ...QueryOption) Recordset { // To get multiple records create a query using a custom index and key value // or use the underlying storage collection. func (s Storage) Get(indexName, indexKey string) (_ Record, found bool) { - var ( - record Record - iter = s.collection.Get(indexName, indexKey) - ) + iter := s.collection.Get(indexName, indexKey) if iter.Next() { return iter.Value().Obj.(Record), true } @@ -198,10 +195,7 @@ func (s Storage) Get(indexName, indexKey string) (_ Record, found bool) { // GetByID returns a record whose ID matches the specified ID. func (s Storage) GetByID(id uint64) (_ Record, found bool) { - var ( - record Record - iter = s.collection.Get(collection.IDIndex, seqid.ID(id).String()) - ) + iter := s.collection.Get(collection.IDIndex, seqid.ID(id).String()) if iter.Next() { return iter.Value().Obj.(Record), true } diff --git a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno index 6c03374a229..8d7b79cf803 100644 --- a/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -6,7 +6,6 @@ import ( "gno.land/p/demo/uassert" "gno.land/p/demo/urequire" - "gno.land/p/moul/collection" ) func TestStorageDefaults(t *testing.T) { From 430966e0dac19249f59876d228f0318fd074bf5a Mon Sep 17 00:00:00 2001 From: jeronimoalbi Date: Sat, 22 Feb 2025 23:34:12 +0100 Subject: [PATCH 27/27] chore: rename `indexes.gno` to `index.gno` --- .../gno.land/p/jeronimoalbi/datastore/{indexes.gno => index.gno} | 0 .../p/jeronimoalbi/datastore/{indexes_test.gno => index_test.gno} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/gno.land/p/jeronimoalbi/datastore/{indexes.gno => index.gno} (100%) rename examples/gno.land/p/jeronimoalbi/datastore/{indexes_test.gno => index_test.gno} (100%) diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno b/examples/gno.land/p/jeronimoalbi/datastore/index.gno similarity index 100% rename from examples/gno.land/p/jeronimoalbi/datastore/indexes.gno rename to examples/gno.land/p/jeronimoalbi/datastore/index.gno diff --git a/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/index_test.gno similarity index 100% rename from examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno rename to examples/gno.land/p/jeronimoalbi/datastore/index_test.gno