fix: normalize FTS queries with slashes
- commit
- 37c9b582dee195f8159c1551f5102e486e2b603d
- author
- brice · · 2026-01-26 04:28 UTC
- parent
- 654b259
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +29 -5
M internal/infrastructure/persistence/sqlite/history_repo.go +8 -5
| @@ -50,10 +50,13 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||
| 50 | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 51 | 51 | log := logging.FromContext(ctx) | |
| 52 | 52 | ||
| 53 | - | // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators | |
| 54 | - | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | |
| 55 | - | // This allows searching "gordon.bne" to match URLs containing both terms | |
| 56 | - | normalizedQuery := strings.ReplaceAll(query, ".", " ") | |
| 53 | + | // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators | |
| 54 | + | // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens | |
| 55 | + | // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms | |
| 56 | + | normalizedQuery := strings.NewReplacer( | |
| 57 | + | ".", " ", | |
| 58 | + | "/", " ", | |
| 59 | + | ).Replace(query) | |
| 57 | 60 | ||
| 58 | 61 | // Split query into words and add prefix matching to each | |
| 59 | 62 | // This enables multi-word searches like "github issues" -> "github* issues*" | |
| @@ -148,7 +151,7 @@ func sanitizeFTS5Word(word string) string { | |||
| 148 | 151 | var result strings.Builder | |
| 149 | 152 | for _, r := range word { | |
| 150 | 153 | switch r { | |
| 151 | - | case '"', '*', '(', ')', ':', '^', '-': | |
| 154 | + | case '"', '*', '(', ')', ':', '^', '-', '/': | |
| 152 | 155 | // Skip FTS5 special characters | |
| 153 | 156 | default: | |
| 154 | 157 | result.WriteRune(r) | |
| @@ -50,10 +50,13 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||||
| 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 51 | log := logging.FromContext(ctx) | 51 | log := logging.FromContext(ctx) | ||
| 52 | 52 | ||||
| 53 | - | // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators | 53 | + | // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators |
| 54 | - | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | 54 | + | // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens |
| 55 | - | // This allows searching "gordon.bne" to match URLs containing both terms | 55 | + | // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms |
| 56 | - | normalizedQuery := strings.ReplaceAll(query, ".", " ") | 56 | + | normalizedQuery := strings.NewReplacer( |
| 57 | + | ".", " ", | |||
| 58 | + | "/", " ", | |||
| 59 | + | ).Replace(query) | |||
| 57 | 60 | ||||
| 58 | // Split query into words and add prefix matching to each | 61 | // Split query into words and add prefix matching to each | ||
| 59 | // This enables multi-word searches like "github issues" -> "github* issues*" | 62 | // This enables multi-word searches like "github issues" -> "github* issues*" | ||
| @@ -148,7 +151,7 @@ func sanitizeFTS5Word(word string) string { | |||||
| 148 | var result strings.Builder | 151 | var result strings.Builder | ||
| 149 | for _, r := range word { | 152 | for _, r := range word { | ||
| 150 | switch r { | 153 | switch r { | ||
| 151 | - | case '"', '*', '(', ')', ':', '^', '-': | 154 | + | case '"', '*', '(', ')', ':', '^', '-', '/': |
| 152 | // Skip FTS5 special characters | 155 | // Skip FTS5 special characters | ||
| 153 | default: | 156 | default: | ||
| 154 | result.WriteRune(r) | 157 | result.WriteRune(r) | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +21 -0
| @@ -546,6 +546,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||
| 546 | 546 | `example"test`, // quotes | |
| 547 | 547 | "example^", // caret | |
| 548 | 548 | "example-test", // hyphen | |
| 549 | + | "example/", // slash | |
| 549 | 550 | } | |
| 550 | 551 | ||
| 551 | 552 | for _, q := range specialQueries { | |
| @@ -555,3 +556,23 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||
| 555 | 556 | _ = results | |
| 556 | 557 | } | |
| 557 | 558 | } | |
| 559 | + | ||
| 560 | + | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |
| 561 | + | ctx := historyTestCtx() | |
| 562 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 563 | + | ||
| 564 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 565 | + | require.NoError(t, err) | |
| 566 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 567 | + | ||
| 568 | + | repo := sqlite.NewHistoryRepository(db) | |
| 569 | + | ||
| 570 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 571 | + | URL: "https://github.com/bnema/dumber", | |
| 572 | + | Title: "Dumber", | |
| 573 | + | })) | |
| 574 | + | ||
| 575 | + | results, err := repo.Search(ctx, "github.com/bnema", 10) | |
| 576 | + | require.NoError(t, err) | |
| 577 | + | require.NotEmpty(t, results, "slash-separated query should match history") | |
| 578 | + | } | |
| @@ -546,6 +546,7 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||||
| 546 | `example"test`, // quotes | 546 | `example"test`, // quotes | ||
| 547 | "example^", // caret | 547 | "example^", // caret | ||
| 548 | "example-test", // hyphen | 548 | "example-test", // hyphen | ||
| 549 | + | "example/", // slash | |||
| 549 | } | 550 | } | ||
| 550 | 551 | ||||
| 551 | for _, q := range specialQueries { | 552 | for _, q := range specialQueries { | ||
| @@ -555,3 +556,23 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||||
| 555 | _ = results | 556 | _ = results | ||
| 556 | } | 557 | } | ||
| 557 | } | 558 | } | ||
| 559 | + | ||||
| 560 | + | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |||
| 561 | + | ctx := historyTestCtx() | |||
| 562 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 563 | + | ||||
| 564 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 565 | + | require.NoError(t, err) | |||
| 566 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 567 | + | ||||
| 568 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 569 | + | ||||
| 570 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 571 | + | URL: "https://github.com/bnema/dumber", | |||
| 572 | + | Title: "Dumber", | |||
| 573 | + | })) | |||
| 574 | + | ||||
| 575 | + | results, err := repo.Search(ctx, "github.com/bnema", 10) | |||
| 576 | + | require.NoError(t, err) | |||
| 577 | + | require.NotEmpty(t, results, "slash-separated query should match history") | |||
| 578 | + | } | |||