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
5 changes: 5 additions & 0 deletions .github/workflows/test-and-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ jobs:
PGPASSWORD: postgres
run: psql -h localhost -U postgres -c 'CREATE DATABASE flashlight;'

- name: Enable pg_trgm extension
env:
PGPASSWORD: postgres
run: psql -h localhost -U postgres -d flashlight -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'

- name: Run tests
run: go test ./...

Expand Down
68 changes: 68 additions & 0 deletions internal/adapters/accountrepository/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,71 @@ func (p *Postgres) GetAccountByUsername(ctx context.Context, username string) (d
QueriedAt: entry.QueriedAt,
}, nil
}

const MaxSearchUsernameResults = 100

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

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

type result struct {
PlayerUUID string `db:"player_uuid"`
}

// Query uses trigram similarity for fuzzy username matching
// NOTE: For optimal performance in production, create a GIN index:
// CREATE INDEX idx_username_queries_username_trgm
// ON username_queries USING gin (username gin_trgm_ops);
// The WHERE clause filters by similarity > 0 to exclude non-matches early.
// Results are grouped by player_uuid to get unique UUIDs with their best match.
var results []result
err := p.db.SelectContext(ctx, &results, fmt.Sprintf(`
WITH ranked_matches AS (
SELECT
player_uuid,
MAX(similarity(username, $1)) as max_similarity,
MAX(last_queried_at) as max_last_queried_at
FROM %s.username_queries
WHERE similarity(username, $1) > 0
GROUP BY player_uuid
)
SELECT player_uuid
FROM ranked_matches
ORDER BY max_similarity DESC, max_last_queried_at DESC
LIMIT $2`,
pq.QuoteIdentifier(p.schema),
),
search,
top,
)
if err != nil {
err := fmt.Errorf("failed to search username: %w", err)
reporting.Report(ctx, err, map[string]string{
"search": search,
"top": fmt.Sprintf("%d", top),
})
return nil, err
}

uuids := make([]string, 0, len(results))
for _, r := range results {
if !strutils.UUIDIsNormalized(r.PlayerUUID) {
err := fmt.Errorf("uuid is not normalized")
reporting.Report(ctx, err, map[string]string{
"uuid": r.PlayerUUID,
})
return nil, err
}
uuids = append(uuids, r.PlayerUUID)
}

return uuids, nil
}
186 changes: 186 additions & 0 deletions internal/adapters/accountrepository/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ func TestPostgres(t *testing.T) {
db, err := database.NewPostgresDatabase(database.LOCAL_CONNECTION_STRING)
require.NoError(t, err)

// Enable pg_trgm extension at database level once for all tests
// This is required for the SearchUsername functionality
_, err = db.ExecContext(ctx, "CREATE EXTENSION IF NOT EXISTS pg_trgm")
require.NoError(t, err, "pg_trgm extension is required for SearchUsername tests. "+
"Ensure postgresql-contrib is installed and the extension is available.")

now := time.Now()

t.Run("Store/RemoveUsername", func(t *testing.T) {
Expand Down Expand Up @@ -731,4 +737,184 @@ 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()

setupTestData := func(t *testing.T, p *Postgres, entries []domain.Account) {
t.Helper()
for _, entry := range entries {
err := p.StoreAccount(ctx, entry)
require.NoError(t, err)
}
}

testCases := []struct {
name string
dbEntries []domain.Account
searchTerm string
top int
expectedUUIDs []string
expectError bool
errorSubstring string
}{
{
name: "exact match returns single result",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Technoblade", QueriedAt: now},
{UUID: makeUUID(2), Username: "Dream", QueriedAt: now},
{UUID: makeUUID(3), Username: "Sapnap", QueriedAt: now},
},
searchTerm: "Dream",
top: 10,
expectedUUIDs: []string{makeUUID(2)},
},
{
name: "partial match returns similar results",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Technoblade", QueriedAt: now},
{UUID: makeUUID(2), Username: "TechnoFan", QueriedAt: now},
{UUID: makeUUID(3), Username: "Technology", QueriedAt: now},
{UUID: makeUUID(4), Username: "Dream", QueriedAt: now},
},
searchTerm: "Techno",
top: 10,
expectedUUIDs: []string{makeUUID(1), makeUUID(2), makeUUID(3)},
},
{
name: "respects top limit",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "TestUser1", QueriedAt: now},
{UUID: makeUUID(2), Username: "TestUser2", QueriedAt: now},
{UUID: makeUUID(3), Username: "TestUser3", QueriedAt: now},
{UUID: makeUUID(4), Username: "TestUser4", QueriedAt: now},
{UUID: makeUUID(5), Username: "TestUser5", QueriedAt: now},
},
searchTerm: "TestUser",
top: 2,
// Should return only 2 results
expectedUUIDs: []string{makeUUID(1), makeUUID(2)},
},
{
name: "case insensitive search",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "TechnoBlade", QueriedAt: now},
{UUID: makeUUID(2), Username: "TECHNOBLADE", QueriedAt: now},
{UUID: makeUUID(3), Username: "technoblade", QueriedAt: now},
},
searchTerm: "techno",
top: 10,
expectedUUIDs: []string{makeUUID(1), makeUUID(2), makeUUID(3)},
},
{
name: "sorts by similarity then by last_queried_at desc",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Technoblade", QueriedAt: now.Add(-1 * time.Hour)},
{UUID: makeUUID(1), Username: "Techno", QueriedAt: now.Add(-30 * time.Minute)},
{UUID: makeUUID(2), Username: "Technology", QueriedAt: now},
},
searchTerm: "Techno",
top: 10,
// UUID 1 should be first (best match "Techno"), then UUID 1 again with older query, then UUID 2
// But DISTINCT should only return UUID 1 once, followed by UUID 2
expectedUUIDs: []string{makeUUID(1), makeUUID(2)},
},
{
name: "returns unique UUIDs when user changed names",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "OldName", QueriedAt: now.Add(-2 * time.Hour)},
{UUID: makeUUID(1), Username: "NewName", QueriedAt: now},
{UUID: makeUUID(2), Username: "OtherUser", QueriedAt: now},
},
searchTerm: "Name",
top: 10,
expectedUUIDs: []string{makeUUID(1), makeUUID(2)},
},
{
name: "empty result when no matches",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Technoblade", QueriedAt: now},
{UUID: makeUUID(2), Username: "Dream", QueriedAt: now},
},
searchTerm: "xyz123notfound",
top: 10,
expectedUUIDs: []string{},
},
{
name: "invalid top value too low",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Test", QueriedAt: now},
},
searchTerm: "Test",
top: 0,
expectError: true,
errorSubstring: "top must be between 1 and 100",
},
{
name: "invalid top value too high",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Test", QueriedAt: now},
},
searchTerm: "Test",
top: 101,
expectError: true,
errorSubstring: "top must be between 1 and 100",
},
{
name: "fuzzy matching with typos",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Technoblade", QueriedAt: now},
{UUID: makeUUID(2), Username: "TechnoBlade123", QueriedAt: now},
{UUID: makeUUID(3), Username: "NotRelated", QueriedAt: now},
},
searchTerm: "Tecnoblade",
top: 10,
// Should match Technoblade and TechnoBlade123 due to similarity
expectedUUIDs: []string{makeUUID(1), makeUUID(2)},
},
{
name: "prefers more recent queries when similarity is equal",
dbEntries: []domain.Account{
{UUID: makeUUID(1), Username: "Test", QueriedAt: now.Add(-2 * time.Hour)},
{UUID: makeUUID(2), Username: "Test", QueriedAt: now},
},
searchTerm: "Test",
top: 1,
// Should return UUID 2 because it has more recent query
expectedUUIDs: []string{makeUUID(2)},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := newPostgres(t, db, fmt.Sprintf("search_username_%s", tc.name))

setupTestData(t, p, tc.dbEntries)

results, err := p.SearchUsername(ctx, tc.searchTerm, tc.top)

if tc.expectError {
require.Error(t, err)
if tc.errorSubstring != "" {
require.Contains(t, err.Error(), tc.errorSubstring)
}
return
}

require.NoError(t, err)

// For tests where order matters (respects top limit, etc), check exact order
if tc.name == "respects top limit" || tc.name == "prefers more recent queries when similarity is equal" {
require.Equal(t, tc.expectedUUIDs, results, "UUIDs should match in exact order")
} else {
// For other tests, just check that expected UUIDs are present
require.ElementsMatch(t, tc.expectedUUIDs, results, "UUIDs should match")
}

// Verify length constraint
require.LessOrEqual(t, len(results), tc.top, "Results should not exceed top limit")
})
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_username_queries_username_trgm;

-- We don't drop the extension as it's database-wide and may be used by other schemas
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Enable pg_trgm extension for trigram similarity matching
-- The extension is installed at the database level, not per-schema
-- Using IF NOT EXISTS to handle parallel test execution gracefully
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Note: GIN index creation is skipped here due to schema access issues in migrations
-- The index can be created manually in production if needed for performance:
-- CREATE INDEX IF NOT EXISTS idx_username_queries_username_trgm
-- ON username_queries USING gin (username gin_trgm_ops);
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, search string, top int) ([]string, error)

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

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

uuids, err := searcher.SearchUsername(ctx, search, top)
if err != nil {
return nil, fmt.Errorf("could not search username: %w", err)
}

return uuids, nil
}
}
Loading