dexkit 0.8.0 → 0.10.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 +36 -0
- data/README.md +50 -18
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +41 -23
- data/guides/llm/FORM.md +202 -61
- data/guides/llm/OPERATION.md +49 -20
- data/guides/llm/QUERY.md +52 -2
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +85 -8
- data/lib/dex/event/handler.rb +18 -0
- data/lib/dex/event/metadata.rb +16 -9
- data/lib/dex/event/processor.rb +1 -1
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event/trace.rb +14 -27
- data/lib/dex/event.rb +2 -7
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +38 -0
- data/lib/dex/operation/async_proxy.rb +13 -2
- data/lib/dex/operation/explain.rb +11 -7
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +24 -15
- data/lib/dex/operation/record_backend.rb +15 -1
- data/lib/dex/operation/record_wrapper.rb +43 -8
- data/lib/dex/operation/test_helpers/assertions.rb +359 -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 +160 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +2 -0
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +50 -5
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +9 -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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Form
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
TYPE_MAP = {
|
|
9
|
+
string: { type: "string" },
|
|
10
|
+
integer: { type: "integer" },
|
|
11
|
+
float: { type: "number" },
|
|
12
|
+
decimal: { type: "number" },
|
|
13
|
+
boolean: { type: "boolean" },
|
|
14
|
+
date: { type: "string", format: "date" },
|
|
15
|
+
datetime: { type: "string", format: "date-time" },
|
|
16
|
+
time: { type: "string", format: "time" }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def build_hash(source)
|
|
20
|
+
h = _serialize_form_definition(source)
|
|
21
|
+
h[:name] = source.name if source.name
|
|
22
|
+
desc = source.description
|
|
23
|
+
h[:description] = desc if desc
|
|
24
|
+
h
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_json_schema(source) # rubocop:disable Metrics/MethodLength
|
|
28
|
+
properties = {}
|
|
29
|
+
required = []
|
|
30
|
+
|
|
31
|
+
source._field_registry.each do |name, field_def|
|
|
32
|
+
schema = _field_to_schema(field_def)
|
|
33
|
+
properties[name.to_s] = schema
|
|
34
|
+
required << name.to_s if field_def.required
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
_add_nested_properties(source, properties, required)
|
|
38
|
+
|
|
39
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
40
|
+
result[:title] = source.name if source.name
|
|
41
|
+
desc = source.description
|
|
42
|
+
result[:description] = desc if desc
|
|
43
|
+
result[:properties] = properties unless properties.empty?
|
|
44
|
+
result[:required] = required unless required.empty?
|
|
45
|
+
result[:additionalProperties] = false
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def _serialize_fields(source)
|
|
50
|
+
source._field_registry.each_with_object({}) do |(name, field_def), hash|
|
|
51
|
+
entry = { type: field_def.type, required: field_def.required }
|
|
52
|
+
entry[:desc] = field_def.desc if field_def.desc
|
|
53
|
+
entry[:default] = field_def.default if field_def.default?
|
|
54
|
+
hash[name] = entry
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _serialize_form_definition(source)
|
|
59
|
+
h = { fields: _serialize_fields(source) }
|
|
60
|
+
nested = _serialize_nested(source)
|
|
61
|
+
h[:nested] = nested unless nested.empty?
|
|
62
|
+
h
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def _serialize_nested(source)
|
|
66
|
+
nested = {}
|
|
67
|
+
source._nested_ones.each do |name, klass|
|
|
68
|
+
nested[name] = { type: :one }.merge(_serialize_form_definition(klass))
|
|
69
|
+
end
|
|
70
|
+
source._nested_manys.each do |name, klass|
|
|
71
|
+
nested[name] = { type: :many }.merge(_serialize_form_definition(klass))
|
|
72
|
+
end
|
|
73
|
+
nested
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _field_to_schema(field_def)
|
|
77
|
+
schema = TYPE_MAP[field_def.type]&.dup || {}
|
|
78
|
+
schema[:description] = field_def.desc if field_def.desc
|
|
79
|
+
schema[:default] = _coerce_default(field_def) if field_def.default?
|
|
80
|
+
schema
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def _coerce_default(field_def)
|
|
84
|
+
val = field_def.default
|
|
85
|
+
case field_def.type
|
|
86
|
+
when :integer then val.is_a?(Integer) ? val : val.to_i
|
|
87
|
+
when :float, :decimal then val.is_a?(Float) ? val : val.to_f
|
|
88
|
+
when :boolean then !!val
|
|
89
|
+
when :string, :date, :datetime, :time then val.to_s
|
|
90
|
+
else val
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def _nested_json_schema(klass)
|
|
95
|
+
properties = {}
|
|
96
|
+
required = []
|
|
97
|
+
|
|
98
|
+
klass._field_registry.each do |name, field_def|
|
|
99
|
+
schema = _field_to_schema(field_def)
|
|
100
|
+
properties[name.to_s] = schema
|
|
101
|
+
required << name.to_s if field_def.required
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
_add_nested_properties(klass, properties, required)
|
|
105
|
+
|
|
106
|
+
result = { type: "object" }
|
|
107
|
+
result[:properties] = properties unless properties.empty?
|
|
108
|
+
result[:required] = required unless required.empty?
|
|
109
|
+
result[:additionalProperties] = false
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def _add_nested_properties(source, properties, required)
|
|
114
|
+
source._nested_ones.each do |name, klass|
|
|
115
|
+
properties[name.to_s] = _nested_json_schema(klass)
|
|
116
|
+
required << name.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
source._nested_manys.each do |name, klass|
|
|
120
|
+
properties[name.to_s] = { type: "array", items: _nested_json_schema(klass) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private_class_method :_serialize_fields, :_serialize_form_definition, :_serialize_nested, :_field_to_schema,
|
|
125
|
+
:_coerce_default, :_nested_json_schema, :_add_nested_properties
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/dex/form/nesting.rb
CHANGED
|
@@ -70,6 +70,8 @@ module Dex
|
|
|
70
70
|
|
|
71
71
|
def _build_nested_class(name, class_name, &block)
|
|
72
72
|
klass = Class.new(Dex::Form, &block)
|
|
73
|
+
klass.instance_variable_set(:@_dex_nested_form, true)
|
|
74
|
+
Dex::Form.deregister(klass)
|
|
73
75
|
const_name = class_name || name.to_s.singularize.camelize
|
|
74
76
|
const_set(const_name, klass)
|
|
75
77
|
klass
|
|
@@ -50,8 +50,12 @@ module Dex
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def _build_query(model_class, column, value)
|
|
53
|
-
|
|
53
|
+
return model_class.where(column => value) unless options[:case_sensitive] == false && value.is_a?(String)
|
|
54
|
+
|
|
55
|
+
if model_class.respond_to?(:arel_table)
|
|
54
56
|
model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
|
|
57
|
+
elsif _mongoid_model_class?(model_class)
|
|
58
|
+
model_class.where(column => /\A#{Regexp.escape(value)}\z/i)
|
|
55
59
|
else
|
|
56
60
|
model_class.where(column => value)
|
|
57
61
|
end
|
|
@@ -78,9 +82,21 @@ module Dex
|
|
|
78
82
|
def _exclude_current_record(query, form)
|
|
79
83
|
return query unless form.record&.persisted?
|
|
80
84
|
|
|
85
|
+
if _mongoid_record?(form.record)
|
|
86
|
+
return query.where(:_id.ne => form.record.id)
|
|
87
|
+
end
|
|
88
|
+
|
|
81
89
|
pk = form.record.class.primary_key
|
|
82
90
|
query.where.not(pk => form.record.public_send(pk))
|
|
83
91
|
end
|
|
92
|
+
|
|
93
|
+
def _mongoid_model_class?(model_class)
|
|
94
|
+
defined?(Mongoid::Document) && model_class.include?(Mongoid::Document)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def _mongoid_record?(record)
|
|
98
|
+
_mongoid_model_class?(record.class)
|
|
99
|
+
end
|
|
84
100
|
end
|
|
85
101
|
end
|
|
86
102
|
end
|
data/lib/dex/form.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "active_model"
|
|
4
4
|
|
|
5
5
|
require_relative "form/nesting"
|
|
6
|
+
require_relative "form/context"
|
|
6
7
|
|
|
7
8
|
module Dex
|
|
8
9
|
class Form
|
|
@@ -15,8 +16,17 @@ module Dex
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
include Nesting
|
|
19
|
+
include Context
|
|
18
20
|
include Match
|
|
19
21
|
|
|
22
|
+
extend Registry
|
|
23
|
+
|
|
24
|
+
FIELD_DEFAULT_UNSET = Object.new.freeze
|
|
25
|
+
|
|
26
|
+
FieldDef = Data.define(:name, :type, :desc, :required, :default) do
|
|
27
|
+
def default? = !default.equal?(Dex::Form::FIELD_DEFAULT_UNSET)
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
class ValidationError < StandardError
|
|
21
31
|
attr_reader :form
|
|
22
32
|
|
|
@@ -27,6 +37,37 @@ module Dex
|
|
|
27
37
|
end
|
|
28
38
|
|
|
29
39
|
class << self
|
|
40
|
+
def _field_registry
|
|
41
|
+
@_field_registry ||= {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _required_fields
|
|
45
|
+
_field_registry.each_with_object([]) do |(name, f), list|
|
|
46
|
+
list << name if f.required
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def field(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
|
|
51
|
+
raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
52
|
+
raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
|
|
53
|
+
raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
|
|
54
|
+
|
|
55
|
+
am_options = options.dup
|
|
56
|
+
am_options[:default] = default unless default.equal?(FIELD_DEFAULT_UNSET)
|
|
57
|
+
attribute name, type, **am_options
|
|
58
|
+
_field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: true, default: default)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def field?(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
|
|
62
|
+
raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
63
|
+
raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
|
|
64
|
+
raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
|
|
65
|
+
|
|
66
|
+
actual_default = default.equal?(FIELD_DEFAULT_UNSET) ? nil : default
|
|
67
|
+
attribute name, type, default: actual_default, **options
|
|
68
|
+
_field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: false, default: default)
|
|
69
|
+
end
|
|
70
|
+
|
|
30
71
|
def model(klass = nil)
|
|
31
72
|
if klass
|
|
32
73
|
raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
|
|
@@ -42,9 +83,38 @@ module Dex
|
|
|
42
83
|
|
|
43
84
|
def inherited(subclass)
|
|
44
85
|
super
|
|
86
|
+
subclass.instance_variable_set(:@_field_registry, _field_registry.dup)
|
|
45
87
|
subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
|
|
46
88
|
subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
|
|
47
89
|
end
|
|
90
|
+
|
|
91
|
+
def _dex_nested_form?
|
|
92
|
+
!!instance_variable_get(:@_dex_nested_form)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Export
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
Export.build_hash(self)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_json_schema
|
|
102
|
+
Export.build_json_schema(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def export(format: :hash)
|
|
106
|
+
unless %i[hash json_schema].include?(format)
|
|
107
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sorted = registry.reject { |klass| klass._dex_nested_form? }.sort_by(&:name)
|
|
111
|
+
sorted.map do |klass|
|
|
112
|
+
case format
|
|
113
|
+
when :hash then klass.to_h
|
|
114
|
+
when :json_schema then klass.to_json_schema
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
48
118
|
end
|
|
49
119
|
|
|
50
120
|
silence_redefinition_of_method :model_name
|
|
@@ -66,6 +136,18 @@ module Dex
|
|
|
66
136
|
# setters are assignable; everything else is silently dropped.
|
|
67
137
|
attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
|
|
68
138
|
attrs = (attributes || {}).transform_keys(&:to_s)
|
|
139
|
+
|
|
140
|
+
# Context injection — fill ambient values for unmapped keys
|
|
141
|
+
mappings = self.class.context_mappings
|
|
142
|
+
unless mappings.empty?
|
|
143
|
+
ambient = Dex.context
|
|
144
|
+
mappings.each do |attr_name, context_key|
|
|
145
|
+
str_name = attr_name.to_s
|
|
146
|
+
next if attrs.key?(str_name)
|
|
147
|
+
attrs[str_name] = ambient[context_key] if ambient.key?(context_key)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
69
151
|
record = attrs.delete("record")
|
|
70
152
|
@record = record if record.nil? || record.respond_to?(:persisted?)
|
|
71
153
|
provided_keys = attrs.keys
|
|
@@ -95,9 +177,10 @@ module Dex
|
|
|
95
177
|
end
|
|
96
178
|
|
|
97
179
|
def valid?(context = nil)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
180
|
+
super
|
|
181
|
+
_fix_boolean_presence_errors
|
|
182
|
+
nested_valid = _validate_nested(context)
|
|
183
|
+
errors.empty? && nested_valid
|
|
101
184
|
end
|
|
102
185
|
|
|
103
186
|
def to_h
|
|
@@ -111,8 +194,40 @@ module Dex
|
|
|
111
194
|
|
|
112
195
|
alias_method :to_hash, :to_h
|
|
113
196
|
|
|
197
|
+
validate :_validate_required_fields
|
|
198
|
+
|
|
114
199
|
private
|
|
115
200
|
|
|
201
|
+
def _validate_required_fields
|
|
202
|
+
explicit = self.class.validators
|
|
203
|
+
.select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
|
|
204
|
+
.reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
|
|
205
|
+
.flat_map(&:attributes).map(&:to_sym).to_set
|
|
206
|
+
|
|
207
|
+
self.class._required_fields.each do |name|
|
|
208
|
+
next if explicit.include?(name)
|
|
209
|
+
field_def = self.class._field_registry[name]
|
|
210
|
+
value = public_send(name)
|
|
211
|
+
blank = (field_def.type == :boolean) ? value.nil? : value.blank?
|
|
212
|
+
errors.add(name, :blank) if blank
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def _fix_boolean_presence_errors
|
|
217
|
+
explicit = self.class.validators
|
|
218
|
+
.select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
|
|
219
|
+
.reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
|
|
220
|
+
.flat_map(&:attributes).map(&:to_sym)
|
|
221
|
+
|
|
222
|
+
explicit.each do |name|
|
|
223
|
+
field_def = self.class._field_registry[name]
|
|
224
|
+
next unless field_def&.type == :boolean
|
|
225
|
+
next if public_send(name).nil?
|
|
226
|
+
|
|
227
|
+
errors.delete(name, :blank)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
116
231
|
def _extract_nested_attributes(attrs)
|
|
117
232
|
nested_keys = self.class._nested_ones.keys.map(&:to_s) +
|
|
118
233
|
self.class._nested_manys.keys.map(&:to_s)
|
|
@@ -140,3 +255,4 @@ module Dex
|
|
|
140
255
|
end
|
|
141
256
|
|
|
142
257
|
require_relative "form/uniqueness_validator"
|
|
258
|
+
require_relative "form/export"
|
data/lib/dex/id.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dex
|
|
6
|
+
module Id
|
|
7
|
+
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
8
|
+
TIMESTAMP_WIDTH = 8
|
|
9
|
+
RANDOM_WIDTH = 12
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def generate(prefix)
|
|
14
|
+
"#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(RANDOM_WIDTH)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def base58_encode(number, width = nil)
|
|
18
|
+
encoded = +""
|
|
19
|
+
value = number.to_i
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
value, remainder = value.divmod(ALPHABET.length)
|
|
23
|
+
encoded.prepend(ALPHABET[remainder])
|
|
24
|
+
break unless value.positive?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
width ? encoded.rjust(width, ALPHABET[0]) : encoded
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def random_suffix(width)
|
|
31
|
+
Array.new(width) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_milliseconds
|
|
35
|
+
Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -21,20 +21,31 @@ module Dex
|
|
|
21
21
|
|
|
22
22
|
def enqueue_direct_job
|
|
23
23
|
job = apply_options(Operation::DirectJob)
|
|
24
|
-
payload = {
|
|
24
|
+
payload = {
|
|
25
|
+
class_name: operation_class_name,
|
|
26
|
+
params: serialized_params,
|
|
27
|
+
trace: Dex::Trace.dump
|
|
28
|
+
}
|
|
25
29
|
apply_once_payload!(payload)
|
|
26
30
|
job.perform_later(**payload)
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def enqueue_record_job
|
|
34
|
+
@operation.send(:_record_validate_backend!, async: true)
|
|
35
|
+
execution_id = Dex::Id.generate("op_")
|
|
30
36
|
record = Dex.record_backend.create_record(
|
|
37
|
+
id: execution_id,
|
|
31
38
|
name: operation_class_name,
|
|
32
39
|
params: serialized_params,
|
|
33
40
|
status: "pending"
|
|
34
41
|
)
|
|
35
42
|
begin
|
|
36
43
|
job = apply_options(Operation::RecordJob)
|
|
37
|
-
payload = {
|
|
44
|
+
payload = {
|
|
45
|
+
class_name: operation_class_name,
|
|
46
|
+
record_id: record.id.to_s,
|
|
47
|
+
trace: Dex::Trace.dump
|
|
48
|
+
}
|
|
38
49
|
apply_once_payload!(payload)
|
|
39
50
|
job.perform_later(**payload)
|
|
40
51
|
rescue => e
|
|
@@ -45,6 +45,7 @@ module Dex
|
|
|
45
45
|
|
|
46
46
|
def _explain_callable?(info)
|
|
47
47
|
return false unless info[:guards][:passed]
|
|
48
|
+
return false if info[:record][:enabled] && info[:record][:status] == :misconfigured
|
|
48
49
|
|
|
49
50
|
if info[:once][:active]
|
|
50
51
|
return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
|
|
@@ -119,12 +120,7 @@ module Dex
|
|
|
119
120
|
return :misconfigured if name.nil?
|
|
120
121
|
return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
|
|
121
122
|
return :unavailable unless Dex.record_backend
|
|
122
|
-
return :misconfigured unless Dex.record_backend.
|
|
123
|
-
|
|
124
|
-
settings = settings_for(:once)
|
|
125
|
-
if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
|
|
126
|
-
return :misconfigured
|
|
127
|
-
end
|
|
123
|
+
return :misconfigured unless Dex.record_backend.missing_fields(send(:_once_required_fields)).empty?
|
|
128
124
|
|
|
129
125
|
existing = Dex.record_backend.find_by_once_key(key)
|
|
130
126
|
return :exists if existing
|
|
@@ -171,7 +167,15 @@ module Dex
|
|
|
171
167
|
enabled: true,
|
|
172
168
|
params: settings.fetch(:params, true),
|
|
173
169
|
result: settings.fetch(:result, true)
|
|
174
|
-
}
|
|
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
|
|
175
179
|
end
|
|
176
180
|
|
|
177
181
|
def _explain_transaction
|
data/lib/dex/operation/jobs.rb
CHANGED
|
@@ -9,29 +9,30 @@ module Dex
|
|
|
9
9
|
case name
|
|
10
10
|
when :DirectJob
|
|
11
11
|
const_set(:DirectJob, Class.new(ActiveJob::Base) do
|
|
12
|
-
def perform(class_name:, params:, once_key: nil, once_bypass: false)
|
|
12
|
+
def perform(class_name:, params:, trace: nil, once_key: nil, once_bypass: false)
|
|
13
13
|
klass = class_name.constantize
|
|
14
14
|
op = klass.new(**klass.send(:_coerce_serialized_hash, params))
|
|
15
15
|
op.once(once_key) if once_key
|
|
16
16
|
op.once(nil) if once_bypass
|
|
17
|
-
op.call
|
|
17
|
+
Dex::Trace.restore(trace) { op.call }
|
|
18
18
|
end
|
|
19
19
|
end)
|
|
20
20
|
when :RecordJob
|
|
21
21
|
const_set(:RecordJob, Class.new(ActiveJob::Base) do
|
|
22
|
-
def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
|
|
22
|
+
def perform(class_name:, record_id:, trace: nil, once_key: nil, once_bypass: false)
|
|
23
23
|
klass = class_name.constantize
|
|
24
24
|
record = Dex.record_backend.find_record(record_id)
|
|
25
25
|
params = klass.send(:_coerce_serialized_hash, record.params || {})
|
|
26
26
|
|
|
27
27
|
op = klass.new(**params)
|
|
28
28
|
op.instance_variable_set(:@_dex_record_id, record_id)
|
|
29
|
+
op.instance_variable_set(:@_dex_execution_id, record_id)
|
|
29
30
|
op.once(once_key) if once_key
|
|
30
31
|
op.once(nil) if once_bypass
|
|
31
32
|
|
|
32
33
|
update_status(record_id, status: "running")
|
|
33
34
|
pipeline_started = true
|
|
34
|
-
op.call
|
|
35
|
+
Dex::Trace.restore(trace) { op.call }
|
|
35
36
|
rescue => e
|
|
36
37
|
# RecordWrapper handles failures during op.call via its own rescue.
|
|
37
38
|
# This catches pre-pipeline failures (find_record, deserialization, etc.)
|
|
@@ -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
|
|
@@ -172,6 +180,7 @@ module Dex
|
|
|
172
180
|
once_key: key, once_key_expires_at: expires_at)
|
|
173
181
|
else
|
|
174
182
|
record = Dex.record_backend.create_record(
|
|
183
|
+
id: @_dex_execution_id,
|
|
175
184
|
name: self.class.name,
|
|
176
185
|
once_key: key,
|
|
177
186
|
once_key_expires_at: expires_at,
|
|
@@ -58,6 +58,19 @@ module Dex
|
|
|
58
58
|
attributes.select { |key, _| has_field?(key.to_s) }
|
|
59
59
|
end
|
|
60
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
|
+
|
|
61
74
|
def has_field?(field_name)
|
|
62
75
|
raise NotImplementedError
|
|
63
76
|
end
|
|
@@ -143,7 +156,8 @@ module Dex
|
|
|
143
156
|
end
|
|
144
157
|
|
|
145
158
|
def has_field?(field_name)
|
|
146
|
-
|
|
159
|
+
field_name = field_name.to_s
|
|
160
|
+
record_class.fields.key?(field_name) || (field_name == "id" && record_class.fields.key?("_id"))
|
|
147
161
|
end
|
|
148
162
|
end
|
|
149
163
|
end
|