Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/adapters/accountrepository/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,72 @@ func (p *Postgres) GetAccountByUsername(ctx context.Context, username string) (d
QueriedAt: entry.QueriedAt,
}, nil
}

func (p *Postgres) SearchUsername(ctx context.Context, searchTerm string, top int) ([]string, error) {
ctx, span := p.tracer.Start(ctx, "Postgres.SearchUsername")
defer span.End()

if top < 1 || top > 100 {
err := fmt.Errorf("top must be between 1 and 100")
reporting.Report(ctx, err, map[string]string{
"top": fmt.Sprintf("%d", top),
})
return nil, err
}

// Use a transaction to set search_path to include public schema for pg_trgm functions
txx, err := p.db.BeginTxx(ctx, nil)
if err != nil {
err := fmt.Errorf("failed to start transaction: %w", err)
reporting.Report(ctx, err)
return nil, err
}
defer txx.Rollback()

_, err = txx.ExecContext(ctx, fmt.Sprintf("SET LOCAL search_path TO %s, public", pq.QuoteIdentifier(p.schema)))
if err != nil {
err := fmt.Errorf("failed to set search path: %w", err)
reporting.Report(ctx, err, map[string]string{
"schema": p.schema,
})
return nil, err
}

// Set similarity threshold to a reasonable value for username search
// Lower threshold allows more fuzzy matching (default is 0.3)
_, err = txx.ExecContext(ctx, "SET LOCAL pg_trgm.similarity_threshold = 0.2")
if err != nil {
err := fmt.Errorf("failed to set similarity threshold: %w", err)
reporting.Report(ctx, err)
return nil, err
}

// Initialize to empty slice instead of nil for consistent JSON marshaling ([] vs null)
uuids := []string{}
err = txx.SelectContext(ctx, &uuids, `
SELECT player_uuid
FROM usernames
WHERE username % $1
ORDER BY similarity(username, $1) DESC, queried_at DESC
LIMIT $2`,
searchTerm,
top,
)
if err != nil {
err := fmt.Errorf("failed to search usernames: %w", err)
reporting.Report(ctx, err, map[string]string{
"searchTerm": searchTerm,
"top": fmt.Sprintf("%d", top),
})
return nil, err
}

err = txx.Commit()
if err != nil {
err := fmt.Errorf("failed to commit transaction: %w", err)
reporting.Report(ctx, err)
return nil, err
}

return uuids, nil
}
140 changes: 140 additions & 0 deletions internal/adapters/accountrepository/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,4 +731,144 @@ func TestPostgres(t *testing.T) {
require.WithinDuration(t, now.Add(-2*time.Hour), account.QueriedAt, 1*time.Millisecond)
})
})

t.Run("SearchUsername", func(t *testing.T) {
t.Parallel()

tests := []struct {
name string
accounts []domain.Account
searchTerm string
top int
expected []string
}{
{
name: "exact match",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "testuser", QueriedAt: now},
{UUID: makeUUID(2), Username: "anotheruser", QueriedAt: now},
},
searchTerm: "testuser",
top: 10,
expected: []string{makeUUID(1)},
},
{
name: "partial match with similarity",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "testuser", QueriedAt: now},
{UUID: makeUUID(2), Username: "testing", QueriedAt: now},
{UUID: makeUUID(3), Username: "tests", QueriedAt: now},
{UUID: makeUUID(4), Username: "unrelated", QueriedAt: now},
},
searchTerm: "test",
top: 10,
// Based on trigram similarity: "tests" is most similar, then "testing", then "testuser"
expected: []string{makeUUID(3), makeUUID(2), makeUUID(1)},
},
{
name: "limit results with top",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "testuser1", QueriedAt: now},
{UUID: makeUUID(2), Username: "testuser2", QueriedAt: now},
{UUID: makeUUID(3), Username: "testuser3", QueriedAt: now},
},
searchTerm: "test",
top: 2,
expected: []string{makeUUID(1), makeUUID(2)},
},
{
name: "secondary sort by queried_at",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "user1", QueriedAt: now.Add(-2 * time.Hour)},
{UUID: makeUUID(2), Username: "user2", QueriedAt: now.Add(-1 * time.Hour)},
{UUID: makeUUID(3), Username: "user3", QueriedAt: now},
},
searchTerm: "user",
top: 10,
// All three have similar trigram similarity, sorted by queried_at DESC
expected: []string{makeUUID(3), makeUUID(2), makeUUID(1)},
},
{
name: "case insensitive search",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "TestUser", QueriedAt: now},
{UUID: makeUUID(2), Username: "testuser", QueriedAt: now},
},
searchTerm: "TESTUSER",
top: 10,
// Only UUID 2 remains because storing "testuser" deletes "TestUser" (case-insensitive)
expected: []string{makeUUID(2)},
},
{
name: "no matches below threshold",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "apple", QueriedAt: now},
{UUID: makeUUID(2), Username: "banana", QueriedAt: now},
},
searchTerm: "xyz",
top: 10,
expected: []string{},
},
{
name: "typo tolerance",
accounts: []domain.Account{
{UUID: makeUUID(1), Username: "player123", QueriedAt: now},
{UUID: makeUUID(2), Username: "plaeyr123", QueriedAt: now},
{UUID: makeUUID(3), Username: "different", QueriedAt: now},
},
searchTerm: "player",
top: 10,
expected: []string{makeUUID(1), makeUUID(2)},
},
{
name: "empty database",
accounts: []domain.Account{},
searchTerm: "anything",
top: 10,
expected: []string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

schemaName := fmt.Sprintf("search_username_%s", tt.name)
p := newPostgres(t, db, schemaName)

// Store accounts
for _, account := range tt.accounts {
err := p.StoreAccount(ctx, account)
require.NoError(t, err)
}

// Execute search
results, err := p.SearchUsername(ctx, tt.searchTerm, tt.top)
require.NoError(t, err)

// Verify results
require.Equal(t, tt.expected, results, "Search results do not match expected UUIDs")
})
}

t.Run("invalid top value too low", func(t *testing.T) {
t.Parallel()

p := newPostgres(t, db, "search_username_invalid_top_low")

_, err := p.SearchUsername(ctx, "test", 0)
require.Error(t, err)
require.Contains(t, err.Error(), "top must be between 1 and 100")
})

t.Run("invalid top value too high", func(t *testing.T) {
t.Parallel()

p := newPostgres(t, db, "search_username_invalid_top_high")

_, err := p.SearchUsername(ctx, "test", 101)
require.Error(t, err)
require.Contains(t, err.Error(), "top must be between 1 and 100")
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_usernames_username_trgm;
-- Note: Not dropping pg_trgm extension as it may be used by other schemas
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Create GIN index on username for fast trigram similarity searches
-- Note: pg_trgm extension is created by the migrator before running migrations
CREATE INDEX IF NOT EXISTS idx_usernames_username_trgm ON usernames USING gin (username gin_trgm_ops);
15 changes: 14 additions & 1 deletion internal/adapters/database/migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ func (m *migrator) Migrate(ctx context.Context, schemaName string) error {
}

func (m *migrator) migrate(ctx context.Context, schemaName string) error {
// Create pg_trgm extension if it doesn't exist (database-wide, not schema-specific)
// This must be done in a separate connection that is committed before migrations start
extensionConn, err := m.db.Conn(ctx)
if err != nil {
return fmt.Errorf("migrate: failed to connect for extension creation: %w", err)
}
_, err = extensionConn.ExecContext(ctx, "CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public")
extensionConn.Close() // Close connection to commit the extension creation
if err != nil {
return fmt.Errorf("migrate: failed to create pg_trgm extension: %w", err)
}

conn, err := m.db.Conn(ctx)
if err != nil {
return fmt.Errorf("migrate: failed to connect to db: %w", err)
Expand All @@ -46,7 +58,8 @@ func (m *migrator) migrate(ctx context.Context, schemaName string) error {
return fmt.Errorf("migrate: failed to create schema: %w", err)
}

_, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName)))
// Set search_path to include both the schema and public (where pg_trgm is installed)
_, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s, public", pq.QuoteIdentifier(schemaName)))
if err != nil {
return fmt.Errorf("migrate: failed to set search path: %w", err)
}
Expand Down
27 changes: 27 additions & 0 deletions internal/app/search_username.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app

import (
"context"
"fmt"
"time"
)

type SearchUsername func(ctx context.Context, searchTerm string, top int) ([]string, error)

type usernameSearcher interface {
SearchUsername(ctx context.Context, searchTerm string, top int) ([]string, error)
}

func BuildSearchUsername(repository usernameSearcher) SearchUsername {
return func(ctx context.Context, searchTerm string, top int) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

uuids, err := repository.SearchUsername(ctx, searchTerm, top)
if err != nil {
return nil, fmt.Errorf("failed to search username: %w", err)
}

return uuids, nil
}
}
Loading
Loading