Skip to content

Commit 7b75d2f

Browse files
committed
feat: query with empty filter
1 parent a8c31d1 commit 7b75d2f

File tree

14 files changed

+377
-154
lines changed

14 files changed

+377
-154
lines changed

pkg/cardinal/internal/ecs/search.go

Lines changed: 123 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,11 @@ import (
1313
// We use expr lang for the where clause to filter the entities, please refer to its documentation
1414
// for more details: https://expr-lang.org/docs/getting-started.
1515
type SearchParam struct {
16-
Find []string // List of component names to search for
17-
Match SearchMatch // A match type to use for the search
18-
Where string // Optional expr language string to filter the results.
19-
}
20-
21-
// validateAndGetFilter validates the search parameters and returns an expr VM program compiled
22-
// from the where clause.
23-
func (s *SearchParam) validateAndGetFilter() (*vm.Program, error) {
24-
if len(s.Find) == 0 {
25-
return nil, eris.New("component list cannot be empty")
26-
}
27-
28-
if s.Match != MatchExact && s.Match != MatchContains {
29-
return nil, eris.Errorf("invalid `match` value: must be either '%s' or '%s'", MatchExact, MatchContains)
30-
}
31-
32-
var filter *vm.Program
33-
34-
// If no expression is provided, return a nil program
35-
if len(s.Where) == 0 {
36-
return filter, nil
37-
}
38-
39-
// Compile the expression and check that the return type is boolean.
40-
filter, err := expr.Compile(s.Where, expr.AsBool())
41-
if err != nil {
42-
return nil, eris.Wrap(err, "failed to parse where clause")
43-
}
44-
45-
return filter, nil
16+
Find []string // List of component names to search for. Must be empty when Match is MatchAll.
17+
Match SearchMatch // A match type to use for the search.
18+
Where string // Optional expr language string to filter the results.
19+
Limit uint32 // Maximum number of results to return (default: unlimited, 0 = unlimited)
20+
Offset uint32 // Number of results to skip before returning (default: 0)
4621
}
4722

4823
// SearchMatch is the type of match to use for the search.
@@ -54,9 +29,16 @@ const (
5429
// MatchContains matches entities that contains the specified components, but may have other
5530
// components as well.
5631
MatchContains SearchMatch = "contains"
32+
// MatchAll matches all entities regardless of components. Find must be empty when using this.
33+
MatchAll SearchMatch = "all"
5734
)
5835

36+
// DefaultQueryLimit is the default maximum number of results returned when Limit is 0 (unlimited).
37+
const DefaultQueryLimit = ^uint32(0) // Max uint32 (4294967295)
38+
5939
// NewSearch returns a map of entities that match the given search parameters.
40+
//
41+
//nolint:gocognit // Complexity from sequential filtering (where, offset, limit) kept together for readability.
6042
func (w *World) NewSearch(params SearchParam) ([]map[string]any, error) {
6143
filter, err := params.validateAndGetFilter()
6244
if err != nil {
@@ -68,60 +50,99 @@ func (w *World) NewSearch(params SearchParam) ([]map[string]any, error) {
6850
return nil, eris.Wrap(err, "failed to get archetypes from components")
6951
}
7052

53+
limit := params.Limit
54+
if limit == 0 {
55+
limit = DefaultQueryLimit
56+
}
57+
7158
results := make([]map[string]any, 0)
59+
skipped := uint32(0)
60+
collected := uint32(0)
61+
7262
for _, id := range archetypeIDs {
73-
// Makes a copy of the arch.
7463
arch := w.state.archetypes[id]
7564

7665
for eid, components := range archIter(arch) {
77-
// Create the payload map.
78-
result := make(map[string]any)
79-
// We have to cast id from EntityID to int here or else we can't query the data because for some
80-
// reason expr can't compare EntityID with integers in the expression.
81-
result["_id"] = uint32(eid)
82-
83-
for _, component := range components {
84-
result[component.Name()] = component
66+
result := buildEntityResult(eid, components)
67+
68+
// Apply Where filter if present
69+
if filter != nil {
70+
matches, err := matchesFilter(filter, result)
71+
if err != nil {
72+
return nil, err
73+
}
74+
if !matches {
75+
continue // Skip this entity
76+
}
8577
}
8678

87-
// If there's no filter, include all entities.
88-
if filter == nil {
89-
results = append(results, result)
79+
// Apply offset: skip first N matching entities
80+
if skipped < params.Offset {
81+
skipped++
9082
continue
9183
}
9284

93-
// Run the filter expression. We set the entity map as the environment for `Run` so the vm
94-
// program has access to the entity data to filter.
95-
output, innerErr := expr.Run(filter, result)
96-
if innerErr != nil {
97-
return nil, eris.Wrap(innerErr, "failed to run filter expression")
98-
}
99-
100-
isMatchFilter, ok := output.(bool)
101-
// Because we compile the expr once without passing in the environment, as it's only available
102-
// while iterating, expr.Compile can't fully check if the expression returns a bool,x
103-
// especially when we filter for a struct field e.g. health.hp > 200, expr can't determine the
104-
// type of health.hp during compilation.
105-
if !ok {
106-
return nil, eris.New("invalid where clause")
107-
}
85+
// Add to results
86+
results = append(results, result)
87+
collected++
10888

109-
if isMatchFilter {
110-
results = append(results, result)
89+
// Early termination: stop when limit is reached
90+
if collected >= limit {
91+
return results, nil
11192
}
11293
}
11394
}
11495

11596
return results, nil
11697
}
11798

99+
// validateAndGetFilter validates the search parameters and returns an expr VM program compiled
100+
// from the where clause.
101+
func (s *SearchParam) validateAndGetFilter() (*vm.Program, error) {
102+
// Validate Match and Find relationship
103+
if s.Match == MatchAll {
104+
if len(s.Find) > 0 {
105+
return nil, eris.New("find must be empty when match is 'all'")
106+
}
107+
} else {
108+
if len(s.Find) == 0 {
109+
return nil, eris.New("find must not be empty when match is not 'all'")
110+
}
111+
if s.Match != MatchExact && s.Match != MatchContains {
112+
return nil, eris.Errorf("invalid `match` value: must be either '%s' or '%s'", MatchExact, MatchContains)
113+
}
114+
}
115+
116+
var filter *vm.Program
117+
118+
// If no expression is provided, return a nil program
119+
if len(s.Where) == 0 {
120+
return filter, nil
121+
}
122+
123+
// Compile the expression and check that the return type is boolean.
124+
filter, err := expr.Compile(s.Where, expr.AsBool())
125+
if err != nil {
126+
return nil, eris.Wrap(err, "failed to parse where clause")
127+
}
128+
129+
return filter, nil
130+
}
131+
118132
// findMatchingArchetypes returns the archetypes that match the given components and match type.
119133
func findMatchingArchetypes(w *World, compNames []string, match SearchMatch) ([]archetypeID, error) {
120-
if len(compNames) == 0 {
121-
return nil, eris.New("component list cannot be empty")
134+
ws := w.state
135+
136+
// If match is MatchAll, return all archetype IDs
137+
if match == MatchAll {
138+
archIDs := make([]archetypeID, 0, len(ws.archetypes))
139+
for id := range ws.archetypes {
140+
archIDs = append(archIDs, id)
141+
}
142+
return archIDs, nil
122143
}
123144

124-
ws := w.state
145+
// Build component bitmap from names
125146
component := bitmap.Bitmap{}
126147
for _, name := range compNames {
127148
id, exists := ws.components.catalog[name]
@@ -131,6 +152,7 @@ func findMatchingArchetypes(w *World, compNames []string, match SearchMatch) ([]
131152
component.Set(id)
132153
}
133154

155+
// Find matching archetypes based on match type
134156
var archIDs []int
135157
switch match {
136158
case MatchExact:
@@ -140,10 +162,49 @@ func findMatchingArchetypes(w *World, compNames []string, match SearchMatch) ([]
140162
}
141163
case MatchContains:
142164
archIDs = ws.archContains(component)
165+
case MatchAll:
166+
// This case should never be reached as MatchAll is handled earlier in the function
167+
// but included for exhaustive switch coverage
168+
return nil, eris.New("MatchAll should be handled before this switch")
143169
}
144170
return archIDs, nil
145171
}
146172

173+
// buildEntityResult creates a result map from an entity ID and its components.
174+
func buildEntityResult(eid EntityID, components []Component) map[string]any {
175+
result := make(map[string]any)
176+
// We have to cast id from EntityID to int here or else we can't query the data because for some
177+
// reason expr can't compare EntityID with integers in the expression.
178+
result["_id"] = uint32(eid)
179+
180+
for _, component := range components {
181+
result[component.Name()] = component
182+
}
183+
184+
return result
185+
}
186+
187+
// matchesFilter checks if an entity matches the filter expression.
188+
func matchesFilter(filter *vm.Program, result map[string]any) (bool, error) {
189+
// Run the filter expression. We set the entity map as the environment for `Run` so the vm
190+
// program has access to the entity data to filter.
191+
output, err := expr.Run(filter, result)
192+
if err != nil {
193+
return false, eris.Wrap(err, "failed to run filter expression")
194+
}
195+
196+
isMatchFilter, ok := output.(bool)
197+
// Because we compile the expr once without passing in the environment, as it's only available
198+
// while iterating, expr.Compile can't fully check if the expression returns a bool,
199+
// especially when we filter for a struct field e.g. health.hp > 200, expr can't determine the
200+
// type of health.hp during compilation.
201+
if !ok {
202+
return false, eris.New("invalid where clause")
203+
}
204+
205+
return isMatchFilter, nil
206+
}
207+
147208
// archIter returns an iterator of the archetypes entities and its components.
148209
func archIter(a *archetype) iter.Seq2[EntityID, []Component] {
149210
return func(yield func(EntityID, []Component) bool) {

pkg/cardinal/service/query.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,26 @@ import (
1919
// For now it has the same structure as ecs.SearchParam, but we might add more fields in the future
2020
// so it's better to keep these as separate types.
2121
type Query struct {
22-
// List of component names to search for.
22+
// List of component names to search for. Must be empty when Match is MatchAll.
2323
Find []string `json:"find"`
24-
// Match type: "exact" or "contains".
24+
// Match type: "exact", "contains", or "all".
2525
Match ecs.SearchMatch `json:"match"`
2626
// Optional expr language string to filter the results.
2727
// See https://expr-lang.org/ for documentation.
2828
Where string `json:"where,omitempty"`
29+
// Maximum number of results to return (default: unlimited, 0 = unlimited).
30+
Limit uint32 `json:"limit,omitempty"`
31+
// Number of results to skip before returning (default: 0).
32+
Offset uint32 `json:"offset,omitempty"`
2933
}
3034

3135
// reset resets the Query object for reuse.
3236
func (q *Query) reset() {
3337
q.Find = q.Find[:0] // Reuse the underlying array
3438
q.Match = ""
3539
q.Where = ""
40+
q.Limit = 0
41+
q.Offset = 0
3642
}
3743

3844
// handleQuery creates a new query handler for the world.
@@ -52,9 +58,11 @@ func (s *ShardService) handleQuery(ctx context.Context, req *micro.Request) *mic
5258
defer s.queryPool.Put(query)
5359

5460
results, err := s.world.NewSearch(ecs.SearchParam{
55-
Find: query.Find,
56-
Match: query.Match,
57-
Where: query.Where,
61+
Find: query.Find,
62+
Match: query.Match,
63+
Where: query.Where,
64+
Limit: query.Limit,
65+
Offset: query.Offset,
5866
})
5967
if err != nil {
6068
return micro.NewErrorResponse(req, eris.Wrap(err, "failed to search entities"), codes.Internal)
@@ -86,6 +94,11 @@ func parseQuery(pool *sync.Pool, req *micro.Request) (*Query, error) {
8694
query.Match = ecs.SearchMatch(iscv1MatchToString(payload.GetMatch()))
8795
query.Where = payload.GetWhere()
8896

97+
// Parse Limit and Offset
98+
// In proto3, unset uint32 fields return 0, which means unlimited for limit
99+
query.Limit = payload.GetLimit()
100+
query.Offset = payload.GetOffset()
101+
89102
return query, nil
90103
}
91104

@@ -123,6 +136,8 @@ func iscv1MatchToString(m iscv1.Query_Match) string {
123136
return "exact"
124137
case iscv1.Query_MATCH_CONTAINS:
125138
return "contains"
139+
case iscv1.Query_MATCH_ALL:
140+
return "all"
126141
case iscv1.Query_MATCH_UNSPECIFIED:
127142
fallthrough
128143
default:

0 commit comments

Comments
 (0)