Skip to content

Commit ad79163

Browse files
committed
feat(query): add query package with abstractions and scenario
1 parent 69aa90d commit ad79163

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

query/query.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Package query provides support and utilities to handle and implement
2+
// Domain Queries in your application.
3+
package query
4+
5+
import (
6+
"context"
7+
8+
"github.com/get-eventually/go-eventually/message"
9+
)
10+
11+
// Query represents a Domain Query, a request for information.
12+
// Queries should be phrased in the present, imperative tense, such as "ListUsers".
13+
type Query message.Message
14+
15+
// Envelope represents a message containing a Domain Query,
16+
// and optionally includes additional fields in the form of Metadata.
17+
type Envelope[T Query] message.Envelope[T]
18+
19+
// Handler is the interface that defines a Query Handler.
20+
//
21+
// Handler accepts a specific kind of Query, evaluates it
22+
// and returns the desired Result.
23+
type Handler[T Query, R any] interface {
24+
Handle(ctx context.Context, query Envelope[T]) (R, error)
25+
}
26+
27+
// ToEnvelope is a convenience function that wraps the provided Query type
28+
// into an Envelope, with no metadata attached to it.
29+
func ToEnvelope[T Query](query T) Envelope[T] {
30+
return Envelope[T]{
31+
Message: query,
32+
Metadata: nil,
33+
}
34+
}
35+
36+
// HandlerFunc is a functional type that implements the Handler interface.
37+
// Useful for testing and stateless Handlers.
38+
type HandlerFunc[T Query, R any] func(ctx context.Context, query Envelope[T]) (R, error)
39+
40+
// Handle implements xquery.Handler.
41+
func (f HandlerFunc[T, R]) Handle(ctx context.Context, query Envelope[T]) (R, error) {
42+
return f(ctx, query)
43+
}

query/scenario.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package query
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/get-eventually/go-eventually/event"
11+
"github.com/get-eventually/go-eventually/version"
12+
)
13+
14+
// ProcessorHandler is a Query Handler that can both handle domain queries,
15+
// and domain events to hydrate the query model.
16+
//
17+
// To be used in the Scenario.
18+
type ProcessorHandler[Q Query, R any] interface {
19+
Handler[Q, R]
20+
event.Processor
21+
}
22+
23+
// ScenarioInit is the entrypoint of the Query Handler scenario API.
24+
//
25+
// A Query Handler scenario can either set the current evaluation context
26+
// by using Given(), or test a "clean-slate" scenario by using When() directly.
27+
type ScenarioInit[Q Query, R any, T ProcessorHandler[Q, R]] struct{}
28+
29+
// Scenario can be used to test the result of Domain Queries
30+
// being handled by a Query Handler.
31+
//
32+
// Query Handlers in Event-sourced systems return read-only data on request by means
33+
// of Domain Queries. This scenario API helps you with testing the values
34+
// returned by a Query Handler when handling a specific Domain Query.
35+
func Scenario[Q Query, R any, T ProcessorHandler[Q, R]]() ScenarioInit[Q, R, T] {
36+
return ScenarioInit[Q, R, T]{}
37+
}
38+
39+
// Given sets the Query Handler scenario preconditions.
40+
//
41+
// Domain Events are used in Event-sourced systems to represent a side effect
42+
// that has taken place in the system. In order to set a given state for the
43+
// system to be in while testing a specific Domain Query evaluation, you should
44+
// specify the Domain Events that have happened thus far.
45+
//
46+
// When you're testing Domain Queries with a clean-slate system, you should either specify
47+
// no Domain Events, or skip directly to When().
48+
func (sc ScenarioInit[Q, R, T]) Given(events ...event.Persisted) ScenarioGiven[Q, R, T] {
49+
return ScenarioGiven[Q, R, T]{
50+
given: events,
51+
}
52+
}
53+
54+
// When provides the Domain Query to evaluate.
55+
func (sc ScenarioInit[Q, R, T]) When(q Envelope[Q]) ScenarioWhen[Q, R, T] {
56+
//nolint:exhaustruct // Zero values are fine here.
57+
return ScenarioWhen[Q, R, T]{
58+
when: q,
59+
}
60+
}
61+
62+
// ScenarioGiven is the state of the scenario once
63+
// a set of Domain Events have been provided using Given(), to represent
64+
// the state of the system at the time of evaluating a Domain Event.
65+
type ScenarioGiven[Q Query, R any, T ProcessorHandler[Q, R]] struct {
66+
given []event.Persisted
67+
}
68+
69+
// When provides the Command to evaluate.
70+
func (sc ScenarioGiven[Q, R, T]) When(q Envelope[Q]) ScenarioWhen[Q, R, T] {
71+
return ScenarioWhen[Q, R, T]{
72+
ScenarioGiven: sc,
73+
when: q,
74+
}
75+
}
76+
77+
// ScenarioWhen is the state of the scenario once the state of the
78+
// system and the Domain Query to evaluate has been provided.
79+
type ScenarioWhen[Q Query, R any, T ProcessorHandler[Q, R]] struct {
80+
ScenarioGiven[Q, R, T]
81+
when Envelope[Q]
82+
}
83+
84+
// Then sets a positive expectation on the scenario outcome, to produce
85+
// the Query Result provided in input.
86+
func (sc ScenarioWhen[Q, R, T]) Then(result R) ScenarioThen[Q, R, T] {
87+
//nolint:exhaustruct // Zero values are fine here.
88+
return ScenarioThen[Q, R, T]{
89+
ScenarioWhen: sc,
90+
then: result,
91+
}
92+
}
93+
94+
// ThenError sets a negative expectation on the scenario outcome,
95+
// to produce an error value that is similar to the one provided in input.
96+
//
97+
// Error assertion happens using errors.Is(), so the error returned
98+
// by the Query Handler is unwrapped until the cause error to match
99+
// the provided expectation.
100+
func (sc ScenarioWhen[Q, R, T]) ThenError(err error) ScenarioThen[Q, R, T] {
101+
//nolint:exhaustruct // Zero values are fine here.
102+
return ScenarioThen[Q, R, T]{
103+
ScenarioWhen: sc,
104+
wantError: true,
105+
thenError: err,
106+
}
107+
}
108+
109+
// ThenFails sets a negative expectation on the scenario outcome,
110+
// to fail the Domain Query evaluation with no particular assertion on the error returned.
111+
//
112+
// This is useful when the error returned is not important for the Domain Query
113+
// you're trying to test.
114+
func (sc ScenarioWhen[Q, R, T]) ThenFails() ScenarioThen[Q, R, T] {
115+
//nolint:exhaustruct // Zero values are fine here.
116+
return ScenarioThen[Q, R, T]{
117+
ScenarioWhen: sc,
118+
wantError: true,
119+
}
120+
}
121+
122+
// ScenarioThen is the state of the scenario once the preconditions
123+
// and expectations have been fully specified.
124+
type ScenarioThen[Q Query, R any, T ProcessorHandler[Q, R]] struct {
125+
ScenarioWhen[Q, R, T]
126+
127+
then R
128+
thenError error
129+
wantError bool
130+
}
131+
132+
// AssertOn performs the specified expectations of the scenario, using the Query Handler
133+
// instance produced by the provided factory function.
134+
func (sc ScenarioThen[Q, R, T]) AssertOn( //nolint:gocritic
135+
t *testing.T,
136+
handlerFactory func(es event.Store) T,
137+
) {
138+
ctx := context.Background()
139+
140+
eventStore := event.NewInMemoryStore()
141+
queryHandler := handlerFactory(eventStore)
142+
143+
for _, evt := range sc.given {
144+
_, err := eventStore.Append(ctx, evt.StreamID, version.CheckExact(evt.Version-1), evt.Envelope)
145+
require.NoError(t, err, "failed to record event on the event store", evt)
146+
147+
err = queryHandler.Process(ctx, evt)
148+
require.NoError(t, err, "event failed to be processed with the query handler", evt)
149+
}
150+
151+
actual, err := queryHandler.Handle(ctx, sc.when)
152+
153+
if !sc.wantError {
154+
assert.NoError(t, err)
155+
assert.Equal(t, sc.then, actual)
156+
157+
return
158+
}
159+
160+
if !assert.Error(t, err) {
161+
return
162+
}
163+
164+
if sc.thenError != nil {
165+
assert.ErrorIs(t, err, sc.thenError)
166+
}
167+
}

0 commit comments

Comments
 (0)