miguel 0.1.0.pre1
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.
- 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 #
|