dexkit 0.9.0 → 0.10.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 +22 -0
- data/README.md +44 -16
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +24 -19
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +27 -3
- data/guides/llm/QUERY.md +50 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +78 -8
- data/lib/dex/event/handler.rb +18 -0
- data/lib/dex/event/metadata.rb +16 -9
- data/lib/dex/event/processor.rb +1 -1
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event/trace.rb +14 -27
- data/lib/dex/event.rb +2 -7
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +38 -0
- data/lib/dex/operation/async_proxy.rb +12 -2
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/test_helpers/assertions.rb +24 -0
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +2 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +3 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 493f1873cf71e1f8e2348f4f15e3e508962d9ae310b85f7b4aac8a756396de05
|
|
4
|
+
data.tar.gz: ad50fd8349a45e4c4bc536f68bb2c74ee10b59e8b80856fca2823bb9f9a998cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18ca385b0d66155e35c3f08164d3b558e85f3f29a586b492d664c72c24f746e6e70c0d6692faedb348e7c849096eb0f3ddfd98a5515e2de5b5ce05084cab3d5e
|
|
7
|
+
data.tar.gz: 2e7c2e59443b2dff7fb3a9de4c3296ce1b71c4f870bf47d2ce503f5d5c23105a1cee5549618451741a21fb6d0c8fcb0eba53b94d4edcf8e999e1164e9eabf96f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.10.0] - 2026-03-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`field` / `field?` DSL for forms** – `field :name, :string` declares a required field with auto-presence validation; unconditional explicit presence validators deduplicate with it, while scoped/conditional validators still layer on top. `field? :notes, :string` declares an optional field (nil by default). Both support `desc:` for metadata and `default:` for defaults. Raw `attribute` remains available as an escape hatch
|
|
8
|
+
- **Form registry and description** – `Dex::Form` now extends `Registry`, giving forms `description`, `Dex::Form.registry`, and `deregister` – the same ecosystem as Operation, Event, and Handler
|
|
9
|
+
- **Form ambient context** – forms support the same `context` DSL as Operation and Event. `context :locale` auto-fills from `Dex.context` during initialization. Uses the same shared `ContextDSL` module with Form-specific injection
|
|
10
|
+
- **Form export** – `Form.to_h` (class-level schema), `Form.to_json_schema`, and `Dex::Form.export(format:)` for bulk export. Nested forms are recursively included in both formats, and bulk export returns top-level named forms without listing nested helper classes separately
|
|
11
|
+
- **Query registry, description, context, and export** – `Dex::Query` now extends `Registry` (giving `description`, `Dex::Query.registry`, `deregister`), includes `ContextSetup` (enabling `context :tenant` to auto-fill from `Dex.with_context`), and adds `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)`. Query is now a full citizen alongside Operation, Event, and Form
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Shared context DSL** – extracted `Dex::ContextDSL` as a shared module used by both `ContextSetup` (Operation/Event) and `Form::Context`. No behavior change for Operation or Event
|
|
16
|
+
|
|
3
17
|
## [0.9.0] - 2026-03-09
|
|
4
18
|
|
|
5
19
|
### Breaking
|
|
6
20
|
|
|
21
|
+
- **Unified execution tracing replaces event-only tracing** – `Dex::Trace` is a new fiber-local trace that spans operations, events, and handlers. Operations get `op_...` execution IDs, events get `ev_...` IDs (replacing UUIDs), handlers get `hd_...` IDs, and traces are correlated with `tr_...` IDs. `event.trace { }` is removed – use `caused_by:` for explicit event causality and `Dex::Trace.start(actor:)` at request/job boundaries. `Dex::Event::Trace` remains as a thin delegation layer
|
|
22
|
+
- **Operation record primary keys are now string IDs** – records use the operation's `op_...` execution ID as a string primary key instead of auto-increment integers. The recording schema adds `trace_id`, `actor_type`, `actor_id`, and `trace` columns. Existing tables need a migration to adopt the new schema
|
|
7
23
|
- **Mongoid transaction support removed** — `transaction :mongoid` and `config.transaction_adapter = :mongoid` are no longer valid. Dex no longer ships a Mongoid transaction adapter. Before: Mongoid transactions could be enabled via configuration or per-operation DSL. After: both forms raise `ArgumentError` immediately at declaration/configuration time. Mongoid-only apps continue to work — transactions are automatically disabled (no adapter detected), and `after_commit` fires immediately after success. If you need Mongoid multi-document transactions, call `Mongoid.transaction` directly inside `perform`
|
|
8
24
|
- **Recording backends now validate required attributes before use** — Dex no longer silently drops missing `params`, `result`, `status`, or `once` attributes from `record_class`. Before: partial ActiveRecord/Mongoid recording models could appear to work while losing status transitions, replay data, or async params. After: Dex raises `ArgumentError` naming the missing attributes required by core recording, async record jobs, or `once`. Apps using minimal recording models must add the required columns/fields or explicitly disable the features that need them
|
|
9
25
|
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`Dex::Trace` API** – `start(actor:, trace_id:)`, `.trace_id`, `.current`, `.current_id`, `.actor`, `.to_s`, `.dump`, `.restore`. Fiber-local, auto-starts when no trace is active, serializes across async job boundaries
|
|
29
|
+
- **Trace persistence** – operation records and event stores persist `id`, `trace_id`, `actor_type`, `actor_id`, and `trace` when the columns exist. Event metadata includes `event_ancestry` for materialized-path tree queries
|
|
30
|
+
- **`Dex::Id`** – Stripe-style prefixed ID generation with embedded timestamps for sortability
|
|
31
|
+
|
|
10
32
|
### Fixed
|
|
11
33
|
|
|
12
34
|
- **Mongoid-only Rails compatibility** — Dex boots and runs cleanly in Mongoid-only Rails apps without `activerecord` loaded, with prescriptive `LoadError`s for unsupported paths such as `advisory_lock` and async event dispatch without `ActiveJob`
|
data/README.md
CHANGED
|
@@ -73,6 +73,17 @@ end
|
|
|
73
73
|
Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
**Execution tracing** – every operation gets a prefixed ID and joins a unified trace across operations, events, and handlers:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
80
|
+
Order::Place.call(customer: 42, product: 7, quantity: 2)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Dex::Trace.trace_id # => "tr_..."
|
|
84
|
+
Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
|
|
85
|
+
```
|
|
86
|
+
|
|
76
87
|
**Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
|
|
77
88
|
|
|
78
89
|
```ruby
|
|
@@ -210,11 +221,12 @@ Order::Placed.publish(order_id: 1, total: 99.99)
|
|
|
210
221
|
|
|
211
222
|
**Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline. If ActiveJob is not loaded, async publish raises `LoadError`.
|
|
212
223
|
|
|
213
|
-
**Causality tracing**
|
|
224
|
+
**Causality tracing** – events join the unified execution trace, and child events link to their cause:
|
|
214
225
|
|
|
215
226
|
```ruby
|
|
216
|
-
|
|
217
|
-
|
|
227
|
+
Dex::Trace.start(actor: { type: :user, id: 42 }) do
|
|
228
|
+
order_placed = Order::Placed.new(order_id: 1, total: 99.99)
|
|
229
|
+
Shipment::Reserved.publish(order_id: 1, caused_by: order_placed)
|
|
218
230
|
end
|
|
219
231
|
```
|
|
220
232
|
|
|
@@ -241,25 +253,29 @@ end
|
|
|
241
253
|
|
|
242
254
|
## Forms
|
|
243
255
|
|
|
244
|
-
Form objects with typed
|
|
256
|
+
Form objects with typed fields, normalization, nested forms, ambient context, JSON Schema export, and Rails form builder compatibility.
|
|
245
257
|
|
|
246
258
|
```ruby
|
|
247
259
|
class Employee::Form < Dex::Form
|
|
260
|
+
description "Employee onboarding form"
|
|
248
261
|
model Employee
|
|
249
262
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
field :first_name, :string
|
|
264
|
+
field :last_name, :string
|
|
265
|
+
field :email, :string
|
|
266
|
+
field :locale, :string
|
|
267
|
+
field? :notes, :string
|
|
268
|
+
|
|
269
|
+
context :locale
|
|
253
270
|
|
|
254
271
|
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
255
272
|
|
|
256
|
-
validates :email,
|
|
257
|
-
validates :first_name, :last_name, presence: true
|
|
273
|
+
validates :email, uniqueness: true
|
|
258
274
|
|
|
259
275
|
nested_one :address do
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
276
|
+
field :street, :string
|
|
277
|
+
field :city, :string
|
|
278
|
+
field? :apartment, :string
|
|
263
279
|
end
|
|
264
280
|
end
|
|
265
281
|
|
|
@@ -270,18 +286,21 @@ form.valid?
|
|
|
270
286
|
|
|
271
287
|
### What you get out of the box
|
|
272
288
|
|
|
273
|
-
|
|
289
|
+
**`field` / `field?`** — required and optional fields with auto-presence validation, `desc:` metadata, and defaults. Backed by ActiveModel attributes with type casting and normalization. Unconditional `validates :attr, presence: true` deduplicates with `field`; scoped validations still layer on top.
|
|
274
290
|
|
|
275
291
|
**Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
|
|
276
292
|
|
|
277
293
|
```ruby
|
|
278
294
|
nested_many :emergency_contacts do
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
validates :name, :phone, presence: true
|
|
295
|
+
field :name, :string
|
|
296
|
+
field :phone, :string
|
|
282
297
|
end
|
|
283
298
|
```
|
|
284
299
|
|
|
300
|
+
**Ambient context** — auto-fill fields from `Dex.context`, same DSL as Operation and Event.
|
|
301
|
+
|
|
302
|
+
**Registry & Export** — `description`, `to_json_schema`, class-level `to_h`, and `Dex::Form.export` for schema introspection. Nested form schemas recurse in both export formats, and bulk export includes only top-level named forms.
|
|
303
|
+
|
|
285
304
|
**Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
|
|
286
305
|
|
|
287
306
|
**Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
|
|
@@ -305,11 +324,16 @@ Declarative query objects for filtering and sorting ActiveRecord and Mongoid sco
|
|
|
305
324
|
|
|
306
325
|
```ruby
|
|
307
326
|
class Order::Query < Dex::Query
|
|
327
|
+
description "Search orders"
|
|
328
|
+
|
|
308
329
|
scope { Order.all }
|
|
309
330
|
|
|
310
331
|
prop? :status, String
|
|
311
332
|
prop? :customer, _Ref(Customer)
|
|
312
333
|
prop? :total_min, Integer
|
|
334
|
+
prop? :tenant, String
|
|
335
|
+
|
|
336
|
+
context tenant: :current_tenant
|
|
313
337
|
|
|
314
338
|
filter :status
|
|
315
339
|
filter :customer
|
|
@@ -323,6 +347,10 @@ orders = Order::Query.call(status: "pending", sort: "-total")
|
|
|
323
347
|
|
|
324
348
|
### What you get out of the box
|
|
325
349
|
|
|
350
|
+
**Registry, description, and context** — same ecosystem as Operation, Event, and Form. `Dex::Query.registry` discovers all query classes, `description` documents intent, and `context` auto-fills props from `Dex.with_context`.
|
|
351
|
+
|
|
352
|
+
**Export** — `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)` for introspection and bulk export.
|
|
353
|
+
|
|
326
354
|
**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.
|
|
327
355
|
|
|
328
356
|
**Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
dexkit (0.
|
|
4
|
+
dexkit (0.10.0)
|
|
5
5
|
activemodel (>= 6.1)
|
|
6
6
|
literal (~> 1.9)
|
|
7
7
|
zeitwerk (~> 2.6)
|
|
@@ -180,7 +180,7 @@ CHECKSUMS
|
|
|
180
180
|
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
|
181
181
|
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
|
|
182
182
|
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
|
|
183
|
-
dexkit (0.
|
|
183
|
+
dexkit (0.10.0)
|
|
184
184
|
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
185
185
|
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
|
|
186
186
|
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
|
data/guides/llm/EVENT.md
CHANGED
|
@@ -30,9 +30,9 @@ class UserCreated < Dex::Event
|
|
|
30
30
|
end
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
|
|
33
|
+
Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `event_ancestry`, `context`, `publish`, `metadata`, `sync`.
|
|
34
34
|
|
|
35
|
-
Events are frozen after creation. Each gets auto-generated `id` (
|
|
35
|
+
Events are frozen after creation. Each gets auto-generated `id` (`ev_...`), `timestamp` (UTC), `trace_id` (`tr_...` by default), optional `caused_by_id`, and `event_ancestry` (ordered ancestor event IDs).
|
|
36
36
|
|
|
37
37
|
### Literal Types Cheatsheet
|
|
38
38
|
|
|
@@ -72,10 +72,11 @@ class NotifyWarehouse < Dex::Event::Handler
|
|
|
72
72
|
def perform
|
|
73
73
|
event # accessor — the event instance
|
|
74
74
|
event.order_id # typed props
|
|
75
|
-
event.id #
|
|
75
|
+
event.id # prefixed event ID (ev_...)
|
|
76
76
|
event.timestamp # Time (UTC)
|
|
77
77
|
event.caused_by_id # parent event ID (if traced)
|
|
78
|
-
event.trace_id # shared trace
|
|
78
|
+
event.trace_id # shared trace / correlation ID
|
|
79
|
+
event.event_ancestry # ordered ancestor event IDs
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
```
|
|
@@ -179,22 +180,20 @@ Default handler pipeline: `[:transaction, :callback]`.
|
|
|
179
180
|
|
|
180
181
|
## Tracing (Causality)
|
|
181
182
|
|
|
182
|
-
|
|
183
|
+
Events participate in the unified `Dex::Trace` used by operations and handlers. All events in a trace share the same `trace_id`.
|
|
183
184
|
|
|
184
185
|
```ruby
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
order_placed.trace do
|
|
189
|
-
InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
|
|
190
|
-
ShippingRequested.publish(order_id: 1) # same trace_id
|
|
186
|
+
# Start a trace at the request/job boundary
|
|
187
|
+
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
188
|
+
OrderPlaced.publish(order_id: 1, total: 99.99)
|
|
191
189
|
end
|
|
192
190
|
|
|
193
|
-
#
|
|
191
|
+
# Explicit event-to-event causality
|
|
192
|
+
order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
194
193
|
InventoryReserved.publish(order_id: 1, caused_by: order_placed)
|
|
195
194
|
```
|
|
196
195
|
|
|
197
|
-
|
|
196
|
+
When an event is published inside a handler, the handler's event becomes the cause automatically. Explicit `caused_by:` sets `caused_by_id` and appends to `event_ancestry`.
|
|
198
197
|
|
|
199
198
|
---
|
|
200
199
|
|
|
@@ -218,12 +217,16 @@ Store events to database when configured:
|
|
|
218
217
|
|
|
219
218
|
```ruby
|
|
220
219
|
Dex.configure do |c|
|
|
221
|
-
c.event_store = EventRecord #
|
|
220
|
+
c.event_store = EventRecord # Dex passes trace fields when the model supports them
|
|
222
221
|
end
|
|
223
222
|
```
|
|
224
223
|
|
|
225
224
|
```ruby
|
|
226
|
-
create_table :event_records do |t|
|
|
225
|
+
create_table :event_records, id: :string do |t|
|
|
226
|
+
t.string :trace_id
|
|
227
|
+
t.string :actor_type
|
|
228
|
+
t.string :actor_id
|
|
229
|
+
t.jsonb :trace
|
|
227
230
|
t.string :event_type
|
|
228
231
|
t.jsonb :payload
|
|
229
232
|
t.jsonb :metadata
|
|
@@ -238,6 +241,11 @@ class EventRecord
|
|
|
238
241
|
include Mongoid::Document
|
|
239
242
|
include Mongoid::Timestamps
|
|
240
243
|
|
|
244
|
+
field :_id, type: String
|
|
245
|
+
field :trace_id, type: String
|
|
246
|
+
field :actor_type, type: String
|
|
247
|
+
field :actor_id, type: String
|
|
248
|
+
field :trace, type: Array
|
|
241
249
|
field :event_type, type: String
|
|
242
250
|
field :payload, type: Hash
|
|
243
251
|
field :metadata, type: Hash
|
|
@@ -381,10 +389,7 @@ class CreateOrderTest < Minitest::Test
|
|
|
381
389
|
def test_trace_chain
|
|
382
390
|
capture_events do
|
|
383
391
|
parent = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
384
|
-
|
|
385
|
-
parent.trace do
|
|
386
|
-
InventoryReserved.publish(order_id: 1)
|
|
387
|
-
end
|
|
392
|
+
InventoryReserved.publish(order_id: 1, caused_by: parent)
|
|
388
393
|
|
|
389
394
|
child = _dex_published_events.last
|
|
390
395
|
assert_event_trace(parent, child)
|
data/guides/llm/FORM.md
CHANGED
|
@@ -10,58 +10,80 @@ All examples below build on this form unless noted otherwise:
|
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
class OnboardingForm < Dex::Form
|
|
13
|
+
description "Employee onboarding"
|
|
13
14
|
model User
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
field :first_name, :string
|
|
17
|
+
field :last_name, :string
|
|
18
|
+
field :email, :string
|
|
19
|
+
field :department, :string
|
|
20
|
+
field :start_date, :date
|
|
21
|
+
field :locale, :string
|
|
22
|
+
field? :notes, :string
|
|
23
|
+
|
|
24
|
+
context :locale
|
|
20
25
|
|
|
21
26
|
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
22
27
|
|
|
23
|
-
validates :email,
|
|
24
|
-
validates :first_name, :last_name, :department, presence: true
|
|
25
|
-
validates :start_date, presence: true
|
|
28
|
+
validates :email, uniqueness: true
|
|
26
29
|
|
|
27
30
|
nested_one :address do
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
validates :street, :city, :country, presence: true
|
|
31
|
+
field :street, :string
|
|
32
|
+
field :city, :string
|
|
33
|
+
field :postal_code, :string
|
|
34
|
+
field :country, :string
|
|
35
|
+
field? :apartment, :string
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
nested_many :documents do
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
validates :document_type, :document_number, presence: true
|
|
39
|
+
field :document_type, :string
|
|
40
|
+
field :document_number, :string
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Declaring Fields
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
### `field` — required
|
|
50
|
+
|
|
51
|
+
Declares a required field. Auto-adds presence validation. Unconditional `validates :attr, presence: true` deduplicates with it; scoped or conditional presence validators do not make the field optional outside those cases.
|
|
50
52
|
|
|
51
53
|
```ruby
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
field :name, :string
|
|
55
|
+
field :email, :string, desc: "Work email"
|
|
56
|
+
field :currency, :string, default: "USD"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `field?` — optional
|
|
60
|
+
|
|
61
|
+
Declares an optional field. Defaults to `nil` unless overridden.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
field? :notes, :string
|
|
65
|
+
field? :priority, :integer, default: 0
|
|
59
66
|
```
|
|
60
67
|
|
|
68
|
+
### Options
|
|
69
|
+
|
|
70
|
+
| Option | Description |
|
|
71
|
+
|--------|-------------|
|
|
72
|
+
| `desc:` | Human-readable description (for introspection and JSON Schema) |
|
|
73
|
+
| `default:` | Default value (forwarded to ActiveModel) |
|
|
74
|
+
|
|
61
75
|
### Available types
|
|
62
76
|
|
|
63
77
|
`:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
|
|
64
78
|
|
|
79
|
+
### `attribute` escape hatch
|
|
80
|
+
|
|
81
|
+
Raw ActiveModel `attribute` is still available. Not tracked in field registry, no auto-presence, not in exports.
|
|
82
|
+
|
|
83
|
+
### Boolean fields
|
|
84
|
+
|
|
85
|
+
`field :active, :boolean` checks for `nil` (not `blank?`), so `false` is valid.
|
|
86
|
+
|
|
65
87
|
### `model(klass)`
|
|
66
88
|
|
|
67
89
|
Declares the backing model class. Used by:
|
|
@@ -93,11 +115,11 @@ normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
|
|
|
93
115
|
|
|
94
116
|
## Validation
|
|
95
117
|
|
|
96
|
-
Full ActiveModel validation DSL:
|
|
118
|
+
Full ActiveModel validation DSL. Required fields auto-validate presence — no need to add `validates :name, presence: true` when using `field :name, :string`.
|
|
97
119
|
|
|
98
120
|
```ruby
|
|
99
|
-
validates :email,
|
|
100
|
-
validates :name,
|
|
121
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
122
|
+
validates :name, length: { minimum: 2, maximum: 100 }
|
|
101
123
|
validates :role, inclusion: { in: %w[admin user] }
|
|
102
124
|
validates :email, uniqueness: true # checks database
|
|
103
125
|
validate :custom_validation_method
|
|
@@ -111,6 +133,15 @@ form.errors[:email] # => ["can't be blank"]
|
|
|
111
133
|
form.errors.full_messages # => ["Email can't be blank"]
|
|
112
134
|
```
|
|
113
135
|
|
|
136
|
+
### Contextual requirements
|
|
137
|
+
|
|
138
|
+
Use `field?` with an explicit validation context:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
field? :published_at, :datetime
|
|
142
|
+
validates :published_at, presence: true, on: :publish
|
|
143
|
+
```
|
|
144
|
+
|
|
114
145
|
### ValidationError
|
|
115
146
|
|
|
116
147
|
```ruby
|
|
@@ -121,6 +152,94 @@ error.form # => the form instance
|
|
|
121
152
|
|
|
122
153
|
---
|
|
123
154
|
|
|
155
|
+
## Ambient Context
|
|
156
|
+
|
|
157
|
+
Auto-fill fields from `Dex.context` – same DSL as Operation and Event:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class Order::Form < Dex::Form
|
|
161
|
+
field :locale, :string
|
|
162
|
+
field :currency, :string
|
|
163
|
+
|
|
164
|
+
context :locale # shorthand: field name = context key
|
|
165
|
+
context currency: :default_currency # explicit: field name → context key
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Dex.with_context(locale: "en", default_currency: "USD") do
|
|
169
|
+
form = Order::Form.new
|
|
170
|
+
form.locale # => "en"
|
|
171
|
+
form.currency # => "USD"
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Explicit values always win. Context references must point to declared fields or attributes.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Registry & Export
|
|
180
|
+
|
|
181
|
+
### Description
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class OnboardingForm < Dex::Form
|
|
185
|
+
description "Employee onboarding"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
OnboardingForm.description # => "Employee onboarding"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Registry
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Dex::Form.registry # => #<Set: {OnboardingForm, ...}>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Class-level `to_h`
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
OnboardingForm.to_h
|
|
201
|
+
# => {
|
|
202
|
+
# name: "OnboardingForm",
|
|
203
|
+
# description: "Employee onboarding",
|
|
204
|
+
# fields: {
|
|
205
|
+
# first_name: { type: :string, required: true },
|
|
206
|
+
# email: { type: :string, required: true },
|
|
207
|
+
# notes: { type: :string, required: false },
|
|
208
|
+
# ...
|
|
209
|
+
# },
|
|
210
|
+
# nested: {
|
|
211
|
+
# address: { type: :one, fields: { ... }, nested: { ... } },
|
|
212
|
+
# documents: { type: :many, fields: { ... } }
|
|
213
|
+
# }
|
|
214
|
+
# }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### `to_json_schema`
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
OnboardingForm.to_json_schema
|
|
221
|
+
# => {
|
|
222
|
+
# "$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
223
|
+
# type: "object",
|
|
224
|
+
# title: "OnboardingForm",
|
|
225
|
+
# description: "Employee onboarding",
|
|
226
|
+
# properties: { email: { type: "string" }, ... },
|
|
227
|
+
# required: ["first_name", "last_name", "email", ...],
|
|
228
|
+
# additionalProperties: false
|
|
229
|
+
# }
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Global export
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Dex::Form.export(format: :json_schema)
|
|
236
|
+
# => [{ ... OnboardingForm schema ... }, ...]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Bulk export returns top-level named forms only. Nested helper classes generated by `nested_one` and `nested_many` stay embedded in their parent export instead of appearing as separate entries.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
124
243
|
## Nested Forms
|
|
125
244
|
|
|
126
245
|
### `nested_one`
|
|
@@ -129,9 +248,9 @@ One-to-one nested form. Automatically coerces Hash input.
|
|
|
129
248
|
|
|
130
249
|
```ruby
|
|
131
250
|
nested_one :address do
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
251
|
+
field :street, :string
|
|
252
|
+
field :city, :string
|
|
253
|
+
field? :apartment, :string
|
|
135
254
|
end
|
|
136
255
|
```
|
|
137
256
|
|
|
@@ -152,9 +271,8 @@ One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
|
|
|
152
271
|
|
|
153
272
|
```ruby
|
|
154
273
|
nested_many :documents do
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
validates :document_type, :document_number, presence: true
|
|
274
|
+
field :document_type, :string
|
|
275
|
+
field :document_number, :string
|
|
158
276
|
end
|
|
159
277
|
```
|
|
160
278
|
|
|
@@ -190,7 +308,7 @@ Override the auto-generated constant name:
|
|
|
190
308
|
|
|
191
309
|
```ruby
|
|
192
310
|
nested_one :address, class_name: "HomeAddress" do
|
|
193
|
-
|
|
311
|
+
field :street, :string
|
|
194
312
|
end
|
|
195
313
|
# Creates MyForm::HomeAddress instead of MyForm::Address
|
|
196
314
|
```
|
|
@@ -306,7 +424,7 @@ form.to_hash # alias for to_h
|
|
|
306
424
|
|
|
307
425
|
### Controller pattern
|
|
308
426
|
|
|
309
|
-
Strong parameters (`permit`) are not required — the form's
|
|
427
|
+
Strong parameters (`permit`) are not required — the form's field declarations are the whitelist. Just `require` the top-level key:
|
|
310
428
|
|
|
311
429
|
```ruby
|
|
312
430
|
class OnboardingController < ApplicationController
|
|
@@ -405,33 +523,34 @@ A form spanning User, Employee, and Address — the core reason form objects exi
|
|
|
405
523
|
|
|
406
524
|
```ruby
|
|
407
525
|
class OnboardingForm < Dex::Form
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
526
|
+
description "Employee onboarding"
|
|
527
|
+
|
|
528
|
+
field :first_name, :string
|
|
529
|
+
field :last_name, :string
|
|
530
|
+
field :email, :string
|
|
531
|
+
field :department, :string
|
|
532
|
+
field :position, :string
|
|
533
|
+
field :start_date, :date
|
|
534
|
+
field :locale, :string
|
|
535
|
+
field? :notes, :string
|
|
536
|
+
|
|
537
|
+
context :locale
|
|
414
538
|
|
|
415
539
|
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
416
540
|
|
|
417
|
-
validates :email,
|
|
418
|
-
validates :first_name, :last_name, :department, :position, presence: true
|
|
419
|
-
validates :start_date, presence: true
|
|
541
|
+
validates :email, uniqueness: { model: User }
|
|
420
542
|
|
|
421
543
|
nested_one :address do
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
validates :street, :city, :country, presence: true
|
|
544
|
+
field :street, :string
|
|
545
|
+
field :city, :string
|
|
546
|
+
field :postal_code, :string
|
|
547
|
+
field :country, :string
|
|
548
|
+
field? :apartment, :string
|
|
428
549
|
end
|
|
429
550
|
|
|
430
551
|
nested_many :documents do
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
validates :document_type, :document_number, presence: true
|
|
552
|
+
field :document_type, :string
|
|
553
|
+
field :document_number, :string
|
|
435
554
|
end
|
|
436
555
|
|
|
437
556
|
def self.for(user)
|
|
@@ -484,23 +603,38 @@ Forms are standard ActiveModel objects. Test them with plain Minitest — no spe
|
|
|
484
603
|
|
|
485
604
|
```ruby
|
|
486
605
|
class OnboardingFormTest < Minitest::Test
|
|
487
|
-
def
|
|
606
|
+
def test_required_fields_validated
|
|
488
607
|
form = OnboardingForm.new
|
|
489
608
|
assert form.invalid?
|
|
490
609
|
assert form.errors[:email].any?
|
|
491
610
|
assert form.errors[:first_name].any?
|
|
492
611
|
end
|
|
493
612
|
|
|
613
|
+
def test_optional_fields_allowed_blank
|
|
614
|
+
form = OnboardingForm.new(
|
|
615
|
+
first_name: "Alice", last_name: "Smith",
|
|
616
|
+
email: "alice@example.com", department: "Eng",
|
|
617
|
+
position: "Dev", start_date: Date.today, locale: "en"
|
|
618
|
+
)
|
|
619
|
+
assert form.valid?
|
|
620
|
+
assert_nil form.notes
|
|
621
|
+
end
|
|
622
|
+
|
|
494
623
|
def test_normalizes_email
|
|
495
624
|
form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
|
|
496
625
|
assert_equal "alice@example.com", form.email
|
|
497
626
|
end
|
|
498
627
|
|
|
628
|
+
def test_context_fills_locale
|
|
629
|
+
form = Dex.with_context(locale: "en") { OnboardingForm.new }
|
|
630
|
+
assert_equal "en", form.locale
|
|
631
|
+
end
|
|
632
|
+
|
|
499
633
|
def test_nested_validation_propagation
|
|
500
634
|
form = OnboardingForm.new(
|
|
501
635
|
first_name: "Alice", last_name: "Smith",
|
|
502
636
|
email: "alice@example.com", department: "Eng",
|
|
503
|
-
position: "Developer", start_date: Date.today,
|
|
637
|
+
position: "Developer", start_date: Date.today, locale: "en",
|
|
504
638
|
address: { street: "", city: "", country: "" }
|
|
505
639
|
)
|
|
506
640
|
assert form.invalid?
|
|
@@ -516,5 +650,12 @@ class OnboardingFormTest < Minitest::Test
|
|
|
516
650
|
assert_equal "Alice", h[:first_name]
|
|
517
651
|
assert_equal "123 Main", h[:address][:street]
|
|
518
652
|
end
|
|
653
|
+
|
|
654
|
+
def test_json_schema_export
|
|
655
|
+
schema = OnboardingForm.to_json_schema
|
|
656
|
+
assert_equal "object", schema[:type]
|
|
657
|
+
assert_includes schema[:required], "first_name"
|
|
658
|
+
refute_includes schema[:required], "notes"
|
|
659
|
+
end
|
|
519
660
|
end
|
|
520
661
|
```
|