dexkit 0.5.0 → 0.7.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.
@@ -34,6 +34,22 @@ module Dex
34
34
  record_class.find(id).update!(safe_attributes(attributes))
35
35
  end
36
36
 
37
+ def find_by_once_key(key)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def find_expired_once_key(key)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def update_record_by_once_key(key, **attributes)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ def unique_constraint_error?(exception)
50
+ raise NotImplementedError
51
+ end
52
+
37
53
  def safe_attributes(attributes)
38
54
  attributes.select { |key, _| has_field?(key.to_s) }
39
55
  end
@@ -49,12 +65,71 @@ module Dex
49
65
  @column_set = record_class.column_names.to_set
50
66
  end
51
67
 
68
+ def find_by_once_key(key)
69
+ scope = record_class.where(once_key: key, status: %w[completed error])
70
+ scope = scope.where("once_key_expires_at IS NULL OR once_key_expires_at >= ?", Time.now) if has_field?("once_key_expires_at")
71
+ scope.first
72
+ end
73
+
74
+ def find_expired_once_key(key)
75
+ return nil unless has_field?("once_key_expires_at")
76
+
77
+ record_class
78
+ .where(once_key: key, status: %w[completed error])
79
+ .where("once_key_expires_at IS NOT NULL AND once_key_expires_at < ?", Time.now)
80
+ .first
81
+ end
82
+
83
+ def update_record_by_once_key(key, **attributes)
84
+ record = record_class.where(once_key: key, status: %w[completed error]).first
85
+ record&.update!(safe_attributes(attributes))
86
+ end
87
+
88
+ def unique_constraint_error?(exception)
89
+ defined?(ActiveRecord::RecordNotUnique) && exception.is_a?(ActiveRecord::RecordNotUnique)
90
+ end
91
+
52
92
  def has_field?(field_name)
53
93
  @column_set.include?(field_name.to_s)
54
94
  end
55
95
  end
56
96
 
57
97
  class MongoidAdapter < Base
98
+ def find_by_once_key(key)
99
+ now = Time.now
100
+ record_class.where(
101
+ :once_key => key,
102
+ :status.in => %w[completed error]
103
+ ).and(
104
+ record_class.or(
105
+ { once_key_expires_at: nil },
106
+ { :once_key_expires_at.gte => now }
107
+ )
108
+ ).first
109
+ end
110
+
111
+ def find_expired_once_key(key)
112
+ return nil unless has_field?("once_key_expires_at")
113
+
114
+ record_class.where(
115
+ :once_key => key,
116
+ :status.in => %w[completed error],
117
+ :once_key_expires_at.ne => nil,
118
+ :once_key_expires_at.lt => Time.now
119
+ ).first
120
+ end
121
+
122
+ def update_record_by_once_key(key, **attributes)
123
+ record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
124
+ record&.update!(safe_attributes(attributes))
125
+ end
126
+
127
+ def unique_constraint_error?(exception)
128
+ defined?(Mongo::Error::OperationFailure) &&
129
+ exception.is_a?(Mongo::Error::OperationFailure) &&
130
+ exception.code == 11_000
131
+ end
132
+
58
133
  def has_field?(field_name)
59
134
  record_class.fields.key?(field_name.to_s)
60
135
  end
@@ -7,26 +7,27 @@ module Dex
7
7
  def _record_wrap
8
8
  interceptor = Operation::HaltInterceptor.new { yield }
9
9
 
10
- if interceptor.success?
11
- if _record_has_pending_record?
12
- _record_update_done!(interceptor.result)
13
- elsif _record_enabled?
14
- _record_save!(interceptor.result)
15
- end
10
+ if _record_has_pending_record?
11
+ _record_update_outcome!(interceptor)
12
+ elsif _record_enabled?
13
+ _record_save!(interceptor)
16
14
  end
17
15
 
18
16
  interceptor.rethrow!
19
17
  interceptor.result
18
+ rescue => e
19
+ _record_failure!(e) if _record_has_pending_record? || _record_enabled?
20
+ raise
20
21
  end
21
22
 
22
23
  module ClassMethods
23
24
  def record(enabled = nil, **options)
24
- validate_options!(options, %i[params response], :record)
25
+ validate_options!(options, %i[params result], :record)
25
26
 
26
27
  if enabled == false
27
28
  set :record, enabled: false
28
29
  elsif enabled == true || enabled.nil?
29
- merged = { enabled: true, params: true, response: true }.merge(options)
30
+ merged = { enabled: true, params: true, result: true }.merge(options)
30
31
  set :record, **merged
31
32
  else
32
33
  raise ArgumentError,
@@ -49,27 +50,78 @@ module Dex
49
50
  defined?(@_dex_record_id) && @_dex_record_id
50
51
  end
51
52
 
52
- def _record_save!(result)
53
- Dex.record_backend.create_record(_record_attributes(result))
53
+ def _record_save!(interceptor)
54
+ attrs = _record_base_attrs
55
+ if interceptor.error?
56
+ attrs.merge!(_record_error_attrs(code: interceptor.halt.error_code,
57
+ message: interceptor.halt.error_message,
58
+ details: interceptor.halt.error_details))
59
+ else
60
+ attrs.merge!(_record_success_attrs(interceptor.result))
61
+ end
62
+ Dex.record_backend.create_record(attrs)
54
63
  rescue => e
55
64
  _record_handle_error(e)
56
65
  end
57
66
 
58
- def _record_update_done!(result)
59
- attrs = { status: "done", performed_at: _record_current_time }
60
- attrs[:response] = _record_response(result) if _record_response?
67
+ def _record_update_outcome!(interceptor)
68
+ attrs = if interceptor.error?
69
+ _record_error_attrs(code: interceptor.halt.error_code,
70
+ message: interceptor.halt.error_message,
71
+ details: interceptor.halt.error_details)
72
+ else
73
+ _record_success_attrs(interceptor.result)
74
+ end
61
75
  Dex.record_backend.update_record(@_dex_record_id, attrs)
62
76
  rescue => e
63
77
  _record_handle_error(e)
64
78
  end
65
79
 
66
- def _record_attributes(result)
67
- attrs = { name: self.class.name, performed_at: _record_current_time, status: "done" }
80
+ def _record_failure!(exception)
81
+ attrs = if exception.is_a?(Dex::Error)
82
+ _record_error_attrs(code: exception.code, message: exception.message, details: exception.details)
83
+ else
84
+ {
85
+ status: "failed",
86
+ error_code: exception.class.name,
87
+ error_message: exception.message,
88
+ performed_at: _record_current_time
89
+ }
90
+ end
91
+
92
+ attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
93
+
94
+ if _record_has_pending_record?
95
+ Dex.record_backend.update_record(@_dex_record_id, attrs)
96
+ else
97
+ Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
98
+ end
99
+ rescue => e
100
+ _record_handle_error(e)
101
+ end
102
+
103
+ def _record_base_attrs
104
+ attrs = { name: self.class.name }
68
105
  attrs[:params] = _record_params? ? _record_params : nil
69
- attrs[:response] = _record_response? ? _record_response(result) : nil
70
106
  attrs
71
107
  end
72
108
 
109
+ def _record_success_attrs(result)
110
+ attrs = { status: "completed", performed_at: _record_current_time }
111
+ attrs[:result] = _record_result(result) if _record_result?
112
+ attrs
113
+ end
114
+
115
+ def _record_error_attrs(code:, message:, details:)
116
+ {
117
+ status: "error",
118
+ error_code: code.to_s,
119
+ error_message: message || code.to_s,
120
+ error_details: _record_sanitize_details(details),
121
+ performed_at: _record_current_time
122
+ }
123
+ end
124
+
73
125
  def _record_params
74
126
  _props_as_json
75
127
  end
@@ -78,11 +130,11 @@ module Dex
78
130
  self.class.settings_for(:record).fetch(:params, true)
79
131
  end
80
132
 
81
- def _record_response?
82
- self.class.settings_for(:record).fetch(:response, true)
133
+ def _record_result?
134
+ self.class.settings_for(:record).fetch(:result, true)
83
135
  end
84
136
 
85
- def _record_response(result)
137
+ def _record_result(result)
86
138
  success_type = self.class.respond_to?(:_success_type) && self.class._success_type
87
139
 
88
140
  if success_type
@@ -91,7 +143,7 @@ module Dex
91
143
  case result
92
144
  when nil then nil
93
145
  when Hash then result
94
- else { value: result }
146
+ else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
95
147
  end
96
148
  end
97
149
  end
@@ -100,6 +152,21 @@ module Dex
100
152
  Time.respond_to?(:current) ? Time.current : Time.now
101
153
  end
102
154
 
155
+ def _record_sanitize_details(details)
156
+ _record_sanitize_value(details)
157
+ end
158
+
159
+ def _record_sanitize_value(value)
160
+ case value
161
+ when NilClass, String, Integer, Float, TrueClass, FalseClass then value
162
+ when Symbol then value.to_s
163
+ when Hash then value.transform_values { |v| _record_sanitize_value(v) }
164
+ when Array then value.map { |v| _record_sanitize_value(v) }
165
+ when Exception then "#{value.class}: #{value.message}"
166
+ else value.to_s
167
+ end
168
+ end
169
+
103
170
  def _record_handle_error(error)
104
171
  Dex.warn("Failed to record operation: #{error.message}")
105
172
  end
@@ -121,7 +121,8 @@ module Dex
121
121
  return if callbacks.empty?
122
122
 
123
123
  flush = -> { callbacks.each(&:call) }
124
- adapter = _transaction_adapter
124
+ enabled = self.class.settings_for(:transaction).fetch(:enabled, true)
125
+ adapter = enabled && _transaction_adapter
125
126
  if adapter
126
127
  adapter.after_commit(&flush)
127
128
  else
data/lib/dex/operation.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Wrapper modules (loaded before class body so `include`/`use` can find them)
4
- require_relative "operation/settings"
5
4
  require_relative "operation/result_wrapper"
5
+ require_relative "operation/once_wrapper"
6
6
  require_relative "operation/record_wrapper"
7
7
  require_relative "operation/transaction_wrapper"
8
8
  require_relative "operation/lock_wrapper"
@@ -10,9 +10,7 @@ require_relative "operation/async_wrapper"
10
10
  require_relative "operation/safe_wrapper"
11
11
  require_relative "operation/rescue_wrapper"
12
12
  require_relative "operation/callback_wrapper"
13
-
14
- # Pipeline (referenced inside class body)
15
- require_relative "operation/pipeline"
13
+ require_relative "operation/guard_wrapper"
16
14
 
17
15
  module Dex
18
16
  class Operation
@@ -44,38 +42,25 @@ module Dex
44
42
  end
45
43
  end
46
44
 
47
- RESERVED_PROP_NAMES = %i[call perform async safe initialize].to_set.freeze
45
+ RESERVED_PROP_NAMES = %i[call perform async safe once initialize].to_set.freeze
48
46
 
47
+ include Executable
49
48
  include PropsSetup
50
49
  include TypeCoercion
50
+ include ContextSetup
51
51
 
52
- Contract = Data.define(:params, :success, :errors)
52
+ Contract = Data.define(:params, :success, :errors, :guards)
53
53
 
54
54
  class << self
55
55
  def contract
56
56
  Contract.new(
57
57
  params: _contract_params,
58
58
  success: _success_type,
59
- errors: _declared_errors
59
+ errors: _declared_errors,
60
+ guards: _contract_guards
60
61
  )
61
62
  end
62
63
 
63
- def inherited(subclass)
64
- subclass.instance_variable_set(:@_pipeline, pipeline.dup)
65
- super
66
- end
67
-
68
- def pipeline
69
- @_pipeline ||= Pipeline.new
70
- end
71
-
72
- def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
73
- step_name = as || _derive_step_name(mod)
74
- wrap_method = wrap || :"_#{step_name}_wrap"
75
- pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
76
- include mod
77
- end
78
-
79
64
  private
80
65
 
81
66
  def _contract_params
@@ -86,24 +71,18 @@ module Dex
86
71
  end
87
72
  end
88
73
 
89
- def _derive_step_name(mod)
90
- base = mod.name&.split("::")&.last
91
- raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
74
+ def _contract_guards
75
+ return [] unless respond_to?(:_guard_list)
92
76
 
93
- base.sub(/Wrapper\z/, "")
94
- .gsub(/([a-z])([A-Z])/, '\1_\2')
95
- .downcase
96
- .to_sym
77
+ _guard_list.map do |g|
78
+ { name: g.name, message: g.message, requires: g.requires }
79
+ end
97
80
  end
98
81
  end
99
82
 
100
83
  def perform(*, **)
101
84
  end
102
85
 
103
- def call
104
- self.class.pipeline.execute(self) { perform }
105
- end
106
-
107
86
  def self.method_added(method_name)
108
87
  super
109
88
  return unless method_name == :perform
@@ -117,14 +96,15 @@ module Dex
117
96
  new(**kwargs).call
118
97
  end
119
98
 
120
- include Settings
121
99
  include AsyncWrapper
122
100
  include SafeWrapper
123
101
 
124
102
  use ResultWrapper
103
+ use GuardWrapper
104
+ use OnceWrapper
125
105
  use LockWrapper
126
- use TransactionWrapper
127
106
  use RecordWrapper
107
+ use TransactionWrapper
128
108
  use RescueWrapper
129
109
  use CallbackWrapper
130
110
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Pipeline
5
+ Step = Data.define(:name, :method)
6
+
7
+ def initialize(steps = [])
8
+ @steps = steps.dup
9
+ end
10
+
11
+ def dup
12
+ self.class.new(@steps)
13
+ end
14
+
15
+ def steps
16
+ @steps.dup.freeze
17
+ end
18
+
19
+ def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
20
+ validate_positioning!(before, after, at)
21
+ step = Step.new(name: name, method: method)
22
+
23
+ if at == :outer then @steps.unshift(step)
24
+ elsif at == :inner then @steps.push(step)
25
+ elsif before then @steps.insert(find_index!(before), step)
26
+ elsif after then @steps.insert(find_index!(after) + 1, step)
27
+ else @steps.push(step)
28
+ end
29
+ self
30
+ end
31
+
32
+ def remove(name)
33
+ @steps.reject! { |s| s.name == name }
34
+ self
35
+ end
36
+
37
+ def execute(target)
38
+ chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
39
+ -> { target.send(step.method, &next_step) }
40
+ end
41
+ chain.call
42
+ end
43
+
44
+ private
45
+
46
+ def validate_positioning!(before, after, at)
47
+ count = [before, after, at].count { |v| !v.nil? }
48
+ raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
49
+ raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
50
+ end
51
+
52
+ def find_index!(name)
53
+ idx = @steps.index { |s| s.name == name }
54
+ raise ArgumentError, "pipeline step :#{name} not found" unless idx
55
+ idx
56
+ end
57
+ end
58
+ end
@@ -214,6 +214,29 @@ module Dex
214
214
  "Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
215
215
  end
216
216
 
217
+ # --- Guard assertions ---
218
+
219
+ def assert_callable(*args, **params)
220
+ klass = _dex_resolve_subject(args)
221
+ result = klass.callable(**params)
222
+ assert result.ok?, "Expected operation to be callable, but guards failed:\n#{_dex_format_err(result)}"
223
+ result
224
+ end
225
+
226
+ def refute_callable(*args, **params)
227
+ klass_args, codes = _dex_split_class_and_symbols(args)
228
+ klass = _dex_resolve_subject(klass_args)
229
+ code = codes.first
230
+ result = klass.callable(**params)
231
+ refute result.ok?, "Expected operation to NOT be callable, but all guards passed"
232
+ if code
233
+ failed_codes = result.details.map { |f| f[:guard] }
234
+ assert_includes failed_codes, code,
235
+ "Expected guard :#{code} to fail, but it didn't.\n Failed guards: #{failed_codes.inspect}"
236
+ end
237
+ result
238
+ end
239
+
217
240
  # --- Batch assertions ---
218
241
 
219
242
  def assert_all_succeed(*args, params_list:)
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -14,7 +14,11 @@ require_relative "dex/concern"
14
14
  require_relative "dex/ref_type"
15
15
  require_relative "dex/type_coercion"
16
16
  require_relative "dex/props_setup"
17
+ require_relative "dex/context_setup"
17
18
  require_relative "dex/error"
19
+ require_relative "dex/settings"
20
+ require_relative "dex/pipeline"
21
+ require_relative "dex/executable"
18
22
  require_relative "dex/operation"
19
23
  require_relative "dex/event"
20
24
  require_relative "dex/form"
@@ -68,5 +72,20 @@ module Dex
68
72
  def transaction_adapter=(adapter)
69
73
  configuration.transaction_adapter = adapter
70
74
  end
75
+
76
+ CONTEXT_KEY = :_dex_context
77
+ EMPTY_CONTEXT = {}.freeze
78
+
79
+ def context
80
+ Fiber[CONTEXT_KEY] || EMPTY_CONTEXT
81
+ end
82
+
83
+ def with_context(**values)
84
+ previous = Fiber[CONTEXT_KEY]
85
+ Fiber[CONTEXT_KEY] = (previous || {}).merge(values)
86
+ yield
87
+ ensure
88
+ Fiber[CONTEXT_KEY] = previous
89
+ end
71
90
  end
72
91
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -166,6 +166,7 @@ files:
166
166
  - guides/llm/OPERATION.md
167
167
  - guides/llm/QUERY.md
168
168
  - lib/dex/concern.rb
169
+ - lib/dex/context_setup.rb
169
170
  - lib/dex/error.rb
170
171
  - lib/dex/event.rb
171
172
  - lib/dex/event/bus.rb
@@ -177,6 +178,7 @@ files:
177
178
  - lib/dex/event/trace.rb
178
179
  - lib/dex/event_test_helpers.rb
179
180
  - lib/dex/event_test_helpers/assertions.rb
181
+ - lib/dex/executable.rb
180
182
  - lib/dex/form.rb
181
183
  - lib/dex/form/nesting.rb
182
184
  - lib/dex/form/uniqueness_validator.rb
@@ -185,24 +187,26 @@ files:
185
187
  - lib/dex/operation/async_proxy.rb
186
188
  - lib/dex/operation/async_wrapper.rb
187
189
  - lib/dex/operation/callback_wrapper.rb
190
+ - lib/dex/operation/guard_wrapper.rb
188
191
  - lib/dex/operation/jobs.rb
189
192
  - lib/dex/operation/lock_wrapper.rb
193
+ - lib/dex/operation/once_wrapper.rb
190
194
  - lib/dex/operation/outcome.rb
191
- - lib/dex/operation/pipeline.rb
192
195
  - lib/dex/operation/record_backend.rb
193
196
  - lib/dex/operation/record_wrapper.rb
194
197
  - lib/dex/operation/rescue_wrapper.rb
195
198
  - lib/dex/operation/result_wrapper.rb
196
199
  - lib/dex/operation/safe_wrapper.rb
197
- - lib/dex/operation/settings.rb
198
200
  - lib/dex/operation/transaction_adapter.rb
199
201
  - lib/dex/operation/transaction_wrapper.rb
202
+ - lib/dex/pipeline.rb
200
203
  - lib/dex/props_setup.rb
201
204
  - lib/dex/query.rb
202
205
  - lib/dex/query/backend.rb
203
206
  - lib/dex/query/filtering.rb
204
207
  - lib/dex/query/sorting.rb
205
208
  - lib/dex/ref_type.rb
209
+ - lib/dex/settings.rb
206
210
  - lib/dex/test_helpers.rb
207
211
  - lib/dex/test_helpers/assertions.rb
208
212
  - lib/dex/test_helpers/execution.rb
@@ -211,14 +215,15 @@ files:
211
215
  - lib/dex/type_coercion.rb
212
216
  - lib/dex/version.rb
213
217
  - lib/dexkit.rb
214
- homepage: https://github.com/razorjack/dexkit
218
+ homepage: https://dex.razorjack.net/
215
219
  licenses:
216
220
  - MIT
217
221
  metadata:
218
222
  allowed_push_host: https://rubygems.org
219
- homepage_uri: https://github.com/razorjack/dexkit
223
+ homepage_uri: https://dex.razorjack.net/
220
224
  source_code_uri: https://github.com/razorjack/dexkit
221
225
  changelog_uri: https://github.com/razorjack/dexkit/blob/master/CHANGELOG.md
226
+ documentation_uri: https://dex.razorjack.net/
222
227
  rdoc_options: []
223
228
  require_paths:
224
229
  - lib
@@ -235,5 +240,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
235
240
  requirements: []
236
241
  rubygems_version: 4.0.3
237
242
  specification_version: 4
238
- summary: 'Dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
243
+ summary: 'dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
239
244
  test_files: []
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dex
4
- class Operation
5
- class Pipeline
6
- Step = Data.define(:name, :method)
7
-
8
- def initialize(steps = [])
9
- @steps = steps.dup
10
- end
11
-
12
- def dup
13
- self.class.new(@steps)
14
- end
15
-
16
- def steps
17
- @steps.dup.freeze
18
- end
19
-
20
- def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
21
- validate_positioning!(before, after, at)
22
- step = Step.new(name: name, method: method)
23
-
24
- if at == :outer then @steps.unshift(step)
25
- elsif at == :inner then @steps.push(step)
26
- elsif before then @steps.insert(find_index!(before), step)
27
- elsif after then @steps.insert(find_index!(after) + 1, step)
28
- else @steps.push(step)
29
- end
30
- self
31
- end
32
-
33
- def remove(name)
34
- @steps.reject! { |s| s.name == name }
35
- self
36
- end
37
-
38
- def execute(operation)
39
- chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
40
- -> { operation.send(step.method, &next_step) }
41
- end
42
- chain.call
43
- end
44
-
45
- private
46
-
47
- def validate_positioning!(before, after, at)
48
- count = [before, after, at].count { |v| !v.nil? }
49
- raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
50
- raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
51
- end
52
-
53
- def find_index!(name)
54
- idx = @steps.index { |s| s.name == name }
55
- raise ArgumentError, "pipeline step :#{name} not found" unless idx
56
- idx
57
- end
58
- end
59
- end
60
- end
File without changes