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.
@@ -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
+ ```
@@ -187,7 +187,7 @@ in Dex::Err(code: :email_taken) then puts "Already exists"
187
187
  end
188
188
  ```
189
189
 
190
- `include Dex::Match` to use `Ok`/`Err` without `Dex::` prefix.
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
 
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Concern
5
+ def included(base)
6
+ base.extend(self::ClassMethods) if const_defined?(:ClassMethods, false)
7
+ super
8
+ end
9
+ end
10
+ end
@@ -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