Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/ccg/main_postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
8 changes: 4 additions & 4 deletions cmd/ccg/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/db/migration/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
)

const (
RequiredSchemaVersion = 5
RequiredSchemaVersion = 6
SchemaVersionKey = "schema"
LegacySchemaVersionTable = "ccg_schema_versions"
)
Expand Down
2 changes: 2 additions & 0 deletions internal/migrationfs/postgres/000006_search_trgm.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_nodes_qualified_name_trgm;
DROP INDEX IF EXISTS idx_nodes_name_trgm;
19 changes: 19 additions & 0 deletions internal/migrationfs/postgres/000006_search_trgm.up.sql
Original file line number Diff line number Diff line change
@@ -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
$$;
1 change: 1 addition & 0 deletions internal/migrationfs/sqlite/000006_search_trgm.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- no-op
1 change: 1 addition & 0 deletions internal/migrationfs/sqlite/000006_search_trgm.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- pg_trgm fuzzy symbol search is PostgreSQL-only; no-op on SQLite.
73 changes: 8 additions & 65 deletions internal/store/search/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand Down
25 changes: 13 additions & 12 deletions internal/store/search/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading