dexkit 0.2.0 → 0.4.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 +29 -0
- data/README.md +105 -2
- data/Untitled +12 -0
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- data/guides/llm/QUERY.md +348 -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.rb +3 -0
- data/lib/dex/query/backend.rb +91 -0
- data/lib/dex/query/filtering.rb +73 -0
- data/lib/dex/query/sorting.rb +95 -0
- data/lib/dex/query.rb +271 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +2 -0
- metadata +41 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc994e44218cf9063af69ad1d01929d86b91fe35b6f30769dd068832fb04fc37
|
|
4
|
+
data.tar.gz: b4a078fb25780824cd2a876f5d668196ba2f3a2094ce63a1b9731dfeab1c7aa6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4bb8dbbe3c9acd66e5c3ed6a6b203408f66c7455544c36de4e60e755a9cfa9a2fa3405a39648e77e5bd42b2593a053d5d517e16ce1cd5236225083bd24891b3
|
|
7
|
+
data.tar.gz: c050bd2dc477e19efcebbe6bfa6fe7d46dbfa4dea5367f38c098b6c82750439211d1b4a28224c21fd66e98c8971b9532f341f4e17b4d2e836383470ccf778913
|
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,109 @@ 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
|
+
|
|
199
|
+
## Queries
|
|
200
|
+
|
|
201
|
+
Declarative query objects for filtering and sorting ActiveRecord relations.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class UserSearch < Dex::Query
|
|
205
|
+
scope { User.all }
|
|
206
|
+
|
|
207
|
+
prop? :name, String
|
|
208
|
+
prop? :role, _Array(String)
|
|
209
|
+
prop? :age_min, Integer
|
|
210
|
+
|
|
211
|
+
filter :name, :contains
|
|
212
|
+
filter :role, :in
|
|
213
|
+
filter :age_min, :gte, column: :age
|
|
214
|
+
|
|
215
|
+
sort :name, :created_at, default: "-created_at"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### What you get out of the box
|
|
222
|
+
|
|
223
|
+
**11 built-in filter strategies** — `:eq`, `:not_eq`, `:contains`, `:starts_with`, `:ends_with`, `:gt`, `:gte`, `:lt`, `:lte`, `:in`, `:not_in`. Custom blocks for complex logic.
|
|
224
|
+
|
|
225
|
+
**Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
|
|
226
|
+
|
|
227
|
+
**`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class UsersController < ApplicationController
|
|
231
|
+
def index
|
|
232
|
+
query = UserSearch.from_params(params, scope: policy_scope(User))
|
|
233
|
+
@users = pagy(query.resolve)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
|
|
239
|
+
|
|
240
|
+
**Scope injection** — narrow the base scope at call time without modifying the query class.
|
|
241
|
+
|
|
141
242
|
## Installation
|
|
142
243
|
|
|
143
244
|
```ruby
|
|
@@ -155,6 +256,8 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
|
|
|
155
256
|
```bash
|
|
156
257
|
cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
|
|
157
258
|
cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
|
|
259
|
+
cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
|
|
260
|
+
cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
|
|
158
261
|
```
|
|
159
262
|
|
|
160
263
|
## License
|
data/Untitled
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
P2
|
|
2
|
+
Preserve explicit nils in `from_params` initialization
|
|
3
|
+
from_params explicitly assigns nil for blank optional values, but new(**kwargs.compact) removes those keys before initialization. This breaks the documented "blank/invalid to nil" behavior whenever an optional prop has a non-nil default (prop? ... default: ...), because blank or uncoercible input silently falls back to the default and applies unintended filters.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:134-134
|
|
8
|
+
P3
|
|
9
|
+
Validate `param_key` input before caching model name
|
|
10
|
+
param_key accepts any truthy value and stores key.to_s without validation, so param_key "" is accepted but later crashes in model_name when ActiveModel::Name.new receives a blank class name. This turns a declaration-time DSL error into a runtime failure in form binding/from_params, which is harder to diagnose.
|
|
11
|
+
/Users/razorjack/Projects/OpenSource/dexkit/lib/dex/query.rb:58-60
|
|
12
|
+
|
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
|
+
```
|