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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user