dexkit 0.1.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module RecordWrapper
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ def _record_wrap
10
+ halted = nil
11
+ result = catch(:_dex_halt) { yield }
12
+
13
+ if result.is_a?(Operation::Halt)
14
+ halted = result
15
+ result = halted.success? ? halted.value : nil
16
+ end
17
+
18
+ if halted.nil? || halted.success?
19
+ if _record_has_pending_record?
20
+ _record_update_done!(result)
21
+ elsif _record_enabled?
22
+ _record_save!(result)
23
+ end
24
+ end
25
+
26
+ throw(:_dex_halt, halted) if halted
27
+ result
28
+ end
29
+
30
+ RECORD_KNOWN_OPTIONS = %i[params response].freeze
31
+
32
+ module ClassMethods
33
+ def record(enabled = nil, **options)
34
+ unknown = options.keys - RecordWrapper::RECORD_KNOWN_OPTIONS
35
+ if unknown.any?
36
+ raise ArgumentError,
37
+ "unknown record option(s): #{unknown.map(&:inspect).join(", ")}. " \
38
+ "Known: #{RecordWrapper::RECORD_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
39
+ end
40
+
41
+ if enabled == false
42
+ set :record, enabled: false
43
+ elsif enabled == true || enabled.nil?
44
+ merged = { enabled: true, params: true, response: true }.merge(options)
45
+ set :record, **merged
46
+ else
47
+ raise ArgumentError,
48
+ "record expects true, false, or nil, got: #{enabled.inspect}"
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def _record_enabled?
56
+ return false unless Dex.record_backend
57
+ return false unless self.class.name
58
+
59
+ record_settings = self.class.settings_for(:record)
60
+ record_settings.fetch(:enabled, true)
61
+ end
62
+
63
+ def _record_has_pending_record?
64
+ defined?(@_dex_record_id) && @_dex_record_id
65
+ end
66
+
67
+ def _record_save!(result)
68
+ Dex.record_backend.create_record(_record_attributes(result))
69
+ rescue => e
70
+ _record_handle_error(e)
71
+ end
72
+
73
+ def _record_update_done!(result)
74
+ attrs = { status: "done", performed_at: _record_current_time }
75
+ attrs[:response] = _record_response(result) if _record_response?
76
+ Dex.record_backend.update_record(@_dex_record_id, attrs)
77
+ rescue => e
78
+ _record_handle_error(e)
79
+ end
80
+
81
+ def _record_attributes(result)
82
+ attrs = { name: self.class.name, performed_at: _record_current_time, status: "done" }
83
+ attrs[:params] = _record_params? ? _record_params : nil
84
+ attrs[:response] = _record_response? ? _record_response(result) : nil
85
+ attrs
86
+ end
87
+
88
+ def _record_params
89
+ _props_as_json
90
+ end
91
+
92
+ def _record_params?
93
+ self.class.settings_for(:record).fetch(:params, true)
94
+ end
95
+
96
+ def _record_response?
97
+ self.class.settings_for(:record).fetch(:response, true)
98
+ end
99
+
100
+ def _record_response(result)
101
+ success_type = self.class.respond_to?(:_success_type) && self.class._success_type
102
+
103
+ if success_type
104
+ _record_serialize_typed_result(result, success_type)
105
+ else
106
+ case result
107
+ when nil then nil
108
+ when Hash then result
109
+ else { value: result }
110
+ end
111
+ end
112
+ end
113
+
114
+ def _record_serialize_typed_result(result, type)
115
+ return nil if result.nil?
116
+
117
+ ref_type = self.class.send(:_dex_find_ref_type, type)
118
+ if ref_type && result.respond_to?(:id)
119
+ result.id
120
+ else
121
+ result.respond_to?(:as_json) ? result.as_json : result
122
+ end
123
+ end
124
+
125
+ def _record_current_time
126
+ Time.respond_to?(:current) ? Time.current : Time.now
127
+ end
128
+
129
+ def _record_handle_error(error)
130
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
131
+ Rails.logger.warn "[Dex] Failed to record operation: #{error.message}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module RescueWrapper
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def rescue_from(*exception_classes, as:, message: nil)
11
+ raise ArgumentError, "rescue_from requires at least one exception class" if exception_classes.empty?
12
+
13
+ invalid = exception_classes.reject { |k| k.is_a?(Module) && k <= Exception }
14
+ if invalid.any?
15
+ raise ArgumentError,
16
+ "rescue_from expects Exception subclasses, got: #{invalid.map(&:inspect).join(", ")}"
17
+ end
18
+
19
+ raise ArgumentError, "rescue_from :as must be a Symbol, got: #{as.inspect}" unless as.is_a?(Symbol)
20
+
21
+ exception_classes.each do |klass|
22
+ _rescue_own << { exception_class: klass, code: as, message: message }
23
+ end
24
+ end
25
+
26
+ def _rescue_handlers
27
+ parent = superclass.respond_to?(:_rescue_handlers) ? superclass._rescue_handlers : []
28
+ parent + _rescue_own
29
+ end
30
+
31
+ private
32
+
33
+ def _rescue_own
34
+ @_rescue_handlers ||= []
35
+ end
36
+ end
37
+
38
+ def _rescue_wrap
39
+ yield
40
+ rescue Dex::Error
41
+ raise
42
+ rescue => e
43
+ handler = _rescue_find_handler(e)
44
+ raise unless handler
45
+
46
+ _rescue_convert!(e, handler)
47
+ end
48
+
49
+ private
50
+
51
+ def _rescue_find_handler(exception)
52
+ self.class._rescue_handlers.reverse_each do |handler|
53
+ return handler if exception.is_a?(handler[:exception_class])
54
+ end
55
+ nil
56
+ end
57
+
58
+ def _rescue_convert!(exception, handler)
59
+ msg = handler[:message] || exception.message
60
+ raise Dex::Error.new(handler[:code], msg, details: { original: exception })
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module ResultWrapper
5
+ module ClassMethods
6
+ def success(type)
7
+ @_success_type = type
8
+ end
9
+
10
+ def error(*codes)
11
+ invalid = codes.reject { |c| c.is_a?(Symbol) }
12
+ if invalid.any?
13
+ raise ArgumentError, "error codes must be Symbols, got: #{invalid.map(&:inspect).join(", ")}"
14
+ end
15
+
16
+ @_declared_errors ||= []
17
+ @_declared_errors.concat(codes)
18
+ end
19
+
20
+ def _success_type
21
+ @_success_type || (superclass.respond_to?(:_success_type) ? superclass._success_type : nil)
22
+ end
23
+
24
+ def _declared_errors
25
+ parent = superclass.respond_to?(:_declared_errors) ? superclass._declared_errors : []
26
+ own = @_declared_errors || []
27
+ (parent + own).uniq
28
+ end
29
+
30
+ def _has_declared_errors?
31
+ _declared_errors.any?
32
+ end
33
+ end
34
+
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ end
38
+
39
+ def _result_wrap
40
+ halted = catch(:_dex_halt) { yield }
41
+ if halted.is_a?(Operation::Halt)
42
+ if halted.success?
43
+ _result_validate_success_type!(halted.value)
44
+ halted.value
45
+ else
46
+ raise Dex::Error.new(halted.error_code, halted.error_message, details: halted.error_details)
47
+ end
48
+ else
49
+ _result_validate_success_type!(halted)
50
+ halted
51
+ end
52
+ end
53
+
54
+ def error!(code, message = nil, details: nil)
55
+ if self.class._has_declared_errors? && !self.class._declared_errors.include?(code)
56
+ raise ArgumentError, "Undeclared error code: #{code.inspect}. Declared: #{self.class._declared_errors.inspect}"
57
+ end
58
+
59
+ throw(:_dex_halt, Operation::Halt.new(type: :error, error_code: code, error_message: message,
60
+ error_details: details))
61
+ end
62
+
63
+ def success!(value = nil, **attrs)
64
+ throw(:_dex_halt, Operation::Halt.new(type: :success, value: attrs.empty? ? value : attrs))
65
+ end
66
+
67
+ def assert!(*args, &block)
68
+ if block
69
+ code = args[0]
70
+ value = yield
71
+ else
72
+ value, code = args
73
+ end
74
+
75
+ error!(code) unless value
76
+ value
77
+ end
78
+
79
+ private
80
+
81
+ def _result_validate_success_type!(value)
82
+ return if value.nil?
83
+
84
+ success_type = self.class._success_type
85
+ return unless success_type
86
+ return if success_type === value
87
+
88
+ raise ArgumentError,
89
+ "#{self.class.name || "Operation"} declared `success #{success_type.inspect}` but returned #{value.class}"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module SafeWrapper
5
+ def safe
6
+ Operation::SafeProxy.new(self)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Settings
5
+ module ClassMethods
6
+ def set(key, **options)
7
+ @_settings ||= {}
8
+ @_settings[key] = (@_settings[key] || {}).merge(options)
9
+ end
10
+
11
+ def settings_for(key)
12
+ parent_settings = if superclass.respond_to?(:settings_for)
13
+ superclass.settings_for(key) || {}
14
+ else
15
+ {}
16
+ end
17
+ own_settings = @_settings&.dig(key) || {}
18
+ parent_settings.merge(own_settings)
19
+ end
20
+ end
21
+
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module TransactionAdapter
6
+ def self.for(adapter_name)
7
+ case adapter_name&.to_sym
8
+ when :active_record
9
+ ActiveRecordAdapter
10
+ when :mongoid
11
+ MongoidAdapter
12
+ when nil
13
+ detect
14
+ else
15
+ raise ArgumentError, "Unknown transaction adapter: #{adapter_name}"
16
+ end
17
+ end
18
+
19
+ def self.detect
20
+ if defined?(ActiveRecord::Base)
21
+ ActiveRecordAdapter
22
+ elsif defined?(Mongoid)
23
+ MongoidAdapter
24
+ end
25
+ end
26
+
27
+ module ActiveRecordAdapter
28
+ def self.wrap(&block)
29
+ unless defined?(ActiveRecord::Base)
30
+ raise LoadError, "ActiveRecord is required for transactions"
31
+ end
32
+ ActiveRecord::Base.transaction(&block)
33
+ end
34
+
35
+ def self.rollback_exception_class
36
+ ActiveRecord::Rollback
37
+ end
38
+ end
39
+
40
+ module MongoidAdapter
41
+ def self.wrap(&block)
42
+ unless defined?(Mongoid)
43
+ raise LoadError, "Mongoid is required for transactions"
44
+ end
45
+ Mongoid.transaction(&block)
46
+ end
47
+
48
+ def self.rollback_exception_class
49
+ Mongoid::Errors::Rollback
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TransactionWrapper
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ def _transaction_wrap
10
+ return yield unless _transaction_enabled?
11
+
12
+ halted = nil
13
+ result = _transaction_execute do
14
+ halted_value = catch(:_dex_halt) { yield }
15
+ if halted_value.is_a?(Operation::Halt)
16
+ halted = halted_value
17
+ raise _transaction_adapter.rollback_exception_class if halted.error?
18
+ halted.value
19
+ else
20
+ halted_value
21
+ end
22
+ end
23
+
24
+ throw(:_dex_halt, halted) if halted
25
+ result
26
+ end
27
+
28
+ TRANSACTION_KNOWN_ADAPTERS = %i[active_record mongoid].freeze
29
+ TRANSACTION_KNOWN_OPTIONS = %i[adapter].freeze
30
+
31
+ module ClassMethods
32
+ def transaction(enabled_or_options = nil, **options)
33
+ unknown = options.keys - TransactionWrapper::TRANSACTION_KNOWN_OPTIONS
34
+ if unknown.any?
35
+ raise ArgumentError,
36
+ "unknown transaction option(s): #{unknown.map(&:inspect).join(", ")}. " \
37
+ "Known: #{TransactionWrapper::TRANSACTION_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
38
+ end
39
+
40
+ case enabled_or_options
41
+ when false
42
+ set :transaction, enabled: false
43
+ when true, nil
44
+ _transaction_validate_adapter!(options[:adapter]) if options.key?(:adapter)
45
+ set :transaction, enabled: true, **options
46
+ when Symbol
47
+ _transaction_validate_adapter!(enabled_or_options)
48
+ set :transaction, enabled: true, adapter: enabled_or_options, **options
49
+ else
50
+ raise ArgumentError,
51
+ "transaction expects true, false, nil, or a Symbol adapter, got: #{enabled_or_options.inspect}"
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def _transaction_validate_adapter!(adapter)
58
+ return if adapter.nil?
59
+
60
+ unless TransactionWrapper::TRANSACTION_KNOWN_ADAPTERS.include?(adapter.to_sym)
61
+ raise ArgumentError,
62
+ "unknown transaction adapter: #{adapter.inspect}. " \
63
+ "Known: #{TransactionWrapper::TRANSACTION_KNOWN_ADAPTERS.map(&:inspect).join(", ")}"
64
+ end
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def _transaction_enabled?
71
+ settings = self.class.settings_for(:transaction)
72
+ return false unless settings.fetch(:enabled, true)
73
+
74
+ !_transaction_adapter.nil?
75
+ end
76
+
77
+ def _transaction_adapter
78
+ settings = self.class.settings_for(:transaction)
79
+ adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
80
+ Operation::TransactionAdapter.for(adapter_name)
81
+ end
82
+
83
+ def _transaction_execute(&block)
84
+ _transaction_adapter.wrap(&block)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wrapper modules (loaded before class body so `include`/`use` can find them)
4
+ require_relative "operation/settings"
5
+ require_relative "operation/props_setup"
6
+ require_relative "operation/result_wrapper"
7
+ require_relative "operation/record_wrapper"
8
+ require_relative "operation/transaction_wrapper"
9
+ require_relative "operation/lock_wrapper"
10
+ require_relative "operation/async_wrapper"
11
+ require_relative "operation/safe_wrapper"
12
+ require_relative "operation/rescue_wrapper"
13
+ require_relative "operation/callback_wrapper"
14
+
15
+ # Pipeline (referenced inside class body)
16
+ require_relative "operation/pipeline"
17
+
18
+ module Dex
19
+ class Operation
20
+ Halt = Struct.new(:type, :value, :error_code, :error_message, :error_details, keyword_init: true) do
21
+ def success? = type == :success
22
+ def error? = type == :error
23
+ end
24
+
25
+ def self._serialized_coercions
26
+ @_serialized_coercions ||= {
27
+ Time => ->(v) { v.is_a?(String) ? Time.parse(v) : v },
28
+ Symbol => ->(v) { v.is_a?(String) ? v.to_sym : v }
29
+ }.tap do |h|
30
+ h[Date] = ->(v) { v.is_a?(String) ? Date.parse(v) : v } if defined?(Date)
31
+ h[DateTime] = ->(v) { v.is_a?(String) ? DateTime.parse(v) : v } if defined?(DateTime)
32
+ h[BigDecimal] = ->(v) { v.is_a?(String) ? BigDecimal(v) : v } if defined?(BigDecimal)
33
+ end.freeze
34
+ end
35
+
36
+ Contract = Data.define(:params, :success, :errors)
37
+
38
+ def self.contract
39
+ Contract.new(
40
+ params: _contract_params,
41
+ success: _success_type,
42
+ errors: _declared_errors
43
+ )
44
+ end
45
+
46
+ def self._contract_params
47
+ return {} unless respond_to?(:literal_properties)
48
+
49
+ literal_properties.each_with_object({}) do |prop, hash|
50
+ hash[prop.name] = prop.type
51
+ end
52
+ end
53
+
54
+ private_class_method :_contract_params
55
+
56
+ def self._dex_find_ref_type(type)
57
+ return type if type.is_a?(Dex::RefType)
58
+
59
+ if type.respond_to?(:type)
60
+ inner = type.type
61
+ return inner if inner.is_a?(Dex::RefType)
62
+ end
63
+ nil
64
+ end
65
+
66
+ def self._dex_coerce_serialized_hash(hash)
67
+ return hash.transform_keys(&:to_sym) unless respond_to?(:literal_properties)
68
+
69
+ result = {}
70
+ literal_properties.each do |prop|
71
+ name = prop.name
72
+ raw = hash.key?(name) ? hash[name] : hash[name.to_s]
73
+ result[name] = _dex_coerce_value(prop.type, raw)
74
+ end
75
+ result
76
+ end
77
+
78
+ def self._dex_resolve_base_class(type)
79
+ return type.model_class if type.is_a?(Dex::RefType)
80
+ return _dex_resolve_base_class(type.type) if type.respond_to?(:type)
81
+
82
+ type if type.is_a?(Class)
83
+ end
84
+
85
+ def self._dex_coerce_value(type, value)
86
+ return value unless value
87
+ return value if _dex_find_ref_type(type)
88
+
89
+ if type.respond_to?(:type) && type.is_a?(Literal::Types::ArrayType)
90
+ return value.map { |v| _dex_coerce_value(type.type, v) } if value.is_a?(Array)
91
+ return value
92
+ end
93
+
94
+ if type.respond_to?(:type) && type.is_a?(Literal::Types::NilableType)
95
+ return _dex_coerce_value(type.type, value)
96
+ end
97
+
98
+ base = _dex_resolve_base_class(type)
99
+ coercion = _serialized_coercions[base]
100
+ coercion ? coercion.call(value) : value
101
+ end
102
+
103
+ private_class_method :_dex_resolve_base_class, :_dex_coerce_value, :_dex_find_ref_type, :_dex_coerce_serialized_hash
104
+
105
+ def self.inherited(subclass)
106
+ subclass.instance_variable_set(:@_pipeline, pipeline.dup)
107
+ super
108
+ end
109
+
110
+ def self.pipeline
111
+ @_pipeline ||= Pipeline.new
112
+ end
113
+
114
+ def self.use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
115
+ step_name = as || _derive_step_name(mod)
116
+ wrap_method = wrap || :"_#{step_name}_wrap"
117
+ pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
118
+ include mod
119
+ end
120
+
121
+ def self._derive_step_name(mod)
122
+ base = mod.name&.split("::")&.last
123
+ raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
124
+
125
+ base.sub(/Wrapper\z/, "")
126
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
127
+ .downcase
128
+ .to_sym
129
+ end
130
+ private_class_method :_derive_step_name
131
+
132
+ def perform(*, **)
133
+ end
134
+
135
+ def call
136
+ self.class.pipeline.execute(self) { perform }
137
+ end
138
+
139
+ def self.method_added(method_name)
140
+ super
141
+ return unless method_name == :perform
142
+
143
+ private :perform
144
+ end
145
+
146
+ private :perform
147
+
148
+ def self.call(**kwargs)
149
+ new(**kwargs).call
150
+ end
151
+
152
+ # Serialization helpers
153
+
154
+ def _props_as_json
155
+ return {} unless self.class.respond_to?(:literal_properties)
156
+
157
+ result = {}
158
+ self.class.literal_properties.each do |prop|
159
+ value = public_send(prop.name)
160
+ ref = self.class.send(:_dex_find_ref_type, prop.type)
161
+ result[prop.name.to_s] = if ref && value
162
+ value.id
163
+ else
164
+ value.respond_to?(:as_json) ? value.as_json : value
165
+ end
166
+ end
167
+ result
168
+ end
169
+
170
+ include Settings
171
+ include AsyncWrapper
172
+ include SafeWrapper
173
+ include PropsSetup
174
+
175
+ use ResultWrapper
176
+ use LockWrapper
177
+ use TransactionWrapper
178
+ use RecordWrapper
179
+ use RescueWrapper
180
+ use CallbackWrapper
181
+ end
182
+ end
183
+
184
+ # Nested classes (reopen Operation after it's defined)
185
+ require_relative "operation/outcome"
186
+ require_relative "operation/async_proxy"
187
+ require_relative "operation/record_backend"
188
+ require_relative "operation/transaction_adapter"
189
+ require_relative "operation/jobs"
190
+
191
+ # Top-level aliases (depend on Operation::Ok/Err)
192
+ require_relative "match"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class RefType
5
+ include Literal::Type
6
+
7
+ attr_reader :model_class, :lock
8
+
9
+ def initialize(model_class, lock: false)
10
+ @model_class = model_class
11
+ @lock = lock
12
+ end
13
+
14
+ def ===(value)
15
+ value.is_a?(@model_class)
16
+ end
17
+
18
+ def >=(other, context: nil)
19
+ other.is_a?(RefType) && other.model_class <= @model_class
20
+ end
21
+
22
+ def coerce(value)
23
+ return value if value.is_a?(@model_class)
24
+ return value if value.nil?
25
+
26
+ scope = @lock ? @model_class.lock : @model_class
27
+ scope.find(value)
28
+ end
29
+
30
+ def inspect
31
+ @lock ? "_Ref(#{@model_class}, lock: true)" : "_Ref(#{@model_class})"
32
+ end
33
+ end
34
+ end