# frozen_string_literal: true RSpec.describe 'DeclareSchema Migration Generator' do include_context 'prepare test app' let(:text_limit) do if current_adapter == 'mysql2' ", limit: 4294967295" end end let(:charset_and_collation) do if current_adapter == 'mysql2' ', charset: "utf8mb4", collation: "utf8mb4_bin"' end end let(:primary_key_type) do if current_adapter == 'postgresql' ':bigserial' else ':bigint' end end let(:create_table_charset_and_collation) do if current_adapter == 'mysql2' ", options: \"CHARACTER SET utf8mb4 COLLATE utf8mb4_bin\"" end end let(:datetime_precision) do if ActiveSupport::VERSION::MAJOR >= 7 ', precision: 6' elsif current_adapter == 'mysql2' ', precision: 0' end end let(:table_options) do case current_adapter when 'mysql2' ', options: "DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", charset: "utf8mb4", collation: "utf8mb4_bin"' when 'postgresql' "" else ", id: :integer" end end let(:lock_version_limit) do if current_adapter == 'sqlite3' '' else ', limit: 4' end end context 'Using declare_schema' do # DeclareSchema - Migration Generator it 'generates migrations' do ## The migration generator -- introduction expect(Generators::DeclareSchema::Migration::Migrator.run).to migrate_up("").and migrate_down("") class Advert < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock expect(Generators::DeclareSchema::Migration::Migrator.run).to migrate_up("").and migrate_down("") Generators::DeclareSchema::Migration::Migrator.ignore_tables = ["green_fishes"] Advert.connection.schema_cache.clear! Advert.reset_column_information class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end end up, _ = Generators::DeclareSchema::Migration::Migrator.run.tap do |migrations| expect(migrations).to( migrate_up(<<~EOS.strip) create_table :adverts, id: #{primary_key_type}#{create_table_charset_and_collation} do |t| t.string :name, limit: 250, null: true#{charset_and_collation} end EOS .and(migrate_down("drop_table :adverts")) ) end ActiveRecord::Migration.class_eval(up) expect(Advert.columns.map(&:name)).to eq(["id", "name"]) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true text :body, null: true datetime :published_at, null: true end end Advert.connection.schema_cache.clear! Advert.reset_column_information expect(migrate).to( migrate_up(<<~EOS.strip) add_column :adverts, :body, :text#{text_limit}, null: true#{charset_and_collation} add_column :adverts, :published_at, :datetime, null: true EOS .and(migrate_down(<<~EOS.strip)) remove_column :adverts, :published_at remove_column :adverts, :body EOS ) Advert.field_specs.clear # not normally needed class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true text :body, null: true end end expect(migrate).to( migrate_up("remove_column :adverts, :published_at").and( migrate_down("add_column :adverts, :published_at, :datetime#{datetime_precision}, null: true") ) ) nuke_model_class(Advert) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true text :body, null: true end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} remove_column :adverts, :name EOS .and(migrate_down(<<~EOS.strip)) add_column :adverts, :name, :string, limit: 250, null: true#{charset_and_collation} remove_column :adverts, :title EOS ) expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: { name: :title })).to( migrate_up("rename_column :adverts, :name, :title").and( migrate_down("rename_column :adverts, :title, :name") ) ) migrate class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :title, null: true text :body, null: true end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up("change_column :adverts, :title, :text#{text_limit}, null: true#{charset_and_collation}").and( migrate_down("change_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation}") ) ) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, default: "Untitled", limit: 250, null: true text :body, null: true end end expect(migrate).to( migrate_up(<<~EOS.strip) change_column :adverts, :title, :string, limit: 250, null: true, default: "Untitled"#{charset_and_collation} EOS .and(migrate_down(<<~EOS.strip)) change_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} EOS ) ### Limits class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :price, null: true, limit: 2 end end up, _ = Generators::DeclareSchema::Migration::Migrator.run.tap do |migrations| expect(migrations).to migrate_up("add_column :adverts, :price, :integer, limit: 2, null: true") end # Now run the migration, then change the limit: ActiveRecord::Migration.class_eval(up) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :price, null: true, limit: 3 end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) change_column :adverts, :price, :integer, limit: 3, null: true EOS .and(migrate_down(<<~EOS.strip)) change_column :adverts, :price, :integer, limit: 2, null: true EOS ) ActiveRecord::Migration.class_eval("remove_column :adverts, :price", __FILE__, __LINE__) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do decimal :price, precision: 4, scale: 1, null: true end end # Limits are generally not needed for `text` fields, because by default, `text` fields will use the maximum size # allowed for that database type (0xffffffff for LONGTEXT in MySQL unlimited in Postgres, 1 billion in Sqlite). # If a `limit` is given, it will only be used in MySQL, to choose the smallest TEXT field that will accommodate # that limit (0xff for TINYTEXT, 0xffff for TEXT, 0xffffff for MEDIUMTEXT, 0xffffffff for LONGTEXT). if current_adapter == 'sqlite3' expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_falsey end class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :notes text :description, limit: 30000 end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :price, :decimal, precision: 4, scale: 1, null: true add_column :adverts, :notes, :text#{text_limit}, null: false#{charset_and_collation} add_column :adverts, :description, :text#{', limit: 65535' if current_adapter == 'mysql2'}, null: false#{charset_and_collation} EOS ) Advert.field_specs.delete :price Advert.field_specs.delete :notes Advert.field_specs.delete :description # In MySQL, limits are applied, rounded up: if current_adapter == 'mysql2' expect(::DeclareSchema::Model::FieldSpec.mysql_text_limits?).to be_truthy class Advert < ActiveRecord::Base declare_schema do text :notes text :description, limit: 250 end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :notes, :text, limit: 4294967295, null: false#{charset_and_collation} add_column :adverts, :description, :text, limit: 255, null: false#{charset_and_collation} EOS ) Advert.field_specs.delete :notes # Limits that are too high for MySQL will raise an exception. expect do class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :notes text :description, limit: 0x1_0000_0000 end end end.to raise_exception(ArgumentError, "limit of 4294967296 is too large for MySQL") Advert.field_specs.delete :notes # And in MySQL, unstated text limits are treated as the maximum (LONGTEXT) limit. # To start, we'll set the database schema for `description` to match the above limit of 250. Advert.connection.execute "ALTER TABLE adverts ADD COLUMN description TINYTEXT" Advert.connection.schema_cache.clear! Advert.reset_column_information expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables).to eq(["adverts"]) expect(Advert.columns.map(&:name)).to eq(["id", "body", "title", "description"]) # Now migrate to an unstated text limit: class Advert < ActiveRecord::Base declare_schema do text :description end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) change_column :adverts, :description, :text, limit: 4294967295, null: false#{charset_and_collation} EOS .and(migrate_down(<<~EOS.strip)) change_column :adverts, :description, :text#{', limit: 255' if current_adapter == 'mysql2'}, null: true#{charset_and_collation} EOS ) # And migrate to a stated text limit that is the same as the unstated one: class Advert < ActiveRecord::Base declare_schema do text :description, limit: 0xffffffff end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) change_column :adverts, :description, :text, limit: 4294967295, null: false#{charset_and_collation} EOS .and(migrate_down(<<~EOS.strip)) change_column :adverts, :description, :text#{', limit: 255' if current_adapter == 'mysql2'}, null: true#{charset_and_collation} EOS ) end Advert.field_specs.clear Advert.connection.schema_cache.clear! Advert.reset_column_information class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end end up = Generators::DeclareSchema::Migration::Migrator.run.first ActiveRecord::Migration.class_eval up Advert.connection.schema_cache.clear! Advert.reset_column_information ### Foreign Keys # DeclareSchema extends the `belongs_to` macro so that it also declares the # foreign-key field. It also generates an index on the field. class Category < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end belongs_to :category end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :category_id, :integer, limit: 8, null: false add_index :adverts, [:category_id], name: :index_adverts_on_category_id #{"add_foreign_key :adverts, :categories, column: :category_id, name: :index_adverts_on_category_id\n" unless current_adapter == 'sqlite3'} EOS .and(migrate_down(<<~EOS.strip)) #{'remove_foreign_key :adverts, name: :index_adverts_on_category_id' unless current_adapter == 'sqlite3'} remove_index :adverts, name: :index_adverts_on_category_id remove_column :adverts, :category_id EOS ) Advert.field_specs.delete(:category_id) Advert.index_definitions.clear # If you specify a custom foreign key, the migration generator observes that: class Category < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :category, foreign_key: "c_id", class_name: 'Category' end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :c_id, :integer, limit: 8, null: false add_index :adverts, [:c_id], name: :index_adverts_on_c_id #{unless current_adapter == 'sqlite3' "add_foreign_key :adverts, :categories, column: :category_id, name: :index_adverts_on_category_id\n" \ 'add_foreign_key :adverts, :categories, column: :c_id, name: :index_adverts_on_c_id' end} EOS ) Advert.field_specs.delete(:c_id) Advert.index_definitions.clear Advert.constraint_definitions.clear # You can avoid generating the index by specifying `index: false` class Category < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :category, index: false end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :category_id, :integer, limit: 8, null: false #{'add_foreign_key :adverts, :categories, column: :category_id, name: :index_adverts_on_category_id' unless current_adapter == 'sqlite3'} EOS ) Advert.field_specs.delete(:category_id) Advert.index_definitions.clear Advert.constraint_definitions.clear # You can specify the index name with index: 'name' [deprecated] expect(DeclareSchema.deprecator).to( receive(:warn).with(/belongs_to :category, index: 'name' is deprecated; use index: \{ name: 'name' \} instead/i) ) class Category < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :category, index: 'my_index' end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :category_id, :integer, limit: 8, null: false add_index :adverts, [:category_id], name: :my_index #{'add_foreign_key :adverts, :categories, column: :category_id, name: :my_index' unless current_adapter == 'sqlite3'} EOS ) Advert.field_specs.delete(:category_id) Advert.index_definitions.clear Advert.constraint_definitions.clear # You can specify the index name with index: { name: 'name', unique: true|false } class Category < ActiveRecord::Base; end # rubocop:disable Lint/ConstantDefinitionInBlock class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :category, index: { name: 'my_index', unique: false } end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :category_id, :integer, limit: 8, null: false add_index :adverts, [:category_id], name: :my_index #{'add_foreign_key :adverts, :categories, column: :category_id, name: :my_index' unless current_adapter == 'sqlite3'} EOS ) Advert.field_specs.delete(:category_id) Advert.index_definitions.clear Advert.constraint_definitions.clear ### Timestamps and Optimimistic Locking # `updated_at` and `created_at` can be declared with the shorthand `timestamps`. # Similarly, `lock_version` can be declared with the "shorthand" `optimimistic_lock`. class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do timestamps optimistic_lock end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :created_at, :datetime, null: true add_column :adverts, :updated_at, :datetime, null: true add_column :adverts, :lock_version, :integer#{lock_version_limit}, null: false, default: 1 EOS .and(migrate_down(<<~EOS.strip)) remove_column :adverts, :lock_version remove_column :adverts, :updated_at remove_column :adverts, :created_at EOS ) Advert.field_specs.delete(:updated_at) Advert.field_specs.delete(:created_at) Advert.field_specs.delete(:lock_version) ### Indices # You can add an index to a field definition expect(DeclareSchema.deprecator).to( receive(:warn).with(/belongs_to :category, index: 'name' is deprecated; use index: \{ name: 'name' \} instead/i) ) expect(DeclareSchema.deprecator).to( receive(:warn).with(/belongs_to :category, unique: true\|false is deprecated; use index: \{ unique: true\|false \} instead/i) ) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, index: true, limit: 250, null: true end belongs_to :category, index: 'my_index', unique: false end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_column :adverts, :category_id, :integer, limit: 8, null: false add_index :adverts, [:title], name: :index_adverts_on_title add_index :adverts, [:category_id], name: :my_index #{'add_foreign_key :adverts, :categories, column: :category_id, name: :my_index' unless current_adapter == 'sqlite3'} EOS ) Advert.field_specs.delete(:category_id) Advert.index_definitions.clear Advert.constraint_definitions.clear # You can ask for a unique index (deprecated syntax; use index: { unique: true } instead). class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, index: true, unique: true, null: true, limit: 250 end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_index :adverts, [:title], name: :index_adverts_on_title, unique: true EOS ) Advert.index_definitions.clear # You can specify the name for the index class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, index: 'my_index', limit: 250, null: true end end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_index :adverts, [:title], name: :my_index EOS ) Advert.index_definitions.clear # You can ask for an index outside of the fields block class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true end index :title end expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_index :adverts, [:title], name: :index_adverts_on_title EOS ) Advert.index_definitions.clear # The available options for the index function are :unique, :name, :where, and :length (as well as :allow_equivalent, :ignore_equivalent_definitions). class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock index :title, unique: false, name: 'my_index', length: 10 end up, down = Generators::DeclareSchema::Migration::Migrator.run expect([up, down]).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_index :adverts, [:title], name: :my_index, length: { title: 10 } EOS ) Advert.index_definitions.clear ActiveRecord::Migration.class_eval(up) # You can migrate an index to have slightly different settings class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true, index: { unique: true, name: 'my_index', length: 5 } end end # Note: the index is removed first, then re-added so that we don't get an error about the index already existing. expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) remove_index :adverts, name: :my_index add_index :adverts, [:title], name: :my_index, unique: true, length: { title: 5 } EOS ) ActiveRecord::Migration.class_eval(down) Advert.index_definitions.clear # You can create an index on more than one field class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true, index: { unique: true, name: 'my_index' } integer :category_id, limit: 8 end index [:title, :category_id] end up, down = Generators::DeclareSchema::Migration::Migrator.run expect([up, down]).to( migrate_up(<<~EOS.strip) add_column :adverts, :title, :string, limit: 250, null: true#{charset_and_collation} add_column :adverts, :category_id, :integer, limit: 8, null: false add_index :adverts, [:title], name: :my_index, unique: true add_index :adverts, [:title, :category_id], name: :index_adverts_on_title_and_category_id EOS ) ActiveRecord::Migration.class_eval(up) Advert.connection.schema_cache.clear! Advert.reset_column_information Advert.index_definitions.clear nuke_model_class(Advert) # You can change the column order of an index class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true, index: { unique: true, name: 'my_index' } integer :category_id, limit: 8 end index [:category_id, :title], name: :index_adverts_on_title_and_category_id end up, down = Generators::DeclareSchema::Migration::Migrator.run puts "!!!!up: #{up}\n\ndown: #{down}\n" expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) remove_index :adverts, name: :index_adverts_on_title_and_category_id add_index :adverts, [:category_id, :title], name: :index_adverts_on_title_and_category_id remove_column :adverts, :name EOS ) # Finally, you can specify that the migration generator should completely ignore an # index by passing its name to ignore_index in the model. # This is helpful for preserving indices that can't be automatically generated, such as prefix indices in MySQL. ### Rename a table # 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. Advert.connection.schema_cache.clear! Advert.reset_column_information Advert.index_definitions.clear nuke_model_class(Advert) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true text :body, null: true end end up, _down = Generators::DeclareSchema::Migration::Migrator.run ActiveRecord::Migration.class_eval(up) Advert.table_name = "ads" expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: "ads")).to( migrate_up(<<~EOS.strip) rename_table :adverts, :ads EOS .and(migrate_down(<<~EOS.strip)) rename_table :ads, :adverts EOS ) # Set the table name back to what it should be and confirm we're in sync: nuke_model_class(Advert) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = "adverts" end expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""]) ### Rename a table # 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. nuke_model_class(Advert) class Advertisement < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 250, null: true text :body, null: true end end expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: "advertisements")).to( migrate_up(<<~EOS.strip) rename_table :adverts, :advertisements EOS .and(migrate_down(<<~EOS.strip)) rename_table :advertisements, :adverts EOS ) ### Drop a table nuke_model_class(Advertisement) # If you delete a model, the migration generator will create a `drop_table` migration. # Dropping tables is where the automatic down-migration really comes in handy: expect(Generators::DeclareSchema::Migration::Migrator.run).to( migrate_up(<<~EOS.strip) drop_table :adverts EOS .and(migrate_down(<<~EOS.strip)) create_table "adverts"#{table_options}, force: :cascade do |t| t.string "title", limit: 250#{charset_and_collation} t.text "body"#{text_limit}#{charset_and_collation} end EOS ) ## STI ### Adding an STI subclass # Adding a subclass or two should introduce the 'type' column and no other changes class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :body, null: true string :title, default: "Untitled", limit: 250, null: true end end up = Generators::DeclareSchema::Migration::Migrator.run.first ActiveRecord::Migration.class_eval(up) class FancyAdvert < Advert # rubocop:disable Lint/ConstantDefinitionInBlock end class SuperFancyAdvert < FancyAdvert # rubocop:disable Lint/ConstantDefinitionInBlock end expect(Generators::DeclareSchema::Migration::Migrator.run.first).to be_present up, _ = Generators::DeclareSchema::Migration::Migrator.run do |migrations| expect(migrations).to( migrate_up(<<~EOS.strip) add_column :adverts, :type, :string, limit: 250, null: true#{charset_and_collation} add_index :adverts, [:type], name: :on_type EOS .and(migrate_down(<<~EOS.strip)) remove_index :adverts, name: :on_type remove_column :adverts, :type EOS ) end Advert.field_specs.delete(:type) nuke_model_class(SuperFancyAdvert) nuke_model_class(FancyAdvert) Advert.index_definitions.clear ## Coping with multiple changes # The migration generator is designed to create complete migrations even if many changes to the models have taken place. # First let's confirm we're in a known state. One model, 'Advert', with a string 'title' and text 'body': ActiveRecord::Migration.class_eval up.gsub(/.*type.*/, '') Advert.connection.schema_cache.clear! Advert.reset_column_information expect(Advert.connection.tables - Generators::DeclareSchema::Migration::Migrator.always_ignore_tables) .to eq(["adverts"]) expect(Advert.columns.map(&:name).sort).to eq(["body", "id", "title"]) expect(Generators::DeclareSchema::Migration::Migrator.run).to eq(["", ""]) ### Rename a column and change the default Advert.field_specs.clear class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, default: "No Name", limit: 250, null: true text :body, null: true end end expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: { title: :name })).to( migrate_up(<<~EOS.strip) rename_column :adverts, :title, :name change_column :adverts, :name, :string, limit: 250, null: true, default: "No Name"#{charset_and_collation} EOS .and(migrate_down(<<~EOS.strip)) change_column :adverts, :name, :string, limit: 250, null: true, default: "Untitled"#{charset_and_collation} rename_column :adverts, :name, :title EOS ) ### Rename a table and add a column nuke_model_class(Advert) class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, default: "Untitled", limit: 250 text :body, null: true datetime :created_at end end expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: :ads)).to( migrate_up(<<~EOS.strip) rename_table :adverts, :ads add_column :ads, :created_at, :datetime, null: false change_column :ads, :title, :string, limit: 250, null: false, default: \"Untitled\"#{charset_and_collation} EOS ) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :body, null: true string :title, default: "Untitled", limit: 250, null: true end end ## Legacy Keys # DeclareSchema has some support for legacy keys. nuke_model_class(Ad) class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :body, null: true end self.primary_key = "advert_id" end expect(Generators::DeclareSchema::Migration::Migrator.run(adverts: { id: :advert_id })).to( migrate_up(<<~EOS.strip) rename_column :adverts, :id, :advert_id EOS ) nuke_model_class(Advert) ActiveRecord::Base.connection.execute("DROP TABLE #{ActiveRecord::Base.connection.quote_table_name('adverts')};") ## DSL # The DSL allows lambdas and constants class User < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :company, limit: 250, ruby_default: -> { "BigCorp" } end end expect(User.field_specs.keys).to eq(['company']) expect(User.field_specs['company'].options[:ruby_default]&.call).to eq("BigCorp") nuke_model_class(User) ## validates # DeclareSchema can accept a validates hash in the field options. class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock class << self def validates(field_name, options) end end end expect(Ad).to receive(:validates).with(:company, presence: true, uniqueness: { case_sensitive: false }) class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :company, limit: 250, index: true, unique: true, validates: { presence: true, uniqueness: { case_sensitive: false } } end self.primary_key = "advert_id" end up, _down = Generators::DeclareSchema::Migration::Migrator.run ActiveRecord::Migration.class_eval(up) expect(Ad.field_specs['company'].options[:validates].inspect).to eq("{:presence=>true, :uniqueness=>{:case_sensitive=>false}}") # DeclareSchema supports has_and_belongs_to_many relationships and generates the intersection ("join") table # with appropriate primary key, indexes, and foreign keys. class Advertiser < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250 end has_and_belongs_to_many :creatives end class Creative < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :url, limit: 500 end has_and_belongs_to_many :advertisers end expect(Generators::DeclareSchema::Migration::Migrator.run).to( if ActiveSupport.version >= Gem::Version.new('7.0.0') && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0') migrate_up(<<~EOS.strip) create_table :advertisers_creatives, primary_key: [:advertiser_id, :creative_id]#{create_table_charset_and_collation} do |t| t.integer :advertiser_id, limit: 8, null: false t.integer :creative_id, limit: 8, null: false end create_table :creatives, id: #{primary_key_type}#{create_table_charset_and_collation} do |t| t.string :url, limit: 500, null: false#{charset_and_collation} end create_table :advertisers, id: #{primary_key_type}#{create_table_charset_and_collation} do |t| t.string :name, limit: 250, null: false#{charset_and_collation} end add_index :advertisers_creatives, [:creative_id], name: :index_advertisers_creatives_on_creative_id add_foreign_key :advertisers_creatives, :advertisers, column: :advertiser_id, name: :advertisers_creatives_FK1 add_foreign_key :advertisers_creatives, :creatives, column: :creative_id, name: :advertisers_creatives_FK2 EOS else migrate_up(<<~EOS.strip) create_table :advertisers, id: #{primary_key_type}#{create_table_charset_and_collation} do |t| t.string :name, limit: 250, null: false#{charset_and_collation} end create_table :advertisers_creatives, primary_key: [:advertiser_id, :creative_id]#{create_table_charset_and_collation} do |t| t.integer :advertiser_id, limit: 8, null: false t.integer :creative_id, limit: 8, null: false end create_table :creatives, id: #{primary_key_type}#{create_table_charset_and_collation} do |t| t.string :url, limit: 500, null: false#{charset_and_collation} end add_index :advertisers_creatives, [:creative_id], name: :index_advertisers_creatives_on_creative_id add_foreign_key :advertisers_creatives, :advertisers, column: :advertiser_id, name: :advertisers_creatives_FK1 add_foreign_key :advertisers_creatives, :creatives, column: :creative_id, name: :advertisers_creatives_FK2 EOS end ) nuke_model_class(Ad) nuke_model_class(Advertiser) nuke_model_class(Creative) end context 'models with the same parent foreign key relation' do include_context 'skip if' do let(:adapter) { 'sqlite3' } end before do class Category < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end end class Advertiser < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end belongs_to :category, limit: 8 end class Affiliate < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true end belongs_to :category, limit: 8 end end let(:fixture_file_path) do if ActiveSupport.version >= Gem::Version.new('7.0.0') && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0') "../../fixtures/migrations/#{current_adapter}/will_generate_unique_constraint_names_rails_7.txt" else "../../fixtures/migrations/#{current_adapter}/will_generate_unique_constraint_names_rails_6.txt" end end it 'will generate unique constraint names' do expect(Generators::DeclareSchema::Migration::Migrator.run).to(migrate_up(File.read(File.expand_path(fixture_file_path, __dir__)).chomp), fixture_file_path) migrate nuke_model_class(Advertiser) nuke_model_class(Affiliate) end end describe 'serialize' do before do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock @serialize_args = [] class << self attr_reader :serialize_args def serialize(*args) @serialize_args << args end end end end describe 'untyped' do it 'allows serialize: true' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do text :allow_list, limit: 0xFFFF, serialize: true end end expect(Ad.serialize_args).to eq([[:allow_list]]) end it 'converts defaults with .to_yaml' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: true, null: true, default: [] string :allow_hash, limit: 250, serialize: true, null: true, default: {} string :allow_string, limit: 250, serialize: true, null: true, default: ['abc'] string :allow_null, limit: 250, serialize: true, null: true, default: nil end end expect(Ad.field_specs['allow_list'].default).to eq("--- []\n") expect(Ad.field_specs['allow_hash'].default).to eq("--- {}\n") expect(Ad.field_specs['allow_string'].default).to eq("---\n- abc\n") expect(Ad.field_specs['allow_null'].default).to eq(nil) end end describe 'Array' do it 'allows serialize: Array' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: Array, null: true end end expect(Ad.serialize_args).to eq([[:allow_list, Array]]) end it 'allows Array defaults' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: Array, null: true, default: [2] string :allow_string, limit: 250, serialize: Array, null: true, default: ['abc'] string :allow_empty, limit: 250, serialize: Array, null: true, default: [] string :allow_null, limit: 250, serialize: Array, null: true, default: nil end end expect(Ad.field_specs['allow_list'].default).to eq("---\n- 2\n") expect(Ad.field_specs['allow_string'].default).to eq("---\n- abc\n") expect(Ad.field_specs['allow_empty'].default).to eq(nil) expect(Ad.field_specs['allow_null'].default).to eq(nil) end end describe 'Hash' do it 'allows serialize: Hash' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: Hash, null: true end end expect(Ad.serialize_args).to eq([[:allow_list, Hash]]) end it 'allows Hash defaults' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_loc, limit: 250, serialize: Hash, null: true, default: { 'state' => 'CA' } string :allow_hash, limit: 250, serialize: Hash, null: true, default: {} string :allow_null, limit: 250, serialize: Hash, null: true, default: nil end end expect(Ad.field_specs['allow_loc'].default).to eq("---\nstate: CA\n") expect(Ad.field_specs['allow_hash'].default).to eq(nil) expect(Ad.field_specs['allow_null'].default).to eq(nil) end end describe 'JSON' do it 'allows serialize: JSON' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: JSON end end expect(Ad.serialize_args).to eq([[:allow_list, JSON]]) end it 'allows JSON defaults' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_hash, limit: 250, serialize: JSON, null: true, default: { 'state' => 'CA' } string :allow_empty_array, limit: 250, serialize: JSON, null: true, default: [] string :allow_empty_hash, limit: 250, serialize: JSON, null: true, default: {} string :allow_null, limit: 250, serialize: JSON, null: true, default: nil end end expect(Ad.field_specs['allow_hash'].default).to eq("{\"state\":\"CA\"}") expect(Ad.field_specs['allow_empty_array'].default).to eq("[]") expect(Ad.field_specs['allow_empty_hash'].default).to eq("{}") expect(Ad.field_specs['allow_null'].default).to eq(nil) end end class ValueClass # rubocop:disable Lint/ConstantDefinitionInBlock delegate :present?, :inspect, to: :@value def initialize(value) @value = value end class << self def dump(object) if object&.present? object.inspect end end def load(serialized) if serialized raise 'not used ???' end end end end describe 'custom coder' do it 'allows serialize: ValueClass' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_list, limit: 250, serialize: ValueClass end end expect(Ad.serialize_args).to eq([[:allow_list, ValueClass]]) end it 'allows ValueClass defaults' do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :allow_hash, limit: 250, serialize: ValueClass, null: true, default: ValueClass.new([2]) string :allow_empty_array, limit: 250, serialize: ValueClass, null: true, default: ValueClass.new([]) string :allow_null, limit: 250, serialize: ValueClass, null: true, default: nil end end expect(Ad.field_specs['allow_hash'].default).to eq("[2]") expect(Ad.field_specs['allow_empty_array'].default).to eq(nil) expect(Ad.field_specs['allow_null'].default).to eq(nil) end end it 'disallows serialize: with a non-string column type' do expect do class Ad < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :allow_list, limit: 8, serialize: true end end end.to raise_exception(ArgumentError, /must be :string or :text/) end end context "for Rails #{ActiveSupport::VERSION::MAJOR}" do let(:optional_true) { { optional: true } } let(:optional_false) { { optional: false } } let(:optional_flag) { { false => optional_false, true => optional_true } } describe 'belongs_to' do context 'with AdCategory and Advert in DB' do before do unless defined?(AdCategory) class AdCategory < ActiveRecord::Base declare_schema { } end end class Advert < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :name, limit: 250, null: true integer :category_id, limit: 8 integer :nullable_category_id, limit: 8, null: true end end up = Generators::DeclareSchema::Migration::Migrator.run.first ActiveRecord::Migration.class_eval(up) end it 'passes through optional: when given' do class AdvertBelongsTo < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'adverts' declare_schema { } reset_column_information belongs_to :ad_category, optional: true end expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_true) end describe 'contradictory settings' do # contradictory settings are ok--for example, during migration it 'passes through optional: true, null: false' do class AdvertBelongsTo < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'adverts' declare_schema { } reset_column_information belongs_to :ad_category, optional: true, null: false end expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_true) expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(false) end it 'passes through optional: false, null: true' do class AdvertBelongsTo < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'adverts' declare_schema { } reset_column_information belongs_to :ad_category, optional: false, null: true end expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_false) expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(true) end end [false, true].each do |nullable| context "nullable=#{nullable}" do it 'infers optional: from null:' do eval <<~EOS # rubocop:disable Style/EvalWithLocation,Security/Eval class AdvertBelongsTo < ActiveRecord::Base declare_schema { } belongs_to :ad_category, null: #{nullable} end EOS expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_flag[nullable]) expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable) end it 'infers null: from optional:' do eval <<~EOS # rubocop:disable Style/EvalWithLocation,Security/Eval class AdvertBelongsTo < ActiveRecord::Base declare_schema { } belongs_to :ad_category, optional: #{nullable} end EOS expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_flag[nullable]) expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable) end end end it 'deprecates limit:' do expect(DeclareSchema.deprecator).to receive(:warn).with("belongs_to :ad_category, limit: 4 is deprecated since it is now inferred") eval <<~EOS # rubocop:disable Style/EvalWithLocation class UsingLimit < ActiveRecord::Base declare_schema { } belongs_to :ad_category, limit: 4 end EOS end end context 'when parent object PKs have different limits' do before do class IdDefault < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } end class Id4 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema id: :integer do end end class Id8 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema id: :bigint do end end class Fk < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :id_default belongs_to :id4 belongs_to :id8 end end it 'creates the proper PKs' do up = Generators::DeclareSchema::Migration::Migrator.run.first create_id4_defaults = up.split("\n").grep(/create_table :id_defaults/).first expect(create_id4_defaults).to be, up case current_adapter when 'postgresql' expect(create_id4_defaults).to match(/, id: :bigserial/) else expect(create_id4_defaults).to match(/, id: :bigint/) end create_id4s = up.split("\n").grep(/create_table :id4s/).first expect(create_id4s).to be, up expect(create_id4s).to match(/, id: :integer/) create_id8s = up.split("\n").grep(/create_table :id8s/).first expect(create_id8s).to be, up expect(create_id8s).to match(/, id: :bigint/) end it 'infers the correct FK type from the create_table id: type' do up = Generators::DeclareSchema::Migration::Migrator.run.first create_fks = up.split("\n").grep(/t\.integer /).map { |command| command.gsub(', null: false', '').gsub(/^ +/, '') } if current_adapter == 'sqlite3' create_fks.map! { |command| command.gsub(/limit: [a-z0-9]+/, 'limit: X') } expect(create_fks).to eq([ 't.integer :id_default_id, limit: X', 't.integer :id4_id, limit: X', 't.integer :id8_id, limit: X' ]), up else expect(create_fks).to eq([ 't.integer :id_default_id, limit: 8', 't.integer :id4_id, limit: 4', 't.integer :id8_id, limit: 8' ]), up end end context "when parent objects were migrated before and later definitions don't have explicit id:" do before do up = Generators::DeclareSchema::Migration::Migrator.run.first ActiveRecord::Migration.class_eval up nuke_model_class(IdDefault) nuke_model_class(Id4) nuke_model_class(Id8) nuke_model_class(Fk) ActiveRecord::Base.connection.schema_cache.clear! class NewIdDefault < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'id_defaults' declare_schema { } end class NewId4 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'id4s' declare_schema { } end class NewId8 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock self.table_name = 'id8s' declare_schema { } end class NewFk < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :new_id_default belongs_to :new_id4 belongs_to :new_id8 end end it 'infers the correct FK :integer limit: ' do up = Generators::DeclareSchema::Migration::Migrator.run.first create_fks = up.split("\n").grep(/t\.integer /).map { |command| command.gsub(', null: false', '').gsub(/^ +/, '') } if current_adapter == 'sqlite3' create_fks.map! { |command| command.gsub(/limit: [a-z0-9]+/, 'limit: X') } expect(create_fks).to eq([ 't.integer :new_id_default_id, limit: X', 't.integer :new_id4_id, limit: X', 't.integer :new_id8_id, limit: X' ]), up else expect(create_fks).to eq([ 't.integer :new_id_default_id, limit: 8', 't.integer :new_id4_id, limit: 4', 't.integer :new_id8_id, limit: 8' ]), up end end end end end end describe 'migration base class' do it 'adapts to Rails 4' do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do string :title, limit: 100 end end generate_migrations '-n', '-m' migrations = Dir.glob('db/migrate/*declare_schema_migration*.rb') expect(migrations.size).to eq(1), migrations.inspect migration_content = File.read(migrations.first) first_line = migration_content.split("\n").first base_class = first_line.split(' < ').last expect(base_class).to eq("ActiveRecord::Migration[4.2]") end end context 'Does not generate migrations' do it 'for aliased fields bigint -> integer limit 8' do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do bigint :price end end generate_migrations '-n', '-m' migrations = Dir.glob('db/migrate/*declare_schema_migration*.rb') expect(migrations.size).to eq(1), migrations.inspect class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :price, limit: 8 end end expect { generate_migrations '-n', '-g' }.to output("Database and models match -- nothing to change\n").to_stdout end end context 'default_schema' do let(:default_schema_block) { nil } let(:declare_model) do -> do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :price, limit: 8 end end end end before do DeclareSchema.default_schema(&default_schema_block) end after do DeclareSchema.clear_default_schema end context 'when unset' do it 'adds nothing' do declare_model.call expect(Advert.field_specs.keys).to eq(['price']) end end context 'when set to a block' do let(:default_schema_block) do -> do timestamps field :lock_version, :integer, default: 1 end end it 'adds the fields in that block' do declare_model.call expect(Advert.field_specs.keys).to eq(['price', 'created_at', 'updated_at', 'lock_version']) end context 'and the model sets default_schema: false' do before do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema default_schema: false do integer :price, limit: 8 end end end it 'does not add the default schema fields' do expect(Advert.field_specs.keys).to eq(['price']) end end context 'and the block has redundant fields' do before do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema do integer :price, limit: 8 timestamps end end end it 'is a no-op' do expect(Advert.field_specs.keys).to eq(['price', 'created_at', 'updated_at', 'lock_version']) end end end end context 'index' do before do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :ad_category end end it "is idempotent and doesn't raise" do expect do Advert.index [:ad_category_id], name: :index_adverts_on_ad_category_id end.to_not change { Advert.index_definitions.size } end it "when equivalent but not marked to allow, it raises" do expect do Advert.index [:ad_category_id], name: :on_ad_category_id end.to raise_exception(ArgumentError, /equivalent index definition found/i) end it "when equivalent and marked to allow, it is idempotent and doesn't raise" do expect do Advert.index [:ad_category_id], name: :on_ad_category_id, ignore_equivalent_definitions: true end.to_not change { Advert.index_definitions.size } end context 'constraint' do before do class Advert < active_record_base_class.constantize # rubocop:disable Lint/ConstantDefinitionInBlock declare_schema { } belongs_to :ad_category end end it "when exactly equal, it is idempotent and doesn't raise" do expect do Advert.constraint(:ad_category_id, parent_table_name: 'ad_categories', constraint_name: :index_adverts_on_ad_category_id, parent_class_name: 'AdCategory') end.to_not change { Advert.index_definitions.size } end it "when equivalent, it is idempotent and doesn't raise" do expect do Advert.constraint(:ad_category_id, parent_table_name: 'ad_categories', constraint_name: :constraint_1, parent_class_name: 'AdCategory') end.to_not change { Advert.index_definitions.size } end end end end end