From 37d8b1d608804d640258331d7df165971b99fd8e Mon Sep 17 00:00:00 2001 From: fschade Date: Tue, 29 Jul 2025 16:38:54 +0200 Subject: [PATCH] enhancement(search): implement engine search skeleton --- services/search/pkg/opensearch/engine.go | 45 ++++++++++- services/search/pkg/opensearch/engine_test.go | 29 +++++++- services/search/pkg/opensearch/kql.go | 74 +++++++++---------- services/search/pkg/opensearch/kql_test.go | 21 +++++- 4 files changed, 124 insertions(+), 45 deletions(-) diff --git a/services/search/pkg/opensearch/engine.go b/services/search/pkg/opensearch/engine.go index f912caad1..b8acef982 100644 --- a/services/search/pkg/opensearch/engine.go +++ b/services/search/pkg/opensearch/engine.go @@ -8,6 +8,8 @@ import ( opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi" + "github.com/opencloud-eu/opencloud/pkg/kql" + searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" "github.com/opencloud-eu/opencloud/services/search/pkg/engine" ) @@ -22,7 +24,48 @@ func NewEngine(index string, client *opensearchgoAPI.Client) (*Engine, error) { } func (e *Engine) Search(ctx context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) { - return &searchService.SearchIndexResponse{}, nil + ast, err := kql.Builder{}.Build(sir.Query) + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + query, err := KQL{}.Compile(ast) + if err != nil { + return nil, fmt.Errorf("failed to compile query: %w", err) + } + + body, err := query.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal query: %w", err) + } + + resp, err := e.client.Search(context.Background(), &opensearchgoAPI.SearchReq{ + Indices: []string{e.index}, + Body: bytes.NewReader(body), + }) + if err != nil { + return nil, fmt.Errorf("failed to count documents: %w", err) + } + + matches := make([]*searchMessage.Match, len(resp.Hits.Hits)) + for i, hit := range resp.Hits.Hits { + resource, err := convert[engine.Resource](hit.Source) + if err != nil { + return nil, fmt.Errorf("failed to convert hit %d: %w", i, err) + } + + matches[i] = &searchMessage.Match{ + Score: hit.Score, + Entity: &searchMessage.Entity{ + Name: resource.Name, + }, + } + } + + return &searchService.SearchIndexResponse{ + Matches: matches, + TotalMatches: int32(resp.Hits.Total.Value), + }, nil } func (e *Engine) Upsert(id string, r engine.Resource) error { diff --git a/services/search/pkg/opensearch/engine_test.go b/services/search/pkg/opensearch/engine_test.go index e26772987..248bc1aa0 100644 --- a/services/search/pkg/opensearch/engine_test.go +++ b/services/search/pkg/opensearch/engine_test.go @@ -5,10 +5,35 @@ import ( "github.com/stretchr/testify/assert" + searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch" "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test" ) +func TestEngine_Search(t *testing.T) { + index := "test-engine-search" + tc := ostest.NewDefaultTestClient(t) + tc.Require.IndicesReset([]string{index}) + tc.Require.IndicesCount([]string{index}, "", 0) + + defer tc.Require.IndicesDelete([]string{index}) + + document := ostest.Testdata.Resources.Full + tc.Require.DocumentCreate(index, document.ID, toJSON(t, document)) + tc.Require.IndicesCount([]string{index}, "", 1) + + engine, err := opensearch.NewEngine(index, tc.Client()) + assert.NoError(t, err) + + t.Run("most simple search", func(t *testing.T) { + resp, err := engine.Search(t.Context(), &searchService.SearchIndexRequest{Query: "\"" + document.Name + "\""}) + assert.NoError(t, err) + assert.Len(t, resp.Matches, 1) + assert.Equal(t, int32(1), resp.TotalMatches) + assert.Equal(t, document.Name, resp.Matches[0].Entity.Name) + }) +} + func TestEngine_Upsert(t *testing.T) { index := "test-engine-upsert" tc := ostest.NewDefaultTestClient(t) @@ -20,7 +45,7 @@ func TestEngine_Upsert(t *testing.T) { engine, err := opensearch.NewEngine(index, tc.Client()) assert.NoError(t, err) - t.Run("Upsert with full document", func(t *testing.T) { + t.Run("upsert with full document", func(t *testing.T) { document := ostest.Testdata.Resources.Full assert.NoError(t, engine.Upsert(document.ID, document)) @@ -96,7 +121,7 @@ func TestEngine_Purge(t *testing.T) { engine, err := opensearch.NewEngine(index, tc.Client()) assert.NoError(t, err) - t.Run("Purge with full document", func(t *testing.T) { + t.Run("purge with full document", func(t *testing.T) { document := ostest.Testdata.Resources.Full tc.Require.DocumentCreate(index, document.ID, toJSON(t, document)) tc.Require.IndicesCount([]string{index}, "", 1) diff --git a/services/search/pkg/opensearch/kql.go b/services/search/pkg/opensearch/kql.go index cc57846df..8b1adc46b 100644 --- a/services/search/pkg/opensearch/kql.go +++ b/services/search/pkg/opensearch/kql.go @@ -1,6 +1,7 @@ package opensearch import ( + "errors" "strings" "github.com/opencloud-eu/opencloud/pkg/ast" @@ -8,52 +9,43 @@ import ( type KQL struct{} -func (k KQL) Compile(givenAst *ast.Ast) (Builder, error) { - q, err := k.compile(givenAst) - if err != nil { - return nil, err - } - return q, nil -} - -func (k KQL) compile(a *ast.Ast) (Builder, error) { - q, _, err := k.walk(0, a.Nodes) - if err != nil { - return nil, err - } - - return q, nil -} - -func (k KQL) walk(offset int, nodes []ast.Node) (Builder, int, error) { - var boolQuery = NewBoolQuery() - for i := offset; i < len(nodes); i++ { - switch n := nodes[i].(type) { - case *ast.StringNode: - field := k.getField(n.Key) - - switch spaces := strings.Split(n.Value, " "); { - case len(spaces) == 1: - boolQuery.Must(NewTermQuery[string](field).Value(n.Value)) - case len(spaces) > 1: - boolQuery.Must(NewMatchPhraseQuery(field).Query(n.Value)) - default: - continue - } - case *ast.OperatorNode: +func (k KQL) Compile(a *ast.Ast) (*RootQuery, error) { + switch { + case len(a.Nodes) == 0: + return nil, errors.New("no nodes in AST") + case len(a.Nodes) == 1: + builder, err := k.getBuilder(a.Nodes[0]) + if err != nil { + return nil, err } - + return NewRootQuery(builder), nil } - return boolQuery, 0, nil + return nil, nil } -func (k KQL) getField(name string) string { - if name == "" { +func (k KQL) getBuilder(someNode ast.Node) (Builder, error) { + var query Builder + switch node := someNode.(type) { + case *ast.StringNode: + field := k.mapField(node.Key) + switch spaces := strings.Split(node.Value, " "); { + case len(spaces) == 1: + query = NewTermQuery[string](field).Value(node.Value) + case len(spaces) > 1: + query = NewMatchPhraseQuery(field).Query(node.Value) + } + } + + return query, nil +} + +func (k KQL) mapField(field string) string { + if field == "" { return "Name" } - fields := map[string]string{ + mappings := map[string]string{ "rootid": "RootID", "path": "Path", "id": "ID", @@ -68,9 +60,9 @@ func (k KQL) getField(name string) string { "hidden": "Hidden", } - if _, ok := fields[strings.ToLower(name)]; ok { - return fields[strings.ToLower(name)] + if mapped, ok := mappings[strings.ToLower(field)]; ok { + return mapped } - return name + return field } diff --git a/services/search/pkg/opensearch/kql_test.go b/services/search/pkg/opensearch/kql_test.go index 0dad4740d..303f7e849 100644 --- a/services/search/pkg/opensearch/kql_test.go +++ b/services/search/pkg/opensearch/kql_test.go @@ -10,7 +10,26 @@ import ( ) func TestKQL_Compile(t *testing.T) { - tests := []tableTest[*ast.Ast, opensearch.Builder]{} + tests := []tableTest[*ast.Ast, opensearch.Builder]{ + { + name: "federated", + got: &ast.Ast{ + Nodes: []ast.Node{ + &ast.StringNode{Value: "federated"}, + }, + }, + want: opensearch.NewRootQuery(opensearch.NewTermQuery[string]("Name").Value("federated")), + }, + { + name: "John Smith", + got: &ast.Ast{ + Nodes: []ast.Node{ + &ast.StringNode{Value: "John Smith"}, + }, + }, + want: opensearch.NewRootQuery(opensearch.NewMatchPhraseQuery("Name").Query("John Smith")), + }, + } for _, test := range tests { t.Run(test.name, func(t *testing.T) {