Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generic datasource package #3318

Merged
merged 3 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions examples/gno.land/p/jeronimoalbi/datasource/datasource.gno
Original file line number Diff line number Diff line change
@@ -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
}
171 changes: 171 additions & 0 deletions examples/gno.land/p/jeronimoalbi/datasource/datasource_test.gno
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions examples/gno.land/p/jeronimoalbi/datasource/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/jeronimoalbi/datasource
70 changes: 70 additions & 0 deletions examples/gno.land/p/jeronimoalbi/datasource/query.gno
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading