mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* revert(issues): restore server-backed table grouping Co-authored-by: multica-agent <github@multica.ai> * test(skills): stabilize import completion coverage Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
368 lines
13 KiB
Go
368 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// The workspace property catalog is capped at 20 active definitions. Six
|
|
// built-in dimensions plus that catalog fit under this guard with headroom,
|
|
// while preventing a single request from scheduling hundreds of sequential
|
|
// aggregation scans inside one snapshot transaction.
|
|
const issueTableMaxFacets = 32
|
|
|
|
type issueTableFacetValueResponse struct {
|
|
Key string `json:"key"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
type issueTableFacetResponse struct {
|
|
Kind string `json:"kind"`
|
|
PropertyID string `json:"property_id,omitempty"`
|
|
Values []issueTableFacetValueResponse `json:"values"`
|
|
}
|
|
|
|
type issueTableFacetsResponse struct {
|
|
QueryFingerprint string `json:"query_fingerprint"`
|
|
Total int64 `json:"total"`
|
|
Facets []issueTableFacetResponse `json:"facets"`
|
|
}
|
|
|
|
func issueTableQueryWithoutFacet(input issueTableQuerySpec, facet issueTableFacetSpec) issueTableQuerySpec {
|
|
output := input
|
|
output.Filters = input.Filters
|
|
if input.Filters.Properties != nil {
|
|
output.Filters.Properties = make(map[string][]string, len(input.Filters.Properties))
|
|
for propertyID, values := range input.Filters.Properties {
|
|
output.Filters.Properties[propertyID] = append([]string(nil), values...)
|
|
}
|
|
}
|
|
|
|
switch facet.Kind {
|
|
case "status":
|
|
output.Filters.Statuses = nil
|
|
case "priority":
|
|
output.Filters.Priorities = nil
|
|
case "assignee":
|
|
output.Filters.Assignees = nil
|
|
output.Filters.IncludeNoAssignee = false
|
|
case "creator":
|
|
output.Filters.Creators = nil
|
|
case "project":
|
|
output.Filters.ProjectIDs = nil
|
|
output.Filters.IncludeNoProject = false
|
|
case "label":
|
|
output.Filters.LabelIDs = nil
|
|
case "property":
|
|
delete(output.Filters.Properties, facet.PropertyID)
|
|
}
|
|
return output
|
|
}
|
|
|
|
func issueTableFacetIdentity(facet issueTableFacetSpec) string {
|
|
if facet.Kind == "property" {
|
|
return "property:" + facet.PropertyID
|
|
}
|
|
return facet.Kind
|
|
}
|
|
|
|
func issueTableBaseFacetExpression(query issueTableQuerySpec, facet issueTableFacetSpec) (string, bool) {
|
|
switch facet.Kind {
|
|
case "status":
|
|
return "i.status", len(query.Filters.Statuses) == 0
|
|
case "priority":
|
|
return "i.priority", len(query.Filters.Priorities) == 0
|
|
case "assignee":
|
|
return "CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END", len(query.Filters.Assignees) == 0 && !query.Filters.IncludeNoAssignee
|
|
case "creator":
|
|
return "i.creator_type || ':' || i.creator_id::text", len(query.Filters.Creators) == 0
|
|
case "project":
|
|
return "COALESCE(i.project_id::text, '__none__')", len(query.Filters.ProjectIDs) == 0 && !query.Filters.IncludeNoProject
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (h *Handler) issueTableBaseFacetQuery(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
base issueTableSQL,
|
|
requestQuery issueTableQuerySpec,
|
|
facets []issueTableFacetSpec,
|
|
includeTotal bool,
|
|
) (map[string]issueTableFacetResponse, int64, bool) {
|
|
markerCases := make([]string, 0, len(facets))
|
|
valueCases := make([]string, 0, len(facets))
|
|
groupingSets := make([]string, 0, len(facets)+1)
|
|
responses := make(map[string]issueTableFacetResponse, len(facets))
|
|
for _, facet := range facets {
|
|
expression, ok := issueTableBaseFacetExpression(requestQuery, facet)
|
|
if !ok {
|
|
writeIssueTableQueryFailure(w, r, "failed to batch table facets")
|
|
return nil, 0, false
|
|
}
|
|
identity := issueTableFacetIdentity(facet)
|
|
markerCases = append(markerCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN '%s'", expression, identity))
|
|
valueCases = append(valueCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN (%s)::text", expression, expression))
|
|
groupingSets = append(groupingSets, "("+expression+")")
|
|
responses[identity] = issueTableFacetResponse{
|
|
Kind: facet.Kind,
|
|
PropertyID: facet.PropertyID,
|
|
Values: []issueTableFacetValueResponse{},
|
|
}
|
|
}
|
|
if includeTotal {
|
|
groupingSets = append(groupingSets, "()")
|
|
}
|
|
|
|
query := fmt.Sprintf(`SELECT CASE %s ELSE '__total__' END,
|
|
CASE %s ELSE '' END,
|
|
COUNT(*)::bigint
|
|
FROM issue i
|
|
WHERE %s
|
|
GROUP BY GROUPING SETS (%s)`, strings.Join(markerCases, " "), strings.Join(valueCases, " "), base.where, strings.Join(groupingSets, ", "))
|
|
rows, err := h.DB.Query(r.Context(), query, base.args...)
|
|
if err != nil {
|
|
slog.Warn("ListIssueTableFacets batch query failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return nil, 0, false
|
|
}
|
|
defer rows.Close()
|
|
|
|
var total int64
|
|
for rows.Next() {
|
|
var identity string
|
|
var value string
|
|
var count int64
|
|
if err := rows.Scan(&identity, &value, &count); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return nil, 0, false
|
|
}
|
|
if identity == "__total__" {
|
|
total = count
|
|
continue
|
|
}
|
|
response, ok := responses[identity]
|
|
if !ok {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return nil, 0, false
|
|
}
|
|
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
|
|
responses[identity] = response
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return nil, 0, false
|
|
}
|
|
for identity, response := range responses {
|
|
sort.Slice(response.Values, func(i, j int) bool {
|
|
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
|
|
})
|
|
responses[identity] = response
|
|
}
|
|
return responses, total, true
|
|
}
|
|
|
|
func (h *Handler) issueTableFacetQuery(w http.ResponseWriter, r *http.Request, requestQuery issueTableQuerySpec, facet issueTableFacetSpec) (issueTableFacetResponse, bool) {
|
|
response := issueTableFacetResponse{Kind: facet.Kind, PropertyID: facet.PropertyID, Values: []issueTableFacetValueResponse{}}
|
|
compiled, ok := h.compileIssueTableQuery(w, r, issueTableQueryWithoutFacet(requestQuery, facet))
|
|
if !ok {
|
|
return response, false
|
|
}
|
|
|
|
query := ""
|
|
switch facet.Kind {
|
|
case "status":
|
|
query = fmt.Sprintf(`SELECT i.status, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.status`, compiled.where)
|
|
case "priority":
|
|
query = fmt.Sprintf(`SELECT i.priority, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.priority`, compiled.where)
|
|
case "assignee":
|
|
query = fmt.Sprintf(`SELECT CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
|
|
case "creator":
|
|
query = fmt.Sprintf(`SELECT i.creator_type || ':' || i.creator_id::text, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
|
|
case "project":
|
|
query = fmt.Sprintf(`SELECT COALESCE(i.project_id::text, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
|
|
case "label":
|
|
query = fmt.Sprintf(`SELECT itl.label_id::text, COUNT(DISTINCT i.id)::bigint FROM issue i JOIN issue_to_label itl ON itl.issue_id = i.id WHERE %s GROUP BY itl.label_id`, compiled.where)
|
|
case "property":
|
|
propertyID, err := util.ParseUUID(facet.PropertyID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid facets.property_id")
|
|
return response, false
|
|
}
|
|
property, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{
|
|
ID: propertyID,
|
|
WorkspaceID: compiled.workspaceID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeIssueTableUnsupportedGroup(w, "property_not_found", "The faceted property no longer exists.")
|
|
return response, false
|
|
}
|
|
slog.Warn("resolve table facet property failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to resolve table facet")
|
|
return response, false
|
|
}
|
|
if property.ArchivedAt.Valid {
|
|
writeIssueTableUnsupportedGroup(w, "property_archived", "The faceted property is archived.")
|
|
return response, false
|
|
}
|
|
propertyKey := "'" + util.UUIDToString(property.ID) + "'"
|
|
switch property.Type {
|
|
case "select":
|
|
query = fmt.Sprintf(`SELECT i.properties ->> %s, COUNT(*)::bigint FROM issue i WHERE %s AND jsonb_typeof(i.properties -> %s) = 'string' GROUP BY 1`, propertyKey, compiled.where, propertyKey)
|
|
case "multi_select":
|
|
query = fmt.Sprintf(`SELECT property_value.value, COUNT(DISTINCT i.id)::bigint FROM issue i JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(i.properties -> %s) = 'array' THEN i.properties -> %s ELSE '[]'::jsonb END) AS property_value(value) ON TRUE WHERE %s GROUP BY property_value.value`, propertyKey, propertyKey, compiled.where)
|
|
case "checkbox":
|
|
query = fmt.Sprintf(`SELECT i.properties ->> %s, COUNT(*)::bigint FROM issue i WHERE %s AND jsonb_typeof(i.properties -> %s) = 'boolean' GROUP BY 1`, propertyKey, compiled.where, propertyKey)
|
|
default:
|
|
writeIssueTableUnsupportedGroup(w, "property_type_unsupported", "This property type cannot be used as a filter facet.")
|
|
return response, false
|
|
}
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid facets.kind")
|
|
return response, false
|
|
}
|
|
|
|
rows, err := h.DB.Query(r.Context(), query, compiled.args...)
|
|
if err != nil {
|
|
slog.Warn("ListIssueTableFacets query failed", append(logger.RequestAttrs(r), "facet", issueTableFacetIdentity(facet), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return response, false
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var value string
|
|
var count int64
|
|
if err := rows.Scan(&value, &count); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return response, false
|
|
}
|
|
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
writeIssueTableQueryFailure(w, r, "failed to list table facets")
|
|
return response, false
|
|
}
|
|
sort.Slice(response.Values, func(i, j int) bool {
|
|
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
|
|
})
|
|
return response, true
|
|
}
|
|
|
|
func (h *Handler) ListIssueTableFacets(w http.ResponseWriter, r *http.Request) {
|
|
if h.DB == nil {
|
|
writeError(w, http.StatusInternalServerError, "database is unavailable")
|
|
return
|
|
}
|
|
var request issueTableFacetsRequest
|
|
if !decodeIssueTableJSON(w, r, &request) {
|
|
return
|
|
}
|
|
if len(request.Facets) == 0 || len(request.Facets) > issueTableMaxFacets {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("facets must contain between 1 and %d entries", issueTableMaxFacets))
|
|
return
|
|
}
|
|
r, cancel := withIssueTableQueryTimeout(r)
|
|
defer cancel()
|
|
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
|
|
if err != nil {
|
|
slog.Warn("ListIssueTableFacets snapshot failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to start table query")
|
|
return
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback(context.Background())
|
|
}
|
|
}()
|
|
h = snapshot
|
|
|
|
base, ok := h.compileIssueTableQuery(w, r, request.Query)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(request.Facets))
|
|
normalizedFacets := make([]issueTableFacetSpec, len(request.Facets))
|
|
for index, facet := range request.Facets {
|
|
facet.Kind = strings.TrimSpace(facet.Kind)
|
|
facet.PropertyID = strings.TrimSpace(facet.PropertyID)
|
|
identity := issueTableFacetIdentity(facet)
|
|
if _, exists := seen[identity]; exists {
|
|
writeError(w, http.StatusBadRequest, "duplicate table facet")
|
|
return
|
|
}
|
|
seen[identity] = struct{}{}
|
|
normalizedFacets[index] = facet
|
|
}
|
|
|
|
includeTotal := request.IncludeTotal == nil || *request.IncludeTotal
|
|
batchFacets := make([]issueTableFacetSpec, 0, len(normalizedFacets))
|
|
individualIndexes := make([]int, 0, len(normalizedFacets))
|
|
for index, facet := range normalizedFacets {
|
|
if _, batchable := issueTableBaseFacetExpression(request.Query, facet); batchable {
|
|
batchFacets = append(batchFacets, facet)
|
|
} else {
|
|
individualIndexes = append(individualIndexes, index)
|
|
}
|
|
}
|
|
|
|
responses := make([]issueTableFacetResponse, len(normalizedFacets))
|
|
var total int64
|
|
totalResolved := false
|
|
if len(batchFacets) > 0 {
|
|
batched, batchTotal, ok := h.issueTableBaseFacetQuery(w, r, base, request.Query, batchFacets, includeTotal)
|
|
if !ok {
|
|
return
|
|
}
|
|
for index, facet := range normalizedFacets {
|
|
if response, exists := batched[issueTableFacetIdentity(facet)]; exists {
|
|
responses[index] = response
|
|
}
|
|
}
|
|
if includeTotal {
|
|
total = batchTotal
|
|
totalResolved = true
|
|
}
|
|
}
|
|
if includeTotal && !totalResolved {
|
|
if err := h.DB.QueryRow(r.Context(), fmt.Sprintf("SELECT COUNT(*)::bigint FROM issue i WHERE %s", base.where), base.args...).Scan(&total); err != nil {
|
|
slog.Warn("ListIssueTableFacets total failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to count table facets")
|
|
return
|
|
}
|
|
}
|
|
for _, index := range individualIndexes {
|
|
resolved, ok := h.issueTableFacetQuery(w, r, request.Query, normalizedFacets[index])
|
|
if !ok {
|
|
return
|
|
}
|
|
responses[index] = resolved
|
|
}
|
|
|
|
response := issueTableFacetsResponse{
|
|
QueryFingerprint: base.fingerprint,
|
|
Total: total,
|
|
Facets: responses,
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
slog.Warn("ListIssueTableFacets snapshot commit failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeIssueTableQueryFailure(w, r, "failed to finish table query")
|
|
return
|
|
}
|
|
committed = true
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|