package database import ( "context" "database/sql" "fmt" "strings" _ "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() //nolint:errcheck // best-effort cleanup on connection failure 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, DropPackages, 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, package_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, byte_size INTEGER NOT NULL DEFAULT 0, line_count INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS packages ( id SERIAL PRIMARY KEY, revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, attr_path TEXT NOT NULL, pname TEXT NOT NULL, version TEXT, description TEXT, long_description TEXT, homepage TEXT, license TEXT, platforms TEXT, maintainers TEXT, broken BOOLEAN NOT NULL DEFAULT FALSE, unfree BOOLEAN NOT NULL DEFAULT FALSE, insecure BOOLEAN NOT NULL DEFAULT FALSE )`, IndexOptionsRevisionName, IndexOptionsRevisionParent, IndexFilesRevisionPath, IndexDeclarationsOption, IndexPackagesRevisionAttr, IndexPackagesRevisionPname, } 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 options _, 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 options FTS index: %w", err) } // Create full-text search index for PostgreSQL packages _, err = s.db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_packages_fts ON packages USING GIN(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, ''))) `) if err != nil { return fmt.Errorf("failed to create packages 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, package_count) VALUES ($1, $2, $3, $4, $5) RETURNING id, indexed_at`, rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount, ).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, package_count FROM revisions WHERE git_hash = $1`, gitHash, ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount) 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, package_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, &rev.PackageCount) 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, package_count FROM revisions ORDER BY indexed_at DESC`) if err != nil { return nil, fmt.Errorf("failed to list revisions: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var revisions []*Revision for rows.Next() { rev := &Revision{} if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); 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() //nolint:errcheck // rollback after commit returns error, which is expected 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() //nolint:errcheck // statement closed with transaction 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() //nolint:errcheck // rows.Err() checked after iteration 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) { var baseQuery string var args []interface{} var argNum int // If the query looks like an option path (contains dots), prioritize name-based matching. if strings.Contains(query, ".") { baseQuery = ` SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only FROM options WHERE revision_id = $1 AND (name = $2 OR name LIKE $3)` args = []interface{}{revisionID, query, query + ".%"} argNum = 4 } else { // For non-path queries, 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 // silence ineffassign - argNum tracks position but final value unused } 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() //nolint:errcheck // rows.Err() checked after iteration 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() //nolint:errcheck // rollback after commit returns error, which is expected 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() //nolint:errcheck // statement closed with transaction 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() //nolint:errcheck // rows.Err() checked after iteration 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 { // Compute metadata if not already set if file.ByteSize == 0 { file.ByteSize = len(file.Content) } if file.LineCount == 0 { file.LineCount = countLines(file.Content) } err := s.db.QueryRowContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount, ).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() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, file := range files { // Compute metadata if not already set if file.ByteSize == 0 { file.ByteSize = len(file.Content) } if file.LineCount == 0 { file.LineCount = countLines(file.Content) } err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount).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, byte_size, line_count FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path, ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } return file, nil } // GetDeclarationsWithMetadata retrieves declarations with file metadata for an option. func (s *PostgresStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) { rows, err := s.db.QueryContext(ctx, ` SELECT d.id, d.option_id, d.file_path, d.line, COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL) FROM declarations d LEFT JOIN files f ON f.revision_id = $1 AND f.file_path = d.file_path WHERE d.option_id = $2`, revisionID, optionID) if err != nil { return nil, fmt.Errorf("failed to get declarations with metadata: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var decls []*DeclarationWithMetadata for rows.Next() { decl := &DeclarationWithMetadata{} if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line, &decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil { return nil, fmt.Errorf("failed to scan declaration: %w", err) } decls = append(decls, decl) } return decls, rows.Err() } // GetFileWithRange retrieves a file with a specified line range. func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) { file, err := s.GetFile(ctx, revisionID, path) if err != nil { return nil, err } if file == nil { return nil, nil } return applyLineRange(file, r), nil } // CreatePackage creates a new package record. func (s *PostgresStore) CreatePackage(ctx context.Context, pkg *Package) error { err := s.db.QueryRowContext(ctx, ` INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure, ).Scan(&pkg.ID) if err != nil { return fmt.Errorf("failed to create package: %w", err) } return nil } // CreatePackagesBatch creates multiple packages in a batch. func (s *PostgresStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected stmt, err := tx.PrepareContext(ctx, ` INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) } defer stmt.Close() //nolint:errcheck // statement closed with transaction for _, pkg := range pkgs { err := stmt.QueryRowContext(ctx, pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure, ).Scan(&pkg.ID) if err != nil { return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err) } } return tx.Commit() } // GetPackage retrieves a package by revision and attr_path. func (s *PostgresStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) { pkg := &Package{} err := s.db.QueryRowContext(ctx, ` SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure FROM packages WHERE revision_id = $1 AND attr_path = $2`, revisionID, attrPath, ).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get package: %w", err) } return pkg, nil } // SearchPackages searches for packages matching a query. func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) { // Query includes exact match priority: // - Priority 0: exact pname match // - Priority 1: exact attr_path match // - Priority 2: pname starts with query // - Priority 3: attr_path starts with query // - Priority 4: FTS match (ordered by ts_rank) baseQuery := ` SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure FROM packages WHERE revision_id = $1 AND to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)` args := []interface{}{revisionID, query} argNum := 3 if filters.Broken != nil { baseQuery += fmt.Sprintf(" AND broken = $%d", argNum) args = append(args, *filters.Broken) argNum++ } if filters.Unfree != nil { baseQuery += fmt.Sprintf(" AND unfree = $%d", argNum) args = append(args, *filters.Unfree) argNum++ } if filters.Insecure != nil { baseQuery += fmt.Sprintf(" AND insecure = $%d", argNum) args = append(args, *filters.Insecure) argNum++ } // Order by exact match priority, then ts_rank, then attr_path // CASE returns priority (lower = better), ts_rank returns positive scores (higher = better, so DESC) baseQuery += fmt.Sprintf(` ORDER BY CASE WHEN pname = $%d THEN 0 WHEN attr_path = $%d THEN 1 WHEN pname LIKE $%d THEN 2 WHEN attr_path LIKE $%d THEN 3 ELSE 4 END, ts_rank(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')), plainto_tsquery('english', $2)) DESC, attr_path`, argNum, argNum+1, argNum+2, argNum+3) // For LIKE comparisons, escape % and _ characters for PostgreSQL likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") + "%" args = append(args, query, query, likeQuery, likeQuery) 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 packages: %w", err) } defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration var packages []*Package for rows.Next() { pkg := &Package{} if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil { return nil, fmt.Errorf("failed to scan package: %w", err) } packages = append(packages, pkg) } return packages, rows.Err() } // UpdateRevisionPackageCount updates the package count for a revision. func (s *PostgresStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error { _, err := s.db.ExecContext(ctx, "UPDATE revisions SET package_count = $1 WHERE id = $2", count, id) if err != nil { return fmt.Errorf("failed to update package count: %w", err) } return nil }