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"
"database/sql"
"fmt"
"strings"
_ "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.
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)`
var baseQuery string
var args []interface{}
argNum := 1
args := []interface{}{revisionID, query}
argNum := 3
// 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)

View File

@@ -316,39 +316,62 @@ func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentP
// SearchOptions searches for options matching a query.
func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
// Use SQLite FTS5 for full-text search
baseQuery := `
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
INNER JOIN options_fts fts ON o.id = fts.rowid
WHERE o.revision_id = ?
AND options_fts MATCH ?`
var baseQuery string
var args []interface{}
// 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, `"`, `""`) + `"`
args := []interface{}{revisionID, escapedQuery}
// 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
FROM options o
INNER JOIN options_fts fts ON o.id = fts.rowid
WHERE o.revision_id = ?
AND options_fts MATCH ?`
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
args = []interface{}{revisionID, escapedQuery}
}
// Use table alias for filters (works for both query types)
tbl := ""
if !strings.Contains(query, ".") {
tbl = "o."
}
if filters.Type != "" {
baseQuery += " AND o.type = ?"
baseQuery += " AND " + tbl + "type = ?"
args = append(args, filters.Type)
}
if filters.Namespace != "" {
baseQuery += " AND o.name LIKE ?"
baseQuery += " AND " + tbl + "name LIKE ?"
args = append(args, filters.Namespace+"%")
}
if filters.HasDefault != nil {
if *filters.HasDefault {
baseQuery += " AND o.default_value IS NOT NULL"
baseQuery += " AND " + tbl + "default_value IS NOT NULL"
} 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 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)