migraine 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/README.md +264 -0
- data/Rakefile +7 -0
- data/examples/generated.rb +571 -0
- data/examples/generation.rb +9 -0
- data/examples/migration.rb +15 -0
- data/lib/migraine.rb +5 -0
- data/lib/migraine/generator.rb +103 -0
- data/lib/migraine/map.rb +85 -0
- data/lib/migraine/migration.rb +108 -0
- data/lib/migraine/version.rb +3 -0
- data/migraine.gemspec +23 -0
- data/spec/map_spec.rb +55 -0
- data/spec/migration_spec.rb +64 -0
- data/spec/spec_helper.rb +27 -0
- metadata +87 -0
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'migraine'
|
2
|
+
|
3
|
+
migration = Migraine::Migration.new(
|
4
|
+
to: 'mysql://root:root@localhost/spree_old',
|
5
|
+
from: 'mysql://root:root@localhost/spree_new'
|
6
|
+
)
|
7
|
+
|
8
|
+
migration.map 'users' => 'spree_users' do
|
9
|
+
map 'email'
|
10
|
+
# ...
|
11
|
+
map 'crypted_password' => 'encrypted_password'
|
12
|
+
# ...
|
13
|
+
end
|
14
|
+
|
15
|
+
migration.run
|
data/lib/migraine.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
module Migraine
|
2
|
+
# Module containing schema generators for different database
|
3
|
+
# adapters. To add support for a new adapter, simply add a
|
4
|
+
# method such as `database_schema_for_<adapter>`. The method
|
5
|
+
# should return a Hash containing table and column hierarchy,
|
6
|
+
# as follows:
|
7
|
+
#
|
8
|
+
# {
|
9
|
+
# 'table' => ['one_column', 'another_column'],
|
10
|
+
# 'another_table' => ['one_column']
|
11
|
+
# }
|
12
|
+
module Generator
|
13
|
+
# Generates a new migration file template by analyzing the
|
14
|
+
# source and destination connections, saving it to the
|
15
|
+
# location specified in the argument. This makes it easy to
|
16
|
+
# create migration files where you can fill in destination
|
17
|
+
# tables/columns instead of writing it all by hand.
|
18
|
+
#
|
19
|
+
# TODO: Factor out any dependencies on instance variables
|
20
|
+
# such as @source_connection and @destination_connection.
|
21
|
+
#
|
22
|
+
# @param [String] Relative path to file.
|
23
|
+
def generate(file)
|
24
|
+
connect
|
25
|
+
src = @source_connection
|
26
|
+
dest = @destination_connection
|
27
|
+
path = File.join(Dir.pwd, File.dirname($0), file)
|
28
|
+
|
29
|
+
File.open(path, 'w') do |file|
|
30
|
+
file.puts "require 'migraine'\n\n"
|
31
|
+
file.puts "migration = Migraine::Migration.new("
|
32
|
+
file.puts " from: '#{source}',"
|
33
|
+
file.puts " to: '#{destination}'"
|
34
|
+
file.puts ")\n\n"
|
35
|
+
|
36
|
+
dest_schema = database_schema_for(@destination)
|
37
|
+
database_schema_for(@source).each do |table, columns|
|
38
|
+
if dest_schema.has_key? table
|
39
|
+
file.puts "migration.map '#{table}' do"
|
40
|
+
elsif dest_schema.has_key? prefix + table
|
41
|
+
file.puts "migration.map '#{table}' => '#{prefix + table}'"
|
42
|
+
else
|
43
|
+
file.puts "migration.map '#{table}' => ''"
|
44
|
+
end
|
45
|
+
|
46
|
+
columns.each do |column|
|
47
|
+
if !dest_schema[table].nil? && dest_schema[table].include?(column)
|
48
|
+
file.puts " map '#{column}'"
|
49
|
+
elsif !dest_schema[prefix + table].nil? &&
|
50
|
+
dest_schema[prefix + table].include?(column)
|
51
|
+
file.puts " map '#{column}'"
|
52
|
+
else
|
53
|
+
file.puts " map '#{column}' => ''"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
file.puts "end\n\n"
|
58
|
+
end
|
59
|
+
|
60
|
+
file.puts "migration.run"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Fetches names of all tables and columns at specified URI
|
65
|
+
# (`@source` or `@destination`) and returns them neatly
|
66
|
+
# organized hierarchically in an array.
|
67
|
+
#
|
68
|
+
# @param [String] Database connection string, e.g.
|
69
|
+
# 'mysql://root:root@localhost/myproj'.
|
70
|
+
def database_schema_for(uri)
|
71
|
+
adapter = uri.split(':')[0]
|
72
|
+
send("database_schema_for_#{adapter}", uri)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Generates a database schema for a MySQL database using the
|
76
|
+
# available connections/instance variables. We do this by
|
77
|
+
# peeking into the information_schema database.
|
78
|
+
#
|
79
|
+
# @param [String] Database connection string, e.g.
|
80
|
+
# 'mysql://root:root@localhost/myproj'.
|
81
|
+
def database_schema_for_mysql(uri)
|
82
|
+
user_and_db = uri.split('://')[1]
|
83
|
+
user_and_host = user_and_db.split('/')[0]
|
84
|
+
source_db = user_and_db.split('/')[1]
|
85
|
+
schema = {}
|
86
|
+
|
87
|
+
db = Sequel.connect('mysql://' + user_and_host + '/information_schema')
|
88
|
+
db[:tables].
|
89
|
+
filter(table_schema: source_db).select(:table_name).
|
90
|
+
all.each do |record|
|
91
|
+
table = record[:table_name]
|
92
|
+
|
93
|
+
columns = db[:columns].
|
94
|
+
filter(table_schema: source_db, table_name: table).
|
95
|
+
select(:column_name).all.map { |record| record[:column_name] }
|
96
|
+
|
97
|
+
schema.merge!({ table => columns })
|
98
|
+
end
|
99
|
+
|
100
|
+
schema
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/lib/migraine/map.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
module Migraine
|
2
|
+
class Map
|
3
|
+
attr_accessor :source
|
4
|
+
attr_accessor :destination
|
5
|
+
attr_accessor :maps
|
6
|
+
|
7
|
+
# Sets the source and destination to use for mapping. Takes a
|
8
|
+
# Hash containing a single element as argument, where key
|
9
|
+
# specifies source and value specifies destination.
|
10
|
+
#
|
11
|
+
# This constructor should not be used publicly, but rather
|
12
|
+
# through the `Migraine::Map#map` DSL method which adds
|
13
|
+
# instances to the @maps array.
|
14
|
+
#
|
15
|
+
# Migraine::Map.new 'source_table' => 'destinatin_table'
|
16
|
+
#
|
17
|
+
# @param [Hash] Hash containing source and destination.
|
18
|
+
def initialize(source_and_destination)
|
19
|
+
begin
|
20
|
+
set_source_and_destination_from(source_and_destination)
|
21
|
+
rescue ArgumentError => e
|
22
|
+
puts e.message
|
23
|
+
exit
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Adds a new map to the @maps array. In other words, maps an
|
28
|
+
# old location to a new location one level below the current,
|
29
|
+
# be it at database, table or column level. This lets us have
|
30
|
+
# nested Maps, using the same class for any 'level'.
|
31
|
+
#
|
32
|
+
# For example, if the current source and destination are
|
33
|
+
# databases, this will map a source table to a destination
|
34
|
+
# table within that database. If current is a table, it will
|
35
|
+
# map column names.
|
36
|
+
#
|
37
|
+
# It accepts a block as optional argument, which will be
|
38
|
+
# evaluated against the new Map instance so that `#map` calls
|
39
|
+
# can be nested using a more convenient DSL.
|
40
|
+
#
|
41
|
+
# Usage in migration/mapping DSL:
|
42
|
+
#
|
43
|
+
# # Map#map at database-level, mapping table names
|
44
|
+
# @migration.map 'source_table' => 'destination_table' do
|
45
|
+
# # Map#map at table-level, mapping column names
|
46
|
+
# map 'source_column' => 'destination_column'
|
47
|
+
# end
|
48
|
+
def map(source_and_destination, &block)
|
49
|
+
@maps ||= []
|
50
|
+
map = Migraine::Map.new(source_and_destination)
|
51
|
+
|
52
|
+
if block_given?
|
53
|
+
map.instance_eval(&block)
|
54
|
+
end
|
55
|
+
|
56
|
+
@maps << map
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# Sets the `@source` and `@destination` instance variables
|
62
|
+
# from argument. Argument can be either a String (when source
|
63
|
+
# has the same name as destination) or a Hash (where both
|
64
|
+
# source and destination need to be specified.)
|
65
|
+
#
|
66
|
+
# Raises ArgumentError unless source and destination is
|
67
|
+
# properly defined in a Hash argument.
|
68
|
+
#
|
69
|
+
# @param [Hash] Hash containing source and destination.
|
70
|
+
def set_source_and_destination_from(s_and_d)
|
71
|
+
# If String is provided, convert it to Hash
|
72
|
+
s_and_d = { s_and_d => s_and_d } if s_and_d.is_a? String
|
73
|
+
|
74
|
+
unless s_and_d.is_a?(Hash) && s_and_d.length === 1
|
75
|
+
raise ArgumentError, "Argument must be a Hash containing one element;
|
76
|
+
source as key, destination as value."
|
77
|
+
end
|
78
|
+
|
79
|
+
s_and_d.each do |source, destination|
|
80
|
+
@source = source
|
81
|
+
@destination = destination
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
module Migraine
|
2
|
+
# This is a special case of a `Migraine::Map` as it specifies
|
3
|
+
# source and destination *databases* and provides methods to,
|
4
|
+
# for example, run the migration. It also overrides
|
5
|
+
# Migraine::Map#set_source_and_destination_from(s_and_d) to
|
6
|
+
# accept surce and destination in a more elegant way.
|
7
|
+
class Migration < Map
|
8
|
+
include Migraine::Generator
|
9
|
+
|
10
|
+
attr_accessor :prefix
|
11
|
+
|
12
|
+
# Runs the migration using the mappings that have been set
|
13
|
+
# up. Walks through the nested Map objects, copying database
|
14
|
+
# records from source to destination.
|
15
|
+
def run
|
16
|
+
connect
|
17
|
+
src = @source_connection
|
18
|
+
dest = @destination_connection
|
19
|
+
|
20
|
+
log "BEGINNING MIGRATION\n" +
|
21
|
+
"-------------------"
|
22
|
+
|
23
|
+
# Iterate through table mappings
|
24
|
+
maps.each do |table|
|
25
|
+
log "MIGRATING TABLE #{table.source}"
|
26
|
+
|
27
|
+
# Fetch all rows from source table
|
28
|
+
rows = src[table.source.to_sym].all
|
29
|
+
|
30
|
+
# Iterate through the records and migrate each one
|
31
|
+
rows.each do |row|
|
32
|
+
new_record = {}
|
33
|
+
|
34
|
+
# Identify the columns we need to grab data from
|
35
|
+
table.maps.each do |column|
|
36
|
+
new_record.merge!(
|
37
|
+
column.destination.to_sym => row[column.source.to_sym]
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Insert new record to destination table
|
42
|
+
log " -> Inserting record into #{table.destination}"
|
43
|
+
dest[table.destination.to_sym].insert(new_record)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
log "-------------------\n" +
|
48
|
+
"MIGRATION COMPLETED"
|
49
|
+
end
|
50
|
+
|
51
|
+
# DSL convenience method for skipping the assignment operator
|
52
|
+
# when specifying prefix.
|
53
|
+
#
|
54
|
+
# @param [String] Prefix to use in generations.
|
55
|
+
def prefix(new_prefix = nil)
|
56
|
+
return @prefix if new_prefix.nil?
|
57
|
+
@prefix = new_prefix
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
# Sets the `@source` and `@destination` instance variables
|
63
|
+
# from a Hash containing :to and :from keys. Raises
|
64
|
+
# ArgumentError unless a proper Hash is provided.
|
65
|
+
#
|
66
|
+
# @param [Hash] Hash containing source and destination.
|
67
|
+
def set_source_and_destination_from(s_and_d)
|
68
|
+
if !s_and_d.is_a? Hash || s_and_d.length != 2
|
69
|
+
raise ArgumentError, "Argument must be a Hash containing two elements;
|
70
|
+
source (:from) and destination (:to)."
|
71
|
+
elsif !s_and_d[:from] || !s_and_d[:to]
|
72
|
+
raise ArgumentError, "Both source (:from) and destination (:to)
|
73
|
+
need to be specified."
|
74
|
+
end
|
75
|
+
|
76
|
+
@source = s_and_d[:from]
|
77
|
+
@destination = s_and_d[:to]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Uses `@source` and `@destination` variables to initialize
|
81
|
+
# database connections. If source or destination is specified
|
82
|
+
# using a 'test://' URI, a `Sequel.sqlite` test database is
|
83
|
+
# used as connection.
|
84
|
+
def connect
|
85
|
+
unless @source && @destination
|
86
|
+
raise RuntimeError, 'Source and destination are not specified.'
|
87
|
+
end
|
88
|
+
|
89
|
+
return if @source_connection && @destination_connection
|
90
|
+
|
91
|
+
if @source =~ /^test:\/\//
|
92
|
+
@source_connection = Sequel.sqlite
|
93
|
+
else
|
94
|
+
@source_connection = Sequel.connect(@source)
|
95
|
+
end
|
96
|
+
|
97
|
+
if @destination =~ /^test:\/\//
|
98
|
+
@destination_connection = Sequel.sqlite
|
99
|
+
else
|
100
|
+
@destination_connection = Sequel.connect(@destination)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def log(message)
|
105
|
+
print "\n#{message}\n"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
data/migraine.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "migraine/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "migraine"
|
7
|
+
s.version = Migraine::VERSION
|
8
|
+
s.authors = ["Daniel Nordstrom"]
|
9
|
+
s.email = ["d@nintera.com"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Simple data migration scripts in Ruby.}
|
12
|
+
s.description = %q{Avoid a migraine when migrating your data from one database to another. Simply specify which, and where, tables and columns should be migrated.}
|
13
|
+
|
14
|
+
s.rubyforge_project = "migraine"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency "rake"
|
22
|
+
s.add_runtime_dependency "sequel"
|
23
|
+
end
|
data/spec/map_spec.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Migraine::Map do
|
4
|
+
before do
|
5
|
+
@map = Migraine::Map.new('old_location' => 'new_location')
|
6
|
+
end
|
7
|
+
|
8
|
+
describe "#new" do
|
9
|
+
it 'should set the source to map from' do
|
10
|
+
@map.source.must_equal 'old_location'
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should set the destination to map to' do
|
14
|
+
@map.destination.must_equal 'new_location'
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#set_source_and_destination_from' do
|
20
|
+
it 'should raise ArgumentError on invalid argument' do
|
21
|
+
begin
|
22
|
+
@map.send(:set_source_and_destination_from,
|
23
|
+
this: 'is not', a: 'valid argument'
|
24
|
+
)
|
25
|
+
rescue ArgumentError
|
26
|
+
raised = true
|
27
|
+
end
|
28
|
+
|
29
|
+
raised.wont_be_nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe '#map' do
|
34
|
+
it "should store new Migraine::Map instance" do
|
35
|
+
@map.map 'old_location' => 'new_location'
|
36
|
+
@map.maps.length.must_equal 1
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should accept a block for use as DSL" do
|
40
|
+
@map.maps = [] # Clear maps added by other tests
|
41
|
+
|
42
|
+
@map.map 'old_table' => 'new_table' do
|
43
|
+
map 'old_olumn' => 'new_column'
|
44
|
+
end
|
45
|
+
|
46
|
+
@map.maps[0].maps.length.must_equal 1
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should accept string argument" do
|
50
|
+
@map.maps = []
|
51
|
+
@map.map 'unchanged'
|
52
|
+
@map.maps[0].destination.must_equal 'unchanged'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
include SpecHelper
|
4
|
+
|
5
|
+
describe Migraine::Migration do
|
6
|
+
before do
|
7
|
+
@migration = Migraine::Migration.new(
|
8
|
+
from: 'test://myproj_old.db',
|
9
|
+
to: 'test://myproj.db'
|
10
|
+
)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "#new" do
|
14
|
+
it 'should set the source database' do
|
15
|
+
@migration.source.must_equal 'test://myproj_old.db'
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'should set the destination to map to' do
|
19
|
+
@migration.destination.must_equal 'test://myproj.db'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#set_source_and_destination_from' do
|
24
|
+
it 'should raise ArgumentError on invalid argument' do
|
25
|
+
begin
|
26
|
+
@migration.send(:set_source_and_destination_from,
|
27
|
+
'this is a Map argument' => 'Migration uses :to and :from'
|
28
|
+
)
|
29
|
+
rescue ArgumentError
|
30
|
+
raised = true
|
31
|
+
end
|
32
|
+
|
33
|
+
raised.wont_be_nil
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#connect' do
|
38
|
+
it "should initialize a database connections" do
|
39
|
+
@migration.send(:connect)
|
40
|
+
@migration.instance_variable_get(:@source_connection).
|
41
|
+
wont_be_nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#run' do
|
46
|
+
it "should run the migration" do
|
47
|
+
@migration = Migraine::Migration.new(from: 'test', to: 'test')
|
48
|
+
@migration.map 'old_table' => 'new_table' do
|
49
|
+
map 'old_column' => 'new_column'
|
50
|
+
end
|
51
|
+
|
52
|
+
create_test_connections_for(@migration)
|
53
|
+
|
54
|
+
@migration.run
|
55
|
+
|
56
|
+
# Check that new column now has one record, which was
|
57
|
+
# migrated from the source (inserted into source by
|
58
|
+
# `create_test_connections_for` helper).
|
59
|
+
@migration.instance_variable_get(
|
60
|
+
:@destination_connection)[:new_table].
|
61
|
+
count.must_equal 1
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|