veza/veza-backend-api/internal/services/fulltext_search_service.go

404 lines
11 KiB
Go

package services
import (
"context"
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
// FullTextSearchService provides full-text search using PostgreSQL tsvector/tsquery
// BE-SVC-006: Implement search service using PostgreSQL full-text search
type FullTextSearchService struct {
db *gorm.DB
logger *zap.Logger
}
// NewFullTextSearchService creates a new full-text search service
func NewFullTextSearchService(db *gorm.DB, logger *zap.Logger) *FullTextSearchService {
if logger == nil {
logger = zap.NewNop()
}
return &FullTextSearchService{
db: db,
logger: logger,
}
}
// SearchParams represents search parameters
type SearchParams struct {
Query string // Search query text
Types []string // Types to search: "track", "user", "playlist"
Page int // Page number (default: 1)
Limit int // Results per page (default: 20, max: 100)
MinScore float64 // Minimum relevance score (0.0-1.0)
}
// FullTextSearchResult represents unified full-text search results
type FullTextSearchResult struct {
Tracks []TrackSearchResult `json:"tracks"`
Users []UserSearchResult `json:"users"`
Playlists []PlaylistSearchResult `json:"playlists"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// TrackSearchResult represents a track search result with relevance score
type TrackSearchResult struct {
models.Track
Relevance float64 `json:"relevance"`
}
// UserSearchResult represents a user search result with relevance score
type UserSearchResult struct {
models.User
Relevance float64 `json:"relevance"`
}
// PlaylistSearchResult represents a playlist search result with relevance score
type PlaylistSearchResult struct {
models.Playlist
Relevance float64 `json:"relevance"`
}
// Search performs a full-text search across tracks, users, and playlists
func (s *FullTextSearchService) Search(ctx context.Context, params SearchParams) (*FullTextSearchResult, error) {
result := &FullTextSearchResult{
Tracks: []TrackSearchResult{},
Users: []UserSearchResult{},
Playlists: []PlaylistSearchResult{},
Page: params.Page,
Limit: params.Limit,
}
// Validate and set defaults
if params.Page < 1 {
params.Page = 1
}
if params.Limit < 1 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.MinScore < 0 {
params.MinScore = 0
}
if params.MinScore > 1 {
params.MinScore = 1
}
// Build search types - if empty, search all
searchAll := len(params.Types) == 0
searchTracks := searchAll || containsString(params.Types, "track")
searchUsers := searchAll || containsString(params.Types, "user")
searchPlaylists := searchAll || containsString(params.Types, "playlist")
// Prepare search query for PostgreSQL tsquery
searchQuery := s.prepareSearchQuery(params.Query)
// Search tracks
if searchTracks && params.Query != "" {
tracks, err := s.searchTracks(ctx, searchQuery, params)
if err != nil {
s.logger.Warn("Failed to search tracks", zap.Error(err))
} else {
result.Tracks = tracks
}
}
// Search users
if searchUsers && params.Query != "" {
users, err := s.searchUsers(ctx, searchQuery, params)
if err != nil {
s.logger.Warn("Failed to search users", zap.Error(err))
} else {
result.Users = users
}
}
// Search playlists
if searchPlaylists && params.Query != "" {
playlists, err := s.searchPlaylists(ctx, searchQuery, params)
if err != nil {
s.logger.Warn("Failed to search playlists", zap.Error(err))
} else {
result.Playlists = playlists
}
}
// Calculate total
result.Total = int64(len(result.Tracks) + len(result.Users) + len(result.Playlists))
return result, nil
}
// prepareSearchQuery converts a search query to PostgreSQL tsquery format
func (s *FullTextSearchService) prepareSearchQuery(query string) string {
if query == "" {
return ""
}
// Remove special characters and split into words
words := strings.Fields(strings.ToLower(query))
// Build tsquery: word1 & word2 & word3 (AND search)
// Use prefix matching with :* for better results
tsquery := strings.Join(words, " & ")
// Add prefix matching to last word if it's not too short
if len(words) > 0 {
lastWord := words[len(words)-1]
if len(lastWord) > 2 {
tsquery = strings.TrimSuffix(tsquery, lastWord) + lastWord + ":*"
}
}
return tsquery
}
// searchTracks performs full-text search on tracks using PostgreSQL tsvector
func (s *FullTextSearchService) searchTracks(ctx context.Context, searchQuery string, params SearchParams) ([]TrackSearchResult, error) {
offset := (params.Page - 1) * params.Limit
// Build tsvector from title and artist
// Use the GIN indexes created in migration 048_search_indexes.sql
var results []struct {
models.Track
Relevance float64
}
query := s.db.WithContext(ctx).
Model(&models.Track{}).
Select(`
tracks.*,
ts_rank_cd(
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(artist, '')), 'B'),
plainto_tsquery('english', ?)
) as relevance
`, searchQuery).
Where("is_public = ? AND deleted_at IS NULL", true).
Where(`
to_tsvector('english', COALESCE(title, '')) ||
to_tsvector('english', COALESCE(artist, '')) @@ plainto_tsquery('english', ?)
`, searchQuery).
Having("relevance >= ?", params.MinScore).
Order("relevance DESC, created_at DESC").
Offset(offset).
Limit(params.Limit)
if err := query.Scan(&results).Error; err != nil {
return nil, fmt.Errorf("failed to search tracks: %w", err)
}
trackResults := make([]TrackSearchResult, len(results))
for i, r := range results {
trackResults[i] = TrackSearchResult{
Track: r.Track,
Relevance: r.Relevance,
}
}
return trackResults, nil
}
// searchUsers performs full-text search on users using PostgreSQL tsvector
func (s *FullTextSearchService) searchUsers(ctx context.Context, searchQuery string, params SearchParams) ([]UserSearchResult, error) {
offset := (params.Page - 1) * params.Limit
var results []struct {
models.User
Relevance float64
}
query := s.db.WithContext(ctx).
Model(&models.User{}).
Select(`
users.*,
ts_rank_cd(
setweight(to_tsvector('english', COALESCE(username, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(first_name, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(last_name, '')), 'B'),
plainto_tsquery('english', ?)
) as relevance
`, searchQuery).
Where("deleted_at IS NULL").
Where(`
to_tsvector('english', COALESCE(username, '')) ||
to_tsvector('english', COALESCE(first_name, '')) ||
to_tsvector('english', COALESCE(last_name, '')) @@ plainto_tsquery('english', ?)
`, searchQuery).
Having("relevance >= ?", params.MinScore).
Order("relevance DESC, created_at DESC").
Offset(offset).
Limit(params.Limit)
if err := query.Scan(&results).Error; err != nil {
return nil, fmt.Errorf("failed to search users: %w", err)
}
userResults := make([]UserSearchResult, len(results))
for i, r := range results {
// Exclude password hash
r.User.PasswordHash = ""
userResults[i] = UserSearchResult{
User: r.User,
Relevance: r.Relevance,
}
}
return userResults, nil
}
// searchPlaylists performs full-text search on playlists using PostgreSQL tsvector
func (s *FullTextSearchService) searchPlaylists(ctx context.Context, searchQuery string, params SearchParams) ([]PlaylistSearchResult, error) {
offset := (params.Page - 1) * params.Limit
var results []struct {
models.Playlist
Relevance float64
}
query := s.db.WithContext(ctx).
Model(&models.Playlist{}).
Select(`
playlists.*,
ts_rank_cd(
setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'B'),
plainto_tsquery('english', ?)
) as relevance
`, searchQuery).
Where("is_public = ? AND deleted_at IS NULL", true).
Where(`
to_tsvector('english', COALESCE(name, '')) ||
to_tsvector('english', COALESCE(description, '')) @@ plainto_tsquery('english', ?)
`, searchQuery).
Having("relevance >= ?", params.MinScore).
Order("relevance DESC, created_at DESC").
Offset(offset).
Limit(params.Limit)
if err := query.Scan(&results).Error; err != nil {
return nil, fmt.Errorf("failed to search playlists: %w", err)
}
playlistResults := make([]PlaylistSearchResult, len(results))
for i, r := range results {
playlistResults[i] = PlaylistSearchResult{
Playlist: r.Playlist,
Relevance: r.Relevance,
}
}
return playlistResults, nil
}
// SearchTracksOnly performs full-text search on tracks only
func (s *FullTextSearchService) SearchTracksOnly(ctx context.Context, query string, page, limit int) ([]TrackSearchResult, int64, error) {
params := SearchParams{
Query: query,
Page: page,
Limit: limit,
Types: []string{"track"},
}
tracks, err := s.searchTracks(ctx, s.prepareSearchQuery(query), params)
if err != nil {
return nil, 0, err
}
// Get total count
var total int64
countQuery := s.db.WithContext(ctx).
Model(&models.Track{}).
Where("is_public = ? AND deleted_at IS NULL", true).
Where(`
to_tsvector('english', COALESCE(title, '')) ||
to_tsvector('english', COALESCE(artist, '')) @@ plainto_tsquery('english', ?)
`, s.prepareSearchQuery(query))
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count tracks: %w", err)
}
return tracks, total, nil
}
// SearchUsersOnly performs full-text search on users only
func (s *FullTextSearchService) SearchUsersOnly(ctx context.Context, query string, page, limit int) ([]UserSearchResult, int64, error) {
params := SearchParams{
Query: query,
Page: page,
Limit: limit,
Types: []string{"user"},
}
users, err := s.searchUsers(ctx, s.prepareSearchQuery(query), params)
if err != nil {
return nil, 0, err
}
// Get total count
var total int64
countQuery := s.db.WithContext(ctx).
Model(&models.User{}).
Where("deleted_at IS NULL").
Where(`
to_tsvector('english', COALESCE(username, '')) ||
to_tsvector('english', COALESCE(first_name, '')) ||
to_tsvector('english', COALESCE(last_name, '')) @@ plainto_tsquery('english', ?)
`, s.prepareSearchQuery(query))
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count users: %w", err)
}
return users, total, nil
}
// SearchPlaylistsOnly performs full-text search on playlists only
func (s *FullTextSearchService) SearchPlaylistsOnly(ctx context.Context, query string, page, limit int) ([]PlaylistSearchResult, int64, error) {
params := SearchParams{
Query: query,
Page: page,
Limit: limit,
Types: []string{"playlist"},
}
playlists, err := s.searchPlaylists(ctx, s.prepareSearchQuery(query), params)
if err != nil {
return nil, 0, err
}
// Get total count
var total int64
countQuery := s.db.WithContext(ctx).
Model(&models.Playlist{}).
Where("is_public = ? AND deleted_at IS NULL", true).
Where(`
to_tsvector('english', COALESCE(name, '')) ||
to_tsvector('english', COALESCE(description, '')) @@ plainto_tsquery('english', ?)
`, s.prepareSearchQuery(query))
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count playlists: %w", err)
}
return playlists, total, nil
}
// containsString checks if a slice contains a string
func containsString(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}