fix: handle database permission issues in sync migration verification
Sync was blocked because the migration registry table creation required CREATE TABLE permissions that the database user might not have. Changes: - Check if migration registry tables exist before attempting to create them - Skip creation if table exists and user lacks CREATE permissions - Use information_schema to reliably check table existence - Apply same fix to user sync status table creation - Gracefully handle ALTER TABLE failures for backward compatibility This allows sync to proceed even if the client is a read-limited database user, as long as the required tables have already been created by an administrator. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
297
csv_export.md
Normal file
297
csv_export.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# CSV Export Pattern (Go + GORM)
|
||||||
|
|
||||||
|
## Архитектура (3-слойная)
|
||||||
|
|
||||||
|
### 1. Handler Layer (HTTP)
|
||||||
|
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
||||||
|
// 1. Валидация параметров
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
// 2. Получение метаданных для формирования имени файла
|
||||||
|
pl, err := h.service.GetByID(uint(id))
|
||||||
|
|
||||||
|
// 3. Установка HTTP-заголовков для скачивания
|
||||||
|
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))
|
||||||
|
|
||||||
|
// 4. UTF-8 BOM для Excel-совместимости
|
||||||
|
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
|
||||||
|
// 5. Настройка CSV writer
|
||||||
|
writer := csv.NewWriter(c.Writer)
|
||||||
|
writer.Comma = ';' // Точка с запятой для Excel
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
// 6. Динамические заголовки (зависят от типа данных)
|
||||||
|
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
||||||
|
var header []string
|
||||||
|
if isWarehouse {
|
||||||
|
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
||||||
|
} else {
|
||||||
|
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
||||||
|
}
|
||||||
|
writer.Write(header)
|
||||||
|
|
||||||
|
// 7. Streaming в batches через callback
|
||||||
|
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
||||||
|
for _, item := range items {
|
||||||
|
row := buildRow(item, isWarehouse)
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Flush() // Flush после каждого batch
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Service Layer
|
||||||
|
**Задачи**: Оркестрация, делегирование в репозиторий
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
||||||
|
}
|
||||||
|
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Repository Layer (Критичный)
|
||||||
|
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 500 // Default batch size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка типа pricelist для conditional enrichment
|
||||||
|
var pl models.Pricelist
|
||||||
|
isWarehouse := false
|
||||||
|
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
||||||
|
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
var items []models.PricelistItem
|
||||||
|
|
||||||
|
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
|
||||||
|
err := r.db.Table("qt_pricelist_items AS pi").
|
||||||
|
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional enrichment для warehouse данных
|
||||||
|
if isWarehouse {
|
||||||
|
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызов callback для обработки batch
|
||||||
|
if err := callback(items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) < batchSize {
|
||||||
|
break // Последний batch
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += batchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые паттерны
|
||||||
|
|
||||||
|
### 1. Streaming (не загружать все в память)
|
||||||
|
```go
|
||||||
|
// ❌ НЕ ТАК:
|
||||||
|
var allItems []Item
|
||||||
|
db.Find(&allItems) // Может упасть на миллионах записей
|
||||||
|
|
||||||
|
// ✅ ТАК:
|
||||||
|
for offset := 0; ; offset += batchSize {
|
||||||
|
var batch []Item
|
||||||
|
db.Offset(offset).Limit(batchSize).Find(&batch)
|
||||||
|
processBatch(batch)
|
||||||
|
if len(batch) < batchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Callback Pattern для гибкости
|
||||||
|
```go
|
||||||
|
// Service не знает о CSV - может использоваться для любого экспорта
|
||||||
|
func StreamItems(callback func([]Item) error) error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. JOIN для избежания N+1
|
||||||
|
```go
|
||||||
|
// ❌ N+1 problem:
|
||||||
|
items := getItems()
|
||||||
|
for _, item := range items {
|
||||||
|
description := getLotDescription(item.LotName) // N запросов
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 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")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. UTF-8 BOM для Excel
|
||||||
|
```go
|
||||||
|
// Excel на Windows требует BOM для корректного отображения UTF-8
|
||||||
|
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Точка с запятой для Excel
|
||||||
|
```go
|
||||||
|
writer := csv.NewWriter(c.Writer)
|
||||||
|
writer.Comma = ';' // Excel в русской локали использует ;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Graceful Error Handling
|
||||||
|
```go
|
||||||
|
// После начала streaming нельзя вернуть JSON
|
||||||
|
if err != nil {
|
||||||
|
// Уже начали писать CSV, поэтому пишем текст
|
||||||
|
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Enrichment Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Для warehouse прайслистов добавляем дополнительные поля
|
||||||
|
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
||||||
|
// 1. Собрать уникальные lot_names
|
||||||
|
lots := make([]string, 0, len(items))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, item := range items {
|
||||||
|
if _, ok := seen[item.LotName]; !ok {
|
||||||
|
lots = append(lots, item.LotName)
|
||||||
|
seen[item.LotName] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Batch-загрузка метрик (qty, partnumbers)
|
||||||
|
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
||||||
|
|
||||||
|
// 3. Обогащение items
|
||||||
|
for i := range items {
|
||||||
|
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
||||||
|
items[i].AvailableQty = &qty
|
||||||
|
}
|
||||||
|
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Virtual Fields Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PricelistItem struct {
|
||||||
|
// Stored fields
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
LotName string `gorm:"size:255"`
|
||||||
|
Price float64 `gorm:"type:decimal(12,2)"`
|
||||||
|
|
||||||
|
// Virtual fields (populated via JOIN or programmatically)
|
||||||
|
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
|
||||||
|
Category string `gorm:"-:migration" json:"category,omitempty"`
|
||||||
|
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
||||||
|
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
|
||||||
|
- `gorm:"-"` - полностью игнорировать при БД операциях
|
||||||
|
|
||||||
|
## Checklist для CSV Export
|
||||||
|
|
||||||
|
- [ ] HTTP заголовки: Content-Type, Content-Disposition
|
||||||
|
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
|
||||||
|
- [ ] Разделитель (`;` для русской локали Excel)
|
||||||
|
- [ ] Streaming с batch processing (не загружать всё в память)
|
||||||
|
- [ ] JOIN для избежания N+1 запросов
|
||||||
|
- [ ] Flush после каждого batch
|
||||||
|
- [ ] Graceful error handling (нельзя JSON после начала streaming)
|
||||||
|
- [ ] Динамические заголовки (если нужно)
|
||||||
|
- [ ] Conditional enrichment (если данные зависят от типа)
|
||||||
|
|
||||||
|
## Когда использовать этот паттерн
|
||||||
|
|
||||||
|
✅ **Используй когда:**
|
||||||
|
- Экспорт больших датасетов (>1000 записей)
|
||||||
|
- Нужна Excel-совместимость
|
||||||
|
- Связанные данные из нескольких таблиц
|
||||||
|
- Conditional логика enrichment
|
||||||
|
|
||||||
|
❌ **Не нужен когда:**
|
||||||
|
- Малые датасеты (<100 записей) - можно загрузить всё сразу
|
||||||
|
- Экспорт JSON/XML - другие подходы
|
||||||
|
- Нет связанных данных - можно упростить
|
||||||
|
|
||||||
|
## Пример роутинга (Gin)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// В файле роутера
|
||||||
|
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
pricelists := api.Group("/pricelists")
|
||||||
|
{
|
||||||
|
pricelists.GET("/:id/export", handler.ExportCSV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
1. **Batch Size**: 500-1000 оптимально для большинства случаев
|
||||||
|
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
|
||||||
|
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
|
||||||
|
4. **Indexes**: Убедись что есть индексы на JOIN колонках
|
||||||
|
|
||||||
|
## Источник
|
||||||
|
|
||||||
|
Реализовано в проекте PriceForge:
|
||||||
|
- Handler: `internal/handlers/pricelist.go:245-346`
|
||||||
|
- Service: `internal/services/pricelist/service.go:373-379`
|
||||||
|
- Repository: `internal/repository/pricelist.go:475-533`
|
||||||
|
- Models: `internal/models/pricelist.go`
|
||||||
@@ -189,33 +189,54 @@ func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||||
if err := db.Exec(`
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
if !tableExists(db, "qt_client_local_migrations") {
|
||||||
id VARCHAR(128) NOT NULL,
|
if err := db.Exec(`
|
||||||
name VARCHAR(255) NOT NULL,
|
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||||
sql_text LONGTEXT NOT NULL,
|
id VARCHAR(128) NOT NULL,
|
||||||
checksum VARCHAR(128) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
min_app_version VARCHAR(64) NULL,
|
sql_text LONGTEXT NOT NULL,
|
||||||
order_no INT NOT NULL DEFAULT 0,
|
checksum VARCHAR(128) NOT NULL,
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
min_app_version VARCHAR(64) NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
order_no INT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id),
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
)
|
PRIMARY KEY (id),
|
||||||
`).Error; err != nil {
|
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||||
return err
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
if !tableExists(db, "qt_client_schema_state") {
|
||||||
username VARCHAR(100) NOT NULL,
|
if err := db.Exec(`
|
||||||
last_applied_migration_id VARCHAR(128) NULL,
|
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||||
app_version VARCHAR(64) NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
last_checked_at DATETIME NOT NULL,
|
last_applied_migration_id VARCHAR(128) NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
app_version VARCHAR(64) NULL,
|
||||||
PRIMARY KEY (username),
|
last_checked_at DATETIME NOT NULL,
|
||||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
updated_at DATETIME NOT NULL,
|
||||||
)
|
PRIMARY KEY (username),
|
||||||
`).Error
|
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, tableName string) bool {
|
||||||
|
var count int64
|
||||||
|
// For MariaDB/MySQL, check information_schema
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||||
|
`, tableName).Scan(&count).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||||
|
|||||||
@@ -553,24 +553,34 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||||
if err := db.Exec(`
|
// Check if table exists instead of trying to create (avoids permission issues)
|
||||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
if !tableExists(db, "qt_pricelist_sync_status") {
|
||||||
username VARCHAR(100) NOT NULL,
|
if err := db.Exec(`
|
||||||
last_sync_at DATETIME NOT NULL,
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
updated_at DATETIME NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
app_version VARCHAR(64) NULL,
|
last_sync_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY (username),
|
updated_at DATETIME NOT NULL,
|
||||||
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
app_version VARCHAR(64) NULL,
|
||||||
)
|
PRIMARY KEY (username),
|
||||||
`).Error; err != nil {
|
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||||
return err
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward compatibility for environments where table was created without app_version.
|
// Backward compatibility for environments where table was created without app_version.
|
||||||
return db.Exec(`
|
// Only try to add column if table exists.
|
||||||
ALTER TABLE qt_pricelist_sync_status
|
if tableExists(db, "qt_pricelist_sync_status") {
|
||||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
if err := db.Exec(`
|
||||||
`).Error
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Log but don't fail if alter fails (column might already exist)
|
||||||
|
slog.Debug("failed to add app_version column", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
|
|||||||
Reference in New Issue
Block a user