check / check (push) Waiting to run
A database at user_version 0 that already has a files table was made by something else: scan used to run its CREATE TABLE on it and fail with a raw SQLite error, and report and trees gave only a bare version mismatch. All three now refuse such a database with the schema-version error telling the operator to remove the file and rescan. scan creates the table and index and sets the version in one transaction, so a first scan stopped partway leaves an empty database the next scan sets up, never a files table at version 0. A genuinely empty database is unchanged. Model: opus-4-8 (implementation); opus-5-5 (rebase)
334 lines
7.6 KiB
Go
334 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testDBPath returns a database path inside a fresh temp dir.
|
|
func testDBPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return filepath.Join(t.TempDir(), "db.sqlite")
|
|
}
|
|
|
|
// openTestDB creates a fresh scan database in a temp dir.
|
|
func openTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
db, err := openScanDatabase(t.Context(), testDBPath(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
func TestDatabasePath(t *testing.T) {
|
|
t.Setenv(databaseEnv, "")
|
|
|
|
if got := databasePath(); got != defaultDatabasePath {
|
|
t.Errorf("databasePath() = %q, want %q", got, defaultDatabasePath)
|
|
}
|
|
|
|
t.Setenv(databaseEnv, "/custom/place.sqlite")
|
|
|
|
if got := databasePath(); got != "/custom/place.sqlite" {
|
|
t.Errorf("databasePath() = %q, want the env override", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenScanDatabaseCreates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The parent directory does not exist yet; scan must create it.
|
|
path := filepath.Join(t.TempDir(), "nested", "dir", "db.sqlite")
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openScanDatabase: %v", err)
|
|
}
|
|
|
|
v, err := userVersion(t.Context(), db)
|
|
if err != nil || v != schemaVersion {
|
|
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
// Reopening an existing database must succeed and find the schema.
|
|
db, err = openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
if recs := dbRecords(t, db); len(recs) != 0 {
|
|
t.Fatalf("records = %v, want none", recs)
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseUnversionedForeign(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A database that has a files table but user_version 0, written by
|
|
// some other tool. report, trees and scan must refuse it with the
|
|
// schema-version error, not adopt it and not emit a raw SQLite
|
|
// "table files already exists".
|
|
path := testDBPath(t)
|
|
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(), "CREATE TABLE files (x INTEGER)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openReportDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) ||
|
|
!strings.Contains(err.Error(), "remove the file and rescan") {
|
|
t.Fatalf("report: err = %v, want errSchemaVersion telling the "+
|
|
"operator to remove the file and rescan", err)
|
|
}
|
|
|
|
_, err = openScanDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) ||
|
|
!strings.Contains(err.Error(), "remove the file and rescan") {
|
|
t.Fatalf("scan: err = %v, want errSchemaVersion telling the "+
|
|
"operator to remove the file and rescan", err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaCreationStoppedPartway(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A first scan stopped while creating the schema must leave a
|
|
// database the next scan accepts. max_page_count(2) leaves room for
|
|
// the files table but not its index, so schema creation fails right
|
|
// after CREATE TABLE, a point an interrupt could also stop it at.
|
|
path := testDBPath(t)
|
|
|
|
db, err := openDB(path, scanParams+"&_pragma=max_page_count(2)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = initSchema(t.Context(), db)
|
|
_ = db.Close()
|
|
|
|
if err == nil {
|
|
t.Fatal("initSchema with no room for the index succeeded")
|
|
}
|
|
|
|
db, err = openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("next scan: %v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
v, err := userVersion(t.Context(), db)
|
|
if err != nil || v != schemaVersion {
|
|
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := openReportDatabase(t.Context(), testDBPath(t))
|
|
if !errors.Is(err, errNoDatabase) {
|
|
t.Fatalf("err = %v, want errNoDatabase", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseVersionMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(context.Background(), "PRAGMA user_version = 99")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openReportDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) {
|
|
t.Fatalf("err = %v, want errSchemaVersion", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseOK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
db, err = openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openReportDatabase: %v", err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
}
|
|
|
|
func TestCloseScanDatabaseWhileReportOpen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A report holding the database open stops scan from taking it out
|
|
// of WAL mode. The -wal and -shm files must then stay beside it, so
|
|
// that a later report still needs only read access.
|
|
path := testDBPath(t)
|
|
|
|
scanDB, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reportDB, err := openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
closeScanDatabase(t.Context(), scanDB, path)
|
|
|
|
_ = reportDB.Close()
|
|
|
|
_, err = os.Stat(path + "-wal")
|
|
if err != nil {
|
|
t.Fatalf("no -wal left: the switch out of WAL mode was not "+
|
|
"stopped: %v", err)
|
|
}
|
|
|
|
makeReadOnly(t, path)
|
|
|
|
reportDB, err = openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openReportDatabase: %v", err)
|
|
}
|
|
|
|
defer func() { _ = reportDB.Close() }()
|
|
|
|
err = loadFileRows(t.Context(), reportDB, func(scanRec) {})
|
|
if err != nil {
|
|
t.Fatalf("loadFileRows: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyChangesRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// Paths may contain tabs and newlines; the database must store
|
|
// them byte-exactly. Every hash, content included, comes back as
|
|
// written.
|
|
recs := []scanRec{
|
|
{
|
|
size: 2, mtime: 20, head: "h2", tail: "t2", content: "c2",
|
|
path: "/a/tab\tnew\nline",
|
|
},
|
|
{size: 1, mtime: 10, head: "h1", tail: "t1", content: "c1", path: "/a/x"},
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
// The records come back in path order, which is the order of recs.
|
|
got := dbRecords(t, db)
|
|
if !slices.Equal(got, recs) {
|
|
t.Fatalf("rows = %+v, want %+v", got, recs)
|
|
}
|
|
|
|
// An upsert for an existing path updates in place; a delete
|
|
// removes exactly its path.
|
|
upd := scanRec{
|
|
size: 3, mtime: 30, head: "h3", tail: "t3", content: "c3", path: "/a/x",
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, []scanRec{upd},
|
|
[]string{"/a/tab\tnew\nline"}, newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
got = dbRecords(t, db)
|
|
if len(got) != 1 || got[0] != upd {
|
|
t.Fatalf("rows = %+v, want just %+v", got, upd)
|
|
}
|
|
}
|
|
|
|
func TestApplyChangesBatching(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// One more change than the batch size, so the update spans two
|
|
// transactions.
|
|
n := updateBatchSize + 1
|
|
|
|
recs := make([]scanRec, 0, n)
|
|
for i := range n {
|
|
recs = append(recs, scanRec{
|
|
size: int64(i), mtime: 1, head: "h", tail: "t",
|
|
path: fmt.Sprintf("/batch/%07d", i),
|
|
})
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
if got := dbRecords(t, db); len(got) != n {
|
|
t.Fatalf("records = %d, want %d", len(got), n)
|
|
}
|
|
|
|
deletes := make([]string, 0, n)
|
|
for _, r := range recs {
|
|
deletes = append(deletes, r.path)
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, nil, deletes,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges deletes: %v", err)
|
|
}
|
|
|
|
if got := dbRecords(t, db); len(got) != 0 {
|
|
t.Fatalf("records = %d, want 0", len(got))
|
|
}
|
|
}
|