diff --git a/server/src/model/event.go b/server/src/model/event.go index 85bb172b..dc7e1646 100644 --- a/server/src/model/event.go +++ b/server/src/model/event.go @@ -138,6 +138,29 @@ func GetEvent(db *sqlx.DB, options GetEventOptions) (Event, error) { return events[0], nil } +// eventNameBooleanQuery converts a free-text event-name search into a MySQL +// fulltext boolean-mode query where each term is given a trailing '*' so that +// prefixes match. Boolean-mode operator characters are replaced with spaces so +// that they are neither interpreted as operators nor merge adjacent words into +// a token that cannot match: MySQL's fulltext parser splits on those characters +// at index time (e.g. "anti-fur" is stored as "anti" and "fur"), so a +// concatenated "antifur" would never match. +func eventNameBooleanQuery(query string) string { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '+', '-', '<', '>', '(', ')', '~', '*', '"', '@': + return ' ' + } + return r + }, query) + + var terms []string + for _, field := range strings.Fields(cleaned) { + terms = append(terms, field+"*") + } + return strings.Join(terms, " ") +} + func getEvents(db *sqlx.DB, options GetEventOptions) ([]Event, error) { query := `SELECT e.id, e.name, e.date, e.event_type, e.survey_sent, e.suppress_survey, e.circle_id, e.chapter_id FROM events e ` @@ -189,7 +212,10 @@ ON (e.id = ea.event_id AND ea.activist_id = a.id) where("e.event_type like ?", options.EventType) } if options.EventNameQuery != "" { - where("MATCH (e.name) AGAINST (?)", options.EventNameQuery) + // Use boolean mode with a trailing '*' on each term so that + // prefixes match (e.g. "chapt" matches "chapter meeting"), not + // just whole words as in natural-language mode. + where("MATCH (e.name) AGAINST (? IN BOOLEAN MODE)", eventNameBooleanQuery(options.EventNameQuery)) } // Add the where clauses to the query. diff --git a/server/src/model/event_test.go b/server/src/model/event_test.go index 9b393c73..c5df134a 100644 --- a/server/src/model/event_test.go +++ b/server/src/model/event_test.go @@ -171,6 +171,68 @@ func TestGetEvents_orderBy(t *testing.T) { require.Equal(t, gotEvents[1].EventName, "earlier event") } +func TestEventNameBooleanQuery(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"single word", "chapter", "chapter*"}, + {"multiple words", "chapter meeting", "chapter* meeting*"}, + {"collapses extra whitespace", " chapter meeting ", "chapter* meeting*"}, + {"strips boolean operators", "+chapter -meeting", "chapter* meeting*"}, + {"strips wildcards and quotes", "*chap* \"meet\"", "chap* meet*"}, + {"splits on operators within a term", "anti-fur", "anti* fur*"}, + {"splits on embedded operators across terms", "anti-fur pro+test", "anti* fur* pro* test*"}, + {"drops operator-only terms", "chapter + -", "chapter*"}, + {"empty input", "", ""}, + {"whitespace only", " ", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, eventNameBooleanQuery(tt.input)) + }) + } +} + +func TestGetEvents_nameQueryPrefix(t *testing.T) { + db := testdb.NewDB() + defer func() { _ = db.Close() }() + + d1, err := time.Parse("2006-01-02", "2017-01-15") + require.NoError(t, err) + + for _, name := range []string{"chapter meeting", "potluck dinner", "anti-fur protest"} { + _, err := InsertUpdateEvent(db, Event{ + EventName: name, + EventDate: d1, + EventType: "Working Group", + ChapterID: 1, + }) + require.NoError(t, err) + } + + // A whole-word query still matches. + gotEvents, err := GetEvents(db, GetEventOptions{EventNameQuery: "chapter"}) + require.NoError(t, err) + require.Len(t, gotEvents, 1) + require.Equal(t, "chapter meeting", gotEvents[0].EventName) + + // A prefix of a word now matches too. + gotEvents, err = GetEvents(db, GetEventOptions{EventNameQuery: "chapt"}) + require.NoError(t, err) + require.Len(t, gotEvents, 1) + require.Equal(t, "chapter meeting", gotEvents[0].EventName) + + // A hyphenated query matches a hyphenated name: MySQL tokenizes both sides + // of the hyphen, so the operator must split the term rather than collapse it. + gotEvents, err = GetEvents(db, GetEventOptions{EventNameQuery: "anti-fur"}) + require.NoError(t, err) + require.Len(t, gotEvents, 1) + require.Equal(t, "anti-fur protest", gotEvents[0].EventName) +} + func TestInsertUpdateEvent(t *testing.T) { db := testdb.NewDB() defer func() { _ = db.Close() }()