Skip to content

Commit bb6d8b5

Browse files
tae2089claude
andauthored
Make SQL migrations the single source of the Postgres search schema (#3)
PostgresBackend.Migrate hand-wrote the tsvector column, trigger, and GIN index that migration 000001 already creates, with a drift (namespace DEFAULT '' vs 'default'), and it created the pg_trgm extension + trigram indexes that no production path ever called — so fuzzy search ran without its indexes in production. - Move pg_trgm + the trigram indexes into a new migration (000006). A DO block attempts CREATE EXTENSION and degrades gracefully (indexes only created when the extension is present) so a missing privilege does not fail startup. Bump RequiredSchemaVersion to 6. - Reduce PostgresBackend.Migrate to running the versioned migrations, so there is one schema definition and it cannot drift. - Point the Postgres search tests at the real migrations (reset schema + RunMigrations) instead of AutoMigrate + backend DDL, and adjust the down-migration step counts and required-version log assertions for v6. Verified against a live Postgres: migrations create pg_trgm, both trigram indexes, and namespace DEFAULT 'default'. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d89de18 commit bb6d8b5

9 files changed

Lines changed: 51 additions & 84 deletions

File tree

cmd/ccg/main_postgres_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func TestRunMigrations_PostgresDownRestoresNullableColumns(t *testing.T) {
153153
if err != nil {
154154
t.Fatalf("create migrator: %v", err)
155155
}
156-
if err := migrator.Steps(-4); err != nil {
156+
if err := migrator.Steps(-5); err != nil {
157157
t.Fatalf("run down migration: %v", err)
158158
}
159159

@@ -186,7 +186,7 @@ func TestRunMigrations_PostgresDownFromVersionThreeDropsPolicyTables(t *testing.
186186
if err != nil {
187187
t.Fatalf("create migrator: %v", err)
188188
}
189-
if err := migrator.Steps(-3); err != nil {
189+
if err := migrator.Steps(-4); err != nil {
190190
t.Fatalf("run down migration: %v", err)
191191
}
192192

cmd/ccg/main_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ func TestRunMigrations_SQLiteDownRestoresNullableColumns(t *testing.T) {
268268
if err != nil {
269269
t.Fatalf("create migrator: %v", err)
270270
}
271-
if err := migrator.Steps(-4); err != nil {
271+
if err := migrator.Steps(-5); err != nil {
272272
t.Fatalf("run down migration: %v", err)
273273
}
274274

@@ -313,7 +313,7 @@ func TestRunMigrations_SQLiteDownFromVersionThreeDropsPolicyTables(t *testing.T)
313313
if err != nil {
314314
t.Fatalf("create migrator: %v", err)
315315
}
316-
if err := migrator.Steps(-3); err != nil {
316+
if err := migrator.Steps(-4); err != nil {
317317
t.Fatalf("run down migration: %v", err)
318318
}
319319

@@ -427,7 +427,7 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) {
427427
t.Fatalf("ensure schema version: %v", err)
428428
}
429429
passLog := logs.String()
430-
for _, want := range []string{"database runtime schema check passed", "driver=sqlite", "required_version=5", "auto_migrated=true"} {
430+
for _, want := range []string{"database runtime schema check passed", "driver=sqlite", "required_version=6", "auto_migrated=true"} {
431431
if !strings.Contains(passLog, want) {
432432
t.Fatalf("expected runtime schema pass log to contain %q, got %q", want, passLog)
433433
}
@@ -441,7 +441,7 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) {
441441
t.Fatal("expected parity failure")
442442
}
443443
failLog := logs.String()
444-
for _, want := range []string{"database runtime schema check failed", "driver=sqlite", "required_version=5", "auto_migrated=false", "error.message="} {
444+
for _, want := range []string{"database runtime schema check failed", "driver=sqlite", "required_version=6", "auto_migrated=false", "error.message="} {
445445
if !strings.Contains(failLog, want) {
446446
t.Fatalf("expected runtime schema failure log to contain %q, got %q", want, failLog)
447447
}

internal/db/migration/migration.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
)
2525

2626
const (
27-
RequiredSchemaVersion = 5
27+
RequiredSchemaVersion = 6
2828
SchemaVersionKey = "schema"
2929
LegacySchemaVersionTable = "ccg_schema_versions"
3030
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP INDEX IF EXISTS idx_nodes_qualified_name_trgm;
2+
DROP INDEX IF EXISTS idx_nodes_name_trgm;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- pg_trgm powers typo-tolerant fuzzy symbol search. The extension may require elevated
2+
-- privileges, so attempt it inside a DO block and degrade gracefully when it is unavailable:
3+
-- the trigram indexes are only created when the extension is present.
4+
DO $$
5+
BEGIN
6+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
7+
EXCEPTION WHEN insufficient_privilege THEN
8+
RAISE WARNING 'pg_trgm unavailable; fuzzy symbol search disabled';
9+
END
10+
$$;
11+
12+
DO $$
13+
BEGIN
14+
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN
15+
CREATE INDEX IF NOT EXISTS idx_nodes_name_trgm ON nodes USING gin (name gin_trgm_ops);
16+
CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name_trgm ON nodes USING gin (qualified_name gin_trgm_ops);
17+
END IF;
18+
END
19+
$$;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
-- no-op
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
-- pg_trgm fuzzy symbol search is PostgreSQL-only; no-op on SQLite.

internal/store/search/postgres.go

Lines changed: 8 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/tae2089/trace"
1313

1414
"github.com/tae2089/code-context-graph/internal/ctxns"
15+
"github.com/tae2089/code-context-graph/internal/db/migration"
1516
"github.com/tae2089/code-context-graph/internal/model"
1617
)
1718

@@ -30,72 +31,14 @@ func NewPostgresBackend() *PostgresBackend {
3031
return &PostgresBackend{}
3132
}
3233

33-
// Migrate prepares the PostgreSQL full-text search schema.
34-
// @intent Sets up the tsvector-based search infrastructure on the search_documents table.
35-
// @sideEffect Creates or replaces columns, indexes, trigger functions, and triggers.
36-
// @ensures The tsv column is automatically updated when search_documents is modified.
34+
// Migrate ensures the PostgreSQL search schema exists by running the versioned migrations,
35+
// which are the single source of truth for the tsvector column, trigger, GIN index, and the
36+
// pg_trgm fuzzy indexes. It no longer hand-writes DDL, so the schema cannot drift from the
37+
// migration files.
38+
// @intent give tests and callers a one-call schema setup that reuses the production migrations.
39+
// @sideEffect applies any pending schema migrations to the connected database.
3740
func (p *PostgresBackend) Migrate(db *gorm.DB) error {
38-
if err := db.Transaction(func(tx *gorm.DB) error {
39-
if err := tx.Exec(`
40-
ALTER TABLE search_documents
41-
ADD COLUMN IF NOT EXISTS tsv tsvector,
42-
ADD COLUMN IF NOT EXISTS namespace varchar(256) NOT NULL DEFAULT ''
43-
`).Error; err != nil {
44-
return trace.Wrap(err, "add tsv column")
45-
}
46-
47-
if err := tx.Exec(`
48-
CREATE OR REPLACE FUNCTION search_documents_tsv_trigger() RETURNS trigger AS $$
49-
BEGIN
50-
NEW.tsv := to_tsvector('simple', COALESCE(NEW.content, ''));
51-
RETURN NEW;
52-
END
53-
$$ LANGUAGE plpgsql
54-
`).Error; err != nil {
55-
return trace.Wrap(err, "create trigger function")
56-
}
57-
58-
if err := tx.Exec(`
59-
DROP TRIGGER IF EXISTS trg_search_documents_tsv ON search_documents
60-
`).Error; err != nil {
61-
return trace.Wrap(err, "drop old trigger")
62-
}
63-
64-
if err := tx.Exec(`
65-
CREATE TRIGGER trg_search_documents_tsv
66-
BEFORE INSERT OR UPDATE ON search_documents
67-
FOR EACH ROW EXECUTE FUNCTION search_documents_tsv_trigger()
68-
`).Error; err != nil {
69-
return trace.Wrap(err, "create trigger")
70-
}
71-
72-
return nil
73-
}); err != nil {
74-
return err
75-
}
76-
77-
if err := db.Exec(`
78-
CREATE INDEX IF NOT EXISTS idx_search_documents_tsv
79-
ON search_documents USING gin(tsv)
80-
`).Error; err != nil {
81-
return trace.Wrap(err, "create gin index")
82-
}
83-
84-
// pg_trgm powers typo-tolerant fuzzy symbol matching. The extension may need
85-
// elevated privileges, so treat its absence as a graceful downgrade to exact FTS
86-
// rather than a fatal migration error.
87-
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
88-
slog.Warn("pg_trgm unavailable; fuzzy symbol search disabled", trace.SlogError(err))
89-
return nil
90-
}
91-
if err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_name_trgm ON nodes USING gin (name gin_trgm_ops)`).Error; err != nil {
92-
return trace.Wrap(err, "create name trgm index")
93-
}
94-
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 {
95-
return trace.Wrap(err, "create qualified_name trgm index")
96-
}
97-
98-
return nil
41+
return migration.RunMigrations(db, "postgres", "")
9942
}
10043

10144
// Rebuild recalculates the tsvector for all search documents.

internal/store/search/postgres_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"gorm.io/gorm/logger"
1616

1717
"github.com/tae2089/code-context-graph/internal/ctxns"
18+
"github.com/tae2089/code-context-graph/internal/db/migration"
1819
"github.com/tae2089/code-context-graph/internal/model"
1920
)
2021

@@ -65,11 +66,13 @@ func setupPostgresDB(t *testing.T) *gorm.DB {
6566
t.Skipf("PostgreSQL not available: %v", err)
6667
}
6768

68-
db.Exec("DROP TABLE IF EXISTS search_documents CASCADE")
69-
db.Exec("DROP TABLE IF EXISTS nodes CASCADE")
70-
71-
if err := db.AutoMigrate(&model.Node{}, &model.SearchDocument{}); err != nil {
72-
t.Fatal(err)
69+
// Reset to a clean schema and build it from the production migrations (the single source
70+
// of truth), rather than AutoMigrate + hand-written backend DDL.
71+
if err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;").Error; err != nil {
72+
t.Fatalf("reset schema: %v", err)
73+
}
74+
if err := migration.RunMigrations(db, "postgres", ""); err != nil {
75+
t.Fatalf("run migrations: %v", err)
7376
}
7477
return db
7578
}
@@ -232,15 +235,13 @@ func TestPostgresFTS_RebuildNodes_ChunksLargeNodeScopes(t *testing.T) {
232235
if err != nil {
233236
t.Skipf("PostgreSQL not available: %v", err)
234237
}
235-
db.Exec("DROP TABLE IF EXISTS search_documents CASCADE")
236-
db.Exec("DROP TABLE IF EXISTS nodes CASCADE")
237-
if err := db.AutoMigrate(&model.Node{}, &model.SearchDocument{}); err != nil {
238-
t.Fatal(err)
238+
if err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;").Error; err != nil {
239+
t.Fatalf("reset schema: %v", err)
239240
}
240-
backend := NewPostgresBackend()
241-
if err := backend.Migrate(db); err != nil {
242-
t.Fatal(err)
241+
if err := migration.RunMigrations(db, "postgres", ""); err != nil {
242+
t.Fatalf("run migrations: %v", err)
243243
}
244+
backend := NewPostgresBackend()
244245

245246
nodeIDs := make([]uint, 0, scopedRebuildChunkSize+1)
246247
for i := range scopedRebuildChunkSize + 1 {

0 commit comments

Comments
 (0)