· 8 years ago · Jan 14, 2018, 08:58 PM
1#!ruby
2# sequel/plugins/inline_migrations.rb
3
4require 'sequel'
5require 'sequel/model'
6
7Sequel.extension( :migration )
8
9
10# A plugin for Sequel::Model that allows migrations for the model to be defined directly
11# in the class declaration.
12#
13# @example Defining a model class with two migrations
14# class LAIKA::Vendor < LAIKA::Model( :vendor )
15#
16# # The schema should always be kept up-to-date. I.e., it should be
17# # modified along with each migration to reflect the state of the table
18# # after the migration is applied.
19# set_schema do
20# primary_key :id
21# String :name
22# String :contact
23# timestamp :created_at, :null => false
24# timestamp :updated_at
25#
26# add_index :name
27# end
28#
29# # Migrations have a symbolic name, which is how they're tracked in the
30# # migrations table, and how they're ordered when they're applied.
31# migration( '20110228_1115_add_timestamps', "Add timestamp fields" ) do
32# change do
33# alter_table do
34# add_column :created_at, :timestamp, :null => false
35# add_column :updated_at, :timestamp
36# end
37# update( :created_at => :now[] )
38# end
39# end
40#
41# migration( '20110303_1751_index_name', "Add an index to the name field" ) do
42# change do
43# alter_table do
44# add_index :name
45# end
46# end
47# end
48#
49# end
50#
51# @example Apply any pending migrations.
52# puts "Running pending migrations..."
53# Sequel::Plugins::InlineMigrations::Migrator.run( LAIKA::Model )
54#
55module Sequel::Plugins::InlineMigrations
56
57 ### Sequel plugin API -- Called the first time the plugin is loaded for
58 ### this model (unless it was already loaded by an ancestor class),
59 ### before including/extending any modules, with the arguments and block
60 ### provided to the call to Model.plugin.
61 def self::apply( model, *args )
62 @plugins ||= []
63 model.plugin( :subclasses ) # track subclasses
64 model.instance_variable_set( :@migrations, {} )
65 end
66
67
68 ### A mixin that gets applied to inline migrations to add introspection attributes
69 ### and accessors.
70 module MigrationIntrospection
71
72 ### Extension callback -- adds 'name', 'model_class', and 'description' instance
73 ### variables.
74 def self::extend_object( obj )
75 super
76 obj.instance_variable_set( :@description, nil )
77 obj.instance_variable_set( :@model_class, nil )
78 obj.instance_variable_set( :@name, nil )
79 end
80
81 attr_accessor :name, :model_class, :description
82
83 end # module MigrationIntrospection
84
85
86 # Methods to extend Model classes with.
87 module ClassMethods
88
89 # A Regexp for matching valid migration names
90 MIGRATION_NAME_PATTERN = /\A\d{8}_\d{4}_\w+\z/
91
92
93 # @return [Hash] the Hash of Sequel::SimpleMigration objects for this model, keyed
94 # by name
95 attr_reader :migrations
96
97
98 ### Add a migration with the specified +name+ and optional +description+. See the
99 ### docs for Sequel::Migration for usage, and Sequel::MigrationDSL for the allowed
100 ### syntax in the +block+.
101 ### @param [String] name the name of the migration, in the form:
102 ### <year><month><day>_<hour><minute>_<underbarred_desc>
103 ### @param [String] description a description of what the migration does, mainly for
104 ### introspection.
105 ### @yield Evaluated in the scope of the Sequel::Migration.
106 def migration( name, description=nil, &block )
107 raise ScriptError, "invalid migration name %p" % [ name ] unless
108 MIGRATION_NAME_PATTERN.match( name )
109
110 @migrations ||= {}
111 migration_obj = Sequel::MigrationDSL.create( &block )
112 migration_obj.extend( MigrationIntrospection )
113 migration_obj.name = name
114 migration_obj.model_class = self
115 migration_obj.description = description
116
117 @migrations[ name ] = migration_obj
118 end
119
120
121 ### Override Sequel::Model.create_table to also register any existing migrations as
122 ### being already applied, as the schema declared by set_schema should be the
123 ### *latest* schema, and already incorporate the changes described by the migrations.
124 def create_table( *args )
125 super
126
127 # Register existing migrations as already being applied
128 if self.migrations && !self.migrations.empty?
129 migrator = Sequel::Plugins::InlineMigrations::Migrator.new( self )
130 self.migrations.each_value do |migration|
131 migrator.dataset.
132 insert( :name => migration.name, :model_class => migration.model_class.name )
133 end
134 end
135 end
136
137 end # module ClassMethods
138
139
140 ### Subclass of Sequel::Migrator that provides the logic for extracting and running
141 ### migrations from the model classes themselves.
142 class Migrator < Sequel::Migrator
143
144 # Default options for .run and #initialize.
145 DEFAULT_OPTS = {
146 :table => :schema_migrations,
147 :column => :name,
148 }
149
150
151 ### Migrates the supplied +db+ using the migrations declared in the given +baseclass+.
152 ### @param [Sequel::Database] db the database to migrate.
153 ### @param [Class] baseclass the class to gather migrations from; it and all of its
154 ### descendents will be considered.
155 ### @param [Hash] opts options hash
156 ### @option opts [String] :column (:version) The column in the :table argument storing
157 ### the migration version.
158 ### @option opts [String] :current The current version of the database. If not given, it
159 ### is retrieved from the database using the :table and
160 ### :column options.
161 ### @option opts [String] :table (:schema_migrations) The column in the :table argument
162 ### storing the migration version.
163 ### @option opts [String] :target The target version to which to migrate. If not given,
164 ### migrates to the maximum version.
165 ###
166 ### @example
167 ### # Assuming LAIKA::Model is a Sequel::Model subclass, and LAIKA::Vendor is a subclass
168 ### # of that...
169 ### Sequel::InlineMigrations::Migrator.run( LAIKA::Model )
170 ### Sequel::InlineMigrations::Migrator.run( LAIKA::Model, :target => 15, :current => 10 )
171 ### Sequel::InlineMigrations::Migrator.run( LAIKA::Vendor, :column => :app2_version)
172 ### Sequel::InlineMigrations::Migrator.run( LAIKA::Vendor, :column => :app2_version,
173 ### :table => :schema_info2 )
174 def self::run( baseclass, db=nil, opts={} )
175 new( baseclass, db, opts ).run
176 end
177
178
179 ### Create a new Migrator that will organize migrations defined for
180 ### +baseclass+ or any of its subclasses for the specified +db+.
181 ### @see Sequel::Plugins::InlineMigrations::Migrator
182 def initialize( baseclass, db=nil, opts={} )
183 db ||= baseclass.db
184
185 opts = DEFAULT_OPTS.merge( opts )
186 schema, table = db.send( :schema_and_table, opts[:table] )
187
188 @db = db
189 @baseclass = baseclass
190 @table = schema ? Sequel::SQL::QualifiedIdentifier.new( schema, table ) : table
191 @column = opts[ :column ]
192 @dataset = make_schema_dataset( @db, @table, @column )
193 @target = opts[ :target ]
194 end
195
196
197 ######
198 public
199 ######
200
201 # The database to which the migrator will apply its migrations
202 # @return [Sequel::Database]
203 attr_reader :db
204
205 # The Class at the top of the hierarchy from which migrations will be fetched
206 # @return [Class]
207 attr_reader :baseclass
208
209 # The migration table
210 # @return [Symbol]
211 attr_reader :table
212
213 # The name of the column which will contain the names of applied migrations
214 # @return [Symbol]
215 attr_reader :column
216
217 # The migration table dataset
218 # @return [Sequel::Dataset]
219 attr_reader :dataset
220
221 # The target migration to play up or down to
222 # @return [String]
223 attr_reader :target
224
225
226 ### Apply all migrations to the database
227 def run
228 applied, pending = self.get_partitioned_migrations
229
230 # If no target was specified, and there are no pending
231 # migrations, return early.
232 return if pending.empty? && self.target.nil?
233
234 # If no target was specified, the last one is the target
235 target = self.target || pending.last.name
236 migrations = nil
237 direction = nil
238
239 # The most likely case is that it's pending, so start looking there
240 if tgtidx = pending.find_index {|m| m.name == target }
241 migrations = pending[ 0..tgtidx ]
242 direction = :up
243
244 elsif tgtidx = applied.find_index {|m| m.name == target }
245 migrations = applied[ tgtidx..-1 ].reverse
246 direction = :down
247 else
248 raise Sequel::Error, "couldn't find migration %p"
249 end
250
251 # Run the selected migrations
252 self.db.log_info "Migrating %d steps %s..." % [ migrations.length, direction ]
253 migrations.each do |migration|
254 start = Time.now
255 self.db.log_info "Begin applying migration %s, direction: %s" %
256 [ migration.name, direction ]
257
258 self.db.transaction do
259 migration.apply( self.db, direction )
260
261 mclass = migration.model_class.name
262 if direction == :up
263 self.dataset.insert( self.column => migration.name, :model_class => mclass )
264 else
265 self.dataset.filter( self.column => migration.name, :model_class => mclass ).delete
266 end
267 end
268
269 self.db.log_info " finished migration %s, direction: %s (%0.6fs)" %
270 [ migration.name, direction, Time.now - start ]
271 end
272 end
273
274
275 ### Fetch an Array of all model classes which are descended from the migrating subclass,
276 ### inclusive.
277 ### @return [Array<Class>]
278 def all_migrating_model_classes
279 return [ self.baseclass ] + self.baseclass.descendents
280 end
281
282
283 ### Returns any migration objects found in the migrating subclass or any of its
284 ### descendents, sorted by the migration name and the name of its migrating class.
285 ### @return [Array<Sequel::SimpleMigration>] the Array of migrations
286 def all_migrations
287 migrations = self.all_migrating_model_classes.
288 collect( &:migrations ).
289 compact.
290 inject {|all, hash| all.merge(hash) }
291
292 return migrations.values.sort_by {|m| [m.name, m.model_class.name] }
293 end
294
295
296 ### Returns two Arrays of migrations, the first one containing those which have already
297 ### been applied, and the second containing migrations which are pending.
298 ### @return [Array<Array, Array>]
299 ### @raise [Sequel::Error] if there is a migration that has been marked as applied, but
300 ### does not have a corresponding migration object.
301 def get_partitioned_migrations
302
303 # Get the list of applied migrations for the subclass and its descendents.
304 migrating_class_names = self.all_migrating_model_classes.map( &:name )
305 applied_map = self.dataset.
306 filter( :model_class => migrating_class_names ).
307 select_hash( column, :model_class )
308
309 self.db.log_info "Applied migrations: %p" % [ applied_map ]
310
311 # Split up the migrations by whether or not it exists in the map of applied migrations.
312 # Each one is removed from the map, so it can be checked for consistency
313 part_migrations = self.all_migrations.partition do |migration|
314 self.db.log_info " partitioning migration: [%p, %p]" %
315 [ migration.name, migration.model_class.name ]
316 applied_map.delete( migration.name )
317 end
318
319 # If there are any "applied" migrations left, that means we can't know if any of them
320 # correspond with a migration object, so rather than double-apply one, we abort.
321 unless applied_map.empty?
322 orphans = applied_map.collect {|tuple| tuple.reverse.join(':') }
323 raise Sequel::Error,
324 "%d applied migration/s are missing from the migrating classes: %s" %
325 [ orphans.length, orphans.join(', ') ]
326 end
327
328 return part_migrations
329 end
330
331
332 #######
333 private
334 #######
335
336 ### Returns the dataset for the schema_migrations table. If no such table
337 ### exists, it is automatically created.
338 def make_schema_dataset( db, table, column )
339 ds = db.from( table )
340
341 if !db.table_exists?( table )
342 db.create_table( table ) do
343 String column, :primary_key => true
344 String :model_class, :null => false
345 end
346 elsif !ds.columns.include?( column )
347 raise Sequel::Error, "Migrator table %p does not contain column %p" %
348 [ table, column ]
349 end
350
351 return ds
352 end
353
354 end # class Migrator
355
356end # Sequel::Plugins::InlineMigrations