declare_schema 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.dependabot/config.yml +10 -0
  3. data/.github/workflows/gem_release.yml +38 -0
  4. data/.gitignore +14 -0
  5. data/.jenkins/Jenkinsfile +72 -0
  6. data/.jenkins/ruby_build_pod.yml +19 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +189 -0
  9. data/.ruby-version +1 -0
  10. data/Appraisals +14 -0
  11. data/CHANGELOG.md +11 -0
  12. data/Gemfile +24 -0
  13. data/Gemfile.lock +203 -0
  14. data/LICENSE.txt +22 -0
  15. data/README.md +11 -0
  16. data/Rakefile +56 -0
  17. data/bin/declare_schema +11 -0
  18. data/declare_schema.gemspec +25 -0
  19. data/gemfiles/.bundle/config +2 -0
  20. data/gemfiles/rails_4.gemfile +25 -0
  21. data/gemfiles/rails_5.gemfile +25 -0
  22. data/gemfiles/rails_6.gemfile +25 -0
  23. data/lib/declare_schema.rb +44 -0
  24. data/lib/declare_schema/command.rb +65 -0
  25. data/lib/declare_schema/extensions/active_record/fields_declaration.rb +28 -0
  26. data/lib/declare_schema/extensions/module.rb +36 -0
  27. data/lib/declare_schema/field_declaration_dsl.rb +40 -0
  28. data/lib/declare_schema/model.rb +242 -0
  29. data/lib/declare_schema/model/field_spec.rb +162 -0
  30. data/lib/declare_schema/model/index_spec.rb +175 -0
  31. data/lib/declare_schema/railtie.rb +12 -0
  32. data/lib/declare_schema/version.rb +5 -0
  33. data/lib/generators/declare_schema/migration/USAGE +47 -0
  34. data/lib/generators/declare_schema/migration/migration_generator.rb +184 -0
  35. data/lib/generators/declare_schema/migration/migrator.rb +567 -0
  36. data/lib/generators/declare_schema/migration/templates/migration.rb.erb +9 -0
  37. data/lib/generators/declare_schema/model/USAGE +19 -0
  38. data/lib/generators/declare_schema/model/model_generator.rb +12 -0
  39. data/lib/generators/declare_schema/model/templates/model_injection.rb.erb +25 -0
  40. data/lib/generators/declare_schema/support/eval_template.rb +21 -0
  41. data/lib/generators/declare_schema/support/model.rb +64 -0
  42. data/lib/generators/declare_schema/support/thor_shell.rb +39 -0
  43. data/spec/lib/declare_schema/field_declaration_dsl_spec.rb +28 -0
  44. data/spec/spec_helper.rb +28 -0
  45. data/test/api.rdoctest +136 -0
  46. data/test/doc-only.rdoctest +76 -0
  47. data/test/generators.rdoctest +60 -0
  48. data/test/interactive_primary_key.rdoctest +56 -0
  49. data/test/migration_generator.rdoctest +846 -0
  50. data/test/migration_generator_comments.rdoctestDISABLED +74 -0
  51. data/test/prepare_testapp.rb +15 -0
  52. data/test_responses.txt +2 -0
  53. metadata +109 -0
@@ -0,0 +1,60 @@
1
+ doctest: prepare testapp environment
2
+ doctest_require: 'prepare_testapp'
3
+
4
+ doctest: generate declare_schema:model
5
+ >> Rails::Generators.invoke 'declare_schema:model', %w(alpha/beta one:string two:integer)
6
+
7
+
8
+ doctest: model file exists
9
+ >> File.exist? 'app/models/alpha/beta.rb'
10
+ => true
11
+
12
+ doctest: model content matches
13
+ >> File.read 'app/models/alpha/beta.rb'
14
+ => "class Alpha::Beta < #{Rails::VERSION::MAJOR > 4 ? 'ApplicationRecord' : 'ActiveRecord::Base'}\n\n fields do\n one :string, limit: 255\n two :integer\n end\n\nend\n"
15
+
16
+ doctest: module file exists
17
+ >> File.exist? 'app/models/alpha.rb'
18
+ => true
19
+
20
+ doctest: module content matches
21
+ >> File.read 'app/models/alpha.rb'
22
+ => "module Alpha\n def self.table_name_prefix\n 'alpha_'\n end\nend\n"
23
+
24
+
25
+ doctest: test file exists
26
+ >> File.exist? 'test/models/alpha/beta_test.rb'
27
+ => true
28
+
29
+ doctest: test content matches
30
+ >> File.read 'test/models/alpha/beta_test.rb'
31
+ =>
32
+ require 'test_helper'
33
+
34
+ class Alpha::BetaTest < ActiveSupport::TestCase
35
+ # test "the truth" do
36
+ # assert true
37
+ # end
38
+ end
39
+
40
+ doctest: fixture file exists
41
+ >> File.exist? 'test/fixtures/alpha/beta.yml'
42
+ => true
43
+
44
+
45
+ doctest: generate declare_schema:migration
46
+ >> require_relative "#{Rails.root}/app/models/alpha.rb" if Rails::VERSION::MAJOR > 5
47
+ >> require_relative "#{Rails.root}/app/models/alpha/beta.rb" if Rails::VERSION::MAJOR > 5
48
+ >> Rails::Generators.invoke 'declare_schema:migration', %w(-n -m)
49
+
50
+ doctest: schema.rb file exists
51
+ >> File.exist? 'db/schema.rb'
52
+ => true
53
+
54
+ doctest: db file exists
55
+ >> File.exist? 'db/development.sqlite3'
56
+ => true
57
+
58
+ doctest: Alpha::Beta class exists
59
+ >> Alpha::Beta
60
+ # will error if class doesn't exist
@@ -0,0 +1,56 @@
1
+ -*- indent-tabs-mode:nil; -*-
2
+
3
+ # DeclareSchema - Migration Generator
4
+
5
+ Our test requires to prepare the testapp:
6
+ {.hidden}
7
+
8
+ doctest_require: 'prepare_testapp'
9
+
10
+ {.hidden}
11
+
12
+ And requires also that you enter the right choice when prompted. OK we're ready to get going.
13
+
14
+ ## Alternate Primary Keys
15
+
16
+ ### create
17
+ doctest: create table with custom primary_key
18
+ >>
19
+ class Foo < ActiveRecord::Base
20
+ fields do
21
+ end
22
+ self.primary_key="foo_id"
23
+ end
24
+ >> Rails::Generators.invoke 'declare_schema:migration', %w(-n -m)
25
+ >> Foo.primary_key
26
+ => 'foo_id'
27
+
28
+ ### migrate from
29
+ doctest: rename from custom primary_key
30
+ >>
31
+ class Foo < ActiveRecord::Base
32
+ self.primary_key="id"
33
+ end
34
+ puts "\n\e[45m Please enter 'id' (no quotes) at the next prompt \e[0m"
35
+ >> Rails::Generators.invoke 'declare_schema:migration', %w(-n -m)
36
+ >> Foo.primary_key
37
+ => 'id'
38
+
39
+ ### migrate to
40
+
41
+ doctest: rename to custom primary_key
42
+ >>
43
+ class Foo < ActiveRecord::Base
44
+ self.primary_key="foo_id"
45
+ end
46
+ puts "\n\e[45m Please enter 'drop id' (no quotes) at the next prompt \e[0m"
47
+ >> Rails::Generators.invoke 'declare_schema:migration', %w(-n -m)
48
+ >> Foo.primary_key
49
+ => 'foo_id'
50
+
51
+ ### ensure it doesn't cause further migrations
52
+
53
+ doctest: check no further migrations
54
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
55
+ >> up
56
+ => ""
@@ -0,0 +1,846 @@
1
+ # DeclareSchema - Migration Generator
2
+
3
+ Our test requires to prepare the testapp:
4
+ {.hidden}
5
+
6
+ doctest_require: 'prepare_testapp'
7
+
8
+ {.hidden}
9
+
10
+ ## The migration generator -- introduction
11
+
12
+ The migration generator works by:
13
+
14
+ * Loading all of the models in your Rails app
15
+ * Using the Rails schema-dumper to extract information about the current state of the database.
16
+ * Calculating the changes that are required to bring the database into sync with your application.
17
+
18
+ Normally you would run the migration generator as a regular Rails generator. You would type
19
+
20
+ $ rails generate declare_schema:migration
21
+
22
+ in your Rails app, and the migration file would be created in `db/migrate`.
23
+
24
+ In order to demonstrate the generator in this doctest script however, we'll be using the Ruby API instead. The method `Generators::DeclareSchema::Migration::Migrator.run` returns a pair of strings -- the up migration and the down migration.
25
+
26
+ At the moment the database is empty and no ActiveRecord models exist, so the generator is going to tell us there is nothing to do.
27
+
28
+ >> Generators::DeclareSchema::Migration::Migrator.run
29
+ => ["", ""]
30
+
31
+
32
+ ### Models without `fields do` are ignored
33
+
34
+ The migration generator only takes into account classes that use DeclareSchema, i.e. classes with a `fields do` declaration. Models without this are ignored:
35
+
36
+ >> class Advert < ActiveRecord::Base; end
37
+ >> Generators::DeclareSchema::Migration::Migrator.run
38
+ => ["", ""]
39
+
40
+ You can also tell DeclareSchema to ignore additional tables. You can place this command in your environment.rb or elsewhere:
41
+
42
+ >> Generators::DeclareSchema::Migration::Migrator.ignore_tables = ["green_fishes"]
43
+
44
+ ### Create the table
45
+
46
+ Here we see a simple `create_table` migration along with the `drop_table` down migration
47
+
48
+ >>
49
+ class Advert < ActiveRecord::Base
50
+ fields do
51
+ name :string, limit: 255, null: true
52
+ end
53
+ end
54
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
55
+ >> up
56
+ =>
57
+ "create_table :adverts do |t|
58
+ t.string :name
59
+ end"
60
+ >> down
61
+ => "drop_table :adverts"
62
+
63
+ Normally we would run the generated migration with `rake db:create`. We can achieve the same effect directly in Ruby like this:
64
+
65
+ >> ActiveRecord::Migration.class_eval up
66
+ >> Advert.columns.map(&:name)
67
+ => ["id", "name"]
68
+
69
+ We'll define a method to make that easier next time
70
+
71
+ >>
72
+ def migrate(renames={})
73
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(renames)
74
+ ActiveRecord::Migration.class_eval(up)
75
+ ActiveRecord::Base.send(:descendants).each { |model| model.reset_column_information }
76
+ [up, down]
77
+ end
78
+
79
+ We'll have a look at the migration generator in more detail later, first we'll have a look at the extra features DeclareSchema has added to the model.
80
+
81
+
82
+ ### Add fields
83
+
84
+ If we add a new field to the model, the migration generator will add it to the database.
85
+
86
+ >>
87
+ class Advert
88
+ fields do
89
+ name :string, limit: 255, null: true
90
+ body :text, null: true
91
+ published_at :datetime, null: true
92
+ end
93
+ end
94
+ >> up, down = migrate
95
+ >> up
96
+ =>
97
+ "add_column :adverts, :body, :text
98
+ add_column :adverts, :published_at, :datetime"
99
+ >> down
100
+ =>
101
+ "remove_column :adverts, :body
102
+ remove_column :adverts, :published_at"
103
+ >>
104
+
105
+ ### Remove fields
106
+
107
+ If we remove a field from the model, the migration generator removes the database column. Note that we have to explicitly clear the known fields to achieve this in rdoctest -- in a Rails context you would simply edit the file
108
+
109
+ >> Advert.field_specs.clear # not normally needed
110
+ class Advert < ActiveRecord::Base
111
+ fields do
112
+ name :string, limit: 255, null: true
113
+ body :text, null: true
114
+ end
115
+ end
116
+ >> up, down = migrate
117
+ >> up
118
+ => "remove_column :adverts, :published_at"
119
+ >> down
120
+ => "add_column :adverts, :published_at, :datetime"
121
+
122
+ ### Rename a field
123
+
124
+ Here we rename the `name` field to `title`. By default the generator sees this as removing `name` and adding `title`.
125
+
126
+ >> Advert.field_specs.clear # not normally needed
127
+ class Advert < ActiveRecord::Base
128
+ fields do
129
+ title :string, limit: 255, null: true
130
+ body :text, null: true
131
+ end
132
+ end
133
+ >> # Just generate - don't run the migration:
134
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
135
+ >> up
136
+ => "add_column :adverts, :title, :string, limit: 255
137
+ remove_column :adverts, :name"
138
+ >> down
139
+ => "remove_column :adverts, :title
140
+ add_column :adverts, :name, :string, limit: 255"
141
+
142
+ When run as a generator, the migration-generator won't make this assumption. Instead it will prompt for user input to resolve the ambiguity. When using the Ruby API, we can ask for a rename instead of an add + drop by passing in a hash:
143
+
144
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { name: :title })
145
+ >> up
146
+ => "rename_column :adverts, :name, :title"
147
+ >> down
148
+ => "rename_column :adverts, :title, :name"
149
+
150
+ Let's apply that change to the database
151
+
152
+ >> migrate
153
+
154
+
155
+ ### Change a type
156
+
157
+ >>
158
+ class Advert
159
+ fields do
160
+ title :text, null: true
161
+ body :text, null: true
162
+ end
163
+ end
164
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
165
+ >> up
166
+ => "change_column :adverts, :title, :text"
167
+ >> down
168
+ => "change_column :adverts, :title, :string, limit: 255"
169
+
170
+
171
+ ### Add a default
172
+
173
+ >>
174
+ class Advert
175
+ fields do
176
+ title :string, default: "Untitled", limit: 255, null: true
177
+ body :text, null: true
178
+ end
179
+ end
180
+ >> up, down = migrate
181
+ >> up.split(',').slice(0,3).join(',')
182
+ => 'change_column :adverts, :title, :string'
183
+ >> up.split(',').slice(3,2).sort.join(',')
184
+ => " default: \"Untitled\", limit: 255"
185
+ >> down
186
+ => "change_column :adverts, :title, :string, limit: 255"
187
+
188
+
189
+ ### Limits
190
+
191
+ >>
192
+ class Advert
193
+ fields do
194
+ price :integer, null: true, limit: 2
195
+ end
196
+ end
197
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
198
+ >> up
199
+ => "add_column :adverts, :price, :integer, limit: 2"
200
+
201
+ Now run the migration, then change the limit:
202
+
203
+ >> ActiveRecord::Migration.class_eval up
204
+ >>
205
+ class Advert
206
+ fields do
207
+ price :integer, null: true, limit: 3
208
+ end
209
+ end
210
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
211
+ >> up
212
+ => "change_column :adverts, :price, :integer, limit: 3"
213
+ >> down
214
+ => "change_column :adverts, :price, :integer, limit: 2"
215
+
216
+ Note that limit on a decimal column is ignored (use :scale and :precision)
217
+
218
+ >> ActiveRecord::Migration.class_eval "remove_column :adverts, :price"
219
+ >>
220
+ class Advert
221
+ fields do
222
+ price :decimal, null: true, limit: 4
223
+ end
224
+ end
225
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
226
+ >> up
227
+ => "add_column :adverts, :price, :decimal"
228
+
229
+ Limits are generally not needed for `text` fields, because by default, `text` fields will use the maximum size
230
+ allowed for that database type (0xffffffff for LONGTEXT in MySQL unlimited in Postgres, 1 billion in Sqlite).
231
+ If a `limit` is given, it will only be used in MySQL, to choose the smallest TEXT field that will accommodate
232
+ that limit (0xff for TINYTEXT, 0xffff for TEXT, 0xffffff for MEDIUMTEXT, 0xffffffff for LONGTEXT).
233
+
234
+ >> ::DeclareSchema::Model::FieldSpec.mysql_text_limits?
235
+ => false
236
+ >>
237
+ class Advert
238
+ fields do
239
+ notes :text
240
+ description :text, limit: 30000
241
+ end
242
+ end
243
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
244
+ >> up
245
+ => "add_column :adverts, :price, :decimal
246
+ add_column :adverts, :notes, :text, null: false
247
+ add_column :adverts, :description, :text, null: false"
248
+
249
+ (There is no limit on `add_column ... :description` above since these tests are run against SQLite.)
250
+
251
+ Cleanup
252
+ {.hidden}
253
+ >> Advert.field_specs.delete :price
254
+ >> Advert.field_specs.delete :notes
255
+ >> Advert.field_specs.delete :description
256
+ {.hidden}
257
+
258
+ In MySQL, limits are applied, rounded up:
259
+
260
+ >> ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
261
+ >> ::DeclareSchema::Model::FieldSpec.mysql_text_limits?
262
+ => true
263
+ >>
264
+ class Advert
265
+ fields do
266
+ notes :text
267
+ description :text, limit: 200
268
+ end
269
+ end
270
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
271
+ >> up
272
+ => "add_column :adverts, :notes, :text, null: false
273
+ add_column :adverts, :description, :text, null: false, limit: 255"
274
+
275
+ Cleanup
276
+ {.hidden}
277
+
278
+ >> Advert.field_specs.delete :notes
279
+ {.hidden}
280
+
281
+ Limits that are too high will for MySQL will raise an exception.
282
+
283
+ >> ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
284
+ >> ::DeclareSchema::Model::FieldSpec.mysql_text_limits?
285
+ => true
286
+ >>
287
+ begin
288
+ class Advert
289
+ fields do
290
+ notes :text
291
+ description :text, limit: 0x1_0000_0000
292
+ end
293
+ end
294
+ rescue => ex
295
+ "#{ex.class}: #{ex.message}"
296
+ end
297
+ => "ArgumentError: limit of 4294967296 is too large for MySQL"
298
+
299
+ Cleanup
300
+ {.hidden}
301
+
302
+ >> Advert.field_specs.delete :notes
303
+ {.hidden}
304
+
305
+ And in MySQL, unstated text limits are treated as the maximum (LONGTEXT) limit.
306
+
307
+ To start, we'll set the database schema for `description` to match the above limit of 255.
308
+
309
+ >> ::DeclareSchema::Model::FieldSpec.mysql_text_limits?
310
+ => true
311
+ >> Advert.connection.execute "ALTER TABLE adverts ADD COLUMN description TINYTEXT"
312
+ >> Advert.connection.schema_cache.clear!
313
+ >> Advert.reset_column_information
314
+ >> Advert.connection.tables
315
+ => ["adverts"]
316
+ >> Advert.columns.map(&:name)
317
+ => ["id", "body", "title", "description"]
318
+
319
+ Now migrate to an unstated text limit:
320
+
321
+ >>
322
+ class Advert
323
+ fields do
324
+ description :text
325
+ end
326
+ end
327
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
328
+ >> up
329
+ => "change_column :adverts, :description, :text, null: false"
330
+ >> down
331
+ => "change_column :adverts, :description, :text"
332
+
333
+ TODO TECH-4814: The above test should have this output:
334
+ TODO => "change_column :adverts, :description, :text, limit: 255"
335
+
336
+
337
+ And migrate to a stated text limit that is the same as the unstated one:
338
+
339
+ >>
340
+ class Advert
341
+ fields do
342
+ description :text, limit: 0xffffffff
343
+ end
344
+ end
345
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
346
+ >> up
347
+ => "change_column :adverts, :description, :text, null: false"
348
+ >> down
349
+ => "change_column :adverts, :description, :text"
350
+ >> ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, false)
351
+
352
+ Cleanup
353
+ {.hidden}
354
+ >> Advert.field_specs.clear
355
+ >> Advert.connection.schema_cache.clear!
356
+ >> Advert.reset_column_information
357
+ >>
358
+ class Advert < ActiveRecord::Base
359
+ fields do
360
+ name :string, limit: 255, null: true
361
+ end
362
+ end
363
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
364
+ >> ActiveRecord::Migration.class_eval up
365
+ >> Advert.connection.schema_cache.clear!
366
+ >> Advert.reset_column_information
367
+ {.hidden}
368
+
369
+
370
+ ### Foreign Keys
371
+
372
+ DeclareSchema extends the `belongs_to` macro so that it also declares the
373
+ foreign-key field. It also generates an index on the field.
374
+
375
+ >>
376
+ class Category < ActiveRecord::Base; end
377
+ class Advert
378
+ belongs_to :category
379
+ end
380
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
381
+ >> up.gsub(/\n+/, "\n")
382
+ => "add_column :adverts, :category_id, :integer, limit: 8, null: false
383
+ add_index :adverts, [:category_id], name: 'on_category_id'"
384
+ >> down.sub(/\n+/, "\n")
385
+ => "remove_column :adverts, :category_id
386
+ remove_index :adverts, name: :on_category_id rescue ActiveRecord::StatementInvalid"
387
+
388
+ Cleanup:
389
+ {.hidden}
390
+
391
+ >> Advert.field_specs.delete(:category_id)
392
+ >> Advert.index_specs.delete_if {|spec| spec.fields==["category_id"]}
393
+ {.hidden}
394
+
395
+ If you specify a custom foreign key, the migration generator observes that:
396
+
397
+ >>
398
+ class Category < ActiveRecord::Base; end
399
+ class Advert
400
+ belongs_to :category, foreign_key: "c_id", class_name: 'Category'
401
+ end
402
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
403
+ >> up.gsub(/\n+/, "\n")
404
+ => "add_column :adverts, :c_id, :integer, limit: 8, null: false
405
+ add_index :adverts, [:c_id], name: 'on_c_id'"
406
+
407
+ Cleanup:
408
+ {.hidden}
409
+
410
+ >> Advert.field_specs.delete(:c_id)
411
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["c_id"] }
412
+ {.hidden}
413
+
414
+ You can avoid generating the index by specifying `index: false`
415
+
416
+ >>
417
+ class Category < ActiveRecord::Base; end
418
+ class Advert
419
+ belongs_to :category, index: false
420
+ end
421
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
422
+ >> up.gsub(/\n+/, "\n")
423
+ => "add_column :adverts, :category_id, :integer, limit: 8, null: false"
424
+
425
+ Cleanup:
426
+ {.hidden}
427
+
428
+ >> Advert.field_specs.delete(:category_id)
429
+ >> Advert.index_specs.delete_if {|spec| spec.fields==["category_id"]}
430
+ {.hidden}
431
+
432
+ You can specify the index name with :index
433
+
434
+ >>
435
+ class Category < ActiveRecord::Base; end
436
+ class Advert
437
+ belongs_to :category, index: 'my_index'
438
+ end
439
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
440
+ >> up.gsub(/\n+/, "\n")
441
+ => "add_column :adverts, :category_id, :integer, limit: 8, null: false
442
+ add_index :adverts, [:category_id], name: 'my_index'"
443
+
444
+ Cleanup:
445
+ {.hidden}
446
+
447
+ >> Advert.field_specs.delete(:category_id)
448
+ >> Advert.index_specs.delete_if {|spec| spec.fields==["category_id"]}
449
+ {.hidden}
450
+
451
+ ### Timestamps and Optimimistic Locking
452
+
453
+ `updated_at` and `created_at` can be declared with the shorthand `timestamps`.
454
+ Similarly, `lock_version` can be declared with the "shorthand" `optimimistic_lock`.
455
+
456
+ >>
457
+ class Advert
458
+ fields do
459
+ timestamps
460
+ optimistic_lock
461
+ end
462
+ end
463
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
464
+ >> up.gsub(/\n+/, "\n")
465
+ => "add_column :adverts, :created_at, :datetime
466
+ add_column :adverts, :updated_at, :datetime
467
+ add_column :adverts, :lock_version, :integer, null: false, default: 1"
468
+ >> down.gsub(/\n+/, "\n")
469
+ => "remove_column :adverts, :created_at
470
+ remove_column :adverts, :updated_at
471
+ remove_column :adverts, :lock_version"
472
+ >>
473
+
474
+ Cleanup:
475
+ {.hidden}
476
+
477
+ >> Advert.field_specs.delete(:updated_at)
478
+ >> Advert.field_specs.delete(:created_at)
479
+ >> Advert.field_specs.delete(:lock_version)
480
+ {.hidden}
481
+
482
+ ### Indices
483
+
484
+ You can add an index to a field definition
485
+
486
+ >>
487
+ class Advert
488
+ fields do
489
+ title :string, index: true, limit: 255, null: true
490
+ end
491
+ end
492
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
493
+ >> up.gsub(/\n+/, "\n")
494
+ => "add_column :adverts, :title, :string, limit: 255
495
+ add_index :adverts, [:title], name: 'on_title'"
496
+
497
+ Cleanup:
498
+ {.hidden}
499
+
500
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
501
+ {.hidden}
502
+
503
+ You can ask for a unique index
504
+
505
+ >>
506
+ class Advert
507
+ fields do
508
+ title :string, index: true, unique: true, null: true, limit: 255
509
+ end
510
+ end
511
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
512
+ >> up.gsub(/\n+/, "\n")
513
+ => "add_column :adverts, :title, :string, limit: 255
514
+ add_index :adverts, [:title], unique: true, name: 'on_title'"
515
+
516
+ Cleanup:
517
+ {.hidden}
518
+
519
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
520
+ {.hidden}
521
+
522
+ You can specify the name for the index
523
+
524
+ >>
525
+ class Advert
526
+ fields do
527
+ title :string, index: 'my_index', limit: 255, null: true
528
+ end
529
+ end
530
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
531
+ >> up.gsub(/\n+/, "\n")
532
+ => "add_column :adverts, :title, :string, limit: 255
533
+ add_index :adverts, [:title], name: 'my_index'"
534
+
535
+ Cleanup:
536
+ {.hidden}
537
+
538
+ >> Advert.index_specs.delete_if {|spec| spec.fields==["title"]}
539
+ {.hidden}
540
+
541
+ You can ask for an index outside of the fields block
542
+
543
+ >>
544
+ class Advert
545
+ index :title
546
+ end
547
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
548
+ >> up.gsub(/\n+/, "\n")
549
+ => "add_column :adverts, :title, :string, limit: 255
550
+ add_index :adverts, [:title], name: 'on_title'"
551
+
552
+ Cleanup:
553
+ {.hidden}
554
+
555
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
556
+ {.hidden}
557
+
558
+ The available options for the index function are `:unique` and `:name`
559
+
560
+ >>
561
+ class Advert
562
+ index :title, unique: true, name: 'my_index'
563
+ end
564
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
565
+ >> up.gsub(/\n+/, "\n")
566
+ => "add_column :adverts, :title, :string, limit: 255
567
+ add_index :adverts, [:title], unique: true, name: 'my_index'"
568
+
569
+ Cleanup:
570
+ {.hidden}
571
+
572
+ >> Advert.index_specs.delete_if {|spec| spec.fields==["title"]}
573
+ {.hidden}
574
+
575
+ You can create an index on more than one field
576
+
577
+ >>
578
+ class Advert
579
+ index [:title, :category_id]
580
+ end
581
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
582
+ >> up.gsub(/\n+/, "\n")
583
+ => "add_column :adverts, :title, :string, limit: 255
584
+ add_index :adverts, [:title, :category_id], name: 'on_title_and_category_id'"
585
+
586
+ Cleanup:
587
+ {.hidden}
588
+
589
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["title", "category_id"] }
590
+ {.hidden}
591
+
592
+ Finally, you can specify that the migration generator should completely ignore an index by passing its name to ignore_index in the model. This is helpful for preserving indices that can't be automatically generated, such as prefix indices in MySQL.
593
+
594
+ ### Rename a table
595
+
596
+ The migration generator respects the `set_table_name` declaration, although as before, we need to explicitly tell the generator that we want a rename rather than a create and a drop.
597
+
598
+ >>
599
+ class Advert
600
+ self.table_name="ads"
601
+ fields do
602
+ title :string, limit: 255, null: true
603
+ body :text, null: true
604
+ end
605
+ end
606
+
607
+ >> Advert.connection.schema_cache.clear!
608
+ >> Advert.reset_column_information
609
+
610
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "ads")
611
+ >> up.gsub(/\n+/, "\n")
612
+ => "rename_table :adverts, :ads
613
+ add_column :ads, :title, :string, limit: 255
614
+ add_column :ads, :body, :text
615
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'"
616
+ >> down.gsub(/\n+/, "\n")
617
+ => "remove_column :ads, :title
618
+ remove_column :ads, :body
619
+ rename_table :ads, :adverts
620
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'"
621
+
622
+ Set the table name back to what it should be and confirm we're in sync:
623
+
624
+ >> Advert.field_specs.delete(:title)
625
+ >> Advert.field_specs.delete(:body)
626
+ >> class Advert; self.table_name="adverts"; end
627
+ >> Generators::DeclareSchema::Migration::Migrator.run
628
+ => ["", ""]
629
+
630
+ ### Rename a table
631
+
632
+ As with renaming columns, we have to tell the migration generator about the rename. Here we create a new class 'Advertisement', and tell ActiveRecord to forget about the Advert class. This requires code that shouldn't be shown to impressionable children.
633
+ {.hidden}
634
+
635
+ >>
636
+ def nuke_model_class(klass)
637
+ ActiveSupport::DescendantsTracker.instance_eval do
638
+ direct_descendants = class_variable_get('@@direct_descendants')
639
+ direct_descendants[ActiveRecord::Base] = direct_descendants[ActiveRecord::Base].to_a.reject { |descendant| descendant == klass }
640
+ end
641
+ Object.instance_eval { remove_const klass.name.to_sym }
642
+ end
643
+ >> nuke_model_class(Advert)
644
+ {.hidden}
645
+
646
+ >>
647
+ class Advertisement < ActiveRecord::Base
648
+ fields do
649
+ title :string, limit: 255, null: true
650
+ body :text, null: true
651
+ end
652
+ end
653
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "advertisements")
654
+ >> up.gsub(/\n+/, "\n")
655
+ => "rename_table :adverts, :advertisements
656
+ add_column :advertisements, :title, :string, limit: 255
657
+ add_column :advertisements, :body, :text
658
+ remove_column :advertisements, :name
659
+ add_index :advertisements, [:id], unique: true, name: 'PRIMARY_KEY'"
660
+ >> down.gsub(/\n+/, "\n")
661
+ => "remove_column :advertisements, :title
662
+ remove_column :advertisements, :body
663
+ add_column :adverts, :name, :string, limit: 255
664
+ rename_table :advertisements, :adverts
665
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'"
666
+
667
+ ### Drop a table
668
+
669
+ >> nuke_model_class(Advertisement)
670
+ {.hidden}
671
+
672
+ If you delete a model, the migration generator will create a `drop_table` migration.
673
+
674
+ Dropping tables is where the automatic down-migration really comes in handy:
675
+
676
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
677
+ >> up
678
+ => "drop_table :adverts"
679
+ >> down.gsub(/,.*/m, '')
680
+ => "create_table \"adverts\""
681
+
682
+ ## STI
683
+
684
+ ### Adding an STI subclass
685
+
686
+ Adding a subclass or two should introduce the 'type' column and no other changes
687
+
688
+ >>
689
+ class Advert < ActiveRecord::Base
690
+ fields do
691
+ body :text, null: true
692
+ title :string, default: "Untitled", limit: 255, null: true
693
+ end
694
+ end
695
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
696
+ >> ActiveRecord::Migration.class_eval up
697
+
698
+ class FancyAdvert < Advert
699
+ end
700
+ class SuperFancyAdvert < FancyAdvert
701
+ end
702
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
703
+ >> up.gsub(/\n+/, "\n")
704
+ => "add_column :adverts, :type, :string, limit: 255
705
+ add_index :adverts, [:type], name: 'on_type'"
706
+ >> down.gsub(/\n+/, "\n")
707
+ => "remove_column :adverts, :type
708
+ remove_index :adverts, name: :on_type rescue ActiveRecord::StatementInvalid"
709
+
710
+ Cleanup
711
+ {.hidden}
712
+
713
+ >> Advert.field_specs.delete(:type)
714
+ >> nuke_model_class(SuperFancyAdvert)
715
+ >> nuke_model_class(FancyAdvert)
716
+ >> Advert.index_specs.delete_if { |spec| spec.fields==["type"] }
717
+ {.hidden}
718
+
719
+
720
+ ## Coping with multiple changes
721
+
722
+ The migration generator is designed to create complete migrations even if many changes to the models have taken place.
723
+
724
+ First let's confirm we're in a known state. One model, 'Advert', with a string 'title' and text 'body':
725
+
726
+ >> ActiveRecord::Migration.class_eval up.gsub(/.*type.*/, '')
727
+ >> Advert.connection.schema_cache.clear!
728
+ >> Advert.reset_column_information
729
+
730
+ >> Advert.connection.tables
731
+ => ["adverts"]
732
+ >> Advert.columns.map(&:name).sort
733
+ => ["body", "id", "title"]
734
+ >> Generators::DeclareSchema::Migration::Migrator.run
735
+ => ["", ""]
736
+
737
+
738
+ ### Rename a column and change the default
739
+
740
+ >> Advert.field_specs.clear
741
+ >>
742
+ class Advert
743
+ fields do
744
+ name :string, default: "No Name", limit: 255, null: true
745
+ body :text, null: true
746
+ end
747
+ end
748
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { title: :name })
749
+ >> up
750
+ => "rename_column :adverts, :title, :name
751
+ change_column :adverts, :name, :string, limit: 255, default: \"No Name\""
752
+ >> down
753
+ => "rename_column :adverts, :name, :title
754
+ change_column :adverts, :title, :string, limit: 255, default: \"Untitled\""
755
+
756
+
757
+ ### Rename a table and add a column
758
+
759
+ >> nuke_model_class(Advert)
760
+ {.hidden}
761
+
762
+ >>
763
+ class Ad < ActiveRecord::Base
764
+ fields do
765
+ title :string, default: "Untitled", limit: 255
766
+ body :text, null: true
767
+ created_at :datetime
768
+ end
769
+ end
770
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: :ads)
771
+ >> up.gsub(/\n+/, "\n")
772
+ => "rename_table :adverts, :ads
773
+ add_column :ads, :created_at, :datetime, null: false
774
+ change_column :ads, :title, :string, limit: 255, null: false, default: \"Untitled\"
775
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'"
776
+
777
+ >>
778
+ class Advert < ActiveRecord::Base
779
+ fields do
780
+ body :text, null: true
781
+ title :string, default: "Untitled", limit: 255, null: true
782
+ end
783
+ end
784
+ {.hidden}
785
+
786
+ ## Legacy Keys
787
+
788
+ DeclareSchema has some support for legacy keys.
789
+
790
+ >> nuke_model_class(Ad)
791
+ >>
792
+ class Advert < ActiveRecord::Base
793
+ fields do
794
+ body :text, null: true
795
+ end
796
+ self.primary_key="advert_id"
797
+ end
798
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { id: :advert_id })
799
+ >> up.gsub(/\n+/, "\n")
800
+ => "rename_column :adverts, :id, :advert_id
801
+ add_index :adverts, [:advert_id], unique: true, name: 'PRIMARY_KEY'"
802
+
803
+ >> nuke_model_class(Advert)
804
+ >> ActiveRecord::Base.connection.execute "drop table `adverts`;"
805
+ {.hidden}
806
+
807
+ ## DSL
808
+
809
+ The DSL allows lambdas and constants
810
+
811
+ >>
812
+ class User < ActiveRecord::Base
813
+ fields do
814
+ company :string, limit: 255, ruby_default: -> { "BigCorp" }
815
+ end
816
+ end
817
+ >> User.field_specs.keys
818
+ => ['company']
819
+ >> User.field_specs['company'].options[:ruby_default]&.call
820
+ => "BigCorp"
821
+
822
+
823
+ ## validates
824
+
825
+ DeclareSchema can accept a validates hash in the field options.
826
+
827
+ >> $company_validates_options = :none
828
+ >>
829
+ class Ad < ActiveRecord::Base; end;
830
+ def Ad.validates(field_name, options)
831
+ $company_validates_options = "got field_name: #{field_name}, options: #{options.inspect}"
832
+ end
833
+ >>
834
+ class Ad < ActiveRecord::Base
835
+ fields do
836
+ company :string, limit: 255, index: true, unique: true, validates: { presence: true, uniqueness: { case_sensitive: false } }
837
+ end
838
+ self.primary_key="advert_id"
839
+ end
840
+ >> # expect(Ad).to receive(:validates).with(:company, presence: true, uniqueness: { case_sensitive: false })
841
+ >> up, down = Generators::DeclareSchema::Migration::Migrator.run
842
+ >> ActiveRecord::Migration.class_eval up
843
+ >> $company_validates_options
844
+ => "got field_name: company, options: {:presence=>true, :uniqueness=>{:case_sensitive=>false}}"
845
+ >> Ad.field_specs['company'].options[:validates].inspect
846
+ => "{:presence=>true, :uniqueness=>{:case_sensitive=>false}}"