Class: Ippon::Migrator

Inherits:
Object
  • Object
show all
Defined in:
lib/ippon/migrator.rb

Overview

Migrator provides a migration system on top of / Sequel. It should be trivial to add if you're already using Sequel to access your database, but you can also use it to migrate a database you're primarily accessing through other means.

Migrator provides a way to write migrations in a single file (no generators required) and you can integrate it in any system in minutes. As your application grows, it provides ways to structure migrations in multiple files.

Getting started

To get started created a file called migrate.rb and populate it:

require 'ippon/migrator'
require 'something_that_connects_to_your_database'

db = Sequel::DATABASES.last  # if you don't have access to it directly

m = Ippon::Migrator.new(db)

m.migrate "1-initial-schema" do |db|
  db.create_table(:users) do
    primary_key :id
    Text :email, null: false, unique: true
    Text :crypted_password
  end
end

m.print_summary
m.apply

You can now run it once to migrate,

$ ruby migrate.rb
** 1 migrations successfully loaded
** Migrating 1-initial-schema

and another time to see that it's not running the migration twice:

$ ruby migrate.rb
** 1 migrations successfully loaded

Append migrate.rb to add another migration. It's very important to not remove the previous migrations as the migrator needs to know about all migrations to work correctly. You should also be aware that the number “2” in the migration name below is not significant, and it's only the ordering of the #migrate calls that matter.

m.migrate "2-add-bio" do |db|
  db.add_column(:users, :bio, :text)
end

We will now see the following:

$ ruby migrate.rb
** 2 migrations successfully loaded
** Migrating 2-add-bio

Moving into files

After working with this system you will find that having the migrations in a single file is quite convenient. You don't need to worry about generators, and if you forgot the correct way create/remove/alter columns, you can easily peek at the recent migrations.

At a certain point however, the file become very large and hard to work with. Migrator provides a helper method #load_directory to help you split out into multiple files:

# migrate.rb

# (same setup as earlier)

m = Ippon::Migrator.new(db)
m.load_directory(__dir__ + "/migrations")
m.print_summary
m.apply

# migrations/2018-01.rb

migrate "1-initial-schema" do |db|
  db.create_table(:users) do
    primary_key :id
    Text :email, null: false, unique: true
    Text :crypted_password
  end
end

migrate "2-add-bio" do |db|
  db.add_column(:users, :bio, :text)
end

It's up to you to decide how to structure the files themselves. Migrator will load the files in order based on the filename, and the rest of the structuring is left to you. You might prefer to have one migration per file, one file per month, or split it manully into smaller files when you think it's too large. Because the migration name is in the code (and not dependent on the filename) you can always restructure later.

Key concepts

You need to know the following concepts:

  • The migrator stores a list of migrations.

  • Every migration has a name (string) and some code which tells how to apply it.

  • You use #migrate to declare migrations. The order in which you call this method decides the order in which migrations are applied.

  • The migration name must be globally unique. It's recommended to use some sort of manual counter: “2-add-name”. This ensures that if you five months later add a migration named “add-name” it won't collide with older migrations.

  • Note that the migration counter is not significant in any other way. If two developers create the migrations “2-add-name” and “2-add-bio” in different branches, you should not rename one of them to “3”. You should only make sure that they are declared in a correct order.

  • Use #load_directory to load migrations from different files.

  • You can use #unapplied_names to see if there are migrations that haven't been applied yet. You can for instance check this right before you boot your web server (or run your test suite) to verify that your database is correctly migrated.

  • The migrator stores information about the currently applied migrations in the schema_migrations table. This is created for you. Never touch this table manually.

Defined Under Namespace

Classes: Migration

Instance Method Summary collapse

Constructor Details

#initialize(db) ⇒ Migrator

Returns a new instance of Migrator.

Parameters:

  • db (Sequel::Database)

130
131
132
133
134
# File 'lib/ippon/migrator.rb', line 130

def initialize(db)
  @db = db
  @seen = Set.new
  @migrations = []
end

Instance Method Details

#applyObject

Applies the loaded migrations to the database.


166
167
168
169
170
171
# File 'lib/ippon/migrator.rb', line 166

def apply
  @migrations.each do |migration|
    apply_migration(migration)
  end
  nil
end

#load_directory(dir) ⇒ Object

Raises:

  • (ArgumentError)

    if dir is not a directory or doesn't exist


208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/ippon/migrator.rb', line 208

def load_directory(dir)
  if !File.directory?(dir)
    raise ArgumentError, "#{dir} is not a directory"
  end

  files = Dir[File.join(dir, "*.rb")]
  files.sort.each do |path|
    puts "[ ] Loading #{path}"
    instance_eval(File.read(path), path)
  end

  nil
end

#migrate(name) {|db| ... } ⇒ Object

Defines a migration.

Parameters:

  • name (#to_s)

    name of the migration

Yields:

  • (db)

    database instance (inside a transaction) where you can apply your migration

Raises:

  • (ArgumentError)

    if there is another migration defined with the same name


153
154
155
156
157
158
159
160
161
162
163
# File 'lib/ippon/migrator.rb', line 153

def migrate(name, &blk)
  name = name.to_s

  if @seen.include?(name)
    raise ArgumentError, "duplicate migration: #{name}"
  end

  @seen << name
  @migrations << Migration.new(name, blk)
  nil
end

Prints a summary of how many migrations were successfully loaded, together with a list of the migrations that have not yet been applied to the database.

Examples:

migrator = Ippon::Migrator.new(db)
migrator.load_directory(__dir__ + "/migrations")
migrator.print_summary

# Prints:
# ** 10 migrations successfully loaded
# !! 1-initial-schema not applied

200
201
202
203
204
205
# File 'lib/ippon/migrator.rb', line 200

def print_summary
  puts "[ ] #{@migrations.size} migrations successfully loaded"
  unapplied_names.each do |name|
    puts "[!] #{name} not applied"
  end
end

#unapplied_namesArray<String>

Returns migrations that have not yet been applied to the database.

Examples:

Verify that the database is correctly migrated

if !migrator.unapplied_names.empty?
  migrator.print_summary
  raise "Unapplied migrations. Cannot load application."
end

Returns:

  • (Array<String>)

    migrations that have not been applied to the database.


182
183
184
185
186
187
# File 'lib/ippon/migrator.rb', line 182

def unapplied_names
  seen = dataset.select_map(:name).to_set
  @migrations
    .select { |migration| !seen.include?(migration.name) }
    .map { |migration| migration.name }
end