Ruby on Rails Cookbook - Миграции

Ruby on Rails Cookbook - Миграции - популярные вопросы

Всем привет. Решил собрать в кучу основные рецепты использования миграций в rails, со сниппетами  и быстрой навигацией, чтобы не пришлось гуглить каждый раз если вдруг что-то подзабыл. Может пригодится кому-то еще.

Каждую команду буду описывать в методе, чтобы было понятно, как использовать.

Операции с таблицей

Как создать таблицу


def change 
  create_table :articles do |t|
    t.string :title, null: false
    t.text   :description
    t.text   :body
    t.string :slug
    t.integer :user_id
  
    t.timestamps, null: false
  end
end

Как удалить таблицу

Миграция не откатываемая, поэтому 2 метода


def up
  drop_table :articles
end

def down
  create_table :articles do |t|
    t.string :title, null: false
    t.text   :description
    t.text   :body
    t.string :slug
    t.integer :user_id
  
    t.timestamps, null: false
  end
end

Как переименовать таблицу


def change 
  rename_table :articles, :blog_articles
end

Как изменить таблицу

Обязательно должна быть обратная миграция


def up
  change_table :articles do |t|
    t.remove :slug
    t.integer :rating
    t.rename :description, :short_description
  end
end

def down
  change_table :articles do |t|
    t.remove :rating
    t.string :slug
    t.rename :short_description, :description
  end
end

Как создать таблицу без первичного ключа

Иногда нужно создать таблицу бе ID. Делается это очень просто, добавлением id: false


def change
  create_table :user_subscriptions, id: false do |t|
    t.string :user_id, null: false
    t.string :category_id, null: false
  end
end

Операции со столбцами

Как добавить столбец


def change
  add_column :articles, :views, :integer, default: 0
end

Как удалить столбец

Обязательно должна быть обратная миграция


def up
  remove_column :articles, :views
end

def down
  add_column :articles, :views, :integer, default: 0
end

Как переименовать столбец


def change
  rename_column :articles, :views, :visits_count
end
Как указать значение по умолчанию
def change
  add_column :articles, :views, :integer, default: 0
end

Как изменить значение по умолчанию

А вот этот случай немного сложнее чем кажется, потому что кроме изменения значения по умолчанию обычно еще требуется изменить данные


class Migration 
  class Article < ActiveRecord::Base ; end  

  def up
    change_column :articles, :views, :integer, default: 1
    Article.where(views: 0).update_all(views: 1)
  end

  def down
    change_column :articles, :views, :integer, default: 0
    Article.where(views: 1).update_all(views: 0)
  end
end

Я объявил класс Article внутри миграции, потому что, на момент прогона миграции в приложении может быть уже другая модель, например BlogArticle. Важно помнить, что все миграции должны проходить с нуля у того, кто устанавливает проект и переименованая модель может это поломать. В моем случае миграция пройдет всегда.

Как изменить тип столбца


def up
  change_column :articles, :views, :string
end

def down
  change_column :articles, :views, :integer
end

Как привести тип при изменнии

Но, база данных не всегда способна самостоятельно выполнить преобразование типа. В этом случае ей нужно помочь. Например, вот пример миграции для PostgreSQL для преобразования json колонки в jsonb


def up
  change_column :settings, :config, 'jsonb USING CAST(config AS jsonb)'
end

def down
  change_column :settings, :config, 'json USING CAST(config AS json)'
end

Как добавить таймстампы в существующую таблицу


def change
  add_column :users, :created_at, :datetime
  add_column :users, :updated_at, :datetime
end

Как сделать значением по умолчанию текущее время

Казалось бы, что тут сложного


def change
  add_column :users, :registered_at, :datetime, null: false, default: Time.now
end

Но, проблема в том, что Time.now вернет значение в момент запуска миграции и именно оно будет всегда использоваться как значение по умолчанию.

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

Но, если уж ну очень надо - нужно использовать SQL команду, для каждой базы она своя


def change
  execute 'alter table foo alter column starts_at set default now()'
end

Как ограничить длину строк

Опять же, два метода, чтобы миграцию можно было откатить.


def up
  change_column :articles, :title, :string, limit: 255
end

def down
  change_column :articles, :title, :string, limit: nil
end

Этот же пример показывает и как убрать лимит по длине из строки

Как изменить NOT NULL constraint

Убрать NOT NULL ограничение очень просто.


def up
  change_column :articles, :published_at, :datetime, null: true
end


def down
  change_column :articles, :published_at, :datetime, null: true
end

А вот добавить NOT NULL немного сложнее, потому что сначала нужно заполинть существоющие колонки какими-то значениями. Иногда можно это сделать через значение по умолчанию, а иногда - придется заполнять самому


class Migration 
  class Article < ActiveRecord::Base ; end  

  def up
    change_column :articles, :views, :integer, default: 0, null: false
    Article.update_all(published_at: Time.now)
    change_column :articles, :published_at, :datetime, null: false
  end

  def down
    change_column :articles, :views, :integer, default: 0, null: true
    change_column :articles, :published_at, :datetime, null: true
  end
end

Как управлять размерностью и точностью для float


def up
  change_column :articles, :rating, :float, precision: 3, scale: 2
end

def down
  change_column :articles, :rating, :float, precision: nil, scale: nil
end

Это значит, что у нас рейтинг может максимум состоять из 3х цифр, две из которых после запятой. Например 4.75

Индексы

Как добавить индекс


def change
  add_index :article, :user_id
end

Как добавить индекс уникальности


def change
   add_index :users, :email, unique: true
end

Как добавить индекс на несколько колонок


def change
   add_index :users, [:first_name, :last_name]
end

Как указать имя индекса

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


 def change
   add_index :users, [:long_column_name, :another_long_column_name], name: 'custom_index_name'
 end
 

Миграция данных

Миграция данных обычно довольно трудоемкий процесс. Как правило, это нетривиальная задача, которая имеет несколько вариантов решения и каждый из них имеет свои преймущества и недостатки. У помещения кода миграции данных в миграции базы есть 2 основных преймущества:

  1. Эту миграцию не забудут прогнать
  2. В случае неудачи все изменеия откатятся вместе с самой миграцией.

Но, у такого подхода есть и ограничения. Все то время, пока проходят миграции таблицы будут заблокированы, а значит, что пользоваться вашим приложением будет невозможно. Но, если ваши измения не очень сложные, или их можно сделать достаточно быстрыми за счет ислзоваия чистого ruby или еще лучше - SQL, то им самое место в миграциях баз данных.

Как мигрировать данные с помощью ActiveRecord

Не забываем внутри объявлять такой же класс


 class MyMigration
   class User < ActiveRecord::Base;
   
   def up
     add_column :users, :auth_token, :string
     add_index :users, :auth_token, unique: true
     
     User.all.each { |user| user.update(auth_token: SecureRandom.hex(12))}
   end
   
   def down
     remove_column :users, :auth_token
   end
 end
 

Как мигрировать данные с помошью SQL

Рассмотрим на примере генерации слага для дружественного URL в таблице пользователей


class MyMigration
  class User < ActiveRecord::Base;
    
  def up
    add_column :users, :slug, :string
    add_index :users, :slug, unique: true
        
    #User.where(id: ids).find_each(&:save)
    # Нее будет работать если пользователей несколько десятков тысяч, нужно сделать быстрее
      
    User.connection.execute "UPDATE users SET slug = replace(lower(first_name || '-' || last_name), ' ', '-') WHERE slug IS NULL"
      
    # Слепили слаг из имени пользователя. Но. есть проблема - имена могут совпадать
      
    duplicated_slugs = User.select(:slug).group(:slug).having("count(*) > 1").count
      
    # Находим все записи с вовпадающими именами
      
    duplicated_slugs.keys.each do |slug|
      ids = User.where(slug: slug).pluck(:id)
      # Очищаем слаг для всех дубликатов
      User.where(id: ids).update_all(name_profile: nil)
      # Для всех таких записей, очевидно что их будет совсем немного, генерируем  слаг просто пересохраняя модель
      # Механизм генерации слага, в моем случае - gem friendly_id, сам сгенерирует нужное значение
      User.where(id: ids).find_each(&:save)
    end
  end
    
  def down
    remove_column :users, :slug
  end
end 

Как мигрировать данные с помощью ruby

Для миграции данных необязательно строить весь ActiveRecord объект, иногда можно работать просто с хешами.

ActiveRecord::Base.connection.execute ‘SELECT * FROM users’ Вернет объект типа PG::RESULT. Получить из него данные неудобно, но этот объект обладает всеми свойствами перечеслимого типа, а значит, можно использовать each, map, reduce и т.д.

Другая полезная информация

Как запускать миграцию без транзакции

Давайте снова рассмотрим пример, когда нам нужно отслеживать количество просмотров в статье. Очень простая задача, мы ее ужее решали чуть выше.


def change
  add_column :articles, :views, :integer, default: 0, null: false
end

Дело в том, что это будет работать, пока у нас относительно мало статей. Если их несколько деястков миллионов, такая миграция будет выполняться несколько часов и все это время приложение не будет работать нормально. Дело в том, что добавление новой колонки - очень быстрая операция, а вот заполнение миллионов строк нулями - нет. Но, так как все выполняется в одной транзакции, то приложение не будет полноценно работать все это время.

Решение очень простое


   сlass MyMigration
     disable_ddl_transaction!
     
     def up
       add_column :articles, :views, :integer
       change_column :articles, :views, :integer, default: 0, null: false
     end
      
     def down
       remove_column :articles, :views
     end
   end
   

Мы запретили выполнение внутри транзакции disable_ddl_transaction! и потом сделали добавление в 2 шага.

Какие есть полезные rake задачи для работы с миграциями

  1. rake db:create - Cоздать баду данных
  2. rake db:drop - Удалить базу данных
  3. rake db:migrate - Запустить миграцию базы
  4. rake db:rollback - Откатить миграцию данных к предыдущему состоянию
  5. rake db:schema:dump - Построение файла schema.rb на основе текущей структуры базы данных
  6. rake db:schema:load - Восстановление структуры базы данных из schema.rb
  7. rake db:seed - Запускает скрипт db/seed.rb который по идее должен заполнить базу первоначальными данными
  8. rake db:setup - Запустит поочереди create, schema:load, seed
  9. rake db:structure:dump - Сделает дамп структуры в db/structue.sql
  10. rake db:structure:load - Восстановит базу из db/structue.sql

Как запустить несколько задач одной командой

rake db:setup эквивалентно rake db:{create,schema:load,seed}

Как откатить миграцию

  1. rake db:rollback - Откатит последнюю миграцию. Важно - имеется ввиду именно файл миграции, их может быть несколько при выполнении rake db:migrate. Откатится только изменения последнего
  2. rake db:rollback STEP=3 - Откатит последние 3 файла
  3. rake db:migrate:down VERSION=integer - Откатит все изменения до указанной версии

Список доступных типов в Rails миграции

  • :binary
  • :boolean
  • :date
  • :datetime
  • :decimal
  • :float
  • :integer
  • :primary_key
  • :references
  • :string
  • :text
  • :time
  • :timestamp

Если используете PostgreSQL и Rails 4 и выше, то так же можно использовать

  • :hstore
  • :json
  • :array
  • :cidr_address
  • :ip_address
  • :mac_address

Если Вам есть чем дополнить, или Вы нашли ошибку - присылайте мне в комментариях.

Комментарии