dexkit 0.9.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 +22 -0
- data/README.md +44 -16
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +24 -19
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +27 -3
- data/guides/llm/QUERY.md +50 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +78 -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 +1 -1
- data/lib/dex/event/trace.rb +14 -27
- data/lib/dex/event.rb +2 -7
- 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.rb +119 -3
- data/lib/dex/id.rb +38 -0
- data/lib/dex/operation/async_proxy.rb +12 -2
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/test_helpers/assertions.rb +24 -0
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +2 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +3 -0
- metadata +8 -1
|
@@ -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
|
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,21 +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
|
|
30
34
|
@operation.send(:_record_validate_backend!, async: true)
|
|
35
|
+
execution_id = Dex::Id.generate("op_")
|
|
31
36
|
record = Dex.record_backend.create_record(
|
|
37
|
+
id: execution_id,
|
|
32
38
|
name: operation_class_name,
|
|
33
39
|
params: serialized_params,
|
|
34
40
|
status: "pending"
|
|
35
41
|
)
|
|
36
42
|
begin
|
|
37
43
|
job = apply_options(Operation::RecordJob)
|
|
38
|
-
payload = {
|
|
44
|
+
payload = {
|
|
45
|
+
class_name: operation_class_name,
|
|
46
|
+
record_id: record.id.to_s,
|
|
47
|
+
trace: Dex::Trace.dump
|
|
48
|
+
}
|
|
39
49
|
apply_once_payload!(payload)
|
|
40
50
|
job.perform_later(**payload)
|
|
41
51
|
rescue => e
|
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.)
|
|
@@ -156,7 +156,8 @@ module Dex
|
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def has_field?(field_name)
|
|
159
|
-
|
|
159
|
+
field_name = field_name.to_s
|
|
160
|
+
record_class.fields.key?(field_name) || (field_name == "id" && record_class.fields.key?("_id"))
|
|
160
161
|
end
|
|
161
162
|
end
|
|
162
163
|
end
|
|
@@ -88,7 +88,7 @@ module Dex
|
|
|
88
88
|
else
|
|
89
89
|
_record_success_attrs(interceptor.result)
|
|
90
90
|
end
|
|
91
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
91
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
92
92
|
rescue => e
|
|
93
93
|
_record_handle_error(e)
|
|
94
94
|
end
|
|
@@ -108,7 +108,7 @@ module Dex
|
|
|
108
108
|
attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
|
|
109
109
|
|
|
110
110
|
if _record_has_pending_record?
|
|
111
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
111
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
112
112
|
else
|
|
113
113
|
Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
|
|
114
114
|
end
|
|
@@ -116,8 +116,18 @@ module Dex
|
|
|
116
116
|
_record_handle_error(e)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
def _record_base_attrs
|
|
120
|
-
|
|
119
|
+
def _record_base_attrs(include_id: true)
|
|
120
|
+
trace_snapshot = Dex::Trace.current
|
|
121
|
+
actor_frame = Dex::Trace.actor
|
|
122
|
+
|
|
123
|
+
attrs = {
|
|
124
|
+
name: self.class.name,
|
|
125
|
+
trace_id: Dex::Trace.trace_id,
|
|
126
|
+
trace: trace_snapshot,
|
|
127
|
+
actor_type: actor_frame&.dig(:actor_type),
|
|
128
|
+
actor_id: actor_frame&.dig(:id)&.to_s
|
|
129
|
+
}
|
|
130
|
+
attrs[:id] = @_dex_execution_id if include_id && defined?(@_dex_execution_id) && @_dex_execution_id
|
|
121
131
|
attrs[:params] = _record_params? ? _record_params : nil
|
|
122
132
|
attrs
|
|
123
133
|
end
|
|
@@ -199,6 +199,30 @@ module Dex
|
|
|
199
199
|
"Expected no operations to be enqueued, but #{after_count - before_count} were"
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
# --- Trace assertions ---
|
|
203
|
+
|
|
204
|
+
def assert_trace_includes(operation_class, msg: nil)
|
|
205
|
+
expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
|
|
206
|
+
trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
|
|
207
|
+
|
|
208
|
+
assert_includes trace_classes, expected,
|
|
209
|
+
msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def assert_trace_actor(type:, id: :_not_given, msg: nil)
|
|
213
|
+
actor = Dex::Trace.actor
|
|
214
|
+
refute_nil actor, msg || "Expected a trace actor, but no actor is set"
|
|
215
|
+
assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
|
|
216
|
+
assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
|
|
217
|
+
actor
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def assert_trace_depth(expected, msg: nil)
|
|
221
|
+
actual = Dex::Trace.current.size
|
|
222
|
+
assert_equal expected, actual,
|
|
223
|
+
msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
|
|
224
|
+
end
|
|
225
|
+
|
|
202
226
|
# --- Transaction assertions ---
|
|
203
227
|
|
|
204
228
|
def assert_rolls_back(model_class, &block)
|
|
@@ -97,6 +97,12 @@ module Dex
|
|
|
97
97
|
Dex::Operation::Ok.new(result)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
trace = Dex::Trace.current + [{
|
|
101
|
+
type: :operation,
|
|
102
|
+
id: @_dex_execution_id,
|
|
103
|
+
class: self.class.name || self.class.to_s
|
|
104
|
+
}]
|
|
105
|
+
|
|
100
106
|
entry = Dex::TestLog::Entry.new(
|
|
101
107
|
type: "Operation",
|
|
102
108
|
name: self.class.name || self.class.to_s,
|
|
@@ -104,7 +110,10 @@ module Dex
|
|
|
104
110
|
params: _test_safe_params,
|
|
105
111
|
result: safe_result,
|
|
106
112
|
duration: duration,
|
|
107
|
-
caller_location: caller_locations(4, 1)&.first
|
|
113
|
+
caller_location: caller_locations(4, 1)&.first,
|
|
114
|
+
execution_id: @_dex_execution_id,
|
|
115
|
+
trace_id: @_dex_trace_id,
|
|
116
|
+
trace: trace
|
|
108
117
|
)
|
|
109
118
|
Dex::TestLog.record(entry)
|
|
110
119
|
end
|
|
@@ -120,6 +129,7 @@ module Dex
|
|
|
120
129
|
|
|
121
130
|
def setup
|
|
122
131
|
super
|
|
132
|
+
Dex::Trace.clear!
|
|
123
133
|
Dex::TestLog.clear!
|
|
124
134
|
Dex::Operation::TestWrapper.clear_all_stubs!
|
|
125
135
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TraceWrapper
|
|
5
|
+
extend Dex::Concern
|
|
6
|
+
|
|
7
|
+
def _trace_wrap
|
|
8
|
+
@_dex_execution_id ||= Dex::Id.generate("op_")
|
|
9
|
+
|
|
10
|
+
Dex::Trace.with_frame(
|
|
11
|
+
type: :operation,
|
|
12
|
+
id: @_dex_execution_id,
|
|
13
|
+
class: self.class.name
|
|
14
|
+
) do
|
|
15
|
+
@_dex_trace_id = Dex::Trace.trace_id
|
|
16
|
+
yield
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/dex/operation.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require_relative "operation/result_wrapper"
|
|
5
5
|
require_relative "operation/once_wrapper"
|
|
6
6
|
require_relative "operation/record_wrapper"
|
|
7
|
+
require_relative "operation/trace_wrapper"
|
|
7
8
|
require_relative "operation/transaction_wrapper"
|
|
8
9
|
require_relative "operation/lock_wrapper"
|
|
9
10
|
require_relative "operation/async_wrapper"
|
|
@@ -139,6 +140,7 @@ module Dex
|
|
|
139
140
|
include AsyncWrapper
|
|
140
141
|
include SafeWrapper
|
|
141
142
|
|
|
143
|
+
use TraceWrapper, at: :outer
|
|
142
144
|
use ResultWrapper
|
|
143
145
|
use GuardWrapper
|
|
144
146
|
use OnceWrapper
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Query
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source)
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source.name
|
|
11
|
+
desc = source.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:props] = _serialize_props(source)
|
|
14
|
+
ctx = _serialize_context(source)
|
|
15
|
+
h[:context] = ctx unless ctx.empty?
|
|
16
|
+
h[:filters] = source.filters unless source.filters.empty?
|
|
17
|
+
h[:sorts] = source.sorts unless source.sorts.empty?
|
|
18
|
+
h
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_json_schema(source) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
23
|
+
properties = {}
|
|
24
|
+
required = []
|
|
25
|
+
|
|
26
|
+
if source.respond_to?(:literal_properties)
|
|
27
|
+
source.literal_properties.each do |prop|
|
|
28
|
+
prop_desc = descs[prop.name]
|
|
29
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
30
|
+
properties[prop.name.to_s] = schema
|
|
31
|
+
required << prop.name.to_s if prop.required?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
36
|
+
result[:title] = source.name if source.name
|
|
37
|
+
desc = source.description
|
|
38
|
+
result[:description] = desc if desc
|
|
39
|
+
result[:type] = "object"
|
|
40
|
+
result[:properties] = properties unless properties.empty?
|
|
41
|
+
result[:required] = required unless required.empty?
|
|
42
|
+
result[:additionalProperties] = false
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _serialize_props(source)
|
|
47
|
+
return {} unless source.respond_to?(:literal_properties)
|
|
48
|
+
|
|
49
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
50
|
+
source.literal_properties.each_with_object({}) do |prop, hash|
|
|
51
|
+
entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
|
|
52
|
+
entry[:desc] = descs[prop.name] if descs[prop.name]
|
|
53
|
+
hash[prop.name] = entry
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def _serialize_context(source)
|
|
58
|
+
source.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method :_serialize_props, :_serialize_context
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|