5.1 KiB
5.1 KiB
Code Patterns
CSV Export (3-layer architecture)
Used for pricelist export.
Implementation:
- Handler:
internal/handlers/pricelist.go—ExportCSV - Service:
internal/services/pricelist/service.go—StreamItemsForExport - Repository:
internal/repository/pricelist.go—StreamItemsForExport - Models:
internal/models/pricelist.go
1. Handler Layer
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
pl, _ := h.service.GetByID(uint(id))
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// UTF-8 BOM for Excel compatibility
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Writer)
writer.Comma = ';' // semicolon for Russian-locale Excel
defer writer.Flush()
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
var header []string
if isWarehouse {
header = []string{"Article", "Category", "Description", "Available", "Partnumbers", "Price, $", "Settings"}
} else {
header = []string{"Article", "Category", "Description", "Price, $", "Settings"}
}
writer.Write(header)
h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
for _, item := range items {
writer.Write(buildRow(item, isWarehouse))
}
writer.Flush() // flush after each batch
return nil
})
}
2. Service Layer
func (s *Service) StreamItemsForExport(id uint, batchSize int, cb func([]models.PricelistItem) error) error {
return s.repo.StreamItemsForExport(id, batchSize, cb)
}
3. Repository Layer
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func([]models.PricelistItem) error) error {
offset := 0
for {
var items []models.PricelistItem
err := r.db.Table("qt_pricelist_items AS pi").
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description").
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
Where("pi.pricelist_id = ?", pricelistID).
Order("pi.lot_name").
Offset(offset).Limit(batchSize).
Scan(&items).Error
if err != nil || len(items) == 0 {
break
}
if isWarehouse {
r.enrichWarehouseItems(items) // adds qty, partnumbers
}
if err := callback(items); err != nil {
return err
}
if len(items) < batchSize {
break
}
offset += batchSize
}
return nil
}
Streaming (avoid loading all into memory)
// ❌ BAD:
var allItems []Item
db.Find(&allItems) // can OOM on millions of rows
// ✅ GOOD:
for offset := 0; ; offset += batchSize {
var batch []Item
db.Offset(offset).Limit(batchSize).Find(&batch)
processBatch(batch)
if len(batch) < batchSize {
break
}
}
Recommended batch size: 500–1000.
JOIN instead of N+1
// ❌ N+1:
for _, item := range items {
description := getLotDescription(item.LotName) // N queries
}
// ✅ JOIN:
db.Table("items AS i").
Select("i.*, COALESCE(l.description, '') AS description").
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
UTF-8 BOM for Excel
// Excel on Windows requires BOM for correct UTF-8 display
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
Semicolon delimiter for Excel (Russian locale)
writer := csv.NewWriter(c.Writer)
writer.Comma = ';'
Graceful error handling during streaming
// Cannot return JSON error after streaming has started
if err != nil {
c.String(http.StatusInternalServerError, "Export failed: %v", err)
return
}
Virtual Fields (GORM)
type PricelistItem struct {
// Stored fields
ID uint `gorm:"primaryKey"`
LotName string `gorm:"size:255"`
Price float64 `gorm:"type:decimal(12,2)"`
// Stored category snapshot
LotCategory *string `gorm:"column:lot_category;size:50" json:"category,omitempty"`
// Virtual: populated via JOIN
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
// Virtual: populated programmatically
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
}
gorm:"-:migration"— no DB column, but mapped on SELECT.gorm:"-"— fully ignored in all DB operations.
CSV Export Checklist
- HTTP headers:
Content-Type,Content-Disposition - UTF-8 BOM
{0xEF, 0xBB, 0xBF} - Semicolon delimiter
;for Russian-locale Excel - Streaming with batch processing (no full load into memory)
- JOIN to avoid N+1 queries
writer.Flush()after each batch- Graceful error handling (no JSON after streaming starts)
- Dynamic headers when needed
- Conditional enrichment (warehouse vs estimate)