diff --git a/Gemfile.lock b/Gemfile.lock index 58ac71ae..f6ebc164 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rolemodel-rails (2.0.1) + rolemodel-rails (2.1.0) benchmark rails (> 7.1) diff --git a/README.md b/README.md index 8dd0cb77..1c502188 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,117 @@ bin/rails g * [Tailored Select](./lib/generators/rolemodel/tailored_select) * [Lograge](./lib/generators/rolemodel/lograge) +## Utilities + +### `Rolemodel::Utility::TaskTools` + +A mixin of helper methods for writing friendlier, more informative Rake tasks. It provides consistent, migration-style console output, progress indication for long-running loops, an opt-in dry-run mode, and sanitized positional task arguments. + +#### Requiring & Including + +The module is available at runtime (rolemodel-rails must not be restricted to the `development` gem group). Require it and `include` it inside the `namespace` block of your `.rake` file: + +```ruby +require 'rolemodel/utility/task_tools' + +namespace :reports do + include Rolemodel::Utility::TaskTools + + task total: :environment do + @total = GeneratedReport.count + end + + namespace :clear do + desc 'Delete all non-current report records' + task expired: :total do + say_with_time "Detecting & Deleting Expired Reports among #{@total} Total" do + deleted_reports = 0 + GeneratedReport.find_each.with_index do |report, index| + indicate_progress(index, @total) + next if report.current? + + report.destroy unless dry_run? + deleted_reports += 1 + end + say "#{deleted_reports}/#{@total} Records Deleted" + end + end + end +end +``` + +> [!NOTE] +> `include` inside a `namespace` block adds the helper methods to the anonymous object that evaluates your task bodies, making them (and any plain methods you define alongside them) available to every task in that namespace. + +#### Helper Methods + +##### `say(message)` + +Prints a message using the same style as Rails migrations. Pass `subitem: true` to indent the line beneath a preceding `say`. + +```ruby +say 'Doing some important stuff!' +say 'Like this one specific thing!', subitem: true +#=> -- Doing some important stuff! +#=> -> Like this one specific thing! +``` + +##### `say_with_time(message, &block)` + +Wraps `say` and prints the block's real execution time as an indented subitem. Use it to announce and time a unit of work. + +```ruby +say_with_time "Deleting all #{@total} Records" do + GeneratedReport.destroy_all unless dry_run? +end +#=> -- Deleting all 42 Records +#=> -> 0.0198s +``` + +##### `indicate_progress(index, total = nil, report_interval: 9)` + +Renders an animated spinner (and, when `total` is given, a completion percentage) for a long-running loop. Call it once per iteration with the current index; it only redraws every `report_interval` iterations so the animation stays eye-trackable. Pass a reduced `report_interval` when iterations are very slow. + +```ruby +GeneratedReport.find_each.with_index do |report, index| + indicate_progress(index, @total) + # ... +end +``` + +##### `dry_run?` + +Returns `true` when the `DRY_RUN` environment variable is present, enabling a dry-run pattern for your tasks. Guard any code that writes to the database or file system with `unless dry_run?` so the task still produces its console feedback without performing the side effects. + +```ruby +report.destroy unless dry_run? +``` + +```shell +DRY_RUN=true rake reports:clear:expired +``` + +##### `sanitize_arguments(args, defaults = {})` + +Improves the usability of positional `Rake::Task` arguments, which cannot declare default values and are awkward to skip. Pass the task's `args` and a hash of defaults; it returns a hash with whitespace stripped, blank/skipped values (passed as `_`) replaced by your defaults. + +```ruby +desc 'Seed a dev user with the given name and email' +task :dev, %i[name email] => :environment do |_, args| + name, email = sanitize_arguments(args, name: 'RoleModel', email: 'it-support@rolemodelsoftware.com').values_at(:name, :email) + say_with_time "Seeding Dev User (name: #{name}, email: #{email}, role: Admin)" do + User.find_or_create(name:, email:, role: 'admin') unless dry_run? + end +end +``` + +```shell +# Skip the name argument with `_` to fall back to its default +DRY_RUN=true rake users:dev[_,bob@example.com] +#=> -- Seeding Dev User (name: RoleModel, email: bob@example.com, role: Admin) +#=> -> 0.0000s +``` + ## Development Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `npm install -g yarn`. diff --git a/example_rails_legacy/Gemfile.lock b/example_rails_legacy/Gemfile.lock index 893f3667..d8b5313b 100644 --- a/example_rails_legacy/Gemfile.lock +++ b/example_rails_legacy/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - rolemodel-rails (2.0.1) + rolemodel-rails (2.1.0) benchmark rails (> 7.1) diff --git a/lib/rolemodel/utility/task_tools.rb b/lib/rolemodel/utility/task_tools.rb index c89f33c3..ff23dd45 100644 --- a/lib/rolemodel/utility/task_tools.rb +++ b/lib/rolemodel/utility/task_tools.rb @@ -12,6 +12,7 @@ def say_with_time(message, &) say '%.4fs' % time.real, subitem: true end + # based on the migration helper of the same name def say(message, subitem: false) puts "#{subitem ? ' ->' : '--'} #{message}" # rubocop:disable Rails/Output end @@ -32,11 +33,37 @@ def indicate_progress(index, total = nil, report_interval: 9) print("#{PROGRESS.next} #{to_percent(index, total) if total}\r") # rubocop:disable Rails/Output end + ## + # Enables a pattern for dry-run mode in tasks. Assumes usage of other utility methods + # like `say_with_time` and `indicate_progress` that provide user feedback, separate from the actions they perform. + # + # Tasks can be updated to support this mode by adding `unless dry_run?` to lines of code that write to the file system or database. + def dry_run? + ENV['DRY_RUN'].present? + end + + ## + # Improves the usability of Rake::Task arguments which are positional and don't support default values. + # This method allows invocation with skipped arguments via `_` and supports a defaults hash. + def sanitize_arguments(args, defaults = {}) + defaults.merge(ArgumentSanitizer.new(args).to_h) + end + private def to_percent(index, total) '%3.f%%' % (index / total.to_f * 100.0) end end + + class ArgumentSanitizer + def initialize(args) + @args = args.to_h + end + + def to_h + @args.transform_values { it.to_s.strip.match?('_') ? nil : it.to_s.strip }.compact + end + end end end diff --git a/lib/rolemodel/version.rb b/lib/rolemodel/version.rb index 26cb6752..44a64a9f 100644 --- a/lib/rolemodel/version.rb +++ b/lib/rolemodel/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Rolemodel - VERSION = '2.0.1' + VERSION = '2.1.0' end