dexkit 0.7.0 → 0.9.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/CHANGELOG.md +45 -0
- data/README.md +40 -7
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +60 -5
- data/guides/llm/FORM.md +3 -3
- data/guides/llm/OPERATION.md +127 -18
- data/guides/llm/QUERY.md +3 -3
- data/lib/dex/event/bus.rb +7 -0
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event.rb +27 -0
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/operation/async_proxy.rb +1 -0
- data/lib/dex/operation/explain.rb +208 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +15 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +23 -15
- data/lib/dex/operation/record_backend.rb +25 -0
- data/lib/dex/operation/record_wrapper.rb +29 -4
- data/lib/dex/operation/test_helpers/assertions.rb +335 -0
- data/lib/dex/operation/test_helpers/execution.rb +30 -0
- data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
- data/lib/dex/operation/test_helpers.rb +150 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +46 -2
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query.rb +9 -5
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +11 -5
- metadata +16 -5
- data/lib/dex/test_helpers/assertions.rb +0 -333
- data/lib/dex/test_helpers/execution.rb +0 -28
- data/lib/dex/test_helpers/stubbing.rb +0 -59
- /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Explain
|
|
6
|
+
def explain(**kwargs)
|
|
7
|
+
error = nil
|
|
8
|
+
instance = begin
|
|
9
|
+
new(**kwargs)
|
|
10
|
+
rescue Literal::TypeError, ArgumentError => e
|
|
11
|
+
error = e
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
info = {}
|
|
16
|
+
active = pipeline.steps.map(&:name).to_set
|
|
17
|
+
|
|
18
|
+
info[:operation] = name || "(anonymous)"
|
|
19
|
+
desc = description
|
|
20
|
+
info[:description] = desc if desc
|
|
21
|
+
info[:error] = "#{error.class}: #{error.message}" if error
|
|
22
|
+
info[:props] = _explain_props(instance)
|
|
23
|
+
info[:context] = _explain_context(instance, kwargs)
|
|
24
|
+
info[:guards] = active.include?(:guard) ? _explain_guards(instance) : { passed: true, results: [] }
|
|
25
|
+
info[:once] = active.include?(:once) ? _explain_once(instance) : { active: false }
|
|
26
|
+
info[:lock] = active.include?(:lock) ? _explain_lock(instance) : { active: false }
|
|
27
|
+
info[:record] = active.include?(:record) ? _explain_record : { enabled: false }
|
|
28
|
+
info[:transaction] = active.include?(:transaction) ? _explain_transaction : { enabled: false }
|
|
29
|
+
info[:rescue_from] = active.include?(:rescue) ? _explain_rescue : {}
|
|
30
|
+
info[:callbacks] = active.include?(:callback) ? _explain_callbacks : { before: 0, after: 0, around: 0 }
|
|
31
|
+
|
|
32
|
+
if instance
|
|
33
|
+
pipeline.steps.each do |step|
|
|
34
|
+
method_name = :"_#{step.name}_explain"
|
|
35
|
+
send(method_name, instance, info) if respond_to?(method_name, true)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
info[:pipeline] = pipeline.steps.map(&:name)
|
|
40
|
+
info[:callable] = instance ? _explain_callable?(info) : false
|
|
41
|
+
info.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def _explain_callable?(info)
|
|
47
|
+
return false unless info[:guards][:passed]
|
|
48
|
+
return false if info[:record][:enabled] && info[:record][:status] == :misconfigured
|
|
49
|
+
|
|
50
|
+
if info[:once][:active]
|
|
51
|
+
return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ONCE_BLOCKING_STATUSES = %i[invalid pending misconfigured unavailable].freeze
|
|
58
|
+
|
|
59
|
+
def _explain_props(instance)
|
|
60
|
+
return {} unless respond_to?(:literal_properties)
|
|
61
|
+
return {} unless instance
|
|
62
|
+
|
|
63
|
+
literal_properties.each_with_object({}) do |prop, hash|
|
|
64
|
+
hash[prop.name] = instance.public_send(prop.name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def _explain_context(instance, explicit_kwargs)
|
|
69
|
+
mappings = respond_to?(:context_mappings) ? context_mappings : {}
|
|
70
|
+
return { resolved: {}, mappings: {}, source: {} } if mappings.empty?
|
|
71
|
+
|
|
72
|
+
ambient = Dex.context
|
|
73
|
+
resolved = {}
|
|
74
|
+
source = {}
|
|
75
|
+
|
|
76
|
+
mappings.each do |prop_name, context_key|
|
|
77
|
+
resolved[prop_name] = instance.public_send(prop_name) if instance
|
|
78
|
+
source[prop_name] = if explicit_kwargs.key?(prop_name)
|
|
79
|
+
:explicit
|
|
80
|
+
elsif ambient.key?(context_key)
|
|
81
|
+
:ambient
|
|
82
|
+
elsif instance || _explain_prop_has_default?(prop_name)
|
|
83
|
+
:default
|
|
84
|
+
else
|
|
85
|
+
:missing
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
{ resolved: resolved, mappings: mappings, source: source }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def _explain_guards(instance)
|
|
93
|
+
return { passed: false, results: [] } unless instance
|
|
94
|
+
|
|
95
|
+
all_results = instance.send(:_guard_evaluate_all)
|
|
96
|
+
results = all_results.map do |r|
|
|
97
|
+
entry = { name: r[:name], passed: r[:passed] }
|
|
98
|
+
entry[:message] = r[:message] if r[:message]
|
|
99
|
+
entry[:skipped] = true if r[:skipped]
|
|
100
|
+
entry
|
|
101
|
+
end
|
|
102
|
+
{ passed: results.all? { |r| r[:passed] }, results: results }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def _explain_once(instance)
|
|
106
|
+
settings = settings_for(:once)
|
|
107
|
+
return { active: false } unless settings.fetch(:defined, false)
|
|
108
|
+
|
|
109
|
+
key = instance&.send(:_once_derive_key)
|
|
110
|
+
{
|
|
111
|
+
active: true,
|
|
112
|
+
key: key,
|
|
113
|
+
status: _explain_once_status(key),
|
|
114
|
+
expires_in: settings[:expires_in]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def _explain_once_status(key)
|
|
119
|
+
return :invalid if key.nil?
|
|
120
|
+
return :misconfigured if name.nil?
|
|
121
|
+
return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
|
|
122
|
+
return :unavailable unless Dex.record_backend
|
|
123
|
+
return :misconfigured unless Dex.record_backend.missing_fields(send(:_once_required_fields)).empty?
|
|
124
|
+
|
|
125
|
+
existing = Dex.record_backend.find_by_once_key(key)
|
|
126
|
+
return :exists if existing
|
|
127
|
+
|
|
128
|
+
if Dex.record_backend.has_field?("once_key_expires_at")
|
|
129
|
+
expired = Dex.record_backend.find_expired_once_key(key)
|
|
130
|
+
return :expired if expired
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
pending = Dex.record_backend.find_pending_once_key(key)
|
|
134
|
+
return :pending if pending
|
|
135
|
+
|
|
136
|
+
:fresh
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def _explain_lock(instance)
|
|
140
|
+
settings = settings_for(:advisory_lock)
|
|
141
|
+
return { active: false } unless settings.fetch(:enabled, false)
|
|
142
|
+
|
|
143
|
+
key = if instance
|
|
144
|
+
instance.send(:_lock_key)
|
|
145
|
+
else
|
|
146
|
+
case settings[:key]
|
|
147
|
+
when String then settings[:key]
|
|
148
|
+
when nil then name
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
{ active: true, key: key, timeout: settings[:timeout] }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def _explain_prop_has_default?(prop_name)
|
|
156
|
+
return false unless respond_to?(:literal_properties)
|
|
157
|
+
|
|
158
|
+
literal_properties.any? { |p| p.name == prop_name && p.default? }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def _explain_record
|
|
162
|
+
settings = settings_for(:record)
|
|
163
|
+
enabled = settings.fetch(:enabled, true) && !!Dex.record_backend && !!name
|
|
164
|
+
return { enabled: false } unless enabled
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
enabled: true,
|
|
168
|
+
params: settings.fetch(:params, true),
|
|
169
|
+
result: settings.fetch(:result, true)
|
|
170
|
+
}.tap do |entry|
|
|
171
|
+
missing = Dex.record_backend.missing_fields(send(:_record_required_fields))
|
|
172
|
+
if missing.empty?
|
|
173
|
+
entry[:status] = :ready
|
|
174
|
+
else
|
|
175
|
+
entry[:status] = :misconfigured
|
|
176
|
+
entry[:missing_fields] = missing
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def _explain_transaction
|
|
182
|
+
settings = settings_for(:transaction)
|
|
183
|
+
return { enabled: false } unless settings.fetch(:enabled, true)
|
|
184
|
+
|
|
185
|
+
adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
|
|
186
|
+
adapter = Operation::TransactionAdapter.for(adapter_name)
|
|
187
|
+
{ enabled: !adapter.nil? }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def _explain_rescue
|
|
191
|
+
handlers = respond_to?(:_rescue_handlers) ? _rescue_handlers : []
|
|
192
|
+
handlers.each_with_object({}) do |h, hash|
|
|
193
|
+
hash[h[:exception_class].name] = h[:code]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def _explain_callbacks
|
|
198
|
+
return { before: 0, after: 0, around: 0 } unless respond_to?(:_callback_list)
|
|
199
|
+
|
|
200
|
+
{
|
|
201
|
+
before: _callback_list(:before).size,
|
|
202
|
+
after: _callback_list(:after).size,
|
|
203
|
+
around: _callback_list(:around).size
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source&.name
|
|
11
|
+
desc = source&.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:params] = _serialize_params(source, contract.params)
|
|
14
|
+
h[:success] = TypeSerializer.to_string(contract.success) if contract.success
|
|
15
|
+
h[:errors] = contract.errors unless contract.errors.empty?
|
|
16
|
+
h[:guards] = contract.guards unless contract.guards.empty?
|
|
17
|
+
ctx = _serialize_context(source)
|
|
18
|
+
h[:context] = ctx unless ctx.empty?
|
|
19
|
+
h[:pipeline] = source.pipeline.steps.map(&:name) if source
|
|
20
|
+
h[:settings] = _serialize_settings(source) if source
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
|
|
25
|
+
case section
|
|
26
|
+
when :params then _params_schema(source, contract)
|
|
27
|
+
when :success then _success_schema(source, contract)
|
|
28
|
+
when :errors then _errors_schema(source, contract)
|
|
29
|
+
when :full then _full_schema(source, contract)
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def _serialize_params(source, params)
|
|
37
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
38
|
+
params.each_with_object({}) do |(name, type), hash|
|
|
39
|
+
entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
|
|
40
|
+
entry[:desc] = descs[name] if descs[name]
|
|
41
|
+
hash[name] = entry
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def _required?(source, prop_name)
|
|
46
|
+
return true unless source&.respond_to?(:literal_properties)
|
|
47
|
+
|
|
48
|
+
prop = source.literal_properties.find { |p| p.name == prop_name }
|
|
49
|
+
return true unless prop
|
|
50
|
+
|
|
51
|
+
prop.required?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def _serialize_context(source)
|
|
55
|
+
source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
|
|
59
|
+
settings = {}
|
|
60
|
+
|
|
61
|
+
record_s = source.settings_for(:record)
|
|
62
|
+
settings[:record] = {
|
|
63
|
+
enabled: record_s.fetch(:enabled, true),
|
|
64
|
+
params: record_s.fetch(:params, true),
|
|
65
|
+
result: record_s.fetch(:result, true)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
tx_s = source.settings_for(:transaction)
|
|
69
|
+
settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
|
|
70
|
+
|
|
71
|
+
once_s = source.settings_for(:once)
|
|
72
|
+
settings[:once] = { defined: once_s.fetch(:defined, false) }
|
|
73
|
+
|
|
74
|
+
settings
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
|
|
78
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
79
|
+
properties = {}
|
|
80
|
+
required = []
|
|
81
|
+
|
|
82
|
+
contract.params.each do |name, type|
|
|
83
|
+
prop_desc = descs[name]
|
|
84
|
+
schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
|
|
85
|
+
properties[name.to_s] = schema
|
|
86
|
+
required << name.to_s if _required?(source, name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
90
|
+
result[:title] = source.name if source&.name
|
|
91
|
+
desc = source&.description
|
|
92
|
+
result[:description] = desc if desc
|
|
93
|
+
result[:properties] = properties unless properties.empty?
|
|
94
|
+
result[:required] = required unless required.empty?
|
|
95
|
+
result[:additionalProperties] = false
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _success_schema(source, contract)
|
|
100
|
+
return {} unless contract.success
|
|
101
|
+
|
|
102
|
+
schema = TypeSerializer.to_json_schema(contract.success)
|
|
103
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
104
|
+
result[:title] = "#{source&.name} success" if source&.name
|
|
105
|
+
result.merge(schema)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def _errors_schema(source, contract)
|
|
109
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
110
|
+
result[:title] = "#{source&.name} errors" if source&.name
|
|
111
|
+
result[:type] = "object"
|
|
112
|
+
|
|
113
|
+
properties = {}
|
|
114
|
+
contract.errors.each do |code|
|
|
115
|
+
properties[code.to_s] = {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
code: { const: code.to_s },
|
|
119
|
+
message: { type: "string" },
|
|
120
|
+
details: { type: "object" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
result[:properties] = properties unless properties.empty?
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def _full_schema(source, contract)
|
|
129
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
130
|
+
result[:title] = source.name if source&.name
|
|
131
|
+
result[:description] = "Operation contract"
|
|
132
|
+
result[:properties] = {
|
|
133
|
+
params: _params_schema(source, contract).except(:$schema),
|
|
134
|
+
success: _success_schema(source, contract).except(:$schema),
|
|
135
|
+
errors: _errors_schema(source, contract).except(:$schema)
|
|
136
|
+
}
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
|
|
141
|
+
:_params_schema, :_success_schema, :_errors_schema, :_full_schema
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -107,16 +107,17 @@ module Dex
|
|
|
107
107
|
|
|
108
108
|
private
|
|
109
109
|
|
|
110
|
-
def
|
|
110
|
+
def _guard_evaluate_all
|
|
111
111
|
guards = self.class._guard_list
|
|
112
112
|
return [] if guards.empty?
|
|
113
113
|
|
|
114
114
|
blocked_names = Set.new
|
|
115
|
-
|
|
115
|
+
results = []
|
|
116
116
|
|
|
117
117
|
guards.each do |guard|
|
|
118
118
|
if guard.requires.any? { |dep| blocked_names.include?(dep) }
|
|
119
119
|
blocked_names << guard.name
|
|
120
|
+
results << { name: guard.name, passed: false, skipped: true }
|
|
120
121
|
next
|
|
121
122
|
end
|
|
122
123
|
|
|
@@ -128,11 +129,21 @@ module Dex
|
|
|
128
129
|
|
|
129
130
|
if threat
|
|
130
131
|
blocked_names << guard.name
|
|
131
|
-
|
|
132
|
+
results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
|
|
133
|
+
else
|
|
134
|
+
results << { name: guard.name, passed: true }
|
|
132
135
|
end
|
|
133
136
|
end
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def _guard_evaluate
|
|
142
|
+
_guard_evaluate_all.filter_map do |r|
|
|
143
|
+
next if r[:passed] || r[:skipped]
|
|
144
|
+
|
|
145
|
+
{ guard: r[:name], message: r[:message] }
|
|
146
|
+
end
|
|
136
147
|
end
|
|
137
148
|
end
|
|
138
149
|
end
|
|
@@ -54,11 +54,24 @@ module Dex
|
|
|
54
54
|
_lock_ensure_loaded!
|
|
55
55
|
key = _lock_key
|
|
56
56
|
ActiveRecord::Base.with_advisory_lock!(key, **_lock_options, &block)
|
|
57
|
-
rescue
|
|
58
|
-
|
|
57
|
+
rescue => e
|
|
58
|
+
if defined?(WithAdvisoryLock::FailedToAcquireLock) && e.is_a?(WithAdvisoryLock::FailedToAcquireLock)
|
|
59
|
+
raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
raise
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
def _lock_ensure_loaded!
|
|
66
|
+
unless defined?(ActiveRecord::Base)
|
|
67
|
+
raise LoadError, "advisory_lock requires ActiveRecord and is not supported in Mongoid-only apps."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless defined?(WithAdvisoryLock::FailedToAcquireLock)
|
|
71
|
+
raise LoadError,
|
|
72
|
+
"with_advisory_lock gem is required for advisory locking. Add 'with_advisory_lock' to your Gemfile."
|
|
73
|
+
end
|
|
74
|
+
|
|
62
75
|
return if ActiveRecord::Base.respond_to?(:with_advisory_lock!)
|
|
63
76
|
|
|
64
77
|
raise LoadError,
|
|
@@ -44,6 +44,7 @@ module Dex
|
|
|
44
44
|
else
|
|
45
45
|
raise ArgumentError, "pass a String key or keyword arguments matching the once props"
|
|
46
46
|
end
|
|
47
|
+
_once_validate_backend!
|
|
47
48
|
Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -56,6 +57,27 @@ module Dex
|
|
|
56
57
|
|
|
57
58
|
private
|
|
58
59
|
|
|
60
|
+
def _once_required_fields
|
|
61
|
+
fields =
|
|
62
|
+
if respond_to?(:_record_required_fields, true)
|
|
63
|
+
send(:_record_required_fields)
|
|
64
|
+
else
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
fields << "once_key"
|
|
69
|
+
fields << "once_key_expires_at" if settings_for(:once)[:expires_in]
|
|
70
|
+
fields.uniq
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def _once_validate_backend!
|
|
74
|
+
unless Dex.record_backend
|
|
75
|
+
raise "once requires a record backend (configure Dex.record_class)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Dex.record_backend.ensure_fields!(_once_required_fields, feature: "once")
|
|
79
|
+
end
|
|
80
|
+
|
|
59
81
|
def _once_validate_props!(prop_names)
|
|
60
82
|
return unless respond_to?(:literal_properties)
|
|
61
83
|
|
|
@@ -112,21 +134,7 @@ module Dex
|
|
|
112
134
|
end
|
|
113
135
|
|
|
114
136
|
def _once_ensure_backend!
|
|
115
|
-
|
|
116
|
-
raise "once requires a record backend (configure Dex.record_class)"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
return if self.class.instance_variable_defined?(:@_once_fields_checked)
|
|
120
|
-
|
|
121
|
-
unless Dex.record_backend.has_field?("once_key")
|
|
122
|
-
raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
|
|
126
|
-
raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
self.class.instance_variable_set(:@_once_fields_checked, true)
|
|
137
|
+
self.class.send(:_once_validate_backend!)
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def _once_derive_key
|
|
@@ -42,6 +42,10 @@ module Dex
|
|
|
42
42
|
raise NotImplementedError
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def find_pending_once_key(key)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
45
49
|
def update_record_by_once_key(key, **attributes)
|
|
46
50
|
raise NotImplementedError
|
|
47
51
|
end
|
|
@@ -54,6 +58,19 @@ module Dex
|
|
|
54
58
|
attributes.select { |key, _| has_field?(key.to_s) }
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def missing_fields(*fields)
|
|
62
|
+
fields.flatten.uniq.reject { |field_name| has_field?(field_name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_fields!(*fields, feature:)
|
|
66
|
+
missing = missing_fields(*fields)
|
|
67
|
+
return if missing.empty?
|
|
68
|
+
|
|
69
|
+
raise ArgumentError,
|
|
70
|
+
"Dex record_class #{record_class} is missing required attributes for #{feature}: #{missing.join(", ")}. " \
|
|
71
|
+
"Define these attributes on #{record_class} or disable #{feature}."
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
def has_field?(field_name)
|
|
58
75
|
raise NotImplementedError
|
|
59
76
|
end
|
|
@@ -80,6 +97,10 @@ module Dex
|
|
|
80
97
|
.first
|
|
81
98
|
end
|
|
82
99
|
|
|
100
|
+
def find_pending_once_key(key)
|
|
101
|
+
record_class.where(once_key: key, status: %w[pending running]).first
|
|
102
|
+
end
|
|
103
|
+
|
|
83
104
|
def update_record_by_once_key(key, **attributes)
|
|
84
105
|
record = record_class.where(once_key: key, status: %w[completed error]).first
|
|
85
106
|
record&.update!(safe_attributes(attributes))
|
|
@@ -119,6 +140,10 @@ module Dex
|
|
|
119
140
|
).first
|
|
120
141
|
end
|
|
121
142
|
|
|
143
|
+
def find_pending_once_key(key)
|
|
144
|
+
record_class.where(:once_key => key, :status.in => %w[pending running]).first
|
|
145
|
+
end
|
|
146
|
+
|
|
122
147
|
def update_record_by_once_key(key, **attributes)
|
|
123
148
|
record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
|
|
124
149
|
record&.update!(safe_attributes(attributes))
|
|
@@ -5,6 +5,7 @@ module Dex
|
|
|
5
5
|
extend Dex::Concern
|
|
6
6
|
|
|
7
7
|
def _record_wrap
|
|
8
|
+
_record_validate_backend! if _record_enabled? || _record_has_pending_record?
|
|
8
9
|
interceptor = Operation::HaltInterceptor.new { yield }
|
|
9
10
|
|
|
10
11
|
if _record_has_pending_record?
|
|
@@ -34,6 +35,14 @@ module Dex
|
|
|
34
35
|
"record expects true, false, or nil, got: #{enabled.inspect}"
|
|
35
36
|
end
|
|
36
37
|
end
|
|
38
|
+
|
|
39
|
+
def _record_required_fields(async: false)
|
|
40
|
+
settings = settings_for(:record)
|
|
41
|
+
fields = %w[name status performed_at error_code error_message error_details]
|
|
42
|
+
fields << "params" if async || settings.fetch(:params, true)
|
|
43
|
+
fields << "result" if settings.fetch(:result, true)
|
|
44
|
+
fields
|
|
45
|
+
end
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
private
|
|
@@ -46,6 +55,13 @@ module Dex
|
|
|
46
55
|
record_settings.fetch(:enabled, true)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def _record_validate_backend!(async: false)
|
|
59
|
+
Dex.record_backend.ensure_fields!(
|
|
60
|
+
self.class.send(:_record_required_fields, async: async),
|
|
61
|
+
feature: async ? "async recording" : "operation recording"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
def _record_has_pending_record?
|
|
50
66
|
defined?(@_dex_record_id) && @_dex_record_id
|
|
51
67
|
end
|
|
@@ -142,8 +158,8 @@ module Dex
|
|
|
142
158
|
else
|
|
143
159
|
case result
|
|
144
160
|
when nil then nil
|
|
145
|
-
when Hash then result
|
|
146
|
-
else { _dex_value
|
|
161
|
+
when Hash then _record_sanitize_value(result)
|
|
162
|
+
else { "_dex_value" => _record_sanitize_value(result) } # namespaced key so replay can distinguish wrapped primitives from user hashes
|
|
147
163
|
end
|
|
148
164
|
end
|
|
149
165
|
end
|
|
@@ -160,10 +176,19 @@ module Dex
|
|
|
160
176
|
case value
|
|
161
177
|
when NilClass, String, Integer, Float, TrueClass, FalseClass then value
|
|
162
178
|
when Symbol then value.to_s
|
|
163
|
-
when Hash
|
|
179
|
+
when Hash
|
|
180
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
181
|
+
result[key.to_s] = _record_sanitize_value(nested_value)
|
|
182
|
+
end
|
|
164
183
|
when Array then value.map { |v| _record_sanitize_value(v) }
|
|
165
184
|
when Exception then "#{value.class}: #{value.message}"
|
|
166
|
-
else
|
|
185
|
+
else
|
|
186
|
+
if value.respond_to?(:as_json)
|
|
187
|
+
serialized = value.as_json
|
|
188
|
+
return _record_sanitize_value(serialized) unless serialized.equal?(value)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
value.to_s
|
|
167
192
|
end
|
|
168
193
|
end
|
|
169
194
|
|