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,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"