Skip to content

Commit

Permalink
Search boards by prop (#4291)
Browse files Browse the repository at this point in the history
* SearchBoardsForUser API with property name search.
  • Loading branch information
wiggin77 authored Dec 16, 2022
1 parent 2a5c033 commit b63542f
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 50 deletions.
19 changes: 17 additions & 2 deletions server/api/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// description: The search term. Must have at least one character
// required: true
// type: string
// - name: field
// in: query
// description: The field to search on for search term. Can be `title`, `property_name`. Defaults to `title`
// required: false
// type: string
// security:
// - BearerAuth: []
// responses:
Expand All @@ -128,8 +133,18 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// schema:
// "$ref": "#/definitions/ErrorResponse"

var err error
teamID := mux.Vars(r)["teamID"]
term := r.URL.Query().Get("q")
searchFieldText := r.URL.Query().Get("field")
searchField := model.BoardSearchFieldTitle
if searchFieldText != "" {
searchField, err = model.BoardSearchFieldFromString(searchFieldText)
if err != nil {
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
return
}
}
userID := getUserID(r)

if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
Expand All @@ -153,7 +168,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
}

// retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
boards, err := a.app.SearchBoardsForUser(term, searchField, userID, !isGuest)
if err != nil {
a.errorResponse(w, r, err)
return
Expand Down Expand Up @@ -312,7 +327,7 @@ func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
}

// retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
boards, err := a.app.SearchBoardsForUser(term, model.BoardSearchFieldTitle, userID, !isGuest)
if err != nil {
a.errorResponse(w, r, err)
return
Expand Down
4 changes: 2 additions & 2 deletions server/app/boards.go
Original file line number Diff line number Diff line change
Expand Up @@ -637,8 +637,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
return nil
}

func (a *App) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, userID, includePublicBoards)
func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
}

func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
Expand Down
11 changes: 11 additions & 0 deletions server/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,17 @@ func (c *Client) GetBoardsForTeam(teamID string) ([]*model.Board, *Response) {
return model.BoardsFromJSON(r.Body), BuildResponse(r)
}

func (c *Client) SearchBoardsForUser(teamID, term string, field model.BoardSearchField) ([]*model.Board, *Response) {
query := fmt.Sprintf("q=%s&field=%s", term, field)
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?"+query, "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)

return model.BoardsFromJSON(r.Body), BuildResponse(r)
}

func (c *Client) SearchBoardsForTeam(teamID, term string) ([]*model.Board, *Response) {
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?q="+term, "")
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions server/integrationtests/pluginteststore.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,8 @@ func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel,
return nil, errTestStore
}

func (s *PluginTestStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) {
boards, err := s.Store.SearchBoardsForUser(term, userID, includePublicBoards)
func (s *PluginTestStore) SearchBoardsForUser(term string, field model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
boards, err := s.Store.SearchBoardsForUser(term, field, userID, includePublicBoards)
if err != nil {
return nil, err
}
Expand Down
17 changes: 17 additions & 0 deletions server/model/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

type BoardType string
type BoardRole string
type BoardSearchField string

const (
BoardTypeOpen BoardType = "O"
Expand All @@ -22,6 +23,12 @@ const (
BoardRoleAdmin BoardRole = "admin"
)

const (
BoardSearchFieldNone BoardSearchField = ""
BoardSearchFieldTitle BoardSearchField = "title"
BoardSearchFieldPropertyName BoardSearchField = "property_name"
)

// Board groups a set of blocks and its layout
// swagger:model
type Board struct {
Expand Down Expand Up @@ -392,3 +399,13 @@ type BoardMemberHistoryEntry struct {
// required: true
InsertAt time.Time `json:"insertAt"`
}

func BoardSearchFieldFromString(field string) (BoardSearchField, error) {
switch field {
case string(BoardSearchFieldTitle):
return BoardSearchFieldTitle, nil
case string(BoardSearchFieldPropertyName):
return BoardSearchFieldPropertyName, nil
}
return BoardSearchFieldNone, ErrInvalidBoardSearchField
}
2 changes: 2 additions & 0 deletions server/model/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
ErrBoardMemberIsLastAdmin = errors.New("cannot leave a board with no admins")

ErrRequestEntityTooLarge = errors.New("request entity too large")

ErrInvalidBoardSearchField = errors.New("invalid board search field")
)

// ErrNotFound is an error type that can be returned by store APIs
Expand Down
44 changes: 30 additions & 14 deletions server/services/store/mattermostauthlayer/mattermostauthlayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ func (s *MattermostAuthLayer) baseUserQuery(showEmail, showName bool) sq.SelectB
// term that are either private and which the user is a member of, or
// they're open, regardless of the user membership.
// Search is case-insensitive.
func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
func (s *MattermostAuthLayer) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
// as we're joining three queries, we need to avoid numbered
// placeholders until the join is done, so we use the default
// question mark placeholder here
Expand Down Expand Up @@ -706,21 +706,37 @@ func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePu
})

if term != "" {
// break search query into space separated words
// and search for all words.
// This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space
// to break words.

conditions := sq.And{}
if searchField == model.BoardSearchFieldPropertyName {
var where, whereTerm string
switch s.dbType {
case model.PostgresDBType:
where = "b.properties->? is not null"
whereTerm = term
case model.MysqlDBType, model.SqliteDBType:
where = "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
whereTerm = "$." + term
default:
where = "b.properties LIKE ?"
whereTerm = "%\"" + term + "\"%"
}
boardMembersQ = boardMembersQ.Where(where, whereTerm)
teamMembersQ = teamMembersQ.Where(where, whereTerm)
channelMembersQ = channelMembersQ.Where(where, whereTerm)
} else { // model.BoardSearchFieldTitle
// break search query into space separated words
// and search for all words.
// This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space
// to break words.
conditions := sq.And{}
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
}

for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
boardMembersQ = boardMembersQ.Where(conditions)
teamMembersQ = teamMembersQ.Where(conditions)
channelMembersQ = channelMembersQ.Where(conditions)
}

boardMembersQ = boardMembersQ.Where(conditions)
teamMembersQ = teamMembersQ.Where(conditions)
channelMembersQ = channelMembersQ.Where(conditions)
}

teamMembersSQL, teamMembersArgs, err := teamMembersQ.ToSql()
Expand Down
8 changes: 4 additions & 4 deletions server/services/store/mockstore/mockstore.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 24 additions & 13 deletions server/services/store/sqlstore/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*mode
// term that are either private and which the user is a member of, or
// they're open, regardless of the user membership.
// Search is case-insensitive.
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, includePublicBoards bool) ([]*model.Board, error) {
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
query := s.getQueryBuilder(db).
Select(boardFields("b.")...).
Distinct().
Expand All @@ -680,19 +680,30 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, in
}

if term != "" {
// break search query into space separated words
// and search for all words.
// This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space
// to break words.

conditions := sq.And{}

for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
if searchField == model.BoardSearchFieldPropertyName {
switch s.dbType {
case model.PostgresDBType:
where := "b.properties->? is not null"
query = query.Where(where, term)
case model.MysqlDBType, model.SqliteDBType:
where := "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
query = query.Where(where, "$."+term)
default:
where := "b.properties LIKE ?"
query = query.Where(where, "%\""+term+"\"%")
}
} else { // model.BoardSearchFieldTitle
// break search query into space separated words
// and search for all words.
// This should later be upgraded to industrial-strength
// word tokenizer, that uses much more than space
// to break words.
conditions := sq.And{}
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
}
query = query.Where(conditions)
}

query = query.Where(conditions)
}

rows, err := query.Query()
Expand Down
4 changes: 2 additions & 2 deletions server/services/store/sqlstore/public_methods.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/services/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ type Store interface {
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
GetMembersForUser(userID string) ([]*model.BoardMember, error)
CanSeeUser(seerID string, seenID string) (bool, error)
SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error)
SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error)
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)

// @withTransaction
Expand Down
Loading

0 comments on commit b63542f

Please sign in to comment.