diff --git a/cmd/ccg/main_postgres_test.go b/cmd/ccg/main_postgres_test.go index 4d088b5..b7139cd 100644 --- a/cmd/ccg/main_postgres_test.go +++ b/cmd/ccg/main_postgres_test.go @@ -153,7 +153,7 @@ func TestRunMigrations_PostgresDownRestoresNullableColumns(t *testing.T) { if err != nil { t.Fatalf("create migrator: %v", err) } - if err := migrator.Steps(-4); err != nil { + if err := migrator.Steps(-5); err != nil { t.Fatalf("run down migration: %v", err) } @@ -186,7 +186,7 @@ func TestRunMigrations_PostgresDownFromVersionThreeDropsPolicyTables(t *testing. if err != nil { t.Fatalf("create migrator: %v", err) } - if err := migrator.Steps(-3); err != nil { + if err := migrator.Steps(-4); err != nil { t.Fatalf("run down migration: %v", err) } diff --git a/cmd/ccg/main_test.go b/cmd/ccg/main_test.go index 968d495..423c839 100644 --- a/cmd/ccg/main_test.go +++ b/cmd/ccg/main_test.go @@ -268,7 +268,7 @@ func TestRunMigrations_SQLiteDownRestoresNullableColumns(t *testing.T) { if err != nil { t.Fatalf("create migrator: %v", err) } - if err := migrator.Steps(-4); err != nil { + if err := migrator.Steps(-5); err != nil { t.Fatalf("run down migration: %v", err) } @@ -313,7 +313,7 @@ func TestRunMigrations_SQLiteDownFromVersionThreeDropsPolicyTables(t *testing.T) if err != nil { t.Fatalf("create migrator: %v", err) } - if err := migrator.Steps(-3); err != nil { + if err := migrator.Steps(-4); err != nil { t.Fatalf("run down migration: %v", err) } @@ -427,7 +427,7 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) { t.Fatalf("ensure schema version: %v", err) } passLog := logs.String() - for _, want := range []string{"database runtime schema check passed", "driver=sqlite", "required_version=5", "auto_migrated=true"} { + for _, want := range []string{"database runtime schema check passed", "driver=sqlite", "required_version=6", "auto_migrated=true"} { if !strings.Contains(passLog, want) { t.Fatalf("expected runtime schema pass log to contain %q, got %q", want, passLog) } @@ -441,7 +441,7 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) { t.Fatal("expected parity failure") } failLog := logs.String() - for _, want := range []string{"database runtime schema check failed", "driver=sqlite", "required_version=5", "auto_migrated=false", "error.message="} { + for _, want := range []string{"database runtime schema check failed", "driver=sqlite", "required_version=6", "auto_migrated=false", "error.message="} { if !strings.Contains(failLog, want) { t.Fatalf("expected runtime schema failure log to contain %q, got %q", want, failLog) } diff --git a/internal/db/migration/migration.go b/internal/db/migration/migration.go index c3aeb0b..1e6d096 100644 --- a/internal/db/migration/migration.go +++ b/internal/db/migration/migration.go @@ -24,7 +24,7 @@ import ( ) const ( - RequiredSchemaVersion = 5 + RequiredSchemaVersion = 6 SchemaVersionKey = "schema" LegacySchemaVersionTable = "ccg_schema_versions" ) diff --git a/internal/migrationfs/postgres/000006_search_trgm.down.sql b/internal/migrationfs/postgres/000006_search_trgm.down.sql new file mode 100644 index 0000000..b053784 --- /dev/null +++ b/internal/migrationfs/postgres/000006_search_trgm.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_nodes_qualified_name_trgm; +DROP INDEX IF EXISTS idx_nodes_name_trgm; diff --git a/internal/migrationfs/postgres/000006_search_trgm.up.sql b/internal/migrationfs/postgres/000006_search_trgm.up.sql new file mode 100644 index 0000000..5501caf --- /dev/null +++ b/internal/migrationfs/postgres/000006_search_trgm.up.sql @@ -0,0 +1,19 @@ +-- pg_trgm powers typo-tolerant fuzzy symbol search. The extension may require elevated +-- privileges, so attempt it inside a DO block and degrade gracefully when it is unavailable: +-- the trigram indexes are only created when the extension is present. +DO $$ +BEGIN + CREATE EXTENSION IF NOT EXISTS pg_trgm; +EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'pg_trgm unavailable; fuzzy symbol search disabled'; +END +$$; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN + CREATE INDEX IF NOT EXISTS idx_nodes_name_trgm ON nodes USING gin (name gin_trgm_ops); + CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name_trgm ON nodes USING gin (qualified_name gin_trgm_ops); + END IF; +END +$$; diff --git a/internal/migrationfs/sqlite/000006_search_trgm.down.sql b/internal/migrationfs/sqlite/000006_search_trgm.down.sql new file mode 100644 index 0000000..9f7060a --- /dev/null +++ b/internal/migrationfs/sqlite/000006_search_trgm.down.sql @@ -0,0 +1 @@ +-- no-op diff --git a/internal/migrationfs/sqlite/000006_search_trgm.up.sql b/internal/migrationfs/sqlite/000006_search_trgm.up.sql new file mode 100644 index 0000000..80fc86b --- /dev/null +++ b/internal/migrationfs/sqlite/000006_search_trgm.up.sql @@ -0,0 +1 @@ +-- pg_trgm fuzzy symbol search is PostgreSQL-only; no-op on SQLite. diff --git a/internal/store/search/postgres.go b/internal/store/search/postgres.go index b0c9a24..3e86202 100644 --- a/internal/store/search/postgres.go +++ b/internal/store/search/postgres.go @@ -12,6 +12,7 @@ import ( "github.com/tae2089/trace" "github.com/tae2089/code-context-graph/internal/ctxns" + "github.com/tae2089/code-context-graph/internal/db/migration" "github.com/tae2089/code-context-graph/internal/model" ) @@ -30,72 +31,14 @@ func NewPostgresBackend() *PostgresBackend { return &PostgresBackend{} } -// Migrate prepares the PostgreSQL full-text search schema. -// @intent Sets up the tsvector-based search infrastructure on the search_documents table. -// @sideEffect Creates or replaces columns, indexes, trigger functions, and triggers. -// @ensures The tsv column is automatically updated when search_documents is modified. +// Migrate ensures the PostgreSQL search schema exists by running the versioned migrations, +// which are the single source of truth for the tsvector column, trigger, GIN index, and the +// pg_trgm fuzzy indexes. It no longer hand-writes DDL, so the schema cannot drift from the +// migration files. +// @intent give tests and callers a one-call schema setup that reuses the production migrations. +// @sideEffect applies any pending schema migrations to the connected database. func (p *PostgresBackend) Migrate(db *gorm.DB) error { - if err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Exec(` - ALTER TABLE search_documents - ADD COLUMN IF NOT EXISTS tsv tsvector, - ADD COLUMN IF NOT EXISTS namespace varchar(256) NOT NULL DEFAULT '' - `).Error; err != nil { - return trace.Wrap(err, "add tsv column") - } - - if err := tx.Exec(` - CREATE OR REPLACE FUNCTION search_documents_tsv_trigger() RETURNS trigger AS $$ - BEGIN - NEW.tsv := to_tsvector('simple', COALESCE(NEW.content, '')); - RETURN NEW; - END - $$ LANGUAGE plpgsql - `).Error; err != nil { - return trace.Wrap(err, "create trigger function") - } - - if err := tx.Exec(` - DROP TRIGGER IF EXISTS trg_search_documents_tsv ON search_documents - `).Error; err != nil { - return trace.Wrap(err, "drop old trigger") - } - - if err := tx.Exec(` - CREATE TRIGGER trg_search_documents_tsv - BEFORE INSERT OR UPDATE ON search_documents - FOR EACH ROW EXECUTE FUNCTION search_documents_tsv_trigger() - `).Error; err != nil { - return trace.Wrap(err, "create trigger") - } - - return nil - }); err != nil { - return err - } - - if err := db.Exec(` - CREATE INDEX IF NOT EXISTS idx_search_documents_tsv - ON search_documents USING gin(tsv) - `).Error; err != nil { - return trace.Wrap(err, "create gin index") - } - - // pg_trgm powers typo-tolerant fuzzy symbol matching. The extension may need - // elevated privileges, so treat its absence as a graceful downgrade to exact FTS - // rather than a fatal migration error. - if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil { - slog.Warn("pg_trgm unavailable; fuzzy symbol search disabled", trace.SlogError(err)) - return nil - } - if err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_name_trgm ON nodes USING gin (name gin_trgm_ops)`).Error; err != nil { - return trace.Wrap(err, "create name trgm index") - } - if err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name_trgm ON nodes USING gin (qualified_name gin_trgm_ops)`).Error; err != nil { - return trace.Wrap(err, "create qualified_name trgm index") - } - - return nil + return migration.RunMigrations(db, "postgres", "") } // Rebuild recalculates the tsvector for all search documents. diff --git a/internal/store/search/postgres_test.go b/internal/store/search/postgres_test.go index e66ac4b..3501125 100644 --- a/internal/store/search/postgres_test.go +++ b/internal/store/search/postgres_test.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm/logger" "github.com/tae2089/code-context-graph/internal/ctxns" + "github.com/tae2089/code-context-graph/internal/db/migration" "github.com/tae2089/code-context-graph/internal/model" ) @@ -65,11 +66,13 @@ func setupPostgresDB(t *testing.T) *gorm.DB { t.Skipf("PostgreSQL not available: %v", err) } - db.Exec("DROP TABLE IF EXISTS search_documents CASCADE") - db.Exec("DROP TABLE IF EXISTS nodes CASCADE") - - if err := db.AutoMigrate(&model.Node{}, &model.SearchDocument{}); err != nil { - t.Fatal(err) + // Reset to a clean schema and build it from the production migrations (the single source + // of truth), rather than AutoMigrate + hand-written backend DDL. + if err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;").Error; err != nil { + t.Fatalf("reset schema: %v", err) + } + if err := migration.RunMigrations(db, "postgres", ""); err != nil { + t.Fatalf("run migrations: %v", err) } return db } @@ -232,15 +235,13 @@ func TestPostgresFTS_RebuildNodes_ChunksLargeNodeScopes(t *testing.T) { if err != nil { t.Skipf("PostgreSQL not available: %v", err) } - db.Exec("DROP TABLE IF EXISTS search_documents CASCADE") - db.Exec("DROP TABLE IF EXISTS nodes CASCADE") - if err := db.AutoMigrate(&model.Node{}, &model.SearchDocument{}); err != nil { - t.Fatal(err) + if err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;").Error; err != nil { + t.Fatalf("reset schema: %v", err) } - backend := NewPostgresBackend() - if err := backend.Migrate(db); err != nil { - t.Fatal(err) + if err := migration.RunMigrations(db, "postgres", ""); err != nil { + t.Fatalf("run migrations: %v", err) } + backend := NewPostgresBackend() nodeIDs := make([]uint, 0, scopedRebuildChunkSize+1) for i := range scopedRebuildChunkSize + 1 {