From e32581aef3253bb0691d6fd678fbf01e86f2c10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 11 Sep 2018 22:45:31 +0200 Subject: MM-11725: Add specific autocomplete endpoint for search autocomplete (#9337) --- api4/channel.go | 25 ++++++++++ app/channel.go | 10 ++++ model/client4.go | 11 +++++ store/sqlstore/channel_store.go | 47 +++++++++++++++++++ store/store.go | 1 + store/storetest/channel_store.go | 87 +++++++++++++++++++++++++++++++++++ store/storetest/mocks/ChannelStore.go | 16 +++++++ 7 files changed, 197 insertions(+) diff --git a/api4/channel.go b/api4/channel.go index d497c9793..b29d2a94c 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -22,6 +22,7 @@ func (api *API) InitChannel() { api.BaseRoutes.ChannelsForTeam.Handle("/ids", api.ApiSessionRequired(getPublicChannelsByIdsForTeam)).Methods("POST") api.BaseRoutes.ChannelsForTeam.Handle("/search", api.ApiSessionRequired(searchChannelsForTeam)).Methods("POST") api.BaseRoutes.ChannelsForTeam.Handle("/autocomplete", api.ApiSessionRequired(autocompleteChannelsForTeam)).Methods("GET") + api.BaseRoutes.ChannelsForTeam.Handle("/search_autocomplete", api.ApiSessionRequired(autocompleteChannelsForTeamForSearch)).Methods("GET") api.BaseRoutes.User.Handle("/teams/{team_id:[A-Za-z0-9]+}/channels", api.ApiSessionRequired(getChannelsForTeamForUser)).Methods("GET") api.BaseRoutes.Channel.Handle("", api.ApiSessionRequired(getChannel)).Methods("GET") @@ -642,6 +643,30 @@ func autocompleteChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Requ w.Write([]byte(channels.ToJson())) } +func autocompleteChannelsForTeamForSearch(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToTeam(c.Session, c.Params.TeamId, model.PERMISSION_LIST_TEAM_CHANNELS) { + c.SetPermissionError(model.PERMISSION_LIST_TEAM_CHANNELS) + return + } + + name := r.URL.Query().Get("name") + + channels, err := c.App.AutocompleteChannelsForSearch(c.Params.TeamId, c.Session.UserId, name) + if err != nil { + c.Err = err + return + } + + // Don't fill in channels props, since unused by client and potentially expensive. + + w.Write([]byte(channels.ToJson())) +} + func searchChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireTeamId() if c.Err != nil { diff --git a/app/channel.go b/app/channel.go index 0ac2894fb..4867908f9 100644 --- a/app/channel.go +++ b/app/channel.go @@ -1507,6 +1507,16 @@ func (a *App) AutocompleteChannels(teamId string, term string) (*model.ChannelLi } } +func (a *App) AutocompleteChannelsForSearch(teamId string, userId string, term string) (*model.ChannelList, *model.AppError) { + includeDeleted := *a.Config().TeamSettings.ExperimentalViewArchivedChannels + + if result := <-a.Srv.Store.Channel().AutocompleteInTeamForSearch(teamId, userId, term, includeDeleted); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.ChannelList), nil + } +} + func (a *App) SearchChannels(teamId string, term string) (*model.ChannelList, *model.AppError) { includeDeleted := *a.Config().TeamSettings.ExperimentalViewArchivedChannels diff --git a/model/client4.go b/model/client4.go index 47d227742..c95aaad0b 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1961,6 +1961,17 @@ func (c *Client4) AutocompleteChannelsForTeam(teamId, name string) (*ChannelList } } +// AutocompleteChannelsForTeamForSearch will return an ordered list of your channels autocomplete suggestions +func (c *Client4) AutocompleteChannelsForTeamForSearch(teamId, name string) (*ChannelList, *Response) { + query := fmt.Sprintf("?name=%v", name) + if r, err := c.DoApiGet(c.GetChannelsForTeamRoute(teamId)+"/search_autocomplete"+query, ""); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return ChannelListFromJson(r.Body), BuildResponse(r) + } +} + // Post Section // CreatePost creates a post based on the provided post struct. diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 1b5ae7ff7..820fe1e9f 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -1589,6 +1589,53 @@ func (s SqlChannelStore) AutocompleteInTeam(teamId string, term string, includeD }) } +func (s SqlChannelStore) AutocompleteInTeamForSearch(teamId string, userId string, term string, includeDeleted bool) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + deleteFilter := "AND DeleteAt = 0" + if includeDeleted { + deleteFilter = "" + } + + queryFormat := ` + SELECT + C.* + FROM + Channels AS C + JOIN + ChannelMembers AS CM ON CM.ChannelId = C.Id + WHERE + C.TeamId = :TeamId + AND CM.UserId = :UserId + ` + deleteFilter + ` + %v + LIMIT 50` + + var channels model.ChannelList + + if likeClause, likeTerm := s.buildLIKEClause(term); likeClause == "" { + if _, err := s.GetReplica().Select(&channels, fmt.Sprintf(queryFormat, ""), map[string]interface{}{"TeamId": teamId, "UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlChannelStore.AutocompleteInTeamForSearch", "store.sql_channel.search.app_error", nil, "term="+term+", "+", "+err.Error(), http.StatusInternalServerError) + } + } else { + // Using a UNION results in index_merge and fulltext queries and is much faster than the ref + // query you would get using an OR of the LIKE and full-text clauses. + fulltextClause, fulltextTerm := s.buildFulltextClause(term) + likeQuery := fmt.Sprintf(queryFormat, "AND "+likeClause) + fulltextQuery := fmt.Sprintf(queryFormat, "AND "+fulltextClause) + query := fmt.Sprintf("(%v) UNION (%v) LIMIT 50", likeQuery, fulltextQuery) + + if _, err := s.GetReplica().Select(&channels, query, map[string]interface{}{"TeamId": teamId, "UserId": userId, "LikeTerm": likeTerm, "FulltextTerm": fulltextTerm}); err != nil { + result.Err = model.NewAppError("SqlChannelStore.AutocompleteInTeamForSearch", "store.sql_channel.search.app_error", nil, "term="+term+", "+", "+err.Error(), http.StatusInternalServerError) + } + } + + sort.Slice(channels, func(a, b int) bool { + return strings.ToLower(channels[a].DisplayName) < strings.ToLower(channels[b].DisplayName) + }) + result.Data = &channels + }) +} + func (s SqlChannelStore) SearchInTeam(teamId string, term string, includeDeleted bool) store.StoreChannel { return store.Do(func(result *store.StoreResult) { deleteFilter := "AND DeleteAt = 0" diff --git a/store/store.go b/store/store.go index 0c89a0a91..8da70d7ec 100644 --- a/store/store.go +++ b/store/store.go @@ -162,6 +162,7 @@ type ChannelStore interface { AnalyticsTypeCount(teamId string, channelType string) StoreChannel GetMembersForUser(teamId string, userId string) StoreChannel AutocompleteInTeam(teamId string, term string, includeDeleted bool) StoreChannel + AutocompleteInTeamForSearch(teamId string, userId string, term string, includeDeleted bool) StoreChannel SearchInTeam(teamId string, term string, includeDeleted bool) StoreChannel SearchMore(userId string, teamId string, term string) StoreChannel GetMembersByIds(channelId string, userIds []string) StoreChannel diff --git a/store/storetest/channel_store.go b/store/storetest/channel_store.go index 776434b6e..54316d1ce 100644 --- a/store/storetest/channel_store.go +++ b/store/storetest/channel_store.go @@ -48,6 +48,7 @@ func TestChannelStore(t *testing.T, ss store.Store) { t.Run("GetMemberCount", func(t *testing.T) { testGetMemberCount(t, ss) }) t.Run("SearchMore", func(t *testing.T) { testChannelStoreSearchMore(t, ss) }) t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, ss) }) + t.Run("AutocompleteInTeamForSearch", func(t *testing.T) { testChannelStoreAutocompleteInTeamForSearch(t, ss) }) t.Run("GetMembersByIds", func(t *testing.T) { testChannelStoreGetMembersByIds(t, ss) }) t.Run("AnalyticsDeletedTypeCount", func(t *testing.T) { testChannelStoreAnalyticsDeletedTypeCount(t, ss) }) t.Run("GetPinnedPosts", func(t *testing.T) { testChannelStoreGetPinnedPosts(t, ss) }) @@ -2054,6 +2055,92 @@ func testChannelStoreSearchInTeam(t *testing.T, ss store.Store) { } } +func testChannelStoreAutocompleteInTeamForSearch(t *testing.T, ss store.Store) { + o1 := model.Channel{} + o1.TeamId = model.NewId() + o1.DisplayName = "ChannelA" + o1.Name = "zz" + model.NewId() + "b" + o1.Type = model.CHANNEL_OPEN + store.Must(ss.Channel().Save(&o1, -1)) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = model.NewId() + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + store.Must(ss.Channel().SaveMember(&m1)) + + o2 := model.Channel{} + o2.TeamId = model.NewId() + o2.DisplayName = "Channel2" + o2.Name = "zz" + model.NewId() + "b" + o2.Type = model.CHANNEL_OPEN + store.Must(ss.Channel().Save(&o2, -1)) + + m2 := model.ChannelMember{} + m2.ChannelId = o2.Id + m2.UserId = m1.UserId + m2.NotifyProps = model.GetDefaultChannelNotifyProps() + store.Must(ss.Channel().SaveMember(&m2)) + + o3 := model.Channel{} + o3.TeamId = o1.TeamId + o3.DisplayName = "ChannelA" + o3.Name = "zz" + model.NewId() + "b" + o3.Type = model.CHANNEL_OPEN + store.Must(ss.Channel().Save(&o3, -1)) + + m3 := model.ChannelMember{} + m3.ChannelId = o3.Id + m3.UserId = m1.UserId + m3.NotifyProps = model.GetDefaultChannelNotifyProps() + store.Must(ss.Channel().SaveMember(&m3)) + + store.Must(ss.Channel().SetDeleteAt(o3.Id, 100, 100)) + + o4 := model.Channel{} + o4.TeamId = o1.TeamId + o4.DisplayName = "ChannelA" + o4.Name = "zz" + model.NewId() + "b" + o4.Type = model.CHANNEL_PRIVATE + store.Must(ss.Channel().Save(&o4, -1)) + + m4 := model.ChannelMember{} + m4.ChannelId = o4.Id + m4.UserId = m1.UserId + m4.NotifyProps = model.GetDefaultChannelNotifyProps() + store.Must(ss.Channel().SaveMember(&m4)) + + o5 := model.Channel{} + o5.TeamId = o1.TeamId + o5.DisplayName = "ChannelC" + o5.Name = "zz" + model.NewId() + "b" + o5.Type = model.CHANNEL_PRIVATE + store.Must(ss.Channel().Save(&o5, -1)) + + tt := []struct { + name string + term string + includeDeleted bool + expectedMatches int + }{ + {"Empty search (list all)", "", false, 3}, + {"Narrow search", "ChannelA", false, 2}, + {"Wide search", "Cha", false, 3}, + {"Wide search with archived channels", "Cha", true, 4}, + {"Narrow with archived channels", "ChannelA", true, 3}, + {"Search without results", "blarg", true, 0}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + result := <-ss.Channel().AutocompleteInTeamForSearch(o1.TeamId, m1.UserId, "ChannelA", false) + require.Nil(t, result.Err) + channels := result.Data.(*model.ChannelList) + require.Len(t, *channels, 2) + }) + } +} + func testChannelStoreGetMembersByIds(t *testing.T, ss store.Store) { o1 := model.Channel{} o1.TeamId = model.NewId() diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 747a844ec..63f6bc6a9 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -61,6 +61,22 @@ func (_m *ChannelStore) AutocompleteInTeam(teamId string, term string, includeDe return r0 } +// AutocompleteInTeamForSearch provides a mock function with given fields: teamId, userId, term, includeDeleted +func (_m *ChannelStore) AutocompleteInTeamForSearch(teamId string, userId string, term string, includeDeleted bool) store.StoreChannel { + ret := _m.Called(teamId, userId, term, includeDeleted) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string, string, bool) store.StoreChannel); ok { + r0 = rf(teamId, userId, term, includeDeleted) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + // ClearAllCustomRoleAssignments provides a mock function with given fields: func (_m *ChannelStore) ClearAllCustomRoleAssignments() store.StoreChannel { ret := _m.Called() -- cgit v1.2.3-1-g7c22