diff --git a/examples/gno.land/p/jeronimoalbi/datasource/datasource.gno b/examples/gno.land/p/jeronimoalbi/datasource/datasource.gno new file mode 100644 index 00000000000..bf80964a9a0 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datasource/datasource.gno @@ -0,0 +1,103 @@ +// Package datasource defines generic interfaces for datasources. +// +// Datasources contain a set of records which can optionally be +// taggable. Tags can optionally be used to filter records by taxonomy. +// +// Datasources can help in cases where the data sent during +// communication between different realms needs to be generic +// to avoid direct dependencies. +package datasource + +import "errors" + +// ErrInvalidRecord indicates that a datasource contains invalid records. +var ErrInvalidRecord = errors.New("datasource records is not valid") + +type ( + // Fields defines an interface for read-only fields. + Fields interface { + // Has checks whether a field exists. + Has(name string) bool + + // Get retrieves the value associated with the given field. + Get(name string) (value interface{}, found bool) + } + + // Record defines a datasource record. + Record interface { + // ID returns the unique record's identifier. + ID() string + + // String returns a string representation of the record. + String() string + + // Fields returns record fields and values. + Fields() (Fields, error) + } + + // TaggableRecord defines a datasource record that supports tags. + // Tags can be used to build a taxonomy to filter records by category. + TaggableRecord interface { + // Tags returns a list of tags for the record. + Tags() []string + } + + // ContentRecord defines a datasource record that can return content. + ContentRecord interface { + // Content returns the record content. + Content() (string, error) + } + + // Iterator defines an iterator of datasource records. + Iterator interface { + // Next returns true when a new record is available. + Next() bool + + // Err returns any error raised when reading records. + Err() error + + // Record returns the current record. + Record() Record + } + + // Datasource defines a generic datasource. + Datasource interface { + // Records returns a new datasource records iterator. + Records(Query) Iterator + + // Size returns the total number of records in the datasource. + // When -1 is returned it means datasource doesn't support size. + Size() int + + // Record returns a single datasource record. + Record(id string) (Record, error) + } +) + +// NewIterator returns a new record iterator for a datasource query. +func NewIterator(ds Datasource, options ...QueryOption) Iterator { + return ds.Records(NewQuery(options...)) +} + +// QueryRecords return a slice of records for a datasource query. +func QueryRecords(ds Datasource, options ...QueryOption) ([]Record, error) { + var ( + records []Record + query = NewQuery(options...) + iter = ds.Records(query) + ) + + for i := 0; i < query.Count && iter.Next(); i++ { + r := iter.Record() + if r == nil { + return nil, ErrInvalidRecord + } + + records = append(records, r) + } + + if err := iter.Err(); err != nil { + return nil, err + } + return records, nil +} diff --git a/examples/gno.land/p/jeronimoalbi/datasource/datasource_test.gno b/examples/gno.land/p/jeronimoalbi/datasource/datasource_test.gno new file mode 100644 index 00000000000..304a311ced7 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datasource/datasource_test.gno @@ -0,0 +1,171 @@ +package datasource + +import ( + "errors" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestNewIterator(t *testing.T) { + cases := []struct { + name string + records []Record + err error + }{ + { + name: "ok", + records: []Record{ + testRecord{id: "1"}, + testRecord{id: "2"}, + testRecord{id: "3"}, + }, + }, + { + name: "error", + err: errors.New("test"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + ds := testDatasource{ + records: tc.records, + err: tc.err, + } + + // Act + iter := NewIterator(ds) + + // Assert + if tc.err != nil { + uassert.ErrorIs(t, tc.err, iter.Err()) + return + } + + uassert.NoError(t, iter.Err()) + + for i := 0; iter.Next(); i++ { + r := iter.Record() + urequire.NotEqual(t, nil, r, "valid record") + urequire.True(t, i < len(tc.records), "iteration count") + uassert.Equal(t, tc.records[i].ID(), r.ID()) + } + }) + } +} + +func TestQueryRecords(t *testing.T) { + cases := []struct { + name string + records []Record + recordCount int + options []QueryOption + err error + }{ + { + name: "ok", + records: []Record{ + testRecord{id: "1"}, + testRecord{id: "2"}, + testRecord{id: "3"}, + }, + recordCount: 3, + }, + { + name: "with count", + options: []QueryOption{WithCount(2)}, + records: []Record{ + testRecord{id: "1"}, + testRecord{id: "2"}, + testRecord{id: "3"}, + }, + recordCount: 2, + }, + { + name: "invalid record", + records: []Record{ + testRecord{id: "1"}, + nil, + testRecord{id: "3"}, + }, + err: ErrInvalidRecord, + }, + { + name: "iterator error", + records: []Record{ + testRecord{id: "1"}, + testRecord{id: "3"}, + }, + err: errors.New("test"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + ds := testDatasource{ + records: tc.records, + err: tc.err, + } + + // Act + records, err := QueryRecords(ds, tc.options...) + + // Assert + if tc.err != nil { + uassert.ErrorIs(t, tc.err, err) + return + } + + uassert.NoError(t, err) + + urequire.Equal(t, tc.recordCount, len(records), "record count") + for i, r := range records { + urequire.NotEqual(t, nil, r, "valid record") + uassert.Equal(t, tc.records[i].ID(), r.ID()) + } + }) + } +} + +type testDatasource struct { + records []Record + err error +} + +func (testDatasource) Size() int { return -1 } +func (testDatasource) Record(string) (Record, error) { return nil, nil } +func (ds testDatasource) Records(Query) Iterator { return &testIter{records: ds.records, err: ds.err} } + +type testRecord struct { + id string + fields Fields + err error +} + +func (r testRecord) ID() string { return r.id } +func (r testRecord) String() string { return "str" + r.id } +func (r testRecord) Fields() (Fields, error) { return r.fields, r.err } + +type testIter struct { + index int + records []Record + current Record + err error +} + +func (it testIter) Err() error { return it.err } +func (it testIter) Record() Record { return it.current } + +func (it *testIter) Next() bool { + count := len(it.records) + if it.err != nil || count == 0 || it.index >= count { + return false + } + it.current = it.records[it.index] + it.index++ + return true +} diff --git a/examples/gno.land/p/jeronimoalbi/datasource/gno.mod b/examples/gno.land/p/jeronimoalbi/datasource/gno.mod new file mode 100644 index 00000000000..3b398971b41 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datasource/gno.mod @@ -0,0 +1 @@ +module gno.land/p/jeronimoalbi/datasource diff --git a/examples/gno.land/p/jeronimoalbi/datasource/query.gno b/examples/gno.land/p/jeronimoalbi/datasource/query.gno new file mode 100644 index 00000000000..f971f9c64db --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datasource/query.gno @@ -0,0 +1,70 @@ +package datasource + +import "gno.land/p/demo/avl" + +// DefaultQueryRecords defines the default number of records returned by queries. +const DefaultQueryRecords = 50 + +var defaultQuery = Query{Count: DefaultQueryRecords} + +type ( + // QueryOption configures datasource queries. + QueryOption func(*Query) + + // Query contains datasource query options. + Query struct { + // Offset of the first record to return during iteration. + Offset int + + // Count contains the number to records that query should return. + Count int + + // Tag contains a tag to use as filter for the records. + Tag string + + // Filters contains optional query filters by field value. + Filters avl.Tree + } +) + +// WithOffset configures query to return records starting from an offset. +func WithOffset(offset int) QueryOption { + return func(q *Query) { + q.Offset = offset + } +} + +// WithCount configures the number of records that query returns. +func WithCount(count int) QueryOption { + return func(q *Query) { + if count < 1 { + count = DefaultQueryRecords + } + q.Count = count + } +} + +// ByTag configures query to filter by tag. +func ByTag(tag string) QueryOption { + return func(q *Query) { + q.Tag = tag + } +} + +// WithFilter assigns a new filter argument to a query. +// This option can be used multiple times if more than one +// filter has to be given to the query. +func WithFilter(field string, value interface{}) QueryOption { + return func(q *Query) { + q.Filters.Set(field, value) + } +} + +// NewQuery creates a new datasource query. +func NewQuery(options ...QueryOption) Query { + q := defaultQuery + for _, apply := range options { + apply(&q) + } + return q +} diff --git a/examples/gno.land/p/jeronimoalbi/datasource/query_test.gno b/examples/gno.land/p/jeronimoalbi/datasource/query_test.gno new file mode 100644 index 00000000000..6f78d41bb35 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/datasource/query_test.gno @@ -0,0 +1,104 @@ +package datasource + +import ( + "fmt" + "testing" + + "gno.land/p/demo/uassert" +) + +func TestNewQuery(t *testing.T) { + cases := []struct { + name string + options []QueryOption + setup func() Query + }{ + { + name: "default", + setup: func() Query { + return Query{Count: DefaultQueryRecords} + }, + }, + { + name: "with offset", + options: []QueryOption{WithOffset(100)}, + setup: func() Query { + return Query{ + Offset: 100, + Count: DefaultQueryRecords, + } + }, + }, + { + name: "with count", + options: []QueryOption{WithCount(10)}, + setup: func() Query { + return Query{Count: 10} + }, + }, + { + name: "with invalid count", + options: []QueryOption{WithCount(0)}, + setup: func() Query { + return Query{Count: DefaultQueryRecords} + }, + }, + { + name: "by tag", + options: []QueryOption{ByTag("foo")}, + setup: func() Query { + return Query{ + Tag: "foo", + Count: DefaultQueryRecords, + } + }, + }, + { + name: "with filter", + options: []QueryOption{WithFilter("foo", 42)}, + setup: func() Query { + q := Query{Count: DefaultQueryRecords} + q.Filters.Set("foo", 42) + return q + }, + }, + { + name: "with multiple filters", + options: []QueryOption{ + WithFilter("foo", 42), + WithFilter("bar", "baz"), + }, + setup: func() Query { + q := Query{Count: DefaultQueryRecords} + q.Filters.Set("foo", 42) + q.Filters.Set("bar", "baz") + return q + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + want := tc.setup() + + // Act + q := NewQuery(tc.options...) + + // Assert + uassert.Equal(t, want.Offset, q.Offset) + uassert.Equal(t, want.Count, q.Count) + uassert.Equal(t, want.Tag, q.Tag) + uassert.Equal(t, want.Filters.Size(), q.Filters.Size()) + + want.Filters.Iterate("", "", func(k string, v interface{}) bool { + got, exists := q.Filters.Get(k) + uassert.True(t, exists) + if exists { + uassert.Equal(t, fmt.Sprint(v), fmt.Sprint(got)) + } + return false + }) + }) + } +}