internal/store/fts_test.go
181 lines · 5357 bytes
8 symbols in this file
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}