mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
feat(search): improve ranking with ILIKE, identifier search, multi-word support (#601)
* feat(search): improve ranking with ILIKE, identifier search, multi-word support - Replace LIKE with ILIKE for case-insensitive matching - Support identifier search (e.g. "MUL-123" or bare "123") - Refine sorting tiers: number match > exact title > title starts with > title contains > all words in title > description > comment - Add status-based tiebreaker (active issues rank higher) - Support multi-word search where all terms must match somewhere - Move search query from sqlc to dynamic SQL for flexibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(search): fix parameter type error for single-word queries Only allocate per-term SQL parameters when there are multiple search terms. For single-word queries, the phrase parameter already covers the search — unused term params caused PostgreSQL error "could not determine data type of parameter $3". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,15 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -128,18 +131,20 @@ func extractSnippet(content, query string) string {
|
||||
queryRunes := []rune(strings.ToLower(query))
|
||||
|
||||
idx := -1
|
||||
for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ {
|
||||
match := true
|
||||
for j := range queryRunes {
|
||||
if lowerRunes[i+j] != queryRunes[j] {
|
||||
match = false
|
||||
if len(queryRunes) > 0 && len(lowerRunes) >= len(queryRunes) {
|
||||
for i := 0; i <= len(lowerRunes)-len(queryRunes); i++ {
|
||||
match := true
|
||||
for j := range queryRunes {
|
||||
if lowerRunes[i+j] != queryRunes[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if idx < 0 {
|
||||
@@ -174,6 +179,263 @@ func escapeLike(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// splitSearchTerms splits a query into individual search terms, filtering empty strings.
|
||||
func splitSearchTerms(q string) []string {
|
||||
fields := strings.FieldsFunc(q, func(r rune) bool {
|
||||
return unicode.IsSpace(r)
|
||||
})
|
||||
terms := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f != "" {
|
||||
terms = append(terms, f)
|
||||
}
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
// identifierNumberRe matches patterns like "MUL-123" or "ABC-45".
|
||||
var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z]+-(\d+)$`)
|
||||
|
||||
// parseQueryNumber extracts an issue number from the query if it looks like
|
||||
// an identifier (e.g. "MUL-123") or a bare number (e.g. "123").
|
||||
func parseQueryNumber(q string) (int, bool) {
|
||||
q = strings.TrimSpace(q)
|
||||
// Check for identifier pattern like "MUL-123"
|
||||
if m := identifierNumberRe.FindStringSubmatch(q); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
// Check for bare number
|
||||
if n, err := strconv.Atoi(q); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// searchResult holds a raw row from the dynamic search query.
|
||||
type searchResult struct {
|
||||
issue db.Issue
|
||||
totalCount int64
|
||||
matchSource string
|
||||
matchedCommentContent string
|
||||
}
|
||||
|
||||
// buildSearchQuery builds a dynamic SQL query for issue search.
|
||||
// It supports ILIKE matching, identifier search, multi-word search,
|
||||
// and refined ranking.
|
||||
func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool) (string, []any) {
|
||||
// Parameter index tracker
|
||||
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) // $1
|
||||
phraseContains := "'%' || " + phraseParam + " || '%'"
|
||||
phraseStartsWith := phraseParam + " || '%'"
|
||||
|
||||
wsParam := nextArg(nil) // $2 — workspace_id, will be filled by caller position
|
||||
|
||||
// Build per-term ILIKE conditions only for multi-word search.
|
||||
// For single-word queries, the phrase parameter already covers the term.
|
||||
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, description, or comment
|
||||
phraseMatch := fmt.Sprintf(
|
||||
"(i.title ILIKE %s OR COALESCE(i.description, '') ILIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.content ILIKE %s))",
|
||||
phraseContains, phraseContains, phraseContains,
|
||||
)
|
||||
whereParts = append(whereParts, phraseMatch)
|
||||
|
||||
// Multi-word AND match (each term must appear somewhere)
|
||||
if len(termParams) > 1 {
|
||||
var termConditions []string
|
||||
for _, tp := range termParams {
|
||||
tc := "'%' || " + tp + " || '%'"
|
||||
termConditions = append(termConditions, fmt.Sprintf(
|
||||
"(i.title ILIKE %s OR COALESCE(i.description, '') ILIKE %s OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.content ILIKE %s))",
|
||||
tc, tc, tc,
|
||||
))
|
||||
}
|
||||
whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")")
|
||||
}
|
||||
|
||||
// Number match
|
||||
numParam := ""
|
||||
if hasNum {
|
||||
numParam = nextArg(queryNum)
|
||||
whereParts = append(whereParts, fmt.Sprintf("i.number = %s", numParam))
|
||||
}
|
||||
|
||||
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
|
||||
|
||||
if !includeClosed {
|
||||
whereClause += " AND i.status NOT IN ('done', 'cancelled')"
|
||||
}
|
||||
|
||||
// --- ORDER BY clause ---
|
||||
// Build ranking CASE with fine-grained tiers.
|
||||
var rankCases []string
|
||||
|
||||
// Tier 0: Identifier exact match
|
||||
if hasNum {
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN i.number = %s THEN 0", numParam))
|
||||
}
|
||||
|
||||
// Tier 1: Exact title match
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(i.title) = LOWER(%s) THEN 1", phraseParam))
|
||||
|
||||
// Tier 2: Title starts with phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN i.title ILIKE %s THEN 2", phraseStartsWith))
|
||||
|
||||
// Tier 3: Title contains phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN i.title ILIKE %s THEN 3", phraseContains))
|
||||
|
||||
// Tier 4: Title matches all words (multi-word only)
|
||||
if len(termParams) > 1 {
|
||||
var titleTerms []string
|
||||
for _, tp := range termParams {
|
||||
titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 4", strings.Join(titleTerms, " AND ")))
|
||||
}
|
||||
|
||||
// Tier 5: Description contains phrase
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN COALESCE(i.description, '') ILIKE %s THEN 5", phraseContains))
|
||||
|
||||
// Tier 6: Description matches all words (multi-word only)
|
||||
if len(termParams) > 1 {
|
||||
var descTerms []string
|
||||
for _, tp := range termParams {
|
||||
descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 6", strings.Join(descTerms, " AND ")))
|
||||
}
|
||||
|
||||
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 7 END"
|
||||
|
||||
// Status priority: active issues first
|
||||
statusRank := `CASE i.status
|
||||
WHEN 'in_progress' THEN 0
|
||||
WHEN 'in_review' THEN 1
|
||||
WHEN 'todo' THEN 2
|
||||
WHEN 'blocked' THEN 3
|
||||
WHEN 'backlog' THEN 4
|
||||
WHEN 'done' THEN 5
|
||||
WHEN 'cancelled' THEN 6
|
||||
ELSE 7
|
||||
END`
|
||||
|
||||
// --- match_source expression ---
|
||||
matchSourceExpr := fmt.Sprintf(`CASE
|
||||
WHEN i.title ILIKE %s THEN 'title'
|
||||
WHEN COALESCE(i.description, '') ILIKE %s THEN 'description'
|
||||
ELSE 'comment'
|
||||
END`, phraseContains, phraseContains)
|
||||
|
||||
// For multi-word: also check if all terms match in title/description
|
||||
if len(termParams) > 1 {
|
||||
var titleTerms []string
|
||||
var descTerms []string
|
||||
for _, tp := range termParams {
|
||||
titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
matchSourceExpr = fmt.Sprintf(`CASE
|
||||
WHEN i.title ILIKE %s THEN 'title'
|
||||
WHEN (%s) THEN 'title'
|
||||
WHEN COALESCE(i.description, '') ILIKE %s THEN 'description'
|
||||
WHEN (%s) THEN 'description'
|
||||
ELSE 'comment'
|
||||
END`,
|
||||
phraseContains, strings.Join(titleTerms, " AND "),
|
||||
phraseContains, strings.Join(descTerms, " AND "),
|
||||
)
|
||||
}
|
||||
|
||||
// --- matched_comment_content subquery ---
|
||||
// Find the most recent matching comment for comment-source matches.
|
||||
commentSubquery := fmt.Sprintf(`CASE
|
||||
WHEN i.title ILIKE %s THEN ''
|
||||
WHEN COALESCE(i.description, '') ILIKE %s THEN ''
|
||||
ELSE COALESCE(
|
||||
(SELECT c.content FROM comment c
|
||||
WHERE c.issue_id = i.id AND c.content ILIKE %s
|
||||
ORDER BY c.created_at DESC LIMIT 1),
|
||||
''
|
||||
)
|
||||
END`, phraseContains, phraseContains, phraseContains)
|
||||
|
||||
// For multi-word, also find comment matching individual terms
|
||||
if len(termParams) > 1 {
|
||||
var titleTerms []string
|
||||
var descTerms []string
|
||||
var commentTerms []string
|
||||
for _, tp := range termParams {
|
||||
titleTerms = append(titleTerms, fmt.Sprintf("i.title ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
descTerms = append(descTerms, fmt.Sprintf("COALESCE(i.description, '') ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
commentTerms = append(commentTerms, fmt.Sprintf("c.content ILIKE '%s' || %s || '%s'", "%", tp, "%"))
|
||||
}
|
||||
commentSubquery = fmt.Sprintf(`CASE
|
||||
WHEN i.title ILIKE %s THEN ''
|
||||
WHEN (%s) THEN ''
|
||||
WHEN COALESCE(i.description, '') ILIKE %s THEN ''
|
||||
WHEN (%s) THEN ''
|
||||
ELSE COALESCE(
|
||||
(SELECT c.content FROM comment c
|
||||
WHERE c.issue_id = i.id AND (c.content ILIKE %s OR (%s))
|
||||
ORDER BY c.created_at DESC LIMIT 1),
|
||||
''
|
||||
)
|
||||
END`,
|
||||
phraseContains, strings.Join(titleTerms, " AND "),
|
||||
phraseContains, strings.Join(descTerms, " AND "),
|
||||
phraseContains, strings.Join(commentTerms, " AND "),
|
||||
)
|
||||
}
|
||||
|
||||
limitParam := nextArg(nil) // placeholder
|
||||
offsetParam := nextArg(nil) // placeholder
|
||||
|
||||
query := fmt.Sprintf(`SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
|
||||
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
|
||||
i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position,
|
||||
i.due_date, i.created_at, i.updated_at, i.number, i.project_id,
|
||||
COUNT(*) OVER() AS total_count,
|
||||
%s AS match_source,
|
||||
%s AS matched_comment_content
|
||||
FROM issue i
|
||||
WHERE i.workspace_id = %s AND %s
|
||||
ORDER BY %s, %s, i.updated_at DESC
|
||||
LIMIT %s OFFSET %s`,
|
||||
matchSourceExpr,
|
||||
commentSubquery,
|
||||
wsParam,
|
||||
whereClause,
|
||||
rankExpr,
|
||||
statusRank,
|
||||
limitParam,
|
||||
offsetParam,
|
||||
)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
@@ -203,38 +465,77 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
|
||||
includeClosed := r.URL.Query().Get("include_closed") == "true"
|
||||
|
||||
wsUUID := parseUUID(workspaceID)
|
||||
queryText := strToText(escapeLike(q))
|
||||
terms := splitSearchTerms(q)
|
||||
queryNum, hasNum := parseQueryNumber(q)
|
||||
|
||||
rows, err := h.Queries.SearchIssues(ctx, db.SearchIssuesParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Query: queryText,
|
||||
SearchLimit: int32(limit),
|
||||
SearchOffset: int32(offset),
|
||||
IncludeClosed: includeClosed,
|
||||
})
|
||||
sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed)
|
||||
// Fill placeholder args: $2 = workspace_id, last two = limit, offset
|
||||
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 issues failed", "error", err, "workspace_id", workspaceID, "query", q)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []searchResult
|
||||
for rows.Next() {
|
||||
var sr searchResult
|
||||
if err := rows.Scan(
|
||||
&sr.issue.ID,
|
||||
&sr.issue.WorkspaceID,
|
||||
&sr.issue.Title,
|
||||
&sr.issue.Description,
|
||||
&sr.issue.Status,
|
||||
&sr.issue.Priority,
|
||||
&sr.issue.AssigneeType,
|
||||
&sr.issue.AssigneeID,
|
||||
&sr.issue.CreatorType,
|
||||
&sr.issue.CreatorID,
|
||||
&sr.issue.ParentIssueID,
|
||||
&sr.issue.AcceptanceCriteria,
|
||||
&sr.issue.ContextRefs,
|
||||
&sr.issue.Position,
|
||||
&sr.issue.DueDate,
|
||||
&sr.issue.CreatedAt,
|
||||
&sr.issue.UpdatedAt,
|
||||
&sr.issue.Number,
|
||||
&sr.issue.ProjectID,
|
||||
&sr.totalCount,
|
||||
&sr.matchSource,
|
||||
&sr.matchedCommentContent,
|
||||
); err != nil {
|
||||
slog.Warn("search issues scan failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
||||
return
|
||||
}
|
||||
results = append(results, sr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("search issues rows error", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to search issues")
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
if len(rows) > 0 {
|
||||
total = rows[0].TotalCount
|
||||
if len(results) > 0 {
|
||||
total = results[0].totalCount
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, wsUUID)
|
||||
resp := make([]SearchIssueResponse, len(rows))
|
||||
for i, row := range rows {
|
||||
resp := make([]SearchIssueResponse, len(results))
|
||||
for i, sr := range results {
|
||||
sir := SearchIssueResponse{
|
||||
IssueResponse: issueToResponse(searchRowToIssue(row), prefix),
|
||||
MatchSource: row.MatchSource,
|
||||
IssueResponse: issueToResponse(sr.issue, prefix),
|
||||
MatchSource: sr.matchSource,
|
||||
}
|
||||
if row.MatchSource == "comment" {
|
||||
if content, ok := row.MatchedCommentContent.(string); ok && content != "" {
|
||||
snippet := extractSnippet(content, q)
|
||||
sir.MatchedSnippet = &snippet
|
||||
}
|
||||
if sr.matchSource == "comment" && sr.matchedCommentContent != "" {
|
||||
snippet := extractSnippet(sr.matchedCommentContent, q)
|
||||
sir.MatchedSnippet = &snippet
|
||||
}
|
||||
resp[i] = sir
|
||||
}
|
||||
@@ -246,30 +547,6 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func searchRowToIssue(row db.SearchIssuesRow) db.Issue {
|
||||
return db.Issue{
|
||||
ID: row.ID,
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
Status: row.Status,
|
||||
Priority: row.Priority,
|
||||
AssigneeType: row.AssigneeType,
|
||||
AssigneeID: row.AssigneeID,
|
||||
CreatorType: row.CreatorType,
|
||||
CreatorID: row.CreatorID,
|
||||
ParentIssueID: row.ParentIssueID,
|
||||
AcceptanceCriteria: row.AcceptanceCriteria,
|
||||
ContextRefs: row.ContextRefs,
|
||||
Position: row.Position,
|
||||
DueDate: row.DueDate,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Number: row.Number,
|
||||
ProjectID: row.ProjectID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
|
||||
@@ -427,127 +427,6 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const searchIssues = `-- name: SearchIssues :many
|
||||
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id,
|
||||
COUNT(*) OVER() AS total_count,
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || $1 || '%' THEN 'title'
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN 'description'
|
||||
ELSE 'comment'
|
||||
END AS match_source,
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || $1 || '%' THEN ''
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN ''
|
||||
ELSE COALESCE(
|
||||
(SELECT c.content FROM comment c
|
||||
WHERE c.issue_id = i.id AND c.content LIKE '%' || $1 || '%'
|
||||
ORDER BY c.created_at DESC LIMIT 1),
|
||||
''
|
||||
)
|
||||
END AS matched_comment_content
|
||||
FROM issue i
|
||||
WHERE i.workspace_id = $2
|
||||
AND (
|
||||
i.title LIKE '%' || $1 || '%'
|
||||
OR COALESCE(i.description, '') LIKE '%' || $1 || '%'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM comment c
|
||||
WHERE c.issue_id = i.id AND c.content LIKE '%' || $1 || '%'
|
||||
)
|
||||
)
|
||||
AND ($3::boolean OR i.status NOT IN ('done', 'cancelled'))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || $1 || '%' THEN 0
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || $1 || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
i.updated_at DESC
|
||||
LIMIT $5 OFFSET $4
|
||||
`
|
||||
|
||||
type SearchIssuesParams struct {
|
||||
Query pgtype.Text `json:"query"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
IncludeClosed bool `json:"include_closed"`
|
||||
SearchOffset int32 `json:"search_offset"`
|
||||
SearchLimit int32 `json:"search_limit"`
|
||||
}
|
||||
|
||||
type SearchIssuesRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Number int32 `json:"number"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
MatchSource string `json:"match_source"`
|
||||
MatchedCommentContent interface{} `json:"matched_comment_content"`
|
||||
}
|
||||
|
||||
func (q *Queries) SearchIssues(ctx context.Context, arg SearchIssuesParams) ([]SearchIssuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, searchIssues,
|
||||
arg.Query,
|
||||
arg.WorkspaceID,
|
||||
arg.IncludeClosed,
|
||||
arg.SearchOffset,
|
||||
arg.SearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SearchIssuesRow{}
|
||||
for rows.Next() {
|
||||
var i SearchIssuesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
&i.ProjectID,
|
||||
&i.TotalCount,
|
||||
&i.MatchSource,
|
||||
&i.MatchedCommentContent,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateIssue = `-- name: UpdateIssue :one
|
||||
UPDATE issue SET
|
||||
title = COALESCE($2, title),
|
||||
|
||||
@@ -80,40 +80,4 @@ SELECT * FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
|
||||
-- name: SearchIssues :many
|
||||
SELECT i.*,
|
||||
COUNT(*) OVER() AS total_count,
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || @query || '%' THEN 'title'
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 'description'
|
||||
ELSE 'comment'
|
||||
END AS match_source,
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || @query || '%' THEN ''
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN ''
|
||||
ELSE COALESCE(
|
||||
(SELECT c.content FROM comment c
|
||||
WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%'
|
||||
ORDER BY c.created_at DESC LIMIT 1),
|
||||
''
|
||||
)
|
||||
END AS matched_comment_content
|
||||
FROM issue i
|
||||
WHERE i.workspace_id = @workspace_id
|
||||
AND (
|
||||
i.title LIKE '%' || @query || '%'
|
||||
OR COALESCE(i.description, '') LIKE '%' || @query || '%'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM comment c
|
||||
WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%'
|
||||
)
|
||||
)
|
||||
AND (@include_closed::boolean OR i.status NOT IN ('done', 'cancelled'))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN i.title LIKE '%' || @query || '%' THEN 0
|
||||
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
i.updated_at DESC
|
||||
LIMIT @search_limit OFFSET @search_offset;
|
||||
-- SearchIssues: moved to handler (dynamic SQL for multi-word search support).
|
||||
|
||||
Reference in New Issue
Block a user