internal/store/fts_test.go

v1.31.0
gitbay/internal/store/fts_test.go history · blame · raw

181 lines · 5357 bytes

  1package store
  2
  3import (
  4	"strings"
  5	"testing"
  6)
  7
  8func TestFTSQuerySanitises(t *testing.T) {
  9	cases := map[string]string{
 10		"memory leak": `"memory" "leak"`,
 11		"c++":         `"c++"`,
 12		// Bare FTS5 operators are terms here, not syntax.
 13		"foo AND": `"foo" "AND"`,
 14		"a NOT b": `"a" "NOT" "b"`,
 15		// A double quote inside a phrase is escaped by doubling it.
 16		`say "hi"`:   `"say" """hi"""`,
 17		"  spaced  ": `"spaced"`,
 18		"":           `""`,
 19		"trailing -": `"trailing" "-"`,
 20		"col:on":     `"col:on"`,
 21	}
 22	for in, want := range cases {
 23		if got := FTSQuery(in); got != want {
 24			t.Errorf("FTSQuery(%q) = %q, want %q", in, got, want)
 25		}
 26	}
 27}
 28
 29// Every one of these is a syntax error as a bare FTS5 expression. The
 30// search must return no rows, not fail.
 31func TestFTSQueryNeverErrors(t *testing.T) {
 32	s, repoID, _ := ftsFixture(t)
 33	for _, q := range []string{
 34		"c++", `"`, `""`, "AND", "NOT", "*", "-", "(", "a AND", "foo:", "^", "a OR",
 35	} {
 36		if _, err := s.QueryIssues(repoID, IssueFilter{State: "all", Search: q}); err != nil {
 37			t.Errorf("search %q failed: %v", q, err)
 38		}
 39	}
 40}
 41
 42func ftsFixture(t *testing.T) (*Store, int64, int64) {
 43	t.Helper()
 44	s := open(t)
 45	if err := s.MigrateUp(); err != nil {
 46		t.Fatal(err)
 47	}
 48	uid, err := s.CreateUser("cmc", true)
 49	if err != nil {
 50		t.Fatal(err)
 51	}
 52	repoID, err := s.CreateRepo("user", uid, "lib", "public")
 53	if err != nil {
 54		t.Fatal(err)
 55	}
 56	return s, repoID, uid
 57}
 58
 59func searchNumbers(t *testing.T, s *Store, repoID int64, q string) []int64 {
 60	t.Helper()
 61	got, err := s.QueryIssues(repoID, IssueFilter{State: "all", Search: q})
 62	if err != nil {
 63		t.Fatalf("search %q: %v", q, err)
 64	}
 65	var ns []int64
 66	for _, i := range got {
 67		ns = append(ns, i.Number)
 68	}
 69	return ns
 70}
 71
 72func TestIssueSearchMatchesTitleAndBody(t *testing.T) {
 73	s, repoID, uid := ftsFixture(t)
 74	if _, err := s.CreateIssue(repoID, uid, "memory leak in the parser", "it climbs forever", "md"); err != nil {
 75		t.Fatal(err)
 76	}
 77	if _, err := s.CreateIssue(repoID, uid, "unrelated", "nothing to see", "md"); err != nil {
 78		t.Fatal(err)
 79	}
 80
 81	if got := searchNumbers(t, s, repoID, "parser"); len(got) != 1 || got[0] != 1 {
 82		t.Fatalf("title match = %v", got)
 83	}
 84	// The body is the half a LIKE over titles could never reach, which is
 85	// the whole point of #114.
 86	if got := searchNumbers(t, s, repoID, "climbs"); len(got) != 1 || got[0] != 1 {
 87		t.Fatalf("body match = %v", got)
 88	}
 89	// Terms are ANDed.
 90	if got := searchNumbers(t, s, repoID, "memory nothing"); len(got) != 0 {
 91		t.Fatalf("terms are not ANDed: %v", got)
 92	}
 93	if got := searchNumbers(t, s, repoID, "MEMORY"); len(got) != 1 {
 94		t.Fatalf("search is case sensitive: %v", got)
 95	}
 96}
 97
 98// An external-content FTS table is not maintained automatically: an edit
 99// or a delete leaves the old terms indexed unless the trigger removes
100// them first. Stale terms are invisible until someone searches for a word
101// that was deleted and gets a row that no longer says it.
102func TestIssueSearchFollowsEdits(t *testing.T) {
103	s, repoID, uid := ftsFixture(t)
104	n, err := s.CreateIssue(repoID, uid, "original title", "original body", "md")
105	if err != nil {
106		t.Fatal(err)
107	}
108	issue, err := s.IssueByNumber(repoID, n)
109	if err != nil {
110		t.Fatal(err)
111	}
112
113	if got := searchNumbers(t, s, repoID, "original"); len(got) != 1 {
114		t.Fatalf("fresh issue not indexed: %v", got)
115	}
116	title, body := "replaced title", "replaced body"
117	if err := s.UpdateIssueText(issue.ID, &title, &body, nil); err != nil {
118		t.Fatal(err)
119	}
120	if got := searchNumbers(t, s, repoID, "original"); len(got) != 0 {
121		t.Fatalf("edited-away terms still match: %v", got)
122	}
123	if got := searchNumbers(t, s, repoID, "replaced"); len(got) != 1 {
124		t.Fatalf("new terms not indexed: %v", got)
125	}
126
127	// Deleting the repository cascades to its issues; their terms must go
128	// with them rather than pointing at rows that no longer exist.
129	if err := s.DeleteRepo(repoID); err != nil {
130		t.Fatal(err)
131	}
132	var n2 int
133	if err := s.DB.QueryRow("SELECT count(*) FROM issue_fts WHERE issue_fts MATCH 'replaced'").Scan(&n2); err != nil {
134		t.Fatal(err)
135	}
136	if n2 != 0 {
137		t.Fatalf("%d index rows survived the delete", n2)
138	}
139}
140
141// The migration backfills what was already in the database, since the
142// triggers only see writes from their own creation onward.
143func TestFTSBackfillsExistingRows(t *testing.T) {
144	s := open(t)
145	// Stop one short of the FTS migration, write rows the triggers cannot
146	// have seen, then apply it.
147	if err := s.MigrateTo(35); err != nil {
148		t.Fatal(err)
149	}
150	uid, err := s.CreateUser("cmc", true)
151	if err != nil {
152		t.Fatal(err)
153	}
154	repoID, err := s.CreateRepo("user", uid, "lib", "public")
155	if err != nil {
156		t.Fatal(err)
157	}
158	if _, err := s.CreateIssue(repoID, uid, "older than the index", "prose from before", "md"); err != nil {
159		t.Fatal(err)
160	}
161	if err := s.MigrateUp(); err != nil {
162		t.Fatal(err)
163	}
164	if got := searchNumbers(t, s, repoID, "prose"); len(got) != 1 {
165		t.Fatalf("pre-existing issue not backfilled: %v", got)
166	}
167}
168
169func TestGlobalSearchReachesBodies(t *testing.T) {
170	s, repoID, uid := ftsFixture(t)
171	if _, err := s.CreateIssue(repoID, uid, "a title", "haystack needle haystack", "md"); err != nil {
172		t.Fatal(err)
173	}
174	got, err := s.SearchIssues(uid, "needle", 20)
175	if err != nil {
176		t.Fatal(err)
177	}
178	if len(got) != 1 || !strings.Contains(got[0].RepoPath, "lib") {
179		t.Fatalf("global body search = %+v", got)
180	}
181}