declare_schema 0.1.2 → 0.3.0.pre.2

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 +24 -0
  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 +0 -1
  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 +54 -30
  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 +686 -0
  23. data/spec/lib/declare_schema/prepare_testapp.rb +29 -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 %> < ActiveRecord::Migration<%= ('[4.2]' if Rails::VERSION::MAJOR >= 5) %>
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,686 @@
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
+ # DeclareSchema - Migration Generator
9
+ it 'generates migrations' do
10
+ ## The migration generator -- introduction
11
+
12
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
13
+ expect(up_down).to eq(["", ""])
14
+
15
+ class Advert < ActiveRecord::Base
16
+ end
17
+
18
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
19
+ expect(up_down).to eq(["", ""])
20
+
21
+ Generators::DeclareSchema::Migration::Migrator.ignore_tables = ["green_fishes"]
22
+
23
+ Advert.connection.schema_cache.clear!
24
+ Advert.reset_column_information
25
+
26
+ class Advert < ActiveRecord::Base
27
+ fields do
28
+ name :string, limit: 255, null: true
29
+ end
30
+ end
31
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
32
+ expect(up).to eq(<<~EOS.strip)
33
+ create_table :adverts, id: :bigint do |t|
34
+ t.string :name, limit: 255
35
+ end
36
+ EOS
37
+ expect(down).to eq("drop_table :adverts")
38
+
39
+ ActiveRecord::Migration.class_eval(up)
40
+ expect(Advert.columns.map(&:name)).to eq(["id", "name"])
41
+
42
+ class Advert < ActiveRecord::Base
43
+ fields do
44
+ name :string, limit: 255, null: true
45
+ body :text, null: true
46
+ published_at :datetime, null: true
47
+ end
48
+ end
49
+ up, down = migrate
50
+ expect(up).to eq(<<~EOS.strip)
51
+ add_column :adverts, :body, :text
52
+ add_column :adverts, :published_at, :datetime
53
+
54
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
55
+ EOS
56
+ # TODO: ^ add_index should not be there?
57
+
58
+ expect(down).to eq(<<~EOS.strip)
59
+ remove_column :adverts, :body
60
+ remove_column :adverts, :published_at
61
+ EOS
62
+
63
+ Advert.field_specs.clear # not normally needed
64
+ class Advert < ActiveRecord::Base
65
+ fields do
66
+ name :string, limit: 255, null: true
67
+ body :text, null: true
68
+ end
69
+ end
70
+
71
+ up, down = migrate
72
+ expect(up).to eq("remove_column :adverts, :published_at")
73
+ expect(down).to eq("add_column :adverts, :published_at, :datetime")
74
+
75
+ nuke_model_class(Advert)
76
+ class Advert < ActiveRecord::Base
77
+ fields do
78
+ title :string, limit: 255, null: true
79
+ body :text, null: true
80
+ end
81
+ end
82
+
83
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
84
+ expect(up).to eq(<<~EOS.strip)
85
+ add_column :adverts, :title, :string, limit: 255
86
+ remove_column :adverts, :name
87
+ EOS
88
+
89
+ expect(down).to eq(<<~EOS.strip)
90
+ remove_column :adverts, :title
91
+ add_column :adverts, :name, :string, limit: 255
92
+ EOS
93
+
94
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { name: :title })
95
+ expect(up).to eq("rename_column :adverts, :name, :title")
96
+ expect(down).to eq("rename_column :adverts, :title, :name")
97
+
98
+ migrate
99
+
100
+ class Advert < ActiveRecord::Base
101
+ fields do
102
+ title :text, null: true
103
+ body :text, null: true
104
+ end
105
+ end
106
+
107
+ up_down = Generators::DeclareSchema::Migration::Migrator.run
108
+ expect(up_down).to eq(["change_column :adverts, :title, :text",
109
+ "change_column :adverts, :title, :string, limit: 255"])
110
+
111
+ class Advert < ActiveRecord::Base
112
+ fields do
113
+ title :string, default: "Untitled", limit: 255, null: true
114
+ body :text, null: true
115
+ end
116
+ end
117
+
118
+ up, down = migrate
119
+ expect(up.split(',').slice(0,3).join(',')).to eq('change_column :adverts, :title, :string')
120
+ expect(up.split(',').slice(3,2).sort.join(',')).to eq(" default: \"Untitled\", limit: 255")
121
+ expect(down).to eq("change_column :adverts, :title, :string, limit: 255")
122
+
123
+
124
+ ### Limits
125
+
126
+ class Advert < ActiveRecord::Base
127
+ fields do
128
+ price :integer, null: true, limit: 2
129
+ end
130
+ end
131
+
132
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
133
+ expect(up).to eq("add_column :adverts, :price, :integer, limit: 2")
134
+
135
+ # Now run the migration, then change the limit:
136
+
137
+ ActiveRecord::Migration.class_eval(up)
138
+ class Advert < ActiveRecord::Base
139
+ fields do
140
+ price :integer, null: true, limit: 3
141
+ end
142
+ end
143
+
144
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
145
+ expect(up).to eq("change_column :adverts, :price, :integer, limit: 3")
146
+ expect(down).to eq("change_column :adverts, :price, :integer, limit: 2")
147
+
148
+ # Note that limit on a decimal column is ignored (use :scale and :precision)
149
+
150
+ ActiveRecord::Migration.class_eval("remove_column :adverts, :price")
151
+ class Advert < ActiveRecord::Base
152
+ fields do
153
+ price :decimal, null: true, limit: 4
154
+ end
155
+ end
156
+
157
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
158
+ expect(up).to eq("add_column :adverts, :price, :decimal")
159
+
160
+ # Limits are generally not needed for `text` fields, because by default, `text` fields will use the maximum size
161
+ # allowed for that database type (0xffffffff for LONGTEXT in MySQL unlimited in Postgres, 1 billion in Sqlite).
162
+ # If a `limit` is given, it will only be used in MySQL, to choose the smallest TEXT field that will accommodate
163
+ # that limit (0xff for TINYTEXT, 0xffff for TEXT, 0xffffff for MEDIUMTEXT, 0xffffffff for LONGTEXT).
164
+
165
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_falsey
166
+ class Advert < ActiveRecord::Base
167
+ fields do
168
+ notes :text
169
+ description :text, limit: 30000
170
+ end
171
+ end
172
+
173
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
174
+ expect(up).to eq(<<~EOS.strip)
175
+ add_column :adverts, :price, :decimal
176
+ add_column :adverts, :notes, :text, null: false
177
+ add_column :adverts, :description, :text, null: false
178
+ EOS
179
+
180
+ # (There is no limit on `add_column ... :description` above since these tests are run against SQLite.)
181
+
182
+ Advert.field_specs.delete :price
183
+ Advert.field_specs.delete :notes
184
+ Advert.field_specs.delete :description
185
+
186
+ # In MySQL, limits are applied, rounded up:
187
+
188
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
189
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
190
+ class Advert < ActiveRecord::Base
191
+ fields do
192
+ notes :text
193
+ description :text, limit: 200
194
+ end
195
+ end
196
+
197
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
198
+ expect(up).to eq(<<~EOS.strip)
199
+ add_column :adverts, :notes, :text, null: false, limit: 4294967295
200
+ add_column :adverts, :description, :text, null: false, limit: 255
201
+ EOS
202
+
203
+ Advert.field_specs.delete :notes
204
+
205
+ # Limits that are too high for MySQL will raise an exception.
206
+
207
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, true)
208
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
209
+ expect do
210
+ class Advert < ActiveRecord::Base
211
+ fields do
212
+ notes :text
213
+ description :text, limit: 0x1_0000_0000
214
+ end
215
+ end
216
+ end.to raise_exception(ArgumentError, "limit of 4294967296 is too large for MySQL")
217
+
218
+ Advert.field_specs.delete :notes
219
+
220
+ # And in MySQL, unstated text limits are treated as the maximum (LONGTEXT) limit.
221
+
222
+ # To start, we'll set the database schema for `description` to match the above limit of 255.
223
+
224
+ expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy
225
+ Advert.connection.execute "ALTER TABLE adverts ADD COLUMN description TINYTEXT"
226
+ Advert.connection.schema_cache.clear!
227
+ Advert.reset_column_information
228
+ expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables).
229
+ to eq(["adverts"])
230
+ expect(Advert.columns.map(&:name)).to eq(["id", "body", "title", "description"])
231
+
232
+ # Now migrate to an unstated text limit:
233
+
234
+ class Advert < ActiveRecord::Base
235
+ fields do
236
+ description :text
237
+ end
238
+ end
239
+
240
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
241
+ expect(up).to eq("change_column :adverts, :description, :text, limit: 4294967295, null: false")
242
+ expect(down).to eq("change_column :adverts, :description, :text")
243
+
244
+ # TODO TECH-4814: The above test should have this output:
245
+ # TODO => "change_column :adverts, :description, :text, limit: 255
246
+
247
+ # And migrate to a stated text limit that is the same as the unstated one:
248
+
249
+ class Advert < ActiveRecord::Base
250
+ fields do
251
+ description :text, limit: 0xffffffff
252
+ end
253
+ end
254
+
255
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
256
+ expect(up).to eq("change_column :adverts, :description, :text, limit: 4294967295, null: false")
257
+ expect(down).to eq("change_column :adverts, :description, :text")
258
+ ::DeclareSchema::Model::FieldSpec::instance_variable_set(:@mysql_text_limits, false)
259
+
260
+ Advert.field_specs.clear
261
+ Advert.connection.schema_cache.clear!
262
+ Advert.reset_column_information
263
+ class Advert < ActiveRecord::Base
264
+ fields do
265
+ name :string, limit: 255, null: true
266
+ end
267
+ end
268
+
269
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
270
+ ActiveRecord::Migration.class_eval up
271
+ Advert.connection.schema_cache.clear!
272
+ Advert.reset_column_information
273
+
274
+ ### Foreign Keys
275
+
276
+ # DeclareSchema extends the `belongs_to` macro so that it also declares the
277
+ # foreign-key field. It also generates an index on the field.
278
+
279
+ class Category < ActiveRecord::Base; end
280
+ class Advert < ActiveRecord::Base
281
+ belongs_to :category
282
+ end
283
+
284
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
285
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
286
+ add_column :adverts, :category_id, :integer, limit: 8, null: false
287
+ add_index :adverts, [:category_id], name: 'on_category_id'
288
+ EOS
289
+ expect(down.sub(/\n+/, "\n")).to eq(<<~EOS.strip)
290
+ remove_column :adverts, :category_id
291
+ remove_index :adverts, name: :on_category_id rescue ActiveRecord::StatementInvalid
292
+ EOS
293
+
294
+ Advert.field_specs.delete(:category_id)
295
+ Advert.index_specs.delete_if {|spec| spec.fields==["category_id"]}
296
+
297
+ # If you specify a custom foreign key, the migration generator observes that:
298
+
299
+ class Category < ActiveRecord::Base; end
300
+ class Advert < ActiveRecord::Base
301
+ belongs_to :category, foreign_key: "c_id", class_name: 'Category'
302
+ end
303
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
304
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
305
+ add_column :adverts, :c_id, :integer, limit: 8, null: false
306
+ add_index :adverts, [:c_id], name: 'on_c_id'
307
+ EOS
308
+
309
+ Advert.field_specs.delete(:c_id)
310
+ Advert.index_specs.delete_if { |spec| spec.fields == ["c_id"] }
311
+
312
+ # You can avoid generating the index by specifying `index: false`
313
+
314
+ class Category < ActiveRecord::Base; end
315
+ class Advert < ActiveRecord::Base
316
+ belongs_to :category, index: false
317
+ end
318
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
319
+ expect(up.gsub(/\n+/, "\n")).to eq("add_column :adverts, :category_id, :integer, limit: 8, null: false")
320
+
321
+ Advert.field_specs.delete(:category_id)
322
+ Advert.index_specs.delete_if { |spec| spec.fields == ["category_id"] }
323
+
324
+ # You can specify the index name with :index
325
+
326
+ class Category < ActiveRecord::Base; end
327
+ class Advert < ActiveRecord::Base
328
+ belongs_to :category, index: 'my_index'
329
+ end
330
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
331
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
332
+ add_column :adverts, :category_id, :integer, limit: 8, null: false
333
+ add_index :adverts, [:category_id], name: 'my_index'
334
+ EOS
335
+
336
+ Advert.field_specs.delete(:category_id)
337
+ Advert.index_specs.delete_if { |spec| spec.fields == ["category_id"] }
338
+
339
+ ### Timestamps and Optimimistic Locking
340
+
341
+ # `updated_at` and `created_at` can be declared with the shorthand `timestamps`.
342
+ # Similarly, `lock_version` can be declared with the "shorthand" `optimimistic_lock`.
343
+
344
+ class Advert < ActiveRecord::Base
345
+ fields do
346
+ timestamps
347
+ optimistic_lock
348
+ end
349
+ end
350
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
351
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
352
+ add_column :adverts, :created_at, :datetime
353
+ add_column :adverts, :updated_at, :datetime
354
+ add_column :adverts, :lock_version, :integer, null: false, default: 1
355
+ EOS
356
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
357
+ remove_column :adverts, :created_at
358
+ remove_column :adverts, :updated_at
359
+ remove_column :adverts, :lock_version
360
+ EOS
361
+
362
+ Advert.field_specs.delete(:updated_at)
363
+ Advert.field_specs.delete(:created_at)
364
+ Advert.field_specs.delete(:lock_version)
365
+
366
+ ### Indices
367
+
368
+ # You can add an index to a field definition
369
+
370
+ class Advert < ActiveRecord::Base
371
+ fields do
372
+ title :string, index: true, limit: 255, null: true
373
+ end
374
+ end
375
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
376
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
377
+ add_column :adverts, :title, :string, limit: 255
378
+ add_index :adverts, [:title], name: 'on_title'
379
+ EOS
380
+
381
+ Advert.index_specs.delete_if { |spec| spec.fields==["title"] }
382
+
383
+ # You can ask for a unique index
384
+
385
+ class Advert < ActiveRecord::Base
386
+ fields do
387
+ title :string, index: true, unique: true, null: true, limit: 255
388
+ end
389
+ end
390
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
391
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
392
+ add_column :adverts, :title, :string, limit: 255
393
+ add_index :adverts, [:title], unique: true, name: 'on_title'
394
+ EOS
395
+
396
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
397
+
398
+ # You can specify the name for the index
399
+
400
+ class Advert < ActiveRecord::Base
401
+ fields do
402
+ title :string, index: 'my_index', limit: 255, null: true
403
+ end
404
+ end
405
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
406
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
407
+ add_column :adverts, :title, :string, limit: 255
408
+ add_index :adverts, [:title], name: 'my_index'
409
+ EOS
410
+
411
+ Advert.index_specs.delete_if {|spec| spec.fields==["title"]}
412
+
413
+ # You can ask for an index outside of the fields block
414
+
415
+ class Advert < ActiveRecord::Base
416
+ index :title
417
+ end
418
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
419
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
420
+ add_column :adverts, :title, :string, limit: 255
421
+ add_index :adverts, [:title], name: 'on_title'
422
+ EOS
423
+
424
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
425
+
426
+ # The available options for the index function are `:unique` and `:name`
427
+
428
+ class Advert < ActiveRecord::Base
429
+ index :title, unique: true, name: 'my_index'
430
+ end
431
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
432
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
433
+ add_column :adverts, :title, :string, limit: 255
434
+ add_index :adverts, [:title], unique: true, name: 'my_index'
435
+ EOS
436
+
437
+ Advert.index_specs.delete_if { |spec| spec.fields == ["title"] }
438
+
439
+ # You can create an index on more than one field
440
+
441
+ class Advert < ActiveRecord::Base
442
+ index [:title, :category_id]
443
+ end
444
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
445
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
446
+ add_column :adverts, :title, :string, limit: 255
447
+ add_index :adverts, [:title, :category_id], name: 'on_title_and_category_id'
448
+ EOS
449
+
450
+ Advert.index_specs.delete_if { |spec| spec.fields==["title", "category_id"] }
451
+
452
+ # Finally, you can specify that the migration generator should completely ignore an
453
+ # index by passing its name to ignore_index in the model.
454
+ # This is helpful for preserving indices that can't be automatically generated, such as prefix indices in MySQL.
455
+
456
+ ### Rename a table
457
+
458
+ # 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.
459
+
460
+ class Advert < ActiveRecord::Base
461
+ self.table_name = "ads"
462
+ fields do
463
+ title :string, limit: 255, null: true
464
+ body :text, null: true
465
+ end
466
+ end
467
+
468
+ Advert.connection.schema_cache.clear!
469
+ Advert.reset_column_information
470
+
471
+ up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "ads")
472
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
473
+ rename_table :adverts, :ads
474
+ add_column :ads, :title, :string, limit: 255
475
+ add_column :ads, :body, :text
476
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'
477
+ EOS
478
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
479
+ remove_column :ads, :title
480
+ remove_column :ads, :body
481
+ rename_table :ads, :adverts
482
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
483
+ EOS
484
+
485
+ # Set the table name back to what it should be and confirm we're in sync:
486
+
487
+ Advert.field_specs.delete(:title)
488
+ Advert.field_specs.delete(:body)
489
+ class Advert < ActiveRecord::Base
490
+ self.table_name = "adverts"
491
+ end
492
+
493
+ expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""])
494
+
495
+ ### Rename a table
496
+
497
+ # 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.
498
+
499
+ nuke_model_class(Advert)
500
+
501
+ class Advertisement < ActiveRecord::Base
502
+ fields do
503
+ title :string, limit: 255, null: true
504
+ body :text, null: true
505
+ end
506
+ end
507
+ up, down = Generators::DeclareSchema::Migration::Migrator.run("adverts" => "advertisements")
508
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
509
+ rename_table :adverts, :advertisements
510
+ add_column :advertisements, :title, :string, limit: 255
511
+ add_column :advertisements, :body, :text
512
+ remove_column :advertisements, :name
513
+ add_index :advertisements, [:id], unique: true, name: 'PRIMARY_KEY'
514
+ EOS
515
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
516
+ remove_column :advertisements, :title
517
+ remove_column :advertisements, :body
518
+ add_column :adverts, :name, :string, limit: 255
519
+ rename_table :advertisements, :adverts
520
+ add_index :adverts, [:id], unique: true, name: 'PRIMARY_KEY'
521
+ EOS
522
+
523
+ ### Drop a table
524
+
525
+ nuke_model_class(Advertisement)
526
+
527
+ # If you delete a model, the migration generator will create a `drop_table` migration.
528
+
529
+ # Dropping tables is where the automatic down-migration really comes in handy:
530
+
531
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
532
+ expect(up).to eq("drop_table :adverts")
533
+ expect(down.gsub(/,.*/m, '')).to eq("create_table \"adverts\"")
534
+
535
+ ## STI
536
+
537
+ ### Adding an STI subclass
538
+
539
+ # Adding a subclass or two should introduce the 'type' column and no other changes
540
+
541
+ class Advert < ActiveRecord::Base
542
+ fields do
543
+ body :text, null: true
544
+ title :string, default: "Untitled", limit: 255, null: true
545
+ end
546
+ end
547
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
548
+ ActiveRecord::Migration.class_eval(up)
549
+
550
+ class FancyAdvert < Advert
551
+ end
552
+ class SuperFancyAdvert < FancyAdvert
553
+ end
554
+ up, down = Generators::DeclareSchema::Migration::Migrator.run
555
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
556
+ add_column :adverts, :type, :string, limit: 255
557
+ add_index :adverts, [:type], name: 'on_type'
558
+ EOS
559
+ expect(down.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
560
+ remove_column :adverts, :type
561
+ remove_index :adverts, name: :on_type rescue ActiveRecord::StatementInvalid
562
+ EOS
563
+
564
+ Advert.field_specs.delete(:type)
565
+ nuke_model_class(SuperFancyAdvert)
566
+ nuke_model_class(FancyAdvert)
567
+ Advert.index_specs.delete_if { |spec| spec.fields==["type"] }
568
+
569
+ ## Coping with multiple changes
570
+
571
+ # The migration generator is designed to create complete migrations even if many changes to the models have taken place.
572
+
573
+ # First let's confirm we're in a known state. One model, 'Advert', with a string 'title' and text 'body':
574
+
575
+ ActiveRecord::Migration.class_eval up.gsub(/.*type.*/, '')
576
+ Advert.connection.schema_cache.clear!
577
+ Advert.reset_column_information
578
+
579
+ expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables).
580
+ to eq(["adverts"])
581
+ expect(Advert.columns.map(&:name).sort).to eq(["body", "id", "title"])
582
+ expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""])
583
+
584
+
585
+ ### Rename a column and change the default
586
+
587
+ Advert.field_specs.clear
588
+
589
+ class Advert < ActiveRecord::Base
590
+ fields do
591
+ name :string, default: "No Name", limit: 255, null: true
592
+ body :text, null: true
593
+ end
594
+ end
595
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { title: :name })
596
+ expect(up).to eq(<<~EOS.strip)
597
+ rename_column :adverts, :title, :name
598
+ change_column :adverts, :name, :string, limit: 255, default: \"No Name\"
599
+ EOS
600
+
601
+ expect(down).to eq(<<~EOS.strip)
602
+ rename_column :adverts, :name, :title
603
+ change_column :adverts, :title, :string, limit: 255, default: \"Untitled\"
604
+ EOS
605
+
606
+ ### Rename a table and add a column
607
+
608
+ nuke_model_class(Advert)
609
+ class Ad < ActiveRecord::Base
610
+ fields do
611
+ title :string, default: "Untitled", limit: 255
612
+ body :text, null: true
613
+ created_at :datetime
614
+ end
615
+ end
616
+ up, down = Generators::DeclareSchema::Migration::Migrator.run(adverts: :ads)
617
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
618
+ rename_table :adverts, :ads
619
+ add_column :ads, :created_at, :datetime, null: false
620
+ change_column :ads, :title, :string, limit: 255, null: false, default: \"Untitled\"
621
+ add_index :ads, [:id], unique: true, name: 'PRIMARY_KEY'
622
+ EOS
623
+
624
+ class Advert < ActiveRecord::Base
625
+ fields do
626
+ body :text, null: true
627
+ title :string, default: "Untitled", limit: 255, null: true
628
+ end
629
+ end
630
+
631
+ ## Legacy Keys
632
+
633
+ # DeclareSchema has some support for legacy keys.
634
+
635
+ nuke_model_class(Ad)
636
+
637
+ class Advert < ActiveRecord::Base
638
+ fields do
639
+ body :text, null: true
640
+ end
641
+ self.primary_key = "advert_id"
642
+ end
643
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run(adverts: { id: :advert_id })
644
+ expect(up.gsub(/\n+/, "\n")).to eq(<<~EOS.strip)
645
+ rename_column :adverts, :id, :advert_id
646
+ add_index :adverts, [:advert_id], unique: true, name: 'PRIMARY_KEY'
647
+ EOS
648
+
649
+ nuke_model_class(Advert)
650
+ ActiveRecord::Base.connection.execute("drop table `adverts`;")
651
+
652
+ ## DSL
653
+
654
+ # The DSL allows lambdas and constants
655
+
656
+ class User < ActiveRecord::Base
657
+ fields do
658
+ company :string, limit: 255, ruby_default: -> { "BigCorp" }
659
+ end
660
+ end
661
+ expect(User.field_specs.keys).to eq(['company'])
662
+ expect(User.field_specs['company'].options[:ruby_default]&.call).to eq("BigCorp")
663
+
664
+ ## validates
665
+
666
+ # DeclareSchema can accept a validates hash in the field options.
667
+
668
+ class Ad < ActiveRecord::Base
669
+ class << self
670
+ def validates(field_name, options)
671
+ end
672
+ end
673
+ end
674
+ expect(Ad).to receive(:validates).with(:company, presence: true, uniqueness: { case_sensitive: false })
675
+ class Ad < ActiveRecord::Base
676
+ fields do
677
+ company :string, limit: 255, index: true, unique: true, validates: { presence: true, uniqueness: { case_sensitive: false } }
678
+ end
679
+ self.primary_key = "advert_id"
680
+ end
681
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run
682
+ ActiveRecord::Migration.class_eval(up)
683
+ expect(Ad.field_specs['company'].options[:validates].inspect).to eq("{:presence=>true, :uniqueness=>{:case_sensitive=>false}}")
684
+ end
685
+ end
686
+