diff --git a/apis.go b/apis.go index b3b83f3c..8dcce6da 100644 --- a/apis.go +++ b/apis.go @@ -162,6 +162,12 @@ type APISettings interface { GetAcceptNewFields() (*bool, error) UpdateAcceptNewFields(bool) (*AsyncUpdateID, error) + + GetAttributesForFaceting() (*[]string, error) + + UpdateAttributesForFaceting([]string) (*AsyncUpdateID, error) + + ResetAttributesForFaceting() (*AsyncUpdateID, error) } // APIStats retrieve statistic over all indexes or a specific index id. diff --git a/client_documents_test.go b/client_documents_test.go index 9ef3d024..170fbc65 100644 --- a/client_documents_test.go +++ b/client_documents_test.go @@ -9,6 +9,12 @@ type docTest struct { Name string `json:"name"` } +type docTestBooks struct { + BookID int `json:"book_id"` + Title string `json:"title"` + Tag string `json:"tag"` +} + func TestClientDocuments_Get(t *testing.T) { var indexUID = "TestClientDocuments_Get" diff --git a/client_search.go b/client_search.go index b37d6a3b..f3c56451 100644 --- a/client_search.go +++ b/client_search.go @@ -1,6 +1,7 @@ package meilisearch import ( + "fmt" "net/http" "net/url" "strconv" @@ -51,6 +52,13 @@ func (c clientSearch) Search(request SearchRequest) (*SearchResponse, error) { if request.Matches { values.Add("matches", strconv.FormatBool(request.Matches)) } + if len(request.FacetsDistribution) != 0 { + values.Add("facetsDistribution", fmt.Sprintf("[%q]", strings.Join(request.FacetsDistribution, "\",\""))) + } + if request.FacetFilters != nil { + facetFiltersToStr := facetFiltersToStr(request.FacetFilters) + values.Add("facetFilters", facetFiltersToStr) + } req := internalRequest{ endpoint: "/indexes/" + c.indexUID + "/search?" + values.Encode(), @@ -76,3 +84,27 @@ func (c clientSearch) IndexID() string { func (c clientSearch) Client() *Client { return c.client } + +func facetFiltersToStr(i interface{}) string { + facetsToStr := "" + switch v := i.(type) { + case []string: + for _, slice := range v { + stringSlice := slice + facetsToStr += fmt.Sprintf("%q,", stringSlice) + } + facetsToStr = fmt.Sprintf("[%s]", facetsToStr[:len(facetsToStr)-1]) + case [][]string: + for _, mainSlice := range v { + facetsToStr += "[" + for _, slice := range mainSlice { + stringSlice := slice + facetsToStr += fmt.Sprintf("%q,", stringSlice) + } + facetsToStr = facetsToStr[:len(facetsToStr)-1] + "]," + + } + facetsToStr = fmt.Sprintf("[%s]", facetsToStr[:len(facetsToStr)-1]) + } + return (facetsToStr) +} diff --git a/client_search_test.go b/client_search_test.go index adeaacc3..0c033534 100644 --- a/client_search_test.go +++ b/client_search_test.go @@ -1,6 +1,7 @@ package meilisearch import ( + "fmt" "testing" ) @@ -19,13 +20,19 @@ func TestClientSearch_Search(t *testing.T) { t.Fatal(err) } + booksTest := []docTestBooks{ + {BookID: 123, Title: "Pride and Prejudice", Tag: "Nice book"}, + {BookID: 456, Title: "Le Petit Prince", Tag: "Nice book"}, + {BookID: 1, Title: "Alice In Wonderland", Tag: "Nice book"}, + {BookID: 1344, Title: "The Hobbit", Tag: "Nice book"}, + {BookID: 4, Title: "Harry Potter and the Half-Blood Prince", Tag: "Interesting book"}, + {BookID: 42, Title: "The Hitchhiker's Guide to the Galaxy", Tag: "Interesting book"}, + {BookID: 24, Title: "You are a princess", Tag: "Interesting book"}, + } + updateIDRes, err := client. Documents(indexUID). - AddOrUpdate([]docTest{ - {"0", "J'adore les citrons"}, - {"1", "Les citrons c'est la vie"}, - {"2", "Les ponchos c'est bien !"}, - }) + AddOrUpdate(booksTest) if err != nil { t.Fatal(err) @@ -33,9 +40,52 @@ func TestClientSearch_Search(t *testing.T) { client.DefaultWaitForPendingUpdate(indexUID, updateIDRes) + // Test basic search + resp, err := client.Search(indexUID).Search(SearchRequest{ - Query: "citrons", - Limit: 10, + Query: "prince", + }) + + if err != nil { + t.Fatal(err) + } + + if len(resp.Hits) != 3 { + fmt.Println(resp) + t.Fatal("Basic search: number of hits should be equal to 3") + } + title := resp.Hits[0].(map[string]interface{})["title"] + if title != booksTest[1].Title { + fmt.Println(resp) + t.Fatalf("Basic search: should have found %s\n", booksTest[1].Title) + } + + // Test basic search with limit + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + Limit: 1, + }) + + if err != nil { + t.Fatal(err) + } + + if len(resp.Hits) != 1 { + fmt.Println(resp) + t.Fatal("Search offset: number of hits should be equal to 1") + } + title = resp.Hits[0].(map[string]interface{})["title"] + if title != booksTest[1].Title { + fmt.Println(resp) + t.Fatalf("Basic search: should have found %s\n", booksTest[1].Title) + } + + // Test basic search with offset + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + Offset: 1, }) if err != nil { @@ -43,6 +93,191 @@ func TestClientSearch_Search(t *testing.T) { } if len(resp.Hits) != 2 { + fmt.Println(resp) t.Fatal("number of hits should be equal to 2") } + retrievedTitles := []string{ + fmt.Sprint(resp.Hits[0].(map[string]interface{})["title"]), + fmt.Sprint(resp.Hits[1].(map[string]interface{})["title"]), + } + expectedTitles := []string{ + booksTest[4].Title, + booksTest[6].Title, + } + + for title := range expectedTitles { + found := false + for retrievedTitle := range retrievedTitles { + if title == retrievedTitle { + found = true + break + } + } + if !found { + fmt.Println(resp) + t.Fatal("Search offset: should have found 'Harry Potter and the Half-Blood Prince'") + } + } + + // Test basic search with attributesToRetrieve + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + AttributesToRetrieve: []string{"book_id", "title"}, + }) + + if err != nil { + t.Fatal(err) + } + + if resp.Hits[0].(map[string]interface{})["title"] == nil { + fmt.Println(resp) + t.Fatal("attributesToRetrieve: Couldn't retrieve field in response") + } + if resp.Hits[0].(map[string]interface{})["tag"] != nil { + fmt.Println(resp) + t.Fatal("attributesToRetrieve: Retrieve unrequested field in response") + } + + // Test basic search with attributesToCrop + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "to", + AttributesToCrop: []string{"title"}, + CropLength: 7, + }) + + if err != nil { + t.Fatal(err) + } + + if resp.Hits[0].(map[string]interface{})["title"] == nil { + fmt.Println(resp) + t.Fatal("attributesToCrop: Couldn't retrieve field in response") + } + formatted := resp.Hits[0].(map[string]interface{})["_formatted"] + if formatted.(map[string]interface{})["title"] != "Guide to the" { + fmt.Println(resp) + t.Fatal("attributesToCrop: CropLength didn't work as expected") + } + + // Test basic search with filters + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "and", + Filters: "tag = \"Nice book\"", + }) + + if err != nil { + t.Fatal(err) + } + + if len(resp.Hits) != 1 { + fmt.Println(resp) + t.Fatal("filters: Unable to filter properly") + } + if resp.Hits[0].(map[string]interface{})["title"] != "Pride and Prejudice" { + fmt.Println(resp) + t.Fatal("filters: Unable to filter properly") + } + + // Test basic search with matches + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "and", + Matches: true, + }) + + if err != nil { + t.Fatal(err) + } + + if resp.Hits[0].(map[string]interface{})["_matchesInfo"] == nil { + fmt.Println(resp) + t.Fatal("matches: Mathes info not found") + } + + // Test basic search with facetsDistribution + + r2, err := client.Settings(indexUID).UpdateAttributesForFaceting([]string{"tag"}) + + if err != nil { + t.Fatal(err) + } + + client.DefaultWaitForPendingUpdate(indexUID, r2) + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + FacetsDistribution: []string{"*"}, + }) + + if err != nil { + t.Fatal(err) + } + + tagCount := resp.FacetsDistribution.(map[string]interface{})["tag"] + + if len(tagCount.(map[string]interface{})) != 2 { + fmt.Println(tagCount.(map[string]interface{})) + t.Fatal("facetsDistribution: Wrong count of facet options") + } + + if tagCount.(map[string]interface{})["interesting book"] != float64(2) { + fmt.Println(tagCount.(map[string]interface{})["interesting book"]) + t.Fatal("facetsDistribution: Wrong count on facetDistribution") + } + + r2, _ = client.Settings(indexUID).ResetAttributesForFaceting() + client.DefaultWaitForPendingUpdate(indexUID, r2) + + // Test basic search with facetFilters + + r2, err = client.Settings(indexUID).UpdateAttributesForFaceting([]string{"tag", "title"}) + + if err != nil { + t.Fatal(err) + } + + client.DefaultWaitForPendingUpdate(indexUID, r2) + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + FacetFilters: []string{"tag:interesting book"}, + }) + if err != nil { + fmt.Println("Error:", err) + } + + if len(resp.Hits) != 2 { + fmt.Println(resp) + t.Fatal("facetsFilters: Error on single attribute facet search") + } + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + FacetFilters: []string{"tag:interesting book", "tag:nice book"}, + }) + if err != nil { + fmt.Println("Error:", err) + } + + if len(resp.Hits) != 0 { + fmt.Println(resp) + t.Fatal("facetsFilters: Error on 'AND' in attribute facet search") + } + + resp, err = client.Search(indexUID).Search(SearchRequest{ + Query: "prince", + FacetFilters: [][]string{{"tag:interesting book", "tag:nice book"}}, + }) + if err != nil { + fmt.Println("Error:", err) + } + + if len(resp.Hits) != 3 { + fmt.Println(resp) + t.Fatal("facetsFilters: Error on 'OR' in attribute facet search") + } + } diff --git a/client_settings.go b/client_settings.go index f8af2fb8..d8b21ddc 100644 --- a/client_settings.go +++ b/client_settings.go @@ -451,3 +451,59 @@ func (c clientSettings) UpdateAcceptNewFields(request bool) (resp *AsyncUpdateID return resp, nil } + +func (c clientSettings) GetAttributesForFaceting() (resp *[]string, err error) { + resp = &[]string{} + req := internalRequest{ + endpoint: "/indexes/" + c.indexUID + "/settings/attributes-for-faceting", + method: http.MethodGet, + withRequest: nil, + withResponse: resp, + acceptedStatusCodes: []int{http.StatusOK}, + functionName: "GetAttributesForFaceting", + apiName: "Settings", + } + + if err := c.client.executeRequest(req); err != nil { + return nil, err + } + return resp, nil +} + +func (c clientSettings) UpdateAttributesForFaceting(request []string) (resp *AsyncUpdateID, err error) { + resp = &AsyncUpdateID{} + req := internalRequest{ + endpoint: "/indexes/" + c.indexUID + "/settings/attributes-for-faceting", + method: http.MethodPost, + withRequest: &request, + withResponse: resp, + acceptedStatusCodes: []int{http.StatusAccepted}, + functionName: "UpdateAttributesForFaceting", + apiName: "Settings", + } + + if err := c.client.executeRequest(req); err != nil { + return nil, err + } + + return resp, nil +} + +func (c clientSettings) ResetAttributesForFaceting() (resp *AsyncUpdateID, err error) { + resp = &AsyncUpdateID{} + req := internalRequest{ + endpoint: "/indexes/" + c.indexUID + "/settings/attributes-for-faceting", + method: http.MethodDelete, + withRequest: nil, + withResponse: resp, + acceptedStatusCodes: []int{http.StatusAccepted}, + functionName: "ResetAttributesForFaceting", + apiName: "Settings", + } + + if err := c.client.executeRequest(req); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/client_settings_test.go b/client_settings_test.go index 4300cb46..eb2184d6 100644 --- a/client_settings_test.go +++ b/client_settings_test.go @@ -1,6 +1,7 @@ package meilisearch import ( + "fmt" "reflect" "testing" ) @@ -27,13 +28,14 @@ func TestClientSettings_GetAll(t *testing.T) { } expected := Settings{ - RankingRules: []string{"typo", "words", "proximity", "attribute", "wordsPosition", "exactness"}, - DistinctAttribute: nil, - SearchableAttributes: []string{}, - DisplayedAttributes: []string{}, - StopWords: []string{}, - Synonyms: map[string][]string{}, - AcceptNewFields: true, + RankingRules: []string{"typo", "words", "proximity", "attribute", "wordsPosition", "exactness"}, + DistinctAttribute: nil, + SearchableAttributes: []string{}, + DisplayedAttributes: []string{}, + StopWords: []string{}, + Synonyms: map[string][]string{}, + AcceptNewFields: true, + AttributesForFaceting: []string{}, } if !reflect.DeepEqual(*settingsRes, expected) { @@ -64,7 +66,8 @@ func TestClientSettings_UpdateAll(t *testing.T) { Synonyms: map[string][]string{ "car": []string{"automobile"}, }, - AcceptNewFields: false, + AcceptNewFields: false, + AttributesForFaceting: []string{"title"}, } updateIDRes, err := client.Settings(indexUID).UpdateAll(settings) @@ -616,3 +619,103 @@ func TestClientSettings_UpdateAcceptNewFields(t *testing.T) { client.DefaultWaitForPendingUpdate(indexUID, updateIDRes) } + +func TestClientSettings_GetAttributesForFaceting(t *testing.T) { + var indexUID = "TestClientSettings_GetAttributesForFaceting" + + var client = NewClient(Config{ + Host: "http://localhost:7700", + }) + + _, err := client.Indexes().Create(CreateIndexRequest{ + UID: indexUID, + }) + + if err != nil { + t.Fatal(err) + } + + AttributesForFacetingRes, err := client.Settings(indexUID).GetAttributesForFaceting() + + if err != nil { + t.Fatal(err) + } + + if reflect.DeepEqual(*AttributesForFacetingRes, nil) { + t.Fatal("getAttributesForFaceting: Error getting attributesForFaceting on empty index") + } +} + +func TestClientSettings_UpdateAttributesForFaceting(t *testing.T) { + var indexUID = "TestClientSettings_UpdateAttributesForFaceting" + + var client = NewClient(Config{ + Host: "http://localhost:7700", + }) + + attributesForFaceting := []string{"tag", "title"} + + _, err := client.Indexes().Create(CreateIndexRequest{ + UID: indexUID, + }) + + if err != nil { + t.Fatal(err) + } + + updateIDRes, err := client.Settings(indexUID).UpdateAttributesForFaceting(attributesForFaceting) + + if err != nil { + t.Fatal(err) + } + + client.DefaultWaitForPendingUpdate(indexUID, updateIDRes) + r, _ := client.Settings(indexUID).GetAttributesForFaceting() + if !reflect.DeepEqual(*r, attributesForFaceting) { + fmt.Println(*r) + t.Fatal("updateAttributesForFaceting: Error getting attributesForFaceting after update") + } +} + +func TestClientSettings_ResetAttributesForFaceting(t *testing.T) { + var indexUID = "TestClientSettings_ResetAttributesForFaceting" + + var client = NewClient(Config{ + Host: "http://localhost:7700", + }) + + attributesForFaceting := []string{"tag", "title"} + + _, err := client.Indexes().Create(CreateIndexRequest{ + UID: indexUID, + }) + + if err != nil { + t.Fatal(err) + } + + updateIDRes, err := client.Settings(indexUID).UpdateAttributesForFaceting(attributesForFaceting) + + if err != nil { + t.Fatal(err) + } + + client.DefaultWaitForPendingUpdate(indexUID, updateIDRes) + r, _ := client.Settings(indexUID).GetAttributesForFaceting() + if !reflect.DeepEqual(*r, attributesForFaceting) { + fmt.Println(*r) + t.Fatal("resetAttributesForFaceting: Error getting attributesForFaceting after update") + } + + updateIDRes, err = client.Settings(indexUID).ResetAttributesForFaceting() + + if err != nil { + t.Fatal(err) + } + + client.DefaultWaitForPendingUpdate(indexUID, updateIDRes) + r, _ = client.Settings(indexUID).GetAttributesForFaceting() + if reflect.DeepEqual(*r, nil) { + t.Fatal("resetAttributesForFaceting: Error getting attributesForFaceting after reset") + } +} diff --git a/types.go b/types.go index 40819a7e..16f90934 100644 --- a/types.go +++ b/types.go @@ -18,18 +18,19 @@ type Index struct { PrimaryKey string `json:"primaryKey,omitempty"` } -// Settings is the type that represent the settings in MeiliSearch +// Settings is the type that represents the settings in MeiliSearch type Settings struct { - RankingRules []string `json:"rankingRules,omitempty"` - DistinctAttribute *string `json:"distinctAttribute,omitempty"` - SearchableAttributes []string `json:"searchableAttributes,omitempty"` - DisplayedAttributes []string `json:"displayedAttributes,omitempty"` - StopWords []string `json:"stopWords,omitempty"` - Synonyms map[string][]string `json:"synonyms,omitempty"` - AcceptNewFields bool `json:"acceptNewFields,omitempty"` + RankingRules []string `json:"rankingRules,omitempty"` + DistinctAttribute *string `json:"distinctAttribute,omitempty"` + SearchableAttributes []string `json:"searchableAttributes,omitempty"` + DisplayedAttributes []string `json:"displayedAttributes,omitempty"` + StopWords []string `json:"stopWords,omitempty"` + Synonyms map[string][]string `json:"synonyms,omitempty"` + AcceptNewFields bool `json:"acceptNewFields,omitempty"` + AttributesForFaceting []string `json:"attributesForFaceting,omitempty"` } -// Version is the type that represent the versions in MeiliSearch +// Version is the type that represents the versions in MeiliSearch type Version struct { CommitSha string `json:"commitSha"` BuildDate time.Time `json:"buildDate"` @@ -158,15 +159,19 @@ type SearchRequest struct { AttributesToHighlight []string Filters string Matches bool + FacetsDistribution []string + FacetFilters interface{} } // SearchResponse is the response body for search method type SearchResponse struct { - Hits []interface{} `json:"hits"` - Offset int64 `json:"offset"` - Limit int64 `json:"limit"` - ProcessingTimeMs int64 `json:"processingTimeMs"` - Query string `json:"query"` + Hits []interface{} `json:"hits"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` + ProcessingTimeMs int64 `json:"processingTimeMs"` + Query string `json:"query"` + FacetsDistribution interface{} `json:"facetsDistribution,omitempty"` + ExhaustiveFacetsCount interface{} `json:"exhaustiveFacetsCount,omitempty"` } // ListDocumentsRequest is the request body for list documents method