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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d7c7b4003b90a683a9ea760719dc9f3fee13c2e41d8e913904d0c25d05cfb3b
4
- data.tar.gz: 23abed296ee83d222794a3467f9fba98430cbb15463505f21ce7be11ff1b4c74
3
+ metadata.gz: 6ac72ddffa6174ea2b67b2e5bbc9ab48a3c2d74a70d9a825da40d72fbf536620
4
+ data.tar.gz: 945ee937b057a9baad85f82cc8008389bea80cdcbf781e971f46f9ed2728e210
5
5
  SHA512:
6
- metadata.gz: f2f015a1096f50f09f5f535dcdd7cfe5d5d2c2093763af254cc69743d542cfe21fe82f90f8d42c40d11a38951ce80046ec413c9944ec02ee5107df4207f6e4c3
7
- data.tar.gz: a50e40759553bebca3718c77d3095417798ae8c946524a45216d0bf331fc3a9cf08bcaf3e74a53b015b0d7f4523dcadcbfa15c73ec3918cbce4d97b7f46a9783
6
+ metadata.gz: 5074c92e21ca067b24e03444007bc3ff7805ed975479e182b735dfba0b73a71729395528d08056a3f0d5ca809309ff0a8dfae893d08c6a4fe22c4bbe96a993a3
7
+ data.tar.gz: 0da824fb923f79dc6d5ee60e4bee9554fe2edacc6c0aae23292d594df249082c608fc1cdfe09cddaaba67c8c36774779bb4e3026904232aee772606d0933e145
data/.standard.yml ADDED
@@ -0,0 +1,2 @@
1
+ ignore:
2
+ - 'spec/dummy/**/*'
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.run(...) = new(...).run
8
+ def self.progress(**options)
9
+ @progress_options = options
10
+ end
9
11
 
10
- def up = raise NotImplementedError
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
- ActiveRecord::Base.transaction do
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 'Run a single data migration from db/data_migrations'
2
+ desc "Run a single data migration from db/data_migrations"
3
3
  task run: :environment do
4
- name = ENV['NAME']
5
- abort '❌ Missing migration name (e.g. `rake data_customs:run NAME=fix_users`)' unless name
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('db', 'data_migrations', "#{name.underscore}.rb")
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['ARGS']
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataCustoms
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.8"
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.1.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: A simple gem to help you perform data migrations in your Rails app. Define
27
- the work and a verification step steps, if any of them fail, your migration will
28
- be rolled back.
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