Skip to content

Commit b6142af

Browse files
protyping on dynamic operation execution
1 parent 8dcced1 commit b6142af

File tree

4 files changed

+249
-0
lines changed

4 files changed

+249
-0
lines changed

operations/operation.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package operations
22

33
import (
44
"context"
5+
"errors"
56
"sync"
67

78
"github.com/Masterminds/semver/v3"
@@ -74,6 +75,30 @@ func (o *Operation[IN, OUT, DEP]) execute(b Bundle, deps DEP, input IN) (output
7475
return o.handler(b, deps, input)
7576
}
7677

78+
// AsUntyped converts the operation to an untyped operation.
79+
// This is useful for storing operations in a slice or passing them around without type constraints.
80+
// Warning: The input and output types will be converted to `any`, so type safety is lost.
81+
func (o *Operation[IN, OUT, DEP]) AsUntyped() *Operation[any, any, DEP] {
82+
return &Operation[any, any, DEP]{
83+
def: Definition{
84+
ID: o.def.ID,
85+
Version: o.def.Version,
86+
Description: o.def.Description,
87+
},
88+
handler: func(b Bundle, deps DEP, input any) (any, error) {
89+
var typedInput IN
90+
if input != nil {
91+
var ok bool
92+
if typedInput, ok = input.(IN); !ok {
93+
return nil, errors.New("input type mismatch")
94+
}
95+
}
96+
97+
return o.execute(b, deps, typedInput)
98+
},
99+
}
100+
}
101+
77102
// NewOperation creates a new operation.
78103
// Version can be created using semver.MustParse("1.0.0") or semver.New("1.0.0").
79104
// Note: The handler should only perform maximum 1 side effect.

operations/operation_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,64 @@ func Test_Operation_WithEmptyInput(t *testing.T) {
8282
require.NoError(t, err)
8383
assert.Equal(t, 1, out)
8484
}
85+
86+
func Test_Operation_AsUntyped(t *testing.T) {
87+
t.Parallel()
88+
89+
// Create a typed operation
90+
version := semver.MustParse("1.0.0")
91+
description := "test operation"
92+
handler1 := func(b Bundle, deps OpDeps, input OpInput) (output int, err error) {
93+
return input.A + input.B, nil
94+
}
95+
typedOp := NewOperation("sum", version, description, handler1)
96+
97+
// Convert to untyped operation
98+
untypedOp := typedOp.AsUntyped()
99+
bundle := NewBundle(context.Background, logger.Test(t), nil)
100+
101+
// Test definition fields are preserved
102+
assert.Equal(t, "sum", untypedOp.ID())
103+
assert.Equal(t, version.String(), untypedOp.Version())
104+
assert.Equal(t, description, untypedOp.Description())
105+
106+
// Table-driven test cases
107+
tests := []struct {
108+
name string
109+
deps OpDeps
110+
input any
111+
wantResult any
112+
wantErr bool
113+
errContains string
114+
}{
115+
{
116+
name: "valid input and dependencies",
117+
deps: OpDeps{},
118+
input: OpInput{A: 3, B: 4},
119+
wantResult: 7,
120+
wantErr: false,
121+
},
122+
{
123+
name: "invalid input type",
124+
deps: OpDeps{},
125+
input: struct{ C int }{C: 5},
126+
wantErr: true,
127+
errContains: "input type mismatch",
128+
},
129+
}
130+
131+
for _, tt := range tests {
132+
t.Run(tt.name, func(t *testing.T) {
133+
t.Parallel()
134+
result, err := untypedOp.handler(bundle, tt.deps, tt.input)
135+
136+
if tt.wantErr {
137+
require.Error(t, err)
138+
assert.Contains(t, err.Error(), tt.errContains)
139+
} else {
140+
require.NoError(t, err)
141+
assert.Equal(t, tt.wantResult, result)
142+
}
143+
})
144+
}
145+
}

operations/registry.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package operations
2+
3+
import "errors"
4+
5+
// OperationRegistry is a store for operations that allows retrieval based on their definitions.
6+
type OperationRegistry[DEP any] struct {
7+
Ops []*Operation[any, any, DEP]
8+
}
9+
10+
// NewOperationRegistry creates a new OperationRegistry with the provided untyped operations.
11+
func NewOperationRegistry[DEP any](ops ...*Operation[any, any, DEP]) OperationRegistry[DEP] {
12+
return OperationRegistry[DEP]{
13+
Ops: ops,
14+
}
15+
}
16+
17+
// Retrieve retrieves an operation from the store based on its definition.
18+
// It returns an error if the operation is not found.
19+
// The definition must match the operation's ID and version.
20+
func (s OperationRegistry[DEP]) Retrieve(def Definition) (*Operation[any, any, DEP], error) {
21+
for _, op := range s.Ops {
22+
if op.ID() == def.ID && op.Version() == def.Version.String() {
23+
return op, nil
24+
}
25+
}
26+
return nil, errors.New("operation not found in registry")
27+
}

operations/registry_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package operations
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/Masterminds/semver/v3"
9+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// ExampleOperationRegistry demonstrates how to create and use an OperationRegistry
15+
// with operations being executed dynamically with different input/output types.
16+
func ExampleOperationRegistry() {
17+
// Create operations with different input/output types
18+
stringOp := NewOperation(
19+
"string-op",
20+
semver.MustParse("1.0.0"),
21+
"Echo string operation",
22+
func(e Bundle, deps OpDeps, input string) (string, error) {
23+
return input, nil
24+
},
25+
)
26+
27+
intOp := NewOperation(
28+
"int-op",
29+
semver.MustParse("1.0.0"),
30+
"Echo integer operation",
31+
func(e Bundle, deps OpDeps, input int) (int, error) {
32+
return input, nil
33+
},
34+
)
35+
// Create registry with untyped operations
36+
registry := NewOperationRegistry(stringOp.AsUntyped(), intOp.AsUntyped())
37+
38+
// Create execution environment
39+
b := NewBundle(context.Background, logger.Nop(), NewMemoryReporter())
40+
41+
inputs := []any{"input1", 42}
42+
defs := []Definition{
43+
{ID: "string-op", Version: semver.MustParse("1.0.0")},
44+
{ID: "int-op", Version: semver.MustParse("1.0.0")},
45+
}
46+
47+
// dynamically retrieve and execute operations on different inputs
48+
for i, def := range defs {
49+
retrievedOp, err := registry.Retrieve(def)
50+
if err != nil {
51+
fmt.Println("error retrieving operation:", err)
52+
continue
53+
}
54+
55+
report, err := ExecuteOperation(b, retrievedOp, OpDeps{}, inputs[i])
56+
57+
fmt.Println("operation output:", report.Output)
58+
}
59+
60+
// Output:
61+
// operation output: input1
62+
// operation output: 42
63+
}
64+
65+
func TestOperationRegistry(t *testing.T) {
66+
t.Parallel()
67+
68+
// Test fixture setup
69+
op1 := NewOperation(
70+
"test-op-1",
71+
semver.MustParse("1.0.0"),
72+
"Operation 1",
73+
func(e Bundle, deps OpDeps, input string) (string, error) { return input, nil },
74+
)
75+
op2 := NewOperation(
76+
"test-op-2",
77+
semver.MustParse("2.0.0"),
78+
"Operation 2",
79+
func(e Bundle, deps OpDeps, input int) (int, error) { return input * 2, nil },
80+
)
81+
82+
tests := []struct {
83+
name string
84+
operations []*Operation[any, any, OpDeps]
85+
lookup Definition
86+
wantErr bool
87+
wantErrMsg string
88+
wantID string
89+
wantVersion string
90+
}{
91+
{
92+
name: "retrieval by exact match - first operation",
93+
lookup: Definition{ID: "test-op-1", Version: semver.MustParse("1.0.0")},
94+
wantErr: false,
95+
wantID: "test-op-1",
96+
wantVersion: "1.0.0",
97+
},
98+
{
99+
name: "retrieval by exact match - second operation",
100+
lookup: Definition{ID: "test-op-2", Version: semver.MustParse("2.0.0")},
101+
wantErr: false,
102+
wantID: "test-op-2",
103+
wantVersion: "2.0.0",
104+
},
105+
{
106+
name: "operation not found - non-existent ID",
107+
lookup: Definition{ID: "non-existent", Version: semver.MustParse("1.0.0")},
108+
wantErr: true,
109+
wantErrMsg: "operation not found in registry",
110+
},
111+
{
112+
name: "operation not found - wrong version",
113+
lookup: Definition{ID: "test-op-1", Version: semver.MustParse("3.0.0")},
114+
wantErr: true,
115+
wantErrMsg: "operation not found in registry",
116+
},
117+
}
118+
119+
for _, tt := range tests {
120+
t.Run(tt.name, func(t *testing.T) {
121+
t.Parallel()
122+
123+
registry := NewOperationRegistry(op1.AsUntyped(), op2.AsUntyped())
124+
retrievedOp, err := registry.Retrieve(tt.lookup)
125+
126+
if tt.wantErr {
127+
require.Error(t, err)
128+
assert.ErrorContains(t, err, tt.wantErrMsg)
129+
} else {
130+
require.NoError(t, err)
131+
assert.Equal(t, tt.wantID, retrievedOp.ID())
132+
assert.Equal(t, tt.wantVersion, retrievedOp.Version())
133+
}
134+
})
135+
}
136+
}

0 commit comments

Comments
 (0)