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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +61 -2
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- 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/version.rb +1 -1
- data/lib/dexkit.rb +1 -0
- metadata +35 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec8b561633fbdf9989f8bc2476fe84ad2cd0c32177990d03380d63f59a8368a9
|
|
4
|
+
data.tar.gz: a348e6b1090374bfda6281e6a58fe16af3e285bb009fad38be60a3ba2c510720
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
|
|
@@ -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
data/lib/dex/version.rb
CHANGED
data/lib/dexkit.rb
CHANGED
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.
|
|
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
|
|
97
|
-
|
|
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
|