turash/bugulma/backend/internal/repository/localization_repository.go

398 lines
13 KiB
Go

package repository
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/localization"
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
// LocalizationRepository implements the domain.LocalizationRepository interface
type LocalizationRepository struct {
db *gorm.DB
}
// NewLocalizationRepository creates a new localization repository
func NewLocalizationRepository(db *gorm.DB) domain.LocalizationRepository {
return &LocalizationRepository{db: db}
}
// Create creates a new localization record
func (r *LocalizationRepository) Create(ctx context.Context, loc *domain.Localization) error {
if loc == nil {
return fmt.Errorf("localization cannot be nil")
}
// Validate required fields
if loc.EntityType == "" {
return fmt.Errorf("entity_type is required")
}
if loc.EntityID == "" {
return fmt.Errorf("entity_id is required")
}
if loc.Field == "" {
return fmt.Errorf("field is required")
}
if loc.Locale == "" {
return fmt.Errorf("locale is required")
}
if loc.Value == "" {
return fmt.Errorf("value cannot be empty")
}
// Generate composite ID if not provided
if loc.ID == "" {
loc.ID = fmt.Sprintf("%s_%s_%s_%s", loc.EntityType, loc.EntityID, loc.Field, loc.Locale)
}
return r.db.WithContext(ctx).Create(loc).Error
}
// GetByEntityAndField retrieves a localization by entity type, entity ID, field, and locale
func (r *LocalizationRepository) GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*domain.Localization, error) {
if entityType == "" || entityID == "" || field == "" || locale == "" {
return nil, fmt.Errorf("all parameters (entityType, entityID, field, locale) are required")
}
var loc domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND entity_id = ? AND field = ? AND locale = ?",
entityType, entityID, field, locale).First(&loc).Error
if err == gorm.ErrRecordNotFound {
return nil, nil // Return nil instead of error for not found
}
return &loc, err
}
// GetAllByEntity retrieves all localizations for a specific entity
func (r *LocalizationRepository) GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*domain.Localization, error) {
if entityType == "" || entityID == "" {
return nil, fmt.Errorf("entityType and entityID are required")
}
var localizations []*domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND entity_id = ?",
entityType, entityID).Order("field, locale").Find(&localizations).Error
return localizations, err
}
// Update updates an existing localization record
func (r *LocalizationRepository) Update(ctx context.Context, loc *domain.Localization) error {
if loc == nil {
return fmt.Errorf("localization cannot be nil")
}
if loc.ID == "" {
return fmt.Errorf("localization ID is required for update")
}
// Validate required fields
if loc.EntityType == "" {
return fmt.Errorf("entity_type is required")
}
if loc.EntityID == "" {
return fmt.Errorf("entity_id is required")
}
if loc.Field == "" {
return fmt.Errorf("field is required")
}
if loc.Locale == "" {
return fmt.Errorf("locale is required")
}
return r.db.WithContext(ctx).Save(loc).Error
}
// Delete deletes a localization record by ID
func (r *LocalizationRepository) Delete(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("localization ID is required")
}
return r.db.WithContext(ctx).Delete(&domain.Localization{}, "id = ?", id).Error
}
// GetByEntityTypeAndLocale retrieves all localizations for entities of a specific type and locale
func (r *LocalizationRepository) GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*domain.Localization, error) {
if entityType == "" || locale == "" {
return nil, fmt.Errorf("entityType and locale are required")
}
var localizations []*domain.Localization
err := r.db.WithContext(ctx).Where("entity_type = ? AND locale = ?",
entityType, locale).Order("entity_id, field").Find(&localizations).Error
return localizations, err
}
// GetAllLocales returns all available locales in the system
func (r *LocalizationRepository) GetAllLocales(ctx context.Context) ([]string, error) {
var locales []string
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Distinct("locale").
Pluck("locale", &locales).Error
return locales, err
}
// GetSupportedLocalesForEntity returns locales that have translations for a specific entity
func (r *LocalizationRepository) GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error) {
if entityType == "" || entityID == "" {
return nil, fmt.Errorf("entityType and entityID are required")
}
var locales []string
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Where("entity_type = ? AND entity_id = ?", entityType, entityID).
Distinct("locale").
Pluck("locale", &locales).Error
return locales, err
}
// BulkCreate creates multiple localization records in a transaction
func (r *LocalizationRepository) BulkCreate(ctx context.Context, localizations []*domain.Localization) error {
if len(localizations) == 0 {
return nil
}
// Validate all localizations before starting transaction
for i, loc := range localizations {
if loc == nil {
return fmt.Errorf("localization at index %d cannot be nil", i)
}
if loc.EntityType == "" || loc.EntityID == "" || loc.Field == "" || loc.Locale == "" {
return fmt.Errorf("localization at index %d has missing required fields", i)
}
// Generate composite ID if not provided
if loc.ID == "" {
loc.ID = fmt.Sprintf("%s_%s_%s_%s", loc.EntityType, loc.EntityID, loc.Field, loc.Locale)
}
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, loc := range localizations {
if err := tx.Create(loc).Error; err != nil {
return err
}
}
return nil
})
}
// BulkDelete deletes multiple localization records by their IDs in a transaction
func (r *LocalizationRepository) BulkDelete(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Where("id IN ?", ids).Delete(&domain.Localization{}).Error
})
}
// SearchLocalizations searches localizations by value content
func (r *LocalizationRepository) SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*domain.Localization, error) {
if query == "" {
return nil, fmt.Errorf("search query cannot be empty")
}
var localizations []*domain.Localization
db := r.db.WithContext(ctx)
if locale != "" {
db = db.Where("locale = ?", locale)
}
err := db.Where("value ILIKE ?", "%"+query+"%").
Order("entity_type, entity_id, field").
Limit(limit).
Find(&localizations).Error
return localizations, err
}
// GetTranslationCountsByEntity returns translation counts grouped by entity type and locale
func (r *LocalizationRepository) GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error) {
var results []struct {
EntityType string
Locale string
Count int
}
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Select("entity_type, locale, COUNT(*) as count").
Group("entity_type, locale").
Order("entity_type, locale").
Scan(&results).Error
if err != nil {
return nil, err
}
counts := make(map[string]map[string]int)
for _, result := range results {
if counts[result.EntityType] == nil {
counts[result.EntityType] = make(map[string]int)
}
counts[result.EntityType][result.Locale] = result.Count
}
return counts, nil
}
// GetUntranslatedFields returns fields that exist in entities but don't have translations
func (r *LocalizationRepository) GetUntranslatedFields(ctx context.Context, entityType, targetLocale string, entityIDs []string) ([]UntranslatedFieldInfo, error) {
// This would require joining with the actual entity tables
// For now, return empty - this is a complex query that would need to be implemented
// based on the specific entity schema
return []UntranslatedFieldInfo{}, nil
}
// UntranslatedFieldInfo represents information about a field that needs translation
type UntranslatedFieldInfo struct {
EntityID string
Field string
RussianValue string
}
// GetTranslationReuseCandidates finds Russian text that appears in multiple entities
// This can help identify good candidates for caching
func (r *LocalizationRepository) GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]domain.ReuseCandidate, error) {
var results []struct {
RussianValue string
Count int
}
// Find Russian values that appear in multiple entities of the same type
err := r.db.WithContext(ctx).Model(&domain.Localization{}).
Select("value as russian_value, COUNT(DISTINCT entity_id) as count").
Where("entity_type = ? AND field = ? AND locale = 'ru'", entityType, field).
Group("value").
Having("COUNT(DISTINCT entity_id) > 1").
Order("count DESC").
Limit(50).
Scan(&results).Error
if err != nil {
return nil, err
}
candidates := make([]domain.ReuseCandidate, len(results))
for i, result := range results {
candidates[i] = domain.ReuseCandidate{
RussianValue: result.RussianValue,
EntityCount: result.Count,
}
}
return candidates, nil
}
// GetEntitiesNeedingTranslation returns entity IDs that need translation for specific fields
// It queries entity tables to find entities with Russian content that don't have translations
func (r *LocalizationRepository) GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error) {
if entityType == "" || field == "" || targetLocale == "" {
return nil, fmt.Errorf("entityType, field, and targetLocale are required")
}
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
// Get entity descriptor from registry to know table name
desc, exists := localization.GetEntityDescriptor(entityType)
if !exists {
return nil, fmt.Errorf("unknown entity type: %s", entityType)
}
// Build query to find entities that:
// 1. Have non-empty Russian content in the specified field
// 2. Don't have a translation in the target locale
// This uses a LEFT JOIN to find missing translations
// We need to use GORM's Raw with proper parameterization
// The field name needs to be validated against the entity descriptor
fieldValid := false
for _, f := range desc.Fields {
if f == field {
fieldValid = true
break
}
}
if !fieldValid {
return nil, fmt.Errorf("field '%s' is not valid for entity type '%s'", field, entityType)
}
// Use parameterized query with proper escaping for table/column names
// Note: Table and column names cannot be parameterized, so we validate them above
// GORM uses ? placeholders for parameters
query := fmt.Sprintf(`
SELECT DISTINCT e.%s as entity_id
FROM %s e
LEFT JOIN localizations l
ON l.entity_type = ?
AND l.entity_id = e.%s
AND l.field = ?
AND l.locale = ?
WHERE e.%s IS NOT NULL
AND e.%s != ''
AND l.id IS NULL
LIMIT ?
`, desc.IDField, desc.TableName, desc.IDField, field, field)
var entityIDs []string
err := r.db.WithContext(ctx).Raw(query, entityType, field, targetLocale, limit).Pluck("entity_id", &entityIDs).Error
if err != nil {
return nil, fmt.Errorf("failed to query entities needing translation: %w", err)
}
return entityIDs, nil
}
// FindExistingTranslationByRussianText finds an existing translation by matching Russian source text
// It looks for entities with the same Russian text in the same field that already have a translation
// Returns the translation value if found, empty string if not found
func (r *LocalizationRepository) FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error) {
if entityType == "" || field == "" || targetLocale == "" || russianText == "" {
return "", nil
}
// Normalize the Russian text for matching
normalized := strings.TrimSpace(russianText)
if normalized == "" {
return "", nil
}
// Query: Find a localization where:
// 1. The entity has a Russian localization with matching text
// 2. The same entity has a translation in the target locale
var translation string
err := r.db.WithContext(ctx).Raw(`
SELECT l_target.value
FROM localizations l_ru
INNER JOIN localizations l_target
ON l_ru.entity_type = l_target.entity_type
AND l_ru.entity_id = l_target.entity_id
AND l_ru.field = l_target.field
WHERE l_ru.entity_type = ?
AND l_ru.field = ?
AND l_ru.locale = 'ru'
AND l_ru.value = ?
AND l_target.locale = ?
LIMIT 1
`, entityType, field, normalized, targetLocale).Scan(&translation).Error
if err != nil {
return "", fmt.Errorf("failed to find existing translation: %w", err)
}
return translation, nil
}