miguel 0.1.0.pre1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/Rakefile +10 -0
- data/TODO +2 -0
- data/bin/miguel +9 -0
- data/lib/miguel/command.rb +242 -0
- data/lib/miguel/dumper.rb +45 -0
- data/lib/miguel/importer.rb +232 -0
- data/lib/miguel/migrator.rb +242 -0
- data/lib/miguel/schema.rb +585 -0
- data/lib/miguel/version.rb +10 -0
- data/lib/miguel.rb +3 -0
- data/miguel.gemspec +26 -0
- data/test/test_dumper.rb +90 -0
- metadata +87 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1c7f6a7a9e366374d9d88b5c5dde5e3ca0e8e9fa
|
4
|
+
data.tar.gz: 99bc1f4cb8a9ab1bc6f84b8595f541d5226b6f0c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3e1d62b3872b4961a7df1276cf82ec89829b81d05560ce3d435d48f9a077bc60e46a919a4b21ae7131e24c01ceb521ebd176120525a01ff7f8d989be66335835
|
7
|
+
data.tar.gz: 483d87a4bd5c54251dce7255e99e54238ddcc6a729d820c881fe841153732854b55b697895b0749d3cd6a07533ea237a43c66abb40dd6b0b4678436f4324908e
|
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
*~
|
data/Rakefile
ADDED
data/bin/miguel
ADDED
@@ -0,0 +1,242 @@
|
|
1
|
+
# Command line driver.
|
2
|
+
|
3
|
+
require 'miguel'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
module Miguel
|
7
|
+
|
8
|
+
# Miguel command line interface.
|
9
|
+
class Command
|
10
|
+
|
11
|
+
attr_reader :env, :format, :loggers, :force, :quiet, :trace
|
12
|
+
|
13
|
+
# Initialize the command options.
|
14
|
+
def init
|
15
|
+
@env = nil
|
16
|
+
@format = nil
|
17
|
+
@loggers = []
|
18
|
+
@force = nil
|
19
|
+
@quiet = nil
|
20
|
+
@trace = nil
|
21
|
+
end
|
22
|
+
|
23
|
+
# Run the command.
|
24
|
+
def run( args )
|
25
|
+
args = args.dup
|
26
|
+
init
|
27
|
+
OptionParser.new( &method( :set_opts ) ).parse!( args )
|
28
|
+
execute( args )
|
29
|
+
exit 0
|
30
|
+
rescue Exception => e
|
31
|
+
raise if trace or e.is_a?( SystemExit )
|
32
|
+
$stderr.print "#{e.class}: " unless e.is_a?( RuntimeError )
|
33
|
+
$stderr.puts e.message
|
34
|
+
exit 1
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# Set the command options.
|
40
|
+
def set_opts( opts )
|
41
|
+
opts.banner = "Miguel: The Database Migrator and Migration Generator for Sequel"
|
42
|
+
opts.define_head "Usage: miguel [options] <command> <db|schema> [db|schema]"
|
43
|
+
opts.separator ""
|
44
|
+
opts.separator "Examples:"
|
45
|
+
opts.separator " miguel show mysql://localhost/main"
|
46
|
+
opts.separator " miguel dump schema.rb"
|
47
|
+
opts.separator " miguel diff db.yml test.yml"
|
48
|
+
opts.separator " miguel apply db.yml schema.rb"
|
49
|
+
opts.separator ""
|
50
|
+
opts.separator "Commands:"
|
51
|
+
opts.separator " show <db|schema> Show schema of given database or schema file"
|
52
|
+
opts.separator " dump <db|schema> Dump migration which creates given schema"
|
53
|
+
opts.separator " down <db|schema> Dump migration which reverses given schema"
|
54
|
+
opts.separator " diff <db|schema> <db|schema> Dump migration needed to migrate to the second schema"
|
55
|
+
opts.separator " apply <db> <db|schema> Apply given schema to the database"
|
56
|
+
opts.separator " clear <db> Drop all tables in given database"
|
57
|
+
opts.separator ""
|
58
|
+
opts.separator "Options:"
|
59
|
+
|
60
|
+
opts.on_tail( '-h', '-?', '--help', 'Show this message' ) do
|
61
|
+
puts opts
|
62
|
+
exit
|
63
|
+
end
|
64
|
+
|
65
|
+
opts.on( '-e', '--env <env>', 'Use given environment config for database(s)' ) do |v|
|
66
|
+
@env = v
|
67
|
+
end
|
68
|
+
|
69
|
+
opts.on( '-E', '--echo', 'Echo SQL statements' ) do
|
70
|
+
require 'logger'
|
71
|
+
@loggers << Logger.new( $stdout )
|
72
|
+
end
|
73
|
+
|
74
|
+
opts.on( '-f', '--force', 'Force changes to be applied without confirmation' ) do
|
75
|
+
@force = true
|
76
|
+
end
|
77
|
+
|
78
|
+
opts.on( '-l', '--log <file>', 'Log SQL statements to given file' ) do |v|
|
79
|
+
require 'logger'
|
80
|
+
@loggers << Logger.new( v )
|
81
|
+
end
|
82
|
+
|
83
|
+
formats = [ :bare, :change, :full ]
|
84
|
+
opts.on( '-m', '--migration <format>', formats, "Select format for dumped migrations (#{formats.join(', ')})" ) do |v|
|
85
|
+
@format = v
|
86
|
+
end
|
87
|
+
|
88
|
+
opts.on( '-q', '--quiet', "Don't display the changes to be applied" ) do
|
89
|
+
@quiet = true
|
90
|
+
end
|
91
|
+
|
92
|
+
opts.on( '-t', '--trace', 'Show full backtrace if an exception is raised' ) do
|
93
|
+
@trace = true
|
94
|
+
end
|
95
|
+
|
96
|
+
opts.on_tail( '-v', '--version', 'Print version' ) do
|
97
|
+
puts "miguel #{Miguel::VERSION}"
|
98
|
+
exit
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Execute the command itself.
|
103
|
+
def execute( args )
|
104
|
+
command = args.shift or fail "Missing command, use -h to see usage."
|
105
|
+
case command
|
106
|
+
when 'show'
|
107
|
+
check_args( args, 1 )
|
108
|
+
schema = get_schema( args.shift )
|
109
|
+
print schema.dump
|
110
|
+
when 'dump'
|
111
|
+
check_args( args, 1 )
|
112
|
+
schema = get_schema( args.shift )
|
113
|
+
show_changes( Schema.new, schema )
|
114
|
+
when 'down'
|
115
|
+
check_args( args, 1 )
|
116
|
+
schema = get_schema( args.shift )
|
117
|
+
show_changes( schema, Schema.new )
|
118
|
+
when 'diff'
|
119
|
+
check_args( args, 2 )
|
120
|
+
old_schema = get_schema( args.shift )
|
121
|
+
new_schema = get_schema( args.shift )
|
122
|
+
show_changes( old_schema, new_schema )
|
123
|
+
when 'apply'
|
124
|
+
check_args( args, 2 )
|
125
|
+
db = get_db( args.shift )
|
126
|
+
schema = get_schema( args.shift )
|
127
|
+
apply_schema( db, schema )
|
128
|
+
when 'clear'
|
129
|
+
check_args( args, 1 )
|
130
|
+
db = get_db( args.shift )
|
131
|
+
apply_schema( db, Schema.new )
|
132
|
+
else
|
133
|
+
fail "Invalid command, use -h to see usage."
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Make sure the argument count is as expected.
|
138
|
+
def check_args( args, count )
|
139
|
+
fail "Not enough arguments present', use -h to see usage." if args.count < count
|
140
|
+
fail "Extra arguments present', use -h to see usage." if args.count > count
|
141
|
+
end
|
142
|
+
|
143
|
+
# Import schema from given database.
|
144
|
+
def import_schema( db )
|
145
|
+
Importer.new( db ).schema
|
146
|
+
end
|
147
|
+
|
148
|
+
# Get schema from given schema file or database.
|
149
|
+
def get_schema( name )
|
150
|
+
schema = if name.nil? or name.empty?
|
151
|
+
fail "Missing database or schema name."
|
152
|
+
elsif File.exist?( name ) and name =~ /\.rb\b/
|
153
|
+
Schema.load( name ) or fail "No schema loaded from file '#{name}'."
|
154
|
+
else
|
155
|
+
db = get_db( name )
|
156
|
+
import_schema( db )
|
157
|
+
end
|
158
|
+
schema
|
159
|
+
end
|
160
|
+
|
161
|
+
# Connect to given database.
|
162
|
+
def get_db( name )
|
163
|
+
db = if name.nil? or name.empty?
|
164
|
+
fail "Missing database name."
|
165
|
+
elsif File.exist?( name )
|
166
|
+
require 'yaml'
|
167
|
+
config = YAML.load_file( name )
|
168
|
+
env = self.env || "development"
|
169
|
+
config = config[ env ] || config[ env.to_sym ] || config
|
170
|
+
config.keys.each{ |k| config[ k.to_sym ] = config.delete( k ) }
|
171
|
+
Sequel.connect( config )
|
172
|
+
else
|
173
|
+
Sequel.connect( name )
|
174
|
+
end
|
175
|
+
db.loggers = loggers
|
176
|
+
db
|
177
|
+
end
|
178
|
+
|
179
|
+
# Show changes between the two schemas.
|
180
|
+
def show_changes( from, to )
|
181
|
+
m = Migrator.new
|
182
|
+
case format
|
183
|
+
when nil, :bare
|
184
|
+
print m.changes( from, to )
|
185
|
+
when :change
|
186
|
+
print m.change_migration( from, to )
|
187
|
+
when :full
|
188
|
+
print m.full_migration( from, to )
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Apply given schema to given database.
|
193
|
+
def apply_schema( db, schema )
|
194
|
+
from = import_schema( db )
|
195
|
+
changes = Migrator.new.changes( from, schema ).to_s
|
196
|
+
|
197
|
+
if changes.empty?
|
198
|
+
puts "No changes are necessary." unless quiet
|
199
|
+
return
|
200
|
+
end
|
201
|
+
|
202
|
+
unless quiet
|
203
|
+
puts "These changes will be applied to the database:"
|
204
|
+
print changes
|
205
|
+
end
|
206
|
+
|
207
|
+
unless force
|
208
|
+
fail "OK, aborting." unless confirm?
|
209
|
+
end
|
210
|
+
|
211
|
+
db.instance_eval( changes )
|
212
|
+
|
213
|
+
puts "OK, those changes were applied." unless quiet
|
214
|
+
end
|
215
|
+
|
216
|
+
# Ask the user for a confirmation.
|
217
|
+
def confirm?
|
218
|
+
loop do
|
219
|
+
print "Confirm (yes or no)? "
|
220
|
+
|
221
|
+
unless line = $stdin.gets
|
222
|
+
puts
|
223
|
+
puts "I take EOF as 'no'. Use --force if you want to skip the confirmation instead."
|
224
|
+
return
|
225
|
+
end
|
226
|
+
|
227
|
+
case line.chomp.downcase
|
228
|
+
when 'yes'
|
229
|
+
return true
|
230
|
+
when 'no'
|
231
|
+
return false
|
232
|
+
else
|
233
|
+
puts "Please answer 'yes' or 'no'."
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
end
|
239
|
+
|
240
|
+
end
|
241
|
+
|
242
|
+
# EOF #
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# Simple dumper.
|
2
|
+
|
3
|
+
module Miguel
|
4
|
+
|
5
|
+
# Class for dumping indented code blocks.
|
6
|
+
class Dumper
|
7
|
+
|
8
|
+
# Create new dumper.
|
9
|
+
def initialize( out = [], step = 2 )
|
10
|
+
@out = out
|
11
|
+
@indent = 0
|
12
|
+
@step = step
|
13
|
+
end
|
14
|
+
|
15
|
+
# Get all output gathered so far as a string.
|
16
|
+
def text
|
17
|
+
@out.join
|
18
|
+
end
|
19
|
+
|
20
|
+
alias to_s text
|
21
|
+
|
22
|
+
# Append given line/block to the output.
|
23
|
+
#
|
24
|
+
# If block is given, it is automatically enclosed between do/end keywords
|
25
|
+
# and anything dumped within it is automatically indented.
|
26
|
+
def dump( line )
|
27
|
+
if block_given?
|
28
|
+
dump "#{line} do"
|
29
|
+
@indent += @step
|
30
|
+
yield
|
31
|
+
@indent -= @step
|
32
|
+
dump "end"
|
33
|
+
else
|
34
|
+
@out << "#{' ' * @indent}#{line}\n"
|
35
|
+
end
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
alias << dump
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
# EOF #
|
@@ -0,0 +1,232 @@
|
|
1
|
+
# Sequel schema import.
|
2
|
+
|
3
|
+
require 'miguel/schema'
|
4
|
+
|
5
|
+
module Miguel
|
6
|
+
|
7
|
+
# Class for importing database schema from Sequel database.
|
8
|
+
class Importer
|
9
|
+
|
10
|
+
# The database we operate upon.
|
11
|
+
attr_reader :db
|
12
|
+
|
13
|
+
# Create new instance for importing schema from given database.
|
14
|
+
def initialize( db )
|
15
|
+
@db = db
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Which characrets we convert when parsing enum values.
|
21
|
+
# Quite likely not exhaustive, but sufficient for our purposes.
|
22
|
+
ESCAPED_CHARS = {
|
23
|
+
"''" => "'",
|
24
|
+
"\\\\" => "\\",
|
25
|
+
"\\n" => "\n",
|
26
|
+
"\\t" => "\t",
|
27
|
+
}
|
28
|
+
|
29
|
+
# Regexp matching escaped sequences in enum values.
|
30
|
+
ESCAPED_CHARS_RE = /''|\\./
|
31
|
+
|
32
|
+
# Parse the element values for enum/set types.
|
33
|
+
def parse_elements( string )
|
34
|
+
string.scan(/'((?:[^']|'')*)'/).flatten.map do
|
35
|
+
|x| x.gsub( ESCAPED_CHARS_RE ){ |c| ESCAPED_CHARS[ c ] || c }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Convert given database type to type and optional options used by our schema definitions.
|
40
|
+
# The ruby type provided serves as a hint of what Sequel's idea of the type is.
|
41
|
+
def revert_type_literal_internal( type, ruby_type )
|
42
|
+
|
43
|
+
return :boolean, :default_size => 1 if ruby_type == :boolean
|
44
|
+
|
45
|
+
case db.database_type
|
46
|
+
when :mysql
|
47
|
+
case type
|
48
|
+
when /\Aint\(\d+\)\z/
|
49
|
+
return :integer, :default_size => 11
|
50
|
+
when /\Aint\(\d+\) unsigned\z/
|
51
|
+
return :integer, :unsigned => true, :default_size => 10
|
52
|
+
when /\Abigint\(\d+\)\z/
|
53
|
+
return :bigint, :default_size => 20
|
54
|
+
when /\Adecimal\(\d+,\d+\)\z/
|
55
|
+
return :decimal, :default_size => [ 10, 0 ]
|
56
|
+
when /\A(enum|set)\((.*)\)\z/
|
57
|
+
return $1.to_sym, :elements => parse_elements( $2 )
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
case type
|
62
|
+
when /\Avarchar/
|
63
|
+
return :string, :default_size => 255
|
64
|
+
when /\Achar/
|
65
|
+
return :string, :fixed => true, :default_size => 255
|
66
|
+
when /\Atext\z/
|
67
|
+
return :string, :text => true
|
68
|
+
when /\A(\w+)\([\s\d,]+\)\z/
|
69
|
+
return $1.to_sym
|
70
|
+
when /\A\w+\z/
|
71
|
+
return type
|
72
|
+
end
|
73
|
+
|
74
|
+
ruby_type
|
75
|
+
end
|
76
|
+
|
77
|
+
# Convert given database type to type and optional options used by our schema definitions.
|
78
|
+
# The ruby type provided serves as a hint of what Sequel's idea of the type is.
|
79
|
+
def revert_type_literal( type, ruby_type )
|
80
|
+
|
81
|
+
case type
|
82
|
+
when /\(\s*(\d+)\s*\)/
|
83
|
+
size = $1.to_i
|
84
|
+
when /\(([\s\d,]+)\)/
|
85
|
+
size = $1.split( ',' ).map{ |x| x.to_i }
|
86
|
+
end
|
87
|
+
|
88
|
+
type, opts = revert_type_literal_internal( type, ruby_type )
|
89
|
+
|
90
|
+
opts ||= {}
|
91
|
+
|
92
|
+
default_size = opts.delete( :default_size )
|
93
|
+
|
94
|
+
if size and size != default_size
|
95
|
+
opts[ :size ] = size
|
96
|
+
end
|
97
|
+
|
98
|
+
[ type, opts ]
|
99
|
+
end
|
100
|
+
|
101
|
+
# Import indexes of given table.
|
102
|
+
def import_indexes( table )
|
103
|
+
# Foreign keys also automatically create indexes, which we must exclude when importing.
|
104
|
+
# But only if they look like indexes named by the automatic foreign key naming convention.
|
105
|
+
foreign_key_indexes = table.foreign_keys.map{ |x| x.columns if x.columns.size == 1 }.compact
|
106
|
+
for name, opts in db.indexes( table.name )
|
107
|
+
opts = opts.dup
|
108
|
+
columns = opts.delete( :columns )
|
109
|
+
next if ( ! opts[ :unique ] ) && foreign_key_indexes.include?( columns ) && name == columns.first
|
110
|
+
table.add_index( columns, opts )
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Import foreign keys of given table.
|
115
|
+
def import_foreign_keys( table )
|
116
|
+
for opts in db.foreign_key_list( table.name )
|
117
|
+
opts = opts.dup
|
118
|
+
name = opts.delete( :name )
|
119
|
+
columns = opts.delete( :columns )
|
120
|
+
table_name = opts.delete( :table )
|
121
|
+
table.add_foreign_key( columns, table_name, opts )
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Our custom mapping of some database defaults into the values we use in our schemas.
|
126
|
+
DEFAULT_CONSTANTS = {
|
127
|
+
"0000-00-00 00:00:00" => 0,
|
128
|
+
"CURRENT_TIMESTAMP" => Sequel.lit("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
129
|
+
}
|
130
|
+
|
131
|
+
# Options which are ignored for columns.
|
132
|
+
# These are usually just schema hints which the user normally doesn't specify.
|
133
|
+
IGNORED_OPTS = [ :max_length ]
|
134
|
+
|
135
|
+
# Import columns of given table.
|
136
|
+
def import_columns( table )
|
137
|
+
schema = db.schema( table.name )
|
138
|
+
|
139
|
+
# Get info about primary key columns.
|
140
|
+
|
141
|
+
primary_key_columns = schema.select{ |name, opts| opts[ :primary_key ] }
|
142
|
+
|
143
|
+
multi_primary_key = ( primary_key_columns.count > 1 )
|
144
|
+
|
145
|
+
# Import each column in sequence.
|
146
|
+
|
147
|
+
for name, opts in schema
|
148
|
+
|
149
|
+
opts = opts.dup
|
150
|
+
|
151
|
+
# Discard anything we don't need.
|
152
|
+
|
153
|
+
opts.delete_if{ |key, value| IGNORED_OPTS.include? key }
|
154
|
+
|
155
|
+
# Import type.
|
156
|
+
|
157
|
+
type = opts.delete( :type )
|
158
|
+
db_type = opts.delete( :db_type )
|
159
|
+
|
160
|
+
type, type_opts = revert_type_literal( db_type, type )
|
161
|
+
opts.merge!( type_opts ) if type_opts
|
162
|
+
|
163
|
+
# Import NULL option.
|
164
|
+
|
165
|
+
opts[ :null ] = opts.delete( :allow_null )
|
166
|
+
|
167
|
+
# Import default value.
|
168
|
+
|
169
|
+
default = opts.delete( :default )
|
170
|
+
ruby_default = opts.delete( :ruby_default )
|
171
|
+
|
172
|
+
default = DEFAULT_CONSTANTS[ default ] || ( ruby_default.nil? ? default : ruby_default )
|
173
|
+
|
174
|
+
unless default.nil?
|
175
|
+
opts[ :default ] = default
|
176
|
+
end
|
177
|
+
|
178
|
+
# Deal with primary keys, which is a bit obscure because of the auto-increment handling.
|
179
|
+
|
180
|
+
primary_key = opts.delete( :primary_key )
|
181
|
+
auto_increment = opts.delete( :auto_increment )
|
182
|
+
|
183
|
+
if primary_key && ! multi_primary_key
|
184
|
+
if auto_increment
|
185
|
+
table.add_column( :primary_key, name, opts.merge( :type => type ) )
|
186
|
+
next
|
187
|
+
end
|
188
|
+
opts[ :primary_key ] = primary_key
|
189
|
+
end
|
190
|
+
|
191
|
+
table.add_column( type, name, opts )
|
192
|
+
end
|
193
|
+
|
194
|
+
# Define multi-column primary key if necessary.
|
195
|
+
# Note that Sequel currently doesn't preserve the primary key order, so neither can we.
|
196
|
+
|
197
|
+
if multi_primary_key
|
198
|
+
table.add_column( :primary_key, primary_key_columns.map{ |name, opts| name } )
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Import all fields of given table.
|
203
|
+
def import_table( table )
|
204
|
+
# This must come first, so we can exclude foreign key indexes later.
|
205
|
+
import_foreign_keys( table )
|
206
|
+
import_indexes( table )
|
207
|
+
import_columns( table )
|
208
|
+
end
|
209
|
+
|
210
|
+
public
|
211
|
+
|
212
|
+
# Which tables we automatically ignore on import.
|
213
|
+
IGNORED_TABLES = [ :schema_info ]
|
214
|
+
|
215
|
+
# Import the database schema.
|
216
|
+
def schema
|
217
|
+
schema = Schema.new
|
218
|
+
|
219
|
+
for name in db.tables
|
220
|
+
next if IGNORED_TABLES.include? name
|
221
|
+
table = schema.add_table( name )
|
222
|
+
import_table( table )
|
223
|
+
end
|
224
|
+
|
225
|
+
schema
|
226
|
+
end
|
227
|
+
|
228
|
+
end
|
229
|
+
|
230
|
+
end
|
231
|
+
|
232
|
+
# EOF #
|