feat(storage): rpc based implementation
All checks were successful
arcad/edge/pipeline/pr-master This commit looks good

This commit is contained in:
2023-09-12 22:03:25 -06:00
parent c3535a4a9b
commit 8e574c299b
113 changed files with 3007 additions and 263 deletions

View File

@ -1,14 +1,14 @@
package blob
import (
"io/ioutil"
"os"
"testing"
"cdr.dev/slog"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"forge.cadoles.com/arcad/edge/pkg/storage/driver/sqlite"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -27,7 +27,7 @@ func TestBlobModule(t *testing.T) {
ModuleFactory(bus, store),
)
data, err := ioutil.ReadFile("testdata/blob.js")
data, err := os.ReadFile("testdata/blob.js")
if err != nil {
t.Fatal(err)
}

View File

@ -1,8 +0,0 @@
package share
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrAttributeRequired = errors.New("attribute required")
)

View File

@ -5,18 +5,19 @@ import (
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"forge.cadoles.com/arcad/edge/pkg/storage/share"
"github.com/dop251/goja"
"github.com/pkg/errors"
)
const (
AnyType ValueType = "*"
AnyName string = "*"
AnyType share.ValueType = "*"
AnyName string = "*"
)
type Module struct {
appID app.ID
repository Repository
appID app.ID
store share.Store
}
func (m *Module) Name() string {
@ -48,19 +49,19 @@ func (m *Module) Export(export *goja.Object) {
panic(errors.Wrap(err, "could not set 'ANY_NAME' property"))
}
if err := export.Set("TYPE_TEXT", TypeText); err != nil {
if err := export.Set("TYPE_TEXT", share.TypeText); err != nil {
panic(errors.Wrap(err, "could not set 'TYPE_TEXT' property"))
}
if err := export.Set("TYPE_NUMBER", TypeNumber); err != nil {
if err := export.Set("TYPE_NUMBER", share.TypeNumber); err != nil {
panic(errors.Wrap(err, "could not set 'TYPE_NUMBER' property"))
}
if err := export.Set("TYPE_BOOL", TypeBool); err != nil {
if err := export.Set("TYPE_BOOL", share.TypeBool); err != nil {
panic(errors.Wrap(err, "could not set 'TYPE_BOOL' property"))
}
if err := export.Set("TYPE_PATH", TypePath); err != nil {
if err := export.Set("TYPE_PATH", share.TypePath); err != nil {
panic(errors.Wrap(err, "could not set 'TYPE_PATH' property"))
}
}
@ -69,20 +70,20 @@ func (m *Module) upsertResource(call goja.FunctionCall, rt *goja.Runtime) goja.V
ctx := util.AssertContext(call.Argument(0), rt)
resourceID := assertResourceID(call.Argument(1), rt)
var attributes []Attribute
var attributes []share.Attribute
if len(call.Arguments) > 2 {
attributes = assertAttributes(call.Arguments[2:], rt)
} else {
attributes = make([]Attribute, 0)
attributes = make([]share.Attribute, 0)
}
for _, attr := range attributes {
if err := AssertType(attr.Value(), attr.Type()); err != nil {
if err := share.AssertType(attr.Value(), attr.Type()); err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
}
resource, err := m.repository.UpdateAttributes(ctx, m.appID, resourceID, attributes...)
resource, err := m.store.UpdateAttributes(ctx, m.appID, resourceID, attributes...)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
@ -101,7 +102,7 @@ func (m *Module) deleteAttributes(call goja.FunctionCall, rt *goja.Runtime) goja
names = make([]string, 0)
}
err := m.repository.DeleteAttributes(ctx, m.appID, resourceID, names...)
err := m.store.DeleteAttributes(ctx, m.appID, resourceID, names...)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
@ -112,23 +113,23 @@ func (m *Module) deleteAttributes(call goja.FunctionCall, rt *goja.Runtime) goja
func (m *Module) findResources(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
funcs := make([]FindResourcesOptionFunc, 0)
funcs := make([]share.FindResourcesOptionFunc, 0)
if len(call.Arguments) > 1 {
name := util.AssertString(call.Argument(1), rt)
if name != AnyName {
funcs = append(funcs, WithName(name))
funcs = append(funcs, share.WithName(name))
}
}
if len(call.Arguments) > 2 {
valueType := assertValueType(call.Argument(2), rt)
if valueType != AnyType {
funcs = append(funcs, WithType(valueType))
funcs = append(funcs, share.WithType(valueType))
}
}
resources, err := m.repository.FindResources(ctx, funcs...)
resources, err := m.store.FindResources(ctx, funcs...)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
@ -140,7 +141,7 @@ func (m *Module) deleteResource(call goja.FunctionCall, rt *goja.Runtime) goja.V
ctx := util.AssertContext(call.Argument(0), rt)
resourceID := assertResourceID(call.Argument(1), rt)
err := m.repository.DeleteResource(ctx, m.appID, resourceID)
err := m.store.DeleteResource(ctx, m.appID, resourceID)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
@ -148,29 +149,29 @@ func (m *Module) deleteResource(call goja.FunctionCall, rt *goja.Runtime) goja.V
return nil
}
func ModuleFactory(appID app.ID, repository Repository) app.ServerModuleFactory {
func ModuleFactory(appID app.ID, store share.Store) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &Module{
appID: appID,
repository: repository,
appID: appID,
store: store,
}
}
}
func assertResourceID(v goja.Value, r *goja.Runtime) ResourceID {
func assertResourceID(v goja.Value, r *goja.Runtime) share.ResourceID {
value := v.Export()
switch typ := value.(type) {
case string:
return ResourceID(typ)
case ResourceID:
return share.ResourceID(typ)
case share.ResourceID:
return typ
default:
panic(r.ToValue(errors.Errorf("expected value to be a string or ResourceID, got '%T'", value)))
}
}
func assertAttributes(values []goja.Value, r *goja.Runtime) []Attribute {
attributes := make([]Attribute, len(values))
func assertAttributes(values []goja.Value, r *goja.Runtime) []share.Attribute {
attributes := make([]share.Attribute, len(values))
for idx, val := range values {
export := val.Export()
@ -195,12 +196,12 @@ func assertAttributes(values []goja.Value, r *goja.Runtime) []Attribute {
panic(r.ToValue(errors.Errorf("could not find 'type' property on attribute '%v'", export)))
}
var valueType ValueType
var valueType share.ValueType
switch typ := rawType.(type) {
case ValueType:
case share.ValueType:
valueType = typ
case string:
valueType = ValueType(typ)
valueType = share.ValueType(typ)
default:
panic(r.ToValue(errors.Errorf("unexpected value for attribute property 'type': expected 'string' or 'ValueType', got '%T'", rawType)))
@ -211,7 +212,7 @@ func assertAttributes(values []goja.Value, r *goja.Runtime) []Attribute {
panic(r.ToValue(errors.Errorf("could not find 'value' property on attribute '%v'", export)))
}
attributes[idx] = NewBaseAttribute(
attributes[idx] = share.NewBaseAttribute(
name,
valueType,
value,
@ -232,12 +233,12 @@ func assertStrings(values []goja.Value, r *goja.Runtime) []string {
return strings
}
func assertValueType(v goja.Value, r *goja.Runtime) ValueType {
func assertValueType(v goja.Value, r *goja.Runtime) share.ValueType {
value := v.Export()
switch typ := value.(type) {
case string:
return ValueType(typ)
case ValueType:
return share.ValueType(typ)
case share.ValueType:
return typ
default:
panic(r.ToValue(errors.Errorf("expected value to be a string or ValueType, got '%T'", value)))
@ -245,7 +246,7 @@ func assertValueType(v goja.Value, r *goja.Runtime) ValueType {
}
type gojaResource struct {
ID ResourceID `goja:"id" json:"id"`
ID share.ResourceID `goja:"id" json:"id"`
Origin app.ID `goja:"origin" json:"origin"`
Attributes []*gojaAttribute `goja:"attributes" json:"attributes"`
}
@ -254,7 +255,7 @@ func (r *gojaResource) Has(call goja.FunctionCall, rt *goja.Runtime) goja.Value
name := util.AssertString(call.Argument(0), rt)
valueType := assertValueType(call.Argument(1), rt)
hasAttr := HasAttribute(toResource(r), name, valueType)
hasAttr := share.HasAttribute(toResource(r), name, valueType)
return rt.ToValue(hasAttr)
}
@ -268,7 +269,7 @@ func (r *gojaResource) Get(call goja.FunctionCall, rt *goja.Runtime) goja.Value
defaultValue = call.Argument(2).Export()
}
attr := GetAttribute(toResource(r), name, valueType)
attr := share.GetAttribute(toResource(r), name, valueType)
if attr == nil {
return rt.ToValue(defaultValue)
@ -278,14 +279,14 @@ func (r *gojaResource) Get(call goja.FunctionCall, rt *goja.Runtime) goja.Value
}
type gojaAttribute struct {
Name string `goja:"name" json:"name"`
Type ValueType `goja:"type" json:"type"`
Value any `goja:"value" json:"value"`
CreatedAt time.Time `goja:"createdAt" json:"createdAt"`
UpdatedAt time.Time `goja:"updatedAt" json:"updatedAt"`
Name string `goja:"name" json:"name"`
Type share.ValueType `goja:"type" json:"type"`
Value any `goja:"value" json:"value"`
CreatedAt time.Time `goja:"createdAt" json:"createdAt"`
UpdatedAt time.Time `goja:"updatedAt" json:"updatedAt"`
}
func toGojaResource(res Resource) *gojaResource {
func toGojaResource(res share.Resource) *gojaResource {
attributes := make([]*gojaAttribute, len(res.Attributes()))
for idx, attr := range res.Attributes() {
@ -305,7 +306,7 @@ func toGojaResource(res Resource) *gojaResource {
}
}
func toGojaResources(resources []Resource) []*gojaResource {
func toGojaResources(resources []share.Resource) []*gojaResource {
gojaResources := make([]*gojaResource, len(resources))
for idx, res := range resources {
gojaResources[idx] = toGojaResource(res)
@ -313,19 +314,19 @@ func toGojaResources(resources []Resource) []*gojaResource {
return gojaResources
}
func toResource(res *gojaResource) Resource {
return NewBaseResource(
func toResource(res *gojaResource) share.Resource {
return share.NewBaseResource(
res.Origin,
res.ID,
toAttributes(res.Attributes)...,
)
}
func toAttributes(gojaAttributes []*gojaAttribute) []Attribute {
attributes := make([]Attribute, len(gojaAttributes))
func toAttributes(gojaAttributes []*gojaAttribute) []share.Attribute {
attributes := make([]share.Attribute, len(gojaAttributes))
for idx, gojaAttr := range gojaAttributes {
attr := NewBaseAttribute(
attr := share.NewBaseAttribute(
gojaAttr.Name,
gojaAttr.Type,
gojaAttr.Value,

View File

@ -1,21 +1,23 @@
package testsuite
package share
import (
"context"
"io/fs"
"os"
"testing"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/module/share"
"forge.cadoles.com/arcad/edge/pkg/storage/driver"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "forge.cadoles.com/arcad/edge/pkg/storage/driver/sqlite"
)
func TestModule(t *testing.T, newRepo NewTestRepoFunc) {
func TestModule(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
repo, err := newRepo("module")
store, err := driver.NewShareStore("sqlite://testdata/test_share_module.sqlite")
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
@ -23,10 +25,10 @@ func TestModule(t *testing.T, newRepo NewTestRepoFunc) {
server := app.NewServer(
module.ContextModuleFactory(),
module.ConsoleModuleFactory(),
share.ModuleFactory("test.app.edge", repo),
ModuleFactory("test.app.edge", store),
)
data, err := fs.ReadFile(testData, "testdata/share.js")
data, err := os.ReadFile("testdata/share.js")
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}

View File

@ -1,30 +0,0 @@
package share
type FindResourcesOptionFunc func(*FindResourcesOptions)
type FindResourcesOptions struct {
Name *string
ValueType *ValueType
}
func FillFindResourcesOptions(funcs ...FindResourcesOptionFunc) *FindResourcesOptions {
opts := &FindResourcesOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithName(name string) FindResourcesOptionFunc {
return func(opts *FindResourcesOptions) {
opts.Name = &name
}
}
func WithType(valueType ValueType) FindResourcesOptionFunc {
return func(opts *FindResourcesOptions) {
opts.ValueType = &valueType
}
}

View File

@ -1,32 +0,0 @@
package share
import (
"context"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
)
type ResourceID string
type Resource interface {
ID() ResourceID
Origin() app.ID
Attributes() []Attribute
}
type Attribute interface {
Name() string
Value() any
Type() ValueType
UpdatedAt() time.Time
CreatedAt() time.Time
}
type Repository interface {
DeleteResource(ctx context.Context, origin app.ID, resourceID ResourceID) error
FindResources(ctx context.Context, funcs ...FindResourcesOptionFunc) ([]Resource, error)
GetResource(ctx context.Context, origin app.ID, resourceID ResourceID) (Resource, error)
UpdateAttributes(ctx context.Context, origin app.ID, resourceID ResourceID, attributes ...Attribute) (Resource, error)
DeleteAttributes(ctx context.Context, origin app.ID, resourceID ResourceID, names ...string) error
}

View File

@ -1,121 +0,0 @@
package share
import (
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
)
type BaseResource struct {
id ResourceID
origin app.ID
attributes []Attribute
}
// Attributes implements Resource
func (r *BaseResource) Attributes() []Attribute {
return r.attributes
}
// ID implements Resource
func (r *BaseResource) ID() ResourceID {
return r.id
}
// Origin implements Resource
func (r *BaseResource) Origin() app.ID {
return r.origin
}
func (r *BaseResource) SetAttribute(attr Attribute) {
for idx, rAttr := range r.attributes {
if rAttr.Name() != attr.Name() {
continue
}
r.attributes[idx] = attr
return
}
r.attributes = append(r.attributes, attr)
}
func NewBaseResource(origin app.ID, resourceID ResourceID, attributes ...Attribute) *BaseResource {
return &BaseResource{
id: resourceID,
origin: origin,
attributes: attributes,
}
}
var _ Resource = &BaseResource{}
type BaseAttribute struct {
name string
valueType ValueType
value any
createdAt time.Time
updatedAt time.Time
}
// CreatedAt implements Attribute
func (a *BaseAttribute) CreatedAt() time.Time {
return a.createdAt
}
// Name implements Attribute
func (a *BaseAttribute) Name() string {
return a.name
}
// Type implements Attribute
func (a *BaseAttribute) Type() ValueType {
return a.valueType
}
// UpdatedAt implements Attribute
func (a *BaseAttribute) UpdatedAt() time.Time {
return a.updatedAt
}
// Value implements Attribute
func (a *BaseAttribute) Value() any {
return a.value
}
func (a *BaseAttribute) SetCreatedAt(createdAt time.Time) {
a.createdAt = createdAt
}
func (a *BaseAttribute) SetUpdatedAt(updatedAt time.Time) {
a.updatedAt = updatedAt
}
func NewBaseAttribute(name string, valueType ValueType, value any) *BaseAttribute {
return &BaseAttribute{
name: name,
valueType: valueType,
value: value,
}
}
var _ Attribute = &BaseAttribute{}
func HasAttribute(res Resource, name string, valueType ValueType) bool {
return GetAttribute(res, name, valueType) != nil
}
func GetAttribute(res Resource, name string, valueType ValueType) Attribute {
for _, attr := range res.Attributes() {
if attr.Name() != name {
continue
}
if attr.Type() == valueType {
return attr
}
}
return nil
}

View File

@ -1,13 +0,0 @@
package sqlite
import (
"testing"
"forge.cadoles.com/arcad/edge/pkg/module/share/testsuite"
"gitlab.com/wpetit/goweb/logger"
)
func TestModule(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
testsuite.TestModule(t, newTestRepo)
}

View File

@ -1,429 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/share"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Repository struct {
getDB sqlite.GetDBFunc
}
// DeleteAttributes implements share.Repository
func (r *Repository) DeleteAttributes(ctx context.Context, origin app.ID, resourceID share.ResourceID, names ...string) error {
err := r.withTx(ctx, func(tx *sql.Tx) error {
query := `
DELETE FROM resources
WHERE origin = $1 AND resource_id = $2
`
args := []any{origin, resourceID}
criteria := ""
for idx, name := range names {
if idx == 0 {
criteria += " AND ("
}
if idx != 0 {
criteria += " OR "
}
criteria += fmt.Sprintf(" name = $%d", len(args)+1)
args = append(args, name)
if idx == len(names)-1 {
criteria += " )"
}
}
query += criteria
logger.Debug(
ctx, "executing query",
logger.F("query", query),
logger.F("args", args),
)
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
affected, err := res.RowsAffected()
if err != nil {
return errors.WithStack(err)
}
if affected == 0 {
return errors.WithStack(share.ErrNotFound)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return err
}
// DeleteResource implements share.Repository
func (r *Repository) DeleteResource(ctx context.Context, origin app.ID, resourceID share.ResourceID) error {
err := r.withTx(ctx, func(tx *sql.Tx) error {
query := `
DELETE FROM resources
WHERE origin = $1 AND resource_id = $2
`
args := []any{origin, resourceID}
logger.Debug(
ctx, "executing query",
logger.F("query", query),
logger.F("args", args),
)
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
affected, err := res.RowsAffected()
if err != nil {
return errors.WithStack(err)
}
if affected == 0 {
return errors.WithStack(share.ErrNotFound)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return err
}
// FindResources implements share.Repository
func (r *Repository) FindResources(ctx context.Context, funcs ...share.FindResourcesOptionFunc) ([]share.Resource, error) {
opts := share.FillFindResourcesOptions(funcs...)
var resources []share.Resource
err := r.withTx(ctx, func(tx *sql.Tx) error {
query := `
SELECT
main.origin, main.resource_id,
main.name, main.type, main.value,
main.created_at, main.updated_at
FROM resources AS main
JOIN resources AS sub ON
main.resource_id = sub.resource_id
AND main.origin = sub.origin
`
criteria := " WHERE 1 = 1"
preparedArgIndex := 1
args := make([]any, 0)
if opts.Name != nil {
criteria += fmt.Sprintf(" AND sub.name = $%d", preparedArgIndex)
args = append(args, *opts.Name)
preparedArgIndex++
}
if opts.ValueType != nil {
criteria += fmt.Sprintf(" AND sub.type = $%d", preparedArgIndex)
args = append(args, *opts.ValueType)
preparedArgIndex++
}
query += criteria
logger.Debug(
ctx, "executing query",
logger.F("query", query),
logger.F("args", args),
)
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
indexedResources := make(map[string]*share.BaseResource)
for rows.Next() {
var (
origin string
resourceID string
name string
valueType string
value any
updatedAt time.Time
createdAt time.Time
)
if err := rows.Scan(&origin, &resourceID, &name, &valueType, &value, &createdAt, &updatedAt); err != nil {
return errors.WithStack(err)
}
resourceKey := origin + resourceID
resource, exists := indexedResources[resourceKey]
if !exists {
resource = share.NewBaseResource(app.ID(origin), share.ResourceID(resourceID))
indexedResources[resourceKey] = resource
}
attr := share.NewBaseAttribute(
name,
share.ValueType(valueType),
value,
)
attr.SetCreatedAt(createdAt)
attr.SetUpdatedAt(updatedAt)
resource.SetAttribute(attr)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
resources = make([]share.Resource, 0, len(indexedResources))
for _, res := range indexedResources {
resources = append(resources, res)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return resources, nil
}
// GetResource implements share.Repository
func (r *Repository) GetResource(ctx context.Context, origin app.ID, resourceID share.ResourceID) (share.Resource, error) {
var (
resource *share.BaseResource
err error
)
err = r.withTx(ctx, func(tx *sql.Tx) error {
resource, err = r.getResourceWithinTx(ctx, tx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return resource, nil
}
// UpdateAttributes implements share.Repository
func (r *Repository) UpdateAttributes(ctx context.Context, origin app.ID, resourceID share.ResourceID, attributes ...share.Attribute) (share.Resource, error) {
if len(attributes) == 0 {
return nil, errors.WithStack(share.ErrAttributeRequired)
}
var resource *share.BaseResource
err := r.withTx(ctx, func(tx *sql.Tx) error {
query := `
INSERT INTO resources (origin, resource_id, name, type, value, created_at, updated_at)
VALUES($1, $2, $3, $4, $5, $6, $6)
ON CONFLICT (origin, resource_id, name) DO UPDATE SET
type = $4, value = $5, updated_at = $6
`
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := stmt.Close(); err != nil {
logger.Error(ctx, "could not close statement", logger.E(errors.WithStack(err)))
}
}()
now := time.Now().UTC()
for _, attr := range attributes {
args := []any{
string(origin), string(resourceID),
attr.Name(), string(attr.Type()), attr.Value(),
now, now,
}
logger.Debug(
ctx, "executing query",
logger.F("query", query),
logger.F("args", args),
)
if _, err := stmt.ExecContext(ctx, args...); err != nil {
return errors.WithStack(err)
}
}
resource, err = r.getResourceWithinTx(ctx, tx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return resource, nil
}
func (r *Repository) getResourceWithinTx(ctx context.Context, tx *sql.Tx, origin app.ID, resourceID share.ResourceID) (*share.BaseResource, error) {
query := `
SELECT name, type, value, created_at, updated_at
FROM resources
WHERE origin = $1 AND resource_id = $2
`
rows, err := tx.QueryContext(ctx, query, origin, resourceID)
if err != nil {
return nil, errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
attributes := make([]share.Attribute, 0)
for rows.Next() {
var (
name string
valueType string
value any
updatedAt time.Time
createdAt time.Time
)
if err := rows.Scan(&name, &valueType, &value, &createdAt, &updatedAt); err != nil {
return nil, errors.WithStack(err)
}
attr := share.NewBaseAttribute(
name,
share.ValueType(valueType),
value,
)
attr.SetCreatedAt(createdAt)
attr.SetUpdatedAt(updatedAt)
attributes = append(attributes, attr)
}
if err := rows.Err(); err != nil {
return nil, errors.WithStack(err)
}
if len(attributes) == 0 {
return nil, errors.WithStack(share.ErrNotFound)
}
resource := share.NewBaseResource(origin, resourceID, attributes...)
return resource, nil
}
func (r *Repository) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
var db *sql.DB
db, err := r.getDB(ctx)
if err != nil {
return errors.WithStack(err)
}
if err := sqlite.WithTx(ctx, db, fn); err != nil {
return errors.WithStack(err)
}
return nil
}
func ensureTables(ctx context.Context, db *sql.DB) error {
err := sqlite.WithTx(ctx, db, func(tx *sql.Tx) error {
query := `
CREATE TABLE IF NOT EXISTS resources (
resource_id TEXT NOT NULL,
origin TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
value TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(origin, resource_id, name) ON CONFLICT REPLACE
);
`
if _, err := tx.ExecContext(ctx, query); err != nil {
return errors.WithStack(err)
}
query = `
CREATE INDEX IF NOT EXISTS resource_idx ON resources (origin, resource_id, name);
`
if _, err := tx.ExecContext(ctx, query); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
func NewRepository(path string) *Repository {
getDB := sqlite.NewGetDBFunc(path, ensureTables)
return &Repository{
getDB: getDB,
}
}
func NewRepositoryWithDB(db *sql.DB) *Repository {
getDB := sqlite.NewGetDBFuncFromDB(db, ensureTables)
return &Repository{
getDB: getDB,
}
}
var _ share.Repository = &Repository{}

View File

@ -1,33 +0,0 @@
package sqlite
import (
"fmt"
"os"
"strings"
"testing"
"time"
"forge.cadoles.com/arcad/edge/pkg/module/share"
"forge.cadoles.com/arcad/edge/pkg/module/share/testsuite"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func TestRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
testsuite.TestRepository(t, newTestRepo)
}
func newTestRepo(testName string) (share.Repository, error) {
filename := strings.ToLower(strings.ReplaceAll(testName, " ", "_"))
file := fmt.Sprintf("./testdata/%s.sqlite", filename)
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.WithStack(err)
}
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
repo := NewRepository(dsn)
return repo, nil
}

View File

@ -1 +0,0 @@
*.sqlite*

View File

@ -1,16 +0,0 @@
package testsuite
import (
"testing"
"forge.cadoles.com/arcad/edge/pkg/module/share"
)
type NewTestRepoFunc func(testname string) (share.Repository, error)
func TestRepository(t *testing.T, newRepo NewTestRepoFunc) {
t.Run("Cases", func(t *testing.T) {
t.Parallel()
runRepositoryTests(t, newRepo)
})
}

View File

@ -1,344 +0,0 @@
package testsuite
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/share"
"github.com/pkg/errors"
)
type repositoryTestCase struct {
Name string
Skip bool
Run func(ctx context.Context, t *testing.T, repo share.Repository) error
}
var repositoryTestCases = []repositoryTestCase{
{
Name: "Update resource attributes",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
origin := app.ID("test")
resourceID := share.ResourceID("test")
// Try to create resource without attributes
_, err := repo.UpdateAttributes(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrAttributeRequired) {
return errors.Errorf("err: expected share.ErrAttributeRequired, got '%+v'", err)
}
attributes := []share.Attribute{
share.NewBaseAttribute("my_text_attr", share.TypeText, "foo"),
share.NewBaseAttribute("my_number_attr", share.TypeNumber, 5),
share.NewBaseAttribute("my_path_attr", share.TypePath, "/my/path"),
share.NewBaseAttribute("my_bool_attr", share.TypeBool, true),
}
resource, err := repo.UpdateAttributes(ctx, origin, resourceID, attributes...)
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resource).IsNil()
if isNil {
return errors.New("resource should not be nil")
}
if e, g := resourceID, resource.ID(); e != g {
return errors.Errorf("resource.ID(): expected '%v', got '%v'", e, g)
}
if e, g := origin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
if e, g := 4, len(resource.Attributes()); e != g {
return errors.Errorf("len(resource.Attributes()): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute name",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_name.json", repo); err != nil {
return errors.WithStack(err)
}
resources, err := repo.FindResources(ctx, share.WithName("my_number"))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 2, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute type",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type.json", repo); err != nil {
return errors.WithStack(err)
}
resources, err := repo.FindResources(ctx, share.WithType(share.TypePath))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 1, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute type and name",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type_and_name.json", repo); err != nil {
return errors.WithStack(err)
}
resources, err := repo.FindResources(ctx, share.WithType(share.TypeText), share.WithName("my_attr"))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 1, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Get resource",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/get_resource.json", repo); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
resource, err := repo.GetResource(ctx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resource).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := origin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
if e, g := resourceID, resource.ID(); e != g {
return errors.Errorf("resource.ID(): expected '%v', got '%v'", e, g)
}
resource, err = repo.GetResource(ctx, origin, "unexistant-id")
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
return nil
},
},
{
Name: "Delete resource",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/delete_resource.json", repo); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
// It should delete an existing resource
if err := repo.DeleteResource(ctx, origin, resourceID); err != nil {
return errors.WithStack(err)
}
_, err := repo.GetResource(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
// The resource should be deleted
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
// It should not delete an unexistant resource
err = repo.DeleteResource(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
otherOrigin := app.ID("app2.edge.app")
// It should not delete a resource with the same id and another origin
resource, err := repo.GetResource(ctx, otherOrigin, resourceID)
if err != nil {
return errors.New("err should not be nil")
}
if e, g := otherOrigin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Delete attributes",
Skip: false,
Run: func(ctx context.Context, t *testing.T, repo share.Repository) error {
if err := loadTestData(ctx, "testdata/delete_attributes.json", repo); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
// It should delete specified attributes
if err := repo.DeleteAttributes(ctx, origin, resourceID, "my_text", "my_bool"); err != nil {
return errors.WithStack(err)
}
resource, err := repo.GetResource(ctx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(resource.Attributes()); e != g {
return errors.Errorf("len(resource.Attributes()): expected '%v', got '%v'", e, g)
}
attr := share.GetAttribute(resource, "my_number", share.TypeNumber)
if attr == nil {
return errors.New("attr shoudl not be nil")
}
return nil
},
},
}
func runRepositoryTests(t *testing.T, newRepo NewTestRepoFunc) {
for _, tc := range repositoryTestCases {
func(tc repositoryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if tc.Skip {
t.SkipNow()
return
}
ctx := context.Background()
repo, err := newRepo(tc.Name)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := tc.Run(ctx, t, repo); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}
type jsonResource struct {
ID string `json:"id"`
Origin string `json:"origin"`
Attributes []jsonAttribute `json:"attributes"`
}
type jsonAttribute struct {
Name string `json:"name"`
Type share.ValueType `json:"type"`
Value any `json:"value"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func loadTestData(ctx context.Context, jsonFile string, repo share.Repository) error {
data, err := testData.ReadFile(jsonFile)
if err != nil {
return errors.WithStack(err)
}
var resources []jsonResource
if err := json.Unmarshal(data, &resources); err != nil {
return errors.WithStack(err)
}
for _, res := range resources {
attributes := make([]share.Attribute, len(res.Attributes))
for idx, attr := range res.Attributes {
attributes[idx] = share.NewBaseAttribute(
attr.Name,
attr.Type,
attr.Value,
)
}
_, err := repo.UpdateAttributes(ctx, app.ID(res.Origin), share.ResourceID(res.ID), attributes...)
if err != nil {
return errors.WithStack(err)
}
}
return nil
}

View File

@ -1,6 +0,0 @@
package testsuite
import "embed"
//go:embed testdata/*
var testData embed.FS

View File

@ -1,11 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_number", "type": "number", "value": 5 }
]
}
]

View File

@ -1,16 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "foo" }
]
}
]

View File

@ -1,29 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"other_text", "type": "text", "value": "foo" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
}
]

View File

@ -1,28 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"other_text", "type": "text", "value": "foo" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
}
]

View File

@ -1,23 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_attr", "type": "text", "value": "bar" }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_attr", "type": "bool", "value": true }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_attr", "type": "number", "value": 5 }
]
}
]

View File

@ -1,16 +0,0 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "foo" }
]
}
]

View File

@ -1,86 +0,0 @@
package share
import (
"strings"
"github.com/pkg/errors"
)
type ValueType string
const (
TypeText ValueType = "text"
TypePath ValueType = "path"
TypeNumber ValueType = "number"
TypeBool ValueType = "bool"
)
func AssertType(value any, valueType ValueType) error {
switch valueType {
case TypeText:
if err := AssertTypeText(value); err != nil {
return errors.WithStack(err)
}
case TypeNumber:
if err := AssertTypeNumber(value); err != nil {
return errors.WithStack(err)
}
case TypeBool:
if err := AssertTypeBool(value); err != nil {
return errors.WithStack(err)
}
case TypePath:
if err := AssertTypePath(value); err != nil {
return errors.WithStack(err)
}
default:
return errors.Errorf("value type '%s' does not exist", valueType)
}
return nil
}
func AssertTypeText(value any) error {
_, ok := value.(string)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypeText, value)
}
return nil
}
func AssertTypeNumber(value any) error {
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return nil
default:
return errors.Errorf("invalid value for type '%s': '%v'", TypeNumber, value)
}
}
func AssertTypeBool(value any) error {
_, ok := value.(bool)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypeBool, value)
}
return nil
}
func AssertTypePath(value any) error {
path, ok := value.(string)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypePath, value)
}
if !strings.HasPrefix(path, "/") {
return errors.Errorf("value '%s' should start with a '/'", value)
}
return nil
}

View File

@ -1,88 +0,0 @@
package share
import (
"testing"
"github.com/pkg/errors"
)
type valueTypeTestCase struct {
Name string
Value any
Type ValueType
ShouldFail bool
}
var valueTypeTestCases = []valueTypeTestCase{
{
Name: "Valid text",
Value: "my_text",
Type: TypeText,
},
{
Name: "Invalid text",
Value: 0,
Type: TypeText,
ShouldFail: true,
},
{
Name: "Valid number",
Value: 5.6,
Type: TypeNumber,
},
{
Name: "Invalid number",
Value: "5",
Type: TypeNumber,
ShouldFail: true,
},
{
Name: "Valid bool",
Value: false,
Type: TypeBool,
},
{
Name: "Invalid bool",
Value: "yes",
Type: TypeBool,
ShouldFail: true,
},
{
Name: "Valid path",
Value: "/foo/bar",
Type: TypePath,
},
{
Name: "Invalid path",
Value: true,
Type: TypePath,
ShouldFail: true,
},
{
Name: "Missing slash",
Value: "missing/slash",
Type: TypePath,
ShouldFail: true,
},
}
func TestAssertType(t *testing.T) {
t.Parallel()
for _, tc := range valueTypeTestCases {
func(tc valueTypeTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
err := AssertType(tc.Value, tc.Type)
if tc.ShouldFail && err == nil {
t.Errorf("err should not be nil")
}
if !tc.ShouldFail && err != nil {
t.Errorf("err: expected nil, got '%+v'", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -2,12 +2,12 @@ package store
import (
"context"
"io/ioutil"
"os"
"testing"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"forge.cadoles.com/arcad/edge/pkg/storage/driver/sqlite"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -22,7 +22,7 @@ func TestStoreModule(t *testing.T) {
ModuleFactory(store),
)
data, err := ioutil.ReadFile("testdata/store.js")
data, err := os.ReadFile("testdata/store.js")
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}