declare_schema 0.1.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/Gemfile +0 -1
  4. data/Gemfile.lock +1 -3
  5. data/README.md +20 -0
  6. data/Rakefile +13 -20
  7. data/gemfiles/rails_4.gemfile +0 -1
  8. data/gemfiles/rails_5.gemfile +0 -1
  9. data/gemfiles/rails_6.gemfile +0 -1
  10. data/lib/declare_schema/model.rb +27 -24
  11. data/lib/declare_schema/model/field_spec.rb +1 -12
  12. data/lib/declare_schema/version.rb +1 -1
  13. data/lib/generators/declare_schema/migration/migration_generator.rb +20 -14
  14. data/lib/generators/declare_schema/migration/migrator.rb +50 -31
  15. data/lib/generators/declare_schema/migration/templates/migration.rb.erb +1 -1
  16. data/lib/generators/declare_schema/support/eval_template.rb +12 -3
  17. data/lib/generators/declare_schema/support/model.rb +77 -2
  18. data/spec/lib/declare_schema/api_spec.rb +125 -0
  19. data/spec/lib/declare_schema/field_declaration_dsl_spec.rb +8 -4
  20. data/spec/lib/declare_schema/generator_spec.rb +57 -0
  21. data/spec/lib/declare_schema/interactive_primary_key_spec.rb +51 -0
  22. data/spec/lib/declare_schema/migration_generator_spec.rb +795 -0
  23. data/spec/lib/declare_schema/prepare_testapp.rb +31 -0
  24. data/spec/lib/generators/declare_schema/migration/migrator_spec.rb +72 -0
  25. data/spec/spec_helper.rb +26 -0
  26. metadata +11 -12
  27. data/lib/generators/declare_schema/model/templates/model_injection.rb.erb +0 -25
  28. data/test/api.rdoctest +0 -136
  29. data/test/generators.rdoctest +0 -62
  30. data/test/interactive_primary_key.rdoctest +0 -56
  31. data/test/migration_generator.rdoctest +0 -846
  32. data/test/migration_generator_comments.rdoctestDISABLED +0 -74
  33. data/test/prepare_testapp.rb +0 -15
@@ -1,4 +1,4 @@
1
- class <%= @migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR > 4 ? '[4.2]' : '' %>
1
+ class <%= @migration_class_name %> < (Rails::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration)
2
2
  def self.up
3
3
  <%= @up %>
4
4
  end
@@ -9,9 +9,18 @@ module DeclareSchema
9
9
  private
10
10
 
11
11
  def eval_template(template_name)
12
- source = File.expand_path(find_in_source_paths(template_name))
13
- context = instance_eval('binding')
14
- ERB.new(::File.binread(source), trim_mode: '-').result(context)
12
+ source = File.expand_path(find_in_source_paths(template_name))
13
+ erb = ERB.new(::File.read(source).force_encoding(Encoding::UTF_8), trim_mode: '>')
14
+ erb.filename = source
15
+ begin
16
+ erb.result(binding)
17
+ rescue Exception => ex
18
+ raise ex.class, <<~EOS
19
+ #{ex.message}
20
+ #{erb.src}
21
+ #{ex.backtrace.join("\n ")}
22
+ EOS
23
+ end
15
24
  end
16
25
  end
17
26
  end
@@ -4,6 +4,39 @@ require_relative './eval_template'
4
4
 
5
5
  module DeclareSchema
6
6
  module Support
7
+ class IndentedBuffer
8
+ def initialize(indent: 0)
9
+ @string = +""
10
+ @indent = indent
11
+ @column = 0
12
+ @indent_amount = 2
13
+ end
14
+
15
+ def to_string
16
+ @string
17
+ end
18
+
19
+ def indent!
20
+ @indent += @indent_amount
21
+ yield
22
+ @indent -= @indent_amount
23
+ end
24
+
25
+ def newline!
26
+ @column = 0
27
+ @string << "\n"
28
+ end
29
+
30
+ def <<(str)
31
+ if (difference = @indent - @column) > 0
32
+ @string << ' ' * difference
33
+ end
34
+ @column += difference
35
+ @string << str
36
+ newline!
37
+ end
38
+ end
39
+
7
40
  module Model
8
41
  class << self
9
42
  def included(base)
@@ -26,9 +59,51 @@ module DeclareSchema
26
59
 
27
60
  def inject_declare_schema_code_into_model_file
28
61
  gsub_file(model_path, / # attr_accessible :title, :body\n/m, "")
29
- inject_into_class model_path, class_name do
30
- eval_template('model_injection.rb.erb')
62
+ inject_into_class(model_path, class_name) do
63
+ declare_model_fields_and_associations
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def declare_model_fields_and_associations
70
+ buffer = ::DeclareSchema::Support::IndentedBuffer.new(indent: 2)
71
+ buffer.newline!
72
+ buffer << 'fields do'
73
+ buffer.indent! do
74
+ field_attributes.each do |attribute|
75
+ decl = "%-#{max_attribute_length}s" % attribute.name + ' ' +
76
+ attribute.type.to_sym.inspect +
77
+ case attribute.type.to_s
78
+ when 'string'
79
+ ', limit: 255'
80
+ else
81
+ ''
82
+ end
83
+ buffer << decl
84
+ end
85
+ if options[:timestamps]
86
+ buffer.newline!
87
+ buffer << 'timestamps'
88
+ end
89
+ end
90
+ buffer << 'end'
91
+
92
+ if bts.any?
93
+ buffer.newline!
94
+ bts.each do |bt|
95
+ buffer << "belongs_to #{bt.to_sym.inspect}"
96
+ end
31
97
  end
98
+ if hms.any?
99
+ buffer.newline
100
+ hms.each do |hm|
101
+ buffer << "has_many #{hm.to_sym.inspect}, dependent: :destroy"
102
+ end
103
+ end
104
+ buffer.newline!
105
+
106
+ buffer.to_string
32
107
  end
33
108
 
34
109
  protected
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'rails/generators'
5
+
6
+ RSpec.describe 'DeclareSchema API' do
7
+ before do
8
+ load File.expand_path('prepare_testapp.rb', __dir__)
9
+ end
10
+
11
+ describe 'example models' do
12
+ it 'generates a model' do
13
+ expect(system("bundle exec rails generate declare_schema:model advert title:string body:text")).to be_truthy
14
+
15
+ # The above will generate the test, fixture and a model file like this:
16
+ # model_declaration = Rails::Generators.invoke('declare_schema:model', ['advert2', 'title:string', 'body:text'])
17
+ # expect(model_declaration.first).to eq([["Advert"], nil, "app/models/advert.rb", nil,
18
+ # [["AdvertTest"], "test/models/advert_test.rb", nil, "test/fixtures/adverts.yml"]])
19
+
20
+ expect(File.read("#{TESTAPP_PATH}/app/models/advert.rb")).to eq(<<~EOS)
21
+ class Advert < #{active_record_base_class}
22
+
23
+ fields do
24
+ title :string, limit: 255
25
+ body :text
26
+ end
27
+
28
+ end
29
+ EOS
30
+ system("rm -rf #{TESTAPP_PATH}/app/models/advert2.rb #{TESTAPP_PATH}/test/models/advert2.rb #{TESTAPP_PATH}/test/fixtures/advert2.rb")
31
+
32
+ # The migration generator uses this information to create a migration.
33
+ # The following creates and runs the migration:
34
+
35
+ expect(system("bundle exec rails generate declare_schema:migration -n -m")).to be_truthy
36
+
37
+ # We're now ready to start demonstrating the API
38
+
39
+ Rails.application.config.autoload_paths += ["#{TESTAPP_PATH}/app/models"]
40
+
41
+ $LOAD_PATH << "#{TESTAPP_PATH}/app/models"
42
+
43
+ unless Rails::VERSION::MAJOR >= 6
44
+ # TODO: get this to work on Travis for Rails 6
45
+ Rails::Generators.invoke('declare_schema:migration', %w[-n -m])
46
+ end
47
+
48
+ require 'advert'
49
+
50
+ ## The Basics
51
+
52
+ # The main feature of DeclareSchema, aside from the migration generator, is the ability to declare rich types for your fields. For example, you can declare that a field is an email address, and the field will be automatically validated for correct email address syntax.
53
+
54
+ ### Field Types
55
+
56
+ # Field values are returned as the type you specify.
57
+
58
+ Advert.destroy_all
59
+
60
+ a = Advert.new(body: "This is the body", id: 1, title: "title")
61
+ expect(a.body).to eq("This is the body")
62
+
63
+ # This also works after a round-trip to the database
64
+
65
+ a.save!
66
+ expect(a.reload.body).to eq("This is the body")
67
+
68
+ ## Names vs. Classes
69
+
70
+ ## Model extensions
71
+
72
+ # DeclareSchema adds a few features to your models.
73
+
74
+ ### `Model.attr_type`
75
+
76
+ # Returns the type (i.e. class) declared for a given field or attribute
77
+
78
+ Advert.connection.schema_cache.clear!
79
+ Advert.reset_column_information
80
+
81
+ expect(Advert.attr_type(:title)).to eq(String)
82
+ expect(Advert.attr_type(:body)).to eq(String)
83
+
84
+ ## Field validations
85
+
86
+ # DeclareSchema gives you some shorthands for declaring some common validations right in the field declaration
87
+
88
+ ### Required fields
89
+
90
+ # The `:required` argument to a field gives a `validates_presence_of`:
91
+
92
+ class AdvertWithRequiredTitle < ActiveRecord::Base
93
+ self.table_name = 'adverts'
94
+
95
+ fields do
96
+ title :string, :required, limit: 255
97
+ end
98
+ end
99
+
100
+ a = AdvertWithRequiredTitle.new
101
+ expect(a.valid? || a.errors.full_messages).to eq(["Title can't be blank"])
102
+ a.id = 2
103
+ a.body = "hello"
104
+ a.title = "Jimbo"
105
+ a.save!
106
+
107
+ ### Unique fields
108
+
109
+ # The `:unique` argument in a field declaration gives `validates_uniqueness_of`:
110
+
111
+ class AdvertWithUniqueTitle < ActiveRecord::Base
112
+ self.table_name = 'adverts'
113
+
114
+ fields do
115
+ title :string, :unique, limit: 255
116
+ end
117
+ end
118
+
119
+ a = AdvertWithUniqueTitle.new :title => "Jimbo", id: 3, body: "hello"
120
+ expect(a.valid? || a.errors.full_messages).to eq(["Title has already been taken"])
121
+ a.title = "Sambo"
122
+ a.save!
123
+ end
124
+ end
125
+ end
@@ -3,11 +3,15 @@
3
3
  require_relative '../../../lib/declare_schema/field_declaration_dsl'
4
4
 
5
5
  RSpec.describe DeclareSchema::FieldDeclarationDsl do
6
- class TestModel < ActiveRecord::Base
7
- fields do
8
- name :string, limit: 127
6
+ before do
7
+ load File.expand_path('prepare_testapp.rb', __dir__)
9
8
 
10
- timestamps
9
+ class TestModel < ActiveRecord::Base
10
+ fields do
11
+ name :string, limit: 127
12
+
13
+ timestamps
14
+ end
11
15
  end
12
16
  end
13
17
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe 'DeclareSchema Migration Generator' do
4
+ before do
5
+ load File.expand_path('prepare_testapp.rb', __dir__)
6
+ end
7
+
8
+ it "generates nested models" do
9
+ Rails::Generators.invoke('declare_schema:model', %w[alpha/beta one:string two:integer])
10
+
11
+ expect(File.exist?('app/models/alpha/beta.rb')).to be_truthy
12
+
13
+ expect(File.read('app/models/alpha/beta.rb')).to eq(<<~EOS)
14
+ class Alpha::Beta < #{active_record_base_class}
15
+
16
+ fields do
17
+ one :string, limit: 255
18
+ two :integer
19
+ end
20
+
21
+ end
22
+ EOS
23
+
24
+ expect(File.read('app/models/alpha.rb')).to eq(<<~EOS)
25
+ module Alpha
26
+ def self.table_name_prefix
27
+ 'alpha_'
28
+ end
29
+ end
30
+ EOS
31
+
32
+ expect(File.read('test/models/alpha/beta_test.rb')).to eq(<<~EOS)
33
+ require 'test_helper'
34
+
35
+ class Alpha::BetaTest < ActiveSupport::TestCase
36
+ # test "the truth" do
37
+ # assert true
38
+ # end
39
+ end
40
+ EOS
41
+
42
+ expect(File.exist?('test/fixtures/alpha/beta.yml')).to be_truthy
43
+
44
+ $LOAD_PATH << "#{TESTAPP_PATH}/app/models"
45
+
46
+ expect(system("bundle exec rails generate declare_schema:migration -n -m")).to be_truthy
47
+
48
+ expect(File.exist?('db/schema.rb')).to be_truthy
49
+
50
+ expect(File.exist?("db/development.sqlite3") || File.exist?("db/test.sqlite3")).to be_truthy
51
+
52
+ module Alpha; end
53
+ require 'alpha/beta'
54
+
55
+ expect { Alpha::Beta }.to_not raise_exception
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe 'DeclareSchema Migration Generator interactive primary key' do
4
+ before do
5
+ load File.expand_path('prepare_testapp.rb', __dir__)
6
+ end
7
+
8
+ it "allows alternate primary keys" do
9
+ class Foo < ActiveRecord::Base
10
+ fields do
11
+ end
12
+ self.primary_key = "foo_id"
13
+ end
14
+
15
+ Rails::Generators.invoke('declare_schema:migration', %w[-n -m])
16
+ expect(Foo.primary_key).to eq('foo_id')
17
+
18
+ ### migrate from
19
+ # rename from custom primary_key
20
+ class Foo < ActiveRecord::Base
21
+ fields do
22
+ end
23
+ self.primary_key = "id"
24
+ end
25
+
26
+ puts "\n\e[45m Please enter 'id' (no quotes) at the next prompt \e[0m"
27
+ Rails::Generators.invoke('declare_schema:migration', %w[-n -m])
28
+ expect(Foo.primary_key).to eq('id')
29
+
30
+ nuke_model_class(Foo)
31
+
32
+ ### migrate to
33
+
34
+ # rename to custom primary_key
35
+ class Foo < ActiveRecord::Base
36
+ fields do
37
+ end
38
+ self.primary_key = "foo_id"
39
+ end
40
+
41
+ puts "\n\e[45m Please enter 'drop id' (no quotes) at the next prompt \e[0m"
42
+ Rails::Generators.invoke('declare_schema:migration', %w[-n -m])
43
+ expect(Foo.primary_key).to eq('foo_id')
44
+
45
+ ### ensure it doesn't cause further migrations
46
+
47
+ # check no further migrations
48
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
49
+ expect(up).to eq("")
50
+ end
51
+ end
@@ -0,0 +1,795 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ RSpec.describe 'DeclareSchema Migration Generator' do
6
+ before do
7
+ load File.expand_path('prepare_testapp.rb', __dir__)
8
+ end
9
+
10
+ # DeclareSchema - Migration Generator
11
+ it 'generates migrations' do
12
+ ## The migration generator -- introduction
13
+
14
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
15
+ expect(up_down).to eq(["", ""])
16
+
17
+ class Advert < ActiveRecord::Base
18
+ end
19
+
20
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
21
+ expect(up_down).to eq(["", ""])
22
+
23
+ Generators::DeclareSchema::Migration::Migrator.ignore_tables = ["green_fishes"]
24
+
25
+ Advert.connection.schema_cache.clear!
26
+ Advert.reset_column_information
27
+
28
+ class Advert < ActiveRecord::Base
29
+ fields do
30
+ name :string, limit: 255, null: true
31
+ end
32
+ end
33
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
34
+ expect(up).to eq(<<~EOS.strip)
35
+ create_table :adverts, id: :bigint do |t|
36
+ t.string :name, limit: 255
37
+ end
38
+ EOS
39
+ expect(down).to eq("drop_table :adverts")
40
+
41
+ ActiveRecord::Migration.class_eval(up)
42
+ expect(Advert.columns.map(&:name)).to eq(["id", "name"])
43
+
44
+ class Advert < ActiveRecord::Base
45
+ fields do
46
+ name :string, limit: 255, null: true
47
+ body :text, null: true
48
+ published_at :datetime, null: true
49
+ end
50
+ end
51
+ up, down = migrate
52
+ expect(up).to eq(<<~EOS.strip)
53
+ add_column :adverts, :body, :text
54
+ add_column :adverts, :published_at, :datetime
55
+
56
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
57
+ EOS
58
+ # TODO: ^ TECH-4975 add_index should not be there
59
+
60
+ expect(down).to eq(<<~EOS.strip)
61
+ remove_column :adverts, :body
62
+ remove_column :adverts, :published_at
63
+ EOS
64
+
65
+ Advert.field_specs.clear # not normally needed
66
+ class Advert < ActiveRecord::Base
67
+ fields do
68
+ name :string, limit: 255, null: true
69
+ body :text, null: true
70
+ end
71
+ end
72
+
73
+ up, down = migrate
74
+ expect(up).to eq("remove_column :adverts, :published_at")
75
+ expect(down).to eq("add_column :adverts, :published_at, :datetime")
76
+
77
+ nuke_model_class(Advert)
78
+ class Advert < ActiveRecord::Base
79
+ fields do
80
+ title :string, limit: 255, null: true
81
+ body :text, null: true
82
+ end
83
+ end
84
+
85
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
86
+ expect(up).to eq(<<~EOS.strip)
87
+ add_column :adverts, :title, :string, limit: 255
88
+ remove_column :adverts, :name
89
+ EOS
90
+
91
+ expect(down).to eq(<<~EOS.strip)
92
+ remove_column :adverts, :title
93
+ add_column :adverts, :name, :string, limit: 255
94
+ EOS
95
+
96
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { name: :title })
97
+ expect(up).to eq("rename_column :adverts, :name, :title")
98
+ expect(down).to eq("rename_column :adverts, :title, :name")
99
+
100
+ migrate
101
+
102
+ class Advert < ActiveRecord::Base
103
+ fields do
104
+ title :text, null: true
105
+ body :text, null: true
106
+ end
107
+ end
108
+
109
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
110
+ expect(up_down).to eq(["change_column :adverts, :title, :text",
111
+ "change_column :adverts, :title, :string, limit: 255"])
112
+
113
+ class Advert < ActiveRecord::Base
114
+ fields do
115
+ title :string, default: "Untitled", limit: 255, null: true
116
+ body :text, null: true
117
+ end
118
+ end
119
+
120
+ up, down = migrate
121
+ expect(up.split(',').slice(0,3).join(',')).to eq('change_column :adverts, :title, :string')
122
+ expect(up.split(',').slice(3,2).sort.join(',')).to eq(" default: \"Untitled\", limit: 255")
123
+ expect(down).to eq("change_column :adverts, :title, :string, limit: 255")
124
+
125
+
126
+ ### Limits
127
+
128
+ class Advert < ActiveRecord::Base
129
+ fields do
130
+ price :integer, null: true, limit: 2
131
+ end
132
+ end
133
+
134
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
135
+ expect(up).to eq("add_column :adverts, :price, :integer, limit: 2")
136
+
137
+ # Now run the migration, then change the limit:
138
+
139
+ ActiveRecord::Migration.class_eval(up)
140
+ class Advert < ActiveRecord::Base
141
+ fields do
142
+ price :integer, null: true, limit: 3
143
+ end
144
+ end
145
+
146
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
147
+ expect(up).to eq("change_column :adverts, :price, :integer, limit: 3")
148
+ expect(down).to eq("change_column :adverts, :price, :integer, limit: 2")
149
+
150
+ # Note that limit on a decimal column is ignored (use :scale and :precision)
151
+
152
+ ActiveRecord::Migration.class_eval("remove_column :adverts, :price")
153
+ class Advert < ActiveRecord::Base
154
+ fields do
155
+ price :decimal, null: true, limit: 4
156
+ end
157
+ end
158
+
159
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
160
+ expect(up).to eq("add_column :adverts, :price, :decimal")
161
+
162
+ # Limits are generally not needed for `text` fields, because by default, `text` fields will use the maximum size
163
+ # allowed for that database type (0xffffffff for LONGTEXT in MySQL unlimited in Postgres, 1 billion in Sqlite).
164
+ # If a `limit` is given, it will only be used in MySQL, to choose the smallest TEXT field that will accommodate
165
+ # that limit (0xff for TINYTEXT, 0xffff for TEXT, 0xffffff for MEDIUMTEXT, 0xffffffff for LONGTEXT).
166
+
167
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_falsey
168
+ class Advert < ActiveRecord::Base
169
+ fields do
170
+ notes :text
171
+ description :text, limit: 30000
172
+ end
173
+ end
174
+
175
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
176
+ expect(up).to eq(<<~EOS.strip)
177
+ add_column :adverts, :price, :decimal
178
+ add_column :adverts, :notes, :text, null: false
179
+ add_column :adverts, :description, :text, null: false
180
+ EOS
181
+
182
+ # (There is no limit on `add_column ... :description` above since these tests are run against SQLite.)
183
+
184
+ Advert.field_specs.delete :price
185
+ Advert.field_specs.delete :notes
186
+ Advert.field_specs.delete :description
187
+
188
+ # In MySQL, limits are applied, rounded up:
189
+
190
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
191
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
192
+ class Advert < ActiveRecord::Base
193
+ fields do
194
+ notes :text
195
+ description :text, limit: 200
196
+ end
197
+ end
198
+
199
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
200
+ expect(up).to eq(<<~EOS.strip)
201
+ add_column :adverts, :notes, :text, null: false, limit: 4294967295
202
+ add_column :adverts, :description, :text, null: false, limit: 255
203
+ EOS
204
+
205
+ Advert.field_specs.delete :notes
206
+
207
+ # Limits that are too high for MySQL will raise an exception.
208
+
209
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
210
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
211
+ expect do
212
+ class Advert < ActiveRecord::Base
213
+ fields do
214
+ notes :text
215
+ description :text, limit: 0x1_0000_0000
216
+ end
217
+ end
218
+ end.to raise_exception(ArgumentError, "limit of 4294967296 is too large for MySQL")
219
+
220
+ Advert.field_specs.delete :notes
221
+
222
+ # And in MySQL, unstated text limits are treated as the maximum (LONGTEXT) limit.
223
+
224
+ # To start, we'll set the database schema for `description` to match the above limit of 255.
225
+
226
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
227
+ Advert.connection.execute "ALTER TABLE adverts ADD COLUMN description TINYTEXT"
228
+ Advert.connection.schema_cache.clear!
229
+ Advert.reset_column_information
230
+ expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables).
231
+ to eq(["adverts"])
232
+ expect(Advert.columns.map(&:name)).to eq(["id", "body", "title", "description"])
233
+
234
+ # Now migrate to an unstated text limit:
235
+
236
+ class Advert < ActiveRecord::Base
237
+ fields do
238
+ description :text
239
+ end
240
+ end
241
+
242
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
243
+ expect(up).to eq("change_column :adverts, :description, :text, limit: 4294967295, null: false")
244
+ expect(down).to eq("change_column :adverts, :description, :text")
245
+
246
+ # TODO TECH-4814: The above test should have this output:
247
+ # TODO => "change_column :adverts, :description, :text, limit: 255
248
+
249
+ # And migrate to a stated text limit that is the same as the unstated one:
250
+
251
+ class Advert < ActiveRecord::Base
252
+ fields do
253
+ description :text, limit: 0xffffffff
254
+ end
255
+ end
256
+
257
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
258
+ expect(up).to eq("change_column :adverts, :description, :text, limit: 4294967295, null: false")
259
+ expect(down).to eq("change_column :adverts, :description, :text")
260
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, false)
261
+
262
+ Advert.field_specs.clear
263
+ Advert.connection.schema_cache.clear!
264
+ Advert.reset_column_information
265
+ class Advert < ActiveRecord::Base
266
+ fields do
267
+ name :string, limit: 255, null: true
268
+ end
269
+ end
270
+
271
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
272
+ ActiveRecord::Migration.class_eval up
273
+ Advert.connection.schema_cache.clear!
274
+ Advert.reset_column_information
275
+
276
+ ### Foreign Keys
277
+
278
+ # DeclareSchema extends the `belongs_to` macro so that it also declares the
279
+ # foreign-key field. It also generates an index on the field.
280
+
281
+ class Category < ActiveRecord::Base; end
282
+ class Advert < ActiveRecord::Base
283
+ fields do
284
+ name :string, limit: 255, null: true
285
+ end
286
+ belongs_to :category
287
+ end
288
+
289
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
290
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
291
+ add_column :adverts, :category_id, :integer, limit: 8, null: false
292
+ add_index :adverts, [:category_id], name: 'on_category_id'
293
+ EOS
294
+ expect(down.sub(/\n+/, "\n")).to eq(<<~EOS.strip)
295
+ remove_column :adverts, :category_id
296
+ remove_index :adverts, name: :on_category_id rescue ActiveRecord::StatementInvalid
297
+ EOS
298
+
299
+ Advert.field_specs.delete(:category_id)
300
+ Advert.index_specs.delete_if {|spec| spec.fields==["category_id"]}
301
+
302
+ # If you specify a custom foreign key, the migration generator observes that:
303
+
304
+ class Category < ActiveRecord::Base; end
305
+ class Advert < ActiveRecord::Base
306
+ fields { }
307
+ belongs_to :category, foreign_key: "c_id", class_name: 'Category'
308
+ end
309
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
310
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
311
+ add_column :adverts, :c_id, :integer, limit: 8, null: false
312
+ add_index :adverts, [:c_id], name: 'on_c_id'
313
+ EOS
314
+
315
+ Advert.field_specs.delete(:c_id)
316
+ Advert.index_specs.delete_if { |spec| spec.fields == ["c_id"] }
317
+
318
+ # You can avoid generating the index by specifying `index: false`
319
+
320
+ class Category < ActiveRecord::Base; end
321
+ class Advert < ActiveRecord::Base
322
+ fields { }
323
+ belongs_to :category, index: false
324
+ end
325
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
326
+ expect(up.gsub(/\n+/, "\n")).to eq("add_column :adverts, :category_id, :integer, limit: 8, null: false")
327
+
328
+ Advert.field_specs.delete(:category_id)
329
+ Advert.index_specs.delete_if { |spec| spec.fields == ["category_id"] }
330
+
331
+ # You can specify the index name with :index
332
+
333
+ class Category < ActiveRecord::Base; end
334
+ class Advert < ActiveRecord::Base
335
+ fields { }
336
+ belongs_to :category, index: 'my_index'
337
+ end
338
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
339
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
340
+ add_column :adverts, :category_id, :integer, limit: 8, null: false
341
+ add_index :adverts, [:category_id], name: 'my_index'
342
+ EOS
343
+
344
+ Advert.field_specs.delete(:category_id)
345
+ Advert.index_specs.delete_if { |spec| spec.fields == ["category_id"] }
346
+
347
+ ### Timestamps and Optimimistic Locking
348
+
349
+ # `updated_at` and `created_at` can be declared with the shorthand `timestamps`.
350
+ # Similarly, `lock_version` can be declared with the "shorthand" `optimimistic_lock`.
351
+
352
+ class Advert < ActiveRecord::Base
353
+ fields do
354
+ timestamps
355
+ optimistic_lock
356
+ end
357
+ end
358
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
359
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
360
+ add_column :adverts, :created_at, :datetime
361
+ add_column :adverts, :updated_at, :datetime
362
+ add_column :adverts, :lock_version, :integer, null: false, default: 1
363
+ EOS
364
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
365
+ remove_column :adverts, :created_at
366
+ remove_column :adverts, :updated_at
367
+ remove_column :adverts, :lock_version
368
+ EOS
369
+
370
+ Advert.field_specs.delete(:updated_at)
371
+ Advert.field_specs.delete(:created_at)
372
+ Advert.field_specs.delete(:lock_version)
373
+
374
+ ### Indices
375
+
376
+ # You can add an index to a field definition
377
+
378
+ class Advert < ActiveRecord::Base
379
+ fields do
380
+ title :string, index: true, limit: 255, null: true
381
+ end
382
+ end
383
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
384
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
385
+ add_column :adverts, :title, :string, limit: 255
386
+ add_index :adverts, [:title], name: 'on_title'
387
+ EOS
388
+
389
+ Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
390
+
391
+ # You can ask for a unique index
392
+
393
+ class Advert < ActiveRecord::Base
394
+ fields do
395
+ title :string, index: true, unique: true, null: true, limit: 255
396
+ end
397
+ end
398
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
399
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
400
+ add_column :adverts, :title, :string, limit: 255
401
+ add_index :adverts, [:title], unique: true, name: 'on_title'
402
+ EOS
403
+
404
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
405
+
406
+ # You can specify the name for the index
407
+
408
+ class Advert < ActiveRecord::Base
409
+ fields do
410
+ title :string, index: 'my_index', limit: 255, null: true
411
+ end
412
+ end
413
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
414
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
415
+ add_column :adverts, :title, :string, limit: 255
416
+ add_index :adverts, [:title], name: 'my_index'
417
+ EOS
418
+
419
+ Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
420
+
421
+ # You can ask for an index outside of the fields block
422
+
423
+ class Advert < ActiveRecord::Base
424
+ index :title
425
+ end
426
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
427
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
428
+ add_column :adverts, :title, :string, limit: 255
429
+ add_index :adverts, [:title], name: 'on_title'
430
+ EOS
431
+
432
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
433
+
434
+ # The available options for the index function are `:unique` and `:name`
435
+
436
+ class Advert < ActiveRecord::Base
437
+ index :title, unique: true, name: 'my_index'
438
+ end
439
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
440
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
441
+ add_column :adverts, :title, :string, limit: 255
442
+ add_index :adverts, [:title], unique: true, name: 'my_index'
443
+ EOS
444
+
445
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
446
+
447
+ # You can create an index on more than one field
448
+
449
+ class Advert < ActiveRecord::Base
450
+ index [:title, :category_id]
451
+ end
452
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
453
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
454
+ add_column :adverts, :title, :string, limit: 255
455
+ add_index :adverts, [:title, :category_id], name: 'on_title_and_category_id'
456
+ EOS
457
+
458
+ Advert.index_specs.delete_if { |spec| spec.fields==["title", "category_id"] }
459
+
460
+ # Finally, you can specify that the migration generator should completely ignore an
461
+ # index by passing its name to ignore_index in the model.
462
+ # This is helpful for preserving indices that can't be automatically generated, such as prefix indices in MySQL.
463
+
464
+ ### Rename a table
465
+
466
+ # 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.
467
+
468
+ class Advert < ActiveRecord::Base
469
+ self.table_name = "ads"
470
+ fields do
471
+ title :string, limit: 255, null: true
472
+ body :text, null: true
473
+ end
474
+ end
475
+
476
+ Advert.connection.schema_cache.clear!
477
+ Advert.reset_column_information
478
+
479
+ up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "ads")
480
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
481
+ rename_table :adverts, :ads
482
+ add_column :ads, :title, :string, limit: 255
483
+ add_column :ads, :body, :text
484
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'
485
+ EOS
486
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
487
+ remove_column :ads, :title
488
+ remove_column :ads, :body
489
+ rename_table :ads, :adverts
490
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
491
+ EOS
492
+
493
+ # Set the table name back to what it should be and confirm we're in sync:
494
+
495
+ Advert.field_specs.delete(:title)
496
+ Advert.field_specs.delete(:body)
497
+ class Advert < ActiveRecord::Base
498
+ self.table_name = "adverts"
499
+ end
500
+
501
+ expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""])
502
+
503
+ ### Rename a table
504
+
505
+ # 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.
506
+
507
+ nuke_model_class(Advert)
508
+
509
+ class Advertisement < ActiveRecord::Base
510
+ fields do
511
+ title :string, limit: 255, null: true
512
+ body :text, null: true
513
+ end
514
+ end
515
+ up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "advertisements")
516
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
517
+ rename_table :adverts, :advertisements
518
+ add_column :advertisements, :title, :string, limit: 255
519
+ add_column :advertisements, :body, :text
520
+ remove_column :advertisements, :name
521
+ add_index :advertisements, [:id], unique: true, name: 'PRIMARY_KEY'
522
+ EOS
523
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
524
+ remove_column :advertisements, :title
525
+ remove_column :advertisements, :body
526
+ add_column :adverts, :name, :string, limit: 255
527
+ rename_table :advertisements, :adverts
528
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
529
+ EOS
530
+
531
+ ### Drop a table
532
+
533
+ nuke_model_class(Advertisement)
534
+
535
+ # If you delete a model, the migration generator will create a `drop_table` migration.
536
+
537
+ # Dropping tables is where the automatic down-migration really comes in handy:
538
+
539
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
540
+ expect(up).to eq("drop_table :adverts")
541
+ expect(down.gsub(/,.*/m, '')).to eq("create_table \"adverts\"")
542
+
543
+ ## STI
544
+
545
+ ### Adding an STI subclass
546
+
547
+ # Adding a subclass or two should introduce the 'type' column and no other changes
548
+
549
+ class Advert < ActiveRecord::Base
550
+ fields do
551
+ body :text, null: true
552
+ title :string, default: "Untitled", limit: 255, null: true
553
+ end
554
+ end
555
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
556
+ ActiveRecord::Migration.class_eval(up)
557
+
558
+ class FancyAdvert < Advert
559
+ end
560
+ class SuperFancyAdvert < FancyAdvert
561
+ end
562
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
563
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
564
+ add_column :adverts, :type, :string, limit: 255
565
+ add_index :adverts, [:type], name: 'on_type'
566
+ EOS
567
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
568
+ remove_column :adverts, :type
569
+ remove_index :adverts, name: :on_type rescue ActiveRecord::StatementInvalid
570
+ EOS
571
+
572
+ Advert.field_specs.delete(:type)
573
+ nuke_model_class(SuperFancyAdvert)
574
+ nuke_model_class(FancyAdvert)
575
+ Advert.index_specs.delete_if { |spec| spec.fields==["type"] }
576
+
577
+ ## Coping with multiple changes
578
+
579
+ # The migration generator is designed to create complete migrations even if many changes to the models have taken place.
580
+
581
+ # First let's confirm we're in a known state. One model, 'Advert', with a string 'title' and text 'body':
582
+
583
+ ActiveRecord::Migration.class_eval up.gsub(/.*type.*/, '')
584
+ Advert.connection.schema_cache.clear!
585
+ Advert.reset_column_information
586
+
587
+ expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables).
588
+ to eq(["adverts"])
589
+ expect(Advert.columns.map(&:name).sort).to eq(["body", "id", "title"])
590
+ expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""])
591
+
592
+
593
+ ### Rename a column and change the default
594
+
595
+ Advert.field_specs.clear
596
+
597
+ class Advert < ActiveRecord::Base
598
+ fields do
599
+ name :string, default: "No Name", limit: 255, null: true
600
+ body :text, null: true
601
+ end
602
+ end
603
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { title: :name })
604
+ expect(up).to eq(<<~EOS.strip)
605
+ rename_column :adverts, :title, :name
606
+ change_column :adverts, :name, :string, limit: 255, default: \"No Name\"
607
+ EOS
608
+
609
+ expect(down).to eq(<<~EOS.strip)
610
+ rename_column :adverts, :name, :title
611
+ change_column :adverts, :title, :string, limit: 255, default: \"Untitled\"
612
+ EOS
613
+
614
+ ### Rename a table and add a column
615
+
616
+ nuke_model_class(Advert)
617
+ class Ad < ActiveRecord::Base
618
+ fields do
619
+ title :string, default: "Untitled", limit: 255
620
+ body :text, null: true
621
+ created_at :datetime
622
+ end
623
+ end
624
+ up = Generators::DeclareSchema::Migration::Migrator.run(adverts: :ads).first
625
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
626
+ rename_table :adverts, :ads
627
+ add_column :ads, :created_at, :datetime, null: false
628
+ change_column :ads, :title, :string, limit: 255, null: false, default: \"Untitled\"
629
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'
630
+ EOS
631
+
632
+ class Advert < ActiveRecord::Base
633
+ fields do
634
+ body :text, null: true
635
+ title :string, default: "Untitled", limit: 255, null: true
636
+ end
637
+ end
638
+
639
+ ## Legacy Keys
640
+
641
+ # DeclareSchema has some support for legacy keys.
642
+
643
+ nuke_model_class(Ad)
644
+
645
+ class Advert < ActiveRecord::Base
646
+ fields do
647
+ body :text, null: true
648
+ end
649
+ self.primary_key = "advert_id"
650
+ end
651
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { id: :advert_id })
652
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
653
+ rename_column :adverts, :id, :advert_id
654
+ add_index :adverts, [:advert_id], unique: true, name: 'PRIMARY_KEY'
655
+ EOS
656
+
657
+ nuke_model_class(Advert)
658
+ ActiveRecord::Base.connection.execute("drop table `adverts`;")
659
+
660
+ ## DSL
661
+
662
+ # The DSL allows lambdas and constants
663
+
664
+ class User < ActiveRecord::Base
665
+ fields do
666
+ company :string, limit: 255, ruby_default: -> { "BigCorp" }
667
+ end
668
+ end
669
+ expect(User.field_specs.keys).to eq(['company'])
670
+ expect(User.field_specs['company'].options[:ruby_default]&.call).to eq("BigCorp")
671
+
672
+ ## validates
673
+
674
+ # DeclareSchema can accept a validates hash in the field options.
675
+
676
+ class Ad < ActiveRecord::Base
677
+ class << self
678
+ def validates(field_name, options)
679
+ end
680
+ end
681
+ end
682
+ expect(Ad).to receive(:validates).with(:company, presence: true, uniqueness: { case_sensitive: false })
683
+ class Ad < ActiveRecord::Base
684
+ fields do
685
+ company :string, limit: 255, index: true, unique: true, validates: { presence: true, uniqueness: { case_sensitive: false } }
686
+ end
687
+ self.primary_key = "advert_id"
688
+ end
689
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run
690
+ ActiveRecord::Migration.class_eval(up)
691
+ expect(Ad.field_specs['company'].options[:validates].inspect).to eq("{:presence=>true, :uniqueness=>{:case_sensitive=>false}}")
692
+ end
693
+
694
+ if Rails::VERSION::MAJOR >= 5
695
+ describe 'belongs_to' do
696
+ before do
697
+ unless defined?(AdCategory)
698
+ class AdCategory < ActiveRecord::Base
699
+ fields { }
700
+ end
701
+ end
702
+
703
+ class Advert < ActiveRecord::Base
704
+ fields do
705
+ name :string, limit: 255, null: true
706
+ category_id :integer, limit: 8
707
+ nullable_category_id :integer, limit: 8, null: true
708
+ end
709
+ end
710
+ up = Generators::DeclareSchema::Migration::Migrator.run.first
711
+ ActiveRecord::Migration.class_eval(up)
712
+ end
713
+
714
+ it 'passes through optional: when given' do
715
+ class AdvertBelongsTo < ActiveRecord::Base
716
+ self.table_name = 'adverts'
717
+ fields { }
718
+ reset_column_information
719
+ belongs_to :ad_category, optional: true
720
+ end
721
+ expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional: true)
722
+ end
723
+
724
+ describe 'contradictory settings' do # contradictory settings are ok during migration
725
+ it 'passes through optional: true, null: false' do
726
+ class AdvertBelongsTo < ActiveRecord::Base
727
+ self.table_name = 'adverts'
728
+ fields { }
729
+ reset_column_information
730
+ belongs_to :ad_category, optional: true, null: false
731
+ end
732
+ expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional: true)
733
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(false)
734
+ end
735
+
736
+ it 'passes through optional: false, null: true' do
737
+ class AdvertBelongsTo < ActiveRecord::Base
738
+ self.table_name = 'adverts'
739
+ fields { }
740
+ reset_column_information
741
+ belongs_to :ad_category, optional: false, null: true
742
+ end
743
+ expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional: false)
744
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(true)
745
+ end
746
+ end
747
+
748
+ [false, true].each do |nullable|
749
+ context "nullable=#{nullable}" do
750
+ it 'infers optional: from null:' do
751
+ eval <<~EOS
752
+ class AdvertBelongsTo < ActiveRecord::Base
753
+ fields { }
754
+ belongs_to :ad_category, null: #{nullable}
755
+ end
756
+ EOS
757
+ expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional: nullable)
758
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable)
759
+ end
760
+
761
+ it 'infers null: from optional:' do
762
+ eval <<~EOS
763
+ class AdvertBelongsTo < ActiveRecord::Base
764
+ fields { }
765
+ belongs_to :ad_category, optional: #{nullable}
766
+ end
767
+ EOS
768
+ expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional: nullable)
769
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable)
770
+ end
771
+ end
772
+ end
773
+ end
774
+ end
775
+
776
+ describe 'migration base class' do
777
+ it 'adapts to Rails 4' do
778
+ class Advert < active_record_base_class.constantize
779
+ fields do
780
+ title :string, limit: 100
781
+ end
782
+ end
783
+
784
+ Rails::Generators.invoke('declare_schema:migration', %w[-n -m])
785
+
786
+ migrations = Dir.glob('db/migrate/*declare_schema_migration*.rb')
787
+ expect(migrations.size).to eq(1), migrations.inspect
788
+
789
+ migration_content = File.read(migrations.first)
790
+ first_line = migration_content.split("\n").first
791
+ base_class = first_line.split(' < ').last
792
+ expect(base_class).to eq("(Rails::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration)")
793
+ end
794
+ end
795
+ end