From 9f57677b3653b1d0aa753b44927edc4fe46f8b12 Mon Sep 17 00:00:00 2001 From: Mane Darbinyan Date: Thu, 27 Feb 2025 18:50:37 +0400 Subject: [PATCH] Support schema diff for structure.sql --- .gitignore | 1 + .rubocop.yml | 9 + .../actual_db_schema/schema_controller.rb | 3 +- lib/actual_db_schema.rb | 1 + lib/actual_db_schema/schema_diff.rb | 48 +- lib/actual_db_schema/schema_diff_html.rb | 16 +- lib/actual_db_schema/structure_sql_parser.rb | 41 ++ lib/tasks/actual_db_schema.rake | 3 +- .../schema_controller_test.rb | 110 ++-- test/rake_task_schema_diff_test.rb | 468 ++++++++++++------ 10 files changed, 515 insertions(+), 185 deletions(-) create mode 100644 lib/actual_db_schema/structure_sql_parser.rb diff --git a/.gitignore b/.gitignore index 91386f7d..a6f2bce7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/dummy_app/tmp/ /test/dummy_app/custom/ /test/dummy_app/db/**/*.rb +/test/dummy_app/db/structure.sql .ruby-version .ruby-gemset /gemfiles/*.gemfile.lock diff --git a/.rubocop.yml b/.rubocop.yml index 58d02072..226316cd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -31,3 +31,12 @@ Metrics/ClassLength: Metrics/ModuleLength: Enabled: false + +Metrics/AbcSize: + Max: 25 + +Metrics/CyclomaticComplexity: + Max: 10 + +Metrics/PerceivedComplexity: + Max: 10 diff --git a/app/controllers/actual_db_schema/schema_controller.rb b/app/controllers/actual_db_schema/schema_controller.rb index fa55c778..a4572d63 100644 --- a/app/controllers/actual_db_schema/schema_controller.rb +++ b/app/controllers/actual_db_schema/schema_controller.rb @@ -11,7 +11,8 @@ def index; end private helper_method def schema_diff_html - schema_diff = ActualDbSchema::SchemaDiffHtml.new("./db/schema.rb", "db/migrate") + schema_path = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb" + schema_diff = ActualDbSchema::SchemaDiffHtml.new(schema_path, "db/migrate") schema_diff.render_html(params[:table]) end end diff --git a/lib/actual_db_schema.rb b/lib/actual_db_schema.rb index 3db0edd3..2ff99d44 100644 --- a/lib/actual_db_schema.rb +++ b/lib/actual_db_schema.rb @@ -21,6 +21,7 @@ require_relative "actual_db_schema/schema_diff" require_relative "actual_db_schema/schema_diff_html" require_relative "actual_db_schema/schema_parser" +require_relative "actual_db_schema/structure_sql_parser" require_relative "actual_db_schema/commands/base" require_relative "actual_db_schema/commands/rollback" diff --git a/lib/actual_db_schema/schema_diff.rb b/lib/actual_db_schema/schema_diff.rb index 9ba18bc1..cb13af56 100644 --- a/lib/actual_db_schema/schema_diff.rb +++ b/lib/actual_db_schema/schema_diff.rb @@ -19,6 +19,14 @@ class SchemaDiff /create_table\s+["']([^"']+)["']/ => :table }.freeze + SQL_CHANGE_PATTERNS = { + /CREATE (?:UNIQUE\s+)?INDEX\s+["']?([^"'\s]+)["']?\s+ON\s+([\w.]+)/i => :index, + /CREATE TABLE\s+(\S+)\s+\(/i => :table, + /CREATE SEQUENCE\s+(\S+)/i => :table, + /ALTER SEQUENCE\s+(\S+)\s+OWNED BY\s+([\w.]+)/i => :table, + /ALTER TABLE\s+ONLY\s+(\S+)\s+/i => :table + }.freeze + def initialize(schema_path, migrations_path) @schema_path = schema_path @migrations_path = migrations_path @@ -48,11 +56,19 @@ def new_schema_content end def parsed_old_schema - @parsed_old_schema ||= SchemaParser.parse_string(old_schema_content.to_s) + @parsed_old_schema ||= parser_class.parse_string(old_schema_content.to_s) end def parsed_new_schema - @parsed_new_schema ||= SchemaParser.parse_string(new_schema_content.to_s) + @parsed_new_schema ||= parser_class.parse_string(new_schema_content.to_s) + end + + def parser_class + structure_sql? ? StructureSqlParser : SchemaParser + end + + def structure_sql? + File.extname(@schema_path) == ".sql" end def migration_changes @@ -105,8 +121,9 @@ def process_diff_output(diff_str) lines.each do |line| if (hunk_match = line.match(/^@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)\s+@@/)) current_table = find_table_in_new_schema(hunk_match[3].to_i) - elsif (ct = line.match(/create_table\s+["']([^"']+)["']/)) - current_table = ct[1] + elsif (ct = line.match(/create_table\s+["']([^"']+)["']/) || + line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i) || line.match(/ALTER TABLE\s+ONLY\s+(\S+)/i)) + current_table = normalize_table_name(ct[1]) end result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line(line, current_table) : line) @@ -128,19 +145,24 @@ def handle_diff_line(line, current_table) end def detect_action_and_name(line_content, sign, current_table) + patterns = structure_sql? ? SQL_CHANGE_PATTERNS : CHANGE_PATTERNS action_map = { column: ->(md) { [guess_action(sign, current_table, md[2]), md[2]] }, index: ->(md) { [sign == "+" ? :add_index : :remove_index, md[1]] }, table: ->(_) { [sign == "+" ? :create_table : :drop_table, nil] } } - CHANGE_PATTERNS.each do |regex, kind| + patterns.each do |regex, kind| next unless (md = line_content.match(regex)) action_proc = action_map[kind] return action_proc.call(md) if action_proc end + if structure_sql? && current_table && (md = line_content.match(/^\s*"?(\w+)"?\s+(.+?)(?:,|\s*$)/i)) + return [guess_action(sign, current_table, md[1]), md[1]] + end + [nil, nil] end @@ -159,8 +181,8 @@ def find_table_in_new_schema(new_line_number) current_table = nil new_schema_content.lines[0...new_line_number].each do |line| - if (match = line.match(/create_table\s+["']([^"']+)["']/)) - current_table = match[1] + if (match = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i)) + current_table = normalize_table_name(match[1]) end end current_table @@ -171,7 +193,7 @@ def find_migrations(action, table_name, col_or_index_name) migration_changes.each do |file_path, changes| changes.each do |chg| - next unless chg[:table].to_s == table_name.to_s + next unless (structure_sql? && index_action?(action)) || chg[:table].to_s == table_name.to_s matches << file_path if migration_matches?(chg, action, col_or_index_name) end @@ -180,6 +202,10 @@ def find_migrations(action, table_name, col_or_index_name) matches end + def index_action?(action) + %i[add_index remove_index rename_index].include?(action) + end + def migration_matches?(chg, action, col_or_index_name) return (chg[:action] == action) if col_or_index_name.nil? @@ -225,5 +251,11 @@ def extract_migration_index_name(chg, table_name) def annotate_line(line, migration_file_paths) "#{line.chomp}#{colorize(" // #{migration_file_paths.join(", ")} //", :gray)}\n" end + + def normalize_table_name(table_name) + return table_name unless structure_sql? && table_name.include?(".") + + table_name.split(".").last + end end end diff --git a/lib/actual_db_schema/schema_diff_html.rb b/lib/actual_db_schema/schema_diff_html.rb index bef71457..667b6c1f 100644 --- a/lib/actual_db_schema/schema_diff_html.rb +++ b/lib/actual_db_schema/schema_diff_html.rb @@ -17,7 +17,7 @@ def render_html(table_filter) def generate_diff_html diff_output = generate_full_diff(old_schema_content, new_schema_content) - return "
#{ERB::Util.html_escape(new_schema_content)}
" if diff_output.strip.empty? + diff_output = new_schema_content if diff_output.strip.empty? process_diff_output_for_html(diff_output) end @@ -43,7 +43,7 @@ def process_diff_output_for_html(diff_str) block_depth = 1 diff_str.lines.each do |line| - next if line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/) + next if skip_line?(line) current_table, table_start, block_depth = process_table(line, current_table, table_start, result_lines.size, block_depth) @@ -53,15 +53,21 @@ def process_diff_output_for_html(diff_str) result_lines.join end + def skip_line?(line) + line != "---\n" && !line.match(/^--- Name/) && + (line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/)) + end + def process_table(line, current_table, table_start, table_end, block_depth) - if (ct = line.match(/create_table\s+["']([^"']+)["']/)) - return [ct[1], table_end, block_depth] + if (ct = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i)) + return [normalize_table_name(ct[1]), table_end, block_depth] end return [current_table, table_start, block_depth] unless current_table block_depth += line.scan(/\bdo\b/).size unless line.match(/create_table\s+["']([^"']+)["']/) block_depth -= line.scan(/\bend\b/).size + block_depth -= line.scan(/\);\s*$/).size if block_depth.zero? @tables[current_table] = { start: table_start, end: table_end } @@ -101,7 +107,7 @@ def colorize_html(text, color) end def link_to_migration(migration_file_path) - migration = migrations.detect { |m| m.filename == migration_file_path } + migration = migrations.detect { |m| File.expand_path(m.filename) == File.expand_path(migration_file_path) } return ERB::Util.html_escape(migration_file_path) unless migration url = "migrations/#{migration.version}?database=#{migration.database}" diff --git a/lib/actual_db_schema/structure_sql_parser.rb b/lib/actual_db_schema/structure_sql_parser.rb new file mode 100644 index 00000000..c23ba4cc --- /dev/null +++ b/lib/actual_db_schema/structure_sql_parser.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ActualDbSchema + # Parses the content of a `structure.sql` file into a structured hash representation. + module StructureSqlParser + module_function + + def parse_string(sql_content) + schema = {} + table_regex = /CREATE TABLE\s+(?:"?([\w.]+)"?)\s*\((.*?)\);/m + sql_content.scan(table_regex) do |table_name, columns_section| + schema[normalize_table_name(table_name)] = parse_columns(columns_section) + end + schema + end + + def parse_columns(columns_section) + columns = {} + columns_section.each_line do |line| + line.strip! + next if line.empty? || line =~ /^(CONSTRAINT|PRIMARY KEY|FOREIGN KEY)/i + + match = line.match(/\A"?(?\w+)"?\s+(?\w+)(?\s*\([\d,]+\))?/i) + next unless match + + col_name = match[:col] + col_type = match[:type].strip.downcase.to_sym + options = {} + columns[col_name] = { type: col_type, options: options } + end + + columns + end + + def normalize_table_name(table_name) + return table_name unless table_name.include?(".") + + table_name.split(".").last + end + end +end diff --git a/lib/tasks/actual_db_schema.rake b/lib/tasks/actual_db_schema.rake index b46003b0..961110eb 100644 --- a/lib/tasks/actual_db_schema.rake +++ b/lib/tasks/actual_db_schema.rake @@ -56,7 +56,8 @@ namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength desc "Show the schema.rb diff annotated with the migrations that made the changes" task :diff_schema_with_migrations, %i[schema_path migrations_path] => :environment do |_, args| - schema_path = args[:schema_path] || "./db/schema.rb" + default_schema = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb" + schema_path = args[:schema_path] || default_schema migrations_path = args[:migrations_path] || "db/migrate" schema_diff = ActualDbSchema::SchemaDiff.new(schema_path, migrations_path) diff --git a/test/controllers/actual_db_schema/schema_controller_test.rb b/test/controllers/actual_db_schema/schema_controller_test.rb index 171d37b8..36f09647 100644 --- a/test/controllers/actual_db_schema/schema_controller_test.rb +++ b/test/controllers/actual_db_schema/schema_controller_test.rb @@ -15,23 +15,18 @@ def setup @utils.reset_database_yml(TestingState.db_config) @utils.cleanup(TestingState.db_config) define_migrations - - ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path| - @schema_path = "test/dummy_app/db/schema.rb" - @migrations_path = "test/dummy_app/db/migrate" - end end def teardown - @utils.define_migration_file("20250212084323_drop_users.rb", <<~RUBY) - class DropUsers < ActiveRecord::Migration[6.0] + @utils.define_migration_file("20250212084323_drop_users_table.rb", <<~RUBY) + class DropUsersTable < ActiveRecord::Migration[6.0] def change drop_table :users end end RUBY - @utils.define_migration_file("20250212084324_drop_products.rb", <<~RUBY) - class DropProducts < ActiveRecord::Migration[6.0] + @utils.define_migration_file("20250212084324_drop_products_table.rb", <<~RUBY) + class DropProductsTable < ActiveRecord::Migration[6.0] def change drop_table :products end @@ -76,37 +71,58 @@ def change end RUBY @utils.run_migrations + end - ActualDbSchema::SchemaDiff.define_method(:old_schema_content) do - <<~RUBY - ActiveRecord::Schema[6.0].define(version: 20250212084322) do - create_table "products", force: :cascade do |t| - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end + def run_migration(file_name, content) + @utils.define_migration_file(file_name, content) + @utils.run_migrations + dump_schema + end - create_table "users", force: :cascade do |t| - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - end - RUBY + def dump_schema + return unless Rails.configuration.active_record.schema_format == :sql + + ActiveRecord::Base.establish_connection(TestingState.db_config["primary"]) + config = if ActiveRecord::Base.respond_to?(:connection_db_config) + ActiveRecord::Base.connection_db_config + else + ActiveRecord::Base.configurations[Rails.env] + end + ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, Rails.root.join("db", "structure.sql").to_s) + end + + def define_schema_diff_html_methods_for_schema_rb + old_schema_content = File.read("test/dummy_app/db/schema.rb") + ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content } + ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path| + @schema_path = "test/dummy_app/db/schema.rb" + @migrations_path = "test/dummy_app/db/migrate" end end - test "GET #index returns a successful response" do - file_name = "20250212084325_add_surname_to_users.rb" - @utils.define_migration_file(file_name, <<~RUBY) + def define_schema_diff_html_methods_for_structure_sql + old_schema_content = File.read("test/dummy_app/db/structure.sql") + ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content } + ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path| + @schema_path = "test/dummy_app/db/structure.sql" + @migrations_path = "test/dummy_app/db/migrate" + end + end + + def add_surname_to_users_migration + <<~RUBY class AddSurnameToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :surname, :string end end RUBY - @utils.run_migrations + end + test "GET #index returns a successful response when using schema.rb" do + define_schema_diff_html_methods_for_schema_rb + file_name = "20250212084325_add_surname_to_users.rb" + run_migration(file_name, add_surname_to_users_migration) get :index assert_response :success assert_select "h2", text: "Database Schema" @@ -117,7 +133,8 @@ def change end end - test "GET #index with search query returns filtered results" do + test "GET #index with search query returns filtered results when using schema.rb" do + define_schema_diff_html_methods_for_schema_rb get :index, params: { table: "users" } assert_response :success assert_select "h2", text: "Database Schema" @@ -126,5 +143,40 @@ def change refute_match(/create_table "products"/, pre.text) end end + + test "GET #index returns a successful response when using structure.sql" do + skip unless TestingState.db_config["primary"]["adapter"] == "postgresql" + + Rails.application.configure { config.active_record.schema_format = :sql } + dump_schema + define_schema_diff_html_methods_for_structure_sql + file_name = "20250212084325_add_surname_to_users.rb" + run_migration(file_name, add_surname_to_users_migration) + get :index + assert_response :success + assert_select "h2", text: "Database Schema" + assert_select "div.schema-diff pre" do |pre| + assert_match(/CREATE TABLE public.products/, pre.text) + assert_match(/CREATE TABLE public.users/, pre.text) + assert_match( + %r{\+ surname character varying // #{File.join("test/dummy_app/db/migrate", file_name)} //}, pre.text + ) + end + end + + test "GET #index with search query returns filtered results when using structure.sql" do + skip unless TestingState.db_config["primary"]["adapter"] == "postgresql" + + Rails.application.configure { config.active_record.schema_format = :sql } + dump_schema + define_schema_diff_html_methods_for_structure_sql + get :index, params: { table: "users" } + assert_response :success + assert_select "h2", text: "Database Schema" + assert_select "div.schema-diff pre" do |pre| + assert_match(/CREATE TABLE public.users/, pre.text) + refute_match(/CREATE TABLE public.products/, pre.text) + end + end end end diff --git a/test/rake_task_schema_diff_test.rb b/test/rake_task_schema_diff_test.rb index c88f3ecb..9b7f6ae2 100644 --- a/test/rake_task_schema_diff_test.rb +++ b/test/rake_task_schema_diff_test.rb @@ -37,27 +37,6 @@ def change end RUBY utils.run_migrations - - ActualDbSchema::SchemaDiff.define_method(:old_schema_content) do - <<~RUBY - ActiveRecord::Schema[6.0].define(version: 20250124084322) do - create_table "products", force: :cascade do |t| - t.string "name" - t.decimal "price", precision: 10, scale: 2 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "users", force: :cascade do |t| - t.string "name" - t.string "middle_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["name"], name: "index_users_on_name", unique: true - end - end - RUBY - end end after do @@ -78,156 +57,387 @@ def change utils.run_migrations end - def invoke_rake_task - Rake::Task["actual_db_schema:diff_schema_with_migrations"].invoke( - "test/dummy_app/db/schema.rb", "test/dummy_app/db/migrate" - ) + def migration_path(file_name) + File.join("test/dummy_app/db/migrate", file_name) + end + + def invoke_rake_task(schema_path) + Rake::Task["actual_db_schema:diff_schema_with_migrations"].invoke(schema_path, "test/dummy_app/db/migrate") Rake::Task["actual_db_schema:diff_schema_with_migrations"].reenable end - def migration_path(file_name) - File.join("test/dummy_app/db/migrate", file_name) + def run_migration(file_name, content) + utils.define_migration_file(file_name, content) + utils.run_migrations + dump_schema + end + + def dump_schema + return unless Rails.configuration.active_record.schema_format == :sql + + config = if ActiveRecord::Base.respond_to?(:connection_db_config) + ActiveRecord::Base.connection_db_config + else + ActiveRecord::Base.configurations[Rails.env] + end + ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, Rails.root.join("db", "structure.sql").to_s) + end + + describe "when using schema.rb" do + before do + old_schema_content = File.read("test/dummy_app/db/schema.rb") + ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content } + end + + it "annotates adding a column" do + file_name = "20250124084325_add_surname_to_users.rb" + run_migration(file_name, add_surname_to_users_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{\+ t\.string "surname" // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates removing a column" do + file_name = "20250124084326_remove_middle_name_from_users.rb" + run_migration(file_name, remove_middle_name_from_users_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- t\.string "middle_name" // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates changing a column" do + file_name = "20250124084327_change_price_precision_in_products.rb" + run_migration(file_name, change_price_precision_in_products_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- t\.decimal "price", precision: 10, scale: 2 // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ t\.decimal "price", precision: 15, scale: 2 // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates renaming a column" do + file_name = "20250124084328_rename_name_to_full_name_in_users.rb" + run_migration(file_name, rename_name_to_full_name_in_users_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- t\.string "name" // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ t\.string "full_name" // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates adding an index" do + file_name = "20250124084329_add_index_on_users_middle_name.rb" + run_migration(file_name, add_index_on_users_middle_name_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{\+ t\.index \["middle_name"\], name: "index_users_on_middle_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates removing an index" do + file_name = "20250124084330_remove_index_on_users_name.rb" + run_migration(file_name, remove_index_on_users_name_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates renaming an index" do + file_name = "20250124084331_rename_index_on_users_name.rb" + run_migration(file_name, rename_index_on_users_name_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ t\.index \["name"\], name: "index_users_on_user_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates creating a new table" do + file_name = "20250124084332_create_categories.rb" + run_migration(file_name, create_categories_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{\+ create_table "categories", force: :cascade do |t| // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + run_migration("20250124084333_drop_categories.rb", drop_categories_migration) + end + + it "annotates dropping a table" do + file_name = "20250124084334_drop_products_table.rb" + run_migration(file_name, drop_products_table_migration) + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{- create_table "products", force: :cascade do |t| // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "processes phantom migrations from tmp/migrated folders" do + file_name = "20250124084335_phantom.rb" + run_migration(file_name, phantom_migration) + utils.remove_app_dir(Rails.root.join("db", "migrate", file_name)) + utils.run_migrations + invoke_rake_task("test/dummy_app/db/schema.rb") + assert_match( + %r{\+ t\.string "email" // #{File.join("test/dummy_app/tmp/migrated", file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + end + + describe "when using structure.sql" do + before do + skip unless TestingState.db_config["primary"]["adapter"] == "postgresql" + + Rails.application.configure { config.active_record.schema_format = :sql } + dump_schema + old_schema_content = File.read("test/dummy_app/db/structure.sql") + ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content } + end + + after do + Rails.application.configure { config.active_record.schema_format = :ruby } + end + + it "annotates adding a column" do + file_name = "20250124084325_add_surname_to_users.rb" + run_migration(file_name, add_surname_to_users_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{\+ surname character varying // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates removing a column" do + file_name = "20250124084326_remove_middle_name_from_users.rb" + run_migration(file_name, remove_middle_name_from_users_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{- middle_name character varying, // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates changing a column" do + file_name = "20250124084327_change_price_precision_in_products.rb" + run_migration(file_name, change_price_precision_in_products_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{- price numeric\(10,2\), // #{migration_path(file_name)} //}, TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ price numeric\(15,2\), // #{migration_path(file_name)} //}, TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates renaming a column" do + file_name = "20250124084328_rename_name_to_full_name_in_users.rb" + run_migration(file_name, rename_name_to_full_name_in_users_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{- name character varying, // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ full_name character varying, // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates adding an index" do + file_name = "20250124084329_add_index_on_users_middle_name.rb" + run_migration(file_name, add_index_on_users_middle_name_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{\+CREATE UNIQUE INDEX index_users_on_middle_name ON public.users USING btree \(middle_name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates removing an index" do + file_name = "20250124084330_remove_index_on_users_name.rb" + run_migration(file_name, remove_index_on_users_name_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{-CREATE UNIQUE INDEX index_users_on_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates renaming an index" do + file_name = "20250124084331_rename_index_on_users_name.rb" + run_migration(file_name, rename_index_on_users_name_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{-CREATE UNIQUE INDEX index_users_on_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+CREATE UNIQUE INDEX index_users_on_user_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "annotates creating a new table" do + file_name = "20250124084332_create_categories.rb" + run_migration(file_name, create_categories_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{\+CREATE TABLE public.categories \( // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+CREATE SEQUENCE public.categories_id_seq // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ALTER SEQUENCE public.categories_id_seq OWNED BY public.categories.id; // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ALTER TABLE ONLY public.categories ALTER COLUMN id SET DEFAULT nextval\('public.categories_id_seq'::regclass\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{\+ALTER TABLE ONLY public.categories // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + run_migration("20250124084333_drop_categories.rb", drop_categories_migration) + end + + it "annotates dropping a table" do + file_name = "20250124084334_drop_products_table.rb" + run_migration(file_name, drop_products_table_migration) + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{-CREATE TABLE public.products \( // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{-CREATE SEQUENCE public.products_id_seq // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{-ALTER SEQUENCE public.products_id_seq OWNED BY public.products.id; // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{-ALTER TABLE ONLY public.products ALTER COLUMN id SET DEFAULT nextval\('public.products_id_seq'::regclass\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength + TestingState.output.gsub(/\e\[\d+m/, "") + ) + assert_match( + %r{-ALTER TABLE ONLY public.products // #{migration_path(file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end + + it "processes phantom migrations from tmp/migrated folders" do + file_name = "20250124084335_phantom.rb" + run_migration(file_name, phantom_migration) + utils.remove_app_dir(Rails.root.join("db", "migrate", file_name)) + utils.run_migrations + dump_schema + invoke_rake_task("test/dummy_app/db/structure.sql") + assert_match( + %r{\+ email character varying // #{File.join("test/dummy_app/tmp/migrated", file_name)} //}, + TestingState.output.gsub(/\e\[\d+m/, "") + ) + end end - it "annotates adding a column" do - file_name = "20250124084325_add_surname_to_users.rb" - utils.define_migration_file(file_name, <<~RUBY) + def add_surname_to_users_migration + <<~RUBY class AddSurnameToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :surname, :string end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{\+ t\.string "surname" // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates removing a column" do - file_name = "20250124084326_remove_middle_name_from_users.rb" - utils.define_migration_file(file_name, <<~RUBY) + def remove_middle_name_from_users_migration + <<~RUBY class RemoveMiddleNameFromUsers < ActiveRecord::Migration[6.0] def change remove_column :users, :middle_name end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{- t\.string "middle_name" // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates changing a column" do - file_name = "20250124084327_change_price_precision_in_products.rb" - utils.define_migration_file(file_name, <<~RUBY) + def change_price_precision_in_products_migration + <<~RUBY class ChangePricePrecisionInProducts < ActiveRecord::Migration[6.0] def change change_column :products, :price, :decimal, precision: 15, scale: 2 end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{- t\.decimal "price", precision: 10, scale: 2 // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) - assert_match( - %r{\+ t\.decimal "price", precision: 15, scale: 2 // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates renaming a column" do - file_name = "20250124084328_rename_name_to_full_name_in_users.rb" - utils.define_migration_file(file_name, <<~RUBY) + def rename_name_to_full_name_in_users_migration + <<~RUBY class RenameNameToFullNameInUsers < ActiveRecord::Migration[6.0] def change rename_column :users, :name, :full_name end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{- t\.string "name" // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) - assert_match( - %r{\+ t\.string "full_name" // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates adding an index" do - file_name = "20250124084329_add_index_on_users_middle_name.rb" - utils.define_migration_file(file_name, <<~RUBY) + def add_index_on_users_middle_name_migration + <<~RUBY class AddIndexOnUsersMiddleName < ActiveRecord::Migration[6.0] def change add_index :users, :middle_name, name: "index_users_on_middle_name", unique: true end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{\+ t\.index \["middle_name"\], name: "index_users_on_middle_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates removing an index" do - file_name = "20250124084330_remove_index_on_users_name.rb" - utils.define_migration_file(file_name, <<~RUBY) + def remove_index_on_users_name_migration + <<~RUBY class RemoveIndexOnUsersName < ActiveRecord::Migration[6.0] def change remove_index :users, name: "index_users_on_name" end end RUBY - utils.run_migrations - invoke_rake_task - assert_match( - %r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates renaming an index" do - file_name = "20250124084331_rename_index_on_users_name.rb" - utils.define_migration_file(file_name, <<~RUBY) + def rename_index_on_users_name_migration + <<~RUBY class RenameIndexOnUsersName < ActiveRecord::Migration[6.0] def change rename_index :users, "index_users_on_name", "index_users_on_user_name" end end RUBY - utils.run_migrations - invoke_rake_task - assert_match( - %r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) - assert_match( - %r{\+ t\.index \["name"\], name: "index_users_on_user_name", unique: true // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "annotates creating a new table" do - file_name = "20250124084332_create_categories.rb" - utils.define_migration_file(file_name, <<~RUBY) + def create_categories_migration + <<~RUBY class CreateCategories < ActiveRecord::Migration[6.0] def change create_table :categories do |t| @@ -237,45 +447,30 @@ def change end end RUBY + end - utils.run_migrations - invoke_rake_task - assert_match( - %r{\+ create_table "categories", force: :cascade do |t| // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) - - utils.define_migration_file("20250124084333_drop_categories.rb", <<~RUBY) + def drop_categories_migration + <<~RUBY class DropCategories < ActiveRecord::Migration[6.0] def change drop_table :categories end end RUBY - utils.run_migrations end - it "annotates dropping a table" do - file_name = "20250124084334_drop_products_table.rb" - utils.define_migration_file(file_name, <<~RUBY) + def drop_products_table_migration + <<~RUBY class DropProductsTable < ActiveRecord::Migration[6.0] def change drop_table :products end end RUBY - - utils.run_migrations - invoke_rake_task - assert_match( - %r{- create_table "products", force: :cascade do |t| // #{migration_path(file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end - it "processes phantom migrations from tmp/migrated folders" do - file_name = "20250124084335_phantom.rb" - utils.define_migration_file(file_name, <<~RUBY) + def phantom_migration + <<~RUBY class Phantom < ActiveRecord::Migration[6.0] disable_ddl_transaction! @@ -289,14 +484,5 @@ def down end end RUBY - - utils.run_migrations - utils.remove_app_dir(Rails.root.join("db", "migrate", file_name)) - utils.run_migrations - invoke_rake_task - assert_match( - %r{\+ t\.string "email" // #{File.join("test/dummy_app/tmp/migrated", file_name)} //}, - TestingState.output.gsub(/\e\[\d+m/, "") - ) end end