migraine 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,9 @@
1
+ require 'migraine'
2
+
3
+ migration = Migraine::Migration.new(
4
+ from: 'mysql://root:root@localhost/migraine_from',
5
+ to: 'mysql://root:root@localhost/migraine_to'
6
+ )
7
+
8
+ migration.prefix 'spree_'
9
+ migration.generate 'generated.rb'
@@ -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,5 @@
1
+ require 'sequel'
2
+ require 'migraine/version'
3
+ require 'migraine/generator'
4
+ require 'migraine/map'
5
+ require 'migraine/migration'
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Migraine
2
+ VERSION = "0.0.1"
3
+ 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