cmdx 1.17.0 → 1.19.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: 6f8ab8732cd1ac0a15ac46872e78dc5d9549905c52bfda74d24948cae216b0d5
4
- data.tar.gz: 7735fc7d16163a8265adc59ad35f17990887c853aded67a92d5039c44c2df888
3
+ metadata.gz: ead1d181ed1eac565ea3287eeaaca3216e5d24a40f4071000539584435fc1327
4
+ data.tar.gz: ba0273c00286e10621f06fc5b0ba7be760afa99dd8d3fbfc6fc7da1c3c11891f
5
5
  SHA512:
6
- metadata.gz: 294c1f8cf3eab880c4e2f5d12c8655b246534cb61c357531171e90102f73146c37efc185cc6e877e4680c5a2809f0d3cba4fb64bf59fe1b6fea410da65454c9e
7
- data.tar.gz: d5e8e31425d658cfe151eeab44c938352d92d4f9e96326691d0c63b2688f0eeb64db26f7297d38264cbc6485c7355ae1ea3ad9a486a4ea0ccea8c02e4cd07a13
6
+ metadata.gz: 30e8c09228e765f2ac8e9b4f8db854da21a13e3a2d7e67b9ad61ecad66d09dd7f8ca9327ac9a8ef8777e78d56d5844a06f52e55d282cc1d78d7ddb9141b9acc2
7
+ data.tar.gz: 922de5ad8342195dc94bf8137e7023612db4df09b3979581c0e77aecf599cae971004e64b9ab71261ea6795feb8ca8b7fe3fe4fc3f37a570ed50157c404aff33
data/CHANGELOG.md CHANGED
@@ -4,7 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [TODO]
7
+ ## [UNRELEASED]
8
+
9
+ ### Changed
10
+ - Add attribute `source` fallback to `:context` when no task is given
11
+ - Improve falsy attribute derived Hash value lookup
12
+ - Freeze chain results
13
+ - Fix missing fault cause no method error issue
14
+ - Add context respond_to? with setter methods
15
+ - Fix validator `allow_nil` inverted logic
16
+ - Array coercion JSON parse error no returns CoercionError
17
+ - Boolean coercions now return `false` for `nil` and `""`
18
+ - Coerce anaglous date, datetime, and time class checks to rely on `to_date`, `to_time`, `to_datetime` methods
19
+
20
+ ## [1.18.0] - 2025-03-09
21
+
22
+ ### Changed
23
+ - Use `Fiber.storage` instead of `Thread.current` for `Chain` and `Correlate` storage, with fallback to `Thread.current` for Ruby < 3.2, making them thread and fiber safe
24
+ - Clone shared logger in `Task#logger` when `log_level` or `log_formatter` is customized to prevent mutation of the shared instance
25
+ - Derive attribute values from source objects that respond to the attribute name (via `send`) as fallback when the source is not callable
8
26
 
9
27
  ## [1.17.0] - 2025-02-23
10
28
 
@@ -208,7 +208,8 @@ module CMDx
208
208
  !!@required
209
209
  end
210
210
 
211
- # Determines the source of the attribute value.
211
+ # Determines the source of the attribute value. Returns :context
212
+ # as a safe fallback when task is not yet set (e.g., schema introspection).
212
213
  #
213
214
  # @return [Symbol] The source identifier for the attribute value
214
215
  #
@@ -217,13 +218,15 @@ module CMDx
217
218
  #
218
219
  # @rbs () -> untyped
219
220
  def source
220
- @source ||= parent&.method_name || begin
221
+ return @source if defined?(@source)
222
+
223
+ @source = parent&.method_name || begin
221
224
  value = options[:source]
222
225
 
223
226
  if value.is_a?(Proc)
224
- task.instance_eval(&value)
227
+ task ? task.instance_eval(&value) : :context
225
228
  elsif value.respond_to?(:call)
226
- value.call(task)
229
+ task ? value.call(task) : :context
227
230
  else
228
231
  value || :context
229
232
  end
@@ -239,7 +242,9 @@ module CMDx
239
242
  #
240
243
  # @rbs () -> Symbol
241
244
  def method_name
242
- @method_name ||= options[:as] || begin
245
+ return @method_name if defined?(@method_name)
246
+
247
+ @method_name = options[:as] || begin
243
248
  prefix = AFFIX.call(options[:prefix]) { "#{source}_" }
244
249
  suffix = AFFIX.call(options[:suffix]) { "_#{source}" }
245
250
 
@@ -166,10 +166,20 @@ module CMDx
166
166
  derived_value =
167
167
  case source_value
168
168
  when Context then source_value[name]
169
- when Hash then source_value[name.to_s] || source_value[name.to_sym]
169
+ when Hash
170
+ if source_value.key?(name.to_s)
171
+ source_value[name.to_s]
172
+ elsif source_value.key?(name.to_sym)
173
+ source_value[name.to_sym]
174
+ end
170
175
  when Symbol then source_value.send(name)
171
176
  when Proc then task.instance_exec(name, &source_value)
172
- else source_value.call(task, name) if source_value.respond_to?(:call)
177
+ else
178
+ if source_value.respond_to?(:call)
179
+ source_value.call(task, name)
180
+ elsif source_value.respond_to?(name, true)
181
+ source_value.send(name)
182
+ end
173
183
  end
174
184
 
175
185
  derived_value.nil? ? default_value : derived_value
data/lib/cmdx/chain.rb CHANGED
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Manages a collection of task execution results in a thread-safe manner.
4
+ # Manages a collection of task execution results in a thread and fiber safe manner.
5
5
  # Chains provide a way to track related task executions and their outcomes
6
6
  # within the same execution context.
7
7
  class Chain
8
8
 
9
9
  extend Forwardable
10
10
 
11
- # @rbs THREAD_KEY: Symbol
12
- THREAD_KEY = :cmdx_chain
11
+ # @rbs CONCURRENCY_KEY: Symbol
12
+ CONCURRENCY_KEY = :cmdx_chain
13
13
 
14
14
  # Returns the unique identifier for this chain.
15
15
  #
@@ -47,7 +47,7 @@ module CMDx
47
47
 
48
48
  class << self
49
49
 
50
- # Retrieves the current chain for the current thread.
50
+ # Retrieves the current chain for the current execution context.
51
51
  #
52
52
  # @return [Chain, nil] The current chain or nil if none exists
53
53
  #
@@ -59,10 +59,10 @@ module CMDx
59
59
  #
60
60
  # @rbs () -> Chain?
61
61
  def current
62
- Thread.current[THREAD_KEY]
62
+ thread_or_fiber[CONCURRENCY_KEY]
63
63
  end
64
64
 
65
- # Sets the current chain for the current thread.
65
+ # Sets the current chain for the current execution context.
66
66
  #
67
67
  # @param chain [Chain] The chain to set as current
68
68
  #
@@ -73,10 +73,10 @@ module CMDx
73
73
  #
74
74
  # @rbs (Chain chain) -> Chain
75
75
  def current=(chain)
76
- Thread.current[THREAD_KEY] = chain
76
+ thread_or_fiber[CONCURRENCY_KEY] = chain
77
77
  end
78
78
 
79
- # Clears the current chain for the current thread.
79
+ # Clears the current chain for the current execution context.
80
80
  #
81
81
  # @return [nil] Always returns nil
82
82
  #
@@ -85,7 +85,7 @@ module CMDx
85
85
  #
86
86
  # @rbs () -> nil
87
87
  def clear
88
- Thread.current[THREAD_KEY] = nil
88
+ thread_or_fiber[CONCURRENCY_KEY] = nil
89
89
  end
90
90
 
91
91
  # Builds or extends the current chain by adding a result.
@@ -111,6 +111,17 @@ module CMDx
111
111
  current
112
112
  end
113
113
 
114
+ private
115
+
116
+ # Returns the thread or fiber storage for the current execution context.
117
+ #
118
+ # @return [Hash] The thread or fiber storage
119
+ #
120
+ # @rbs () -> Hash
121
+ def thread_or_fiber
122
+ Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current
123
+ end
124
+
114
125
  end
115
126
 
116
127
  # Returns whether the chain is running in dry-run mode.
@@ -125,6 +136,20 @@ module CMDx
125
136
  !!@dry_run
126
137
  end
127
138
 
139
+ # Freezes the chain and its internal results to prevent modifications.
140
+ #
141
+ # @return [Chain] the frozen chain
142
+ #
143
+ # @example
144
+ # chain.freeze
145
+ # chain.results << result # => raises FrozenError
146
+ #
147
+ # @rbs () -> self
148
+ def freeze
149
+ results.freeze
150
+ super
151
+ end
152
+
128
153
  # Converts the chain to a hash representation.
129
154
  #
130
155
  # @option return [String] :id The chain identifier
@@ -18,7 +18,7 @@ module CMDx
18
18
  #
19
19
  # @return [Array] The converted array value
20
20
  #
21
- # @raise [JSON::ParserError] If the string value contains invalid JSON
21
+ # @raise [CoercionError] If the value cannot be converted to an array
22
22
  #
23
23
  # @example Convert a JSON-like string to an array
24
24
  # Array.call("[1, 2, 3]") # => [1, 2, 3]
@@ -26,6 +26,8 @@ module CMDx
26
26
  # Array.call("hello") # => ["hello"]
27
27
  # Array.call(42) # => [42]
28
28
  # Array.call(nil) # => []
29
+ # @example Handle invalid JSON-like strings
30
+ # Array.call("[not json") # => raises CoercionError
29
31
  #
30
32
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Array[untyped]
31
33
  def call(value, options = {})
@@ -37,6 +39,9 @@ module CMDx
37
39
  else
38
40
  Array(value)
39
41
  end
42
+ rescue JSON::ParserError
43
+ type = Locale.t("cmdx.types.array")
44
+ raise CoercionError, Locale.t("cmdx.coercions.into_an", type:)
40
45
  end
41
46
 
42
47
  end
@@ -34,14 +34,18 @@ module CMDx
34
34
  # Boolean.call("false") # => false
35
35
  # Boolean.call("no") # => false
36
36
  # Boolean.call("0") # => false
37
+ # Boolean.call(nil) # => false
38
+ # Boolean.call("") # => false
37
39
  # @example Handle case-insensitive input
38
40
  # Boolean.call("TRUE") # => true
39
41
  # Boolean.call("False") # => false
42
+ # @example Handle edge cases
43
+ # Boolean.call("abc") # => raises CoercionError
40
44
  #
41
45
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool
42
46
  def call(value, options = {})
43
47
  case value.to_s
44
- when FALSEY then false
48
+ when FALSEY, "" then false
45
49
  when TRUTHY then true
46
50
  else
47
51
  type = Locale.t("cmdx.types.boolean")
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already date-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[Date DateTime Time].freeze
18
-
19
14
  # Converts a value to a Date object
20
15
  #
21
16
  # @param value [Object] The value to convert to a Date
@@ -38,7 +33,7 @@ module CMDx
38
33
  #
39
34
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Date
40
35
  def call(value, options = {})
41
- return value if ANALOG_TYPES.include?(value.class.name)
36
+ return value.to_date if value.respond_to?(:to_date)
42
37
  return ::Date.strptime(value, options[:strptime]) if options[:strptime]
43
38
 
44
39
  ::Date.parse(value)
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already date-time-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[Date DateTime Time].freeze
18
-
19
14
  # Converts a value to a DateTime
20
15
  #
21
16
  # @param value [Object] The value to convert to DateTime
@@ -38,7 +33,7 @@ module CMDx
38
33
  #
39
34
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> DateTime
40
35
  def call(value, options = {})
41
- return value if ANALOG_TYPES.include?(value.class.name)
36
+ return value.to_datetime if value.respond_to?(:to_datetime)
42
37
  return ::DateTime.strptime(value, options[:strptime]) if options[:strptime]
43
38
 
44
39
  ::DateTime.parse(value)
@@ -30,10 +30,9 @@ module CMDx
30
30
  # Integer.call(3.14) # => 3
31
31
  # Integer.call(0.0) # => 0
32
32
  # @example Handle edge cases
33
- # Integer.call("") # => 0
34
- # Integer.call(nil) # => 0
35
- # Integer.call(false) # => 0
36
- # Integer.call(true) # => 1
33
+ # Integer.call("") # => raises CoercionError
34
+ # Integer.call(nil) # => raises CoercionError
35
+ # Integer.call("abc") # => raises CoercionError
37
36
  #
38
37
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Integer
39
38
  def call(value, options = {})
@@ -11,11 +11,6 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
- # Types that are already time-like and don't need conversion
15
- #
16
- # @rbs ANALOG_TYPES: Array[String]
17
- ANALOG_TYPES = %w[DateTime Time].freeze
18
-
19
14
  # Converts a value to a Time object
20
15
  #
21
16
  # @param value [Object] The value to convert to a Time object
@@ -40,7 +35,6 @@ module CMDx
40
35
  #
41
36
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Time
42
37
  def call(value, options = {})
43
- return value if ANALOG_TYPES.include?(value.class.name)
44
38
  return value.to_time if value.respond_to?(:to_time)
45
39
  return ::Time.strptime(value, options[:strptime]) if options[:strptime]
46
40
 
data/lib/cmdx/executor.rb CHANGED
@@ -169,7 +169,7 @@ module CMDx
169
169
  jitter.to_f * current_retry
170
170
  end
171
171
 
172
- sleep(jitter) if jitter.positive?
172
+ sleep(jitter) if Float(jitter).positive?
173
173
 
174
174
  true
175
175
  end
@@ -291,8 +291,8 @@ module CMDx
291
291
  def log_backtrace!
292
292
  return unless result.failed?
293
293
 
294
- exception = result.caused_failure.cause
295
- return if exception.is_a?(Fault)
294
+ exception = result.caused_failure&.cause
295
+ return if exception.nil? || exception.is_a?(Fault)
296
296
 
297
297
  task.logger.error do
298
298
  "[#{exception.class}] #{exception.message}\n" <<
@@ -4,18 +4,18 @@ module CMDx
4
4
  module Middlewares
5
5
  # Middleware for correlating task executions with unique identifiers.
6
6
  #
7
- # The Correlate middleware provides thread-safe correlation ID management
8
- # for tracking task execution flows across different operations.
9
- # It automatically generates correlation IDs when none are provided and
10
- # stores them in task result metadata for traceability.
7
+ # The Correlate middleware provides thread and fiber safe correlation ID management
8
+ # for tracking task execution flows across different operations. It automatically
9
+ # generates correlation IDs when none are provided and stores them in task result
10
+ # metadata for traceability.
11
11
  module Correlate
12
12
 
13
13
  extend self
14
14
 
15
- # @rbs THREAD_KEY: Symbol
16
- THREAD_KEY = :cmdx_correlate
15
+ # @rbs CONCURRENCY_KEY: Symbol
16
+ CONCURRENCY_KEY = :cmdx_correlate
17
17
 
18
- # Retrieves the current correlation ID from thread-local storage.
18
+ # Retrieves the current correlation ID from local storage.
19
19
  #
20
20
  # @return [String, nil] The current correlation ID or nil if not set
21
21
  #
@@ -24,10 +24,10 @@ module CMDx
24
24
  #
25
25
  # @rbs () -> String?
26
26
  def id
27
- Thread.current[THREAD_KEY]
27
+ thread_or_fiber[CONCURRENCY_KEY]
28
28
  end
29
29
 
30
- # Sets the correlation ID in thread-local storage.
30
+ # Sets the correlation ID in local storage.
31
31
  #
32
32
  # @param id [String] The correlation ID to set
33
33
  # @return [String] The set correlation ID
@@ -37,10 +37,10 @@ module CMDx
37
37
  #
38
38
  # @rbs (String id) -> String
39
39
  def id=(id)
40
- Thread.current[THREAD_KEY] = id
40
+ thread_or_fiber[CONCURRENCY_KEY] = id
41
41
  end
42
42
 
43
- # Clears the current correlation ID from thread-local storage.
43
+ # Clears the current correlation ID from local storage.
44
44
  #
45
45
  # @return [nil] Always returns nil
46
46
  #
@@ -49,7 +49,7 @@ module CMDx
49
49
  #
50
50
  # @rbs () -> nil
51
51
  def clear
52
- Thread.current[THREAD_KEY] = nil
52
+ thread_or_fiber[CONCURRENCY_KEY] = nil
53
53
  end
54
54
 
55
55
  # Temporarily uses a new correlation ID for the duration of a block.
@@ -122,6 +122,17 @@ module CMDx
122
122
  use(correlation_id, &)
123
123
  end
124
124
 
125
+ private
126
+
127
+ # Returns the thread or fiber storage for the current execution context.
128
+ #
129
+ # @return [Hash] The thread or fiber storage
130
+ #
131
+ # @rbs () -> Hash
132
+ def thread_or_fiber
133
+ Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current
134
+ end
135
+
125
136
  end
126
137
  end
127
138
  end
data/lib/cmdx/task.rb CHANGED
@@ -95,7 +95,9 @@ module CMDx
95
95
  @id = Identifier.generate
96
96
  @context = Context.build(context)
97
97
  @result = Result.new(self)
98
- @chain = Chain.build(@result, dry_run: @context.delete(:dry_run))
98
+
99
+ dry_run = @context.delete(:dry_run)
100
+ @chain = Chain.build(@result, dry_run:)
99
101
  end
100
102
 
101
103
  class << self
@@ -259,8 +261,8 @@ module CMDx
259
261
  #
260
262
  # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]]
261
263
  def attributes_schema
262
- Array(settings[:attributes]).each_with_object({}) do |attr, schema|
263
- schema[attr.method_name] = attr.to_h
264
+ Array(settings[:attributes]).to_h do |attr|
265
+ [attr.method_name, attr.to_h]
264
266
  end
265
267
  end
266
268
 
@@ -351,6 +353,10 @@ module CMDx
351
353
  raise UndefinedMethodError, "undefined method #{self.class.name}#work"
352
354
  end
353
355
 
356
+ # Returns a logger for this task. When a custom log_level or
357
+ # log_formatter is configured, the shared logger is duplicated
358
+ # so the original instance is never mutated.
359
+ #
354
360
  # @return [Logger] The logger instance for this task
355
361
  #
356
362
  # @example
@@ -360,10 +366,17 @@ module CMDx
360
366
  # @rbs () -> Logger
361
367
  def logger
362
368
  @logger ||= begin
363
- logger = self.class.settings[:logger] || CMDx.configuration.logger
364
- logger.level = self.class.settings[:log_level] || logger.level
365
- logger.formatter = self.class.settings[:log_formatter] || logger.formatter
366
- logger
369
+ log_instance = self.class.settings[:logger] || CMDx.configuration.logger
370
+ log_level = self.class.settings[:log_level]
371
+ log_formatter = self.class.settings[:log_formatter]
372
+
373
+ if log_level || log_formatter
374
+ log_instance = log_instance.dup
375
+ log_instance.level = log_level if log_level
376
+ log_instance.formatter = log_formatter if log_formatter
377
+ end
378
+
379
+ log_instance
367
380
  end
368
381
  end
369
382
 
@@ -102,7 +102,7 @@ module CMDx
102
102
  match =
103
103
  if options.is_a?(Hash)
104
104
  case options
105
- in allow_nil: then allow_nil && value.nil?
105
+ in allow_nil: then !(allow_nil && value.nil?)
106
106
  else Utils::Condition.evaluate(task, options, value)
107
107
  end
108
108
  else
data/lib/cmdx/version.rb CHANGED
@@ -5,6 +5,6 @@ module CMDx
5
5
  # @return [String] the version of the CMDx gem
6
6
  #
7
7
  # @rbs return: String
8
- VERSION = "1.17.0"
8
+ VERSION = "1.19.0"
9
9
 
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cmdx
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juan Gomez
@@ -420,7 +420,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
420
420
  - !ruby/object:Gem::Version
421
421
  version: '0'
422
422
  requirements: []
423
- rubygems_version: 4.0.4
423
+ rubygems_version: 4.0.6
424
424
  specification_version: 4
425
425
  summary: CMDx is a framework for building maintainable business processes.
426
426
  test_files: []