declare_schema 0.1.3 → 0.3.0

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.
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