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.
@@ -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