Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions typesense/multi_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"

"github.com/typesense/typesense-go/v4/typesense/api"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions typesense/multi_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
106 changes: 106 additions & 0 deletions typesense/test/multi_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading