dexkit 0.1.0 → 0.3.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 +63 -0
- data/README.md +116 -3
- data/guides/llm/EVENT.md +300 -0
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- data/lib/dex/concern.rb +10 -0
- data/lib/dex/event/bus.rb +98 -0
- data/lib/dex/event/execution_state.rb +17 -0
- data/lib/dex/event/handler.rb +77 -0
- data/lib/dex/event/metadata.rb +54 -0
- data/lib/dex/event/processor.rb +61 -0
- data/lib/dex/event/suppression.rb +49 -0
- data/lib/dex/event/trace.rb +56 -0
- data/lib/dex/event.rb +87 -0
- data/lib/dex/event_test_helpers/assertions.rb +70 -0
- data/lib/dex/event_test_helpers.rb +88 -0
- data/lib/dex/form/nesting.rb +189 -0
- data/lib/dex/form/uniqueness_validator.rb +86 -0
- data/lib/dex/form.rb +142 -0
- data/lib/dex/operation/async_proxy.rb +30 -36
- data/lib/dex/operation/async_wrapper.rb +3 -19
- data/lib/dex/operation/callback_wrapper.rb +11 -15
- data/lib/dex/operation/jobs.rb +8 -14
- data/lib/dex/operation/lock_wrapper.rb +2 -11
- data/lib/dex/operation/pipeline.rb +5 -5
- data/lib/dex/operation/record_wrapper.rb +10 -38
- data/lib/dex/operation/rescue_wrapper.rb +1 -3
- data/lib/dex/operation/result_wrapper.rb +7 -14
- data/lib/dex/operation/settings.rb +10 -3
- data/lib/dex/operation/transaction_wrapper.rb +7 -20
- data/lib/dex/operation.rb +57 -105
- data/lib/dex/{operation/props_setup.rb → props_setup.rb} +12 -15
- data/lib/dex/test_helpers.rb +3 -1
- data/lib/dex/type_coercion.rb +96 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +15 -1
- metadata +49 -4
data/guides/llm/FORM.md
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# Dex::Form — LLM Reference
|
|
2
|
+
|
|
3
|
+
Copy this to your app's forms directory (e.g., `app/forms/AGENTS.md`) so coding agents know the full API when implementing and testing forms.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Reference Form
|
|
8
|
+
|
|
9
|
+
All examples below build on this form unless noted otherwise:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class OnboardingForm < Dex::Form
|
|
13
|
+
model User
|
|
14
|
+
|
|
15
|
+
attribute :first_name, :string
|
|
16
|
+
attribute :last_name, :string
|
|
17
|
+
attribute :email, :string
|
|
18
|
+
attribute :department, :string
|
|
19
|
+
attribute :start_date, :date
|
|
20
|
+
|
|
21
|
+
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
22
|
+
|
|
23
|
+
validates :email, presence: true, uniqueness: true
|
|
24
|
+
validates :first_name, :last_name, :department, presence: true
|
|
25
|
+
validates :start_date, presence: true
|
|
26
|
+
|
|
27
|
+
nested_one :address do
|
|
28
|
+
attribute :street, :string
|
|
29
|
+
attribute :city, :string
|
|
30
|
+
attribute :postal_code, :string
|
|
31
|
+
attribute :country, :string
|
|
32
|
+
|
|
33
|
+
validates :street, :city, :country, presence: true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nested_many :documents do
|
|
37
|
+
attribute :document_type, :string
|
|
38
|
+
attribute :document_number, :string
|
|
39
|
+
|
|
40
|
+
validates :document_type, :document_number, presence: true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Defining Forms
|
|
48
|
+
|
|
49
|
+
Forms use ActiveModel under the hood. Attributes are declared with `attribute` (same as ActiveModel::Attributes).
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class ProfileForm < Dex::Form
|
|
53
|
+
attribute :name, :string
|
|
54
|
+
attribute :age, :integer
|
|
55
|
+
attribute :bio, :string
|
|
56
|
+
attribute :active, :boolean, default: true
|
|
57
|
+
attribute :born_on, :date
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Available types
|
|
62
|
+
|
|
63
|
+
`:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
|
|
64
|
+
|
|
65
|
+
### `model(klass)`
|
|
66
|
+
|
|
67
|
+
Declares the backing model class. Used by:
|
|
68
|
+
- `model_name` — delegates to model for Rails routing (`form_with model: @form`)
|
|
69
|
+
- `validates :attr, uniqueness: true` — queries this model
|
|
70
|
+
- `persisted?` — delegates to `record`
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class UserForm < Dex::Form
|
|
74
|
+
model User
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Optional. Multi-model forms often skip it.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Normalization
|
|
83
|
+
|
|
84
|
+
Uses Rails' `normalizes` (Rails 7.1+). Applied on every assignment.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
88
|
+
normalizes :phone, with: -> { _1&.gsub(/\D/, "").presence }
|
|
89
|
+
normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Validation
|
|
95
|
+
|
|
96
|
+
Full ActiveModel validation DSL:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
100
|
+
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
|
|
101
|
+
validates :role, inclusion: { in: %w[admin user] }
|
|
102
|
+
validates :email, uniqueness: true # checks database
|
|
103
|
+
validate :custom_validation_method
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
form.valid? # => true/false
|
|
108
|
+
form.invalid? # => true/false
|
|
109
|
+
form.errors # => ActiveModel::Errors
|
|
110
|
+
form.errors[:email] # => ["can't be blank"]
|
|
111
|
+
form.errors.full_messages # => ["Email can't be blank"]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### ValidationError
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
error = Dex::Form::ValidationError.new(form)
|
|
118
|
+
error.message # => "Validation failed: Email can't be blank, Name can't be blank"
|
|
119
|
+
error.form # => the form instance
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Nested Forms
|
|
125
|
+
|
|
126
|
+
### `nested_one`
|
|
127
|
+
|
|
128
|
+
One-to-one nested form. Automatically coerces Hash input.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
nested_one :address do
|
|
132
|
+
attribute :street, :string
|
|
133
|
+
attribute :city, :string
|
|
134
|
+
validates :street, :city, presence: true
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
form = MyForm.new(address: { street: "123 Main", city: "NYC" })
|
|
140
|
+
form.address.street # => "123 Main"
|
|
141
|
+
form.address.class # => MyForm::Address (auto-generated)
|
|
142
|
+
|
|
143
|
+
form.build_address(street: "456 Oak") # build new nested
|
|
144
|
+
form.address_attributes = { city: "Boston" } # Rails compat setter
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Default: initialized as empty form when not provided.
|
|
148
|
+
|
|
149
|
+
### `nested_many`
|
|
150
|
+
|
|
151
|
+
One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
nested_many :documents do
|
|
155
|
+
attribute :document_type, :string
|
|
156
|
+
attribute :document_number, :string
|
|
157
|
+
validates :document_type, :document_number, presence: true
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# Array of hashes
|
|
163
|
+
form = MyForm.new(documents: [
|
|
164
|
+
{ document_type: "passport", document_number: "AB123" },
|
|
165
|
+
{ document_type: "visa", document_number: "CD456" }
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
# Rails numbered hash format (from form submissions)
|
|
169
|
+
form = MyForm.new(documents: {
|
|
170
|
+
"0" => { document_type: "passport", document_number: "AB123" },
|
|
171
|
+
"1" => { document_type: "visa", document_number: "CD456" }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
# _destroy support
|
|
175
|
+
form = MyForm.new(documents: [
|
|
176
|
+
{ document_type: "passport", document_number: "AB123" },
|
|
177
|
+
{ document_type: "visa", document_number: "CD456", _destroy: "1" } # filtered out
|
|
178
|
+
])
|
|
179
|
+
form.documents.size # => 1
|
|
180
|
+
|
|
181
|
+
form.build_document(document_type: "id_card") # append new
|
|
182
|
+
form.documents_attributes = { "0" => { document_type: "id_card" } } # Rails compat setter
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Default: initialized as `[]` when not provided.
|
|
186
|
+
|
|
187
|
+
### `class_name` option
|
|
188
|
+
|
|
189
|
+
Override the auto-generated constant name:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
nested_one :address, class_name: "HomeAddress" do
|
|
193
|
+
attribute :street, :string
|
|
194
|
+
end
|
|
195
|
+
# Creates MyForm::HomeAddress instead of MyForm::Address
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Validation propagation
|
|
199
|
+
|
|
200
|
+
Nested errors bubble up with prefixed attribute names:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
form.valid?
|
|
204
|
+
form.errors[:"address.street"] # => ["can't be blank"]
|
|
205
|
+
form.errors[:"documents[0].doc_type"] # => ["can't be blank"]
|
|
206
|
+
form.errors.full_messages
|
|
207
|
+
# => ["Address street can't be blank", "Documents[0] doc type can't be blank"]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Uniqueness Validation
|
|
213
|
+
|
|
214
|
+
Checks uniqueness against the database.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
validates :email, uniqueness: true
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Options
|
|
221
|
+
|
|
222
|
+
| Option | Description | Example |
|
|
223
|
+
|--------|-------------|---------|
|
|
224
|
+
| `model:` | Explicit model class | `uniqueness: { model: User }` |
|
|
225
|
+
| `attribute:` | Column name if different | `uniqueness: { attribute: :email }` |
|
|
226
|
+
| `scope:` | Scoped uniqueness | `uniqueness: { scope: :tenant_id }` |
|
|
227
|
+
| `case_sensitive:` | Case-insensitive check | `uniqueness: { case_sensitive: false }` |
|
|
228
|
+
| `conditions:` | Extra query conditions | `uniqueness: { conditions: -> { where(active: true) } }` |
|
|
229
|
+
| `message:` | Custom error message | `uniqueness: { message: "already registered" }` |
|
|
230
|
+
|
|
231
|
+
### Model resolution
|
|
232
|
+
|
|
233
|
+
1. `options[:model]` (explicit)
|
|
234
|
+
2. `form.class._model_class` (from `model` DSL)
|
|
235
|
+
3. Infer from class name: `RegistrationForm` → `Registration`
|
|
236
|
+
4. If none found, validation is a no-op
|
|
237
|
+
|
|
238
|
+
### Record exclusion
|
|
239
|
+
|
|
240
|
+
When `form.record` is persisted, the current record is excluded from the uniqueness check (for updates).
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Record Binding
|
|
245
|
+
|
|
246
|
+
Use `with_record` to associate a model instance with the form. This is the recommended approach in controllers – it keeps the record separate from user-submitted params.
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# Chainable — preferred in controllers
|
|
250
|
+
form = OnboardingForm.new(params.require(:onboarding)).with_record(user)
|
|
251
|
+
|
|
252
|
+
# Constructor hash — convenient in plain Ruby / tests
|
|
253
|
+
form = OnboardingForm.new(name: "Alice", record: user)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
form.record # => the ActiveRecord instance (or nil)
|
|
258
|
+
form.persisted? # => true if record is present and persisted
|
|
259
|
+
form.to_key # => delegates to record (for URL generation)
|
|
260
|
+
form.to_param # => delegates to record
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`record` is read-only after construction — there is no public `record=` setter.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Serialization
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
form.to_h
|
|
271
|
+
# => {
|
|
272
|
+
# first_name: "Alice", last_name: "Smith", email: "alice@example.com",
|
|
273
|
+
# address: { street: "123 Main", city: "NYC", postal_code: nil, country: nil },
|
|
274
|
+
# documents: [{ document_type: "passport", document_number: "AB123" }]
|
|
275
|
+
# }
|
|
276
|
+
|
|
277
|
+
form.to_hash # alias for to_h
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Rails Integration
|
|
283
|
+
|
|
284
|
+
### `form_with`
|
|
285
|
+
|
|
286
|
+
```erb
|
|
287
|
+
<%= form_with model: @form, url: onboarding_path do |f| %>
|
|
288
|
+
<%= f.text_field :first_name %>
|
|
289
|
+
<%= f.text_field :last_name %>
|
|
290
|
+
<%= f.email_field :email %>
|
|
291
|
+
|
|
292
|
+
<%= f.fields_for :address do |a| %>
|
|
293
|
+
<%= a.text_field :street %>
|
|
294
|
+
<%= a.text_field :city %>
|
|
295
|
+
<% end %>
|
|
296
|
+
|
|
297
|
+
<%= f.fields_for :documents do |d| %>
|
|
298
|
+
<%= d.text_field :document_type %>
|
|
299
|
+
<%= d.text_field :document_number %>
|
|
300
|
+
<%= d.hidden_field :_destroy %>
|
|
301
|
+
<% end %>
|
|
302
|
+
|
|
303
|
+
<%= f.submit %>
|
|
304
|
+
<% end %>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Controller pattern
|
|
308
|
+
|
|
309
|
+
Strong parameters (`permit`) are not required — the form's attribute declarations are the whitelist. Just `require` the top-level key:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
class OnboardingController < ApplicationController
|
|
313
|
+
def new
|
|
314
|
+
@form = OnboardingForm.new
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def create
|
|
318
|
+
@form = OnboardingForm.new(params.require(:onboarding))
|
|
319
|
+
|
|
320
|
+
if @form.save
|
|
321
|
+
redirect_to dashboard_path
|
|
322
|
+
else
|
|
323
|
+
render :new, status: :unprocessable_entity
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def edit
|
|
328
|
+
@form = OnboardingForm.for(current_user)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def update
|
|
332
|
+
@form = OnboardingForm.new(params.require(:onboarding)).with_record(current_user)
|
|
333
|
+
|
|
334
|
+
if @form.save
|
|
335
|
+
redirect_to dashboard_path
|
|
336
|
+
else
|
|
337
|
+
render :edit, status: :unprocessable_entity
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Suggested Conventions
|
|
346
|
+
|
|
347
|
+
Dex::Form handles data holding, normalization, and validation. Persistence and record mapping are user-defined. These conventions are recommended:
|
|
348
|
+
|
|
349
|
+
### `.for(record)` — load from record
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
class OnboardingForm < Dex::Form
|
|
353
|
+
def self.for(user)
|
|
354
|
+
employee = user.employee
|
|
355
|
+
|
|
356
|
+
new(
|
|
357
|
+
first_name: user.first_name,
|
|
358
|
+
last_name: user.last_name,
|
|
359
|
+
email: user.email,
|
|
360
|
+
department: employee.department,
|
|
361
|
+
start_date: employee.start_date,
|
|
362
|
+
address: {
|
|
363
|
+
street: employee.address.street, city: employee.address.city,
|
|
364
|
+
postal_code: employee.address.postal_code, country: employee.address.country
|
|
365
|
+
},
|
|
366
|
+
documents: employee.documents.map { |d|
|
|
367
|
+
{ document_type: d.document_type, document_number: d.document_number }
|
|
368
|
+
}
|
|
369
|
+
).with_record(user)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### `#save` — persist via Operation
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
class OnboardingForm < Dex::Form
|
|
378
|
+
def save
|
|
379
|
+
return false unless valid?
|
|
380
|
+
|
|
381
|
+
case operation.safe.call
|
|
382
|
+
in Ok then true
|
|
383
|
+
in Err => e then errors.add(:base, e.message) and false
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def operation
|
|
390
|
+
Onboarding::Upsert.new(
|
|
391
|
+
user: record, first_name:, last_name:, email:,
|
|
392
|
+
department:, start_date:,
|
|
393
|
+
address: address.to_h,
|
|
394
|
+
documents: documents.map(&:to_h)
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Complete Example
|
|
403
|
+
|
|
404
|
+
A form spanning User, Employee, and Address — the core reason form objects exist.
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
class OnboardingForm < Dex::Form
|
|
408
|
+
attribute :first_name, :string
|
|
409
|
+
attribute :last_name, :string
|
|
410
|
+
attribute :email, :string
|
|
411
|
+
attribute :department, :string
|
|
412
|
+
attribute :position, :string
|
|
413
|
+
attribute :start_date, :date
|
|
414
|
+
|
|
415
|
+
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
416
|
+
|
|
417
|
+
validates :email, presence: true, uniqueness: { model: User }
|
|
418
|
+
validates :first_name, :last_name, :department, :position, presence: true
|
|
419
|
+
validates :start_date, presence: true
|
|
420
|
+
|
|
421
|
+
nested_one :address do
|
|
422
|
+
attribute :street, :string
|
|
423
|
+
attribute :city, :string
|
|
424
|
+
attribute :postal_code, :string
|
|
425
|
+
attribute :country, :string
|
|
426
|
+
|
|
427
|
+
validates :street, :city, :country, presence: true
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
nested_many :documents do
|
|
431
|
+
attribute :document_type, :string
|
|
432
|
+
attribute :document_number, :string
|
|
433
|
+
|
|
434
|
+
validates :document_type, :document_number, presence: true
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def self.for(user)
|
|
438
|
+
employee = user.employee
|
|
439
|
+
|
|
440
|
+
new(
|
|
441
|
+
first_name: user.first_name,
|
|
442
|
+
last_name: user.last_name,
|
|
443
|
+
email: user.email,
|
|
444
|
+
department: employee.department,
|
|
445
|
+
position: employee.position,
|
|
446
|
+
start_date: employee.start_date,
|
|
447
|
+
address: {
|
|
448
|
+
street: employee.address.street, city: employee.address.city,
|
|
449
|
+
postal_code: employee.address.postal_code, country: employee.address.country
|
|
450
|
+
},
|
|
451
|
+
documents: employee.documents.map { |d|
|
|
452
|
+
{ document_type: d.document_type, document_number: d.document_number }
|
|
453
|
+
}
|
|
454
|
+
).with_record(user)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def save
|
|
458
|
+
return false unless valid?
|
|
459
|
+
|
|
460
|
+
case operation.safe.call
|
|
461
|
+
in Ok then true
|
|
462
|
+
in Err => e then errors.add(:base, e.message) and false
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
private
|
|
467
|
+
|
|
468
|
+
def operation
|
|
469
|
+
Onboarding::Upsert.new(
|
|
470
|
+
user: record, first_name:, last_name:, email:,
|
|
471
|
+
department:, position:, start_date:,
|
|
472
|
+
address: address.to_h,
|
|
473
|
+
documents: documents.map(&:to_h)
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## Testing
|
|
482
|
+
|
|
483
|
+
Forms are standard ActiveModel objects. Test them with plain Minitest — no special helpers needed.
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
class OnboardingFormTest < Minitest::Test
|
|
487
|
+
def test_validates_required_fields
|
|
488
|
+
form = OnboardingForm.new
|
|
489
|
+
assert form.invalid?
|
|
490
|
+
assert form.errors[:email].any?
|
|
491
|
+
assert form.errors[:first_name].any?
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def test_normalizes_email
|
|
495
|
+
form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
|
|
496
|
+
assert_equal "alice@example.com", form.email
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def test_nested_validation_propagation
|
|
500
|
+
form = OnboardingForm.new(
|
|
501
|
+
first_name: "Alice", last_name: "Smith",
|
|
502
|
+
email: "alice@example.com", department: "Eng",
|
|
503
|
+
position: "Developer", start_date: Date.today,
|
|
504
|
+
address: { street: "", city: "", country: "" }
|
|
505
|
+
)
|
|
506
|
+
assert form.invalid?
|
|
507
|
+
assert form.errors[:"address.street"].any?
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def test_to_h_serialization
|
|
511
|
+
form = OnboardingForm.new(
|
|
512
|
+
first_name: "Alice", email: "alice@example.com",
|
|
513
|
+
address: { street: "123 Main", city: "NYC" }
|
|
514
|
+
)
|
|
515
|
+
h = form.to_h
|
|
516
|
+
assert_equal "Alice", h[:first_name]
|
|
517
|
+
assert_equal "123 Main", h[:address][:street]
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
```
|
data/guides/llm/OPERATION.md
CHANGED
|
@@ -187,7 +187,7 @@ in Dex::Err(code: :email_taken) then puts "Already exists"
|
|
|
187
187
|
end
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
`
|
|
190
|
+
`Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
data/lib/dex/concern.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
class Bus
|
|
6
|
+
@_subscribers = {}
|
|
7
|
+
@_mutex = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def subscribe(event_class, handler_class)
|
|
11
|
+
Event.validate_event_class!(event_class)
|
|
12
|
+
unless handler_class.is_a?(Class) && handler_class < Dex::Event::Handler
|
|
13
|
+
raise ArgumentError, "#{handler_class.inspect} is not a Dex::Event::Handler subclass"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@_mutex.synchronize do
|
|
17
|
+
@_subscribers[event_class] ||= []
|
|
18
|
+
@_subscribers[event_class] |= [handler_class]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unsubscribe(event_class, handler_class)
|
|
23
|
+
@_mutex.synchronize do
|
|
24
|
+
list = @_subscribers[event_class]
|
|
25
|
+
return unless list
|
|
26
|
+
|
|
27
|
+
list.delete(handler_class)
|
|
28
|
+
@_subscribers.delete(event_class) if list.empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subscribers_for(event_class)
|
|
33
|
+
@_mutex.synchronize do
|
|
34
|
+
@_subscribers.each_with_object([]) do |(subscribed_class, handlers), result|
|
|
35
|
+
result.concat(handlers) if event_class <= subscribed_class
|
|
36
|
+
end.uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def subscribers
|
|
41
|
+
@_mutex.synchronize { @_subscribers.transform_values(&:dup) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def publish(event, sync:)
|
|
45
|
+
return if Suppression.suppressed?(event.class)
|
|
46
|
+
|
|
47
|
+
persist(event)
|
|
48
|
+
handlers = subscribers_for(event.class)
|
|
49
|
+
return if handlers.empty?
|
|
50
|
+
|
|
51
|
+
event_frame = event.trace_frame
|
|
52
|
+
|
|
53
|
+
handlers.each do |handler_class|
|
|
54
|
+
if sync
|
|
55
|
+
Trace.restore(event_frame) do
|
|
56
|
+
handler_class._event_handle(event)
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
enqueue(handler_class, event, event_frame)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear!
|
|
65
|
+
@_mutex.synchronize { @_subscribers.clear }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def persist(event)
|
|
71
|
+
store = Dex.configuration.event_store
|
|
72
|
+
return unless store
|
|
73
|
+
|
|
74
|
+
store.create!(
|
|
75
|
+
event_type: event.class.name,
|
|
76
|
+
payload: event._props_as_json,
|
|
77
|
+
metadata: event.metadata.as_json
|
|
78
|
+
)
|
|
79
|
+
rescue => e
|
|
80
|
+
Event._warn("Failed to persist event: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def enqueue(handler_class, event, trace_data)
|
|
84
|
+
ctx = event.context
|
|
85
|
+
|
|
86
|
+
Dex::Event::Processor.perform_later(
|
|
87
|
+
handler_class: handler_class.name,
|
|
88
|
+
event_class: event.class.name,
|
|
89
|
+
payload: event._props_as_json,
|
|
90
|
+
metadata: event.metadata.as_json,
|
|
91
|
+
trace: trace_data,
|
|
92
|
+
context: ctx
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module ExecutionState
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def _execution_state
|
|
9
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
|
10
|
+
ActiveSupport::IsolatedExecutionState
|
|
11
|
+
else
|
|
12
|
+
Thread.current
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|