data_customs 0.1.0 → 0.2.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 +4 -4
- data/.standard.yml +2 -0
- data/CHANGELOG.md +23 -0
- data/README.md +62 -2
- data/lib/data_customs/migration.rb +26 -6
- data/lib/data_customs/progress_output.rb +43 -0
- data/lib/data_customs/progress_reporter.rb +58 -0
- data/lib/data_customs/tasks/data_customs.rake +6 -6
- data/lib/data_customs/throttled_output.rb +25 -0
- data/lib/data_customs/version.rb +1 -1
- data/lib/data_customs.rb +3 -0
- data/mise.toml +2 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ac72ddffa6174ea2b67b2e5bbc9ab48a3c2d74a70d9a825da40d72fbf536620
|
|
4
|
+
data.tar.gz: 945ee937b057a9baad85f82cc8008389bea80cdcbf781e971f46f9ed2728e210
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5074c92e21ca067b24e03444007bc3ff7805ed975479e182b735dfba0b73a71729395528d08056a3f0d5ca809309ff0a8dfae893d08c6a4fe22c4bbe96a993a3
|
|
7
|
+
data.tar.gz: 0da824fb923f79dc6d5ee60e4bee9554fe2edacc6c0aae23292d594df249082c608fc1cdfe09cddaaba67c8c36774779bb4e3026904232aee772606d0933e145
|
data/.standard.yml
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-03-04
|
|
4
|
+
|
|
5
|
+
- Add `progress.report` for tracking migration progress with a visual bar that updates in place on TTY terminals
|
|
6
|
+
- Add `progress eta: true` class-level macro to enable ETA display without passing it on every `report` call
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class MyMigration < DataCustoms::Migration
|
|
10
|
+
progress eta: true
|
|
11
|
+
|
|
12
|
+
def up
|
|
13
|
+
users = User.where(active: true)
|
|
14
|
+
total = users.count
|
|
15
|
+
users.find_each.with_index(1) do |user, i|
|
|
16
|
+
user.update!(name: user.name.strip)
|
|
17
|
+
progress.report(i * 100 / total)
|
|
18
|
+
end
|
|
19
|
+
progress.report(100)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- Fix transaction to wrap `Migration.new` so database operations in `initialize` are rolled back on failure
|
|
25
|
+
|
|
3
26
|
## [0.1.0] - 2025-09-04
|
|
4
27
|
|
|
5
28
|
- Initial release
|
data/README.md
CHANGED
|
@@ -146,6 +146,66 @@ class LongMigration < DataCustoms::Migration
|
|
|
146
146
|
end
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
#### Reporting progress
|
|
150
|
+
|
|
151
|
+
For long-running migrations, you can use `progress.report` to display a progress
|
|
152
|
+
bar:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class BackfillUsernames < DataCustoms::Migration
|
|
156
|
+
def up
|
|
157
|
+
scope = User.where(username: nil)
|
|
158
|
+
total = scope.count
|
|
159
|
+
processed = 0
|
|
160
|
+
|
|
161
|
+
find_each(scope) do |user|
|
|
162
|
+
user.update!(username: "guest_#{user.id}")
|
|
163
|
+
processed += 1
|
|
164
|
+
progress.report(processed.to_f / total * 100)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def verify!
|
|
169
|
+
raise "Some users still have no usernames!" if User.exists?(username: nil)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
🛃 Progress: ██████████░░░░░░░░░░ 50%
|
|
176
|
+
🛃 Progress: ████████████████████ 100% (5s elapsed)
|
|
177
|
+
🛃 Data migration ran successfully!
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It accepts a percentage (0–100) and throttles output, so it's safe to call on
|
|
181
|
+
every iteration. At 100%, it shows the total elapsed time.
|
|
182
|
+
|
|
183
|
+
Use `progress eta: true` at the class level to show estimated time remaining:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class BackfillUsernames < DataCustoms::Migration
|
|
187
|
+
progress eta: true
|
|
188
|
+
|
|
189
|
+
def up
|
|
190
|
+
scope = User.where(username: nil)
|
|
191
|
+
total = scope.count
|
|
192
|
+
processed = 0
|
|
193
|
+
|
|
194
|
+
find_each(scope) do |user|
|
|
195
|
+
user.update!(username: "guest_#{user.id}")
|
|
196
|
+
processed += 1
|
|
197
|
+
progress.report(processed.to_f / total * 100)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ...
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
🛃 Progress: ██████████░░░░░░░░░░ 50% (2m 30s left)
|
|
207
|
+
```
|
|
208
|
+
|
|
149
209
|
### Running a data migration in the command line
|
|
150
210
|
|
|
151
211
|
These migrations don't run automatically. You need to invoke them manually.
|
|
@@ -208,8 +268,8 @@ thoughtbot are trademarks of thoughtbot, inc.
|
|
|
208
268
|
We love open source software! See [our other projects][community]. We are
|
|
209
269
|
[available for hire][hire].
|
|
210
270
|
|
|
211
|
-
[community]: https://thoughtbot.com/community?utm_source=github
|
|
212
|
-
[hire]: https://thoughtbot.com/hire-us?utm_source=github
|
|
271
|
+
[community]: https://thoughtbot.com/community?utm_source=github&utm_medium=readme&utm_campaign=data_customs
|
|
272
|
+
[hire]: https://thoughtbot.com/hire-us?utm_source=github&utm_medium=readme&utm_campaign=data_customs
|
|
213
273
|
|
|
214
274
|
<!-- END /templates/footer.md -->
|
|
215
275
|
|
|
@@ -5,25 +5,45 @@ module DataCustoms
|
|
|
5
5
|
DEFAULT_BATCH_SIZE = 1000
|
|
6
6
|
DEFAULT_THROTTLE = 0.01
|
|
7
7
|
|
|
8
|
-
def self.
|
|
8
|
+
def self.progress(**options)
|
|
9
|
+
@progress_options = options
|
|
10
|
+
end
|
|
9
11
|
|
|
10
|
-
def
|
|
12
|
+
def self.progress_options
|
|
13
|
+
@progress_options || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.run(...)
|
|
17
|
+
ActiveRecord::Base.transaction do
|
|
18
|
+
new(...).run
|
|
19
|
+
end
|
|
20
|
+
rescue => e
|
|
21
|
+
warn "🛃 Data migration failed"
|
|
22
|
+
raise e
|
|
23
|
+
end
|
|
11
24
|
|
|
25
|
+
def up = raise NotImplementedError
|
|
12
26
|
def verify! = raise NotImplementedError
|
|
13
27
|
|
|
14
28
|
def run
|
|
15
|
-
|
|
29
|
+
with_progress_reporter do
|
|
16
30
|
up
|
|
17
31
|
verify!
|
|
18
32
|
puts "🛃 Data migration ran successfully!"
|
|
19
|
-
rescue => e
|
|
20
|
-
warn "🛃 Data migration failed"
|
|
21
|
-
raise e
|
|
22
33
|
end
|
|
23
34
|
end
|
|
24
35
|
|
|
25
36
|
private
|
|
26
37
|
|
|
38
|
+
def with_progress_reporter(&block)
|
|
39
|
+
ProgressOutput.wrap do |output|
|
|
40
|
+
@_progress = ProgressReporter.new(output, **self.class.progress_options)
|
|
41
|
+
block.call
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def progress = @_progress
|
|
46
|
+
|
|
27
47
|
def batch(scope, batch_size: DEFAULT_BATCH_SIZE, throttle_seconds: DEFAULT_THROTTLE)
|
|
28
48
|
scope.in_batches(of: batch_size) do |relation|
|
|
29
49
|
yield relation
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataCustoms
|
|
4
|
+
class ProgressOutput
|
|
5
|
+
def self.wrap
|
|
6
|
+
real_output = $stdout
|
|
7
|
+
tty = real_output.respond_to?(:tty?) && real_output.tty?
|
|
8
|
+
|
|
9
|
+
if tty
|
|
10
|
+
tui = new(real_output)
|
|
11
|
+
$stdout = tui.buffer
|
|
12
|
+
yield tui
|
|
13
|
+
else
|
|
14
|
+
yield real_output
|
|
15
|
+
end
|
|
16
|
+
ensure
|
|
17
|
+
$stdout = real_output
|
|
18
|
+
tui&.flush
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :buffer
|
|
22
|
+
|
|
23
|
+
def initialize(output)
|
|
24
|
+
@output = output
|
|
25
|
+
@buffer = StringIO.new
|
|
26
|
+
@buffer_flushed_to = 0
|
|
27
|
+
output.print "\e[H\e[2J" # Clear screen and move cursor to top-left
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def puts(line)
|
|
31
|
+
@output.print "\e[H" # Move cursor to top-left
|
|
32
|
+
"#{line}\n#{@buffer.string}".each_line do |l|
|
|
33
|
+
@output.print "\e[2K#{l}" # Clear line and print new content
|
|
34
|
+
end
|
|
35
|
+
@buffer_flushed_to = @buffer.string.length
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def flush
|
|
39
|
+
remaining = @buffer.string[@buffer_flushed_to..]
|
|
40
|
+
@output.write(remaining) if remaining && !remaining.empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataCustoms
|
|
4
|
+
class ProgressReporter
|
|
5
|
+
BAR_WIDTH = 20
|
|
6
|
+
PRINT_INTERVAL = 1 # seconds
|
|
7
|
+
ETA_MIN_ELAPSED = 2 # seconds before showing ETA
|
|
8
|
+
|
|
9
|
+
def initialize(output, eta: false)
|
|
10
|
+
@output = ThrottledOutput.new(output, interval: PRINT_INTERVAL)
|
|
11
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
@eta = eta
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def report(percentage)
|
|
16
|
+
percentage = percentage.floor.clamp(0, 100)
|
|
17
|
+
line = bar(percentage)
|
|
18
|
+
|
|
19
|
+
if percentage == 100
|
|
20
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
|
|
21
|
+
line += " (#{format_duration(elapsed)} elapsed)" if elapsed >= 1
|
|
22
|
+
@output.write(line, force: true)
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if @eta && percentage > 0
|
|
27
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
|
|
28
|
+
if elapsed >= ETA_MIN_ELAPSED
|
|
29
|
+
remaining = elapsed / percentage * (100 - percentage)
|
|
30
|
+
line += " (#{format_duration(remaining)} left)"
|
|
31
|
+
else
|
|
32
|
+
line += " (estimating...)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@output.write(line)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def bar(percentage)
|
|
42
|
+
filled = percentage / (100 / BAR_WIDTH)
|
|
43
|
+
empty = BAR_WIDTH - filled
|
|
44
|
+
"🛃 Progress: #{"█" * filled}#{"░" * empty} #{percentage}%"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_duration(seconds)
|
|
48
|
+
seconds = seconds.ceil
|
|
49
|
+
if seconds < 60
|
|
50
|
+
"#{seconds}s"
|
|
51
|
+
elsif seconds < 3600
|
|
52
|
+
"#{seconds / 60}m #{seconds % 60}s"
|
|
53
|
+
else
|
|
54
|
+
"#{seconds / 3600}h #{(seconds % 3600) / 60}m"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
namespace :data_customs do
|
|
2
|
-
desc
|
|
2
|
+
desc "Run a single data migration from db/data_migrations"
|
|
3
3
|
task run: :environment do
|
|
4
|
-
name = ENV[
|
|
5
|
-
abort
|
|
4
|
+
name = ENV["NAME"]
|
|
5
|
+
abort "❌ Missing migration name (e.g. `rake data_customs:run NAME=fix_users`)" unless name
|
|
6
6
|
|
|
7
|
-
path = Rails.root.join(
|
|
7
|
+
path = Rails.root.join("db", "data_migrations", "#{name.underscore}.rb")
|
|
8
8
|
abort "❌ Migration not found: #{path}" unless File.exist?(path)
|
|
9
9
|
|
|
10
10
|
require path
|
|
11
11
|
migration_class = name.camelize.constantize
|
|
12
|
-
if args = ENV[
|
|
13
|
-
migration_class.run(args.split(
|
|
12
|
+
if (args = ENV["ARGS"])
|
|
13
|
+
migration_class.run(args.split(","))
|
|
14
14
|
else
|
|
15
15
|
migration_class.run
|
|
16
16
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataCustoms
|
|
4
|
+
class ThrottledOutput
|
|
5
|
+
def initialize(output, interval:)
|
|
6
|
+
@output = output
|
|
7
|
+
@interval = interval
|
|
8
|
+
@last_printed_at = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write(line, force: false)
|
|
12
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
return if throttled?(now) && !force
|
|
14
|
+
|
|
15
|
+
@last_printed_at = now
|
|
16
|
+
@output.puts(line)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def throttled?(now)
|
|
22
|
+
@last_printed_at && (now - @last_printed_at) < @interval
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/data_customs/version.rb
CHANGED
data/lib/data_customs.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "data_customs/migration"
|
|
4
|
+
require_relative "data_customs/progress_reporter"
|
|
4
5
|
require_relative "data_customs/railtie"
|
|
6
|
+
require_relative "data_customs/throttled_output"
|
|
7
|
+
require_relative "data_customs/progress_output"
|
|
5
8
|
require_relative "data_customs/version"
|
|
6
9
|
|
|
7
10
|
module DataCustoms
|
data/mise.toml
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: data_customs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matheus Richard
|
|
@@ -23,15 +23,17 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.1'
|
|
26
|
-
description:
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
description: |
|
|
27
|
+
A simple gem to help you perform data migrations in your Rails app.
|
|
28
|
+
Define the migration and a verification step; if any of them fail,
|
|
29
|
+
everything will be rolled back, so you're left with the original state.
|
|
29
30
|
email:
|
|
30
31
|
- matheusrichardt@gmail.com
|
|
31
32
|
executables: []
|
|
32
33
|
extensions: []
|
|
33
34
|
extra_rdoc_files: []
|
|
34
35
|
files:
|
|
36
|
+
- ".standard.yml"
|
|
35
37
|
- CHANGELOG.md
|
|
36
38
|
- CODE_OF_CONDUCT.md
|
|
37
39
|
- LICENSE.txt
|
|
@@ -39,11 +41,15 @@ files:
|
|
|
39
41
|
- Rakefile
|
|
40
42
|
- lib/data_customs.rb
|
|
41
43
|
- lib/data_customs/migration.rb
|
|
44
|
+
- lib/data_customs/progress_output.rb
|
|
45
|
+
- lib/data_customs/progress_reporter.rb
|
|
42
46
|
- lib/data_customs/railtie.rb
|
|
43
47
|
- lib/data_customs/tasks/data_customs.rake
|
|
48
|
+
- lib/data_customs/throttled_output.rb
|
|
44
49
|
- lib/data_customs/version.rb
|
|
45
50
|
- lib/generators/data_migration/data_migration_generator.rb
|
|
46
51
|
- lib/generators/data_migration/templates/data_migration.rb.tt
|
|
52
|
+
- mise.toml
|
|
47
53
|
homepage: https://github.com/thoughtbot/data_customs
|
|
48
54
|
licenses:
|
|
49
55
|
- MIT
|