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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/guides/llm/OPERATION.md +553 -0
- data/lib/dex/error.rb +17 -0
- data/lib/dex/match.rb +13 -0
- data/lib/dex/operation/async_proxy.rb +115 -0
- data/lib/dex/operation/async_wrapper.rb +35 -0
- data/lib/dex/operation/callback_wrapper.rb +113 -0
- data/lib/dex/operation/jobs.rb +64 -0
- data/lib/dex/operation/lock_wrapper.rb +77 -0
- data/lib/dex/operation/outcome.rb +70 -0
- data/lib/dex/operation/pipeline.rb +60 -0
- data/lib/dex/operation/props_setup.rb +50 -0
- data/lib/dex/operation/record_backend.rb +64 -0
- data/lib/dex/operation/record_wrapper.rb +135 -0
- data/lib/dex/operation/rescue_wrapper.rb +63 -0
- data/lib/dex/operation/result_wrapper.rb +92 -0
- data/lib/dex/operation/safe_wrapper.rb +9 -0
- data/lib/dex/operation/settings.rb +26 -0
- data/lib/dex/operation/transaction_adapter.rb +54 -0
- data/lib/dex/operation/transaction_wrapper.rb +87 -0
- data/lib/dex/operation.rb +192 -0
- data/lib/dex/ref_type.rb +34 -0
- data/lib/dex/test_helpers/assertions.rb +310 -0
- data/lib/dex/test_helpers/execution.rb +28 -0
- data/lib/dex/test_helpers/stubbing.rb +59 -0
- data/lib/dex/test_helpers.rb +146 -0
- data/lib/dex/test_log.rb +55 -0
- data/lib/dex/version.rb +5 -0
- data/lib/dexkit.rb +57 -0
- metadata +160 -0
data/lib/dex/error.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :details
|
|
6
|
+
|
|
7
|
+
def initialize(code, message = nil, details: nil)
|
|
8
|
+
@code = code
|
|
9
|
+
@details = details
|
|
10
|
+
super(message || code.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deconstruct_keys(keys)
|
|
14
|
+
{ code: @code, message: message, details: @details }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/dex/match.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
# Top-level aliases for clean pattern matching
|
|
5
|
+
Ok = Operation::Ok
|
|
6
|
+
Err = Operation::Err
|
|
7
|
+
|
|
8
|
+
# Module for including Ok/Err constants without namespace prefix
|
|
9
|
+
module Match
|
|
10
|
+
Ok = Dex::Ok
|
|
11
|
+
Err = Dex::Err
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
class AsyncProxy
|
|
6
|
+
def initialize(operation, **runtime_options)
|
|
7
|
+
@operation = operation
|
|
8
|
+
@runtime_options = runtime_options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
_async_ensure_active_job_loaded!
|
|
13
|
+
if _async_use_record_strategy?
|
|
14
|
+
_async_enqueue_record_job
|
|
15
|
+
else
|
|
16
|
+
_async_enqueue_direct_job
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def _async_enqueue_direct_job
|
|
23
|
+
job = _async_apply_options(Operation::DirectJob)
|
|
24
|
+
job.perform_later(class_name: _async_operation_class_name, params: _async_serialized_params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def _async_enqueue_record_job
|
|
28
|
+
record = Dex.record_backend.create_record(
|
|
29
|
+
name: _async_operation_class_name,
|
|
30
|
+
params: _async_serialized_params,
|
|
31
|
+
status: "pending"
|
|
32
|
+
)
|
|
33
|
+
begin
|
|
34
|
+
job = _async_apply_options(Operation::RecordJob)
|
|
35
|
+
job.perform_later(class_name: _async_operation_class_name, record_id: record.id)
|
|
36
|
+
rescue => e
|
|
37
|
+
begin
|
|
38
|
+
record.destroy
|
|
39
|
+
rescue => destroy_error
|
|
40
|
+
_async_log_warning("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
|
|
41
|
+
end
|
|
42
|
+
raise e
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _async_use_record_strategy?
|
|
47
|
+
return false unless Dex.record_backend
|
|
48
|
+
return false unless @operation.class.name
|
|
49
|
+
|
|
50
|
+
record_settings = @operation.class.settings_for(:record)
|
|
51
|
+
return false if record_settings[:enabled] == false
|
|
52
|
+
return false if record_settings[:params] == false
|
|
53
|
+
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def _async_apply_options(job_class)
|
|
58
|
+
options = {}
|
|
59
|
+
options[:queue] = _async_queue if _async_queue
|
|
60
|
+
options[:wait_until] = _async_scheduled_at if _async_scheduled_at
|
|
61
|
+
options[:wait] = _async_scheduled_in if _async_scheduled_in
|
|
62
|
+
options.empty? ? job_class : job_class.set(**options)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def _async_ensure_active_job_loaded!
|
|
66
|
+
return if defined?(ActiveJob::Base)
|
|
67
|
+
|
|
68
|
+
raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def _async_merged_options
|
|
72
|
+
@operation.class.settings_for(:async).merge(@runtime_options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def _async_queue = _async_merged_options[:queue]
|
|
76
|
+
def _async_scheduled_at = _async_merged_options[:at]
|
|
77
|
+
def _async_scheduled_in = _async_merged_options[:in]
|
|
78
|
+
def _async_operation_class_name = @operation.class.name
|
|
79
|
+
|
|
80
|
+
def _async_serialized_params
|
|
81
|
+
@_async_serialized_params ||= begin
|
|
82
|
+
hash = @operation._props_as_json
|
|
83
|
+
_async_validate_serializable!(hash)
|
|
84
|
+
hash
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def _async_log_warning(message)
|
|
89
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
90
|
+
Rails.logger.warn "[Dex] #{message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def _async_validate_serializable!(hash, path: "")
|
|
95
|
+
hash.each do |key, value|
|
|
96
|
+
current = path.empty? ? key.to_s : "#{path}.#{key}"
|
|
97
|
+
case value
|
|
98
|
+
when String, Integer, Float, NilClass, TrueClass, FalseClass
|
|
99
|
+
next
|
|
100
|
+
when Hash
|
|
101
|
+
_async_validate_serializable!(value, path: current)
|
|
102
|
+
when Array
|
|
103
|
+
value.each_with_index do |v, i|
|
|
104
|
+
_async_validate_serializable!({ i => v }, path: current)
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"Param '#{current}' (#{value.class}) is not JSON-serializable. " \
|
|
109
|
+
"Async operations require all params to be serializable."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module AsyncWrapper
|
|
5
|
+
ASYNC_KNOWN_OPTIONS = %i[queue in at].freeze
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def async(**options)
|
|
9
|
+
unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
|
|
10
|
+
if unknown.any?
|
|
11
|
+
raise ArgumentError,
|
|
12
|
+
"unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
13
|
+
"Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
set(:async, **options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def async(**options)
|
|
25
|
+
unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
|
|
26
|
+
if unknown.any?
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
29
|
+
"Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Operation::AsyncProxy.new(self, **options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module CallbackWrapper
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def before(callable = nil, &block)
|
|
11
|
+
_callback_validate!(callable, block)
|
|
12
|
+
entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
|
|
13
|
+
_callback_own(:before) << entry
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def after(callable = nil, &block)
|
|
17
|
+
_callback_validate!(callable, block)
|
|
18
|
+
entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
|
|
19
|
+
_callback_own(:after) << entry
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def around(callable = nil, &block)
|
|
23
|
+
_callback_validate!(callable, block)
|
|
24
|
+
entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
|
|
25
|
+
_callback_own(:around) << entry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def _callback_list(type)
|
|
29
|
+
parent_callbacks = superclass.respond_to?(:_callback_list) ? superclass._callback_list(type) : []
|
|
30
|
+
parent_callbacks + _callback_own(type)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def _callback_any?
|
|
34
|
+
%i[before after around].any? { |type| _callback_list(type).any? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def _callback_validate!(callable, block)
|
|
40
|
+
return if callable.is_a?(Symbol)
|
|
41
|
+
return if callable.nil? && block
|
|
42
|
+
return if callable.is_a?(Proc)
|
|
43
|
+
|
|
44
|
+
if callable.nil?
|
|
45
|
+
raise ArgumentError, "callback requires a Symbol, Proc, or block"
|
|
46
|
+
else
|
|
47
|
+
raise ArgumentError, "callback must be a Symbol or Proc, got: #{callable.class}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def _callback_own(type)
|
|
52
|
+
@_callbacks ||= { before: [], after: [], around: [] }
|
|
53
|
+
@_callbacks[type]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def _callback_wrap
|
|
58
|
+
return yield unless self.class._callback_any?
|
|
59
|
+
|
|
60
|
+
halted = nil
|
|
61
|
+
result = _callback_run_around(self.class._callback_list(:around)) do
|
|
62
|
+
_callback_run_before
|
|
63
|
+
caught = catch(:_dex_halt) { yield }
|
|
64
|
+
if caught.is_a?(Operation::Halt)
|
|
65
|
+
if caught.success?
|
|
66
|
+
halted = caught
|
|
67
|
+
_callback_run_after
|
|
68
|
+
caught.value
|
|
69
|
+
else
|
|
70
|
+
throw(:_dex_halt, caught)
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
_callback_run_after
|
|
74
|
+
caught
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
throw(:_dex_halt, halted) if halted
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def _callback_run_before
|
|
84
|
+
self.class._callback_list(:before).each { |cb| _callback_invoke(cb) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def _callback_run_after
|
|
88
|
+
self.class._callback_list(:after).each { |cb| _callback_invoke(cb) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def _callback_invoke(cb)
|
|
92
|
+
kind, callable = cb
|
|
93
|
+
case kind
|
|
94
|
+
when :method then send(callable)
|
|
95
|
+
when :proc then instance_exec(&callable)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _callback_run_around(chain, &core)
|
|
100
|
+
if chain.empty?
|
|
101
|
+
core.call
|
|
102
|
+
else
|
|
103
|
+
kind, callable = chain.first
|
|
104
|
+
rest = chain[1..]
|
|
105
|
+
continuation = -> { _callback_run_around(rest, &core) }
|
|
106
|
+
case kind
|
|
107
|
+
when :method then send(callable) { continuation.call }
|
|
108
|
+
when :proc then instance_exec(continuation, &callable)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
# Job classes are defined lazily when ActiveJob is loaded
|
|
6
|
+
def self.const_missing(name)
|
|
7
|
+
return super unless defined?(ActiveJob::Base)
|
|
8
|
+
|
|
9
|
+
case name
|
|
10
|
+
when :DirectJob
|
|
11
|
+
const_set(:DirectJob, Class.new(ActiveJob::Base) do
|
|
12
|
+
def perform(class_name:, params:)
|
|
13
|
+
klass = class_name.constantize
|
|
14
|
+
klass.new(**klass.send(:_dex_coerce_serialized_hash, params)).call
|
|
15
|
+
end
|
|
16
|
+
end)
|
|
17
|
+
when :RecordJob
|
|
18
|
+
const_set(:RecordJob, Class.new(ActiveJob::Base) do
|
|
19
|
+
def perform(class_name:, record_id:)
|
|
20
|
+
klass = class_name.constantize
|
|
21
|
+
record = Dex.record_backend.find_record(record_id)
|
|
22
|
+
params = klass.send(:_dex_coerce_serialized_hash, record.params || {})
|
|
23
|
+
|
|
24
|
+
op = klass.new(**params)
|
|
25
|
+
op.instance_variable_set(:@_dex_record_id, record_id)
|
|
26
|
+
|
|
27
|
+
_dex_update_status(record_id, status: "running")
|
|
28
|
+
op.call
|
|
29
|
+
rescue => e
|
|
30
|
+
_dex_handle_failure(record_id, e)
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def _dex_update_status(record_id, **attributes)
|
|
37
|
+
Dex.record_backend.update_record(record_id, attributes)
|
|
38
|
+
rescue => e
|
|
39
|
+
_dex_log_warning("Failed to update record status: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def _dex_handle_failure(record_id, exception)
|
|
43
|
+
error_value = if exception.is_a?(Dex::Error)
|
|
44
|
+
exception.code.to_s
|
|
45
|
+
else
|
|
46
|
+
exception.class.name
|
|
47
|
+
end
|
|
48
|
+
_dex_update_status(record_id, status: "failed", error: error_value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def _dex_log_warning(message)
|
|
52
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
53
|
+
Rails.logger.warn "[Dex] #{message}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end)
|
|
57
|
+
when :Job
|
|
58
|
+
const_set(:Job, const_get(:DirectJob))
|
|
59
|
+
else
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module LockWrapper
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
LOCK_KNOWN_OPTIONS = %i[timeout].freeze
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def advisory_lock(key = nil, **options, &block)
|
|
13
|
+
unknown = options.keys - LockWrapper::LOCK_KNOWN_OPTIONS
|
|
14
|
+
if unknown.any?
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"unknown advisory_lock option(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
17
|
+
"Known: #{LockWrapper::LOCK_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
lock_key = block || key
|
|
21
|
+
|
|
22
|
+
unless lock_key.nil? || lock_key.is_a?(String) || lock_key.is_a?(Symbol) || lock_key.is_a?(Proc)
|
|
23
|
+
raise ArgumentError, "advisory_lock key must be a String, Symbol, or Proc, got: #{lock_key.class}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if options.key?(:timeout) && !options[:timeout].is_a?(Numeric)
|
|
27
|
+
raise ArgumentError, "advisory_lock :timeout must be Numeric, got: #{options[:timeout].inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
set(:advisory_lock, enabled: true, key: lock_key, **options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def _lock_wrap
|
|
35
|
+
_lock_enabled? ? _lock_execute { yield } : yield
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def _lock_enabled?
|
|
41
|
+
self.class.settings_for(:advisory_lock).fetch(:enabled, false)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _lock_key
|
|
45
|
+
key = self.class.settings_for(:advisory_lock)[:key]
|
|
46
|
+
case key
|
|
47
|
+
when String then key
|
|
48
|
+
when Symbol then send(key)
|
|
49
|
+
when Proc then instance_exec(&key)
|
|
50
|
+
when nil then self.class.name || raise(ArgumentError, "Anonymous classes must provide an explicit lock key")
|
|
51
|
+
else raise ArgumentError, "Unsupported advisory_lock key type: #{key.class}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def _lock_options
|
|
56
|
+
opts = {}
|
|
57
|
+
timeout = self.class.settings_for(:advisory_lock)[:timeout]
|
|
58
|
+
opts[:timeout_seconds] = timeout if timeout
|
|
59
|
+
opts
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def _lock_execute(&block)
|
|
63
|
+
_lock_ensure_loaded!
|
|
64
|
+
key = _lock_key
|
|
65
|
+
ActiveRecord::Base.with_advisory_lock!(key, **_lock_options, &block)
|
|
66
|
+
rescue WithAdvisoryLock::FailedToAcquireLock
|
|
67
|
+
raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def _lock_ensure_loaded!
|
|
71
|
+
return if ActiveRecord::Base.respond_to?(:with_advisory_lock!)
|
|
72
|
+
|
|
73
|
+
raise LoadError,
|
|
74
|
+
"with_advisory_lock gem is required for advisory locking. Add 'with_advisory_lock' to your Gemfile."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
class Ok
|
|
6
|
+
attr_reader :value
|
|
7
|
+
|
|
8
|
+
def initialize(value)
|
|
9
|
+
@value = value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ok? = true
|
|
13
|
+
def error? = false
|
|
14
|
+
|
|
15
|
+
def value! = @value
|
|
16
|
+
|
|
17
|
+
def method_missing(method, *args, &block)
|
|
18
|
+
if @value.respond_to?(method)
|
|
19
|
+
@value.public_send(method, *args, &block)
|
|
20
|
+
else
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(method, include_private = false)
|
|
26
|
+
@value.respond_to?(method, include_private) || super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deconstruct_keys(keys)
|
|
30
|
+
return { value: @value } unless @value.respond_to?(:deconstruct_keys)
|
|
31
|
+
@value.deconstruct_keys(keys)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Err
|
|
36
|
+
attr_reader :error
|
|
37
|
+
|
|
38
|
+
def initialize(error)
|
|
39
|
+
@error = error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ok? = false
|
|
43
|
+
def error? = true
|
|
44
|
+
|
|
45
|
+
def value = nil
|
|
46
|
+
def value! = raise @error
|
|
47
|
+
|
|
48
|
+
def code = @error.code
|
|
49
|
+
def message = @error.message
|
|
50
|
+
def details = @error.details
|
|
51
|
+
|
|
52
|
+
def deconstruct_keys(keys)
|
|
53
|
+
{ code: @error.code, message: @error.message, details: @error.details }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class SafeProxy
|
|
58
|
+
def initialize(operation)
|
|
59
|
+
@operation = operation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call
|
|
63
|
+
result = @operation.call
|
|
64
|
+
Operation::Ok.new(result)
|
|
65
|
+
rescue Dex::Error => e
|
|
66
|
+
Operation::Err.new(e)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Dex
|
|
6
|
+
module PropsSetup
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(Literal::Properties)
|
|
9
|
+
base.extend(Literal::Types)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
RESERVED_PROP_NAMES = %i[call perform async safe initialize].to_set.freeze
|
|
15
|
+
|
|
16
|
+
def prop(name, type, kind = :keyword, **options, &block)
|
|
17
|
+
_props_validate_name!(name)
|
|
18
|
+
options[:reader] = :public unless options.key?(:reader)
|
|
19
|
+
if type.is_a?(Dex::RefType) && !block
|
|
20
|
+
ref = type
|
|
21
|
+
block = ->(v) { ref.coerce(v) }
|
|
22
|
+
end
|
|
23
|
+
super(name, type, kind, **options, &block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prop?(name, type, kind = :keyword, **options, &block)
|
|
27
|
+
options[:reader] = :public unless options.key?(:reader)
|
|
28
|
+
options[:default] = nil unless options.key?(:default)
|
|
29
|
+
if type.is_a?(Dex::RefType) && !block
|
|
30
|
+
ref = type
|
|
31
|
+
block = ->(v) { v.nil? ? v : ref.coerce(v) }
|
|
32
|
+
end
|
|
33
|
+
prop(name, _Nilable(type), kind, **options, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def _Ref(model_class, lock: false) # rubocop:disable Naming/MethodName
|
|
37
|
+
Dex::RefType.new(model_class, lock: lock)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def _props_validate_name!(name)
|
|
43
|
+
return unless RESERVED_PROP_NAMES.include?(name)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"Property :#{name} conflicts with core Operation methods."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module RecordBackend
|
|
6
|
+
def self.for(record_class)
|
|
7
|
+
return nil unless record_class
|
|
8
|
+
|
|
9
|
+
if defined?(ActiveRecord::Base) && record_class < ActiveRecord::Base
|
|
10
|
+
ActiveRecordAdapter.new(record_class)
|
|
11
|
+
elsif defined?(Mongoid::Document) && record_class.include?(Mongoid::Document)
|
|
12
|
+
MongoidAdapter.new(record_class)
|
|
13
|
+
else
|
|
14
|
+
raise ArgumentError, "record_class must inherit from ActiveRecord::Base or include Mongoid::Document"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Base
|
|
19
|
+
attr_reader :record_class
|
|
20
|
+
|
|
21
|
+
def initialize(record_class)
|
|
22
|
+
@record_class = record_class
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_record(attributes)
|
|
26
|
+
record_class.create!(safe_attributes(attributes))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_record(id)
|
|
30
|
+
record_class.find(id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update_record(id, attributes)
|
|
34
|
+
record_class.find(id).update!(safe_attributes(attributes))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def safe_attributes(attributes)
|
|
38
|
+
attributes.select { |key, _| has_field?(key.to_s) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_field?(field_name)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ActiveRecordAdapter < Base
|
|
47
|
+
def initialize(record_class)
|
|
48
|
+
super
|
|
49
|
+
@column_set = record_class.column_names.to_set
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def has_field?(field_name)
|
|
53
|
+
@column_set.include?(field_name.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class MongoidAdapter < Base
|
|
58
|
+
def has_field?(field_name)
|
|
59
|
+
record_class.fields.key?(field_name.to_s)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|