package db import ( "context" "encoding/json" "fmt" ) // AuditListOptions controls filtering for the batch audit item query. type AuditListOptions struct { Project string // filter by project code Category string // filter by category prefix ("F", "F01") Limit int Offset int } // ItemWithProperties combines an Item with its current revision properties. type ItemWithProperties struct { Item Properties map[string]any } // ListItemsWithProperties returns items joined with their current revision // properties in a single query, avoiding the N+1 pattern. func (r *ItemRepository) ListItemsWithProperties(ctx context.Context, opts AuditListOptions) ([]*ItemWithProperties, error) { args := []any{} argNum := 1 var query string if opts.Project != "" { query = ` SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, i.sourcing_type, i.long_description, COALESCE(r.properties, '{}'::jsonb) as properties FROM items i LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision JOIN item_projects ip ON ip.item_id = i.id JOIN projects p ON p.id = ip.project_id WHERE i.archived_at IS NULL AND p.code = $1 ` args = append(args, opts.Project) argNum++ } else { query = ` SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, i.sourcing_type, i.long_description, COALESCE(r.properties, '{}'::jsonb) as properties FROM items i LEFT JOIN revisions r ON r.item_id = i.id AND r.revision_number = i.current_revision WHERE i.archived_at IS NULL ` } if opts.Category != "" { query += fmt.Sprintf(" AND i.part_number LIKE $%d", argNum) args = append(args, opts.Category+"%") argNum++ } query += " ORDER BY i.part_number" if opts.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argNum) args = append(args, opts.Limit) argNum++ } if opts.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argNum) args = append(args, opts.Offset) } rows, err := r.db.pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("querying items with properties: %w", err) } defer rows.Close() var items []*ItemWithProperties for rows.Next() { iwp := &ItemWithProperties{} var propsJSON []byte err := rows.Scan( &iwp.ID, &iwp.PartNumber, &iwp.SchemaID, &iwp.ItemType, &iwp.Description, &iwp.CreatedAt, &iwp.UpdatedAt, &iwp.ArchivedAt, &iwp.CurrentRevision, &iwp.SourcingType, &iwp.LongDescription, &propsJSON, ) if err != nil { return nil, fmt.Errorf("scanning item with properties: %w", err) } iwp.Properties = make(map[string]any) if len(propsJSON) > 0 { if err := json.Unmarshal(propsJSON, &iwp.Properties); err != nil { return nil, fmt.Errorf("unmarshaling properties for %s: %w", iwp.PartNumber, err) } } items = append(items, iwp) } return items, nil } // BatchCheckBOM returns a map of item ID to BOM child count for the given // item IDs. Items not in the map have zero children. func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (map[string]int, error) { if len(itemIDs) == 0 { return map[string]int{}, nil } rows, err := r.db.pool.Query(ctx, ` SELECT parent_item_id, COUNT(*) as child_count FROM relationships WHERE parent_item_id = ANY($1) AND rel_type = 'component' GROUP BY parent_item_id `, itemIDs) if err != nil { return nil, fmt.Errorf("batch checking BOM: %w", err) } defer rows.Close() result := make(map[string]int) for rows.Next() { var itemID string var count int if err := rows.Scan(&itemID, &count); err != nil { return nil, fmt.Errorf("scanning BOM count: %w", err) } result[itemID] = count } return result, nil } // FileStats holds aggregated file attachment statistics for an item. type FileStats struct { Count int TotalSize int64 } // BatchGetFileStats returns a map of item ID to file attachment statistics // for the given item IDs. Items not in the map have no files. func (r *ItemRepository) BatchGetFileStats(ctx context.Context, itemIDs []string) (map[string]FileStats, error) { if len(itemIDs) == 0 { return map[string]FileStats{}, nil } rows, err := r.db.pool.Query(ctx, ` SELECT item_id, COUNT(*), COALESCE(SUM(size), 0) FROM item_files WHERE item_id = ANY($1) GROUP BY item_id `, itemIDs) if err != nil { return nil, fmt.Errorf("batch getting file stats: %w", err) } defer rows.Close() result := make(map[string]FileStats) for rows.Next() { var itemID string var fs FileStats if err := rows.Scan(&itemID, &fs.Count, &fs.TotalSize); err != nil { return nil, fmt.Errorf("scanning file stats: %w", err) } result[itemID] = fs } return result, nil } // BatchGetProjectCodes returns a map of item ID to project code list for // the given item IDs. func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) { if len(itemIDs) == 0 { return map[string][]string{}, nil } rows, err := r.db.pool.Query(ctx, ` SELECT ip.item_id, p.code FROM item_projects ip JOIN projects p ON p.id = ip.project_id WHERE ip.item_id = ANY($1) ORDER BY p.code `, itemIDs) if err != nil { return nil, fmt.Errorf("batch getting project codes: %w", err) } defer rows.Close() result := make(map[string][]string) for rows.Next() { var itemID, code string if err := rows.Scan(&itemID, &code); err != nil { return nil, fmt.Errorf("scanning project code: %w", err) } result[itemID] = append(result[itemID], code) } return result, nil }