Рекомендации по работе с JSONB в PostgreSQL¶
Этот документ содержит рекомендации по правильному и безопасному использованию типа данных JSONB в PostgreSQL из Go-кода.
Найденные проблемы¶
В текущей имплементации репозитория авторизации (auth_repository.go) используется небезопасный подход к работе с JSONB-данными:
- Прямое форматирование SQL-запросов с использованием
fmt.Sprintf() - Ручное экранирование строк, которое может быть неполным
- Отсутствие параметризации запросов
- Избыточная сложность кода и низкая читаемость
// Пример текущего подхода (небезопасно)
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)
}
Преимущества параметризованных запросов¶
- Безопасность: Защита от SQL-инъекций
- Читаемость: Более чистый и понятный код
- Переиспользование: Возможность повторного использования подготовленных запросов
- Производительность: Более эффективное выполнение запросов на сервере
- Типобезопасность: Корректное преобразование типов данных
Рекомендации для работы с JSONB¶
- Всегда используйте параметризованные запросы вместо прямого форматирования строк
- Маршалинг JSON-данных перед передачей в запрос
- Проверка ошибок на всех этапах
- Транзакции для атомарных операций с несколькими запросами
- Типизированные структуры для данных, которые хранятся в 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!
Результаты тестирования разных подходов¶
В результате тестирования были выявлены следующие особенности:
- Наиболее надежный метод для вставки JSONB:
// Передача JSON как строки с явным приведением типа ::jsonb
query := `INSERT INTO table (id, data) VALUES ($1, $2::jsonb)`
_, err = db.ExecContext(ctx, query, id, string(jsonData))
- При работе с NamedExec не используйте приведение типа в SQL:
// Правильно:
query := `INSERT INTO table (id, data) VALUES (:id, :data)`
// НЕ ИСПОЛЬЗОВАТЬ:
// query := `INSERT INTO table (id, data) VALUES (:id, :data::jsonb)`
- Для пустых структур и массивов:
- Поддержка разных типов данных:
- Строковые массивы работают корректно
- Объекты JSON работают корректно
- Вложенные структуры передаются и сохраняются без проблем
Особенности работы с UUID и JSONB одновременно¶
При работе с JSONB в таблицах, содержащих поля типа UUID, необходимо учитывать следующие особенности:
- Для полей с типом UUID в PostgreSQL нужно передавать именно UUID объекты, а не строки
- Преобразование строковых 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)
)
Типичные ошибки и их решения¶
-
Ошибка:
invalid input syntax for type jsonРешение: Передавайте JSON как строку (string(jsonBytes)) с явным указанием типа (::jsonb) -
Ошибка:
invalid input syntax for type uuid: "some-uuid-string"Решение: Используйтеuuid.Parse()для преобразования строк в UUID объекты -
Ошибка:
cannot use type []string as type []interface{}Решение: Конвертируйте в[]interface{}перед маршалингом -
Ошибка:
sql: converting argument $N type: unsupported type []stringРешение: Преобразуйте слайс в JSON с помощьюjson.Marshalперед передачей в запрос
Заключение¶
Безопасность работы с базой данных является критически важным аспектом разработки. Всегда используйте параметризованные запросы для работы с любыми данными, особенно с JSONB, которые могут содержать сложные структуры и специальные символы.
- Используйте параметризованные запросы с явным указанием типа
::jsonb - Преобразуйте байты JSON в строку перед передачей:
string(jsonBytes) - Для работы с массивами строк используйте промежуточное преобразование в
[]interface{} - Для полей типа UUID используйте объекты UUID, а не строки
- Всегда тщательно проверяйте API методов для передачи данных
Более подробные примеры работы с JSONB можно найти в существующих репозиториях проекта. Имплементация в backend/storage/repository/blockchain_repository.go предоставляет безопасные альтернативы для работы с JSONB и UUID и может быть использована как образец для рефакторинга существующего кода.