a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
546 lines
20 KiB
Go
546 lines
20 KiB
Go
package store_sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// withRawDB opens a bare *sql.DB on path, runs fn, and closes it — used to
|
|
// simulate an on-disk store written by an older/newer build (set user_version,
|
|
// insert rows) without going through Open's reconciliation.
|
|
func withRawDB(t *testing.T, path string, fn func(db *sql.DB)) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
t.Fatalf("open raw db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
fn(db)
|
|
}
|
|
|
|
func nodeCount(t *testing.T, db *sql.DB) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&n); err != nil {
|
|
t.Fatalf("count nodes: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestOpenStampsFreshDB: a brand-new on-disk store is stamped to the current
|
|
// schema version.
|
|
func TestOpenStampsFreshDB(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open fresh: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if v, err := readUserVersion(s.db); err != nil || v != currentSchemaVersion {
|
|
t.Fatalf("fresh user_version = %d (err %v), want %d", v, err, currentSchemaVersion)
|
|
}
|
|
}
|
|
|
|
// TestOpenBaselineStampsOldDBWithoutWipe: a pre-versioning store (user_version
|
|
// 0, reconcilable to current by schemaSQL + ensureNodeColumns) is stamped in
|
|
// place — its data must survive, not be wiped.
|
|
func TestOpenBaselineStampsOldDBWithoutWipe(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
|
|
// Create the store, then simulate a pre-versioning DB: a row + user_version 0.
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
if _, err := db.Exec(`PRAGMA user_version = 0`); err != nil {
|
|
t.Fatalf("reset user_version: %v", err)
|
|
}
|
|
})
|
|
|
|
s2, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen old DB: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
if v, _ := readUserVersion(s2.db); v != currentSchemaVersion {
|
|
t.Fatalf("user_version after baseline = %d, want %d", v, currentSchemaVersion)
|
|
}
|
|
if n := nodeCount(t, s2.db); n != 1 {
|
|
t.Fatalf("node count after baseline = %d, want 1 (data must NOT be wiped)", n)
|
|
}
|
|
}
|
|
|
|
// TestOpenRebuildsNewerDB: a store written by a NEWER build (user_version above
|
|
// current) cannot be trusted, so Open drops and rebuilds it — the data is gone
|
|
// and the version is re-stamped to current. Proves the wipe path (and that the
|
|
// -wal/-shm companions are cleared along with the main file).
|
|
func TestOpenRebuildsNewerDB(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
if _, err := db.Exec(`PRAGMA user_version = 999`); err != nil { // a future version this binary doesn't know
|
|
t.Fatalf("set future user_version: %v", err)
|
|
}
|
|
})
|
|
|
|
s2, err := Open(path, WithRebuild()) // simulate the daemon: holds the lock, may rebuild
|
|
if err != nil {
|
|
t.Fatalf("reopen newer DB: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
if v, _ := readUserVersion(s2.db); v != currentSchemaVersion {
|
|
t.Fatalf("user_version after rebuild = %d, want %d", v, currentSchemaVersion)
|
|
}
|
|
if n := nodeCount(t, s2.db); n != 0 {
|
|
t.Fatalf("node count after rebuild = %d, want 0 (newer DB must be wiped)", n)
|
|
}
|
|
}
|
|
|
|
// TestOpenRefusesWipeWithoutOptIn: the default Open must NOT destroy an
|
|
// incompatible on-disk database. Without WithRebuild it returns
|
|
// ErrSchemaRebuildRequired and leaves the file (and its rows) intact, so a
|
|
// caller that does not hold the store lock cannot silently corrupt a store
|
|
// another process may have open.
|
|
func TestOpenRefusesWipeWithoutOptIn(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`PRAGMA user_version = 999`); err != nil {
|
|
t.Fatalf("set future version: %v", err)
|
|
}
|
|
})
|
|
|
|
if _, err := Open(path); !errors.Is(err, ErrSchemaRebuildRequired) {
|
|
t.Fatalf("Open without WithRebuild = %v, want ErrSchemaRebuildRequired", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if n := nodeCount(t, db); n != 1 {
|
|
t.Fatalf("node count = %d after a refused wipe, want 1 (the file must be untouched)", n)
|
|
}
|
|
var v int
|
|
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
|
t.Fatalf("read user_version: %v", err)
|
|
}
|
|
if v != 999 {
|
|
t.Fatalf("user_version = %d after a refused wipe, want 999 (unchanged)", v)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPlanSchemaMigration covers the pure decision logic, including the
|
|
// in-place vs rebuild dispatch a future currentSchemaVersion=2 would exercise.
|
|
func TestPlanSchemaMigration(t *testing.T) {
|
|
inPlace := schemaMigration{version: 2, name: "add-index", inPlace: func(*sql.Tx) error { return nil }}
|
|
rebuild := schemaMigration{version: 2, name: "typed-column", rebuild: true}
|
|
|
|
cases := []struct {
|
|
name string
|
|
stored, current int
|
|
migs []schemaMigration
|
|
wantWipe bool
|
|
wantStamp bool
|
|
wantInPlace int
|
|
}{
|
|
{"up to date", 1, 1, nil, false, false, 0},
|
|
{"fresh at v1 baseline-stamps", 0, 1, nil, false, true, 0},
|
|
{"newer DB rebuilds", 2, 1, nil, true, true, 0},
|
|
{"v0 with only in-place pending upgrades in place, no wipe", 0, 2, []schemaMigration{inPlace}, false, true, 1},
|
|
{"v0 with a pending rebuild wipes", 0, 2, []schemaMigration{rebuild}, true, true, 0},
|
|
{"v1->v2 in-place", 1, 2, []schemaMigration{inPlace}, false, true, 1},
|
|
{"v1->v2 rebuild", 1, 2, []schemaMigration{rebuild}, true, true, 0},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := planSchemaMigrationWith(c.stored, c.current, c.migs)
|
|
if got.wipe != c.wantWipe || got.stamp != c.wantStamp || len(got.inPlace) != c.wantInPlace {
|
|
t.Fatalf("plan(%d->%d) = {wipe:%v stamp:%v inPlace:%d}, want {wipe:%v stamp:%v inPlace:%d}",
|
|
c.stored, c.current, got.wipe, got.stamp, len(got.inPlace), c.wantWipe, c.wantStamp, c.wantInPlace)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestApplyInPlaceMigrations: steps run in order and commit; a failing step
|
|
// rolls the whole transaction back.
|
|
func TestApplyInPlaceMigrations(t *testing.T) {
|
|
t.Run("commit", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "m.sqlite")
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
step := schemaMigration{version: 2, name: "mk", inPlace: func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(`CREATE TABLE marker (x TEXT)`)
|
|
return err
|
|
}}
|
|
if err := applyInPlaceMigrations(db, []schemaMigration{step}); err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
var name string
|
|
if err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='marker'`).Scan(&name); err != nil {
|
|
t.Fatalf("marker table not created: %v", err)
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("rollback on failure preserves cause and rolls back every step", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "m.sqlite")
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
// Two steps in one batch: the first creates table A, the second
|
|
// creates B then fails. Both must roll back, proving the steps
|
|
// share a single transaction.
|
|
stepA := schemaMigration{version: 2, name: "make-a", inPlace: func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(`CREATE TABLE a (x TEXT)`)
|
|
return err
|
|
}}
|
|
stepB := schemaMigration{version: 3, name: "boom", inPlace: func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(`CREATE TABLE b (x TEXT)`); err != nil {
|
|
return err
|
|
}
|
|
return sql.ErrConnDone // synthetic failure after a partial write
|
|
}}
|
|
err := applyInPlaceMigrations(db, []schemaMigration{stepA, stepB})
|
|
if err == nil {
|
|
t.Fatal("expected applyInPlaceMigrations to surface the step error")
|
|
}
|
|
if !errors.Is(err, sql.ErrConnDone) {
|
|
t.Fatalf("error should wrap the step's cause; got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "v3") || !strings.Contains(err.Error(), "boom") {
|
|
t.Fatalf("error should name the failing migration (v3/boom); got %q", err.Error())
|
|
}
|
|
for _, tbl := range []string{"a", "b"} {
|
|
var name string
|
|
e := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl).Scan(&name)
|
|
if e != sql.ErrNoRows {
|
|
t.Fatalf("table %q should have rolled back (shared transaction), got name=%q err=%v", tbl, name, e)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestOpenAtCurrentVersionIsNoOp covers the highest-frequency path — every
|
|
// daemon restart reopens an up-to-date store. It must be a no-op that
|
|
// preserves data; an off-by-one to wipe here would destroy the cache on every
|
|
// restart.
|
|
func TestOpenAtCurrentVersionIsNoOp(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
withRawSeed := func(db *sql.DB) {
|
|
if _, err := db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
}
|
|
withRawSeed(s.db)
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
s2, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen at current version: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
if v, _ := readUserVersion(s2.db); v != currentSchemaVersion {
|
|
t.Fatalf("user_version = %d, want %d", v, currentSchemaVersion)
|
|
}
|
|
if n := nodeCount(t, s2.db); n != 1 {
|
|
t.Fatalf("node count = %d, want 1 (a no-op reopen must NOT wipe)", n)
|
|
}
|
|
if s2.NeedsRebuild() {
|
|
t.Fatal("a no-op reopen must not signal NeedsRebuild")
|
|
}
|
|
}
|
|
|
|
// TestOpenWithInPlaceMigration drives the in-place arm end-to-end through the
|
|
// real Open composition (via the openWith seam): an older store at version 1
|
|
// is upgraded to version 2 by a registered in-place step that runs AFTER
|
|
// schemaSQL, the step's effect is visible, the existing data survives, and the
|
|
// version is stamped.
|
|
func TestOpenWithInPlaceMigration(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
|
|
// Create a store with a row, then knock it back to the v1 baseline so the
|
|
// openWith below drives the v1->v2 in-place arm (a fresh Open now stamps the
|
|
// current version, which is >= 2).
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`PRAGMA user_version = 1`); err != nil {
|
|
t.Fatalf("reset to v1 baseline: %v", err)
|
|
}
|
|
})
|
|
|
|
// An in-place v2 step that depends on the base schema (an index on a
|
|
// nodes column) — proving it runs after schemaSQL/ensureNodeColumns.
|
|
ran := false
|
|
v2 := schemaMigration{version: 2, name: "idx-language", inPlace: func(tx *sql.Tx) error {
|
|
ran = true
|
|
_, err := tx.Exec(`CREATE INDEX IF NOT EXISTS test_nodes_by_language ON nodes(language)`)
|
|
return err
|
|
}}
|
|
|
|
s2, err := openWith(path, 2, []schemaMigration{v2}, false) // in-place never wipes
|
|
if err != nil {
|
|
t.Fatalf("openWith v2 in-place: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
if !ran {
|
|
t.Fatal("the in-place migration step did not run")
|
|
}
|
|
if v, _ := readUserVersion(s2.db); v != 2 {
|
|
t.Fatalf("user_version = %d, want 2", v)
|
|
}
|
|
if n := nodeCount(t, s2.db); n != 1 {
|
|
t.Fatalf("node count = %d, want 1 (in-place upgrade must preserve data)", n)
|
|
}
|
|
var name string
|
|
if err := s2.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='index' AND name='test_nodes_by_language'`).Scan(&name); err != nil {
|
|
t.Fatalf("in-place index not created: %v", err)
|
|
}
|
|
if s2.NeedsRebuild() {
|
|
t.Fatal("an in-place upgrade must not signal NeedsRebuild")
|
|
}
|
|
}
|
|
|
|
// TestOpenWithInPlaceFailureDoesNotStamp: a failing in-place step makes Open
|
|
// return an error and leaves the stored version unchanged, so the next open
|
|
// retries the upgrade rather than treating it as done.
|
|
func TestOpenWithInPlaceFailureDoesNotStamp(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
// A fresh Open now stamps the current version (>= 2); knock it back to the
|
|
// v1 baseline so openWith drives the v1->v2 arm and the failing step runs.
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`PRAGMA user_version = 1`); err != nil {
|
|
t.Fatalf("reset to v1 baseline: %v", err)
|
|
}
|
|
})
|
|
|
|
boom := schemaMigration{version: 2, name: "boom", inPlace: func(*sql.Tx) error {
|
|
return sql.ErrConnDone
|
|
}}
|
|
if _, err := openWith(path, 2, []schemaMigration{boom}, false); err == nil {
|
|
t.Fatal("expected openWith to fail when an in-place step errors")
|
|
}
|
|
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
var v int
|
|
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
|
t.Fatalf("read user_version: %v", err)
|
|
}
|
|
if v != 1 {
|
|
t.Fatalf("user_version = %d after a failed migration, want 1 (unstamped, so the next open retries)", v)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOpenWithMemoryUnderWipePlanStampsWithoutError: an in-memory store under a
|
|
// plan that would wipe an on-disk DB must not attempt a file removal — it is
|
|
// always fresh and simply stamps the current version.
|
|
func TestOpenWithMemoryUnderWipePlanStampsWithoutError(t *testing.T) {
|
|
rebuildV2 := schemaMigration{version: 2, name: "typed-col", rebuild: true}
|
|
// stored==0, current==2, a pending rebuild => plan.wipe==true; the memory
|
|
// guard must skip the wipe and stamp anyway.
|
|
s, err := openWith(":memory:", 2, []schemaMigration{rebuildV2}, false) // memory never wipes
|
|
if err != nil {
|
|
t.Fatalf("openWith :memory: under wipe plan: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if v, _ := readUserVersion(s.db); v != 2 {
|
|
t.Fatalf("user_version = %d, want 2", v)
|
|
}
|
|
if s.NeedsRebuild() {
|
|
t.Fatal(":memory: must never report a wipe (nothing to remove)")
|
|
}
|
|
}
|
|
|
|
// TestNeedsRebuildSignalAfterWipe: a store written by a newer build is wiped on
|
|
// open and reports NeedsRebuild so the daemon forces a full re-index.
|
|
func TestNeedsRebuildSignalAfterWipe(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`PRAGMA user_version = 999`); err != nil {
|
|
t.Fatalf("set future version: %v", err)
|
|
}
|
|
})
|
|
s2, err := Open(path, WithRebuild()) // daemon-equivalent: lock held, rebuild permitted
|
|
if err != nil {
|
|
t.Fatalf("reopen newer DB: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
if !s2.NeedsRebuild() {
|
|
t.Fatal("a wiped store must report NeedsRebuild so the daemon re-indexes")
|
|
}
|
|
}
|
|
|
|
// TestSchemaMigrationsWellFormed asserts the shipped registry is valid and that
|
|
// the validator rejects the dangerous misconfigurations — above all, bumping
|
|
// currentSchemaVersion without appending a matching migration.
|
|
func TestSchemaMigrationsWellFormed(t *testing.T) {
|
|
if err := validateSchemaMigrations(currentSchemaVersion, schemaMigrations); err != nil {
|
|
t.Fatalf("shipped registry is invalid: %v", err)
|
|
}
|
|
|
|
inPlace := func(*sql.Tx) error { return nil }
|
|
bad := []struct {
|
|
name string
|
|
current int
|
|
migs []schemaMigration
|
|
}{
|
|
{"bumped version with no migration", 2, nil},
|
|
{"highest below current", 3, []schemaMigration{{version: 2, name: "x", rebuild: true}}},
|
|
{"both strategies set", 2, []schemaMigration{{version: 2, name: "x", rebuild: true, inPlace: inPlace}}},
|
|
{"neither strategy set", 2, []schemaMigration{{version: 2, name: "x"}}},
|
|
{"not strictly ascending", 3, []schemaMigration{{version: 2, name: "a", rebuild: true}, {version: 2, name: "b", rebuild: true}}},
|
|
{"v1 entry (baseline is implicit)", 1, []schemaMigration{{version: 1, name: "a", rebuild: true}}},
|
|
}
|
|
for _, c := range bad {
|
|
if err := validateSchemaMigrations(c.current, c.migs); err == nil {
|
|
t.Errorf("%s: expected a validation error, got nil", c.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOpenDedupesFnValuePlaceholders drives the shipped v2 migration through the
|
|
// real Open composition: a store knocked back to the v1 baseline with duplicate
|
|
// fn-value placeholder edges is deduped in place on reopen — one survivor per
|
|
// (from_id, to_id), the MIN(id) row kept — while a distinct placeholder, a
|
|
// resolved edge, and an ordinary unresolved stub are untouched, and the version
|
|
// stamps to current. Covers both the bare and the multi-repo COPY-rewrite form.
|
|
func TestOpenDedupesFnValuePlaceholders(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "store.sqlite")
|
|
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
// Seed edges by explicit id so the MIN(id) survivors are predictable. The
|
|
// is_unresolved column is generated, so it is omitted from the INSERT.
|
|
ins := `INSERT INTO edges (id, from_id, to_id, kind, file_path, line) VALUES (?,?,?,?,?,?)`
|
|
seed := []struct {
|
|
id int
|
|
from, to string
|
|
kind string
|
|
line int
|
|
}{
|
|
// Duplicate bare placeholders: same (from,to), distinct lines. Keep id 1.
|
|
{1, "a", "unresolved::fnvalue::handler", "references", 10},
|
|
{2, "a", "unresolved::fnvalue::handler", "references", 20},
|
|
{3, "a", "unresolved::fnvalue::handler", "references", 30},
|
|
// A distinct placeholder (different name) — must survive untouched.
|
|
{4, "a", "unresolved::fnvalue::other", "references", 10},
|
|
// Duplicate multi-repo COPY-rewrite placeholders — exercises the
|
|
// is_unresolved infix branch of the migration. Keep id 5.
|
|
{5, "b", "r::unresolved::fnvalue::handler", "references", 10},
|
|
{6, "b", "r::unresolved::fnvalue::handler", "references", 20},
|
|
// A resolved edge and an ordinary unresolved stub — never touched.
|
|
{7, "a", "b", "calls", 1},
|
|
{8, "a", "unresolved::Foo", "calls", 1},
|
|
}
|
|
for _, r := range seed {
|
|
if _, err := s.db.Exec(ins, r.id, r.from, r.to, r.kind, "f.go", r.line); err != nil {
|
|
t.Fatalf("seed edge %d: %v", r.id, err)
|
|
}
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
// Knock the store back to the v1 baseline so the reopen runs the v2 dedup.
|
|
withRawDB(t, path, func(db *sql.DB) {
|
|
if _, err := db.Exec(`PRAGMA user_version = 1`); err != nil {
|
|
t.Fatalf("reset to v1 baseline: %v", err)
|
|
}
|
|
})
|
|
|
|
s2, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen for dedup: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
if v, _ := readUserVersion(s2.db); v != currentSchemaVersion {
|
|
t.Fatalf("user_version after dedup = %d, want %d", v, currentSchemaVersion)
|
|
}
|
|
|
|
present := func(id int) bool {
|
|
var n int
|
|
if err := s2.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE id = ?`, id).Scan(&n); err != nil {
|
|
t.Fatalf("count id %d: %v", id, err)
|
|
}
|
|
return n == 1
|
|
}
|
|
// Bare-form dedup keeps the MIN(id) survivor and drops the rest.
|
|
if !present(1) || present(2) || present(3) {
|
|
t.Fatalf("bare dedup wrong: want keep 1 / drop 2,3; got 1=%v 2=%v 3=%v", present(1), present(2), present(3))
|
|
}
|
|
// A distinct placeholder pair survives.
|
|
if !present(4) {
|
|
t.Fatal("distinct fn-value placeholder (id 4) was wrongly deleted")
|
|
}
|
|
// Multi-repo infix dedup keeps the MIN(id) survivor and drops the rest.
|
|
if !present(5) || present(6) {
|
|
t.Fatalf("multi-repo dedup wrong: want keep 5 / drop 6; got 5=%v 6=%v", present(5), present(6))
|
|
}
|
|
// A resolved edge and an ordinary unresolved stub must be untouched.
|
|
if !present(7) {
|
|
t.Fatal("resolved edge (id 7) must survive the placeholder dedup")
|
|
}
|
|
if !present(8) {
|
|
t.Fatal("ordinary unresolved stub (id 8) must survive the placeholder dedup")
|
|
}
|
|
}
|