From 930eb4701303d11f0a99562d4cc663d6bddcd8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:29:58 +0800 Subject: [PATCH] Add files via upload --- internal/database/project.go | 50 ++++++++++----- internal/database/project_search_test.go | 82 ++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 internal/database/project_search_test.go diff --git a/internal/database/project.go b/internal/database/project.go index 701389a7..b5ad9cfe 100644 --- a/internal/database/project.go +++ b/internal/database/project.go @@ -111,19 +111,43 @@ func (db *DB) GetProject(id string) (*Project, error) { return &p, nil } -// CountProjects 统计项目数量。 -func (db *DB) CountProjects(status, search string) (int, error) { - query := `SELECT COUNT(*) FROM projects WHERE 1=1` - args := []interface{}{} +func projectListSearchPattern(q string) string { + q = strings.TrimSpace(q) + if q == "" { + return "" + } + var b strings.Builder + b.WriteByte('%') + for _, r := range q { + switch r { + case '%', '_', '\\': + b.WriteByte('\\') + b.WriteRune(r) + default: + b.WriteRune(r) + } + } + b.WriteByte('%') + return b.String() +} + +func appendProjectListFilters(query string, args []interface{}, status, search string) (string, []interface{}) { if s := strings.TrimSpace(status); s != "" { query += " AND status = ?" args = append(args, s) } - if q := strings.TrimSpace(search); q != "" { - pattern := "%" + q + "%" - query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)" - args = append(args, pattern, pattern) + if pattern := projectListSearchPattern(search); pattern != "" { + query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')` + args = append(args, pattern, pattern, pattern) } + return query, args +} + +// CountProjects 统计项目数量。 +func (db *DB) CountProjects(status, search string) (int, error) { + query := `SELECT COUNT(*) FROM projects WHERE 1=1` + args := []interface{}{} + query, args = appendProjectListFilters(query, args, status, search) var count int if err := db.QueryRow(query, args...).Scan(&count); err != nil { return 0, fmt.Errorf("统计项目失败: %w", err) @@ -139,15 +163,7 @@ func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at FROM projects WHERE 1=1` args := []interface{}{} - if s := strings.TrimSpace(status); s != "" { - query += " AND status = ?" - args = append(args, s) - } - if q := strings.TrimSpace(search); q != "" { - pattern := "%" + q + "%" - query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)" - args = append(args, pattern, pattern) - } + query, args = appendProjectListFilters(query, args, status, search) query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) diff --git a/internal/database/project_search_test.go b/internal/database/project_search_test.go new file mode 100644 index 00000000..62bc1111 --- /dev/null +++ b/internal/database/project_search_test.go @@ -0,0 +1,82 @@ +package database + +import ( + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestListProjectsSearchCaseInsensitive(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "projects-search.db") + db, err := NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + p1, err := db.CreateProject(&Project{Name: "Alpha Security Review", Status: "active"}) + if err != nil { + t.Fatal(err) + } + p2, err := db.CreateProject(&Project{Name: "beta-scan", Status: "active"}) + if err != nil { + t.Fatal(err) + } + if _, err := db.CreateProject(&Project{Name: "Other", Status: "archived"}); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + search string + status string + want []string + }{ + {name: "case insensitive name", search: "alpha", status: "active", want: []string{p1.ID}}, + {name: "upper query", search: "BETA", status: "active", want: []string{p2.ID}}, + {name: "search by id substring", search: p1.ID[:8], status: "", want: []string{p1.ID}}, + {name: "status filter", search: "alpha", status: "archived", want: nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + list, err := db.ListProjects(tc.status, tc.search, 50, 0) + if err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(list)) + for _, p := range list { + got = append(got, p.ID) + } + if len(got) != len(tc.want) { + t.Fatalf("got %v want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("got %v want %v", got, tc.want) + } + } + }) + } +} + +func TestProjectListSearchPatternEscapesWildcards(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "projects-like.db") + db, err := NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + p, err := db.CreateProject(&Project{Name: "100% coverage", Status: "active"}) + if err != nil { + t.Fatal(err) + } + list, err := db.ListProjects("active", "100%", 50, 0) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != p.ID { + t.Fatalf("expected exact match for literal %% query, got %#v", list) + } +}