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..0893ba11a1a --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/datastore.gno @@ -0,0 +1,54 @@ +package datastore + +import ( + "errors" + + "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") + +// 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 + } + + s := NewStorage(name, options...) + ds.storages.Set(name, &s) + 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) + } + return nil +} 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()) +} 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 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 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..0bcba34ca96 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes.gno @@ -0,0 +1,100 @@ +package datastore + +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 + fn interface{} + } +) + +// 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, + options: DefaultIndexOptions, + fn: func(v interface{}) string { + return fn(v.(Record)) + }, + } +} + +// 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, + options: DefaultIndexOptions, + fn: func(v interface{}) []string { + return fn(v.(Record)) + }, + } +} + +// 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 + } + 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 + } + 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..8f90a82c85d --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/indexes_test.gno @@ -0,0 +1,98 @@ +package datastore + +import ( + "testing" + + "gno.land/p/demo/uassert" + "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) + }) + } +} 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..9a52dbab4d3 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/record.gno @@ -0,0 +1,142 @@ +package datastore + +import ( + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/demo/seqid" + "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 returns the value of a record's field. + Get(field string) (value interface{}, found bool) + + // MustGet returns the value of a record's field or panics when the field is not found. + MustGet(field string) interface{} + } +) + +type record struct { + id uint64 + schema *Schema + collection *collection.Collection + 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 { + if r.schema.IsStrict() { + return ErrUndefinedField + } + + i, _ = r.schema.AddField(field, nil) + } + + key := castIntToKey(i) + r.values.Set(key, value) + 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 { + return nil, false + } + + key := castIntToKey(i) + 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 { + panic("field not found: " + field) + } + 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) + 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..d0e842e222d --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/record_test.gno @@ -0,0 +1,172 @@ +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, "0000000", r.Key()) + 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, 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() }, + }, + { + name: "create second record", + id: 2, + key: "0000002", + fieldValue: "bar", + storageSize: 2, + setup: func(s Storage) Record { return s.NewRecord() }, + }, + { + name: "update second record", + id: 2, + key: "0000002", + fieldValue: "baz", + storageSize: 2, + 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") + uassert.Equal(t, tc.storageSize, storage.Size()) + + 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)) + }) + } +} 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..89abf2bc695 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema.gno @@ -0,0 +1,149 @@ +package datastore + +import ( + "encoding/binary" + + "gno.land/p/demo/avl" + "gno.land/p/demo/avl/list" +) + +// 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 + fields list.List // int(field index) -> string(field name) + 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 { + apply(s) + } + 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 { + fields[i] = v.(string) + return false + }) + 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 + } + + s.fields.Append(name) + index = s.fields.Len() - 1 + if defaultValue != nil { + key := castIntToKey(index) + s.defaults.Set(key, defaultValue) + } + 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 { + if name != v.(string) { + return false + } + + index = i + return true + }) + 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 { + return "", false + } + 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 { + return nil, false + } + 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) + if !found { + return nil, false + } + + if fn, ok := v.(func() interface{}); ok { + return fn(), true + } + return v, true +} + +// RenameField renames a field. +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..78054b13255 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_options.gno @@ -0,0 +1,41 @@ +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) + if name != "" { + s.fields.Append(name) + } + } +} + +// 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) + if name != "" { + s.fields.Append(name) + + key := castIntToKey(s.fields.Len() - 1) + s.defaults.Set(key, value) + } + } +} + +// 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/schema_test.gno b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno new file mode 100644 index 00000000000..f764d85dba2 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/schema_test.gno @@ -0,0 +1,210 @@ +package datastore + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestSchemaNew(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) { + s := NewSchema("Foo", tc.options...) + + 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) { + s := NewSchema("Foo", tc.options...) + + index, added := s.AddField(tc.fieldName, nil) + + 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)) + } +} 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..7998fa39132 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage.gno @@ -0,0 +1,207 @@ +package datastore + +import ( + "strings" + + "gno.land/p/moul/collection" +) + +// 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)), + } + + for _, apply := range options { + apply(&s) + } + 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 + collection *collection.Collection + 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, + 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 +} + +// 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 +} + +// 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 + } + + // 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 + }) +} + +// 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)) + } + 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 + iter = s.collection.Get(s.currentIndex, key) + ) + if iter.Next() { + record = iter.Value().Obj.(Record) + } + return record +} + +// Delete deletes a record from the storage. +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 +} 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..7e3b9e82e33 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_options.gno @@ -0,0 +1,30 @@ +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 { + st.schema = s + } + } +} + +// 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() + 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..921692d0e3a --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datastore/storage_test.gno @@ -0,0 +1,334 @@ +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()) + uassert.Equal(t, 0, storage.Size()) + + 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) +} + +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) +}