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>
This commit is contained in:
@@ -8,12 +8,13 @@ import (
|
||||
|
||||
// Revision represents an indexed nixpkgs revision.
|
||||
type Revision struct {
|
||||
ID int64
|
||||
GitHash string
|
||||
ChannelName string
|
||||
CommitDate time.Time
|
||||
IndexedAt time.Time
|
||||
OptionCount int
|
||||
ID int64
|
||||
GitHash string
|
||||
ChannelName string
|
||||
CommitDate time.Time
|
||||
IndexedAt time.Time
|
||||
OptionCount int
|
||||
PackageCount int
|
||||
}
|
||||
|
||||
// Option represents a NixOS configuration option.
|
||||
@@ -48,6 +49,24 @@ type File struct {
|
||||
LineCount int
|
||||
}
|
||||
|
||||
// Package represents a Nix package from nixpkgs.
|
||||
type Package struct {
|
||||
ID int64
|
||||
RevisionID int64
|
||||
AttrPath string // e.g., "python312Packages.requests"
|
||||
Pname string // Package name
|
||||
Version string
|
||||
Description string
|
||||
LongDescription string
|
||||
Homepage string
|
||||
License string // JSON array
|
||||
Platforms string // JSON array
|
||||
Maintainers string // JSON array
|
||||
Broken bool
|
||||
Unfree bool
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// DeclarationWithMetadata includes declaration info plus file metadata.
|
||||
type DeclarationWithMetadata struct {
|
||||
Declaration
|
||||
@@ -79,6 +98,15 @@ type SearchFilters struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// PackageSearchFilters contains optional filters for package search.
|
||||
type PackageSearchFilters struct {
|
||||
Broken *bool
|
||||
Unfree *bool
|
||||
Insecure *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Store defines the interface for database operations.
|
||||
type Store interface {
|
||||
// Schema operations
|
||||
@@ -111,4 +139,11 @@ type Store interface {
|
||||
CreateFilesBatch(ctx context.Context, files []*File) error
|
||||
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
|
||||
GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error)
|
||||
|
||||
// Package operations
|
||||
CreatePackage(ctx context.Context, pkg *Package) error
|
||||
CreatePackagesBatch(ctx context.Context, pkgs []*Package) error
|
||||
GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error)
|
||||
SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error)
|
||||
UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
dropStmts := []string{
|
||||
DropDeclarations,
|
||||
DropOptions,
|
||||
DropPackages,
|
||||
DropFiles,
|
||||
DropRevisions,
|
||||
DropSchemaInfo,
|
||||
@@ -64,7 +65,8 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
option_count INTEGER NOT NULL DEFAULT 0,
|
||||
package_count INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -92,10 +94,28 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
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 {
|
||||
@@ -104,13 +124,22 @@ func (s *PostgresStore) Initialize(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create full-text search index for PostgreSQL
|
||||
// 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 FTS index: %w", err)
|
||||
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
|
||||
@@ -133,10 +162,10 @@ func (s *PostgresStore) Close() error {
|
||||
// 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)
|
||||
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.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)
|
||||
@@ -148,9 +177,9 @@ func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error
|
||||
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
|
||||
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)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -164,10 +193,10 @@ func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revis
|
||||
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
|
||||
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)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -180,7 +209,7 @@ func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string
|
||||
// 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
|
||||
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)
|
||||
@@ -190,7 +219,7 @@ func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error)
|
||||
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 {
|
||||
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)
|
||||
@@ -542,3 +571,126 @@ func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package database
|
||||
|
||||
// SchemaVersion is the current database schema version.
|
||||
// When this changes, the database will be dropped and recreated.
|
||||
const SchemaVersion = 2
|
||||
const SchemaVersion = 3
|
||||
|
||||
// Common SQL statements shared between implementations.
|
||||
const (
|
||||
@@ -20,7 +20,8 @@ const (
|
||||
channel_name TEXT,
|
||||
commit_date TIMESTAMP,
|
||||
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
option_count INTEGER NOT NULL DEFAULT 0
|
||||
option_count INTEGER NOT NULL DEFAULT 0,
|
||||
package_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
|
||||
// OptionsTable creates the options table.
|
||||
@@ -57,6 +58,25 @@ const (
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
line_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
|
||||
// PackagesTable creates the packages table.
|
||||
PackagesTable = `
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER 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
|
||||
)`
|
||||
)
|
||||
|
||||
// Index creation statements.
|
||||
@@ -80,6 +100,16 @@ const (
|
||||
IndexDeclarationsOption = `
|
||||
CREATE INDEX IF NOT EXISTS idx_declarations_option
|
||||
ON declarations(option_id)`
|
||||
|
||||
// IndexPackagesRevisionAttr creates an index on packages(revision_id, attr_path).
|
||||
IndexPackagesRevisionAttr = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_packages_revision_attr
|
||||
ON packages(revision_id, attr_path)`
|
||||
|
||||
// IndexPackagesRevisionPname creates an index on packages(revision_id, pname).
|
||||
IndexPackagesRevisionPname = `
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_revision_pname
|
||||
ON packages(revision_id, pname)`
|
||||
)
|
||||
|
||||
// Drop statements for schema recreation.
|
||||
@@ -87,6 +117,7 @@ const (
|
||||
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
|
||||
DropDeclarations = `DROP TABLE IF EXISTS declarations`
|
||||
DropOptions = `DROP TABLE IF EXISTS options`
|
||||
DropPackages = `DROP TABLE IF EXISTS packages`
|
||||
DropFiles = `DROP TABLE IF EXISTS files`
|
||||
DropRevisions = `DROP TABLE IF EXISTS revisions`
|
||||
)
|
||||
|
||||
@@ -44,10 +44,12 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
dropStmts := []string{
|
||||
DropDeclarations,
|
||||
DropOptions,
|
||||
DropPackages,
|
||||
DropFiles,
|
||||
DropRevisions,
|
||||
DropSchemaInfo,
|
||||
"DROP TABLE IF EXISTS options_fts",
|
||||
"DROP TABLE IF EXISTS packages_fts",
|
||||
}
|
||||
for _, stmt := range dropStmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -63,10 +65,13 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
OptionsTable,
|
||||
DeclarationsTable,
|
||||
FilesTable,
|
||||
PackagesTable,
|
||||
IndexOptionsRevisionName,
|
||||
IndexOptionsRevisionParent,
|
||||
IndexFilesRevisionPath,
|
||||
IndexDeclarationsOption,
|
||||
IndexPackagesRevisionAttr,
|
||||
IndexPackagesRevisionPname,
|
||||
}
|
||||
|
||||
for _, stmt := range createStmts {
|
||||
@@ -88,8 +93,8 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to create FTS table: %w", err)
|
||||
}
|
||||
|
||||
// Create triggers to keep FTS in sync
|
||||
triggers := []string{
|
||||
// Create triggers to keep options FTS in sync
|
||||
optionsTriggers := []string{
|
||||
`CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN
|
||||
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
||||
END`,
|
||||
@@ -101,9 +106,42 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
|
||||
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
|
||||
END`,
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
for _, trigger := range optionsTriggers {
|
||||
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
|
||||
return fmt.Errorf("failed to create trigger: %w", err)
|
||||
return fmt.Errorf("failed to create options trigger: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create FTS5 virtual table for packages full-text search
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(
|
||||
attr_path,
|
||||
pname,
|
||||
description,
|
||||
content='packages',
|
||||
content_rowid='id'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create packages FTS table: %w", err)
|
||||
}
|
||||
|
||||
// Create triggers to keep packages FTS in sync
|
||||
packagesTriggers := []string{
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_ai AFTER INSERT ON packages BEGIN
|
||||
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_ad AFTER DELETE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS packages_au AFTER UPDATE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
|
||||
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
|
||||
END`,
|
||||
}
|
||||
for _, trigger := range packagesTriggers {
|
||||
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
|
||||
return fmt.Errorf("failed to create packages trigger: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +165,9 @@ func (s *SQLiteStore) Close() error {
|
||||
// CreateRevision creates a new revision record.
|
||||
func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
|
||||
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create revision: %w", err)
|
||||
@@ -155,9 +193,9 @@ func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
|
||||
func (s *SQLiteStore) 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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions WHERE git_hash = ?`, gitHash,
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -171,10 +209,10 @@ func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revisio
|
||||
func (s *SQLiteStore) 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
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
|
||||
FROM revisions WHERE channel_name = ?
|
||||
ORDER BY indexed_at DESC LIMIT 1`, channel,
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
|
||||
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -187,17 +225,17 @@ func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string)
|
||||
// ListRevisions returns all indexed revisions.
|
||||
func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
|
||||
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 //nolint:errcheck // rows.Err() checked after iteration
|
||||
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); err != nil {
|
||||
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)
|
||||
@@ -588,6 +626,138 @@ func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, pa
|
||||
return applyLineRange(file, r), nil
|
||||
}
|
||||
|
||||
// CreatePackage creates a new package record.
|
||||
func (s *SQLiteStore) CreatePackage(ctx context.Context, pkg *Package) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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 != nil {
|
||||
return fmt.Errorf("failed to create package: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||
}
|
||||
pkg.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePackagesBatch creates multiple packages in a batch.
|
||||
func (s *SQLiteStore) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
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 {
|
||||
result, err := stmt.ExecContext(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,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last insert id: %w", err)
|
||||
}
|
||||
pkg.ID = id
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetPackage retrieves a package by revision and attr_path.
|
||||
func (s *SQLiteStore) 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 = ? AND attr_path = ?`, 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 *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
|
||||
baseQuery := `
|
||||
SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure
|
||||
FROM packages p
|
||||
INNER JOIN packages_fts fts ON p.id = fts.rowid
|
||||
WHERE p.revision_id = ?
|
||||
AND packages_fts MATCH ?`
|
||||
|
||||
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
|
||||
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
|
||||
args := []interface{}{revisionID, escapedQuery}
|
||||
|
||||
if filters.Broken != nil {
|
||||
baseQuery += " AND p.broken = ?"
|
||||
args = append(args, *filters.Broken)
|
||||
}
|
||||
|
||||
if filters.Unfree != nil {
|
||||
baseQuery += " AND p.unfree = ?"
|
||||
args = append(args, *filters.Unfree)
|
||||
}
|
||||
|
||||
if filters.Insecure != nil {
|
||||
baseQuery += " AND p.insecure = ?"
|
||||
args = append(args, *filters.Insecure)
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY p.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 *SQLiteStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
"UPDATE revisions SET package_count = ? WHERE id = ?", count, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update package count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countLines counts the number of lines in content.
|
||||
func countLines(content string) int {
|
||||
if content == "" {
|
||||
|
||||
Reference in New Issue
Block a user