mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
feat(search): add project search support to Cmd+K search
Projects are now searchable alongside issues in the Cmd+K search dialog. Results are grouped by type (Projects / Issues) with project icon, status, and description snippet highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
@@ -201,6 +202,14 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { MemberRole } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
|
||||
// Issue API
|
||||
export interface CreateIssueRequest {
|
||||
@@ -57,6 +58,16 @@ export interface SearchIssuesResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchProjectResult extends Project {
|
||||
match_source: "title" | "description";
|
||||
matched_snippet?: string;
|
||||
}
|
||||
|
||||
export interface SearchProjectsResponse {
|
||||
projects: SearchProjectResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Clock, Loader2, MessageSquare, SearchIcon } from "lucide-react";
|
||||
import { Clock, FolderKanban, Loader2, MessageSquare, SearchIcon } from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type { SearchIssueResult } from "@multica/core/types";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
import type { ProjectStatus } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -54,17 +56,24 @@ function HighlightText({ text, query }: { text: string; query: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
issues: SearchIssueResult[];
|
||||
projects: SearchProjectResult[];
|
||||
}
|
||||
|
||||
export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentIssues = useRecentIssuesStore((s) => s.items);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchIssueResult[]>([]);
|
||||
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const hasResults = results.issues.length > 0 || results.projects.length > 0;
|
||||
|
||||
// Global Cmd+K / Ctrl+K shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -103,7 +112,7 @@ export function SearchCommand() {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setResults({ issues: [], projects: [] });
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -113,7 +122,7 @@ export function SearchCommand() {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setResults({ issues: [], projects: [] });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -123,14 +132,25 @@ export function SearchCommand() {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await api.searchIssues({
|
||||
q: q.trim(),
|
||||
limit: 20,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const [issueRes, projectRes] = await Promise.all([
|
||||
api.searchIssues({
|
||||
q: q.trim(),
|
||||
limit: 20,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
api.searchProjects({
|
||||
q: q.trim(),
|
||||
limit: 10,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
]);
|
||||
if (!controller.signal.aborted) {
|
||||
setResults(res.issues);
|
||||
setResults({
|
||||
issues: issueRes.issues,
|
||||
projects: projectRes.projects,
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
@@ -150,11 +170,15 @@ export function SearchCommand() {
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(issueId: string) => {
|
||||
(value: string) => {
|
||||
setOpen(false);
|
||||
push(`/issues/${issueId}`);
|
||||
if (value.startsWith("project:")) {
|
||||
push(`/projects/${value.slice(8)}`);
|
||||
} else {
|
||||
push(`/issues/${value}`);
|
||||
}
|
||||
},
|
||||
[push],
|
||||
[push, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -164,9 +188,9 @@ export function SearchCommand() {
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search Issues</DialogTitle>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search issues by title, description, or comments
|
||||
Search issues and projects by title or description
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CommandPrimitive
|
||||
@@ -195,15 +219,59 @@ export function SearchCommand() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim() && results.length === 0 && (
|
||||
{!isLoading && query.trim() && !hasResults && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No issues found.
|
||||
No results found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
{results.map((issue) => (
|
||||
{!isLoading && results.projects.length > 0 && (
|
||||
<CommandPrimitive.Group
|
||||
heading="Projects"
|
||||
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
>
|
||||
{results.projects.map((project) => (
|
||||
<CommandPrimitive.Item
|
||||
key={`project:${project.id}`}
|
||||
value={`project:${project.id}`}
|
||||
onSelect={handleSelect}
|
||||
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="size-4 shrink-0 text-center text-sm leading-4">
|
||||
{project.icon || <FolderKanban className="size-4 text-muted-foreground" />}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
<HighlightText text={project.title} query={query} />
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-xs shrink-0 ${PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.color ?? "text-muted-foreground"}`}
|
||||
>
|
||||
{PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.label ?? project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.match_source === "description" &&
|
||||
project.matched_snippet && (
|
||||
<div className="flex items-start gap-2 pl-[26px]">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<HighlightText
|
||||
text={project.matched_snippet}
|
||||
query={query}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{!isLoading && results.issues.length > 0 && (
|
||||
<CommandPrimitive.Group
|
||||
heading="Issues"
|
||||
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
>
|
||||
{results.issues.map((issue) => (
|
||||
<CommandPrimitive.Item
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
@@ -277,7 +345,7 @@ export function SearchCommand() {
|
||||
|
||||
{!isLoading && !query.trim() && recentIssues.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<span>Type to search issues...</span>
|
||||
<span>Type to search issues and projects...</span>
|
||||
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">⌘K</kbd> to open this anytime</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -196,6 +196,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
|
||||
// Projects
|
||||
r.Route("/api/projects", func(r chi.Router) {
|
||||
r.Get("/search", h.SearchProjects)
|
||||
r.Get("/", h.ListProjects)
|
||||
r.Post("/", h.CreateProject)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
|
||||
@@ -2,8 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -273,3 +277,270 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// SearchProjectResponse extends ProjectResponse with search metadata.
|
||||
type SearchProjectResponse struct {
|
||||
ProjectResponse
|
||||
MatchSource string `json:"match_source"`
|
||||
MatchedSnippet *string `json:"matched_snippet,omitempty"`
|
||||
}
|
||||
|
||||
// buildProjectSearchQuery builds a dynamic SQL query for project search.
|
||||
func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool) (string, []any) {
|
||||
phrase = strings.ToLower(phrase)
|
||||
for i, t := range terms {
|
||||
terms[i] = strings.ToLower(t)
|
||||
}
|
||||
|
||||
argIdx := 1
|
||||
args := []any{}
|
||||
nextArg := func(val any) string {
|
||||
args = append(args, val)
|
||||
s := fmt.Sprintf("$%d", argIdx)
|
||||
argIdx++
|
||||
return s
|
||||
}
|
||||
|
||||
escapedPhrase := escapeLike(phrase)
|
||||
phraseParam := nextArg(escapedPhrase)
|
||||
phraseContains := "'%' || " + phraseParam + " || '%'"
|
||||
phraseStartsWith := phraseParam + " || '%'"
|
||||
|
||||
wsParam := nextArg(nil) // workspace_id placeholder
|
||||
|
||||
var termParams []string
|
||||
if len(terms) > 1 {
|
||||
for _, t := range terms {
|
||||
et := escapeLike(t)
|
||||
termParams = append(termParams, nextArg(et))
|
||||
}
|
||||
}
|
||||
|
||||
// --- WHERE clause ---
|
||||
var whereParts []string
|
||||
|
||||
// Full phrase match: title or description
|
||||
phraseMatch := fmt.Sprintf(
|
||||
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
|
||||
phraseContains, phraseContains,
|
||||
)
|
||||
whereParts = append(whereParts, phraseMatch)
|
||||
|
||||
// Multi-word AND match
|
||||
if len(termParams) > 1 {
|
||||
var termConditions []string
|
||||
for _, tp := range termParams {
|
||||
tc := "'%' || " + tp + " || '%'"
|
||||
termConditions = append(termConditions, fmt.Sprintf(
|
||||
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
|
||||
tc, tc,
|
||||
))
|
||||
}
|
||||
whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")")
|
||||
}
|
||||
|
||||
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
|
||||
|
||||
if !includeClosed {
|
||||
whereClause += " AND p.status NOT IN ('completed', 'cancelled')"
|
||||
}
|
||||
|
||||
// --- ORDER BY ranking ---
|
||||
var rankCases []string
|
||||
|
||||
// Tier 0: Exact title match
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) = %s THEN 0", phraseParam))
|
||||
|
||||
// Tier 1: Title starts with phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 1", phraseStartsWith))
|
||||
|
||||
// Tier 2: Title contains phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 2", phraseContains))
|
||||
|
||||
// Tier 3: Title matches all words (multi-word only)
|
||||
if len(termParams) > 1 {
|
||||
var titleTerms []string
|
||||
for _, tp := range termParams {
|
||||
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 3", strings.Join(titleTerms, " AND ")))
|
||||
}
|
||||
|
||||
// Tier 4: Description contains phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(COALESCE(p.description, '')) LIKE %s THEN 4", phraseContains))
|
||||
|
||||
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 5 END"
|
||||
|
||||
// --- match_source expression ---
|
||||
matchSourceExpr := fmt.Sprintf(`CASE
|
||||
WHEN LOWER(p.title) LIKE %s THEN 'title'
|
||||
ELSE 'description'
|
||||
END`, phraseContains)
|
||||
|
||||
if len(termParams) > 1 {
|
||||
var titleTerms []string
|
||||
for _, tp := range termParams {
|
||||
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
matchSourceExpr = fmt.Sprintf(`CASE
|
||||
WHEN LOWER(p.title) LIKE %s THEN 'title'
|
||||
WHEN (%s) THEN 'title'
|
||||
ELSE 'description'
|
||||
END`,
|
||||
phraseContains, strings.Join(titleTerms, " AND "),
|
||||
)
|
||||
}
|
||||
|
||||
limitParam := nextArg(nil)
|
||||
offsetParam := nextArg(nil)
|
||||
|
||||
query := fmt.Sprintf(`SELECT p.id, p.workspace_id, p.title, p.description, p.icon,
|
||||
p.status, p.priority, p.lead_type, p.lead_id,
|
||||
p.created_at, p.updated_at,
|
||||
COUNT(*) OVER() AS total_count,
|
||||
%s AS match_source
|
||||
FROM project p
|
||||
WHERE p.workspace_id = %s AND %s
|
||||
ORDER BY %s, p.updated_at DESC
|
||||
LIMIT %s OFFSET %s`,
|
||||
matchSourceExpr,
|
||||
wsParam,
|
||||
whereClause,
|
||||
rankExpr,
|
||||
limitParam,
|
||||
offsetParam,
|
||||
)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "" {
|
||||
writeError(w, http.StatusBadRequest, "q parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
offset := 0
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
includeClosed := r.URL.Query().Get("include_closed") == "true"
|
||||
|
||||
wsUUID := parseUUID(workspaceID)
|
||||
terms := splitSearchTerms(q)
|
||||
|
||||
sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed)
|
||||
args[1] = wsUUID
|
||||
args[len(args)-2] = limit
|
||||
args[len(args)-1] = offset
|
||||
|
||||
rows, err := h.DB.Query(ctx, sqlQuery, args...)
|
||||
if err != nil {
|
||||
slog.Warn("search projects failed", "error", err, "workspace_id", workspaceID, "query", q)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type projectSearchRow struct {
|
||||
project db.Project
|
||||
totalCount int64
|
||||
matchSource string
|
||||
}
|
||||
|
||||
var results []projectSearchRow
|
||||
for rows.Next() {
|
||||
var row projectSearchRow
|
||||
if err := rows.Scan(
|
||||
&row.project.ID,
|
||||
&row.project.WorkspaceID,
|
||||
&row.project.Title,
|
||||
&row.project.Description,
|
||||
&row.project.Icon,
|
||||
&row.project.Status,
|
||||
&row.project.Priority,
|
||||
&row.project.LeadType,
|
||||
&row.project.LeadID,
|
||||
&row.project.CreatedAt,
|
||||
&row.project.UpdatedAt,
|
||||
&row.totalCount,
|
||||
&row.matchSource,
|
||||
); err != nil {
|
||||
slog.Warn("search projects scan failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
||||
return
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("search projects rows error", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
if len(results) > 0 {
|
||||
total = results[0].totalCount
|
||||
}
|
||||
|
||||
// Batch-fetch issue stats
|
||||
statsMap := make(map[string]db.GetProjectIssueStatsRow)
|
||||
if len(results) > 0 {
|
||||
projectIDs := make([]pgtype.UUID, len(results))
|
||||
for i, r := range results {
|
||||
projectIDs[i] = r.project.ID
|
||||
}
|
||||
stats, err := h.Queries.GetProjectIssueStats(ctx, projectIDs)
|
||||
if err == nil {
|
||||
for _, s := range stats {
|
||||
statsMap[uuidToString(s.ProjectID)] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := make([]SearchProjectResponse, len(results))
|
||||
for i, row := range results {
|
||||
pr := projectToResponse(row.project)
|
||||
if s, ok := statsMap[pr.ID]; ok {
|
||||
pr.IssueCount = s.TotalCount
|
||||
pr.DoneCount = s.DoneCount
|
||||
}
|
||||
spr := SearchProjectResponse{
|
||||
ProjectResponse: pr,
|
||||
MatchSource: row.matchSource,
|
||||
}
|
||||
if row.matchSource == "description" {
|
||||
desc := ""
|
||||
if row.project.Description.Valid {
|
||||
desc = row.project.Description.String
|
||||
}
|
||||
if desc != "" {
|
||||
snippet := extractSnippet(desc, q)
|
||||
spr.MatchedSnippet = &snippet
|
||||
}
|
||||
}
|
||||
resp[i] = spr
|
||||
}
|
||||
|
||||
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"projects": resp,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,3 +93,54 @@ func TestBuildSearchQuery_SpecialChars(t *testing.T) {
|
||||
t.Errorf("expected %% to be escaped in phrase arg, got %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Project search tests ---
|
||||
|
||||
func TestBuildProjectSearchQuery_SingleTerm(t *testing.T) {
|
||||
query, args := buildProjectSearchQuery("Hello", []string{"Hello"}, false)
|
||||
|
||||
if args[0] != "hello" {
|
||||
t.Errorf("expected phrase arg to be lowercased, got %q", args[0])
|
||||
}
|
||||
|
||||
if strings.Contains(query, "ILIKE") {
|
||||
t.Error("query should not contain ILIKE")
|
||||
}
|
||||
if !strings.Contains(query, "LOWER(p.title) LIKE") {
|
||||
t.Error("query should contain LOWER(p.title) LIKE")
|
||||
}
|
||||
if !strings.Contains(query, "LOWER(COALESCE(p.description, '')) LIKE") {
|
||||
t.Error("query should contain LOWER(COALESCE(p.description, '')) LIKE")
|
||||
}
|
||||
|
||||
// Should exclude completed/cancelled by default.
|
||||
if !strings.Contains(query, "NOT IN ('completed', 'cancelled')") {
|
||||
t.Error("query should exclude completed/cancelled when includeClosed=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectSearchQuery_MultiTerm(t *testing.T) {
|
||||
query, args := buildProjectSearchQuery("Foo Bar", []string{"Foo", "Bar"}, false)
|
||||
|
||||
if args[0] != "foo bar" {
|
||||
t.Errorf("expected phrase arg lowercased, got %q", args[0])
|
||||
}
|
||||
if args[2] != "foo" {
|
||||
t.Errorf("expected first term arg lowercased, got %q", args[2])
|
||||
}
|
||||
if args[3] != "bar" {
|
||||
t.Errorf("expected second term arg lowercased, got %q", args[3])
|
||||
}
|
||||
|
||||
if !strings.Contains(query, " AND ") {
|
||||
t.Error("multi-word query should contain AND conditions for per-term matching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectSearchQuery_IncludeClosed(t *testing.T) {
|
||||
query, _ := buildProjectSearchQuery("test", []string{"test"}, true)
|
||||
|
||||
if strings.Contains(query, "NOT IN ('completed', 'cancelled')") {
|
||||
t.Error("query should not exclude completed/cancelled when includeClosed=true")
|
||||
}
|
||||
}
|
||||
|
||||
2
server/migrations/039_project_search_index.down.sql
Normal file
2
server/migrations/039_project_search_index.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_project_title_bigm;
|
||||
DROP INDEX IF EXISTS idx_project_description_bigm;
|
||||
9
server/migrations/039_project_search_index.up.sql
Normal file
9
server/migrations/039_project_search_index.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Add GIN bigram indexes on project title and description for search.
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE INDEX idx_project_title_bigm ON project USING gin (LOWER(title) gin_bigm_ops);
|
||||
CREATE INDEX idx_project_description_bigm ON project USING gin (LOWER(COALESCE(description, '')) gin_bigm_ops);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'skipping bigram indexes on project (pg_bigm not installed)';
|
||||
END
|
||||
$$;
|
||||
Reference in New Issue
Block a user