← Назад к вопросам
В каких случаях нужны кастомные миграции
2.2 Middle🔥 101 комментариев
#Django#Базы данных (SQL)
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
# Кастомные миграции в БД: когда они нужны
Кастомные миграции — это когда разработчик пишет собственный SQL код вместо того, чтобы полагаться на автоматическое сгенерирование инструментами ORM.
1. Когда автогенерация недостаточна
Большинство инструментов миграций (Alembic, Django migrations) генерируют базовые DDL операции:
# Автогенерирует:
ALTER TABLE users ADD COLUMN email VARCHAR(255);
ALTER TABLE users DROP COLUMN phone;
CREATE INDEX idx_users_email ON users(email);
Но есть сценарии, где нужна кастомная логика.
2. Случаи, когда нужны кастомные миграции
Случай 1: Миграция данных (Data Migration)
-- Была таблица:
-- users: id, name, email, phone
-- Изменения:
-- 1. Добавляем новое поле
-- 2. Заполняем его данными из старого
-- 3. Удаляем старое поле
ALTER TABLE users ADD COLUMN country_code VARCHAR(2);
-- Это кастомная логика! Нельзя автогенерировать
UPDATE users
SET country_code = SUBSTRING(phone, 1, 2)
WHERE phone IS NOT NULL;
ALTER TABLE users DROP COLUMN phone;
На Python (Alembic):
from alembic import op
import sqlalchemy as sa
def upgrade():
# Добавляем колонку
op.add_column('users', sa.Column('country_code', sa.String(2)))
# Кастомная миграция данных
connection = op.get_bind()
connection.execute(
"UPDATE users SET country_code = SUBSTRING(phone, 1, 2) WHERE phone IS NOT NULL"
)
# Удаляем старую колонку
op.drop_column('users', 'phone')
def downgrade():
op.add_column('users', sa.Column('phone', sa.String(20)))
op.drop_column('users', 'country_code')
Случай 2: Переименование колонки (безопасно)
-- ❌ ПЛОХО - теряем данные
ALTER TABLE users DROP COLUMN user_name;
ALTER TABLE users ADD COLUMN name VARCHAR(255);
-- ✅ ПРАВИЛЬНО - кастомная миграция
ALTER TABLE users RENAME COLUMN user_name TO name;
На Python:
def upgrade():
# PostgreSQL
op.execute('ALTER TABLE users RENAME COLUMN user_name TO name')
# MySQL
op.execute('ALTER TABLE users CHANGE COLUMN user_name name VARCHAR(255)')
def downgrade():
op.execute('ALTER TABLE users RENAME COLUMN name TO user_name')
Случай 3: Создание индексов для оптимизации
from alembic import op
import sqlalchemy as sa
def upgrade():
# Добавляем новую колонку
op.add_column('posts', sa.Column('status', sa.String(20)))
# Кастомный индекс для быстрого поиска по статусу
op.execute(
'CREATE INDEX idx_posts_status_created '
'ON posts(status, created_at DESC) '
'WHERE status = \'published\'' # Partial Index!
)
def downgrade():
op.drop_index('idx_posts_status_created')
op.drop_column('posts', 'status')
Случай 4: Заполнение значений по умолчанию
def upgrade():
# Добавляем поле с обязательным значением
op.add_column(
'users',
sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='false')
)
# Кастомная логика для существующих данных
connection = op.get_bind()
# Помечаем как верифицированы только админов
connection.execute(
"UPDATE users SET is_verified = TRUE WHERE role = 'admin'"
)
# Остальные остаются FALSE (по умолчанию)
def downgrade():
op.drop_column('users', 'is_verified')
Случай 5: Денормализация для производительности
def upgrade():
# Добавляем денормализованное поле
op.add_column(
'products',
sa.Column('total_reviews_count', sa.Integer(), default=0)
)
# Заполняем начальные значения
connection = op.get_bind()
connection.execute("""
UPDATE products p
SET total_reviews_count = (
SELECT COUNT(*) FROM reviews r WHERE r.product_id = p.id
)
""")
def downgrade():
op.drop_column('products', 'total_reviews_count')
Случай 6: Сложные преобразования JSON
def upgrade():
# Меняем структуру JSON данных
connection = op.get_bind()
# Было: {"first_name": "John", "last_name": "Doe"}
# Стало: {"full_name": "John Doe"}
connection.execute("""
UPDATE users
SET metadata = jsonb_set(
metadata - 'first_name' - 'last_name',
'{full_name}',
to_jsonb(metadata->>'first_name' || ' ' || metadata->>'last_name')
)
""")
def downgrade():
pass # Реверс очень сложен, может быть невозможен
Случай 7: Создание VIEW
def upgrade():
op.execute("""
CREATE VIEW user_stats AS
SELECT
u.id,
u.name,
COUNT(p.id) as post_count,
COUNT(c.id) as comment_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
LEFT JOIN comments c ON u.id = c.user_id
GROUP BY u.id, u.name
""")
def downgrade():
op.execute('DROP VIEW IF EXISTS user_stats')
Случай 8: Добавление триггеров
def upgrade():
op.execute("""
CREATE TRIGGER update_post_modified
BEFORE UPDATE ON posts
FOR EACH ROW
BEGIN
SET NEW.updated_at = NOW();
END
""")
def downgrade():
op.execute('DROP TRIGGER IF EXISTS update_post_modified')
Случай 9: Bulk операции с большими таблицами
def upgrade():
op.add_column('orders', sa.Column('category', sa.String(50)))
connection = op.get_bind()
# Обновляем большую таблицу батчами (не весь датасет за раз)
batch_size = 10000
offset = 0
while True:
connection.execute("""
UPDATE orders
SET category = CASE
WHEN amount < 100 THEN 'small'
WHEN amount < 1000 THEN 'medium'
ELSE 'large'
END
WHERE category IS NULL
LIMIT :batch_size
""", {'batch_size': batch_size})
# Проверяем, остались ли строки для обновления
result = connection.execute(
"SELECT COUNT(*) FROM orders WHERE category IS NULL"
)
if result.fetchone()[0] == 0:
break
def downgrade():
op.drop_column('orders', 'category')
Случай 10: Работа с ENUM типами
def upgrade():
# PostgreSQL
op.execute("CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'deleted')")
op.add_column(
'users',
sa.Column('status', sa.Enum('active', 'inactive', 'deleted'), default='active')
)
# Миграция данных
connection = op.get_bind()
connection.execute("""
UPDATE users
SET status = CASE
WHEN is_deleted THEN 'deleted'::status_enum
WHEN is_active THEN 'active'::status_enum
ELSE 'inactive'::status_enum
END
""")
op.drop_column('users', 'is_deleted')
op.drop_column('users', 'is_active')
def downgrade():
# Реверс сложный, обычно не реализуют полностью
pass
3. Best Practices для кастомных миграций
# ✅ ПРАВИЛЬНО
def upgrade():
# 1. Добавляем с default для обратной совместимости
op.add_column(
'users',
sa.Column('new_field', sa.String(100), server_default='default_value')
)
# 2. Отдельно обновляем данные
connection = op.get_bind()
connection.execute("UPDATE users SET new_field = 'actual_value' WHERE condition")
# 3. Затем удаляем default если больше не нужен
op.alter_column('users', 'new_field', server_default=None)
# ❌ ПЛОХО
def upgrade():
# Не добавляй новые constraints сразу
op.add_column(
'users',
sa.Column('email', sa.String(100), nullable=False, unique=True)
)
# Если в таблице уже есть строки, это упадет!
4. Когда НЕ нужны кастомные миграции
# ✅ Автогенерация справится:
op.add_column('users', sa.Column('email', sa.String(255))) # Новая колонка
op.drop_column('users', 'old_field') # Удаление пустой колонки
op.create_index(...) # Простой индекс
op.create_table(...) # Новая таблица
op.add_constraint(...) # Constraint без данных
Резюме: Кастомные миграции нужны для:
- Data migrations — преобразование существующих данных
- Rename операции — безопасное переименование
- Сложные индексы — partial indexes, expressions
- Заполнение значений — логика для default значений
- Денормализация — добавление кэш-полей
- JSON преобразования — сложные структуры
- Views и Triggers — функции БД
- Bulk операции — обновление больших таблиц
- ENUM типы — с миграцией из старых типов
- Откат операций — downgrade сложной логики
Правило: если ORM/инструмент не может это сделать безопасно, пиши кастомную миграцию!