dexkit 0.7.0 → 0.9.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 +45 -0
- data/README.md +40 -7
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +60 -5
- data/guides/llm/FORM.md +3 -3
- data/guides/llm/OPERATION.md +127 -18
- data/guides/llm/QUERY.md +3 -3
- data/lib/dex/event/bus.rb +7 -0
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event.rb +27 -0
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/operation/async_proxy.rb +1 -0
- data/lib/dex/operation/explain.rb +208 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +15 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +23 -15
- data/lib/dex/operation/record_backend.rb +25 -0
- data/lib/dex/operation/record_wrapper.rb +29 -4
- data/lib/dex/operation/test_helpers/assertions.rb +335 -0
- data/lib/dex/operation/test_helpers/execution.rb +30 -0
- data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
- data/lib/dex/operation/test_helpers.rb +150 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +46 -2
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query.rb +9 -5
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +11 -5
- metadata +16 -5
- data/lib/dex/test_helpers/assertions.rb +0 -333
- data/lib/dex/test_helpers/execution.rb +0 -28
- data/lib/dex/test_helpers/stubbing.rb +0 -59
- /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
data/guides/llm/OPERATION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Operation — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/operations/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -95,7 +95,7 @@ prop? :note, String # optional (nilable, default: nil)
|
|
|
95
95
|
|
|
96
96
|
### _Ref(Model)
|
|
97
97
|
|
|
98
|
-
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only.
|
|
98
|
+
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE) – requires a model that responds to `.lock` (ActiveRecord). Mongoid documents do not support row locks and raise `ArgumentError` at declaration time. Instances pass through without re-locking. In serialization (recording, async), stores model ID only via `id.as_json`, so Mongoid BSON::ObjectId values are safe in ActiveJob payloads too. IDs are treated as strings in JSON Schema – this supports integer PKs, UUIDs, and Mongoid BSON::ObjectId equally.
|
|
99
99
|
|
|
100
100
|
Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
|
|
101
101
|
|
|
@@ -234,6 +234,51 @@ result.details # => [{ guard: :unauthorized, message: "..." }, ...]
|
|
|
234
234
|
|
|
235
235
|
---
|
|
236
236
|
|
|
237
|
+
## Explain
|
|
238
|
+
|
|
239
|
+
Full preflight check — resolves context, coerces props, evaluates guards, computes derived keys, reports settings. No side effects, `perform` never runs.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Returns a frozen Hash:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
249
|
+
# => {
|
|
250
|
+
# operation: "Order::Place",
|
|
251
|
+
# props: { product: #<Product>, customer: #<Customer>, quantity: 2 },
|
|
252
|
+
# context: {
|
|
253
|
+
# resolved: { customer: #<Customer> },
|
|
254
|
+
# mappings: { customer: :current_customer },
|
|
255
|
+
# source: { customer: :ambient } # :ambient, :explicit, or :default
|
|
256
|
+
# },
|
|
257
|
+
# guards: {
|
|
258
|
+
# passed: true,
|
|
259
|
+
# results: [{ name: :out_of_stock, passed: true }, ...]
|
|
260
|
+
# },
|
|
261
|
+
# once: { active: true, key: "Order::Place/product_id=7", status: :fresh, expires_in: nil },
|
|
262
|
+
# lock: { active: true, key: "order:7", timeout: nil },
|
|
263
|
+
# record: { enabled: true, params: true, result: true },
|
|
264
|
+
# transaction: { enabled: true },
|
|
265
|
+
# rescue_from: { "Stripe::CardError" => :card_declined },
|
|
266
|
+
# callbacks: { before: 1, after: 2, around: 0 },
|
|
267
|
+
# pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
|
|
268
|
+
# callable: true
|
|
269
|
+
# }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- Invalid props (`Literal::TypeError`, `ArgumentError`) return a partial result with `info[:error]` — class-level info still available, instance-dependent sections degrade to empty/nil. Static lock keys preserved. Context source uses `:missing` for props without defaults. Other errors propagate normally
|
|
273
|
+
- `info[:callable]` is a full preflight verdict — checks guards AND once blocking statuses; always `false` when props are invalid
|
|
274
|
+
- Once status: `:fresh` (new), `:exists` (would replay), `:expired`, `:pending` (in-flight), `:invalid` (nil key), `:misconfigured` (anonymous op, missing record step, missing column), `:unavailable` (no backend)
|
|
275
|
+
- Guard results include `message:` on failures and `skipped: true` when a guard was skipped via `requires:` dependency
|
|
276
|
+
- Custom middleware can contribute via `_name_explain(instance, info)` class methods
|
|
277
|
+
|
|
278
|
+
**Use cases:** console debugging, admin tooling, LLM agent preflight, test assertions.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
237
282
|
## Flow Control
|
|
238
283
|
|
|
239
284
|
All three halt execution immediately via non-local exit (work from `perform`, helpers, and callbacks).
|
|
@@ -363,11 +408,11 @@ end
|
|
|
363
408
|
|
|
364
409
|
## Transactions
|
|
365
410
|
|
|
366
|
-
Operations run inside database transactions
|
|
411
|
+
Operations run inside database transactions when Dex has an active transaction adapter. ActiveRecord is auto-detected. In Mongoid-only apps, no adapter is active, so transactions are automatically disabled – but `after_commit` still works (callbacks fire immediately after success). If you need Mongoid transactions, use `Mongoid.transaction` directly inside `perform`.
|
|
367
412
|
|
|
368
413
|
```ruby
|
|
369
414
|
transaction false # disable
|
|
370
|
-
transaction :
|
|
415
|
+
transaction :active_record # explicit adapter
|
|
371
416
|
```
|
|
372
417
|
|
|
373
418
|
Child classes can re-enable: `transaction true`.
|
|
@@ -388,7 +433,7 @@ end
|
|
|
388
433
|
Callbacks are always deferred — they run after the outermost operation boundary succeeds:
|
|
389
434
|
|
|
390
435
|
- **Transactional operations:** deferred until the DB transaction commits.
|
|
391
|
-
- **Non-transactional operations:** queued in memory, flushed after the operation pipeline completes successfully.
|
|
436
|
+
- **Non-transactional operations (including Mongoid-only):** queued in memory, flushed after the operation pipeline completes successfully.
|
|
392
437
|
- **Nested operations:** callbacks queue up and flush once at the outermost successful boundary.
|
|
393
438
|
- **On error (`error!` or exception):** queued callbacks are discarded.
|
|
394
439
|
|
|
@@ -396,13 +441,11 @@ Multiple blocks run in registration order.
|
|
|
396
441
|
|
|
397
442
|
**ActiveRecord:** requires Rails 7.2+ (`after_all_transactions_commit`).
|
|
398
443
|
|
|
399
|
-
**Mongoid:** deferred across nested Dex operations. Ambient `Mongoid.transaction` blocks opened outside Dex are not detected — callbacks will fire immediately in that case.
|
|
400
|
-
|
|
401
444
|
---
|
|
402
445
|
|
|
403
446
|
## Advisory Locking
|
|
404
447
|
|
|
405
|
-
Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction.
|
|
448
|
+
Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction. ActiveRecord-only; Mongoid-only apps get a clear `LoadError`.
|
|
406
449
|
|
|
407
450
|
```ruby
|
|
408
451
|
advisory_lock { "pay:#{charge_id}" } # dynamic key from props
|
|
@@ -465,7 +508,17 @@ record result: false # params only
|
|
|
465
508
|
record params: false # result only
|
|
466
509
|
```
|
|
467
510
|
|
|
468
|
-
All outcomes are recorded — success (`completed`), business errors (`error`), and exceptions (`failed`). Recording runs outside the operation's own transaction so error records survive its rollbacks. Records still participate in ambient transactions (e.g., an outer operation's transaction).
|
|
511
|
+
All outcomes are recorded — success (`completed`), business errors (`error`), and exceptions (`failed`). Recording runs outside the operation's own transaction so error records survive its rollbacks. Records still participate in ambient transactions (e.g., an outer operation's transaction). Dex validates the configured record model before use and raises if required attributes are missing.
|
|
512
|
+
|
|
513
|
+
Required attributes by feature:
|
|
514
|
+
|
|
515
|
+
- Core recording: `name`, `status`, `error_code`, `error_message`, `error_details`, `performed_at`
|
|
516
|
+
- Params capture: `params` unless `record params: false`
|
|
517
|
+
- Result capture: `result` unless `record result: false`
|
|
518
|
+
- Async record jobs: `params`
|
|
519
|
+
- `once`: `once_key`, plus `once_key_expires_at` when `expires_in:` is used
|
|
520
|
+
|
|
521
|
+
Untyped results are sanitized to JSON-safe values before persistence: Hash keys round-trip as strings, and objects fall back to `as_json`/`to_s` under `"_dex_value"`.
|
|
469
522
|
|
|
470
523
|
Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
|
|
471
524
|
|
|
@@ -529,7 +582,7 @@ Clearing is idempotent — clearing a non-existent key is a no-op. After clearin
|
|
|
529
582
|
**Requirements:**
|
|
530
583
|
|
|
531
584
|
- Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
|
|
532
|
-
- The record
|
|
585
|
+
- The record backend must satisfy the Recording requirements above, and `once` additionally requires `once_key` plus `once_key_expires_at` when `expires_in:` is used
|
|
533
586
|
- `once` cannot be declared with `record false` — raises `ArgumentError`
|
|
534
587
|
- Only one `once` declaration per operation
|
|
535
588
|
|
|
@@ -541,11 +594,10 @@ Clearing is idempotent — clearing a non-existent key is a no-op. After clearin
|
|
|
541
594
|
# config/initializers/dexkit.rb
|
|
542
595
|
Dex.configure do |config|
|
|
543
596
|
config.record_class = OperationRecord # model for recording (default: nil)
|
|
544
|
-
config.transaction_adapter = nil # auto-detect (default); or :active_record / :mongoid
|
|
545
597
|
end
|
|
546
598
|
```
|
|
547
599
|
|
|
548
|
-
All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`).
|
|
600
|
+
All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`). Only `:active_record` is a valid transaction adapter.
|
|
549
601
|
|
|
550
602
|
---
|
|
551
603
|
|
|
@@ -553,22 +605,20 @@ All DSL methods validate arguments at declaration time — typos and wrong types
|
|
|
553
605
|
|
|
554
606
|
```ruby
|
|
555
607
|
# test/test_helper.rb
|
|
556
|
-
require "dex/test_helpers"
|
|
608
|
+
require "dex/operation/test_helpers"
|
|
557
609
|
|
|
558
610
|
class Minitest::Test
|
|
559
|
-
include Dex::TestHelpers
|
|
611
|
+
include Dex::Operation::TestHelpers
|
|
560
612
|
end
|
|
561
613
|
```
|
|
562
614
|
|
|
563
615
|
Not autoloaded — stays out of production. TestLog and stubs are auto-cleared in `setup`.
|
|
564
616
|
|
|
565
|
-
For Mongoid-backed operation tests, run against a MongoDB replica set (MongoDB transactions require it).
|
|
566
|
-
|
|
567
617
|
### Subject & Execution
|
|
568
618
|
|
|
569
619
|
```ruby
|
|
570
620
|
class CreateUserTest < Minitest::Test
|
|
571
|
-
include Dex::TestHelpers
|
|
621
|
+
include Dex::Operation::TestHelpers
|
|
572
622
|
|
|
573
623
|
testing CreateUser # default for all helpers
|
|
574
624
|
|
|
@@ -733,7 +783,7 @@ Each entry has: `name`, `operation_class`, `params`, `result` (Ok/Err), `duratio
|
|
|
733
783
|
|
|
734
784
|
```ruby
|
|
735
785
|
class CreateUserTest < Minitest::Test
|
|
736
|
-
include Dex::TestHelpers
|
|
786
|
+
include Dex::Operation::TestHelpers
|
|
737
787
|
|
|
738
788
|
testing CreateUser
|
|
739
789
|
|
|
@@ -780,4 +830,63 @@ end
|
|
|
780
830
|
|
|
781
831
|
---
|
|
782
832
|
|
|
833
|
+
## Registry, Export & Description
|
|
834
|
+
|
|
835
|
+
### Description
|
|
836
|
+
|
|
837
|
+
Operations can declare a human-readable description. Props can include `desc:`:
|
|
838
|
+
|
|
839
|
+
```ruby
|
|
840
|
+
class Order::Place < Dex::Operation
|
|
841
|
+
description "Places a new order, charges payment, and schedules fulfillment"
|
|
842
|
+
|
|
843
|
+
prop :product, _Ref(Product), desc: "Product to order"
|
|
844
|
+
prop :quantity, _Integer(1..), desc: "Number of units"
|
|
845
|
+
end
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Descriptions appear in `contract.to_h`, `to_json_schema`, `explain`, and LLM tool definitions.
|
|
849
|
+
|
|
850
|
+
### Registry
|
|
851
|
+
|
|
852
|
+
```ruby
|
|
853
|
+
Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
|
|
854
|
+
Dex::Operation.deregister(klass) # remove from registry (useful in tests)
|
|
855
|
+
Dex::Operation.clear! # empty the registry
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
Only named, reachable classes are included. Anonymous classes and stale objects from code reloads are excluded. Populates lazily via `inherited` — in Rails, `eager_load!` to get the full list.
|
|
859
|
+
|
|
860
|
+
### Export
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
Order::Place.contract.to_h
|
|
864
|
+
# => { name: "Order::Place", description: "...", params: { product: { type: "Ref(Product)", required: true, desc: "..." } }, ... }
|
|
865
|
+
|
|
866
|
+
Order::Place.contract.to_json_schema # params input schema (default)
|
|
867
|
+
Order::Place.contract.to_json_schema(section: :success) # success return schema
|
|
868
|
+
Order::Place.contract.to_json_schema(section: :errors) # error catalog schema
|
|
869
|
+
Order::Place.contract.to_json_schema(section: :full) # everything
|
|
870
|
+
|
|
871
|
+
Dex::Operation.export # all operations as hashes
|
|
872
|
+
Dex::Operation.export(format: :json_schema) # all as JSON Schema
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### LLM Tools (ruby-llm integration)
|
|
876
|
+
|
|
877
|
+
```ruby
|
|
878
|
+
chat = RubyLLM.chat
|
|
879
|
+
chat.with_tools(*Dex::Tool.all) # all operations as tools
|
|
880
|
+
chat.with_tools(*Dex::Tool.from_namespace("Order")) # namespace filter
|
|
881
|
+
chat.with_tools(Dex::Tool.explain_tool) # preflight check tool
|
|
882
|
+
|
|
883
|
+
Dex.with_context(current_user: user) do
|
|
884
|
+
chat.ask("Place an order for 2 units of product #42")
|
|
885
|
+
end
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
783
892
|
**End of reference.**
|
data/guides/llm/QUERY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Query — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/queries/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -91,7 +91,7 @@ Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_para
|
|
|
91
91
|
| `:in` | `IN (values)` | `filter :roles, :in, column: :role` |
|
|
92
92
|
| `:not_in` | `NOT IN (values)` | `filter :roles, :not_in, column: :role` |
|
|
93
93
|
|
|
94
|
-
String strategies (`:contains`, `:starts_with`, `:ends_with`) use case-insensitive matching. With ActiveRecord, this uses Arel `matches` (LIKE); with Mongoid, case-insensitive regex. Wildcards in values are auto-sanitized. The adapter is auto-detected from the scope.
|
|
94
|
+
String strategies (`:contains`, `:starts_with`, `:ends_with`) use case-insensitive matching. With ActiveRecord, this uses Arel `matches` (LIKE); with Mongoid, case-insensitive regex. Wildcards in values are auto-sanitized. The adapter is auto-detected from the scope, and Mongoid association scopes/proxies are normalized to `Mongoid::Criteria` automatically.
|
|
95
95
|
|
|
96
96
|
### Column Mapping
|
|
97
97
|
|
|
@@ -159,7 +159,7 @@ Only one default per class. Applied when no sort is provided.
|
|
|
159
159
|
|
|
160
160
|
### `.call`
|
|
161
161
|
|
|
162
|
-
Returns a queryable scope (`ActiveRecord::Relation` or `Mongoid::Criteria`):
|
|
162
|
+
Returns a queryable scope (`ActiveRecord::Relation` or `Mongoid::Criteria`). Association scopes like `current_user.posts` work too:
|
|
163
163
|
|
|
164
164
|
```ruby
|
|
165
165
|
users = UserSearch.call(name: "ali", role: %w[admin], sort: "-name")
|
data/lib/dex/event/bus.rb
CHANGED
|
@@ -81,6 +81,7 @@ module Dex
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def enqueue(handler_class, event, trace_data)
|
|
84
|
+
ensure_active_job_loaded!
|
|
84
85
|
ctx = event.context
|
|
85
86
|
|
|
86
87
|
Dex::Event::Processor.perform_later(
|
|
@@ -92,6 +93,12 @@ module Dex
|
|
|
92
93
|
context: ctx
|
|
93
94
|
)
|
|
94
95
|
end
|
|
96
|
+
|
|
97
|
+
def ensure_active_job_loaded!
|
|
98
|
+
return if defined?(ActiveJob::Base)
|
|
99
|
+
|
|
100
|
+
raise LoadError, "ActiveJob is required for async event handlers. Add 'activejob' to your Gemfile."
|
|
101
|
+
end
|
|
95
102
|
end
|
|
96
103
|
end
|
|
97
104
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source)
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source.name
|
|
11
|
+
desc = source.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:props] = _serialize_props(source)
|
|
14
|
+
h
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_json_schema(source)
|
|
18
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
19
|
+
properties = {}
|
|
20
|
+
required = []
|
|
21
|
+
|
|
22
|
+
if source.respond_to?(:literal_properties)
|
|
23
|
+
source.literal_properties.each do |prop|
|
|
24
|
+
prop_desc = descs[prop.name]
|
|
25
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
26
|
+
properties[prop.name.to_s] = schema
|
|
27
|
+
required << prop.name.to_s if prop.required?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
32
|
+
result[:title] = source.name if source.name
|
|
33
|
+
desc = source.description
|
|
34
|
+
result[:description] = desc if desc
|
|
35
|
+
result[:type] = "object"
|
|
36
|
+
result[:properties] = properties unless properties.empty?
|
|
37
|
+
result[:required] = required unless required.empty?
|
|
38
|
+
result[:additionalProperties] = false
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def _serialize_props(source)
|
|
43
|
+
return {} unless source.respond_to?(:literal_properties)
|
|
44
|
+
|
|
45
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
46
|
+
source.literal_properties.each_with_object({}) do |prop, hash|
|
|
47
|
+
entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
|
|
48
|
+
entry[:desc] = descs[prop.name] if descs[prop.name]
|
|
49
|
+
hash[prop.name] = entry
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method :_serialize_props
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/dex/event/handler.rb
CHANGED
|
@@ -5,15 +5,48 @@ module Dex
|
|
|
5
5
|
class Handler
|
|
6
6
|
include Dex::Executable
|
|
7
7
|
|
|
8
|
+
extend Registry
|
|
9
|
+
|
|
10
|
+
def self.deregister(klass)
|
|
11
|
+
if klass.respond_to?(:handled_events)
|
|
12
|
+
klass.handled_events.each { |ec| Bus.unsubscribe(ec, klass) }
|
|
13
|
+
end
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
attr_reader :event
|
|
9
18
|
|
|
10
19
|
def self.on(*event_classes)
|
|
11
20
|
event_classes.each do |ec|
|
|
12
21
|
Event.validate_event_class!(ec)
|
|
13
22
|
Bus.subscribe(ec, self)
|
|
23
|
+
(@_handled_events ||= []) << ec
|
|
14
24
|
end
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
def self.handled_events
|
|
28
|
+
defined?(@_handled_events) ? @_handled_events.dup.freeze : [].freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.to_h
|
|
32
|
+
h = {}
|
|
33
|
+
h[:name] = name if name
|
|
34
|
+
event_names = handled_events.filter_map(&:name)
|
|
35
|
+
h[:events] = event_names unless event_names.empty?
|
|
36
|
+
retry_config = _event_handler_retry_config
|
|
37
|
+
h[:retries] = retry_config[:count] if retry_config
|
|
38
|
+
tx_s = settings_for(:transaction)
|
|
39
|
+
h[:transaction] = tx_s.fetch(:enabled, false)
|
|
40
|
+
h[:pipeline] = pipeline.steps.map(&:name)
|
|
41
|
+
h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.export(format: :hash)
|
|
45
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash" unless format == :hash
|
|
46
|
+
|
|
47
|
+
registry.sort_by(&:name).map(&:to_h)
|
|
48
|
+
end
|
|
49
|
+
|
|
17
50
|
def self.retries(count, **opts)
|
|
18
51
|
raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
|
|
19
52
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module EventTestWrapper
|
|
6
|
+
CAPTURING_KEY = :_dex_event_capturing
|
|
7
|
+
PUBLISHED_KEY = :_dex_event_published
|
|
8
|
+
|
|
9
|
+
@_installed = false
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
include ExecutionState
|
|
13
|
+
|
|
14
|
+
def install!
|
|
15
|
+
return if @_installed
|
|
16
|
+
|
|
17
|
+
Dex::Event::Bus.singleton_class.prepend(BusInterceptor)
|
|
18
|
+
@_installed = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def installed?
|
|
22
|
+
@_installed
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def capturing?
|
|
26
|
+
(_execution_state[CAPTURING_KEY] || 0) > 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def begin_capture!
|
|
30
|
+
_execution_state[CAPTURING_KEY] = (_execution_state[CAPTURING_KEY] || 0) + 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def end_capture!
|
|
34
|
+
depth = (_execution_state[CAPTURING_KEY] || 0) - 1
|
|
35
|
+
_execution_state[CAPTURING_KEY] = [depth, 0].max
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def published_events
|
|
39
|
+
_execution_state[PUBLISHED_KEY] ||= []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_published!
|
|
43
|
+
_execution_state[PUBLISHED_KEY] = []
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module BusInterceptor
|
|
48
|
+
def publish(event, sync:)
|
|
49
|
+
if Dex::Event::EventTestWrapper.capturing?
|
|
50
|
+
return if Dex::Event::Suppression.suppressed?(event.class)
|
|
51
|
+
|
|
52
|
+
Dex::Event::EventTestWrapper.published_events << event
|
|
53
|
+
else
|
|
54
|
+
super(event, sync: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module TestHelpers
|
|
61
|
+
def self.included(base)
|
|
62
|
+
EventTestWrapper.install!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def setup
|
|
66
|
+
super
|
|
67
|
+
EventTestWrapper.clear_published!
|
|
68
|
+
Dex::Event::Trace.clear!
|
|
69
|
+
Dex::Event::Suppression.clear!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capture_events
|
|
73
|
+
EventTestWrapper.begin_capture!
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
EventTestWrapper.end_capture!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def _dex_published_events
|
|
82
|
+
EventTestWrapper.published_events
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
require_relative "test_helpers/assertions"
|
data/lib/dex/event.rb
CHANGED
|
@@ -17,6 +17,32 @@ module Dex
|
|
|
17
17
|
include TypeCoercion
|
|
18
18
|
include ContextSetup
|
|
19
19
|
|
|
20
|
+
extend Registry
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def to_h
|
|
24
|
+
Export.build_hash(self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_json_schema
|
|
28
|
+
Export.build_json_schema(self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def export(format: :hash)
|
|
32
|
+
unless %i[hash json_schema].include?(format)
|
|
33
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sorted = registry.sort_by(&:name)
|
|
37
|
+
sorted.map do |klass|
|
|
38
|
+
case format
|
|
39
|
+
when :hash then klass.to_h
|
|
40
|
+
when :json_schema then klass.to_json_schema
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
20
46
|
def self._warn(message)
|
|
21
47
|
Dex.warn("Event: #{message}")
|
|
22
48
|
end
|
|
@@ -84,3 +110,4 @@ end
|
|
|
84
110
|
require_relative "event/bus"
|
|
85
111
|
require_relative "event/handler"
|
|
86
112
|
require_relative "event/processor"
|
|
113
|
+
require_relative "event/export"
|
|
@@ -1,88 +1,3 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
class Event
|
|
5
|
-
module EventTestWrapper
|
|
6
|
-
CAPTURING_KEY = :_dex_event_capturing
|
|
7
|
-
PUBLISHED_KEY = :_dex_event_published
|
|
8
|
-
|
|
9
|
-
@_installed = false
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
include ExecutionState
|
|
13
|
-
|
|
14
|
-
def install!
|
|
15
|
-
return if @_installed
|
|
16
|
-
|
|
17
|
-
Dex::Event::Bus.singleton_class.prepend(BusInterceptor)
|
|
18
|
-
@_installed = true
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def installed?
|
|
22
|
-
@_installed
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def capturing?
|
|
26
|
-
(_execution_state[CAPTURING_KEY] || 0) > 0
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def begin_capture!
|
|
30
|
-
_execution_state[CAPTURING_KEY] = (_execution_state[CAPTURING_KEY] || 0) + 1
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def end_capture!
|
|
34
|
-
depth = (_execution_state[CAPTURING_KEY] || 0) - 1
|
|
35
|
-
_execution_state[CAPTURING_KEY] = [depth, 0].max
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def published_events
|
|
39
|
-
_execution_state[PUBLISHED_KEY] ||= []
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def clear_published!
|
|
43
|
-
_execution_state[PUBLISHED_KEY] = []
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
module BusInterceptor
|
|
48
|
-
def publish(event, sync:)
|
|
49
|
-
if Dex::Event::EventTestWrapper.capturing?
|
|
50
|
-
return if Dex::Event::Suppression.suppressed?(event.class)
|
|
51
|
-
|
|
52
|
-
Dex::Event::EventTestWrapper.published_events << event
|
|
53
|
-
else
|
|
54
|
-
super(event, sync: true)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
module TestHelpers
|
|
61
|
-
def self.included(base)
|
|
62
|
-
EventTestWrapper.install!
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def setup
|
|
66
|
-
super
|
|
67
|
-
EventTestWrapper.clear_published!
|
|
68
|
-
Dex::Event::Trace.clear!
|
|
69
|
-
Dex::Event::Suppression.clear!
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def capture_events
|
|
73
|
-
EventTestWrapper.begin_capture!
|
|
74
|
-
yield
|
|
75
|
-
ensure
|
|
76
|
-
EventTestWrapper.end_capture!
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def _dex_published_events
|
|
82
|
-
EventTestWrapper.published_events
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
require_relative "event_test_helpers/assertions"
|
|
3
|
+
require_relative "event/test_helpers"
|
|
@@ -50,8 +50,12 @@ module Dex
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def _build_query(model_class, column, value)
|
|
53
|
-
|
|
53
|
+
return model_class.where(column => value) unless options[:case_sensitive] == false && value.is_a?(String)
|
|
54
|
+
|
|
55
|
+
if model_class.respond_to?(:arel_table)
|
|
54
56
|
model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
|
|
57
|
+
elsif _mongoid_model_class?(model_class)
|
|
58
|
+
model_class.where(column => /\A#{Regexp.escape(value)}\z/i)
|
|
55
59
|
else
|
|
56
60
|
model_class.where(column => value)
|
|
57
61
|
end
|
|
@@ -78,9 +82,21 @@ module Dex
|
|
|
78
82
|
def _exclude_current_record(query, form)
|
|
79
83
|
return query unless form.record&.persisted?
|
|
80
84
|
|
|
85
|
+
if _mongoid_record?(form.record)
|
|
86
|
+
return query.where(:_id.ne => form.record.id)
|
|
87
|
+
end
|
|
88
|
+
|
|
81
89
|
pk = form.record.class.primary_key
|
|
82
90
|
query.where.not(pk => form.record.public_send(pk))
|
|
83
91
|
end
|
|
92
|
+
|
|
93
|
+
def _mongoid_model_class?(model_class)
|
|
94
|
+
defined?(Mongoid::Document) && model_class.include?(Mongoid::Document)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def _mongoid_record?(record)
|
|
98
|
+
_mongoid_model_class?(record.class)
|
|
99
|
+
end
|
|
84
100
|
end
|
|
85
101
|
end
|
|
86
102
|
end
|