package database import ( "context" "database/sql" "fmt" _ "github.com/lib/pq" ) // PostgresStore implements Store using PostgreSQL. type PostgresStore struct { db *sql.DB } // NewPostgresStore creates a new PostgreSQL store. func NewPostgresStore(connStr string) (*PostgresStore, error) { db, err := sql.Open("postgres", connStr) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("failed to ping database: %w", err) } return &PostgresStore{db: db}, nil } // Initialize creates or migrates the database schema. func (s *PostgresStore) Initialize(ctx context.Context) error { // Check current schema version var version int err := s.db.QueryRowContext(ctx, "SELECT version FROM schema_info LIMIT 1").Scan(&version) needsRecreate := err != nil || version != SchemaVersion if needsRecreate { // Drop all tables in correct order (respecting foreign keys) dropStmts := []string{ DropDeclarations, DropOptions, DropFiles, DropRevisions, DropSchemaInfo, } for _, stmt := range dropStmts { if _, err := s.db.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("failed to drop table: %w", err) } } } // Create tables createStmts := []string{ SchemaInfoTable, // PostgreSQL uses SERIAL for auto-increment `CREATE TABLE IF NOT EXISTS revisions ( id SERIAL PRIMARY KEY, git_hash TEXT NOT NULL UNIQUE, channel_name TEXT, commit_date TIMESTAMP, indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, option_count INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS options ( id SERIAL PRIMARY KEY, revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, name TEXT NOT NULL, parent_path TEXT NOT NULL, type TEXT, default_value TEXT, example TEXT, description TEXT, read_only BOOLEAN NOT NULL DEFAULT FALSE )`, `CREATE TABLE IF NOT EXISTS declarations ( id SERIAL PRIMARY KEY, option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE, file_path TEXT NOT NULL, line INTEGER )`, `CREATE TABLE IF NOT EXISTS files ( id SERIAL PRIMARY KEY, revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, file_path TEXT NOT NULL, extension TEXT, content TEXT NOT NULL )`, IndexOptionsRevisionName, IndexOptionsRevisionParent, IndexFilesRevisionPath, IndexDeclarationsOption, } for _, stmt := range createStmts { if _, err := s.db.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("failed to create schema: %w", err) } } // Create full-text search index for PostgreSQL _, err = s.db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_options_fts ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, ''))) `) if err != nil { return fmt.Errorf("failed to create FTS index: %w", err) } // Set schema version if needsRecreate { _, err = s.db.ExecContext(ctx, "INSERT INTO schema_info (version) VALUES ($1)", SchemaVersion) if err != nil { return fmt.Errorf("failed to set schema version: %w", err) } } return nil } // Close closes the database connection. func (s *PostgresStore) Close() error { return s.db.Close() } // CreateRevision creates a new revision record. func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error { err := s.db.QueryRowContext(ctx, ` INSERT INTO revisions (git_hash, channel_name, commit_date, option_count) VALUES ($1, $2, $3, $4) RETURNING id, indexed_at`, rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, ).Scan(&rev.ID, &rev.IndexedAt) if err != nil { return fmt.Errorf("failed to create revision: %w", err) } return nil } // GetRevision retrieves a revision by git hash. func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { rev := &Revision{} err := s.db.QueryRowContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count FROM revisions WHERE git_hash = $1`, gitHash, ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get revision: %w", err) } return rev, nil } // GetRevisionByChannel retrieves a revision by channel name. func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { rev := &Revision{} err := s.db.QueryRowContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count FROM revisions WHERE channel_name = $1 ORDER BY indexed_at DESC LIMIT 1`, channel, ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get revision by channel: %w", err) } return rev, nil } // ListRevisions returns all indexed revisions. func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count FROM revisions ORDER BY indexed_at DESC`) if err != nil { return nil, fmt.Errorf("failed to list revisions: %w", err) } defer rows.Close() var revisions []*Revision for rows.Next() { rev := &Revision{} if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil { return nil, fmt.Errorf("failed to scan revision: %w", err) } revisions = append(revisions, rev) } return revisions, rows.Err() } // DeleteRevision removes a revision and all associated data. func (s *PostgresStore) DeleteRevision(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = $1", id) if err != nil { return fmt.Errorf("failed to delete revision: %w", err) } return nil } // UpdateRevisionOptionCount updates the option count for a revision. func (s *PostgresStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error { _, err := s.db.ExecContext(ctx, "UPDATE revisions SET option_count = $1 WHERE id = $2", count, id) if err != nil { return fmt.Errorf("failed to update option count: %w", err) } return nil } // CreateOption creates a new option record. func (s *PostgresStore) CreateOption(ctx context.Context, opt *Option) error { err := s.db.QueryRowContext(ctx, ` INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, ).Scan(&opt.ID) if err != nil { return fmt.Errorf("failed to create option: %w", err) } return nil } // CreateOptionsBatch creates multiple options in a batch. func (s *PostgresStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, ` INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, opt := range opts { err := stmt.QueryRowContext(ctx, opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, ).Scan(&opt.ID) if err != nil { return fmt.Errorf("failed to insert option %s: %w", opt.Name, err) } } return tx.Commit() } // GetOption retrieves an option by revision and name. func (s *PostgresStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) { opt := &Option{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = $1 AND name = $2`, revisionID, name, ).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get option: %w", err) } return opt, nil } // GetChildren retrieves direct children of an option. func (s *PostgresStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = $1 AND parent_path = $2 ORDER BY name`, revisionID, parentPath) if err != nil { return nil, fmt.Errorf("failed to get children: %w", err) } defer rows.Close() var options []*Option for rows.Next() { opt := &Option{} if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { return nil, fmt.Errorf("failed to scan option: %w", err) } options = append(options, opt) } return options, rows.Err() } // SearchOptions searches for options matching a query. func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { // Use PostgreSQL full-text search baseQuery := ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = $1 AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)` args := []interface{}{revisionID, query} argNum := 3 if filters.Type != "" { baseQuery += fmt.Sprintf(" AND type = $%d", argNum) args = append(args, filters.Type) argNum++ } if filters.Namespace != "" { baseQuery += fmt.Sprintf(" AND name LIKE $%d", argNum) args = append(args, filters.Namespace+"%") argNum++ } if filters.HasDefault != nil { if *filters.HasDefault { baseQuery += " AND default_value IS NOT NULL" } else { baseQuery += " AND default_value IS NULL" } } baseQuery += " ORDER BY name" if filters.Limit > 0 { baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) } if filters.Offset > 0 { baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset) } rows, err := s.db.QueryContext(ctx, baseQuery, args...) if err != nil { return nil, fmt.Errorf("failed to search options: %w", err) } defer rows.Close() var options []*Option for rows.Next() { opt := &Option{} if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { return nil, fmt.Errorf("failed to scan option: %w", err) } options = append(options, opt) } return options, rows.Err() } // CreateDeclaration creates a new declaration record. func (s *PostgresStore) CreateDeclaration(ctx context.Context, decl *Declaration) error { err := s.db.QueryRowContext(ctx, ` INSERT INTO declarations (option_id, file_path, line) VALUES ($1, $2, $3) RETURNING id`, decl.OptionID, decl.FilePath, decl.Line, ).Scan(&decl.ID) if err != nil { return fmt.Errorf("failed to create declaration: %w", err) } return nil } // CreateDeclarationsBatch creates multiple declarations in a batch. func (s *PostgresStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, ` INSERT INTO declarations (option_id, file_path, line) VALUES ($1, $2, $3) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, decl := range decls { err := stmt.QueryRowContext(ctx, decl.OptionID, decl.FilePath, decl.Line).Scan(&decl.ID) if err != nil { return fmt.Errorf("failed to insert declaration: %w", err) } } return tx.Commit() } // GetDeclarations retrieves declarations for an option. func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, option_id, file_path, line FROM declarations WHERE option_id = $1`, optionID) if err != nil { return nil, fmt.Errorf("failed to get declarations: %w", err) } defer rows.Close() var decls []*Declaration for rows.Next() { decl := &Declaration{} if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil { return nil, fmt.Errorf("failed to scan declaration: %w", err) } decls = append(decls, decl) } return decls, rows.Err() } // CreateFile creates a new file record. func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error { err := s.db.QueryRowContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content) VALUES ($1, $2, $3, $4) RETURNING id`, file.RevisionID, file.FilePath, file.Extension, file.Content, ).Scan(&file.ID) if err != nil { return fmt.Errorf("failed to create file: %w", err) } return nil } // CreateFilesBatch creates multiple files in a batch. func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content) VALUES ($1, $2, $3, $4) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() for _, file := range files { err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID) if err != nil { return fmt.Errorf("failed to insert file: %w", err) } } return tx.Commit() } // GetFile retrieves a file by revision and path. func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) { file := &File{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, file_path, extension, content FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path, ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } return file, nil }