data-migration 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +14 -0
  3. data/.github/dependabot.yml +27 -0
  4. data/.github/workflows/_trunk_check.yml +15 -0
  5. data/.github/workflows/test.yml +42 -0
  6. data/.gitignore +24 -0
  7. data/.ruby-version +1 -0
  8. data/.trunk/.gitignore +9 -0
  9. data/.trunk/configs/.markdownlint.yaml +2 -0
  10. data/.trunk/configs/.shellcheckrc +7 -0
  11. data/.trunk/configs/.yamllint.yaml +7 -0
  12. data/.trunk/trunk.yaml +39 -0
  13. data/.vscode/extensions.json +18 -0
  14. data/.vscode/settings.json +7 -0
  15. data/CHANGELOG.md +3 -0
  16. data/Gemfile +3 -0
  17. data/Gemfile.lock +246 -0
  18. data/LICENSE.md +21 -0
  19. data/README.md +141 -0
  20. data/data-migration.gemspec +44 -0
  21. data/lib/data_migration/config.rb +84 -0
  22. data/lib/data_migration/job.rb +63 -0
  23. data/lib/data_migration/task.rb +127 -0
  24. data/lib/data_migration.rb +37 -0
  25. data/lib/generators/data_migration_generator.rb +13 -0
  26. data/lib/generators/install_generator.rb +17 -0
  27. data/lib/generators/templates/data_migration.rb.tt +26 -0
  28. data/lib/generators/templates/install_data_migration_tasks.rb.tt +27 -0
  29. data/spec/data_migration/config_spec.rb +116 -0
  30. data/spec/data_migration/job_spec.rb +96 -0
  31. data/spec/data_migration/task_spec.rb +152 -0
  32. data/spec/data_migration_spec.rb +65 -0
  33. data/spec/fixtures/data_migrations/20241206200111_create_users.rb +17 -0
  34. data/spec/fixtures/data_migrations/20241206200112_create_bad_users.rb +5 -0
  35. data/spec/fixtures/data_migrations/20241206200113_change_users.rb +5 -0
  36. data/spec/fixtures/data_migrations/20241206200114_create_batch_users.rb +9 -0
  37. data/spec/fixtures/schema.rb +26 -0
  38. data/spec/generators/install_generator_spec.rb +48 -0
  39. data/spec/generators/migration_generator_spec.rb +50 -0
  40. data/spec/rails_helper.rb +21 -0
  41. data/spec/spec_helper.rb +30 -0
  42. data/spec/support/junit_formatter.rb +6 -0
  43. data/spec/support/rails_helpers.rb +53 -0
  44. data/usr/bin/release.sh +35 -0
  45. metadata +246 -0
@@ -0,0 +1,44 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.name = "data-migration"
3
+ gem.version = File.read(File.expand_path("../lib/data_migration.rb", __FILE__)).match(/VERSION\s*=\s*"(.*?)"/)[1]
4
+
5
+ repository_url = "https://github.com/amkisko/data-migration.rb"
6
+ root_files = %w[CHANGELOG.md LICENSE.md README.md]
7
+ root_files << "#{gem.name}.gemspec"
8
+
9
+ gem.license = "MIT"
10
+
11
+ gem.platform = Gem::Platform::RUBY
12
+
13
+ gem.authors = ["Andrei Makarov"]
14
+ gem.email = ["andrei@kiskolabs.com"]
15
+ gem.homepage = repository_url
16
+ gem.summary = "Data migrations kit for ActiveRecord and ActiveJob"
17
+ gem.description = gem.summary
18
+ gem.metadata = {
19
+ "homepage" => repository_url,
20
+ "source_code_uri" => repository_url,
21
+ "bug_tracker_uri" => "#{repository_url}/issues",
22
+ "changelog_uri" => "#{repository_url}/blob/main/CHANGELOG.md",
23
+ "rubygems_mfa_required" => "true"
24
+ }
25
+
26
+ gem.files = `git ls-files`.split("\n")
27
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
28
+ gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
29
+
30
+ gem.required_ruby_version = ">= 3"
31
+ gem.require_paths = ["lib"]
32
+
33
+ gem.add_dependency "rails", "> 5"
34
+ gem.add_dependency "activejob", "> 5"
35
+ gem.add_dependency "activerecord", "> 5"
36
+ gem.add_dependency "activesupport", "> 5"
37
+
38
+ gem.add_development_dependency "bundler", "~> 2"
39
+ gem.add_development_dependency "rspec", "~> 3"
40
+ gem.add_development_dependency "rspec_junit_formatter", "~> 0.6"
41
+ gem.add_development_dependency "simplecov", "~> 0.21"
42
+ gem.add_development_dependency "simplecov-cobertura", "~> 2"
43
+ gem.add_development_dependency "sqlite3", "~> 2.4"
44
+ end
@@ -0,0 +1,84 @@
1
+ module DataMigration
2
+ class Config
3
+ def schema_migrations_path
4
+ @schema_migrations_path ||= "db/migrate"
5
+ end
6
+
7
+ attr_writer :data_migrations_path
8
+ def data_migrations_path
9
+ @data_migrations_path ||= "db/data_migrations"
10
+ end
11
+
12
+ attr_writer :data_migrations_full_path
13
+ def data_migrations_full_path
14
+ @data_migrations_full_path ||= Rails.root.join(data_migrations_path)
15
+ end
16
+
17
+ def data_migrations_path_glob
18
+ "#{data_migrations_full_path}/*.rb"
19
+ end
20
+
21
+ attr_writer :generate_spec
22
+ def generate_spec?
23
+ @generate_spec.nil? ? true : @generate_spec
24
+ end
25
+
26
+ attr_writer :job_class
27
+ def job_class
28
+ @job_class ||= DataMigration::Job
29
+ end
30
+
31
+ attr_writer :task_class
32
+ def task_class
33
+ @task_class ||= DataMigration::Task
34
+ end
35
+
36
+ attr_writer :operator_resolver
37
+ def operator_resolver(&block)
38
+ if block_given?
39
+ @operator_resolver = block
40
+ else
41
+ @operator_resolver ||= -> do
42
+ if Object.const_defined?(:ActionReporter)
43
+ ActionReporter.current_user
44
+ elsif Object.const_defined?(:Audited)
45
+ Audited.store[:audited_user]
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ attr_writer :monitoring_context
52
+ def monitoring_context(&block)
53
+ if block_given?
54
+ @monitoring_context = block
55
+ else
56
+ @monitoring_context ||= ->(migration) do
57
+ context = {
58
+ data_migration_name: migration.name,
59
+ data_migration_id: migration.try(:to_global_id) || migration.id,
60
+ data_migration_operator_id: migration.operator&.try(:to_global_id) || migration.operator&.id
61
+ }
62
+ if Object.const_defined?(:ActionReporter)
63
+ ActionReporter.current_user ||= migration.operator
64
+ ActionReporter.context(**context)
65
+ elsif Object.const_defined?(:Audited)
66
+ Audited.store[:audited_user] ||= migration.operator
67
+ else
68
+ Rails.logger.info("Data migration context: #{context.inspect}")
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ attr_writer :job_queue_name
75
+ def job_queue_name
76
+ @job_queue_name ||= :default
77
+ end
78
+
79
+ attr_writer :default_jobs_limit
80
+ def default_jobs_limit
81
+ @default_jobs_limit ||= 10
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,63 @@
1
+ require "active_job"
2
+
3
+ module DataMigration
4
+ class Job < ActiveJob::Base
5
+ queue_as { DataMigration.config.job_queue_name }
6
+
7
+ discard_on StandardError
8
+
9
+ def perform(task_id, *job_args, **job_kwargs)
10
+ task = DataMigration::Task.find(task_id)
11
+ DataMigration.config.monitoring_context.call(task)
12
+
13
+ migration_name = task.name
14
+ migration_path = task.file_path
15
+
16
+ unless task.file_exists?
17
+ DataMigration.notify("#{migration_name} not found")
18
+ return
19
+ end
20
+
21
+ task.job_check_in!(job_id, job_args:, job_kwargs:)
22
+
23
+ require migration_path
24
+ klass_name = migration_name.gsub(/^[0-9_]+/, "").camelize
25
+ klass = klass_name.safe_constantize
26
+ raise "Data migration class #{klass_name} not found" unless klass.is_a?(Class)
27
+ raise "Data migration class #{klass_name} must implement `perform` method" unless klass.method_defined?(:perform)
28
+
29
+ if task.started_at.blank?
30
+ task.update!(started_at: Time.current, status: :started)
31
+ end
32
+
33
+ if task.requires_pause?
34
+ DataMigration::Job.set(wait: task.pause_minutes.minutes).perform_later(task_id, *job_args, **job_kwargs)
35
+ task.update!(status: :paused)
36
+ return
37
+ end
38
+
39
+ Thread.current[:data_migration_enqueue_called] ||= {}
40
+ Thread.current[:data_migration_enqueue_kwargs] ||= {}
41
+ klass.define_method(:enqueue) do |**enqueue_kwargs|
42
+ Thread.current[:data_migration_enqueue_called][klass.name] = true
43
+ Thread.current[:data_migration_enqueue_kwargs][klass.name] = enqueue_kwargs
44
+ end
45
+
46
+ task.update!(status: :performing, pause_minutes: 0)
47
+ klass.new.perform(**job_kwargs)
48
+ task.job_check_out!(job_id)
49
+
50
+ enqueue_called = Thread.current[:data_migration_enqueue_called].delete(klass.name)
51
+ enqueue_kwargs = Thread.current[:data_migration_enqueue_kwargs].delete(klass.name)
52
+ if enqueue_called
53
+ if enqueue_kwargs[:background] == false
54
+ self.class.new.perform(task_id, *job_args, **enqueue_kwargs)
55
+ else
56
+ DataMigration::Job.perform_later(task_id, *job_args, **enqueue_kwargs)
57
+ end
58
+ else
59
+ task.update!(completed_at: Time.current, status: :completed)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,127 @@
1
+ require "active_record"
2
+
3
+ module DataMigration
4
+ class JobConcurrencyLimitError < StandardError; end
5
+ class JobConflictError < StandardError; end
6
+
7
+ def self.tasks_table_name
8
+ "data_migration_tasks"
9
+ end
10
+
11
+ class Task < ActiveRecord::Base
12
+ self.table_name = DataMigration.tasks_table_name
13
+
14
+ belongs_to :operator, polymorphic: true, optional: true
15
+
16
+ before_validation do
17
+ self.operator ||= DataMigration.config.operator_resolver.call
18
+ self.jobs_limit ||= DataMigration.config.default_jobs_limit
19
+ end
20
+
21
+ enum :status, {
22
+ started: "started",
23
+ performing: "performing",
24
+ paused: "paused",
25
+ completed: "completed"
26
+ }
27
+
28
+ validates :name, presence: true
29
+ validates :pause_minutes, numericality: { greater_than_or_equal_to: 0, only_integer: true }, if: -> { pause_minutes.present? }
30
+ validates :jobs_limit, numericality: { greater_than_or_equal_to: 0, only_integer: true }, if: -> { jobs_limit.present? }
31
+ validate :file_should_exist
32
+
33
+ after_save do
34
+ if saved_change_to_started_at? && started_at.present?
35
+ DataMigration.notify("#{user_title} started")
36
+ end
37
+ if saved_change_to_completed_at? && completed_at.present?
38
+ DataMigration.notify("#{user_title} finished")
39
+ end
40
+ end
41
+
42
+ scope :not_started, -> { where(status: nil, started_at: nil) }
43
+ scope :started, -> { where(status: :started) }
44
+ scope :paused, -> { where(status: :paused) }
45
+ scope :performing, -> { where(status: :performing) }
46
+ scope :completed, -> { where(status: :completed) }
47
+
48
+ def self.job_class
49
+ DataMigration.config.job_class
50
+ end
51
+
52
+ def self.perform_now(name, **kwargs)
53
+ create!(name:).perform_now(**kwargs)
54
+ end
55
+
56
+ def perform_now(**perform_args)
57
+ update!(kwargs: perform_args)
58
+ self.class.job_class.perform_now(id, **perform_args)
59
+ end
60
+
61
+ def self.perform_later(name, **kwargs)
62
+ create!(name:).perform_later(**kwargs)
63
+ end
64
+
65
+ def perform_later(**perform_args)
66
+ update!(kwargs: perform_args)
67
+ self.class.job_class.perform_later(id, **perform_args)
68
+ end
69
+
70
+ def self.prepare(name, pause_minutes: nil, jobs_limit: nil)
71
+ create!(name:, pause_minutes:, jobs_limit:)
72
+ end
73
+
74
+ def self.root_path
75
+ DataMigration.config.data_migrations_full_path
76
+ end
77
+
78
+ def self.list
79
+ Dir[DataMigration.config.data_migrations_path_glob].map { |f| File.basename(f, ".*") }
80
+ end
81
+
82
+ def file_path
83
+ "#{self.class.root_path}/#{name}.rb"
84
+ end
85
+
86
+ def file_exists?
87
+ self.class.list.include?(name)
88
+ end
89
+
90
+ def not_started?
91
+ status.nil? && started_at.nil?
92
+ end
93
+
94
+ def job_check_in!(job_id, job_args: [], job_kwargs: {})
95
+ self.current_jobs ||= {}
96
+
97
+ raise DataMigration::JobConflictError, "#{user_title} already has job ##{job_id}" if current_jobs.key?(job_id)
98
+ raise DataMigration::JobConcurrencyLimitError, "#{user_title} reached limit of #{jobs_limit} jobs" if jobs_limit.present? && current_jobs.size >= jobs_limit
99
+
100
+ self.current_jobs[job_id] = {
101
+ ts: Time.current,
102
+ args: job_args,
103
+ kwargs: job_kwargs
104
+ }
105
+ save!
106
+ end
107
+
108
+ def job_check_out!(job_id)
109
+ self.current_jobs.delete(job_id)
110
+ save!
111
+ end
112
+
113
+ def user_title
114
+ "Data migration ##{id} #{name}"
115
+ end
116
+
117
+ def requires_pause?
118
+ pause_minutes.positive? && !paused?
119
+ end
120
+
121
+ private
122
+
123
+ def file_should_exist
124
+ errors.add(:name, "is not found") unless file_exists?
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,37 @@
1
+ require "data_migration/config"
2
+ require "data_migration/job"
3
+ require "data_migration/task"
4
+
5
+ module DataMigration
6
+ VERSION = "1.0.0".freeze
7
+
8
+ module_function
9
+
10
+ def config
11
+ @@config ||= DataMigration::Config.new
12
+ end
13
+
14
+ def configure
15
+ yield config
16
+ end
17
+
18
+ def notify(message, context: {})
19
+ if Object.const_defined?(:ActionReporter)
20
+ ActionReporter.notify(message, context:)
21
+ elsif Object.const_defined?(:Rails)
22
+ Rails.logger.info("#{message} #{context.inspect}")
23
+ end
24
+ end
25
+
26
+ def perform_now(...)
27
+ DataMigration::Task.perform_now(...)
28
+ end
29
+
30
+ def perform_later(...)
31
+ DataMigration::Task.perform_later(...)
32
+ end
33
+
34
+ def prepare(...)
35
+ DataMigration::Task.prepare(...)
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "rails/generators/active_record/migration/migration_generator"
4
+
5
+ class DataMigrationGenerator < ActiveRecord::Generators::MigrationGenerator
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def create_migration_file
9
+ set_local_assigns!
10
+ validate_file_name!
11
+ migration_template "data_migration.rb", "#{DataMigration.config.data_migrations_path}/#{file_name}.rb"
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "rails/generators/active_record/migration/migration_generator"
4
+
5
+ class InstallGenerator < ActiveRecord::Generators::MigrationGenerator
6
+ source_root File.expand_path("../templates", __FILE__)
7
+
8
+ def create_migration_file
9
+ set_local_assigns!
10
+ validate_file_name!
11
+ migration_template "install_#{name}.rb", "#{DataMigration.config.schema_migrations_path}/#{file_name}.rb"
12
+ end
13
+
14
+ def migration_parent
15
+ "ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]"
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # USAGE: within remote console run one of the following commands
2
+ # - rails db:migrate:data <%= migration_number %>_<%= migration_file_name %>
3
+ # - DataMigration.perform_now("<%= migration_number %>_<%= migration_file_name %>")
4
+ # - DataMigration.perform_later("<%= migration_number %>_<%= migration_file_name %>")
5
+ # - DataMigration.prepare("<%= migration_number %>_<%= migration_file_name %>", pause: 1.minute, jobs_limit: 2).perform_later
6
+
7
+ class <%= migration_class_name %>
8
+ def perform(**kwargs)
9
+
10
+ end
11
+ end
12
+ <%- if DataMigration.config.generate_spec? %>
13
+ # TESTING:
14
+ # - bin/rspec <%= DataMigration.config.data_migrations_path %>/<%= migration_number %>_<%= migration_file_name %>.rb
15
+ if Rails.env.test? && Object.const_defined?(:RSpec)
16
+ require "rails_helper"
17
+
18
+ RSpec.describe <%= migration_class_name %>, type: :data_migration do
19
+ subject(:perform) { described_class.new.perform }
20
+
21
+ it do
22
+ expect { perform }.not_to raise_error
23
+ end
24
+ end
25
+ end
26
+ <%- end %>
@@ -0,0 +1,27 @@
1
+ class <%= migration_class_name %> < <%= migration_parent %>
2
+ def self.up
3
+ create_table :<%= DataMigration.tasks_table_name %>, force: true do |t|
4
+ t.string "name", null: false
5
+
6
+ t.jsonb "kwargs", default: {}, null: false
7
+ t.jsonb "current_jobs", default: {}, null: false
8
+
9
+ t.string "status"
10
+ t.datetime "started_at"
11
+ t.datetime "completed_at"
12
+
13
+ t.bigint "operator_id"
14
+ t.string "operator_type"
15
+
16
+ t.integer "pause_minutes", default: 0, null: false
17
+ t.integer "jobs_limit"
18
+
19
+ t.datetime "created_at", null: false
20
+ t.datetime "updated_at", null: false
21
+ end
22
+ end
23
+
24
+ def self.down
25
+ drop_table :<%= DataMigration.tasks_table_name %>
26
+ end
27
+ end
@@ -0,0 +1,116 @@
1
+ require "spec_helper"
2
+
3
+ describe DataMigration::Config do
4
+ subject(:config) { described_class.new }
5
+
6
+ let(:current_user) do
7
+ User.new(id: 1)
8
+ end
9
+ let(:action_reporter) do
10
+ Class.new do
11
+ def current_user
12
+ User.new(id: 3)
13
+ end
14
+
15
+ def context(**kwargs)
16
+ kwargs
17
+ end
18
+ end.new
19
+ end
20
+ let(:audited) do
21
+ Class.new do
22
+ def store
23
+ { audited_user: User.new(id: 4) }
24
+ end
25
+ end.new
26
+ end
27
+ let(:logger) do
28
+ Class.new do
29
+ def info(message)
30
+ puts message
31
+ end
32
+ end.new
33
+ end
34
+ let(:other_user) do
35
+ User.new(id: 2)
36
+ end
37
+ let(:migration) do
38
+ DataMigration::Task.new(id: 1, name: "test", operator: other_user)
39
+ end
40
+
41
+ before do
42
+ allow(Rails).to receive(:root).and_return(rails_root)
43
+ allow(Rails).to receive(:logger).and_return(logger)
44
+ end
45
+
46
+ it "has default schema migrations path" do
47
+ expect(config.schema_migrations_path).to eq "db/migrate"
48
+ end
49
+
50
+ it "has default data migrations path" do
51
+ expect(config.data_migrations_path).to eq "db/data_migrations"
52
+ end
53
+
54
+ it "has default data migrations full path" do
55
+ expect(config.data_migrations_full_path).to eq Rails.root.join("db/data_migrations")
56
+ end
57
+
58
+ it "has default data migrations path glob" do
59
+ expect(config.data_migrations_path_glob).to eq Rails.root.join("db/data_migrations/*.rb").to_s
60
+ end
61
+
62
+ it "has default generate spec" do
63
+ expect(config.generate_spec?).to be true
64
+ end
65
+
66
+ it "has default task class" do
67
+ expect(config.task_class).to eq DataMigration::Task
68
+ end
69
+
70
+ it "has default job class" do
71
+ expect(config.job_class).to eq DataMigration::Job
72
+ end
73
+
74
+ it "has default job queue name" do
75
+ expect(config.job_queue_name).to eq :default
76
+ end
77
+
78
+ it "has default default jobs limit" do
79
+ expect(config.default_jobs_limit).to eq 10
80
+ end
81
+
82
+ it "has default monitoring context" do
83
+ expect(config.monitoring_context).to be_a Proc
84
+
85
+ expect(Rails.logger).to receive(:info).with("Data migration context: {:data_migration_name=>\"test\", :data_migration_id=>1, :data_migration_operator_id=>2}")
86
+ expect { config.monitoring_context.call(migration) }.not_to raise_error
87
+ end
88
+
89
+ context "when ActionReporter is defined" do
90
+ before do
91
+ stub_const("ActionReporter", action_reporter)
92
+ end
93
+
94
+ it "sets monitoring context" do
95
+ expect(ActionReporter).to receive(:context).with(
96
+ data_migration_name: "test",
97
+ data_migration_id: 1,
98
+ data_migration_operator_id: 2
99
+ )
100
+ expect(ActionReporter).to receive(:current_user).and_return(nil)
101
+ expect(ActionReporter).to receive(:current_user=).with(other_user)
102
+ expect { config.monitoring_context.call(migration) }.not_to raise_error
103
+ end
104
+ end
105
+
106
+ context "when Audited is defined" do
107
+ before do
108
+ stub_const("Audited", audited)
109
+ end
110
+
111
+ it "sets monitoring context" do
112
+ expect(Audited).to receive(:store).and_return(audited_user: User.new(id: 5))
113
+ expect { config.monitoring_context.call(migration) }.not_to raise_error
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,96 @@
1
+ require "spec_helper"
2
+
3
+ describe DataMigration::Job do
4
+ subject(:job) { DataMigration::Job.new }
5
+
6
+ let(:operator) { User.create!(email: "test@example.com") }
7
+ let(:task) { DataMigration::Task.create!(name: migration_name, operator: operator) }
8
+
9
+ let(:migration_name) { "20241206200111_create_users" }
10
+ let(:data_migrations_full_path) { File.expand_path("../fixtures/data_migrations", __dir__) }
11
+
12
+ before do
13
+ allow(Rails).to receive(:env).and_return(rails_env)
14
+ allow(Rails).to receive(:logger).and_return(rails_logger)
15
+ allow(DataMigration.config).to receive(:data_migrations_full_path).and_return(data_migrations_full_path)
16
+ end
17
+
18
+ it "runs rspec spec/fixtures/data_migrations/20241206200111_create_users.rb" do
19
+ output = `RUN_MIGRATION_TESTS=1 rspec #{data_migrations_full_path}/#{migration_name}.rb`
20
+ expect(output).to include("1 example, 0 failures")
21
+ end
22
+
23
+ describe "#perform" do
24
+ subject(:perform) { job.perform(task.id, **job_kwargs) }
25
+
26
+ let(:job_kwargs) { {} }
27
+
28
+ it "updates the task status to completed" do
29
+ expect { perform }.to change { task.reload.status }.to("completed")
30
+ end
31
+
32
+ context "when migration file is not found" do
33
+ before do
34
+ task
35
+ allow_any_instance_of(DataMigration::Task).to receive(:file_exists?).and_return(false)
36
+ end
37
+
38
+ it "updates the task status to failed" do
39
+ expect(DataMigration).to receive(:notify).with("#{migration_name} not found")
40
+ expect { perform }.not_to raise_error
41
+ end
42
+ end
43
+
44
+ context "when migration class is not found" do
45
+ let(:migration_name) { "20241206200112_create_bad_users" }
46
+
47
+ it "raises an error" do
48
+ expect { perform }.to raise_error("Data migration class #{migration_name.gsub(/^[0-9_]+/, "").camelize} not found")
49
+ end
50
+ end
51
+
52
+ context "when migration class does not implement perform method" do
53
+ let(:migration_name) { "20241206200113_change_users" }
54
+
55
+ it "raises an error" do
56
+ expect { perform }.to raise_error("Data migration class #{migration_name.gsub(/^[0-9_]+/, "").camelize} must implement `perform` method")
57
+ end
58
+ end
59
+
60
+ context "when there is an enqueue call" do
61
+ let(:migration_name) { "20241206200114_create_batch_users" }
62
+
63
+ it "runs the migration in foreground" do
64
+ expect { perform }.to change { User.count }.by(3)
65
+ expect(task.reload.current_jobs.count).to eq(0)
66
+ expect(task.status).to eq("completed")
67
+ expect(User.pluck(:email)).to match_array(["test@example.com", "test_1@example.com", "test_2@example.com"])
68
+ end
69
+
70
+ context "when background is true" do
71
+ let(:job_kwargs) { { background: true } }
72
+
73
+ before do
74
+ operator
75
+ end
76
+
77
+ it "runs the migration in background" do
78
+ expect { perform }.to change(User, :count).by(1)
79
+ expect(task.reload.status).to eq("performing")
80
+ expect(task.current_jobs.count).to eq(0)
81
+ expect(task.kwargs).to eq({})
82
+
83
+ expect { job.perform(task.id, index: 2, background: true) }.to change(User, :count).by(1)
84
+ expect(task.reload.status).to eq("performing")
85
+ expect(task.reload.current_jobs.count).to eq(0)
86
+ expect(task.kwargs).to eq({})
87
+
88
+ expect { job.perform(task.id, index: 3, background: true) }.to change(User, :count).by(0)
89
+ expect(task.reload.status).to eq("completed")
90
+ expect(task.reload.current_jobs.count).to eq(0)
91
+ expect(task.kwargs).to eq({})
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end