monarch_migrate 0.4.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b59ad47f996564cc02e0577133692329e9a3b7052e2b0e0268c713cbf44b563
4
+ data.tar.gz: 98ab757d6e2fcdba24451c5f1816555e7ba6d65ba5c47d1dc0ab4cc94d562a7d
5
+ SHA512:
6
+ metadata.gz: 296572f26ef2ac623e3409c4048f18ada537588100ada9b42caf08d73d9bd251c5ff9111b1079de512ff95892c3263177393268b860a6624617849f4c15ca49a
7
+ data.tar.gz: bb9a83dfb41ce4649ae45b58d4aaea1faeb9e47057f10b0161926a472164f15a2485faab8c4448058391f3daf5a1cd19c5c26cad8dd8a4377a7204f93bb4dd18
@@ -0,0 +1,49 @@
1
+ name: "CI Tests"
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ name: "Ruby ${{ matrix.ruby }}, Rails ${{ matrix.gemfile }}"
12
+
13
+ runs-on: 'ubuntu-latest'
14
+
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ gemfile:
19
+ - "5.2"
20
+ - "6.1"
21
+ - "7.0"
22
+ ruby:
23
+ - "2.7.3"
24
+ - "3.0.0"
25
+ - "3.1.0"
26
+ exclude:
27
+ - gemfile: "5.2"
28
+ ruby: "3.0.0"
29
+ - gemfile: "5.2"
30
+ ruby: "3.1.0"
31
+
32
+ env:
33
+ BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.gemfile }}.gemfile
34
+ RAILS_ENV: test
35
+
36
+ steps:
37
+ - uses: actions/checkout@v2
38
+
39
+ - name: "Install Ruby ${{ matrix.ruby }}"
40
+ uses: ruby/setup-ruby@v1
41
+ with:
42
+ ruby-version: ${{ matrix.ruby }}
43
+ bundler-cache: true
44
+
45
+ - name: "Reset app database"
46
+ run: bundle exec rake fake:db:reset
47
+
48
+ - name: "Run test"
49
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,113 @@
1
+ # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2
+ #
3
+ # If you find yourself ignoring temporary files generated by your text editor
4
+ # or operating system, you probably want to add a global ignore instead:
5
+ # git config --global core.excludesfile '~/.gitignore_global'
6
+
7
+ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
8
+ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
9
+
10
+ # User-specific stuff
11
+ .idea/**/workspace.xml
12
+ .idea/**/tasks.xml
13
+ .idea/**/usage.statistics.xml
14
+ .idea/**/dictionaries
15
+ .idea/**/shelf
16
+
17
+ # Generated files
18
+ .idea/**/contentModel.xml
19
+
20
+ # Sensitive or high-churn files
21
+ .idea/**/dataSources/
22
+ .idea/**/dataSources.ids
23
+ .idea/**/dataSources.local.xml
24
+ .idea/**/sqlDataSources.xml
25
+ .idea/**/dynamic.xml
26
+ .idea/**/uiDesigner.xml
27
+ .idea/**/dbnavigator.xml
28
+
29
+ # Gradle
30
+ .idea/**/gradle.xml
31
+ .idea/**/libraries
32
+
33
+ # Gradle and Maven with auto-import
34
+ # When using Gradle or Maven with auto-import, you should exclude module files,
35
+ # since they will be recreated, and may cause churn. Uncomment if using
36
+ # auto-import.
37
+ # .idea/modules.xml
38
+ # .idea/*.iml
39
+ # .idea/modules
40
+
41
+ # CMake
42
+ cmake-build-*/
43
+
44
+ # Mongo Explorer plugin
45
+ .idea/**/mongoSettings.xml
46
+
47
+ # File-based project format
48
+ *.iws
49
+
50
+ # IntelliJ
51
+ out/
52
+
53
+ # mpeltonen/sbt-idea plugin
54
+ .idea_modules/
55
+
56
+ # JIRA plugin
57
+ atlassian-ide-plugin.xml
58
+
59
+ # Cursive Clojure plugin
60
+ .idea/replstate.xml
61
+
62
+ # Crashlytics plugin (for Android Studio and IntelliJ)
63
+ com_crashlytics_export_strings.xml
64
+ crashlytics.properties
65
+ crashlytics-build.properties
66
+ fabric.properties
67
+
68
+ # Editor-based Rest Client
69
+ .idea/httpRequests
70
+
71
+ # Android studio 3.1+ serialized cache file
72
+ .idea/caches/build_file_checksums.ser
73
+
74
+ # Migration generated during tests
75
+ /test/generators/monarch_migrate/tmp
76
+
77
+ # Ignore gem compile directory
78
+ /pkg
79
+
80
+ # Ignore bundler config.
81
+ /.bundle
82
+
83
+ # Ignore the default SQLite database.
84
+ /db/*.sqlite3
85
+ /db/*.sqlite3-journal
86
+
87
+ # Ignore all logfiles and tempfiles.
88
+ /log/*
89
+ /tmp/*
90
+ !/log/.keep
91
+ !/tmp/.keep
92
+
93
+ # Ignore uploaded files in development
94
+ /storage/*
95
+ !/storage/.keep
96
+
97
+ /node_modules
98
+ /yarn-error.log
99
+
100
+ /public/assets
101
+ .byebug_history
102
+
103
+ # Ignore master key for decrypting credentials and more.
104
+ /config/master.key
105
+
106
+ .foreman
107
+
108
+ /public/packs
109
+ /public/packs-test
110
+ /node_modules
111
+ /yarn-error.log
112
+ yarn-debug.log*
113
+ .yarn-integrity
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.7.3
data/Appraisals ADDED
@@ -0,0 +1,11 @@
1
+ appraise "rails_5.2" do
2
+ gem "railties", "~> 5.2"
3
+ end
4
+
5
+ appraise "rails_6.1" do
6
+ gem "railties", "~> 6.1"
7
+ end
8
+
9
+ appraise "rails_7.0" do
10
+ gem "railties", "~> 7.0"
11
+ end
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in *.gemspec
4
+ gemspec name: "monarch_migrate"
data/Gemfile.lock ADDED
@@ -0,0 +1,112 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ monarch_migrate (0.4.0)
5
+ activerecord (>= 5.2.0)
6
+ railties (>= 5.2.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ actionpack (7.0.3)
12
+ actionview (= 7.0.3)
13
+ activesupport (= 7.0.3)
14
+ rack (~> 2.0, >= 2.2.0)
15
+ rack-test (>= 0.6.3)
16
+ rails-dom-testing (~> 2.0)
17
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
18
+ actionview (7.0.3)
19
+ activesupport (= 7.0.3)
20
+ builder (~> 3.1)
21
+ erubi (~> 1.4)
22
+ rails-dom-testing (~> 2.0)
23
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
24
+ activemodel (7.0.3)
25
+ activesupport (= 7.0.3)
26
+ activerecord (7.0.3)
27
+ activemodel (= 7.0.3)
28
+ activesupport (= 7.0.3)
29
+ activesupport (7.0.3)
30
+ concurrent-ruby (~> 1.0, >= 1.0.2)
31
+ i18n (>= 1.6, < 2)
32
+ minitest (>= 5.1)
33
+ tzinfo (~> 2.0)
34
+ appraisal (2.4.1)
35
+ bundler
36
+ rake
37
+ thor (>= 0.14.0)
38
+ ast (2.4.2)
39
+ builder (3.2.4)
40
+ concurrent-ruby (1.1.10)
41
+ crass (1.0.6)
42
+ erubi (1.10.0)
43
+ i18n (1.10.0)
44
+ concurrent-ruby (~> 1.0)
45
+ loofah (2.18.0)
46
+ crass (~> 1.0.2)
47
+ nokogiri (>= 1.5.9)
48
+ method_source (1.0.0)
49
+ minitest (5.15.0)
50
+ nokogiri (1.13.6-x86_64-darwin)
51
+ racc (~> 1.4)
52
+ parallel (1.22.1)
53
+ parser (3.1.2.0)
54
+ ast (~> 2.4.1)
55
+ racc (1.6.0)
56
+ rack (2.2.3.1)
57
+ rack-test (1.1.0)
58
+ rack (>= 1.0, < 3)
59
+ rails-dom-testing (2.0.3)
60
+ activesupport (>= 4.2.0)
61
+ nokogiri (>= 1.6)
62
+ rails-html-sanitizer (1.4.2)
63
+ loofah (~> 2.3)
64
+ railties (7.0.3)
65
+ actionpack (= 7.0.3)
66
+ activesupport (= 7.0.3)
67
+ method_source
68
+ rake (>= 12.2)
69
+ thor (~> 1.0)
70
+ zeitwerk (~> 2.5)
71
+ rainbow (3.1.1)
72
+ rake (13.0.6)
73
+ regexp_parser (2.4.0)
74
+ rexml (3.2.5)
75
+ rubocop (1.29.1)
76
+ parallel (~> 1.10)
77
+ parser (>= 3.1.0.0)
78
+ rainbow (>= 2.2.2, < 4.0)
79
+ regexp_parser (>= 1.8, < 3.0)
80
+ rexml (>= 3.2.5, < 4.0)
81
+ rubocop-ast (>= 1.17.0, < 2.0)
82
+ ruby-progressbar (~> 1.7)
83
+ unicode-display_width (>= 1.4.0, < 3.0)
84
+ rubocop-ast (1.18.0)
85
+ parser (>= 3.1.1.0)
86
+ rubocop-performance (1.13.3)
87
+ rubocop (>= 1.7.0, < 2.0)
88
+ rubocop-ast (>= 0.4.0)
89
+ ruby-progressbar (1.11.0)
90
+ sqlite3 (1.4.2)
91
+ standard (1.12.1)
92
+ rubocop (= 1.29.1)
93
+ rubocop-performance (= 1.13.3)
94
+ thor (1.2.1)
95
+ tzinfo (2.0.4)
96
+ concurrent-ruby (~> 1.0)
97
+ unicode-display_width (2.1.0)
98
+ zeitwerk (2.5.4)
99
+
100
+ PLATFORMS
101
+ ruby
102
+
103
+ DEPENDENCIES
104
+ appraisal
105
+ minitest
106
+ monarch_migrate!
107
+ rake
108
+ sqlite3
109
+ standard
110
+
111
+ BUNDLED WITH
112
+ 2.1.4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Y.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # Sensible Data Migrations for Rails
2
+
3
+ <blockquote>
4
+ <p>The main purpose of Rails' migration feature is to issue commands that modify the schema using a consistent process. Migrations can also be used to add or modify data. This is useful in an existing database that can't be destroyed and recreated, such as a production database.</p>
5
+ <a href="https://guides.rubyonrails.org/active_record_migrations.html#migrations-and-seed-data">
6
+ <sup>–Migrations and Seed Data, Rails Guide for Active Record Migrations</sup>
7
+ </a>
8
+ </blockquote>
9
+
10
+ The motivation behind Rails' migration mechanism is schema modification. Using it
11
+ for data changes in the database comes second.
12
+
13
+ Yet, adding of modifying data via regular migrations can be problematic.
14
+
15
+ The first issue is that application deployment now depends on the data migration
16
+ to be completed. This may not be a problem with small databases but large
17
+ databases with millions of records will respond with hanging or failed migrations.
18
+
19
+ Another issue is that data migration files usually stay in `db/migrate` for posterity.
20
+ As a result, they will run whenever a developer sets up their local development environment.
21
+ This is unnecessary for a pristine database. Especially so when there are [scripts][2] to
22
+ seed the correct data.
23
+
24
+ In addition, using ActiveRecord models in migrations has its [quirks](#using-activerecord-models-in-migrations).
25
+
26
+ The purpose of `monarch_migrate` is to solve the above issues with separating data
27
+ from schema migrations.
28
+
29
+ This library assumes that:
30
+
31
+ - You run data migrations *only* on production and rely on seed [scripts][2] i.e. `dev:prime` for local development.
32
+ - You run data migrations manually.
33
+
34
+ ## Install
35
+
36
+ Add the gem to your Gemfile:
37
+
38
+ ```ruby
39
+ gem "monarch_migrate"
40
+ ```
41
+
42
+ Run the bundle command to install it.
43
+
44
+ After you install MonarchMigrate, you need to run the generator:
45
+
46
+ ```shell
47
+ rails generate monarch_migrate:install
48
+ ```
49
+
50
+ The above generates a schema migration which adds a table for keeping track
51
+ of run data migrations.
52
+
53
+
54
+ ## Usage
55
+
56
+ Data migrations in MonarchMigrate have a similar structure to regular migrations
57
+ in Rails. Files are put into `db/data_migrate` and follow the same naming pattern.
58
+
59
+ To create a new data migration, run:
60
+
61
+ ```shell
62
+ rails generate monarch_migrate:data_migration downcase_usernames
63
+ ```
64
+
65
+ In contrast to regular migrations, there is no need to inherit any classes:
66
+
67
+ ```ruby
68
+ # db/data_migrate/20220605083010_downcase_usernames.rb
69
+ ActiveRecord::Base.connection.execute(<<-SQL)
70
+ UPDATE users SET username = lower(username);
71
+ SQL
72
+
73
+ SearchIndex.rebuild
74
+ ```
75
+
76
+ As seen above, it is plain ruby code where you can refer to any object you
77
+ need. MonarchMigrate will run each migration in a separate transaction.
78
+
79
+ To run pending data migrations:
80
+
81
+ ```shell
82
+ rails data:migrate
83
+ ```
84
+
85
+ Or a specific version:
86
+
87
+ ```shell
88
+ rails data:migrate VERSION=20220605083010
89
+ ```
90
+
91
+ ## Known Issues and Limitations
92
+
93
+ The following issues and limitations are not necessary inherent to MonarchMigrate.
94
+ Some are innate to migrations in general.
95
+
96
+ ### Using ActiveRecord Models in Migrations
97
+
98
+ <blockquote>
99
+ <p>The Active Record way claims that intelligence belongs in your models, not in the database.</p>
100
+ <a href="https://guides.rubyonrails.org/active_record_migrations.html#active-record-and-referential-integrity">
101
+ <sup>–Active Record and Referential Integrity, Rails Guide for Active Record Migrations</sup>
102
+ </a>
103
+ </blockquote>
104
+
105
+ Typically, data migrations relate closely to business models. In an ideal Rails world,
106
+ data manipulations would depend on model logic to enforce validation, conform to
107
+ business rules, etc. Hence, it is very tempting to use ActiveRecord models in migrations.
108
+
109
+ Suppose we have designed a system where users have first and last names. Time passes and
110
+ it becomes clear this is [wrong][3]. Now, we want to put things right and come
111
+ up with the following plan:
112
+
113
+ 1. Add a `name` column to `users` table to hold the entire name of a person.
114
+ 2. Change the `User` model and use a data migration to update existing records.
115
+ 3. Drop `first_name` and `last_name` columns.
116
+
117
+ A regular Rails migration for updating existing records may look something like this:
118
+
119
+ ```ruby
120
+ # db/migrate/20220605083010_backfill_users_name.rb
121
+ def up
122
+ User.all.each do |user|
123
+ user.name = "#{user.first_name} #{user.last_name}"
124
+ user.save
125
+ end
126
+ end
127
+ ```
128
+
129
+ The code above is problematic because:
130
+
131
+ 1. It iterates through every user.
132
+ 2. It invokes validations and callbacks, which may have unintended consequences.
133
+ 3. It does not check if the user has already been migrated.
134
+ 4. It will fail when a future developer runs the migration during local development setup after `first_name` and `last_name` columns are gone.
135
+
136
+ To avoid issues 1-3, we can rewrite the migration to:
137
+
138
+ ```ruby
139
+ # db/migrate/20220605083010_backfill_users_name.rb
140
+ def up
141
+ User.where(name: nil).find_each do |user|
142
+ user.update_column(:name, "#{user.first_name} #{user.last_name}")
143
+ end
144
+ end
145
+ ```
146
+
147
+ Unfortunately, with regular Rails migrations we will still face issue 4.
148
+
149
+ To avoid it, we need to separate data from schema migrations and not run data
150
+ migrations locally. With seed [scripts][2], there is no need to run them anyway.
151
+
152
+ Keep the above in mind when referencing ActiveRecord models in data migrations. Ideally,
153
+ limit their use and do as much processing as possible in Postgres.
154
+
155
+
156
+ ### Long-running Tasks in Migrations
157
+
158
+ As mentioned above, MonarchMigrate (similar to Rails) runs each migration in a separate
159
+ transaction. A long-running task within a migration will keep the transaction open for
160
+ the duration of the task. As a result, the migration may hang or fail.
161
+
162
+ To avoid this, we can run such tasks asynchronously.
163
+
164
+ Back to the previous example:
165
+
166
+ ```ruby
167
+ # db/data_migrate/20220605083010_downcase_usernames.rb
168
+ ActiveRecord::Base.connection.execute(<<-SQL)
169
+ UPDATE users SET username = lower(username);
170
+ SQL
171
+
172
+ SearchIndex::RebuildJob.perform_later
173
+ ```
174
+
175
+
176
+ ## Trivia
177
+
178
+ One of the most impressive migrations on Earth is the multi-generational
179
+ round trip of the monarch butterfly.
180
+
181
+ Each year, millions of monarch butterflies leave their northern ranges
182
+ and fly south, where they gather in huge roosts to survive the winter.
183
+ When spring arrives, the monarchs start their return journey north.
184
+ The population cycles through three to five generations to reach their
185
+ destination. In the end, a new generation of butterflies complete the
186
+ journey their great-great-great-grandparents started.
187
+
188
+ It is still a mystery to scientists how the new generations know where to go,
189
+ but they appear to navigate using a combination of the Earth's magnetic field
190
+ and the position of the sun.
191
+
192
+ Genetically speaking, this is a truly incredible data migration!
193
+
194
+
195
+ ## See Also
196
+
197
+ Alternative gems
198
+
199
+ - [nonschema_migrations](https://github.com/jasonfb/nonschema_migrations) - Exactly like schema migrations but for data.
200
+ - [data-migrate](https://github.com/ilyakatz/data-migrate) - A gem to run data migrations alongside schema migrations.
201
+
202
+ Articles
203
+
204
+ - [Data Migrations in Rails](https://thoughtbot.com/blog/data-migrations-in-rails)
205
+ - [Zero downtime migrations: 500 million rows](https://www.honeybadger.io/blog/zero-downtime-migrations-of-large-databases-using-rails-postgres-and-redis/)
206
+ - [Three Useful Data Migration Patterns for Rails](https://www.ombulabs.com/blog/rails/data-migrations/three-useful-data-migrations-patterns-in-rails.html)
207
+ - [Ruby on Rails Model Patterns and Anti-patterns](https://blog.appsignal.com/2020/11/18/rails-model-patterns-and-anti-patterns.html)
208
+ - [Rails Migrations with Zero Downtime](https://www.cloudbees.com/blog/rails-migrations-zero-downtime)
209
+
210
+
211
+ ## License
212
+
213
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
214
+
215
+ [2]: https://thoughtbot.com/blog/priming-the-pump
216
+ [3]: https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ namespace :fake do
5
+ require_relative "test/fake/application"
6
+ Fake::Application.load_tasks
7
+ end
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.libs << "lib"
12
+ t.test_files = FileList["test/**/*_test.rb"]
13
+ end
14
+
15
+ task default: :test
data/bin/setup ADDED
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ # Install required gems, including Appraisal, which helps us test against
6
+ # multiple Rails versions
7
+ gem install bundler --conservative
8
+ bundle check || bundle install
9
+
10
+ if [ -z "$CI" ]; then
11
+ bundle exec appraisal install
12
+ fi
13
+
14
+ # Set up database for the application that we test against
15
+ RAILS_ENV=test bundle exec rake fake:db:reset
data/db/schema.rb ADDED
@@ -0,0 +1,20 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema.define(version: 2022_05_25_052032) do
14
+ create_table "data_migration_records", force: :cascade do |t|
15
+ t.string "version", null: false
16
+ t.datetime "created_at", null: false
17
+ t.datetime "updated_at", null: false
18
+ t.index ["version"], name: "index_data_migration_records_on_version", unique: true
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Generate a new data migration
3
+
4
+ Examples:
5
+ rails generate monarch_migrate:data_migration assign_name_to_users
@@ -0,0 +1,28 @@
1
+ require "rails/generators/active_record"
2
+
3
+ module MonarchMigrate
4
+ module Generators
5
+ class DataMigrationGenerator < Rails::Generators::NamedBase
6
+ include ActiveRecord::Generators::Migration
7
+
8
+ source_root File.expand_path("../templates", __FILE__)
9
+
10
+ def create_data_migration
11
+ validate_file_name!
12
+
13
+ migration_template(
14
+ "data_migration.rb.erb",
15
+ File.join(MonarchMigrate.data_migrations_path, "#{file_name}.rb")
16
+ )
17
+ end
18
+
19
+ private
20
+
21
+ def validate_file_name!
22
+ unless /^[_a-z0-9]+$/.match?(file_name)
23
+ raise ActiveRecord::IllegalMigrationNameError.new(file_name)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Generate a schema migration to create the table used for data migrations
3
+
4
+ Examples:
5
+ rails generate monarch_migrate:install
@@ -0,0 +1,45 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module MonarchMigrate
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("../templates", __FILE__)
10
+
11
+ def self.next_migration_number(dir)
12
+ ActiveRecord::Generators::Base.next_migration_number(dir)
13
+ end
14
+
15
+ def create_monarch_migrate_migration
16
+ return if migration_exists?
17
+ return if migration_table_exists?
18
+
19
+ migration_template(
20
+ "create_data_migration_records.rb.erb",
21
+ "db/migrate/create_data_migration_records.rb",
22
+ migration_version: migration_version
23
+ )
24
+ end
25
+
26
+ def migration_version
27
+ "[#{ActiveRecord::Migration.current_version}]"
28
+ end
29
+
30
+ def migration_table_name
31
+ MonarchMigrate.data_migrations_table_name
32
+ end
33
+
34
+ private
35
+
36
+ def migration_table_exists?
37
+ ActiveRecord::Base.connection.data_source_exists?(migration_table_name)
38
+ end
39
+
40
+ def migration_exists?
41
+ Dir.glob("db/migrate/*.rb").any? { |f| f.end_with?("create_data_migration_records.rb") }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ class CreateDataMigrationRecords < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :<%= migration_table_name %> do |t|
4
+ t.string :version, null: false
5
+ t.timestamps
6
+ end
7
+
8
+ add_index :<%= migration_table_name %>, :version, unique: true
9
+ end
10
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonarchMigrate
4
+ class Migration
5
+ def initialize(path)
6
+ @path = path.to_s
7
+ end
8
+
9
+ def filename
10
+ File.basename(path)
11
+ end
12
+
13
+ def name
14
+ File.basename(path, ".rb").match(/^[0-9]+_(.*)$/)[1].humanize
15
+ end
16
+
17
+ def version
18
+ filename.match(/^([0-9]+)_/)[1]
19
+ end
20
+
21
+ def pending?
22
+ !MigrationRecord.exists?(version: version)
23
+ end
24
+
25
+ def run(io = nil)
26
+ io ||= File.open(File::NULL, "w")
27
+
28
+ ActiveRecord::Base.connection.transaction do
29
+ io.puts "Running data migration #{version}: #{name}"
30
+
31
+ begin
32
+ instance_eval File.read(path), path
33
+ MigrationRecord.create!(version: version)
34
+ io.puts "Migration complete"
35
+ rescue => e
36
+ io.puts "Migration failed due to #{e}"
37
+ raise ActiveRecord::Rollback
38
+ end
39
+
40
+ io.puts
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :path
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonarchMigrate
4
+ class MigrationRecord < ActiveRecord::Base
5
+ class << self
6
+ def table_name
7
+ "#{table_name_prefix}#{MonarchMigrate.data_migrations_table_name}#{table_name_suffix}"
8
+ end
9
+
10
+ def normalized_versions
11
+ all_versions.map { |v| normalize_version(v) }
12
+ end
13
+
14
+ private
15
+
16
+ def all_versions
17
+ order(version: :asc).pluck(:version)
18
+ end
19
+
20
+ def normalize_version(version)
21
+ "%.3d" % version.to_i
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonarchMigrate
4
+ class Migrator
5
+ attr_reader :path
6
+ attr_reader :version
7
+
8
+ def initialize(path, version: nil)
9
+ @path = path.to_s
10
+ @version = version
11
+ end
12
+
13
+ def migrations
14
+ migration_files.sort.map do |f|
15
+ Migration.new(f)
16
+ end
17
+ end
18
+
19
+ def pending_migrations
20
+ migrations.select(&:pending?)
21
+ end
22
+
23
+ def run(io = nil)
24
+ io ||= File.open(File::NULL, "w")
25
+
26
+ if pending_migrations.any?
27
+ io.puts "Running #{pending_migrations.size} data migrations"
28
+ pending_migrations.sort_by(&:version).each { |m| m.run(io) }
29
+ else
30
+ io.puts "No data migrations pending"
31
+ []
32
+ end
33
+ end
34
+
35
+ def migrations_status
36
+ db_list = MigrationRecord.normalized_versions
37
+
38
+ file_list = migrations.filter_map do |migration|
39
+ version = migration.version
40
+ status = db_list.delete(version) ? "up" : "down"
41
+ [status, version, migration.name]
42
+ end
43
+
44
+ db_list.map! do |version|
45
+ ["up", version, "***** NO FILE *****"]
46
+ end
47
+
48
+ (db_list + file_list).sort_by { |_, version, _| version.to_i }
49
+ end
50
+
51
+ private
52
+
53
+ def migration_files
54
+ if version
55
+ Dir["#{path}/#{version}_*.rb"].tap do |entries|
56
+ raise ActiveRecord::UnknownMigrationVersionError.new(version) if entries.empty?
57
+ end
58
+ else
59
+ Dir["#{path}/*_*.rb"]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,9 @@
1
+ module MonarchMigrate
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :monarch_migrate
4
+
5
+ rake_tasks do
6
+ load File.expand_path("tasks.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ require "monarch_migrate"
2
+
3
+ namespace :data do
4
+ desc "Run pending data migrations, or a single version specified by environment variable VERSION"
5
+ task migrate: :environment do
6
+ MonarchMigrate.migrator.run($stdout)
7
+ end
8
+
9
+ namespace :migrate do
10
+ desc "Display status of data migrations"
11
+ task status: :environment do
12
+ unless ActiveRecord::Base.connection.data_source_exists?(MonarchMigrate.data_migrations_table_name)
13
+ Kernel.abort "Data migrations table does not exist yet."
14
+ end
15
+
16
+ database =
17
+ if Rails::VERSION::MAJOR < 6
18
+ ActiveRecord::Base.connection_config[:database]
19
+ else
20
+ ActiveRecord::Base.connection_db_config.database
21
+ end
22
+
23
+ puts "\ndatabase: #{database}\n\n"
24
+ puts "#{"Status".center(8)} #{"Data Migration ID".ljust(19)} Data Migration Name"
25
+ puts "-" * 50
26
+
27
+ MonarchMigrate.migrator.migrations_status.each do |status, version, name|
28
+ puts "#{status.center(8)} #{version.ljust(19)} #{name}"
29
+ end
30
+
31
+ puts
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module MonarchMigrate
2
+ VERSION = "0.4.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc
4
+ module MonarchMigrate
5
+ def self.data_migrations_table_name
6
+ "data_migration_records"
7
+ end
8
+
9
+ def self.data_migrations_path
10
+ "db/data_migrate"
11
+ end
12
+
13
+ def self.migrator
14
+ Migrator.new(data_migrations_path, version: ENV.fetch("VERSION", nil))
15
+ end
16
+ end
17
+
18
+ require "monarch_migrate/migration"
19
+ require "monarch_migrate/migration_record"
20
+ require "monarch_migrate/migrator"
21
+ require "monarch_migrate/railtie"
22
+ require "monarch_migrate/version"
@@ -0,0 +1,31 @@
1
+ require_relative "lib/monarch_migrate/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "monarch_migrate"
5
+ spec.version = MonarchMigrate::VERSION
6
+ spec.authors = ["Yanko Ivanov"]
7
+ spec.email = ["yanko.ivanov@gmail.com"]
8
+
9
+ spec.summary = "Sensible data migrations for Rails"
10
+ spec.homepage = "https://github.com/lunohodov/monarch"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.3")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = "https://github.com/lunohodov/monarch"
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|gemfiles|tmp)/}) }
21
+ end
22
+
23
+ spec.add_dependency("activerecord", ">= 5.2.0")
24
+ spec.add_dependency("railties", ">= 5.2.0")
25
+
26
+ spec.add_development_dependency("appraisal")
27
+ spec.add_development_dependency("minitest")
28
+ spec.add_development_dependency("rake")
29
+ spec.add_development_dependency("sqlite3")
30
+ spec.add_development_dependency("standard")
31
+ end
metadata ADDED
@@ -0,0 +1,168 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: monarch_migrate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Yanko Ivanov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-06-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 5.2.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 5.2.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: appraisal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email:
113
+ - yanko.ivanov@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".github/workflows/ci.yml"
119
+ - ".gitignore"
120
+ - ".ruby-version"
121
+ - Appraisals
122
+ - Gemfile
123
+ - Gemfile.lock
124
+ - LICENSE
125
+ - README.md
126
+ - Rakefile
127
+ - bin/setup
128
+ - db/schema.rb
129
+ - lib/generators/monarch_migrate/data_migration/USAGE
130
+ - lib/generators/monarch_migrate/data_migration/data_migration_generator.rb
131
+ - lib/generators/monarch_migrate/data_migration/templates/data_migration.rb.erb
132
+ - lib/generators/monarch_migrate/install/USAGE
133
+ - lib/generators/monarch_migrate/install/install_generator.rb
134
+ - lib/generators/monarch_migrate/install/templates/create_data_migration_records.rb.erb
135
+ - lib/monarch_migrate.rb
136
+ - lib/monarch_migrate/migration.rb
137
+ - lib/monarch_migrate/migration_record.rb
138
+ - lib/monarch_migrate/migrator.rb
139
+ - lib/monarch_migrate/railtie.rb
140
+ - lib/monarch_migrate/tasks.rake
141
+ - lib/monarch_migrate/version.rb
142
+ - monarch_migrate.gemspec
143
+ homepage: https://github.com/lunohodov/monarch
144
+ licenses:
145
+ - MIT
146
+ metadata:
147
+ homepage_uri: https://github.com/lunohodov/monarch
148
+ source_code_uri: https://github.com/lunohodov/monarch
149
+ post_install_message:
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: 2.7.3
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubygems_version: 3.1.6
165
+ signing_key:
166
+ specification_version: 4
167
+ summary: Sensible data migrations for Rails
168
+ test_files: []