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,152 @@
1
+ require "spec_helper"
2
+
3
+ describe DataMigration::Task do
4
+ subject(:task) { DataMigration::Task.create!(name: migration_name) }
5
+
6
+ let(:migration_name) { "20241206200111_create_users" }
7
+ let(:data_migrations_full_path) { File.expand_path("../fixtures/data_migrations", __dir__) }
8
+
9
+ before do
10
+ # allow(Rails).to receive(:root).and_return(rails_root)
11
+ # allow(Rails).to receive(:logger).and_return(rails_logger)
12
+ allow(DataMigration.config).to receive(:data_migrations_full_path).and_return(data_migrations_full_path)
13
+ end
14
+
15
+ describe "#file_path" do
16
+ it "returns the full path to the migration file" do
17
+ expect(task.file_path).to eq("#{data_migrations_full_path}/#{migration_name}.rb")
18
+ end
19
+ end
20
+
21
+ describe "#file_exists?" do
22
+ it "returns true if the file exists" do
23
+ expect(task.file_exists?).to be(true)
24
+ expect(task.valid?).to be(true)
25
+ end
26
+
27
+ context "when the file does not exist" do
28
+ let(:migration_name) { "20241206200111_create_something_else" }
29
+ let(:task) { DataMigration::Task.new(name: migration_name) }
30
+
31
+ it "returns false" do
32
+ expect(task.file_exists?).to be(false)
33
+ expect(task.valid?).to be(false)
34
+ end
35
+ end
36
+ end
37
+
38
+ describe "#requires_pause?" do
39
+ before do
40
+ task.status = :started
41
+ task.pause_minutes = 10
42
+ end
43
+
44
+ it "returns true if pause_minutes is positive and status is not paused" do
45
+ expect(task.requires_pause?).to be(true)
46
+ end
47
+
48
+ context "when pause_minutes is 0" do
49
+ before do
50
+ task.pause_minutes = 0
51
+ end
52
+
53
+ it "returns false" do
54
+ expect(task.requires_pause?).to be(false)
55
+ end
56
+ end
57
+
58
+ context "when status is paused" do
59
+ before do
60
+ task.status = :paused
61
+ end
62
+
63
+ it "returns false" do
64
+ expect(task.requires_pause?).to be(false)
65
+ end
66
+ end
67
+ end
68
+
69
+ describe "#perform_now" do
70
+ it "calls the job class with the task and arguments" do
71
+ kwargs = { foo: "bar" }
72
+ expect(DataMigration::Job).to receive(:perform_now).with(task.id, **kwargs)
73
+ task.perform_now(**kwargs)
74
+ end
75
+ end
76
+
77
+ describe "#perform_later" do
78
+ it "calls the job class with the task and arguments" do
79
+ kwargs = { foo: "bar" }
80
+ expect(DataMigration::Job).to receive(:perform_later).with(task.id, **kwargs)
81
+ task.perform_later(**kwargs)
82
+ end
83
+ end
84
+
85
+ describe "#prepare" do
86
+ subject(:task) { DataMigration::Task.prepare(migration_name, pause_minutes: 10, jobs_limit: 5) }
87
+
88
+ it "creates a new task with the given name, pause, and jobs_limit" do
89
+ expect { task }.to change(DataMigration::Task, :count).by(1)
90
+ expect(task.name).to eq(migration_name)
91
+ expect(task.status).to be_nil
92
+ expect(task.pause_minutes).to eq(10)
93
+ expect(task.jobs_limit).to eq(5)
94
+ end
95
+
96
+ context "when chained with perform_now" do
97
+ it "calls the job class with the task and arguments" do
98
+ perform_args = { "foo" => "bar" }
99
+ expect(DataMigration::Job).to receive(:perform_now).with(task.id, **perform_args)
100
+ task.perform_now(**perform_args)
101
+ expect(task.kwargs).to eq(perform_args)
102
+ end
103
+ end
104
+ end
105
+
106
+ describe "#job_check_in!" do
107
+ subject(:job_check_in!) { task.job_check_in!(job_id, job_args: ["foo"], job_kwargs: { bar: "baz" }) }
108
+
109
+ let(:job_id) { "123" }
110
+
111
+ it "adds the job to the current_jobs hash" do
112
+ expect { job_check_in! }.to change { task.current_jobs.size }.by(1)
113
+ end
114
+
115
+ context "when there is a job with the same id" do
116
+ before do
117
+ task.job_check_in!(job_id, job_args: ["foo"], job_kwargs: { bar: "baz" })
118
+ end
119
+
120
+ it "raises a JobConflictError" do
121
+ expect { job_check_in! }.to raise_error(DataMigration::JobConflictError)
122
+ end
123
+ end
124
+
125
+ context "when default_jobs_limit is 1 and there are jobs" do
126
+ before do
127
+ DataMigration.config.default_jobs_limit = 1
128
+ task.job_check_in!("321", job_args: ["foo"], job_kwargs: { bar: "baz" })
129
+ end
130
+
131
+ it "raises a JobConcurrencyLimitError" do
132
+ expect { job_check_in! }.to raise_error(DataMigration::JobConcurrencyLimitError)
133
+ end
134
+ end
135
+ end
136
+
137
+ describe "#job_check_out!" do
138
+ subject(:job_check_out!) { task.job_check_out!(job_id) }
139
+
140
+ let(:job_id) { "123" }
141
+
142
+ before do
143
+ task.save!
144
+ task.job_check_in!(job_id, job_args: ["foo"], job_kwargs: { bar: "baz" })
145
+ end
146
+
147
+ it "removes the job from the current_jobs hash" do
148
+ expect { job_check_out! }.to change { task.current_jobs.size }.by(-1)
149
+ expect(task.current_jobs).not_to have_key(job_id)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,65 @@
1
+ require "spec_helper"
2
+
3
+ describe DataMigration do
4
+ let(:gem_specification) { Gem::Specification.load(File.expand_path("../../data-migration.gemspec", __FILE__)) }
5
+
6
+ it "has a version number" do
7
+ expect(described_class::VERSION).to eq gem_specification.version.to_s
8
+ end
9
+
10
+ let(:changelog_file) { File.expand_path("../../CHANGELOG.md", __FILE__) }
11
+ it "has changelog for the version" do
12
+ expect(File.exist?(changelog_file)).to be true
13
+ expect(File.read(changelog_file)).to include("# #{gem_specification.version}")
14
+ end
15
+
16
+ let(:license_file) { File.expand_path("../../LICENSE.md", __FILE__) }
17
+ it "has license" do
18
+ expect(File.exist?(license_file)).to be true
19
+ end
20
+
21
+ let(:readme_file) { File.expand_path("../../README.md", __FILE__) }
22
+ it "has readme" do
23
+ expect(File.exist?(readme_file)).to be true
24
+ end
25
+
26
+ describe ".notify" do
27
+ subject(:notify) { DataMigration.notify("test") }
28
+ let(:action_reporter) { Class.new }
29
+ before do
30
+ stub_const("ActionReporter", action_reporter)
31
+ end
32
+
33
+ it "sends message to ActionReporter" do
34
+ expect(action_reporter).to receive(:notify).with("test", context: {})
35
+ expect { notify }.not_to raise_error
36
+ end
37
+ end
38
+
39
+ describe ".perform_now" do
40
+ subject(:perform_now) { DataMigration.perform_now("test_perform_now") }
41
+
42
+ it "delegates to DataMigration::Task.perform_now" do
43
+ expect(DataMigration::Task).to receive(:perform_now).with("test_perform_now")
44
+ expect { perform_now }.not_to raise_error
45
+ end
46
+ end
47
+
48
+ describe ".perform_later" do
49
+ subject(:perform_later) { DataMigration.perform_later("test_perform_later") }
50
+
51
+ it "delegates to DataMigration::Task.perform_later" do
52
+ expect(DataMigration::Task).to receive(:perform_later).with("test_perform_later")
53
+ expect { perform_later }.not_to raise_error
54
+ end
55
+ end
56
+
57
+ describe ".prepare" do
58
+ subject(:prepare) { DataMigration.prepare("test_prepare") }
59
+
60
+ it "delegates to DataMigration::Task.prepare" do
61
+ expect(DataMigration::Task).to receive(:prepare).with("test_prepare")
62
+ expect { prepare }.not_to raise_error
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,17 @@
1
+ class CreateUsers
2
+ def perform(**kwargs)
3
+ User.find_or_create_by(email: "test@example.com")
4
+ end
5
+ end
6
+
7
+ if ENV["RUN_MIGRATION_TESTS"] && Object.const_defined?(:RSpec)
8
+ require "rails_helper"
9
+
10
+ RSpec.describe CreateUsers, type: :data_migration do
11
+ subject(:perform) { described_class.new.perform }
12
+
13
+ it do
14
+ expect { perform }.not_to raise_error
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ class CreateGoodUsers
2
+ def perform(**kwargs)
3
+ User.find_or_create_by(email: "test@example.com")
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class ChangeUsers
2
+ def change(**kwargs)
3
+ User.find_or_create_by(email: "test@example.com")
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ class CreateBatchUsers
2
+ def perform(index: 1, background: false)
3
+ return if index > 2
4
+
5
+ User.find_or_create_by(email: "test_#{index}@example.com")
6
+
7
+ enqueue(index: index + 1, background:)
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ ActiveRecord::Schema.define(version: 2024_12_06_200411) do
2
+ create_table :data_migration_tasks, force: true do |t|
3
+ t.string "name", null: false
4
+
5
+ t.json "kwargs", default: {}, null: false
6
+ t.json "current_jobs", default: {}, null: false
7
+
8
+ t.string "status"
9
+ t.datetime "started_at"
10
+ t.datetime "completed_at"
11
+
12
+ t.bigint "operator_id"
13
+ t.string "operator_type"
14
+
15
+ t.integer "pause_minutes", default: 0, null: false
16
+ t.integer "jobs_limit"
17
+
18
+ t.datetime "created_at", null: false
19
+ t.datetime "updated_at", null: false
20
+ end
21
+
22
+ create_table :users, force: true do |t|
23
+ t.string :email
24
+ t.string :version
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ require "spec_helper"
2
+
3
+ require "generators/install_generator"
4
+
5
+ describe InstallGenerator, type: :generator do
6
+ include FileUtils
7
+
8
+ subject(:generator) { described_class.start params }
9
+
10
+ let(:root_path) { rails_root(File.expand_path("../../../tmp/rspec", __FILE__)) }
11
+
12
+ let(:migration_name) { "data_migration_tasks" }
13
+ let(:params) { [migration_name] }
14
+ let(:created_files) { Dir["#{root_path}/db/migrate/*_#{migration_name}.rb"] }
15
+ let(:migration_content) { File.readlines(created_files.first).reject(&:blank?).map(&:strip) }
16
+
17
+ before do
18
+ mkdir_p root_path.to_s
19
+ allow(DataMigration.config).to receive(:schema_migrations_path).and_return("#{root_path}/db/migrate")
20
+ allow(Rails).to receive(:root).and_return(root_path)
21
+ end
22
+
23
+ after do
24
+ rm_rf root_path.to_s
25
+ end
26
+
27
+ it "creates a migration file" do
28
+ generator
29
+
30
+ expect(created_files).not_to be_empty
31
+
32
+ expect(migration_content).to include("create_table :data_migration_tasks, force: true do |t|")
33
+ end
34
+
35
+ context "when tasks_table_name is not default" do
36
+ before do
37
+ allow(DataMigration).to receive(:tasks_table_name).and_return("other_data_migration_tasks")
38
+ end
39
+
40
+ it "creates a migration file with the correct table name" do
41
+ generator
42
+
43
+ expect(created_files).not_to be_empty
44
+
45
+ expect(migration_content).to include("create_table :other_data_migration_tasks, force: true do |t|")
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ require "spec_helper"
2
+
3
+ require "generators/data_migration_generator"
4
+
5
+ describe DataMigrationGenerator, type: :generator do
6
+ include FileUtils
7
+
8
+ subject(:generator) { described_class.start params }
9
+
10
+ let(:root_path) { rails_root(File.expand_path("../../../tmp/rspec", __FILE__)) }
11
+
12
+ let(:migration_name) { "create_users" }
13
+ let(:params) { [migration_name] }
14
+ let(:created_files) { Dir["#{root_path}/db/data_migrations/*_#{migration_name}.rb"] }
15
+ let(:migration_content) { File.readlines(created_files.first).reject(&:blank?).map(&:strip) }
16
+
17
+ before do
18
+ mkdir_p root_path.to_s
19
+ allow(DataMigration.config).to receive(:data_migrations_path).and_return("#{root_path}/db/data_migrations")
20
+ allow(Rails).to receive(:root).and_return(root_path)
21
+ end
22
+
23
+ after do
24
+ rm_rf root_path.to_s
25
+ end
26
+
27
+ it "creates a migration file" do
28
+ generator
29
+
30
+ expect(created_files).not_to be_empty
31
+
32
+ expect(migration_content).to include("class CreateUsers")
33
+ expect(migration_content).to include("def perform(**kwargs)")
34
+ expect(migration_content).to include("RSpec.describe CreateUsers, type: :data_migration do")
35
+ end
36
+
37
+ context "when generate_spec is false" do
38
+ before do
39
+ DataMigration.config.generate_spec = false
40
+ end
41
+
42
+ it "does not add RSpec describe block" do
43
+ generator
44
+
45
+ expect(created_files).not_to be_empty
46
+
47
+ expect(migration_content).not_to include("RSpec.describe CreateUsers, type: :data_migration do")
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ require "active_record"
2
+
3
+ require "data_migration"
4
+
5
+ require "support/rails_helpers"
6
+
7
+ RSpec.configure do |config|
8
+ include RailsHelpers
9
+
10
+ config.before(:suite) do
11
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
12
+ load File.expand_path("../fixtures/schema.rb", __FILE__)
13
+ end
14
+
15
+ config.before(:each) do
16
+ tables = ActiveRecord::Base.connection.tables
17
+ tables.each do |table|
18
+ ActiveRecord::Base.connection.execute("DELETE FROM #{table}")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ require "simplecov"
2
+ SimpleCov.start do
3
+ add_filter "/spec/"
4
+ add_filter { |source_file| source_file.lines.count < 5 }
5
+ end
6
+
7
+ require "simplecov-cobertura"
8
+ SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
9
+
10
+ require "active_record"
11
+
12
+ require "data_migration"
13
+
14
+ Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require_relative f }
15
+
16
+ RSpec.configure do |config|
17
+ include RailsHelpers
18
+
19
+ config.before(:suite) do
20
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
21
+ load File.expand_path("../fixtures/schema.rb", __FILE__)
22
+ end
23
+
24
+ config.before(:each) do
25
+ tables = ActiveRecord::Base.connection.tables
26
+ tables.each do |table|
27
+ ActiveRecord::Base.connection.execute("DELETE FROM #{table}")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,6 @@
1
+ if ENV["CI"]
2
+ require "rspec_junit_formatter"
3
+ RSpec.configure do |config|
4
+ config.add_formatter RspecJunitFormatter, "coverage/junit-coverage.xml"
5
+ end
6
+ end
@@ -0,0 +1,53 @@
1
+ module RailsHelpers
2
+ class User < ActiveRecord::Base
3
+ self.table_name = "users"
4
+ end
5
+
6
+ def rails_root(destination_root = File.expand_path("../../tmp/rspec", __dir__))
7
+ Class.new do
8
+ def initialize(destination_root)
9
+ @destination_root = destination_root
10
+ end
11
+
12
+ def to_s
13
+ @destination_root
14
+ end
15
+
16
+ def join(*args)
17
+ File.join(@destination_root, *args)
18
+ end
19
+ end.new(destination_root)
20
+ end
21
+
22
+ def rails_env(env = "development")
23
+ Class.new do
24
+ def initialize(env)
25
+ @env = env.to_s
26
+ end
27
+
28
+ def development?
29
+ @env == "development"
30
+ end
31
+
32
+ def test?
33
+ @env == "test"
34
+ end
35
+
36
+ def production?
37
+ @env == "production"
38
+ end
39
+
40
+ def to_s
41
+ @env
42
+ end
43
+ end.new(env)
44
+ end
45
+
46
+ def rails_logger
47
+ Class.new do
48
+ def info(message)
49
+ puts message
50
+ end
51
+ end.new
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ e() {
6
+ GREEN='\033[0;32m'
7
+ NC='\033[0m'
8
+ echo -e "${GREEN}$1${NC}"
9
+ eval "$1"
10
+ }
11
+
12
+ e "bundle"
13
+ e "bundle exec rspec"
14
+
15
+ if [[ $(git diff --shortstat 2>/dev/null | tail -n1) != "" ]]; then
16
+ echo -e "\033[1;31mgit working directory not clean, please commit your changes first \033[0m"
17
+ exit 1
18
+ fi
19
+
20
+ GEM_NAME="data-migration"
21
+ VERSION=$(grep -Eo "VERSION\s*=\s*\".+\"" lib/data_migration.rb | grep -Eo "[0-9.]{5,}")
22
+ GEM_FILE="$GEM_NAME-$VERSION.gem"
23
+
24
+ e "gem build $GEM_NAME.gemspec"
25
+
26
+ echo "Ready to release $GEM_FILE $VERSION"
27
+ read -p "Continue? [Y/n] " answer
28
+ if [[ "$answer" != "Y" ]]; then
29
+ echo "Exiting"
30
+ exit 1
31
+ fi
32
+
33
+ e "gem push $GEM_FILE"
34
+ e "git tag $VERSION && git push --tags"
35
+ e "gh release create $VERSION --generate-notes"