sequel-inline_schema 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,281 @@
1
+ # -*- ruby -*-
2
+ # encoding: utf-8
3
+ # frozen-string-literal: true
4
+
5
+ require 'tsort'
6
+ require 'sequel'
7
+
8
+ # A replacement for Sequel's old built in schema plugin. It allows you to define
9
+ # your schema directly in the model using Model.set_schema (which takes a block
10
+ # similar to Database#create_table), and use Model.create_table to create a
11
+ # table using the schema information.
12
+ #
13
+ # ## Usage
14
+ #
15
+ # There are several ways to use this plugin.
16
+ #
17
+ # Add the schema methods to all model subclasses:
18
+ #
19
+ # Sequel::Model.plugin :inline_schema
20
+ #
21
+ # Add the schema methods to a particular class:
22
+ #
23
+ # Album.plugin :inline_schema
24
+ # Album.set_schema { ... }
25
+ # Album.create_table?
26
+ #
27
+ # Add the schema methods to an abstract base class:
28
+ #
29
+ # # lib/acme/model.rb
30
+ # require 'sequel'
31
+ # require 'acme'
32
+ #
33
+ # module ACME
34
+ # Model = Class.new( Sequel::Model )
35
+ # Model.def_Model( ACME )
36
+ #
37
+ # class Model
38
+ # plugin :inline_schema
39
+ # end
40
+ # end
41
+ #
42
+ # # lib/acme/product.rb
43
+ # require 'acme/model'
44
+ #
45
+ # class ACME::Product < ACME::Model( :products )
46
+ #
47
+ # set_schema do
48
+ # primary_key :id
49
+ # String :sku, null: false
50
+ # String :name, null: false
51
+ # ...
52
+ # end
53
+ #
54
+ # end
55
+ #
56
+ # ## Notable Model Methods
57
+ #
58
+ # See Sequel::Plugins::InlineSchema::ClassMethods for documentation for the methods the
59
+ # plugin adds to your model class/es.
60
+ #
61
+ # Of particular note:
62
+ #
63
+ # A model class with an inline schema has several methods for creating/dropping its
64
+ # associated table:
65
+ #
66
+ # * create_table
67
+ # * create_table!
68
+ # * create_table?
69
+ # * table_exists?
70
+ # * drop_table
71
+ # * drop_table?
72
+ #
73
+ # If you use it with an abstract base class, you can ask the base class which of
74
+ # its subclasses need their tables created:
75
+ #
76
+ # * uninstalled_tables
77
+ #
78
+ # It can also define hooks for creating and dropping the table:
79
+ #
80
+ # * before_create_table
81
+ # * after_create_table
82
+ # * before_drop_table
83
+ # * after_drop_table
84
+ #
85
+ # As with other Sequel
86
+ # [model hooks](http://sequel.jeremyevans.net/rdoc/files/doc/model_hooks_rdoc.html),
87
+ # you can prevent the action from the `before_*` hooks by calling `cancel_action`.
88
+ module Sequel::Plugins::InlineSchema
89
+
90
+
91
+ ### Sequel plugin API -- called the first time the plugin is loaded for this
92
+ ### +model+.
93
+ def self::apply( model, *args ) # :nodoc:
94
+ model.plugin( :subclasses ) # track subclasses
95
+ model.extend( TSort )
96
+ model.require_valid_table = false
97
+ end
98
+
99
+
100
+ # Sequel plugin API -- add these methods to model classes which load the plugin.
101
+ module ClassMethods
102
+
103
+ ### Creates table, using the column information from set_schema.
104
+ def create_table( *args, &block )
105
+ self.set_schema( *args, &block ) if block
106
+ self.before_create_table
107
+ self.db.create_table( self.table_name, generator: self.schema )
108
+ @db_schema = get_db_schema( true )
109
+ self.after_create_table
110
+ return self.columns
111
+ end
112
+
113
+
114
+ ### Drops the table if it exists and then runs create_table. Should probably
115
+ ### not be used except in testing.
116
+ def create_table!( *args, &block )
117
+ self.drop_table?
118
+ return self.create_table( *args, &block )
119
+ end
120
+
121
+
122
+ ### Creates the table unless the table already exists
123
+ def create_table?( *args, &block )
124
+ self.create_table( *args, &block ) unless self.table_exists?
125
+ end
126
+
127
+
128
+ ### Called before the table is created.
129
+ def before_create_table
130
+ # No-op
131
+ end
132
+
133
+
134
+ ### Called after the table is created.
135
+ def after_create_table
136
+ # No-op
137
+ end
138
+
139
+
140
+ ### Drops table. If the table doesn't exist, this will probably raise an error.
141
+ def drop_table
142
+ self.before_drop_table
143
+ self.db.drop_table( self.table_name )
144
+ self.after_drop_table
145
+ end
146
+
147
+
148
+ ### Drops table if it already exists, do nothing if it doesn't exist.
149
+ def drop_table?
150
+ self.db.drop_table?( self.table_name )
151
+ end
152
+
153
+
154
+ ### Called before the table is dropped.
155
+ def before_drop_table
156
+ # No-op
157
+ end
158
+
159
+
160
+ ### Called after the table is dropped.
161
+ def after_drop_table
162
+ # No-op
163
+ end
164
+
165
+
166
+ ### Returns the table schema created with set_schema.
167
+ def schema
168
+ if !@schema && @schema_block
169
+ self.set_dataset( self.db[@schema_name] ) if @schema_name
170
+ @schema = self.db.create_table_generator( &@schema_block )
171
+ self.set_primary_key( @schema.primary_key_name ) if @schema.primary_key_name
172
+ end
173
+ return @schema || ( superclass.schema unless superclass == Sequel::Model )
174
+ end
175
+
176
+
177
+ ### Defines a table schema (see Schema::CreateTableGenerator for more information).
178
+ ###
179
+ ### This will also set the dataset if you provide a +name+, as well as setting
180
+ ### the primary key if you define one in the passed block.
181
+ ###
182
+ ### Since this plugin allows you to declare the schema inline with the model
183
+ ### class that acts as its interface, the table will not always exist when the
184
+ ### class loads, so calling #set_schema will call require_valid_table to `false`
185
+ ### for you. You can disable this by passing `require_table: true`.
186
+ def set_schema( name=nil, require_table: false, &block )
187
+ self.require_valid_table = require_table
188
+ @schema = nil
189
+ @schema_name = name
190
+ @schema_block = block
191
+ end
192
+
193
+
194
+ ### Returns true if table exists, false otherwise.
195
+ def table_exists?
196
+ return self.db.table_exists?( self.table_name )
197
+ end
198
+
199
+
200
+ ### Table-creation hook; called on a model class before its table is created.
201
+ def before_create_table
202
+ return true
203
+ end
204
+
205
+
206
+ ### Table-creation hook; called on a model class after its table is created.
207
+ def after_create_table
208
+ return true
209
+ end
210
+
211
+
212
+ ### Return an Array of model table names that don't yet exist, in the order they
213
+ ### need to be created to satisfy foreign key constraints.
214
+ def uninstalled_tables
215
+ self.db.log_info " searching for unbacked model classes..."
216
+
217
+ self.tsort.find_all do |modelclass|
218
+ next unless modelclass.name && modelclass.name != ''
219
+ !modelclass.table_exists?
220
+ end.uniq( &:table_name )
221
+ end
222
+
223
+
224
+ #########
225
+ protected
226
+ #########
227
+
228
+ ### Raise an appropriate Sequel::HookFailure exception for the specified +type+.
229
+ def raise_hook_failure( type=nil )
230
+ msg = case type
231
+ when String
232
+ type
233
+ when Symbol
234
+ "the #{type} hook failed"
235
+ else
236
+ "a hook failed"
237
+ end
238
+
239
+ raise Sequel::HookFailed.new( msg, self )
240
+ end
241
+
242
+
243
+ ### Cancel the currently-running before_* hook. If a +msg+ is given, use it when
244
+ ### constructing the HookFailed exception.
245
+ def cancel_action( msg=nil )
246
+ self.raise_hook_failure( msg )
247
+ end
248
+
249
+
250
+ ### TSort API -- yield each model class.
251
+ def tsort_each_node( &block )
252
+ self.descendents.select( &:name ).each( &block )
253
+ end
254
+
255
+
256
+ ### TSort API -- yield each of the given +model_class+'s dependent model
257
+ ### classes.
258
+ def tsort_each_child( model_class ) # :yields: model_class
259
+ # Include (non-anonymous) parents other than Model
260
+ model_class.ancestors[1..-1].
261
+ select {|cl| cl < self }.
262
+ select( &:name ).
263
+ each do |parentclass|
264
+ yield( parentclass )
265
+ end
266
+
267
+ # Include associated classes for which this model class's table has a
268
+ # foreign key
269
+ model_class.association_reflections.each do |name, config|
270
+ next if config[:polymorphic]
271
+
272
+ associated_class = config.associated_class
273
+
274
+ yield( associated_class ) if config[:type] == :many_to_one
275
+ end
276
+ end
277
+
278
+
279
+ end # module ClassMethods
280
+
281
+ end # module Sequel::Plugin::Schema
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env rspec -cfd
2
+
3
+ require_relative '../../spec_helper'
4
+
5
+ require 'sequel'
6
+ require 'sequel/model'
7
+ require 'sequel/inline_schema'
8
+ require 'sequel/plugins/inline_migrations'
9
+
10
+ describe Sequel::Plugins::InlineMigrations do
11
+
12
+ let( :db ) { Sequel.connect('mock://postgres', logger: Loggability[Sequel::InlineSchema]) }
13
+
14
+ let( :model_class ) do
15
+ cls = Class.new( Sequel::Model ) do
16
+ def self::name; "Thing"; end
17
+ end
18
+ cls.dataset = db[:things]
19
+ cls.plugin( :inline_migrations )
20
+ cls
21
+ end
22
+
23
+
24
+ it "also adds the 'subclasses' and 'inline_schema' plugins to including models" do
25
+ expect( model_class ).to respond_to( :create_table )
26
+ expect( model_class ).to respond_to( :descendents )
27
+ end
28
+
29
+
30
+ it "allows a migration to be defined for the class" do
31
+ model_class.migration( '20110308_1335_simple', "A very simple migration." ) do
32
+ change do
33
+ alter_table(:things) do
34
+ add_column :age, :number
35
+ end
36
+ end
37
+ end
38
+
39
+ migrations = model_class.migrations
40
+
41
+ expect( migrations.size ).to eq( 1 )
42
+ expect( migrations ).to have_key( '20110308_1335_simple' )
43
+ expect( migrations['20110308_1335_simple'] ).to be_a( Sequel::SimpleMigration )
44
+ expect( migrations['20110308_1335_simple'].name ).to eq( '20110308_1335_simple' )
45
+ expect( migrations['20110308_1335_simple'].model_class ).to eq( model_class )
46
+ expect( migrations['20110308_1335_simple'].description ).to eq( 'A very simple migration.' )
47
+ end
48
+
49
+
50
+ it "adds existing migrations to the migrations table on table creation" do
51
+ model_class.migration( '20110404_1817_index_name', "Add an index to the name field" ) do
52
+ change do
53
+ alter_table(:things) do
54
+ add_index :name
55
+ end
56
+ end
57
+ end
58
+
59
+ model_class.db.columns = [ :name, :model_class ]
60
+ model_class.db.fetch = nil
61
+
62
+ model_class.create_table
63
+
64
+ expect( model_class.db.sqls.last ).to eq(
65
+ %Q{INSERT INTO "schema_migrations" ("name", "model_class") } +
66
+ %Q{VALUES ('20110404_1817_index_name', 'Thing') RETURNING "id"}
67
+ )
68
+ end
69
+
70
+
71
+ it "ignores migrations which have been removed" do
72
+ db.fetch = {
73
+ name: '20140603_1139_add_unique_email_constraint',
74
+ model_class: model_class.name
75
+ }
76
+
77
+ migrator = model_class.migrator
78
+ migrations = migrator.get_partitioned_migrations
79
+
80
+ expect( migrations ).to be_an( Array )
81
+ expect( migrations.size ).to eq( 2 )
82
+ expect( migrations ).to all( be_empty )
83
+ end
84
+
85
+
86
+ it "can migrate up" do
87
+ model_class.migration( '20110308_1335_simple', "A very simple migration." ) do
88
+ change do
89
+ alter_table(:things) do
90
+ add_column :age, :number
91
+ end
92
+ end
93
+ end
94
+
95
+ model_class.migrate
96
+
97
+ expect( db.sqls ).to include(
98
+ %Q{ALTER TABLE "things" ADD COLUMN "age" number}
99
+ )
100
+ end
101
+
102
+
103
+ it "doesn't try to apply already-applied migrations" do
104
+ model_class.migration( '20110308_1335_simple', "A very simple migration." ) do
105
+ change do
106
+ alter_table(:things) do
107
+ add_column :age, :number
108
+ end
109
+ end
110
+ end
111
+ model_class.migration( '20110711_1623_another_simple', "A later simple migration." ) do
112
+ change do
113
+ alter_table(:things) do
114
+ add_column :strength, :number
115
+ end
116
+ end
117
+ end
118
+
119
+ db.fetch = Proc.new do |query|
120
+ case query
121
+ when /SELECT .* FROM "schema_migrations"/
122
+ [{name: '20110308_1335_simple', model_class: 'Things'}]
123
+ else
124
+ []
125
+ end
126
+ end
127
+ model_class.migrate
128
+
129
+ statements = db.sqls
130
+ expect( statements ).to_not include(
131
+ %Q{ALTER TABLE "things" ADD COLUMN "age" number}
132
+ )
133
+ expect( statements ).to include(
134
+ %Q{ALTER TABLE "things" ADD COLUMN "strength" number}
135
+ )
136
+ end
137
+
138
+
139
+ it "can migrate up to a particular migration" do
140
+ model_class.migration( '20110308_1335_simple', "A very simple migration." ) do
141
+ change do
142
+ alter_table(:things) do
143
+ add_column :age, :number
144
+ end
145
+ end
146
+ end
147
+ model_class.migration( '20110711_1623_another_simple', "A later simple migration." ) do
148
+ change do
149
+ alter_table(:things) do
150
+ add_column :strength, :number
151
+ end
152
+ end
153
+ end
154
+
155
+ model_class.migrate( '20110308_1335_simple' )
156
+
157
+ statements = db.sqls
158
+ expect( statements ).to include(
159
+ %Q{ALTER TABLE "things" ADD COLUMN "age" number}
160
+ )
161
+ expect( statements ).to_not include(
162
+ %Q{ALTER TABLE "things" ADD COLUMN "strength" number}
163
+ )
164
+ end
165
+
166
+
167
+ it "can reverse migrate down to a particular migration" do
168
+ model_class.migration( '20110308_1335_simple', "A very simple migration." ) do
169
+ change do
170
+ alter_table(:things) do
171
+ add_column :age, :number
172
+ end
173
+ end
174
+ end
175
+ model_class.migration( '20110711_1623_another_simple', "A later simple migration." ) do
176
+ change do
177
+ alter_table(:things) do
178
+ add_column :strength, :number
179
+ end
180
+ end
181
+ end
182
+
183
+ db.fetch = Proc.new do |query|
184
+ case query
185
+ when /SELECT .* FROM "schema_migrations"/
186
+ [{name: '20110308_1335_simple', model_class: 'Things'}]
187
+ else
188
+ []
189
+ end
190
+ end
191
+ model_class.migrate( '20110308_1335_simple' )
192
+
193
+ statements = db.sqls
194
+ expect( statements ).to include(
195
+ %Q{ALTER TABLE "things" DROP COLUMN "age"}
196
+ )
197
+ expect( statements ).to_not include(
198
+ %Q{ALTER TABLE "things" DROP COLUMN "strength"}
199
+ )
200
+ end
201
+
202
+
203
+ describe "hooks" do
204
+
205
+ let( :model_class ) do
206
+ class_obj = super()
207
+ class_obj.migration( '20110308_1335_simple', "A very simple migration." ) do
208
+ change do
209
+ alter_table(:things) do
210
+ add_column :age, :number
211
+ end
212
+ end
213
+ end
214
+ class_obj.singleton_class.send( :attr_accessor, :called )
215
+ class_obj.called = {}
216
+ class_obj
217
+ end
218
+
219
+
220
+ it "calls a hook before applying pending migrations" do
221
+ def model_class.before_migration
222
+ self.called[ :before_migration ] = true
223
+ super
224
+ end
225
+
226
+ model_class.migrate
227
+
228
+ expect( model_class.called ).to include( :before_migration )
229
+ end
230
+
231
+
232
+ it "allows cancellation of migration from the before_migration hook" do
233
+ def model_class.before_migration
234
+ self.called[ :before_migration ] = true
235
+ cancel_action
236
+ end
237
+
238
+ expect {
239
+ model_class.migrate
240
+ }.to raise_error( Sequel::HookFailed, /hook failed/i )
241
+ end
242
+
243
+
244
+ it "allows cancellation of migration with a message from the before_migration hook" do
245
+ def model_class.before_migration
246
+ self.called[ :before_migration ] = true
247
+ cancel_action( "Wait, don't migrate yet!" )
248
+ end
249
+
250
+ expect {
251
+ model_class.migrate
252
+ }.to raise_error( Sequel::HookFailed, "Wait, don't migrate yet!" )
253
+ end
254
+
255
+
256
+ it "allows cancellation of migration with a Symbol from the before_migration hook" do
257
+ def model_class.before_migration
258
+ self.called[ :before_migration ] = true
259
+ cancel_action( :before_migration )
260
+ end
261
+
262
+ expect {
263
+ model_class.migrate
264
+ }.to raise_error( Sequel::HookFailed, /before_migration/ )
265
+ end
266
+
267
+
268
+ it "calls a hook after migration" do
269
+ def model_class.after_migration
270
+ self.called[ :after_migration ] = true
271
+ super
272
+ end
273
+
274
+ model_class.migrate
275
+
276
+ expect( model_class.called ).to include( :after_migration )
277
+ end
278
+
279
+ end
280
+
281
+ end
282
+
283
+ # vim: set nosta noet ts=4 sw=4: