cmdx 0.5.0 → 1.0.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/.DS_Store +0 -0
- data/.cursor/rules/cursor-instructions.mdc +6 -0
- data/.rubocop.yml +16 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +31 -1
- data/README.md +72 -25
- data/docs/ai_prompts.md +309 -0
- data/docs/basics/call.md +225 -14
- data/docs/basics/chain.md +271 -0
- data/docs/basics/context.md +232 -33
- data/docs/basics/setup.md +76 -12
- data/docs/callbacks.md +273 -0
- data/docs/configuration.md +158 -28
- data/docs/getting_started.md +134 -22
- data/docs/interruptions/exceptions.md +189 -11
- data/docs/interruptions/faults.md +187 -44
- data/docs/interruptions/halt.md +179 -35
- data/docs/logging.md +194 -53
- data/docs/middlewares.md +735 -0
- data/docs/outcomes/result.md +296 -10
- data/docs/outcomes/states.md +203 -31
- data/docs/outcomes/statuses.md +275 -30
- data/docs/parameters/coercions.md +402 -29
- data/docs/parameters/defaults.md +249 -25
- data/docs/parameters/definitions.md +238 -72
- data/docs/parameters/namespacing.md +250 -27
- data/docs/parameters/validations.md +193 -168
- data/docs/testing.md +550 -0
- data/docs/tips_and_tricks.md +95 -43
- data/docs/workflows.md +319 -0
- data/lib/cmdx/.DS_Store +0 -0
- data/lib/cmdx/callback.rb +69 -0
- data/lib/cmdx/callback_registry.rb +106 -0
- data/lib/cmdx/chain.rb +190 -0
- data/lib/cmdx/chain_inspector.rb +149 -0
- data/lib/cmdx/chain_serializer.rb +175 -0
- data/lib/cmdx/coercions/array.rb +37 -0
- data/lib/cmdx/coercions/big_decimal.rb +33 -0
- data/lib/cmdx/coercions/boolean.rb +41 -1
- data/lib/cmdx/coercions/complex.rb +31 -0
- data/lib/cmdx/coercions/date.rb +39 -0
- data/lib/cmdx/coercions/date_time.rb +39 -0
- data/lib/cmdx/coercions/float.rb +31 -0
- data/lib/cmdx/coercions/hash.rb +42 -0
- data/lib/cmdx/coercions/integer.rb +32 -0
- data/lib/cmdx/coercions/rational.rb +31 -0
- data/lib/cmdx/coercions/string.rb +31 -0
- data/lib/cmdx/coercions/time.rb +39 -0
- data/lib/cmdx/coercions/virtual.rb +31 -0
- data/lib/cmdx/configuration.rb +217 -9
- data/lib/cmdx/context.rb +173 -2
- data/lib/cmdx/core_ext/hash.rb +72 -0
- data/lib/cmdx/core_ext/module.rb +94 -0
- data/lib/cmdx/core_ext/object.rb +105 -0
- data/lib/cmdx/correlator.rb +217 -0
- data/lib/cmdx/error.rb +210 -8
- data/lib/cmdx/errors.rb +256 -1
- data/lib/cmdx/fault.rb +177 -2
- data/lib/cmdx/faults.rb +158 -2
- data/lib/cmdx/immutator.rb +121 -2
- data/lib/cmdx/lazy_struct.rb +261 -18
- data/lib/cmdx/log_formatters/json.rb +46 -0
- data/lib/cmdx/log_formatters/key_value.rb +46 -0
- data/lib/cmdx/log_formatters/line.rb +54 -0
- data/lib/cmdx/log_formatters/logstash.rb +64 -0
- data/lib/cmdx/log_formatters/pretty_json.rb +57 -0
- data/lib/cmdx/log_formatters/pretty_key_value.rb +51 -0
- data/lib/cmdx/log_formatters/pretty_line.rb +60 -0
- data/lib/cmdx/log_formatters/raw.rb +54 -0
- data/lib/cmdx/logger.rb +85 -0
- data/lib/cmdx/logger_ansi.rb +93 -7
- data/lib/cmdx/logger_serializer.rb +116 -0
- data/lib/cmdx/middleware.rb +74 -0
- data/lib/cmdx/middleware_registry.rb +106 -0
- data/lib/cmdx/middlewares/correlate.rb +266 -0
- data/lib/cmdx/middlewares/timeout.rb +232 -0
- data/lib/cmdx/parameter.rb +228 -1
- data/lib/cmdx/parameter_inspector.rb +61 -0
- data/lib/cmdx/parameter_registry.rb +125 -0
- data/lib/cmdx/parameter_serializer.rb +83 -0
- data/lib/cmdx/parameter_validator.rb +62 -0
- data/lib/cmdx/parameter_value.rb +109 -1
- data/lib/cmdx/parameters_inspector.rb +59 -0
- data/lib/cmdx/parameters_serializer.rb +102 -0
- data/lib/cmdx/railtie.rb +123 -3
- data/lib/cmdx/result.rb +367 -25
- data/lib/cmdx/result_ansi.rb +105 -9
- data/lib/cmdx/result_inspector.rb +76 -0
- data/lib/cmdx/result_logger.rb +90 -3
- data/lib/cmdx/result_serializer.rb +137 -0
- data/lib/cmdx/rspec/result_matchers.rb +917 -0
- data/lib/cmdx/rspec/task_matchers.rb +570 -0
- data/lib/cmdx/task.rb +405 -37
- data/lib/cmdx/task_serializer.rb +74 -2
- data/lib/cmdx/utils/ansi_color.rb +95 -0
- data/lib/cmdx/utils/log_timestamp.rb +48 -0
- data/lib/cmdx/utils/monotonic_runtime.rb +71 -4
- data/lib/cmdx/utils/name_affix.rb +78 -0
- data/lib/cmdx/validators/custom.rb +82 -0
- data/lib/cmdx/validators/exclusion.rb +94 -0
- data/lib/cmdx/validators/format.rb +102 -8
- data/lib/cmdx/validators/inclusion.rb +104 -0
- data/lib/cmdx/validators/length.rb +128 -0
- data/lib/cmdx/validators/numeric.rb +128 -0
- data/lib/cmdx/validators/presence.rb +93 -7
- data/lib/cmdx/version.rb +7 -1
- data/lib/cmdx/workflow.rb +394 -0
- data/lib/cmdx.rb +25 -64
- data/lib/generators/cmdx/install_generator.rb +37 -1
- data/lib/generators/cmdx/task_generator.rb +69 -1
- data/lib/generators/cmdx/templates/install.rb +8 -12
- data/lib/generators/cmdx/workflow_generator.rb +109 -0
- metadata +54 -15
- data/docs/basics/run.md +0 -34
- data/docs/batch.md +0 -53
- data/docs/example.md +0 -82
- data/docs/hooks.md +0 -62
- data/lib/cmdx/batch.rb +0 -43
- data/lib/cmdx/parameters.rb +0 -35
- data/lib/cmdx/run.rb +0 -39
- data/lib/cmdx/run_inspector.rb +0 -26
- data/lib/cmdx/run_serializer.rb +0 -20
- data/lib/cmdx/task_hook.rb +0 -18
- data/lib/generators/cmdx/batch_generator.rb +0 -30
- /data/lib/generators/cmdx/templates/{batch.rb.tt → workflow.rb.tt} +0 -0
data/lib/cmdx/faults.rb
CHANGED
@@ -2,10 +2,166 @@
|
|
2
2
|
|
3
3
|
module CMDx
|
4
4
|
|
5
|
-
|
5
|
+
##
|
6
|
+
# Skipped is a specific fault type raised when a task execution is intentionally
|
7
|
+
# skipped via the `skip!` method. This represents a controlled interruption where
|
8
|
+
# the task determines that execution is not necessary or appropriate under the
|
9
|
+
# current conditions.
|
10
|
+
#
|
11
|
+
# Skipped faults are typically used for:
|
12
|
+
# - Conditional logic where certain conditions make execution unnecessary
|
13
|
+
# - Early returns when prerequisites are not met
|
14
|
+
# - Business logic that determines the operation is redundant
|
15
|
+
# - Graceful handling of edge cases that don't constitute errors
|
16
|
+
#
|
17
|
+
# @example Basic skip usage
|
18
|
+
# class ProcessOrderTask < CMDx::Task
|
19
|
+
# required :order_id, type: :integer
|
20
|
+
#
|
21
|
+
# def call
|
22
|
+
# context.order = Order.find(order_id)
|
23
|
+
# skip!(reason: "Order already processed") if context.order.processed?
|
24
|
+
#
|
25
|
+
# context.order.process!
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# # Non-bang call returns result
|
30
|
+
# result = ProcessOrderTask.call(order_id: 123)
|
31
|
+
# result.skipped? #=> true
|
32
|
+
# result.metadata[:reason] #=> "Order already processed"
|
33
|
+
#
|
34
|
+
# # Bang call raises exception
|
35
|
+
# begin
|
36
|
+
# ProcessOrderTask.call!(order_id: 123)
|
37
|
+
# rescue CMDx::Skipped => e
|
38
|
+
# puts "Skipped: #{e.message}"
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# @example Conditional skip logic
|
42
|
+
# class SendNotificationTask < CMDx::Task
|
43
|
+
# required :user_id, type: :integer
|
44
|
+
# optional :force, type: :boolean, default: false
|
45
|
+
#
|
46
|
+
# def call
|
47
|
+
# context.user = User.find(user_id)
|
48
|
+
#
|
49
|
+
# unless force || context.user.notifications_enabled?
|
50
|
+
# skip!(reason: "User has notifications disabled")
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# NotificationService.send(context.user)
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# @example Handling skipped tasks in workflows
|
58
|
+
# begin
|
59
|
+
# OrderProcessingWorkflow.call!(orders: orders)
|
60
|
+
# rescue CMDx::Skipped => e
|
61
|
+
# # Log skipped operations but continue processing
|
62
|
+
# logger.info "Skipped processing: #{e.message}"
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# @see Fault Base fault class with advanced matching capabilities
|
66
|
+
# @see Failed Failed fault type for error conditions
|
67
|
+
# @see Result#skip! Method for triggering skipped faults
|
68
|
+
# @since 1.0.0
|
6
69
|
Skipped = Class.new(Fault)
|
7
70
|
|
8
|
-
|
71
|
+
##
|
72
|
+
# Failed is a specific fault type raised when a task execution encounters an
|
73
|
+
# error condition via the `fail!` method. This represents a controlled failure
|
74
|
+
# where the task explicitly determines that execution cannot continue successfully.
|
75
|
+
#
|
76
|
+
# Failed faults are typically used for:
|
77
|
+
# - Validation errors that prevent successful execution
|
78
|
+
# - Business rule violations that constitute failures
|
79
|
+
# - Resource unavailability or constraint violations
|
80
|
+
# - Explicit error conditions that require attention
|
81
|
+
#
|
82
|
+
# @example Basic failure usage
|
83
|
+
# class ProcessPaymentTask < CMDx::Task
|
84
|
+
# required :payment_amount, type: :float
|
85
|
+
# required :payment_method, type: :string
|
86
|
+
#
|
87
|
+
# def call
|
88
|
+
# unless payment_amount > 0
|
89
|
+
# fail!(reason: "Payment amount must be positive", code: "INVALID_AMOUNT")
|
90
|
+
# end
|
91
|
+
#
|
92
|
+
# unless valid_payment_method?
|
93
|
+
# fail!(reason: "Invalid payment method", code: "INVALID_METHOD")
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# process_payment
|
97
|
+
# end
|
98
|
+
# end
|
99
|
+
#
|
100
|
+
# # Non-bang call returns result
|
101
|
+
# result = ProcessPaymentTask.call(payment_amount: -10, payment_method: "card")
|
102
|
+
# result.failed? #=> true
|
103
|
+
# result.metadata[:reason] #=> "Payment amount must be positive"
|
104
|
+
# result.metadata[:code] #=> "INVALID_AMOUNT"
|
105
|
+
#
|
106
|
+
# # Bang call raises exception
|
107
|
+
# begin
|
108
|
+
# ProcessPaymentTask.call!(payment_amount: -10, payment_method: "card")
|
109
|
+
# rescue CMDx::Failed => e
|
110
|
+
# puts "Failed: #{e.message}"
|
111
|
+
# puts "Error code: #{e.result.metadata[:code]}"
|
112
|
+
# end
|
113
|
+
#
|
114
|
+
# @example Validation failure with detailed metadata
|
115
|
+
# class CreateUserTask < CMDx::Task
|
116
|
+
# required :email, type: :string
|
117
|
+
# required :password, type: :string
|
118
|
+
#
|
119
|
+
# def call
|
120
|
+
# if User.exists?(email: email)
|
121
|
+
# fail!(
|
122
|
+
# "Email already exists",
|
123
|
+
# code: "EMAIL_EXISTS",
|
124
|
+
# field: "email",
|
125
|
+
# suggested_action: "Use different email or login instead"
|
126
|
+
# )
|
127
|
+
# end
|
128
|
+
#
|
129
|
+
# context.user = User.create!(email: email, password: password)
|
130
|
+
# end
|
131
|
+
# end
|
132
|
+
#
|
133
|
+
# @example Handling specific failure types
|
134
|
+
# begin
|
135
|
+
# ProcessOrderTask.call!(order_id: 123)
|
136
|
+
# rescue CMDx::Failed.matches? { |f| f.result.metadata[:code] == "PAYMENT_DECLINED" } => e
|
137
|
+
# # Handle payment failures specifically
|
138
|
+
# retry_with_backup_payment_method(e.context)
|
139
|
+
# rescue CMDx::Failed => e
|
140
|
+
# # Handle all other failures
|
141
|
+
# log_failure_and_notify_support(e)
|
142
|
+
# end
|
143
|
+
#
|
144
|
+
# @example Failure propagation in complex workflows
|
145
|
+
# class OrderFulfillmentTask < CMDx::Task
|
146
|
+
# def call
|
147
|
+
# payment_result = ProcessPaymentTask.call(context)
|
148
|
+
#
|
149
|
+
# if payment_result.failed?
|
150
|
+
# fail!(
|
151
|
+
# "Cannot fulfill order due to payment failure",
|
152
|
+
# code: "PAYMENT_REQUIRED",
|
153
|
+
# original_error: payment_result.metadata
|
154
|
+
# )
|
155
|
+
# end
|
156
|
+
#
|
157
|
+
# fulfill_order
|
158
|
+
# end
|
159
|
+
# end
|
160
|
+
#
|
161
|
+
# @see Fault Base fault class with advanced matching capabilities
|
162
|
+
# @see Skipped Skipped fault type for conditional interruptions
|
163
|
+
# @see Result#fail! Method for triggering failed faults
|
164
|
+
# @since 1.0.0
|
9
165
|
Failed = Class.new(Fault)
|
10
166
|
|
11
167
|
end
|
data/lib/cmdx/immutator.rb
CHANGED
@@ -1,20 +1,139 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module CMDx
|
4
|
+
##
|
5
|
+
# Immutator provides task finalization by freezing objects to prevent mutation
|
6
|
+
# after task execution is complete. This ensures task immutability and prevents
|
7
|
+
# accidental side effects or modifications to completed task instances.
|
8
|
+
#
|
9
|
+
# The Immutator is automatically called during the task termination phase as part
|
10
|
+
# of the execution lifecycle. It freezes the task instance, its result, and
|
11
|
+
# associated objects to maintain data integrity and enforce the single-use pattern
|
12
|
+
# of CMDx tasks.
|
13
|
+
#
|
14
|
+
# ## Freezing Strategy
|
15
|
+
#
|
16
|
+
# The Immutator employs a selective freezing strategy:
|
17
|
+
# 1. **Task Instance**: Always frozen to prevent method calls and modifications
|
18
|
+
# 2. **Result Object**: Always frozen to preserve execution outcome
|
19
|
+
# 3. **Context Object**: Frozen only for the first task in a chain (index 0)
|
20
|
+
# 4. **Chain Object**: Frozen only for the first task in a chain (index 0)
|
21
|
+
#
|
22
|
+
# This selective approach allows subsequent tasks in a workflow or chain to continue
|
23
|
+
# using the shared context and chain objects while ensuring completed tasks remain
|
24
|
+
# immutable.
|
25
|
+
#
|
26
|
+
# ## Test Environment Handling
|
27
|
+
#
|
28
|
+
# In test environments (Rails or Rack), freezing is automatically disabled to
|
29
|
+
# prevent conflicts with test frameworks that may need to stub or mock frozen
|
30
|
+
# objects. This ensures smooth testing without compromising the immutability
|
31
|
+
# guarantees in production environments.
|
32
|
+
#
|
33
|
+
# @example Task execution with automatic freezing
|
34
|
+
# class ProcessOrderTask < CMDx::Task
|
35
|
+
# required :order_id, type: :integer
|
36
|
+
#
|
37
|
+
# def call
|
38
|
+
# context.order = Order.find(order_id)
|
39
|
+
# context.order.process!
|
40
|
+
# end
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# result = ProcessOrderTask.call(order_id: 123)
|
44
|
+
# result.frozen? #=> true
|
45
|
+
# result.task.frozen? #=> true
|
46
|
+
# result.context.frozen? #=> true (if first task in chain)
|
47
|
+
#
|
48
|
+
# @example Attempting to modify frozen task (will raise error)
|
49
|
+
# result = ProcessOrderTask.call(order_id: 123)
|
50
|
+
# result.context.new_field = "value" #=> FrozenError
|
51
|
+
# result.task.call #=> FrozenError
|
52
|
+
#
|
53
|
+
# @example Workflow execution with selective freezing
|
54
|
+
# class OrderWorkflow < CMDx::Workflow
|
55
|
+
# def call
|
56
|
+
# ProcessOrderTask.call(context)
|
57
|
+
# SendEmailTask.call(context)
|
58
|
+
# end
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# result = OrderWorkflow.call(order_id: 123)
|
62
|
+
# # First task freezes context and chain
|
63
|
+
# # Second task can still use unfrozen context for execution
|
64
|
+
# # But both task instances are individually frozen
|
65
|
+
#
|
66
|
+
# @example Test environment behavior
|
67
|
+
# # In test environment (SKIP_CMDX_FREEZING=1)
|
68
|
+
# result = ProcessOrderTask.call(order_id: 123)
|
69
|
+
# result.frozen? #=> false (freezing disabled)
|
70
|
+
# result.task.frozen? #=> false (allows stubbing/mocking)
|
71
|
+
#
|
72
|
+
# @see Task Task execution lifecycle
|
73
|
+
# @see Result Result object that gets frozen
|
74
|
+
# @see Context Context object that may get frozen
|
75
|
+
# @see Chain Chain object that may get frozen
|
76
|
+
# @since 1.0.0
|
4
77
|
module Immutator
|
5
78
|
|
6
79
|
module_function
|
7
80
|
|
81
|
+
##
|
82
|
+
# Freezes task-related objects to ensure immutability after execution.
|
83
|
+
# This method is called automatically during task termination and implements
|
84
|
+
# a selective freezing strategy based on task position within a chain.
|
85
|
+
#
|
86
|
+
# The freezing process:
|
87
|
+
# 1. Checks if running in test environment and skips freezing if so
|
88
|
+
# 2. Always freezes the task instance and its result
|
89
|
+
# 3. Freezes context and chain only for the first task (index 0) in a chain
|
90
|
+
#
|
91
|
+
# This selective approach ensures that:
|
92
|
+
# - Completed tasks cannot be modified or re-executed
|
93
|
+
# - Results remain immutable and trustworthy
|
94
|
+
# - Shared objects (context/chain) remain available for subsequent tasks
|
95
|
+
# - Test environments can continue to function with mocking/stubbing
|
96
|
+
#
|
97
|
+
# @param task [Task] the task instance to freeze along with its associated objects
|
98
|
+
# @return [void]
|
99
|
+
#
|
100
|
+
# @example First task in chain (freezes everything)
|
101
|
+
# task = ProcessOrderTask.call(order_id: 123)
|
102
|
+
# # task.result.index == 0
|
103
|
+
# Immutator.call(task)
|
104
|
+
# # Freezes: task, result, context, chain
|
105
|
+
#
|
106
|
+
# @example Subsequent task in chain (selective freezing)
|
107
|
+
# # After first task has run
|
108
|
+
# task = SendEmailTask.call(context)
|
109
|
+
# # task.result.index == 1
|
110
|
+
# Immutator.call(task)
|
111
|
+
# # Freezes: task, result (context and chain remain unfrozen)
|
112
|
+
#
|
113
|
+
# @example Test environment (no freezing)
|
114
|
+
# ENV["RAILS_ENV"] = "test"
|
115
|
+
# task = ProcessOrderTask.call(order_id: 123)
|
116
|
+
# Immutator.call(task)
|
117
|
+
# # No objects are frozen, allows test stubbing
|
118
|
+
#
|
119
|
+
# @note This method is automatically called by the task execution framework
|
120
|
+
# and should not typically be called directly by user code.
|
121
|
+
#
|
122
|
+
# @note Freezing is skipped entirely in test environments to prevent conflicts
|
123
|
+
# with test frameworks that need to stub or mock objects.
|
8
124
|
def call(task)
|
9
125
|
# Stubbing on frozen objects is not allowed
|
10
|
-
|
126
|
+
skip_freezing = ENV.fetch("SKIP_CMDX_FREEZING", false)
|
127
|
+
return if Coercions::Boolean.call(skip_freezing)
|
11
128
|
|
12
129
|
task.freeze
|
13
130
|
task.result.freeze
|
14
131
|
return unless task.result.index.zero?
|
15
132
|
|
16
133
|
task.context.freeze
|
17
|
-
task.
|
134
|
+
task.chain.freeze
|
135
|
+
|
136
|
+
Chain.clear
|
18
137
|
end
|
19
138
|
|
20
139
|
end
|
data/lib/cmdx/lazy_struct.rb
CHANGED
@@ -1,78 +1,321 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module CMDx
|
4
|
+
##
|
5
|
+
# LazyStruct provides a flexible, hash-like data structure with dynamic method access
|
6
|
+
# and lazy attribute definition. It serves as the foundation for CMDx's Context system,
|
7
|
+
# allowing for dynamic parameter access and manipulation with both hash-style and
|
8
|
+
# method-style syntax.
|
9
|
+
#
|
10
|
+
# LazyStruct combines the flexibility of a Hash with the convenience of method access,
|
11
|
+
# automatically creating getter and setter methods for any key-value pairs stored within it.
|
12
|
+
# All keys are normalized to symbols for consistent access patterns.
|
13
|
+
#
|
14
|
+
#
|
15
|
+
# @example Basic usage
|
16
|
+
# struct = LazyStruct.new(name: "John", age: 30)
|
17
|
+
# struct.name #=> "John"
|
18
|
+
# struct.age #=> 30
|
19
|
+
# struct[:name] #=> "John"
|
20
|
+
# struct["age"] #=> 30
|
21
|
+
#
|
22
|
+
# @example Dynamic attribute assignment
|
23
|
+
# struct = LazyStruct.new
|
24
|
+
# struct.email = "john@example.com"
|
25
|
+
# struct[:phone] = "555-1234"
|
26
|
+
# struct["address"] = "123 Main St"
|
27
|
+
#
|
28
|
+
# struct.email #=> "john@example.com"
|
29
|
+
# struct.phone #=> "555-1234"
|
30
|
+
# struct.address #=> "123 Main St"
|
31
|
+
#
|
32
|
+
# @example Hash-like operations
|
33
|
+
# struct = LazyStruct.new(name: "John")
|
34
|
+
# struct.merge!(age: 30, city: "NYC")
|
35
|
+
# struct.delete!(:city)
|
36
|
+
# struct.to_h #=> {:name => "John", :age => 30}
|
37
|
+
#
|
38
|
+
# @example Nested data access
|
39
|
+
# struct = LazyStruct.new(user: {profile: {name: "John"}})
|
40
|
+
# struct.dig(:user, :profile, :name) #=> "John"
|
41
|
+
#
|
42
|
+
# @example Usage in CMDx Context
|
43
|
+
# class ProcessUserTask < CMDx::Task
|
44
|
+
# required :user_id, type: :integer
|
45
|
+
#
|
46
|
+
# def call
|
47
|
+
# context.user = User.find(user_id)
|
48
|
+
# context.processed_at = Time.now
|
49
|
+
# context.result_data = {status: "complete"}
|
50
|
+
# end
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# result = ProcessUserTask.call(user_id: 123)
|
54
|
+
# result.context.user #=> <User id: 123>
|
55
|
+
# result.context.processed_at #=> 2023-01-01 12:00:00 UTC
|
56
|
+
#
|
57
|
+
# @see Context Context class that inherits from LazyStruct
|
58
|
+
# @see Configuration Configuration class that uses LazyStruct
|
59
|
+
# @since 1.0.0
|
4
60
|
class LazyStruct
|
5
61
|
|
62
|
+
##
|
63
|
+
# Initializes a new LazyStruct with the given data.
|
64
|
+
# The input must respond to `to_h` for hash conversion.
|
65
|
+
#
|
66
|
+
# @param args [Hash, #to_h] initial data for the struct
|
67
|
+
# @raise [ArgumentError] if args doesn't respond to `to_h`
|
68
|
+
#
|
69
|
+
# @example With hash
|
70
|
+
# struct = LazyStruct.new(name: "John", age: 30)
|
71
|
+
#
|
72
|
+
# @example With hash-like object
|
73
|
+
# params = ActionController::Parameters.new(name: "John")
|
74
|
+
# struct = LazyStruct.new(params)
|
75
|
+
#
|
76
|
+
# @example Empty initialization
|
77
|
+
# struct = LazyStruct.new
|
78
|
+
# struct.name = "John" # Dynamic assignment
|
6
79
|
def initialize(args = {})
|
7
80
|
unless args.respond_to?(:to_h)
|
8
81
|
raise ArgumentError,
|
9
82
|
"must be respond to `to_h`"
|
10
83
|
end
|
11
84
|
|
12
|
-
@table = args.transform_keys(
|
85
|
+
@table = args.to_h.transform_keys { |k| symbolized_key(k) }
|
13
86
|
end
|
14
87
|
|
88
|
+
##
|
89
|
+
# Retrieves a value by key using hash-style access.
|
90
|
+
# Keys are automatically converted to symbols.
|
91
|
+
#
|
92
|
+
# @param key [Symbol, String] the key to retrieve
|
93
|
+
# @return [Object, nil] the stored value or nil if not found
|
94
|
+
#
|
95
|
+
# @example
|
96
|
+
# struct[:name] #=> "John"
|
97
|
+
# struct["name"] #=> "John"
|
98
|
+
# struct[:missing] #=> nil
|
15
99
|
def [](key)
|
16
|
-
|
100
|
+
table[symbolized_key(key)]
|
17
101
|
end
|
18
102
|
|
103
|
+
##
|
104
|
+
# Retrieves a value by key with error handling and default support.
|
105
|
+
# Similar to Hash#fetch, raises KeyError if key not found and no default given.
|
106
|
+
#
|
107
|
+
# @param key [Symbol, String] the key to retrieve
|
108
|
+
# @param args [Array] default value if key not found
|
109
|
+
# @return [Object] the stored value or default
|
110
|
+
# @raise [KeyError] if key not found and no default provided
|
111
|
+
#
|
112
|
+
# @example With existing key
|
113
|
+
# struct.fetch!(:name) #=> "John"
|
114
|
+
#
|
115
|
+
# @example With default value
|
116
|
+
# struct.fetch!(:missing, "default") #=> "default"
|
117
|
+
#
|
118
|
+
# @example With block default
|
119
|
+
# struct.fetch!(:missing) { "computed default" } #=> "computed default"
|
120
|
+
#
|
121
|
+
# @example Key not found
|
122
|
+
# struct.fetch!(:missing) #=> raises KeyError
|
19
123
|
def fetch!(key, ...)
|
20
|
-
|
124
|
+
table.fetch(symbolized_key(key), ...)
|
21
125
|
end
|
22
126
|
|
127
|
+
##
|
128
|
+
# Stores a value by key, converting the key to a symbol.
|
129
|
+
#
|
130
|
+
# @param key [Symbol, String] the key to store under
|
131
|
+
# @param value [Object] the value to store
|
132
|
+
# @return [Object] the stored value
|
133
|
+
#
|
134
|
+
# @example
|
135
|
+
# struct.store!(:name, "John")
|
136
|
+
# struct.store!("age", 30)
|
137
|
+
# struct.name #=> "John"
|
138
|
+
# struct.age #=> 30
|
23
139
|
def store!(key, value)
|
24
|
-
|
140
|
+
table[symbolized_key(key)] = value
|
25
141
|
end
|
26
142
|
alias []= store!
|
27
143
|
|
144
|
+
##
|
145
|
+
# Merges another hash-like object into this struct.
|
146
|
+
# All keys from the source are converted to symbols.
|
147
|
+
#
|
148
|
+
# @param args [Hash, #to_h] data to merge into this struct
|
149
|
+
# @return [LazyStruct] self for method chaining
|
150
|
+
#
|
151
|
+
# @example
|
152
|
+
# struct = LazyStruct.new(name: "John")
|
153
|
+
# struct.merge!(age: 30, city: "NYC")
|
154
|
+
# struct.to_h #=> {:name => "John", :age => 30, :city => "NYC"}
|
28
155
|
def merge!(args = {})
|
29
|
-
args.to_h.each { |key, value| store!(key, value) }
|
156
|
+
args.to_h.each { |key, value| store!(symbolized_key(key), value) }
|
30
157
|
self
|
31
158
|
end
|
32
159
|
|
160
|
+
##
|
161
|
+
# Deletes a key-value pair from the struct.
|
162
|
+
#
|
163
|
+
# @param key [Symbol, String] the key to delete
|
164
|
+
# @param block [Proc] optional block to execute if key not found
|
165
|
+
# @return [Object, nil] the deleted value or result of block
|
166
|
+
#
|
167
|
+
# @example
|
168
|
+
# struct.delete!(:name) #=> "John"
|
169
|
+
# struct.delete!(:missing) #=> nil
|
170
|
+
# struct.delete!(:missing) { "not found" } #=> "not found"
|
33
171
|
def delete!(key, &)
|
34
|
-
|
172
|
+
table.delete(symbolized_key(key), &)
|
35
173
|
end
|
36
174
|
alias delete_field! delete!
|
37
175
|
|
176
|
+
##
|
177
|
+
# Compares this struct with another for equality.
|
178
|
+
# Two LazyStructs are equal if they have the same class and hash representation.
|
179
|
+
#
|
180
|
+
# @param other [Object] object to compare with
|
181
|
+
# @return [Boolean] true if structs are equal
|
182
|
+
#
|
183
|
+
# @example
|
184
|
+
# struct1 = LazyStruct.new(name: "John")
|
185
|
+
# struct2 = LazyStruct.new(name: "John")
|
186
|
+
# struct1 == struct2 #=> true
|
187
|
+
# struct1.eql?(struct2) #=> true
|
38
188
|
def eql?(other)
|
39
189
|
other.is_a?(self.class) && (to_h == other.to_h)
|
40
190
|
end
|
41
191
|
alias == eql?
|
42
192
|
|
193
|
+
##
|
194
|
+
# Retrieves nested values using a sequence of keys.
|
195
|
+
# Similar to Hash#dig, safely navigates nested structures.
|
196
|
+
#
|
197
|
+
# @param key [Symbol, String] the first key to access
|
198
|
+
# @param keys [Array<Symbol, String>] additional keys for nested access
|
199
|
+
# @return [Object, nil] the nested value or nil if path doesn't exist
|
200
|
+
# @raise [TypeError] if key cannot be converted to symbol
|
201
|
+
#
|
202
|
+
# @example
|
203
|
+
# struct = LazyStruct.new(user: {profile: {name: "John"}})
|
204
|
+
# struct.dig(:user, :profile, :name) #=> "John"
|
205
|
+
# struct.dig(:user, :missing, :name) #=> nil
|
43
206
|
def dig(key, *keys)
|
44
|
-
|
45
|
-
key = key.to_sym
|
46
|
-
rescue NoMethodError
|
47
|
-
raise TypeError, "#{key} is not a symbol nor a string"
|
48
|
-
end
|
49
|
-
|
50
|
-
@table.dig(key, *keys)
|
207
|
+
table.dig(symbolized_key(key), *keys)
|
51
208
|
end
|
52
209
|
|
210
|
+
##
|
211
|
+
# Iterates over each key-value pair in the struct.
|
212
|
+
#
|
213
|
+
# @yieldparam key [Symbol] the key
|
214
|
+
# @yieldparam value [Object] the value
|
215
|
+
# @return [LazyStruct] self if block given, Enumerator otherwise
|
216
|
+
#
|
217
|
+
# @example
|
218
|
+
# struct.each_pair { |key, value| puts "#{key}: #{value}" }
|
53
219
|
def each_pair(&)
|
54
|
-
|
220
|
+
table.each_pair(&)
|
55
221
|
end
|
56
222
|
|
223
|
+
##
|
224
|
+
# Converts the struct to a hash representation.
|
225
|
+
#
|
226
|
+
# @param block [Proc] optional block for hash transformation
|
227
|
+
# @return [Hash] hash representation with symbol keys
|
228
|
+
#
|
229
|
+
# @example
|
230
|
+
# struct = LazyStruct.new(name: "John", age: 30)
|
231
|
+
# struct.to_h #=> {:name => "John", :age => 30}
|
57
232
|
def to_h(&)
|
58
|
-
|
233
|
+
table.to_h(&)
|
59
234
|
end
|
60
235
|
|
236
|
+
##
|
237
|
+
# Returns a string representation of the struct showing all key-value pairs.
|
238
|
+
#
|
239
|
+
# @return [String] formatted string representation
|
240
|
+
#
|
241
|
+
# @example
|
242
|
+
# struct = LazyStruct.new(name: "John", age: 30)
|
243
|
+
# struct.inspect #=> '#<CMDx::LazyStruct:name="John" :age=30>'
|
61
244
|
def inspect
|
62
|
-
"#<#{self.class}#{
|
245
|
+
"#<#{self.class.name}#{table.map { |key, value| ":#{key}=#{value.inspect}" }.join(' ')}>"
|
63
246
|
end
|
64
247
|
alias to_s inspect
|
65
248
|
|
66
249
|
private
|
67
250
|
|
251
|
+
def table
|
252
|
+
@table ||= {}
|
253
|
+
end
|
254
|
+
|
255
|
+
##
|
256
|
+
# Handles dynamic method calls for attribute access and assignment.
|
257
|
+
# Getter methods return the stored value, setter methods (ending with =) store values.
|
258
|
+
#
|
259
|
+
# @param method_name [Symbol] the method name being called
|
260
|
+
# @param args [Array] arguments passed to the method
|
261
|
+
# @return [Object] the stored value for getters, the assigned value for setters
|
262
|
+
#
|
263
|
+
# @example Getter methods
|
264
|
+
# struct.name # Calls method_missing(:name)
|
265
|
+
# struct.undefined # Calls method_missing(:undefined) => nil
|
266
|
+
#
|
267
|
+
# @example Setter methods
|
268
|
+
# struct.name = "John" # Calls method_missing(:name=, "John")
|
269
|
+
#
|
270
|
+
# @api private
|
68
271
|
def method_missing(method_name, *args, **_kwargs, &)
|
69
|
-
|
272
|
+
table.fetch(symbolized_key(method_name)) do
|
70
273
|
store!(method_name[0..-2], args.first) if method_name.end_with?("=")
|
71
274
|
end
|
72
275
|
end
|
73
276
|
|
277
|
+
##
|
278
|
+
# Determines if the struct responds to a given method name.
|
279
|
+
# Returns true for any key in the internal table or standard methods.
|
280
|
+
#
|
281
|
+
# @param method_name [Symbol] the method name to check
|
282
|
+
# @param include_private [Boolean] whether to include private methods
|
283
|
+
# @return [Boolean] true if the struct responds to the method
|
284
|
+
#
|
285
|
+
# @example
|
286
|
+
# struct = LazyStruct.new(name: "John")
|
287
|
+
# struct.respond_to?(:name) #=> true
|
288
|
+
# struct.respond_to?(:missing) #=> false
|
289
|
+
# struct.respond_to?(:to_h) #=> true
|
290
|
+
#
|
291
|
+
# @api private
|
74
292
|
def respond_to_missing?(method_name, include_private = false)
|
75
|
-
|
293
|
+
table.key?(symbolized_key(method_name)) || super
|
294
|
+
end
|
295
|
+
|
296
|
+
##
|
297
|
+
# Converts a key to a symbol for consistent internal storage.
|
298
|
+
# This method normalizes all keys to symbols regardless of their input type,
|
299
|
+
# ensuring consistent access patterns throughout the LazyStruct.
|
300
|
+
#
|
301
|
+
# @param key [Object] the key to convert to a symbol
|
302
|
+
# @return [Symbol] the key converted to a symbol
|
303
|
+
# @raise [TypeError] if the key cannot be converted to a symbol (doesn't respond to `to_sym`)
|
304
|
+
#
|
305
|
+
# @example Valid key conversion
|
306
|
+
# symbolized_key("name") #=> :name
|
307
|
+
# symbolized_key(:name) #=> :name
|
308
|
+
# symbolized_key("123") #=> :"123"
|
309
|
+
#
|
310
|
+
# @example Invalid key conversion
|
311
|
+
# symbolized_key(Object.new) #=> raises TypeError
|
312
|
+
# symbolized_key(123) #=> raises TypeError
|
313
|
+
#
|
314
|
+
# @api private
|
315
|
+
def symbolized_key(key)
|
316
|
+
key.to_sym
|
317
|
+
rescue NoMethodError
|
318
|
+
raise TypeError, "#{key} is not a symbol nor a string"
|
76
319
|
end
|
77
320
|
|
78
321
|
end
|