Перейти к содержанию

Рекомендации по работе с JSONB в PostgreSQL

Этот документ содержит рекомендации по правильному и безопасному использованию типа данных JSONB в PostgreSQL из Go-кода.

Найденные проблемы

В текущей имплементации репозитория авторизации (auth_repository.go) используется небезопасный подход к работе с JSONB-данными:

  1. Прямое форматирование SQL-запросов с использованием fmt.Sprintf()
  2. Ручное экранирование строк, которое может быть неполным
  3. Отсутствие параметризации запросов
  4. Избыточная сложность кода и низкая читаемость
// Пример текущего подхода (небезопасно)
metadataStr := string(metadataJSON)
metadataStr = strings.ReplaceAll(metadataStr, "'", "''")

directQuery := fmt.Sprintf(`
    INSERT INTO users (id, metadata) VALUES ('%s', '%s')
`, id, metadataStr)

_, err = db.ExecContext(ctx, directQuery)

Рекомендуемый подход

1. Базовый подход с параметризованными запросами

// Подготовка данных
metadataObj := map[string]interface{}{
    "key": "value",
}
metadataJSON, err := json.Marshal(metadataObj)
if err != nil {
    return fmt.Errorf("ошибка маршалинга JSON: %w", err)
}

// Параметризованный запрос
query := `INSERT INTO users (id, metadata) VALUES ($1, $2)`
_, err = db.ExecContext(ctx, query, id, metadataJSON)
if err != nil {
    return fmt.Errorf("ошибка выполнения запроса: %w", err)
}

2. Подход с явным указанием типа JSONB

query := `INSERT INTO users (id, metadata) VALUES ($1, $2::jsonb)`
_, err = db.ExecContext(ctx, query, id, metadataJSON)

3. Использование именованных параметров (рекомендуется для сложных запросов)

type UserParams struct {
    ID       string `db:"id"`
    Metadata []byte `db:"metadata"`
}

params := UserParams{
    ID:       id,
    Metadata: metadataJSON,
}

query := `INSERT INTO users (id, metadata) VALUES (:id, :metadata)`
_, err = db.NamedExecContext(ctx, query, params)

4. Транзакционный подход для нескольких связанных операций

// Начинаем транзакцию
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
    return fmt.Errorf("ошибка начала транзакции: %w", err)
}
defer func() {
    if err != nil {
        tx.Rollback()
    }
}()

// Выполняем несколько операций
_, err = tx.ExecContext(ctx, `INSERT INTO users...`, ...)
if err != nil {
    return fmt.Errorf("ошибка вставки пользователя: %w", err)
}

_, err = tx.ExecContext(ctx, `INSERT INTO profiles...`, ...)
if err != nil {
    return fmt.Errorf("ошибка вставки профиля: %w", err)
}

// Фиксируем транзакцию
if err = tx.Commit(); err != nil {
    return fmt.Errorf("ошибка фиксации транзакции: %w", err)
}

Преимущества параметризованных запросов

  1. Безопасность: Защита от SQL-инъекций
  2. Читаемость: Более чистый и понятный код
  3. Переиспользование: Возможность повторного использования подготовленных запросов
  4. Производительность: Более эффективное выполнение запросов на сервере
  5. Типобезопасность: Корректное преобразование типов данных

Рекомендации для работы с JSONB

  1. Всегда используйте параметризованные запросы вместо прямого форматирования строк
  2. Маршалинг JSON-данных перед передачей в запрос
  3. Проверка ошибок на всех этапах
  4. Транзакции для атомарных операций с несколькими запросами
  5. Типизированные структуры для данных, которые хранятся в JSONB

Пример полной имплементации

func (r *Repository) CreateUserWithJSON(ctx context.Context, user *models.User) error {
    // Подготовка JSON-метаданных
    metadataObj := map[string]interface{}{
        "full_name": user.FullName,
        "settings": map[string]interface{}{
            "theme": "default",
            "notifications": true,
        },
    }

    metadataJSON, err := json.Marshal(metadataObj)
    if err != nil {
        return fmt.Errorf("ошибка маршалинга метаданных: %w", err)
    }

    // Начинаем транзакцию
    tx, err := r.db.BeginTxx(ctx, nil)
    if err != nil {
        return fmt.Errorf("ошибка начала транзакции: %w", err)
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // Вставляем пользователя
    userQuery := `
        INSERT INTO users (
            id, email, password_hash, status, created_at, updated_at, metadata
        ) VALUES (
            $1, $2, $3, $4, $5, $6, $7
        )
    `

    _, err = tx.ExecContext(ctx, userQuery,
        user.ID,
        user.Email,
        user.PasswordHash,
        user.Status,
        user.CreatedAt,
        user.UpdatedAt,
        metadataJSON,
    )

    if err != nil {
        return fmt.Errorf("ошибка создания пользователя: %w", err)
    }

    // Фиксируем транзакцию
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("ошибка фиксации транзакции: %w", err)
    }

    return nil
}

Тестирование

Для проверки различных подходов и их сравнения были созданы тесты в директории tests/. Запустите их с помощью скриптов:

  • ./run_test.sh - запуск функциональных тестов
  • ./run_benchmark.sh - запуск бенчмарков производительности

Рекомендации по работе с массивами и слайсами в JSONB

На основе тестирования было обнаружено, что при работе со слайсами/массивами требуется особый подход:

// Конвертируем []string в []interface{} для корректного маршалинга
var tagsInterface []interface{}
for _, tag := range tags {
    tagsInterface = append(tagsInterface, tag)
}

// Маршалим в JSON
var tagsJSON []byte
if len(tagsInterface) > 0 {
    tagsJSON, err = json.Marshal(tagsInterface)
    if err != nil {
        return nil, fmt.Errorf("ошибка маршалинга тегов: %w", err)
    }
} else {
    // Пустой массив в JSON
    tagsJSON = []byte("[]")
}

// Используем параметризованный запрос с явным указанием типа ::jsonb
query := `INSERT INTO wallets (id, tags) VALUES ($1, $2::jsonb)`
_, err = db.ExecContext(ctx, query, wallet.ID, string(tagsJSON))
// Важно: передаем преобразованный в строку JSON!

Результаты тестирования разных подходов

В результате тестирования были выявлены следующие особенности:

  1. Наиболее надежный метод для вставки JSONB:
// Передача JSON как строки с явным приведением типа ::jsonb
query := `INSERT INTO table (id, data) VALUES ($1, $2::jsonb)`
_, err = db.ExecContext(ctx, query, id, string(jsonData))
  1. При работе с NamedExec не используйте приведение типа в SQL:
// Правильно:
query := `INSERT INTO table (id, data) VALUES (:id, :data)`
// НЕ ИСПОЛЬЗОВАТЬ:
// query := `INSERT INTO table (id, data) VALUES (:id, :data::jsonb)`
  1. Для пустых структур и массивов:
// Пустой массив
emptyArrayJSON := []byte("[]")
// Пустой объект
emptyObjectJSON := []byte("{}")
  1. Поддержка разных типов данных:
  2. Строковые массивы работают корректно
  3. Объекты JSON работают корректно
  4. Вложенные структуры передаются и сохраняются без проблем

Особенности работы с UUID и JSONB одновременно

При работе с JSONB в таблицах, содержащих поля типа UUID, необходимо учитывать следующие особенности:

  1. Для полей с типом UUID в PostgreSQL нужно передавать именно UUID объекты, а не строки
  2. Преобразование строковых UUID в объекты UUID необходимо делать перед передачей в SQL-запрос

Пример правильного подхода:

// Модель с UUID хранящимся как строка
type Wallet struct {
    ID        string    // В Go используем строку
    CreatedBy string    // В Go используем строку
    Tags      []byte    // JSON в виде []byte
}

// Преобразуем ID из строки в UUID объект
var walletUUID uuid.UUID
if wallet.ID == "" {
    walletUUID = uuid.New()
    wallet.ID = walletUUID.String()
} else {
    // Преобразуем строку ID в UUID
    var err error
    walletUUID, err = uuid.Parse(wallet.ID)
    if err != nil {
        return fmt.Errorf("ошибка парсинга UUID для ID: %w", err)
    }
}

// Преобразуем CreatedBy из строки в UUID
userUUID, err := uuid.Parse(wallet.CreatedBy)
if err != nil {
    return fmt.Errorf("ошибка парсинга UUID для CreatedBy: %w", err)
}

// Маршалим JSON и преобразуем в строку
jsonStr := string(wallet.Tags)

// Запрос с параметрами
query := `
    INSERT INTO wallets (id, tags, created_by)
    VALUES ($1, $2::jsonb, $3)
`

// Выполняем запрос, передавая правильные типы
_, err = db.ExecContext(
    ctx, query,
    walletUUID,      // UUID объект для поля id (типа UUID)
    jsonStr,         // Строка для JSONB поля
    userUUID,        // UUID объект для поля created_by (типа UUID)
)

Типичные ошибки и их решения

  1. Ошибка: invalid input syntax for type json Решение: Передавайте JSON как строку (string(jsonBytes)) с явным указанием типа (::jsonb)

  2. Ошибка: invalid input syntax for type uuid: "some-uuid-string" Решение: Используйте uuid.Parse() для преобразования строк в UUID объекты

  3. Ошибка: cannot use type []string as type []interface{} Решение: Конвертируйте в []interface{} перед маршалингом

  4. Ошибка: sql: converting argument $N type: unsupported type []string Решение: Преобразуйте слайс в JSON с помощью json.Marshal перед передачей в запрос

Заключение

Безопасность работы с базой данных является критически важным аспектом разработки. Всегда используйте параметризованные запросы для работы с любыми данными, особенно с JSONB, которые могут содержать сложные структуры и специальные символы.

  1. Используйте параметризованные запросы с явным указанием типа ::jsonb
  2. Преобразуйте байты JSON в строку перед передачей: string(jsonBytes)
  3. Для работы с массивами строк используйте промежуточное преобразование в []interface{}
  4. Для полей типа UUID используйте объекты UUID, а не строки
  5. Всегда тщательно проверяйте API методов для передачи данных

Более подробные примеры работы с JSONB можно найти в существующих репозиториях проекта. Имплементация в backend/storage/repository/blockchain_repository.go предоставляет безопасные альтернативы для работы с JSONB и UUID и может быть использована как образец для рефакторинга существующего кода.