diff --git a/typesense/multi_search.go b/typesense/multi_search.go index 980e1138..0ed9b3ef 100644 --- a/typesense/multi_search.go +++ b/typesense/multi_search.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "github.com/typesense/typesense-go/v4/typesense/api" @@ -12,6 +13,10 @@ import ( type MultiSearchInterface interface { Perform(ctx context.Context, commonSearchParams *api.MultiSearchParams, searchParams api.MultiSearchSearchesParameter) (*api.MultiSearchResult, error) PerformWithContentType(ctx context.Context, commonSearchParams *api.MultiSearchParams, searchParams api.MultiSearchSearchesParameter, contentType string) (*api.MultiSearchResponse, error) + // PerformUnion performs a multi-search and merges the results into a single `SearchResult`. + // The `Union` field in searchParams is automatically set to `true`. If it is explicitly + // passed as `false`, this method will return an error. + PerformUnion(ctx context.Context, commonSearchParams *api.MultiSearchParams, searchParams api.MultiSearchSearchesParameter) (*api.SearchResult, error) } type multiSearch struct { @@ -53,6 +58,35 @@ func (m *multiSearch) PerformWithContentType(ctx context.Context, commonSearchPa return response, nil } +func (m *multiSearch) PerformUnion(ctx context.Context, commonSearchParams *api.MultiSearchParams, searchParams api.MultiSearchSearchesParameter) (*api.SearchResult, error) { + if searchParams.Union != nil && !*searchParams.Union { + return nil, errors.New("invalid parameter: cannot set union to false when calling PerformUnion") + } + + // Force the Union parameter to be true + unionTrue := true + searchParams.Union = &unionTrue + + response, err := m.apiClient.MultiSearchWithResponse(ctx, commonSearchParams, api.MultiSearchJSONRequestBody(searchParams)) + if err != nil { + return nil, err + } + if err := multiSearchTopLevelError(response); err != nil { + return nil, err + } + if response.JSON200 == nil { + return nil, &HTTPError{Status: response.StatusCode(), Body: response.Body} + } + + // Unmarshal the raw JSON body into SearchResult instead of MultiSearchResult + var searchResult api.SearchResult + if err := json.Unmarshal(response.Body, &searchResult); err != nil { + return nil, err + } + + return &searchResult, nil +} + func multiSearchTopLevelError(response *api.MultiSearchResponse) error { if response == nil || len(response.Body) == 0 { return nil diff --git a/typesense/multi_search_test.go b/typesense/multi_search_test.go index a050d32f..675e706a 100644 --- a/typesense/multi_search_test.go +++ b/typesense/multi_search_test.go @@ -404,3 +404,133 @@ func TestMultiSearchRAG(t *testing.T) { }, }, res) } + +func newPerformUnionExpectedBodyParams() api.MultiSearchSearchesParameter { + body := newMultiSearchBodyParams() + unionTrue := true + body.Union = &unionTrue + return body +} + +func TestMultiSearchPerformUnion(t *testing.T) { + expectedParams := newMultiSearchParams() + expectedResult := newSearchResult() + expectedBody := newPerformUnionExpectedBodyParams() + + responseBody, err := json.Marshal(expectedResult) + assert.Nil(t, err) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockAPIClient := mocks.NewMockAPIClientInterface(ctrl) + + mockAPIClient.EXPECT(). + MultiSearchWithResponse(gomock.Not(gomock.Nil()), expectedParams, api.MultiSearchJSONRequestBody(expectedBody)). + Return(&api.MultiSearchResponse{ + JSON200: &api.MultiSearchResult{}, + Body: responseBody, + }, nil).Times(1) + + client := NewClient(WithAPIClient(mockAPIClient)) + params := newMultiSearchParams() + body := newMultiSearchBodyParams() + + result, err := client.MultiSearch.PerformUnion(context.Background(), params, body) + + assert.Nil(t, err) + assert.Equal(t, expectedResult, result) +} + +func TestMultiSearchPerformUnionValidation(t *testing.T) { + client := NewClient(WithAPIClient(nil)) + params := newMultiSearchParams() + + body := newMultiSearchBodyParams() + unionFalse := false + body.Union = &unionFalse // Explicitly setting Union to false + + _, err := client.MultiSearch.PerformUnion(context.Background(), params, body) + + assert.NotNil(t, err) + assert.Equal(t, "invalid parameter: cannot set union to false when calling PerformUnion", err.Error()) +} + +func TestMultiSearchPerformUnionOnTopLevelErrorResponseReturnsError(t *testing.T) { + expectedParams := newMultiSearchParams() + expectedBody := newPerformUnionExpectedBodyParams() + responseBody := []byte(`{ + "code": 404, + "error": "` + "`my-collection`" + ` collection not found." + }`) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockAPIClient := mocks.NewMockAPIClientInterface(ctrl) + + mockAPIClient.EXPECT(). + MultiSearchWithResponse(gomock.Not(gomock.Nil()), expectedParams, api.MultiSearchJSONRequestBody(expectedBody)). + Return(&api.MultiSearchResponse{ + HTTPResponse: &http.Response{ + StatusCode: http.StatusNotFound, + }, + Body: responseBody, + }, nil).Times(1) + + client := NewClient(WithAPIClient(mockAPIClient)) + params := newMultiSearchParams() + + _, err := client.MultiSearch.PerformUnion(context.Background(), params, newMultiSearchBodyParams()) + + var httpErr *HTTPError + assert.ErrorAs(t, err, &httpErr) + assert.Equal(t, 404, httpErr.Status) + assert.Equal(t, responseBody, httpErr.Body) +} + +func TestMultiSearchPerformUnionOnHttpStatusErrorCodeReturnsError(t *testing.T) { + expectedParams := newMultiSearchParams() + expectedBody := newPerformUnionExpectedBodyParams() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockAPIClient := mocks.NewMockAPIClientInterface(ctrl) + + mockAPIClient.EXPECT(). + MultiSearchWithResponse(gomock.Not(gomock.Nil()), expectedParams, api.MultiSearchJSONRequestBody(expectedBody)). + Return(&api.MultiSearchResponse{ + HTTPResponse: &http.Response{ + StatusCode: 500, + }, + Body: []byte("Internal Server error"), + }, nil).Times(1) + + client := NewClient(WithAPIClient(mockAPIClient)) + params := newMultiSearchParams() + + _, err := client.MultiSearch.PerformUnion(context.Background(), params, newMultiSearchBodyParams()) + assert.NotNil(t, err) + + var httpErr *HTTPError + assert.ErrorAs(t, err, &httpErr) + assert.Equal(t, 500, httpErr.Status) +} + +func TestMultiSearchPerformUnionOnApiClientError(t *testing.T) { + expectedParams := newMultiSearchParams() + expectedBody := newPerformUnionExpectedBodyParams() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockAPIClient := mocks.NewMockAPIClientInterface(ctrl) + + mockAPIClient.EXPECT(). + MultiSearchWithResponse(gomock.Not(gomock.Nil()), expectedParams, api.MultiSearchJSONRequestBody(expectedBody)). + Return(nil, errors.New("failed request")).Times(1) + + client := NewClient(WithAPIClient(mockAPIClient)) + params := newMultiSearchParams() + + _, err := client.MultiSearch.PerformUnion(context.Background(), params, newMultiSearchBodyParams()) + assert.NotNil(t, err) + assert.Equal(t, "failed request", err.Error()) +} diff --git a/typesense/test/multi_search_test.go b/typesense/test/multi_search_test.go index 7d7f9fe2..12b1c6f5 100644 --- a/typesense/test/multi_search_test.go +++ b/typesense/test/multi_search_test.go @@ -373,3 +373,109 @@ func TestMultiSearchWithStopwords(t *testing.T) { // Check second result require.Equal(t, 0, len(*result.Results[1].Hits), "Number of docs in second result did not equal") } + +func TestMultiSearchPerformUnion(t *testing.T) { + collectionName1 := createNewCollection(t, "companies") + collectionName2 := createNewCollection(t, "companies") + + documents1 := []interface{}{ + newDocument("123", withCompanyName("Stark Industries 1"), withNumEmployees(50)), + newDocument("125", withCompanyName("Stark Industries 2"), withNumEmployees(150)), + } + documents2 := []interface{}{ + newDocument("127", withCompanyName("Wayne Enterprises 1"), withNumEmployees(250)), + newDocument("129", withCompanyName("Wayne Enterprises 2"), withNumEmployees(500)), + } + + params := &api.ImportDocumentsParams{Action: pointer.Any(api.Create)} + + _, err := typesenseClient.Collection(collectionName1).Documents().Import(context.Background(), documents1, params) + require.NoError(t, err) + + _, err = typesenseClient.Collection(collectionName2).Documents().Import(context.Background(), documents2, params) + require.NoError(t, err) + + searchParams := &api.MultiSearchParams{ + Q: pointer.String("*"), + QueryBy: pointer.String("company_name"), + SortBy: pointer.String("num_employees:desc"), + Page: pointer.Int(1), + PerPage: pointer.Int(10), + } + + searches := api.MultiSearchSearchesParameter{ + Searches: []api.MultiSearchCollectionParameters{ + { + Collection: pointer.String(collectionName1), + }, + { + Collection: pointer.String(collectionName2), + }, + }, + // PerformUnion will internally enforce Union=true. + } + + result, err := typesenseClient.MultiSearch.PerformUnion(context.Background(), searchParams, searches) + require.NoError(t, err) + + require.NotNil(t, result) + require.NotNil(t, result.Hits) + + // Because of global SortBy: num_employees:desc, the first hit should be Wayne Enterprises 2 (500 employees) + firstHitDoc := *(*result.Hits)[0].Document + require.Equal(t, "Wayne Enterprises 2", firstHitDoc["company_name"]) + require.Equal(t, float64(500), firstHitDoc["num_employees"]) + + // The last hit should be Stark Industries 1 (50) + lastHitDoc := *(*result.Hits)[3].Document + require.Equal(t, "Stark Industries 1", lastHitDoc["company_name"]) + require.Equal(t, float64(50), lastHitDoc["num_employees"]) + + require.NotNil(t, result.Found) + require.Equal(t, 4, *result.Found) + + require.NotNil(t, result.OutOf) + require.Equal(t, 4, *result.OutOf) + + require.NotNil(t, result.Page) + require.Equal(t, 1, *result.Page) + + require.NotNil(t, result.UnionRequestParams) + require.Equal(t, 2, len(*result.UnionRequestParams)) + + // Validate the first sub-query (corresponds to collectionName1) + param1 := (*result.UnionRequestParams)[0] + require.Equal(t, collectionName1, param1.CollectionName) + require.Equal(t, "*", param1.Q) + require.Equal(t, 10, param1.PerPage) + + // Validate the second sub-query (corresponds to collectionName2) + param2 := (*result.UnionRequestParams)[1] + require.Equal(t, collectionName2, param2.CollectionName) + require.Equal(t, "*", param2.Q) + require.Equal(t, 10, param2.PerPage) +} + +func TestMultiSearchPerformUnionTopLevelErrorReturnsHTTPError(t *testing.T) { + _, err := typesenseClient.MultiSearch.PerformUnion( + context.Background(), + &api.MultiSearchParams{ + Q: pointer.String("query"), + }, + api.MultiSearchSearchesParameter{ + Searches: []api.MultiSearchCollectionParameters{ + { + Collection: pointer.String("non-existent-collection"), + }, + }, + }, + ) + require.Error(t, err) + + var httpErr *typesense.HTTPError + require.True(t, errors.As(err, &httpErr)) + require.Equal(t, 404, httpErr.Status) + require.Contains(t, string(httpErr.Body), `"code"`) + require.Contains(t, string(httpErr.Body), `"error"`) + require.Contains(t, string(httpErr.Body), "collection not found") +}