data-migration 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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