fix: improve search to prioritize path-based matching

When searching for option paths like "services.nginx", use name-based
LIKE matching instead of full-text search. This ensures the results
are options that start with the query, not random options that mention
the term somewhere in their description.

- Path queries (containing dots): use LIKE for name prefix matching
- Text queries (no dots): use FTS for full-text search on name+description

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 18:23:50 +01:00
parent ec0eba4bef
commit 88e8a55347
2 changed files with 63 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -297,15 +298,29 @@ func (s *PostgresStore) GetChildren(ctx context.Context, revisionID int64, paren
// SearchOptions searches for options matching a query. // SearchOptions searches for options matching a query.
func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
// Use PostgreSQL full-text search var baseQuery string
baseQuery := ` var args []interface{}
argNum := 1
// 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 SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options FROM options
WHERE revision_id = $1 WHERE revision_id = $1
AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)` AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
args = []interface{}{revisionID, query}
args := []interface{}{revisionID, query} argNum = 3
argNum := 3 }
if filters.Type != "" { if filters.Type != "" {
baseQuery += fmt.Sprintf(" AND type = $%d", argNum) baseQuery += fmt.Sprintf(" AND type = $%d", argNum)

View File

@@ -316,8 +316,26 @@ func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentP
// SearchOptions searches for options matching a query. // SearchOptions searches for options matching a query.
func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
// Use SQLite FTS5 for full-text search var baseQuery string
baseQuery := ` var args []interface{}
// If the query looks like an option path (contains dots), prioritize name-based matching.
// This ensures "services.nginx" finds "services.nginx.*" options, not random options
// that happen to mention "nginx" in their description.
if strings.Contains(query, ".") {
// Use LIKE-based search for path queries, with ranking:
// 1. Exact match
// 2. Direct children (query.*)
// 3. All descendants (query.*.*)
baseQuery = `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options
WHERE revision_id = ?
AND (name = ? OR name LIKE ?)`
args = []interface{}{revisionID, query, query + ".%"}
} else {
// For non-path queries, use FTS5 for full-text search on name and description
baseQuery = `
SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only
FROM options o FROM options o
INNER JOIN options_fts fts ON o.id = fts.rowid INNER JOIN options_fts fts ON o.id = fts.rowid
@@ -325,30 +343,35 @@ func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query
AND options_fts MATCH ?` AND options_fts MATCH ?`
// Escape the query for FTS5 by wrapping in double quotes for literal matching. // Escape the query for FTS5 by wrapping in double quotes for literal matching.
// This prevents special characters (dots, colons, etc.) from being interpreted as operators.
// Also escape any double quotes within the query.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"` escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
args := []interface{}{revisionID, escapedQuery} args = []interface{}{revisionID, escapedQuery}
}
// Use table alias for filters (works for both query types)
tbl := ""
if !strings.Contains(query, ".") {
tbl = "o."
}
if filters.Type != "" { if filters.Type != "" {
baseQuery += " AND o.type = ?" baseQuery += " AND " + tbl + "type = ?"
args = append(args, filters.Type) args = append(args, filters.Type)
} }
if filters.Namespace != "" { if filters.Namespace != "" {
baseQuery += " AND o.name LIKE ?" baseQuery += " AND " + tbl + "name LIKE ?"
args = append(args, filters.Namespace+"%") args = append(args, filters.Namespace+"%")
} }
if filters.HasDefault != nil { if filters.HasDefault != nil {
if *filters.HasDefault { if *filters.HasDefault {
baseQuery += " AND o.default_value IS NOT NULL" baseQuery += " AND " + tbl + "default_value IS NOT NULL"
} else { } else {
baseQuery += " AND o.default_value IS NULL" baseQuery += " AND " + tbl + "default_value IS NULL"
} }
} }
baseQuery += " ORDER BY o.name" baseQuery += " ORDER BY " + tbl + "name"
if filters.Limit > 0 { if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)