migraine 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|