dexkit 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3adbbfa21a62c17b74b7ac6807ed7a9abdca2b02c85e06bb2d542bdbf7d39677
4
- data.tar.gz: cead77e688d4c30a7256c05f73d509fefd44c7c6b6bfc52d9b0bdbfd8675954e
3
+ metadata.gz: ec8b561633fbdf9989f8bc2476fe84ad2cd0c32177990d03380d63f59a8368a9
4
+ data.tar.gz: a348e6b1090374bfda6281e6a58fe16af3e285bb009fad38be60a3ba2c510720
5
5
  SHA512:
6
- metadata.gz: b58aec14261efd1c679bb662b163df64f96d86239e5efe8897d7b8d007aef4cd19864a8fabc03ee4acf139379c1185e56f8a5f6e488a1f5dec00442a24223dee
7
- data.tar.gz: 6009bd9567cf1fd4adacf21cfb5c6627cfce8d1d399498923ae9ff8a11c3f2b31df884549a8b3bdb0c45bede9951b672c6d50dbf2199a691e45a79c37b5c1497
6
+ metadata.gz: 49d44b87657f65927b88c7fc1296ccab5d5246637a1887a126b0949e11bd452ecc0deb5fea90ef7f3329051b7bc13895f8f0ef73286705de44f7d21dccc0802a
7
+ data.tar.gz: 43182a4b6b6c904afe87fb385cff3a42057975363b5fd53e10744264f3af8cd5d48306b0ade26fd195b0d4fca696cbd85ac6c06e4434f48c280fb42a959beb80
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-03
4
+
5
+ ### Added
6
+
7
+ - **Form objects** — `Dex::Form` base class for user-facing input handling
8
+ - Typed attributes via ActiveModel (`attribute :name, :string`)
9
+ - Normalization on assignment (`normalizes :email, with: -> { _1&.strip&.downcase }`)
10
+ - Full ActiveModel validation support, including custom `uniqueness` validator with scope, case-insensitive matching, conditions, and record exclusion
11
+ - `model` DSL for binding a form to an ActiveRecord model class (drives `model_name`, `persisted?`, `to_key`, `to_param`)
12
+ - `record` reader and `with_record` chainable method for edit/update forms — record is excluded from form attributes and protected from mass assignment
13
+ - `nested_one` / `nested_many` DSL for nested form objects with auto-generated constants, `build_` methods, and `_attributes=` setters
14
+ - Hash coercion, Rails numbered hash format, and `_destroy` filtering in nested forms
15
+ - Validation propagation from nested forms with prefixed error keys (`address.street`, `documents[0].doc_type`)
16
+ - `ActionController::Parameters` support — strong parameters (`permit`) not required; the form's attribute declarations are the whitelist
17
+ - `to_h` / `to_hash` serialization including nested forms
18
+ - `ValidationError` for bang-style save patterns
19
+ - Full Rails `form_with` / `fields_for` compatibility
20
+ - **Form uniqueness validator** — `validates :email, uniqueness: true`
21
+ - Model resolution chain: explicit `model:` option, `model` DSL, or infer from class name (`UserForm` → `User`)
22
+ - Options: `scope`, `case_sensitive`, `conditions` (zero-arg or form-arg lambda), `attribute` mapping, `message`
23
+ - Excludes current record via model's `primary_key` (not hardcoded `id`)
24
+ - Declaration-time validation of `model:` and `conditions:` options
25
+ - Added `actionpack` as development dependency for testing Rails controller integration
26
+ - Added `activemodel >= 6.1` as runtime dependency
27
+
28
+ ### Changed
29
+
30
+ - `Dex::Match` is now included in `Dex::Operation` – `Ok`/`Err` are available without prefix inside operations. External contexts (controllers, POROs) can still use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
31
+
3
32
  ## [0.2.0] - 2026-03-02
4
33
 
5
34
  ### Added
data/README.md CHANGED
@@ -48,8 +48,6 @@ rescue_from Stripe::CardError, as: :card_declined
48
48
  **Ok / Err** – pattern match on operation outcomes with `.safe.call`:
49
49
 
50
50
  ```ruby
51
- include Dex::Match
52
-
53
51
  case CreateUser.new(email: email).safe.call
54
52
  in Ok(name:)
55
53
  puts "Welcome, #{name}!"
@@ -138,6 +136,66 @@ class CreateOrderTest < Minitest::Test
138
136
  end
139
137
  ```
140
138
 
139
+ ## Forms
140
+
141
+ Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
142
+
143
+ ```ruby
144
+ class OnboardingForm < Dex::Form
145
+ model User
146
+
147
+ attribute :first_name, :string
148
+ attribute :last_name, :string
149
+ attribute :email, :string
150
+
151
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
152
+
153
+ validates :email, presence: true, uniqueness: true
154
+ validates :first_name, :last_name, presence: true
155
+
156
+ nested_one :address do
157
+ attribute :street, :string
158
+ attribute :city, :string
159
+ validates :street, :city, presence: true
160
+ end
161
+ end
162
+
163
+ form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
164
+ form.email # => "alice@example.com"
165
+ form.valid?
166
+ ```
167
+
168
+ ### What you get out of the box
169
+
170
+ **ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
171
+
172
+ **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
173
+
174
+ ```ruby
175
+ nested_many :documents do
176
+ attribute :document_type, :string
177
+ attribute :document_number, :string
178
+ validates :document_type, :document_number, presence: true
179
+ end
180
+ ```
181
+
182
+ **Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
183
+
184
+ **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
185
+
186
+ **Multi-model forms** — when a form spans User, Employee, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
187
+
188
+ ```ruby
189
+ def save
190
+ return false unless valid?
191
+
192
+ case operation.safe.call
193
+ in Ok then true
194
+ in Err => e then errors.add(:base, e.message) and false
195
+ end
196
+ end
197
+ ```
198
+
141
199
  ## Installation
142
200
 
143
201
  ```ruby
@@ -155,6 +213,7 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
155
213
  ```bash
156
214
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
157
215
  cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
216
+ cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
158
217
  ```
159
218
 
160
219
  ## License
@@ -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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ module Nesting
6
+ extend Dex::Concern
7
+
8
+ module ClassMethods
9
+ def _nested_ones
10
+ @_nested_ones ||= {}
11
+ end
12
+
13
+ def _nested_manys
14
+ @_nested_manys ||= {}
15
+ end
16
+
17
+ def nested_one(name, class_name: nil, &block)
18
+ raise ArgumentError, "nested_one requires a block" unless block
19
+
20
+ name = name.to_sym
21
+ nested_class = _build_nested_class(name, class_name, &block)
22
+ _nested_ones[name] = nested_class
23
+
24
+ attr_reader name
25
+
26
+ define_method(:"#{name}=") do |value|
27
+ coerced = _coerce_nested_one(name, value)
28
+ instance_variable_set(:"@#{name}", coerced)
29
+ end
30
+
31
+ define_method(:"build_#{name}") do |attrs = {}|
32
+ instance = self.class._nested_ones[name].new(attrs)
33
+ send(:"#{name}=", instance)
34
+ instance
35
+ end
36
+
37
+ define_method(:"#{name}_attributes=") do |attrs|
38
+ send(:"#{name}=", attrs)
39
+ end
40
+ end
41
+
42
+ def nested_many(name, class_name: nil, &block)
43
+ raise ArgumentError, "nested_many requires a block" unless block
44
+
45
+ name = name.to_sym
46
+ nested_class = _build_nested_class(name, class_name, &block)
47
+ _nested_manys[name] = nested_class
48
+
49
+ attr_reader name
50
+
51
+ define_method(:"#{name}=") do |value|
52
+ coerced = _coerce_nested_many(name, value)
53
+ instance_variable_set(:"@#{name}", coerced)
54
+ end
55
+
56
+ define_method(:"build_#{name.to_s.singularize}") do |attrs = {}|
57
+ instance = self.class._nested_manys[name].new(attrs)
58
+ items = send(name) || []
59
+ items << instance
60
+ instance_variable_set(:"@#{name}", items)
61
+ instance
62
+ end
63
+
64
+ define_method(:"#{name}_attributes=") do |attrs|
65
+ send(:"#{name}=", attrs)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def _build_nested_class(name, class_name, &block)
72
+ klass = Class.new(Dex::Form, &block)
73
+ const_name = class_name || name.to_s.singularize.camelize
74
+ const_set(const_name, klass)
75
+ klass
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def _coerce_nested_one(name, value)
82
+ klass = self.class._nested_ones[name]
83
+ value = _unwrap_hash_like(value)
84
+ case value
85
+ when Hash
86
+ return nil if _marked_for_destroy?(value)
87
+ klass.new(value.except("_destroy", :_destroy))
88
+ when klass then value
89
+ when nil then value
90
+ else raise ArgumentError, "#{name} must be a Hash or #{klass}, got #{value.class}"
91
+ end
92
+ end
93
+
94
+ def _coerce_nested_many(name, value)
95
+ klass = self.class._nested_manys[name]
96
+ value = _unwrap_hash_like(value)
97
+ items = case value
98
+ when Array then value
99
+ when Hash then _normalize_nested_hash(value)
100
+ else raise ArgumentError, "#{name} must be an Array or Hash, got #{value.class}"
101
+ end
102
+
103
+ items.filter_map do |item|
104
+ item = _unwrap_hash_like(item)
105
+ case item
106
+ when Hash
107
+ next nil if _marked_for_destroy?(item)
108
+ klass.new(item.except("_destroy", :_destroy))
109
+ when klass then item
110
+ else raise ArgumentError, "each #{name} item must be a Hash or #{klass}"
111
+ end
112
+ end
113
+ end
114
+
115
+ def _unwrap_hash_like(value)
116
+ return value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
117
+ return value if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Dex::Form) || value.nil?
118
+
119
+ value.respond_to?(:to_h) ? value.to_h : value
120
+ end
121
+
122
+ def _normalize_nested_hash(hash)
123
+ hash.sort_by { |k, _| k.to_s.to_i }.map(&:last)
124
+ end
125
+
126
+ def _marked_for_destroy?(attrs)
127
+ destroy_val = attrs["_destroy"] || attrs[:_destroy]
128
+ ActiveModel::Type::Boolean.new.cast(destroy_val)
129
+ end
130
+
131
+ def _initialize_nested_defaults(provided_keys)
132
+ self.class._nested_ones.each_key do |name|
133
+ key = name.to_s
134
+ next if provided_keys.include?(key) || provided_keys.include?("#{key}_attributes")
135
+
136
+ send(:"#{name}=", {})
137
+ end
138
+
139
+ self.class._nested_manys.each_key do |name|
140
+ next if instance_variable_get(:"@#{name}")
141
+
142
+ instance_variable_set(:"@#{name}", [])
143
+ end
144
+ end
145
+
146
+ def _validate_nested(context)
147
+ valid = true
148
+
149
+ self.class._nested_ones.each_key do |name|
150
+ nested = send(name)
151
+ next unless nested
152
+
153
+ unless nested.valid?(context)
154
+ nested.errors.each do |error|
155
+ errors.add(:"#{name}.#{error.attribute}", error.message)
156
+ end
157
+ valid = false
158
+ end
159
+ end
160
+
161
+ self.class._nested_manys.each_key do |name|
162
+ items = send(name) || []
163
+ items.each_with_index do |item, index|
164
+ next if item.valid?(context)
165
+
166
+ item.errors.each do |error|
167
+ errors.add(:"#{name}[#{index}].#{error.attribute}", error.message)
168
+ end
169
+ valid = false
170
+ end
171
+ end
172
+
173
+ valid
174
+ end
175
+
176
+ def _nested_to_h(result)
177
+ self.class._nested_ones.each_key do |name|
178
+ nested = send(name)
179
+ result[name] = nested&.to_h
180
+ end
181
+
182
+ self.class._nested_manys.each_key do |name|
183
+ items = send(name) || []
184
+ result[name] = items.map(&:to_h)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ class UniquenessValidator < ActiveModel::EachValidator
6
+ def check_validity!
7
+ if options.key?(:model) && !options[:model].is_a?(Class)
8
+ raise ArgumentError, "uniqueness :model must be a Class, got #{options[:model].inspect}"
9
+ end
10
+ if options.key?(:conditions) && !options[:conditions].respond_to?(:call)
11
+ raise ArgumentError, "uniqueness :conditions must be callable"
12
+ end
13
+ end
14
+
15
+ def validate_each(form, attribute, value)
16
+ return if value.blank?
17
+
18
+ model_class = _resolve_model_class(form)
19
+ return unless model_class
20
+
21
+ column = options[:attribute] || attribute
22
+ query = _build_query(model_class, column, value)
23
+ query = _apply_scope(query, form)
24
+ query = _apply_conditions(query, form)
25
+ query = _exclude_current_record(query, form)
26
+
27
+ form.errors.add(attribute, options[:message] || :taken) if query.exists?
28
+ end
29
+
30
+ private
31
+
32
+ def _resolve_model_class(form)
33
+ return options[:model] if options[:model]
34
+ return form.class._model_class if form.class.respond_to?(:_model_class) && form.class._model_class
35
+
36
+ _infer_model_class(form)
37
+ end
38
+
39
+ def _infer_model_class(form)
40
+ class_name = form.class.name
41
+ return unless class_name
42
+
43
+ model_name = class_name.sub(/Form\z/, "")
44
+ return if model_name == class_name
45
+
46
+ klass = Object.const_get(model_name)
47
+ klass.respond_to?(:where) ? klass : nil
48
+ rescue NameError
49
+ nil
50
+ end
51
+
52
+ def _build_query(model_class, column, value)
53
+ if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
54
+ model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
55
+ else
56
+ model_class.where(column => value)
57
+ end
58
+ end
59
+
60
+ def _apply_scope(query, form)
61
+ Array(options[:scope]).each do |scope_attr|
62
+ query = query.where(scope_attr => form.public_send(scope_attr))
63
+ end
64
+ query
65
+ end
66
+
67
+ def _apply_conditions(query, form)
68
+ return query unless options[:conditions]
69
+
70
+ callable = options[:conditions]
71
+ if callable.arity.zero?
72
+ query.instance_exec(&callable)
73
+ else
74
+ query.instance_exec(form, &callable)
75
+ end
76
+ end
77
+
78
+ def _exclude_current_record(query, form)
79
+ return query unless form.record&.persisted?
80
+
81
+ pk = form.record.class.primary_key
82
+ query.where.not(pk => form.record.public_send(pk))
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/dex/form.rb ADDED
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ require_relative "form/nesting"
6
+
7
+ module Dex
8
+ class Form
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+ include ActiveModel::Validations::Callbacks
12
+
13
+ if defined?(ActiveModel::Attributes::Normalization)
14
+ include ActiveModel::Attributes::Normalization
15
+ end
16
+
17
+ include Nesting
18
+ include Match
19
+
20
+ class ValidationError < StandardError
21
+ attr_reader :form
22
+
23
+ def initialize(form)
24
+ @form = form
25
+ super("Validation failed: #{form.errors.full_messages.join(", ")}")
26
+ end
27
+ end
28
+
29
+ class << self
30
+ def model(klass = nil)
31
+ if klass
32
+ raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
33
+ @_model_class = klass
34
+ end
35
+ _model_class
36
+ end
37
+
38
+ def _model_class
39
+ return @_model_class if defined?(@_model_class)
40
+ superclass._model_class if superclass.respond_to?(:_model_class)
41
+ end
42
+
43
+ def inherited(subclass)
44
+ super
45
+ subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
46
+ subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
47
+ end
48
+ end
49
+
50
+ silence_redefinition_of_method :model_name
51
+ def self.model_name
52
+ if _model_class
53
+ _model_class.model_name
54
+ elsif name && !name.start_with?("#")
55
+ super
56
+ else
57
+ @_model_name ||= ActiveModel::Name.new(self, nil, name&.split("::")&.last || "Form")
58
+ end
59
+ end
60
+
61
+ attr_reader :record
62
+
63
+ def initialize(attributes = {})
64
+ # Accept ActionController::Parameters without requiring .permit — the form's
65
+ # attribute declarations are the whitelist. Only declared attributes and nested
66
+ # setters are assignable; everything else is silently dropped.
67
+ attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
68
+ attrs = (attributes || {}).transform_keys(&:to_s)
69
+ record = attrs.delete("record")
70
+ @record = record if record.nil? || record.respond_to?(:persisted?)
71
+ provided_keys = attrs.keys
72
+ nested_attrs = _extract_nested_attributes(attrs)
73
+ super(attrs.slice(*self.class.attribute_names))
74
+ _apply_nested_attributes(nested_attrs)
75
+ _initialize_nested_defaults(provided_keys)
76
+ end
77
+
78
+ def with_record(record)
79
+ raise ArgumentError, "record must respond to #persisted?, got #{record.inspect}" unless record.respond_to?(:persisted?)
80
+
81
+ @record = record
82
+ self
83
+ end
84
+
85
+ def persisted?
86
+ record&.persisted? || false
87
+ end
88
+
89
+ def to_key
90
+ record&.to_key
91
+ end
92
+
93
+ def to_param
94
+ record&.to_param
95
+ end
96
+
97
+ def valid?(context = nil)
98
+ super_result = super
99
+ nested_result = _validate_nested(context)
100
+ super_result && nested_result
101
+ end
102
+
103
+ def to_h
104
+ result = {}
105
+ self.class.attribute_names.each do |name|
106
+ result[name.to_sym] = public_send(name)
107
+ end
108
+ _nested_to_h(result)
109
+ result
110
+ end
111
+
112
+ alias_method :to_hash, :to_h
113
+
114
+ private
115
+
116
+ def _extract_nested_attributes(attrs)
117
+ nested_keys = self.class._nested_ones.keys.map(&:to_s) +
118
+ self.class._nested_manys.keys.map(&:to_s)
119
+
120
+ extracted = {}
121
+ nested_keys.each do |key|
122
+ attr_key = "#{key}_attributes"
123
+ if attrs.key?(attr_key)
124
+ extracted[attr_key] = attrs.delete(attr_key)
125
+ attrs.delete(key)
126
+ elsif attrs.key?(key)
127
+ extracted[key] = attrs.delete(key)
128
+ end
129
+ end
130
+ extracted
131
+ end
132
+
133
+ def _apply_nested_attributes(nested_attrs)
134
+ nested_attrs.each do |key, value|
135
+ next if value.nil?
136
+ send(:"#{key}=", value)
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require_relative "form/uniqueness_validator"
data/lib/dex/operation.rb CHANGED
@@ -139,3 +139,6 @@ require_relative "operation/jobs"
139
139
 
140
140
  # Top-level aliases (depend on Operation::Ok/Err)
141
141
  require_relative "match"
142
+
143
+ # Make Ok/Err available without prefix inside operations
144
+ Dex::Operation.include(Dex::Match)
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -17,6 +17,7 @@ require_relative "dex/props_setup"
17
17
  require_relative "dex/error"
18
18
  require_relative "dex/operation"
19
19
  require_relative "dex/event"
20
+ require_relative "dex/form"
20
21
 
21
22
  module Dex
22
23
  class Configuration
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activemodel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: literal
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +79,20 @@ dependencies:
65
79
  - - ">="
66
80
  - !ruby/object:Gem::Version
67
81
  version: '6.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: actionpack
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '6.1'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '6.1'
68
96
  - !ruby/object:Gem::Dependency
69
97
  name: activerecord
70
98
  requirement: !ruby/object:Gem::Requirement
@@ -93,8 +121,8 @@ dependencies:
93
121
  - - ">="
94
122
  - !ruby/object:Gem::Version
95
123
  version: '2.1'
96
- description: 'A toolbelt of patterns for your Rails applications: Operation (more
97
- coming soon)'
124
+ description: 'A toolbelt of patterns for your Rails applications: Operation, Event,
125
+ Form'
98
126
  email:
99
127
  - jacek.galanciak@gmail.com
100
128
  executables: []
@@ -105,6 +133,7 @@ files:
105
133
  - LICENSE.txt
106
134
  - README.md
107
135
  - guides/llm/EVENT.md
136
+ - guides/llm/FORM.md
108
137
  - guides/llm/OPERATION.md
109
138
  - lib/dex/concern.rb
110
139
  - lib/dex/error.rb
@@ -118,6 +147,9 @@ files:
118
147
  - lib/dex/event/trace.rb
119
148
  - lib/dex/event_test_helpers.rb
120
149
  - lib/dex/event_test_helpers/assertions.rb
150
+ - lib/dex/form.rb
151
+ - lib/dex/form/nesting.rb
152
+ - lib/dex/form/uniqueness_validator.rb
121
153
  - lib/dex/match.rb
122
154
  - lib/dex/operation.rb
123
155
  - lib/dex/operation/async_proxy.rb