diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 8ba552d..2cde849 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -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) diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 1e0e241..e2be387 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -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)