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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +40 -7
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +60 -5
  7. data/guides/llm/FORM.md +3 -3
  8. data/guides/llm/OPERATION.md +127 -18
  9. data/guides/llm/QUERY.md +3 -3
  10. data/lib/dex/event/bus.rb +7 -0
  11. data/lib/dex/event/export.rb +56 -0
  12. data/lib/dex/event/handler.rb +33 -0
  13. data/lib/dex/event/test_helpers.rb +88 -0
  14. data/lib/dex/event.rb +27 -0
  15. data/lib/dex/event_test_helpers.rb +1 -86
  16. data/lib/dex/form/uniqueness_validator.rb +17 -1
  17. data/lib/dex/operation/async_proxy.rb +1 -0
  18. data/lib/dex/operation/explain.rb +208 -0
  19. data/lib/dex/operation/export.rb +144 -0
  20. data/lib/dex/operation/guard_wrapper.rb +15 -4
  21. data/lib/dex/operation/lock_wrapper.rb +15 -2
  22. data/lib/dex/operation/once_wrapper.rb +23 -15
  23. data/lib/dex/operation/record_backend.rb +25 -0
  24. data/lib/dex/operation/record_wrapper.rb +29 -4
  25. data/lib/dex/operation/test_helpers/assertions.rb +335 -0
  26. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  27. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  28. data/lib/dex/operation/test_helpers.rb +150 -0
  29. data/lib/dex/operation/transaction_adapter.rb +29 -68
  30. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  31. data/lib/dex/operation.rb +46 -2
  32. data/lib/dex/props_setup.rb +25 -2
  33. data/lib/dex/query/backend.rb +13 -0
  34. data/lib/dex/query.rb +9 -5
  35. data/lib/dex/railtie.rb +84 -0
  36. data/lib/dex/ref_type.rb +4 -0
  37. data/lib/dex/registry.rb +63 -0
  38. data/lib/dex/test_helpers.rb +4 -139
  39. data/lib/dex/tool.rb +115 -0
  40. data/lib/dex/type_coercion.rb +4 -1
  41. data/lib/dex/type_serializer.rb +132 -0
  42. data/lib/dex/version.rb +1 -1
  43. data/lib/dexkit.rb +11 -5
  44. metadata +16 -5
  45. data/lib/dex/test_helpers/assertions.rb +0 -333
  46. data/lib/dex/test_helpers/execution.rb +0 -28
  47. data/lib/dex/test_helpers/stubbing.rb +0 -59
  48. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -1,6 +1,6 @@
1
1
  # Dex::Operation — LLM Reference
2
2
 
3
- Copy this to your app's operations directory (e.g., `app/operations/AGENTS.md`) so coding agents know the full API when implementing and testing operations.
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 by default. All changes roll back on error. Nested operations share the outer transaction.
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 :mongoid # adapter override (default: auto-detect AR → Mongoid)
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). Missing columns silently skipped.
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 table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
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
- Copy this to your app's queries directory (e.g., `app/queries/AGENTS.md`) so coding agents know the full API when implementing and testing queries.
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
@@ -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
- 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 "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
- if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
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
@@ -27,6 +27,7 @@ module Dex
27
27
  end
28
28
 
29
29
  def enqueue_record_job
30
+ @operation.send(:_record_validate_backend!, async: true)
30
31
  record = Dex.record_backend.create_record(
31
32
  name: operation_class_name,
32
33
  params: serialized_params,