This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/database/postgres.go
Torjus Håkestad ea4c69bc23 feat: add nixpkgs-search binary with package search support
Add a new nixpkgs-search CLI that combines NixOS options search with
Nix package search functionality. This provides two MCP servers from
a single binary:
- `nixpkgs-search options serve` for NixOS options
- `nixpkgs-search packages serve` for Nix packages

Key changes:
- Add packages table to database schema (version 3)
- Add Package type and search methods to database layer
- Create internal/packages/ with indexer and parser for nix-env JSON
- Add MCP server mode (options/packages) with separate tool sets
- Add package handlers: search_packages, get_package
- Create cmd/nixpkgs-search with combined indexing support
- Update flake.nix with nixpkgs-search package (now default)
- Bump version to 0.2.0

The index command can index both options and packages together, or
use --no-packages/--no-options flags for partial indexing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:12:41 +01:00

697 lines
24 KiB
Go

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) {
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 // silence ineffassign - argNum tracks position but final value unused
}
baseQuery += " ORDER BY attr_path"
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
}