cmdx 1.9.0 → 1.10.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/.cursor/prompts/llms.md +3 -13
- data/.cursor/prompts/yardoc.md +1 -0
- data/CHANGELOG.md +16 -0
- data/LLM.md +436 -374
- data/README.md +7 -2
- data/docs/basics/setup.md +17 -0
- data/docs/callbacks.md +1 -1
- data/docs/getting_started.md +22 -2
- data/docs/index.md +13 -1
- data/docs/retries.md +121 -0
- data/docs/tips_and_tricks.md +2 -1
- data/examples/stoplight_circuit_breaker.md +36 -0
- data/lib/cmdx/attribute.rb +82 -1
- data/lib/cmdx/attribute_registry.rb +20 -0
- data/lib/cmdx/attribute_value.rb +25 -0
- data/lib/cmdx/callback_registry.rb +19 -0
- data/lib/cmdx/chain.rb +34 -1
- data/lib/cmdx/coercion_registry.rb +18 -0
- data/lib/cmdx/coercions/array.rb +2 -0
- data/lib/cmdx/coercions/big_decimal.rb +3 -0
- data/lib/cmdx/coercions/boolean.rb +5 -0
- data/lib/cmdx/coercions/complex.rb +2 -0
- data/lib/cmdx/coercions/date.rb +4 -0
- data/lib/cmdx/coercions/date_time.rb +5 -0
- data/lib/cmdx/coercions/float.rb +2 -0
- data/lib/cmdx/coercions/hash.rb +2 -0
- data/lib/cmdx/coercions/integer.rb +2 -0
- data/lib/cmdx/coercions/rational.rb +2 -0
- data/lib/cmdx/coercions/string.rb +2 -0
- data/lib/cmdx/coercions/symbol.rb +2 -0
- data/lib/cmdx/coercions/time.rb +5 -0
- data/lib/cmdx/configuration.rb +126 -3
- data/lib/cmdx/context.rb +36 -0
- data/lib/cmdx/deprecator.rb +3 -0
- data/lib/cmdx/errors.rb +22 -0
- data/lib/cmdx/executor.rb +71 -11
- data/lib/cmdx/faults.rb +14 -0
- data/lib/cmdx/identifier.rb +2 -0
- data/lib/cmdx/locale.rb +3 -0
- data/lib/cmdx/log_formatters/json.rb +2 -0
- data/lib/cmdx/log_formatters/key_value.rb +2 -0
- data/lib/cmdx/log_formatters/line.rb +2 -0
- data/lib/cmdx/log_formatters/logstash.rb +2 -0
- data/lib/cmdx/log_formatters/raw.rb +2 -0
- data/lib/cmdx/middleware_registry.rb +20 -0
- data/lib/cmdx/middlewares/correlate.rb +11 -0
- data/lib/cmdx/middlewares/runtime.rb +4 -0
- data/lib/cmdx/middlewares/timeout.rb +4 -0
- data/lib/cmdx/pipeline.rb +20 -1
- data/lib/cmdx/railtie.rb +4 -0
- data/lib/cmdx/result.rb +123 -1
- data/lib/cmdx/task.rb +91 -1
- data/lib/cmdx/utils/call.rb +2 -0
- data/lib/cmdx/utils/condition.rb +3 -0
- data/lib/cmdx/utils/format.rb +5 -0
- data/lib/cmdx/validator_registry.rb +18 -0
- data/lib/cmdx/validators/exclusion.rb +2 -0
- data/lib/cmdx/validators/format.rb +2 -0
- data/lib/cmdx/validators/inclusion.rb +2 -0
- data/lib/cmdx/validators/length.rb +14 -0
- data/lib/cmdx/validators/numeric.rb +14 -0
- data/lib/cmdx/validators/presence.rb +2 -0
- data/lib/cmdx/version.rb +4 -1
- data/lib/cmdx/workflow.rb +10 -0
- data/lib/cmdx.rb +8 -0
- data/lib/generators/cmdx/locale_generator.rb +0 -1
- data/mkdocs.yml +3 -1
- metadata +3 -1
    
        data/README.md
    CHANGED
    
    | @@ -6,7 +6,7 @@ | |
| 6 6 |  | 
| 7 7 | 
             
              Build business logic that’s powerful, predictable, and maintainable.
         | 
| 8 8 |  | 
| 9 | 
            -
              [Documentation](https://drexed.github.io/cmdx) · [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx/issues) · [Request Feature](https://github.com/drexed/cmdx/issues)
         | 
| 9 | 
            +
              [Documentation](https://drexed.github.io/cmdx) · [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx/issues) · [Request Feature](https://github.com/drexed/cmdx/issues) · [LLM.md](https://raw.githubusercontent.com/drexed/cmdx/refs/heads/main/LLM.md)
         | 
| 10 10 |  | 
| 11 11 | 
             
              <img alt="Version" src="https://img.shields.io/gem/v/cmdx">
         | 
| 12 12 | 
             
              <img alt="Build" src="https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg">
         | 
| @@ -17,6 +17,9 @@ | |
| 17 17 |  | 
| 18 18 | 
             
            Say goodbye to messy service objects. CMDx helps you design business logic with clarity and consistency—build faster, debug easier, and ship with confidence.
         | 
| 19 19 |  | 
| 20 | 
            +
            > [!NOTE]
         | 
| 21 | 
            +
            > Documentation reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag.
         | 
| 22 | 
            +
             | 
| 20 23 | 
             
            ## Requirements
         | 
| 21 24 |  | 
| 22 25 | 
             
            - Ruby: MRI 3.1+ or JRuby 9.4+.
         | 
| @@ -105,6 +108,8 @@ I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx: | |
| 105 108 | 
             
            index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187}
         | 
| 106 109 | 
             
            ```
         | 
| 107 110 |  | 
| 111 | 
            +
            Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide to learn more.
         | 
| 112 | 
            +
             | 
| 108 113 | 
             
            ## Ecosystem
         | 
| 109 114 |  | 
| 110 115 | 
             
            - [cmdx-rspec](https://github.com/drexed/cmdx-rspec) - RSpec test matchers
         | 
| @@ -116,7 +121,7 @@ For backwards compatibility of certain functionality: | |
| 116 121 |  | 
| 117 122 | 
             
            ## Contributing
         | 
| 118 123 |  | 
| 119 | 
            -
            Bug reports and pull requests are welcome at https://github.com/drexed/cmdx | 
| 124 | 
            +
            Bug reports and pull requests are welcome at <https://github.com/drexed/cmdx>. We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](CODE_OF_CONDUCT.md).
         | 
| 120 125 |  | 
| 121 126 | 
             
            ## License
         | 
| 122 127 |  | 
    
        data/docs/basics/setup.md
    CHANGED
    
    | @@ -24,6 +24,22 @@ end | |
| 24 24 | 
             
            IncompleteTask.execute #=> raises CMDx::UndefinedMethodError
         | 
| 25 25 | 
             
            ```
         | 
| 26 26 |  | 
| 27 | 
            +
            ## Rollback
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            Undo any operations linked to the given status, helping to restore a pristine state.
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            ```ruby
         | 
| 32 | 
            +
            class ValidateDocument < CMDx::Task
         | 
| 33 | 
            +
              def work
         | 
| 34 | 
            +
                # Your logic here...
         | 
| 35 | 
            +
              end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
              def rollback
         | 
| 38 | 
            +
                # Your undo logic...
         | 
| 39 | 
            +
              end
         | 
| 40 | 
            +
            end
         | 
| 41 | 
            +
            ```
         | 
| 42 | 
            +
             | 
| 27 43 | 
             
            ## Inheritance
         | 
| 28 44 |  | 
| 29 45 | 
             
            Share configuration across tasks using inheritance:
         | 
| @@ -65,3 +81,4 @@ Tasks follow a predictable execution pattern: | |
| 65 81 | 
             
            | **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs |
         | 
| 66 82 | 
             
            | **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized |
         | 
| 67 83 | 
             
            | **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable |
         | 
| 84 | 
            +
            | **Rollback** | `executed` | `failed`/`skipped` | Work undone |
         | 
    
        data/docs/callbacks.md
    CHANGED
    
    
    
        data/docs/getting_started.md
    CHANGED
    
    | @@ -78,6 +78,16 @@ CMDx.configure do |config| | |
| 78 78 | 
             
            end
         | 
| 79 79 | 
             
            ```
         | 
| 80 80 |  | 
| 81 | 
            +
            ### Rollback
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            Control when a `rollback` of task execution is called.
         | 
| 84 | 
            +
             | 
| 85 | 
            +
            ```ruby
         | 
| 86 | 
            +
            CMDx.configure do |config|
         | 
| 87 | 
            +
              config.rollback_on = ["failed"] # String or Array[String]
         | 
| 88 | 
            +
            end
         | 
| 89 | 
            +
            ```
         | 
| 90 | 
            +
             | 
| 81 91 | 
             
            ### Backtraces
         | 
| 82 92 |  | 
| 83 93 | 
             
            Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise.
         | 
| @@ -258,7 +268,8 @@ class GenerateInvoice < CMDx::Task | |
| 258 268 | 
             
                deprecated: true,                            # Task deprecations
         | 
| 259 269 | 
             
                retries: 3,                                  # Non-fault exception retries
         | 
| 260 270 | 
             
                retry_on: [External::ApiError],              # List of exceptions to retry on
         | 
| 261 | 
            -
                retry_jitter: 1 | 
| 271 | 
            +
                retry_jitter: 1,                             # Space between retry iteration, eg: current retry num + 1
         | 
| 272 | 
            +
                rollback_on: ["failed", "skipped"],          # Rollback on override
         | 
| 262 273 | 
             
              )
         | 
| 263 274 |  | 
| 264 275 | 
             
              def work
         | 
| @@ -269,7 +280,7 @@ end | |
| 269 280 |  | 
| 270 281 | 
             
            !!! warning "Important"
         | 
| 271 282 |  | 
| 272 | 
            -
                Retries reuse the same context. By default, all `StandardError` exceptions are retried unless you specify `retry_on | 
| 283 | 
            +
                Retries reuse the same context. By default, all `StandardError` exceptions (including faults) are retried unless you specify `retry_on` option for specific matches.
         | 
| 273 284 |  | 
| 274 285 | 
             
            ### Registrations
         | 
| 275 286 |  | 
| @@ -367,3 +378,12 @@ end | |
| 367 378 | 
             
            !!! tip
         | 
| 368 379 |  | 
| 369 380 | 
             
                Use **present tense verbs + noun** for task names, eg: `ModerateBlogPost`, `ScheduleAppointment`, `ValidateDocument`
         | 
| 381 | 
            +
             | 
| 382 | 
            +
            ## Type safety
         | 
| 383 | 
            +
             | 
| 384 | 
            +
            CMDx includes built-in RBS (Ruby Type Signature) inline annotations throughout the codebase, providing type information for static analysis and editor support.
         | 
| 385 | 
            +
             | 
| 386 | 
            +
            - **Type checking** — Catch type errors before runtime using tools like Steep or TypeProf
         | 
| 387 | 
            +
            - **Better IDE support** — Enhanced autocomplete, navigation, and inline documentation
         | 
| 388 | 
            +
            - **Self-documenting code** — Clear method signatures and return types
         | 
| 389 | 
            +
            - **Refactoring confidence** — Type-aware refactoring reduces bugs
         | 
    
        data/docs/index.md
    CHANGED
    
    | @@ -6,8 +6,20 @@ Build business logic that's powerful, predictable, and maintainable. | |
| 6 6 | 
             
            [](https://github.com/drexed/cmdx/actions/workflows/ci.yml)
         | 
| 7 7 | 
             
            [](https://github.com/drexed/cmdx/blob/main/LICENSE.txt)
         | 
| 8 8 |  | 
| 9 | 
            +
            ---
         | 
| 10 | 
            +
             | 
| 9 11 | 
             
            Say goodbye to messy service objects. CMDx helps you design business logic with clarity and consistency—build faster, debug easier, and ship with confidence.
         | 
| 10 12 |  | 
| 13 | 
            +
            !!! note
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                Documentation reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag.
         | 
| 16 | 
            +
             | 
| 17 | 
            +
            ## Requirements
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            - Ruby: MRI 3.1+ or JRuby 9.4+.
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            CMDx works with any Ruby framework. Rails support is built-in, but it's framework-agnostic at its core.
         | 
| 22 | 
            +
             | 
| 11 23 | 
             
            ## Installation
         | 
| 12 24 |  | 
| 13 25 | 
             
            ```sh
         | 
| @@ -113,7 +125,7 @@ For backwards compatibility of certain functionality: | |
| 113 125 |  | 
| 114 126 | 
             
            ## Contributing
         | 
| 115 127 |  | 
| 116 | 
            -
            Bug reports and pull requests are welcome at https://github.com/drexed/cmdx | 
| 128 | 
            +
            Bug reports and pull requests are welcome at <https://github.com/drexed/cmdx>. We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](CODE_OF_CONDUCT.md).
         | 
| 117 129 |  | 
| 118 130 | 
             
            ## License
         | 
| 119 131 |  | 
    
        data/docs/retries.md
    ADDED
    
    | @@ -0,0 +1,121 @@ | |
| 1 | 
            +
            # Retries
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            CMDx provides automatic retry functionality for tasks that encounter transient failures. This is essential for handling temporary issues like network timeouts, rate limits, or database locks without manual intervention.
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            ## Basic Usage
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            Configure retries upto n attempts without any delay.
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            ```ruby
         | 
| 10 | 
            +
            class FetchExternalData < CMDx::Task
         | 
| 11 | 
            +
              settings retries: 3
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              def work
         | 
| 14 | 
            +
                response = HTTParty.get("https://api.example.com/data")
         | 
| 15 | 
            +
                context.data = response.parsed_response
         | 
| 16 | 
            +
              end
         | 
| 17 | 
            +
            end
         | 
| 18 | 
            +
            ```
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            When an exception occurs during execution, CMDx automatically retries up to the configured limit.
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            ## Selective Retries
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            By default, CMDx retries on `StandardError` and its subclasses. Narrow this to specific exception types:
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            ```ruby
         | 
| 27 | 
            +
            class ProcessPayment < CMDx::Task
         | 
| 28 | 
            +
              settings retries: 5, retry_on: [Stripe::RateLimitError, Net::ReadTimeout]
         | 
| 29 | 
            +
             | 
| 30 | 
            +
              def work
         | 
| 31 | 
            +
                # Your logic here...
         | 
| 32 | 
            +
              end
         | 
| 33 | 
            +
            end
         | 
| 34 | 
            +
            ```
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            !!! warning "Important"
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                Only exceptions matching the `retry_on` configuration will trigger retries. Uncaught exceptions immediately fail the task.
         | 
| 39 | 
            +
             | 
| 40 | 
            +
            ## Retry Jitter
         | 
| 41 | 
            +
             | 
| 42 | 
            +
            Add delays between retry attempts to avoid overwhelming external services or to implement exponential backoff strategies.
         | 
| 43 | 
            +
             | 
| 44 | 
            +
            ### Fixed Value
         | 
| 45 | 
            +
             | 
| 46 | 
            +
            Use a numeric value to calculate linear delay (`jitter * current_retry`):
         | 
| 47 | 
            +
             | 
| 48 | 
            +
            ```ruby
         | 
| 49 | 
            +
            class ImportRecords < CMDx::Task
         | 
| 50 | 
            +
              settings retries: 3, retry_jitter: 0.5
         | 
| 51 | 
            +
             | 
| 52 | 
            +
              def work
         | 
| 53 | 
            +
                # Delays: 0s, 0.5s (retry 1), 1.0s (retry 2), 1.5s (retry 3)
         | 
| 54 | 
            +
                context.records = ExternalAPI.fetch_records
         | 
| 55 | 
            +
              end
         | 
| 56 | 
            +
            end
         | 
| 57 | 
            +
            ```
         | 
| 58 | 
            +
             | 
| 59 | 
            +
            ### Symbol References
         | 
| 60 | 
            +
             | 
| 61 | 
            +
            Define an instance method for custom delay logic:
         | 
| 62 | 
            +
             | 
| 63 | 
            +
            ```ruby
         | 
| 64 | 
            +
            class SyncInventory < CMDx::Task
         | 
| 65 | 
            +
              settings retries: 5, retry_jitter: :exponential_backoff
         | 
| 66 | 
            +
             | 
| 67 | 
            +
              def work
         | 
| 68 | 
            +
                context.inventory = InventoryAPI.sync
         | 
| 69 | 
            +
              end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
              private
         | 
| 72 | 
            +
             | 
| 73 | 
            +
              def exponential_backoff(current_retry)
         | 
| 74 | 
            +
                2 ** current_retry # 2s, 4s, 8s, 16s, 32s
         | 
| 75 | 
            +
              end
         | 
| 76 | 
            +
            end
         | 
| 77 | 
            +
            ```
         | 
| 78 | 
            +
             | 
| 79 | 
            +
            ### Proc or Lambda
         | 
| 80 | 
            +
             | 
| 81 | 
            +
            Pass a proc for inline delay calculations:
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            ```ruby
         | 
| 84 | 
            +
            class PollJobStatus < CMDx::Task
         | 
| 85 | 
            +
              # Proc
         | 
| 86 | 
            +
              settings retries: 10, retry_jitter: proc { |retry_count| [retry_count * 0.5, 5.0].min }
         | 
| 87 | 
            +
             | 
| 88 | 
            +
              # Lambda
         | 
| 89 | 
            +
              settings retries: 10, retry_jitter: ->(retry_count) { [retry_count * 0.5, 5.0].min }
         | 
| 90 | 
            +
             | 
| 91 | 
            +
              def work
         | 
| 92 | 
            +
                # Delays: 0.5s, 1.0s, 1.5s, 2.0s, 2.5s, 3.0s, 3.5s, 4.0s, 4.5s, 5.0s (capped)
         | 
| 93 | 
            +
                context.status = JobAPI.check_status(context.job_id)
         | 
| 94 | 
            +
              end
         | 
| 95 | 
            +
            end
         | 
| 96 | 
            +
            ```
         | 
| 97 | 
            +
             | 
| 98 | 
            +
            ### Class or Module
         | 
| 99 | 
            +
             | 
| 100 | 
            +
            Implement reusable delay logic in dedicated modules and classes:
         | 
| 101 | 
            +
             | 
| 102 | 
            +
            ```ruby
         | 
| 103 | 
            +
            class ExponentialBackoff
         | 
| 104 | 
            +
              def call(task, retry_count)
         | 
| 105 | 
            +
                base_delay = task.context.base_delay || 1.0
         | 
| 106 | 
            +
                [base_delay * (2 ** retry_count), 60.0].min
         | 
| 107 | 
            +
              end
         | 
| 108 | 
            +
            end
         | 
| 109 | 
            +
             | 
| 110 | 
            +
            class FetchUserProfile < CMDx::Task
         | 
| 111 | 
            +
              # Class or Module
         | 
| 112 | 
            +
              settings retries: 4, retry_jitter: ExponentialBackoff
         | 
| 113 | 
            +
             | 
| 114 | 
            +
              # Instance
         | 
| 115 | 
            +
              settings retries: 4, retry_jitter: ExponentialBackoff.new
         | 
| 116 | 
            +
             | 
| 117 | 
            +
              def work
         | 
| 118 | 
            +
                # Your logic here...
         | 
| 119 | 
            +
              end
         | 
| 120 | 
            +
            end
         | 
| 121 | 
            +
            ```
         | 
    
        data/docs/tips_and_tricks.md
    CHANGED
    
    | @@ -145,7 +145,8 @@ class ConfigureCompany < CMDx::Task | |
| 145 145 | 
             
            end
         | 
| 146 146 | 
             
            ```
         | 
| 147 147 |  | 
| 148 | 
            -
            ##  | 
| 148 | 
            +
            ## More Examples
         | 
| 149 149 |  | 
| 150 150 | 
             
            - [Active Record Query Tagging](https://github.com/drexed/cmdx/blob/main/examples/active_record_query_tagging.md)
         | 
| 151 151 | 
             
            - [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md)
         | 
| 152 | 
            +
            - [Stoplight Circuit Breaker](https://github.com/drexed/cmdx/blob/main/examples/stoplight_circuit_breaker.md)
         | 
| @@ -0,0 +1,36 @@ | |
| 1 | 
            +
            # Stoplight Circuit Breaker
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            Integrate circuit breakers to protect external service calls and prevent cascading failures when dependencies are unavailable.
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            <https://github.com/bolshakov/stoplight>
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            ### Setup
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            ```ruby
         | 
| 10 | 
            +
            # lib/cmdx_stoplight_middleware.rb
         | 
| 11 | 
            +
            class CmdxStoplightMiddleware
         | 
| 12 | 
            +
              def self.call(task, **options, &)
         | 
| 13 | 
            +
                light = Stoplight(options[:name] || task.class.name, **options)
         | 
| 14 | 
            +
                light.run(&)
         | 
| 15 | 
            +
              rescue Stoplight::Error::RedLight => e
         | 
| 16 | 
            +
                task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e) }
         | 
| 17 | 
            +
              end
         | 
| 18 | 
            +
            end
         | 
| 19 | 
            +
            ```
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            ### Usage
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            ```ruby
         | 
| 24 | 
            +
            class MyTask < CMDx::Task
         | 
| 25 | 
            +
              # With default options
         | 
| 26 | 
            +
              register :middleware, CmdxStoplightMiddleware
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              # With stoplight options
         | 
| 29 | 
            +
              register :middleware, CmdxStoplightMiddleware, cool_off_time: 10
         | 
| 30 | 
            +
             | 
| 31 | 
            +
              def work
         | 
| 32 | 
            +
                # Do work...
         | 
| 33 | 
            +
              end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
            end
         | 
| 36 | 
            +
            ```
         | 
    
        data/lib/cmdx/attribute.rb
    CHANGED
    
    | @@ -6,14 +6,71 @@ module CMDx | |
| 6 6 | 
             
              # They can be nested to create complex hierarchical data structures.
         | 
| 7 7 | 
             
              class Attribute
         | 
| 8 8 |  | 
| 9 | 
            +
                # @rbs AFFIX: Proc
         | 
| 9 10 | 
             
                AFFIX = proc do |value, &block|
         | 
| 10 11 | 
             
                  value == true ? block.call : value
         | 
| 11 12 | 
             
                end.freeze
         | 
| 12 13 | 
             
                private_constant :AFFIX
         | 
| 13 14 |  | 
| 15 | 
            +
                # Returns the task instance associated with this attribute.
         | 
| 16 | 
            +
                #
         | 
| 17 | 
            +
                # @return [CMDx::Task] The task instance
         | 
| 18 | 
            +
                #
         | 
| 19 | 
            +
                # @example
         | 
| 20 | 
            +
                #   attribute.task.context[:user_id] # => 42
         | 
| 21 | 
            +
                #
         | 
| 22 | 
            +
                # @rbs @task: Task
         | 
| 14 23 | 
             
                attr_accessor :task
         | 
| 15 24 |  | 
| 16 | 
            -
                 | 
| 25 | 
            +
                # Returns the name of this attribute.
         | 
| 26 | 
            +
                #
         | 
| 27 | 
            +
                # @return [Symbol] The attribute name
         | 
| 28 | 
            +
                #
         | 
| 29 | 
            +
                # @example
         | 
| 30 | 
            +
                #   attribute.name # => :user_id
         | 
| 31 | 
            +
                #
         | 
| 32 | 
            +
                # @rbs @name: Symbol
         | 
| 33 | 
            +
                attr_reader :name
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                # Returns the configuration options for this attribute.
         | 
| 36 | 
            +
                #
         | 
| 37 | 
            +
                # @return [Hash{Symbol => Object}] Configuration options hash
         | 
| 38 | 
            +
                #
         | 
| 39 | 
            +
                # @example
         | 
| 40 | 
            +
                #   attribute.options # => { required: true, default: 0 }
         | 
| 41 | 
            +
                #
         | 
| 42 | 
            +
                # @rbs @options: Hash[Symbol, untyped]
         | 
| 43 | 
            +
                attr_reader :options
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                # Returns the child attributes for nested structures.
         | 
| 46 | 
            +
                #
         | 
| 47 | 
            +
                # @return [Array<Attribute>] Array of child attributes
         | 
| 48 | 
            +
                #
         | 
| 49 | 
            +
                # @example
         | 
| 50 | 
            +
                #   attribute.children # => [#<Attribute @name=:street>, #<Attribute @name=:city>]
         | 
| 51 | 
            +
                #
         | 
| 52 | 
            +
                # @rbs @children: Array[Attribute]
         | 
| 53 | 
            +
                attr_reader :children
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                # Returns the parent attribute if this is a nested attribute.
         | 
| 56 | 
            +
                #
         | 
| 57 | 
            +
                # @return [Attribute, nil] The parent attribute, or nil if root-level
         | 
| 58 | 
            +
                #
         | 
| 59 | 
            +
                # @example
         | 
| 60 | 
            +
                #   attribute.parent # => #<Attribute @name=:address>
         | 
| 61 | 
            +
                #
         | 
| 62 | 
            +
                # @rbs @parent: (Attribute | nil)
         | 
| 63 | 
            +
                attr_reader :parent
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                # Returns the expected type(s) for this attribute's value.
         | 
| 66 | 
            +
                #
         | 
| 67 | 
            +
                # @return [Array<Class>] Array of expected type classes
         | 
| 68 | 
            +
                #
         | 
| 69 | 
            +
                # @example
         | 
| 70 | 
            +
                #   attribute.types # => [Integer, String]
         | 
| 71 | 
            +
                #
         | 
| 72 | 
            +
                # @rbs @types: Array[Class]
         | 
| 73 | 
            +
                attr_reader :types
         | 
| 17 74 |  | 
| 18 75 | 
             
                # Creates a new attribute with the specified name and configuration.
         | 
| 19 76 | 
             
                #
         | 
| @@ -35,6 +92,8 @@ module CMDx | |
| 35 92 | 
             
                #     required :name, types: String
         | 
| 36 93 | 
             
                #     optional :email, types: String
         | 
| 37 94 | 
             
                #   end
         | 
| 95 | 
            +
                #
         | 
| 96 | 
            +
                # @rbs ((Symbol | String) name, ?Hash[Symbol, untyped] options) ?{ () -> void } -> void
         | 
| 38 97 | 
             
                def initialize(name, options = {}, &)
         | 
| 39 98 | 
             
                  @parent = options.delete(:parent)
         | 
| 40 99 | 
             
                  @required = options.delete(:required) || false
         | 
| @@ -62,6 +121,8 @@ module CMDx | |
| 62 121 | 
             
                  #
         | 
| 63 122 | 
             
                  # @example
         | 
| 64 123 | 
             
                  #   Attribute.build(:first_name, :last_name, required: true, types: String)
         | 
| 124 | 
            +
                  #
         | 
| 125 | 
            +
                  # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 65 126 | 
             
                  def build(*names, **options, &)
         | 
| 66 127 | 
             
                    if names.none?
         | 
| 67 128 | 
             
                      raise ArgumentError, "no attributes given"
         | 
| @@ -83,6 +144,8 @@ module CMDx | |
| 83 144 | 
             
                  #
         | 
| 84 145 | 
             
                  # @example
         | 
| 85 146 | 
             
                  #   Attribute.optional(:description, :tags, types: String)
         | 
| 147 | 
            +
                  #
         | 
| 148 | 
            +
                  # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 86 149 | 
             
                  def optional(*names, **options, &)
         | 
| 87 150 | 
             
                    build(*names, **options.merge(required: false), &)
         | 
| 88 151 | 
             
                  end
         | 
| @@ -98,6 +161,8 @@ module CMDx | |
| 98 161 | 
             
                  #
         | 
| 99 162 | 
             
                  # @example
         | 
| 100 163 | 
             
                  #   Attribute.required(:id, :name, types: [Integer, String])
         | 
| 164 | 
            +
                  #
         | 
| 165 | 
            +
                  # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 101 166 | 
             
                  def required(*names, **options, &)
         | 
| 102 167 | 
             
                    build(*names, **options.merge(required: true), &)
         | 
| 103 168 | 
             
                  end
         | 
| @@ -110,6 +175,8 @@ module CMDx | |
| 110 175 | 
             
                #
         | 
| 111 176 | 
             
                # @example
         | 
| 112 177 | 
             
                #   attribute.required? # => true
         | 
| 178 | 
            +
                #
         | 
| 179 | 
            +
                # @rbs () -> bool
         | 
| 113 180 | 
             
                def required?
         | 
| 114 181 | 
             
                  !!@required
         | 
| 115 182 | 
             
                end
         | 
| @@ -120,6 +187,8 @@ module CMDx | |
| 120 187 | 
             
                #
         | 
| 121 188 | 
             
                # @example
         | 
| 122 189 | 
             
                #   attribute.source # => :context
         | 
| 190 | 
            +
                #
         | 
| 191 | 
            +
                # @rbs () -> untyped
         | 
| 123 192 | 
             
                def source
         | 
| 124 193 | 
             
                  @source ||= parent&.method_name || begin
         | 
| 125 194 | 
             
                    value = options[:source]
         | 
| @@ -140,6 +209,8 @@ module CMDx | |
| 140 209 | 
             
                #
         | 
| 141 210 | 
             
                # @example
         | 
| 142 211 | 
             
                #   attribute.method_name # => :user_name
         | 
| 212 | 
            +
                #
         | 
| 213 | 
            +
                # @rbs () -> Symbol
         | 
| 143 214 | 
             
                def method_name
         | 
| 144 215 | 
             
                  @method_name ||= options[:as] || begin
         | 
| 145 216 | 
             
                    prefix = AFFIX.call(options[:prefix]) { "#{source}_" }
         | 
| @@ -150,6 +221,8 @@ module CMDx | |
| 150 221 | 
             
                end
         | 
| 151 222 |  | 
| 152 223 | 
             
                # Defines and verifies the entire attribute tree including nested children.
         | 
| 224 | 
            +
                #
         | 
| 225 | 
            +
                # @rbs () -> void
         | 
| 153 226 | 
             
                def define_and_verify_tree
         | 
| 154 227 | 
             
                  define_and_verify
         | 
| 155 228 |  | 
| @@ -172,6 +245,8 @@ module CMDx | |
| 172 245 | 
             
                #
         | 
| 173 246 | 
             
                # @example
         | 
| 174 247 | 
             
                #   attributes :street, :city, :zip, types: String
         | 
| 248 | 
            +
                #
         | 
| 249 | 
            +
                # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 175 250 | 
             
                def attributes(*names, **options, &)
         | 
| 176 251 | 
             
                  attrs = self.class.build(*names, **options.merge(parent: self), &)
         | 
| 177 252 | 
             
                  children.concat(attrs)
         | 
| @@ -189,6 +264,8 @@ module CMDx | |
| 189 264 | 
             
                #
         | 
| 190 265 | 
             
                # @example
         | 
| 191 266 | 
             
                #   optional :middle_name, :nickname, types: String
         | 
| 267 | 
            +
                #
         | 
| 268 | 
            +
                # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 192 269 | 
             
                def optional(*names, **options, &)
         | 
| 193 270 | 
             
                  attributes(*names, **options.merge(required: false), &)
         | 
| 194 271 | 
             
                end
         | 
| @@ -204,6 +281,8 @@ module CMDx | |
| 204 281 | 
             
                #
         | 
| 205 282 | 
             
                # @example
         | 
| 206 283 | 
             
                #   required :first_name, :last_name, types: String
         | 
| 284 | 
            +
                #
         | 
| 285 | 
            +
                # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute]
         | 
| 207 286 | 
             
                def required(*names, **options, &)
         | 
| 208 287 | 
             
                  attributes(*names, **options.merge(required: true), &)
         | 
| 209 288 | 
             
                end
         | 
| @@ -211,6 +290,8 @@ module CMDx | |
| 211 290 | 
             
                # Defines the attribute method on the task and validates the configuration.
         | 
| 212 291 | 
             
                #
         | 
| 213 292 | 
             
                # @raise [RuntimeError] When the method name is already defined on the task
         | 
| 293 | 
            +
                #
         | 
| 294 | 
            +
                # @rbs () -> void
         | 
| 214 295 | 
             
                def define_and_verify
         | 
| 215 296 | 
             
                  if task.respond_to?(method_name, true)
         | 
| 216 297 | 
             
                    raise <<~MESSAGE
         | 
| @@ -6,6 +6,14 @@ module CMDx | |
| 6 6 | 
             
              # in a hierarchical structure, supporting nested attribute definitions.
         | 
| 7 7 | 
             
              class AttributeRegistry
         | 
| 8 8 |  | 
| 9 | 
            +
                # Returns the collection of registered attributes.
         | 
| 10 | 
            +
                #
         | 
| 11 | 
            +
                # @return [Array<Attribute>] Array of registered attributes
         | 
| 12 | 
            +
                #
         | 
| 13 | 
            +
                # @example
         | 
| 14 | 
            +
                #   registry.registry # => [#<Attribute @name=:name>, #<Attribute @name=:email>]
         | 
| 15 | 
            +
                #
         | 
| 16 | 
            +
                # @rbs @registry: Array[Attribute]
         | 
| 9 17 | 
             
                attr_reader :registry
         | 
| 10 18 | 
             
                alias to_a registry
         | 
| 11 19 |  | 
| @@ -18,6 +26,8 @@ module CMDx | |
| 18 26 | 
             
                # @example
         | 
| 19 27 | 
             
                #   registry = AttributeRegistry.new
         | 
| 20 28 | 
             
                #   registry = AttributeRegistry.new([attr1, attr2])
         | 
| 29 | 
            +
                #
         | 
| 30 | 
            +
                # @rbs (?Array[Attribute] registry) -> void
         | 
| 21 31 | 
             
                def initialize(registry = [])
         | 
| 22 32 | 
             
                  @registry = registry
         | 
| 23 33 | 
             
                end
         | 
| @@ -28,6 +38,8 @@ module CMDx | |
| 28 38 | 
             
                #
         | 
| 29 39 | 
             
                # @example
         | 
| 30 40 | 
             
                #   new_registry = registry.dup
         | 
| 41 | 
            +
                #
         | 
| 42 | 
            +
                # @rbs () -> AttributeRegistry
         | 
| 31 43 | 
             
                def dup
         | 
| 32 44 | 
             
                  self.class.new(registry.dup)
         | 
| 33 45 | 
             
                end
         | 
| @@ -41,6 +53,8 @@ module CMDx | |
| 41 53 | 
             
                # @example
         | 
| 42 54 | 
             
                #   registry.register(attribute)
         | 
| 43 55 | 
             
                #   registry.register([attr1, attr2])
         | 
| 56 | 
            +
                #
         | 
| 57 | 
            +
                # @rbs (Attribute | Array[Attribute] attributes) -> self
         | 
| 44 58 | 
             
                def register(attributes)
         | 
| 45 59 | 
             
                  @registry.concat(Array(attributes))
         | 
| 46 60 | 
             
                  self
         | 
| @@ -56,6 +70,8 @@ module CMDx | |
| 56 70 | 
             
                # @example
         | 
| 57 71 | 
             
                #   registry.deregister(:name)
         | 
| 58 72 | 
             
                #   registry.deregister(['name1', 'name2'])
         | 
| 73 | 
            +
                #
         | 
| 74 | 
            +
                # @rbs ((Symbol | String | Array[Symbol | String]) names) -> self
         | 
| 59 75 | 
             
                def deregister(names)
         | 
| 60 76 | 
             
                  Array(names).each do |name|
         | 
| 61 77 | 
             
                    @registry.reject! { |attribute| matches_attribute_tree?(attribute, name.to_sym) }
         | 
| @@ -69,6 +85,8 @@ module CMDx | |
| 69 85 | 
             
                # and validate the attribute hierarchy.
         | 
| 70 86 | 
             
                #
         | 
| 71 87 | 
             
                # @param task [Task] The task to associate with all attributes
         | 
| 88 | 
            +
                #
         | 
| 89 | 
            +
                # @rbs (Task task) -> void
         | 
| 72 90 | 
             
                def define_and_verify(task)
         | 
| 73 91 | 
             
                  registry.each do |attribute|
         | 
| 74 92 | 
             
                    attribute.task = task
         | 
| @@ -84,6 +102,8 @@ module CMDx | |
| 84 102 | 
             
                # @param name [Symbol] The name to match against
         | 
| 85 103 | 
             
                #
         | 
| 86 104 | 
             
                # @return [Boolean] True if the attribute or any child matches the name
         | 
| 105 | 
            +
                #
         | 
| 106 | 
            +
                # @rbs (Attribute attribute, Symbol name) -> bool
         | 
| 87 107 | 
             
                def matches_attribute_tree?(attribute, name)
         | 
| 88 108 | 
             
                  return true if attribute.method_name == name
         | 
| 89 109 |  | 
    
        data/lib/cmdx/attribute_value.rb
    CHANGED
    
    | @@ -8,6 +8,14 @@ module CMDx | |
| 8 8 |  | 
| 9 9 | 
             
                extend Forwardable
         | 
| 10 10 |  | 
| 11 | 
            +
                # Returns the attribute managed by this value handler.
         | 
| 12 | 
            +
                #
         | 
| 13 | 
            +
                # @return [Attribute] The attribute instance
         | 
| 14 | 
            +
                #
         | 
| 15 | 
            +
                # @example
         | 
| 16 | 
            +
                #   attr_value.attribute.name # => :user_id
         | 
| 17 | 
            +
                #
         | 
| 18 | 
            +
                # @rbs @attribute: Attribute
         | 
| 11 19 | 
             
                attr_reader :attribute
         | 
| 12 20 |  | 
| 13 21 | 
             
                def_delegators :attribute, :task, :parent, :name, :options, :types, :source, :method_name, :required?
         | 
| @@ -20,6 +28,8 @@ module CMDx | |
| 20 28 | 
             
                # @example
         | 
| 21 29 | 
             
                #   attr = Attribute.new(:user_id, required: true)
         | 
| 22 30 | 
             
                #   attr_value = AttributeValue.new(attr)
         | 
| 31 | 
            +
                #
         | 
| 32 | 
            +
                # @rbs (Attribute attribute) -> void
         | 
| 23 33 | 
             
                def initialize(attribute)
         | 
| 24 34 | 
             
                  @attribute = attribute
         | 
| 25 35 | 
             
                end
         | 
| @@ -30,6 +40,8 @@ module CMDx | |
| 30 40 | 
             
                #
         | 
| 31 41 | 
             
                # @example
         | 
| 32 42 | 
             
                #   attr_value.value # => "john_doe"
         | 
| 43 | 
            +
                #
         | 
| 44 | 
            +
                # @rbs () -> untyped
         | 
| 33 45 | 
             
                def value
         | 
| 34 46 | 
             
                  attributes[method_name]
         | 
| 35 47 | 
             
                end
         | 
| @@ -41,6 +53,8 @@ module CMDx | |
| 41 53 | 
             
                #
         | 
| 42 54 | 
             
                # @example
         | 
| 43 55 | 
             
                #   attr_value.generate # => 42
         | 
| 56 | 
            +
                #
         | 
| 57 | 
            +
                # @rbs () -> untyped
         | 
| 44 58 | 
             
                def generate
         | 
| 45 59 | 
             
                  return value if attributes.key?(method_name)
         | 
| 46 60 |  | 
| @@ -64,6 +78,8 @@ module CMDx | |
| 64 78 | 
             
                # @example
         | 
| 65 79 | 
             
                #   attr_value.validate
         | 
| 66 80 | 
             
                #   # Validates value against :presence, :format, etc.
         | 
| 81 | 
            +
                #
         | 
| 82 | 
            +
                # @rbs () -> void
         | 
| 67 83 | 
             
                def validate
         | 
| 68 84 | 
             
                  registry = task.class.settings[:validators]
         | 
| 69 85 |  | 
| @@ -86,6 +102,7 @@ module CMDx | |
| 86 102 | 
             
                # @example
         | 
| 87 103 | 
             
                #   # Sources from task method, proc, or direct value
         | 
| 88 104 | 
             
                #   source_value # => "raw_value"
         | 
| 105 | 
            +
                # @rbs () -> untyped
         | 
| 89 106 | 
             
                def source_value
         | 
| 90 107 | 
             
                  sourced_value =
         | 
| 91 108 | 
             
                    case source
         | 
| @@ -115,6 +132,8 @@ module CMDx | |
| 115 132 | 
             
                # @example
         | 
| 116 133 | 
             
                #   # Default can be symbol, proc, or direct value
         | 
| 117 134 | 
             
                #   -> { rand(100) } # => 23
         | 
| 135 | 
            +
                #
         | 
| 136 | 
            +
                # @rbs () -> untyped
         | 
| 118 137 | 
             
                def default_value
         | 
| 119 138 | 
             
                  default = options[:default]
         | 
| 120 139 |  | 
| @@ -140,6 +159,8 @@ module CMDx | |
| 140 159 | 
             
                # @example
         | 
| 141 160 | 
             
                #   # Derives from hash key, method call, or proc execution
         | 
| 142 161 | 
             
                #   context.user_id # => 42
         | 
| 162 | 
            +
                #
         | 
| 163 | 
            +
                # @rbs (untyped source_value) -> untyped
         | 
| 143 164 | 
             
                def derive_value(source_value)
         | 
| 144 165 | 
             
                  derived_value =
         | 
| 145 166 | 
             
                    case source_value
         | 
| @@ -163,6 +184,8 @@ module CMDx | |
| 163 184 | 
             
                #
         | 
| 164 185 | 
             
                # @example
         | 
| 165 186 | 
             
                #   :downcase # => "hello"
         | 
| 187 | 
            +
                #
         | 
| 188 | 
            +
                # @rbs (untyped derived_value) -> untyped
         | 
| 166 189 | 
             
                def transform_value(derived_value)
         | 
| 167 190 | 
             
                  transform = options[:transform]
         | 
| 168 191 |  | 
| @@ -186,6 +209,8 @@ module CMDx | |
| 186 209 | 
             
                # @example
         | 
| 187 210 | 
             
                #   # Coerces "42" to Integer, "true" to Boolean, etc.
         | 
| 188 211 | 
             
                #   coerce_value("42") # => 42
         | 
| 212 | 
            +
                #
         | 
| 213 | 
            +
                # @rbs (untyped transformed_value) -> untyped
         | 
| 189 214 | 
             
                def coerce_value(transformed_value)
         | 
| 190 215 | 
             
                  return transformed_value if types.empty?
         | 
| 191 216 |  |