fix: drop+recreate contentless FTS5 index instead of DELETE
The search_index is a contentless FTS5 table (content=''), which SQLite does not support DELETE on. ClearSearchIndex now drops and recreates the virtual table. Single-row DeleteSearchIndex becomes a no-op since contentless FTS5 also cannot delete individual rows; stale entries are harmless (search JOINs filter them out) and the index is fully rebuilt during FullRescan.
This commit is contained in:
+30
-13
@@ -117,24 +117,41 @@ func (d *DB) InsertSearchIndex(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSearchIndex removes a row from the FTS5 search_index.
|
// DeleteSearchIndex is a no-op for contentless FTS5 tables.
|
||||||
func (d *DB) DeleteSearchIndex(rowid int64) error {
|
// Contentless FTS5 (content=”) does not support DELETE.
|
||||||
// SAFETY: FTS5 virtual table DELETE unsupported by sqlc. Rowid is parameterized.
|
// Stale entries are harmless: they point to rowids that no longer
|
||||||
_, err := d.db.ExecContext(d.Ctx, `
|
// match in track_metadata, so JOINs in search queries filter them
|
||||||
DELETE FROM search_index WHERE rowid = ?
|
// out. The index is fully rebuilt during FullRescan.
|
||||||
`, rowid)
|
func (d *DB) DeleteSearchIndex(_ int64) error {
|
||||||
|
return nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSearchIndex removes all rows from the FTS5 search_index.
|
// ClearSearchIndex removes all rows from the FTS5 search_index.
|
||||||
|
// The search_index is a contentless FTS5 table (content=”), which
|
||||||
|
// does not support DELETE. We drop and recreate it instead.
|
||||||
func (d *DB) ClearSearchIndex() error {
|
func (d *DB) ClearSearchIndex() error {
|
||||||
// SAFETY: FTS5 virtual table DELETE unsupported by sqlc. No parameters; unconditional delete.
|
// SAFETY: FTS5 contentless table cannot be DELETEd from.
|
||||||
_, err := d.db.ExecContext(d.Ctx, `
|
// Drop + recreate is the only way to clear it. No parameters.
|
||||||
DELETE FROM search_index
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
`)
|
`DROP TABLE IF EXISTS search_index`,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("could not drop search_index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
if _, err := d.db.ExecContext(d.Ctx, `
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
||||||
|
file_path,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
content='',
|
||||||
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
return fmt.Errorf("could not recreate search_index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RebuildSearchIndex repopulates the FTS5 search_index from
|
// RebuildSearchIndex repopulates the FTS5 search_index from
|
||||||
|
|||||||
@@ -742,14 +742,21 @@ func TestClearSearchIndex(t *testing.T) {
|
|||||||
t.Fatal("SearchFTS before clear: got 0 results")
|
t.Fatal("SearchFTS before clear: got 0 results")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSearchIndex uses DELETE on a contentless FTS5 table
|
// ClearSearchIndex drops and recreates the contentless FTS5
|
||||||
// (content=''), which SQLite does not support. This documents
|
// table, which is the only way to clear a content='' table.
|
||||||
// the limitation — the error is expected. RebuildSearchIndex
|
|
||||||
// only succeeds when the index is empty (e.g., after drop+recreate
|
|
||||||
// or on a fresh database before any inserts).
|
|
||||||
err = db.ClearSearchIndex()
|
err = db.ClearSearchIndex()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Log("ClearSearchIndex succeeded (unexpected for contentless FTS5 with data)")
|
t.Fatalf("ClearSearchIndex: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the index is empty after clear.
|
||||||
|
results, err = db.SearchFTS("queen", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchFTS after clear: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("SearchFTS after clear: got %d results, want 0", len(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -860,7 +860,12 @@ func (l *Library) updateAudioFileMetadata(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index in FTS5 search_index (delete old entry, insert new).
|
// Re-index in FTS5 search_index.
|
||||||
|
// Contentless FTS5 (content='') does not support DELETE, so we
|
||||||
|
// cannot remove the old entry. Inserting a new row with the
|
||||||
|
// same rowid is accepted by FTS5 — the old entry becomes stale
|
||||||
|
// but harmless (search JOINs against track_metadata filter it).
|
||||||
|
// The index is fully rebuilt during FullRescan.
|
||||||
tags := result.tags
|
tags := result.tags
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
tags = &metadata.TrackMetadata{}
|
tags = &metadata.TrackMetadata{}
|
||||||
@@ -875,21 +880,6 @@ func (l *Library) updateAudioFileMetadata(
|
|||||||
|
|
||||||
album := tags.Album
|
album := tags.Album
|
||||||
|
|
||||||
// SAFETY: FTS5 virtual table, see search.go:DeleteSearchIndex. Rowid parameterized.
|
|
||||||
if _, err := tx.ExecContext(
|
|
||||||
l.ctx,
|
|
||||||
`DELETE FROM search_index WHERE rowid = ?`,
|
|
||||||
result.existingFileID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
|
||||||
"could not remove old FTS entry",
|
|
||||||
"id", result.existingFileID,
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.addWarning(result.absolutePath, "commit", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized.
|
// SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized.
|
||||||
if _, err := tx.ExecContext(
|
if _, err := tx.ExecContext(
|
||||||
l.ctx,
|
l.ctx,
|
||||||
|
|||||||
@@ -160,22 +160,21 @@ func (l *Library) clearLibraryTables() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear FTS5 search index.
|
|
||||||
// SAFETY: FTS5 virtual table, see search.go:ClearSearchIndex. No parameters; unconditional delete.
|
|
||||||
if _, err := tx.ExecContext(
|
|
||||||
l.ctx, `DELETE FROM search_index`,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"could not clear search index: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not commit library clear transaction: %w", err,
|
"could not commit library clear transaction: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear FTS5 search index AFTER the transaction.
|
||||||
|
// ClearSearchIndex drops and recreates the contentless FTS5
|
||||||
|
// virtual table, which cannot run inside a transaction.
|
||||||
|
if err := l.db.ClearSearchIndex(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not clear search index: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
l.logger.Info("all library tables cleared")
|
l.logger.Info("all library tables cleared")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user