diff --git a/server/channels/store/searchtest/post_layer.go b/server/channels/store/searchtest/post_layer.go index 116e92b282..4f3c18d985 100644 --- a/server/channels/store/searchtest/post_layer.go +++ b/server/channels/store/searchtest/post_layer.go @@ -30,8 +30,8 @@ var searchPostStoreTests = []searchTest{ Tags: []string{EngineAll}, }, { - Name: "Should be able to search for exact phrases in quotes", - Fn: testSearchExactPhraseInQuotes, + Name: "Should be able to search for quoted patterns with AND OR combinations", + Fn: testSearchANDORQuotesCombinations, Tags: []string{EnginePostgres, EngineMySql, EngineElasticSearch}, }, { @@ -352,24 +352,107 @@ func testSearchReturnPinnedAndUnpinned(t *testing.T, th *SearchTestHelper) { th.checkPostInSearchResults(t, p2.Id, results.Posts) } -func testSearchExactPhraseInQuotes(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "channel test 1 2 3", "", model.PostTypeDefault, 0, false) +func testSearchANDORQuotesCombinations(t *testing.T, th *SearchTestHelper) { + p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "one two three four", "", model.PostTypeDefault, 0, false) require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "channel test 123", "", model.PostTypeDefault, 0, false) + p2, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "one two five", "", model.PostTypeDefault, 0, false) require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "channel something test 1 2 3", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "channel 1 2 3", "", model.PostTypeDefault, 0, false) + p3, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "one five six", "", model.PostTypeDefault, 0, false) require.NoError(t, err) defer th.deleteUserPosts(th.User.Id) - params := &model.SearchParams{Terms: "\"channel test 1 2 3\""} - results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) - require.NoError(t, err) + testCases := []struct { + name string + terms string + orTerms bool + expectedLen int + expectedIDs []string + }{ + { + name: "AND operator, No Quotes, Matches 1", + terms: `two four`, + orTerms: false, + expectedLen: 1, + expectedIDs: []string{p1.Id}, + }, + { + name: "AND operator, No Quotes, Matches 0", + terms: `two six`, + orTerms: false, + expectedLen: 0, + expectedIDs: []string{}, + }, + { + name: "AND operator, With Full Quotes, Matches 0", + terms: `"two four"`, + orTerms: false, + expectedLen: 0, + expectedIDs: []string{}, + }, + { + name: "AND operator, With Full Quotes, Matches 1", + terms: `"two three four"`, + orTerms: false, + expectedLen: 1, + expectedIDs: []string{p1.Id}, + }, + { + name: "AND operator, With Part Quotes, Matches 1", + terms: `two "three four"`, + orTerms: false, + expectedLen: 1, + expectedIDs: []string{p1.Id}, + }, + { + name: "OR operator, No Quotes, Matches 2", + terms: `two four`, + orTerms: true, + expectedLen: 2, + expectedIDs: []string{p1.Id, p2.Id}, + }, + { + name: "OR operator, No Quotes, Matches 3", + terms: `two six`, + orTerms: true, + expectedLen: 3, + expectedIDs: []string{p1.Id, p2.Id, p3.Id}, + }, + { + name: "OR operator, With Full Quotes, Matches 0", + terms: `"two four"`, + orTerms: true, + expectedLen: 0, + expectedIDs: []string{}, + }, + { + name: "OR operator, With Full Quotes, Matches 1", + terms: `"two three four"`, + orTerms: true, + expectedLen: 1, + expectedIDs: []string{p1.Id}, + }, + { + name: "OR operator, With Part Quotes, Matches 2", + terms: `two "three four"`, + orTerms: true, + expectedLen: 2, + expectedIDs: []string{p1.Id, p2.Id}, + }, + } - require.Len(t, results.Posts, 1) - th.checkPostInSearchResults(t, p1.Id, results.Posts) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := &model.SearchParams{Terms: tc.terms, OrTerms: tc.orTerms} + results, err := th.Store.Post().SearchPostsForUser([]*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) + require.NoError(t, err) + + require.Len(t, results.Posts, tc.expectedLen) + for _, id := range tc.expectedIDs { + th.checkPostInSearchResults(t, id, results.Posts) + } + }) + } } func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index 9120be25bc..9df833e82d 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -24,6 +24,9 @@ import ( "github.com/mattermost/mattermost/server/v8/einterfaces" ) +// Regex to get quoted strings +var quotedStringsRegex = regexp.MustCompile(`("[^"]*")`) + type SqlPostStore struct { *SqlStore metrics einterfaces.MetricsInterface @@ -2029,27 +2032,38 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search excludedTerms = wildcard.ReplaceAllLiteralString(excludedTerms, ":* ") } - excludeClause := "" - if excludedTerms != "" { - excludeClause = " & !(" + strings.Join(strings.Fields(excludedTerms), " | ") + ")" - } - - var termsClause string - if params.OrTerms { - termsClause = "(" + strings.Join(strings.Fields(terms), " | ") + ")" + excludeClause - } else if strings.HasPrefix(terms, `"`) && strings.HasSuffix(terms, `"`) { - termsClause = "(" + strings.Join(strings.Fields(terms), " <-> ") + ")" + excludeClause - } else { - tsVectorSearchQuery := strings.Join(strings.Fields(terms), " & ") - if params.IsHashtag { - tsVectorSearchQuery = terms + // Replace spaces with to_tsquery symbols + replaceSpaces := func(input string, excludedInput bool) string { + if input == "" { + return input } - termsClause = "(" + tsVectorSearchQuery + ")" + excludeClause + // Remove extra spaces + input = strings.Join(strings.Fields(input), " ") + + // Replace spaces within quoted strings with '<->' + input = quotedStringsRegex.ReplaceAllStringFunc(input, func(match string) string { + return strings.Replace(match, " ", "<->", -1) + }) + + // Replace spaces outside of quoted substrings with '&' or '|' + replacer := "&" + if excludedInput || params.OrTerms { + replacer = "|" + } + input = strings.Replace(input, " ", replacer, -1) + + return input + } + + tsQueryClause := replaceSpaces(terms, false) + excludedClause := replaceSpaces(excludedTerms, true) + if excludedClause != "" { + tsQueryClause += " &!(" + excludedClause + ")" } searchClause := fmt.Sprintf("to_tsvector('%[1]s', %[2]s) @@ to_tsquery('%[1]s', ?)", s.pgDefaultTextSearchConfig, searchType) - baseQuery = baseQuery.Where(searchClause, termsClause) + baseQuery = baseQuery.Where(searchClause, tsQueryClause) } else if s.DriverName() == model.DatabaseDriverMysql { if searchType == "Message" { terms, err = removeMysqlStopWordsFromTerms(terms) diff --git a/webapp/channels/src/actions/views/rhs.ts b/webapp/channels/src/actions/views/rhs.ts index e95e86400c..f337c62ece 100644 --- a/webapp/channels/src/actions/views/rhs.ts +++ b/webapp/channels/src/actions/views/rhs.ts @@ -23,7 +23,7 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; -import {getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUser, getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users'; import type {Action, ActionResult, DispatchFunc, GenericAction, GetStateFunc} from 'mattermost-redux/types/actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; @@ -193,21 +193,38 @@ function updateSearchResultsTerms(terms: string) { export function performSearch(terms: string, isMentionSearch?: boolean) { return (dispatch: DispatchFunc, getState: GetStateFunc) => { + let searchTerms = terms; const teamId = getCurrentTeamId(getState()); const config = getConfig(getState()); const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; const extensionsFilters = getFilesSearchExtFilter(getState() as GlobalState); const extensions = extensionsFilters?.map((ext) => `ext:${ext}`).join(' '); - let termsWithExtensionsFilters = terms; + let termsWithExtensionsFilters = searchTerms; if (extensions?.trim().length > 0) { termsWithExtensionsFilters += ` ${extensions}`; } + if (isMentionSearch) { + // Username should be quoted to allow specific search + // in case username is made with multiple words splitted by dashes or other symbols. + const user = getCurrentUser(getState()); + const termsArr = searchTerms.split(' ').filter((t) => Boolean(t && t.trim())); + const username = '@' + user.username; + const quotedUsername = `"${username}"`; + for (let i = 0; i < termsArr.length; i++) { + if (termsArr[i] === username) { + termsArr[i] = quotedUsername; + break; + } + } + searchTerms = termsArr.join(' '); + } + // timezone offset in seconds const userCurrentTimezone = getCurrentTimezone(getState()); const timezoneOffset = ((userCurrentTimezone && (userCurrentTimezone.length > 0)) ? getUtcOffsetForTimeZone(userCurrentTimezone) : getBrowserUtcOffset()) * 60; - const messagesPromise = dispatch(searchPostsWithParams(isMentionSearch ? '' : teamId, {terms, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); + const messagesPromise = dispatch(searchPostsWithParams(isMentionSearch ? '' : teamId, {terms: searchTerms, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); const filesPromise = dispatch(searchFilesWithParams(teamId, {terms: termsWithExtensionsFilters, is_or_search: Boolean(isMentionSearch), include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, page: 0, per_page: 20})); return Promise.all([filesPromise, messagesPromise]); };