dexkit 0.4.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6f6f437fc7f7726b58232820e6f1afd65c5adf674f51c4be07944a43d92a58b
4
- data.tar.gz: 13046423e606ff6e000b6d557ce468efa94d0f13d8aa36530dc5ae69a688e097
3
+ metadata.gz: f8d7274727e727937b55704faa81e4da8a54bb3af7758b11e9144e59fb2dba14
4
+ data.tar.gz: cb899ee88b5e83ec57a913f0173036ac228f672c7850d88e95209f4804218f96
5
5
  SHA512:
6
- metadata.gz: d48748c521d0e0c298ab78230797f7a22e45ae194fee6669f0f99669ea0ac1333b59158a8068211319694cbd073660f298babdb0a784b251444b579b898ba1c0
7
- data.tar.gz: 4a574717aa7d2f5f5de9d192b86d7a6151d5b1a8cd2bba04c79e933cb2768e5c4d7cd05d91e22cf3ca9973cfca59467cd1a6972df8626ce90971ca9fec2a365d
6
+ metadata.gz: 7758fe2c0d5b9cdd2bcf62aa510c4146ef29898335ddffb118abb35409367d5f7cbd1aeba6365f865bb36d4011b1030f136cfb9b6eee27fb3aa3d06d11eeb21e
7
+ data.tar.gz: e437a4af9b4aa0c121245de966775cd3be0bad5ceb5918f139cfaf059545159631797b630a9b21f3add8855308c832f5846efb78f97f36141042f40d28b32341
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-03-07
4
+
5
+ ### Added
6
+
7
+ - **Handler callbacks** — `Dex::Event::Handler` now supports `before`, `after`, and `around` callbacks, same DSL as operations
8
+ - **Handler transactions** — `Dex::Event::Handler` supports `transaction` and `after_commit` DSL. Transactions are disabled by default on handlers (opt in with `transaction`)
9
+ - **Handler pipeline** — `Dex::Event::Handler` supports `use` for adding custom wrapper modules, same as operations
10
+ - **`Dex::Executable`** — shared execution skeleton (Settings, Pipeline, `use` DSL) extracted from Operation and used by both Operation and Handler
11
+
12
+ ### Breaking
13
+
14
+ - **`transaction false` fully opts out** — operations with `transaction false` no longer route `after_commit` through the database adapter. Previously, `after_commit` on a non-transactional operation would still detect and defer to ambient database transactions (e.g., `ActiveRecord::Base.transaction { op.call }`); now it fires callbacks directly after the pipeline completes. To restore ambient transaction awareness, remove `transaction false` or use `transaction` (enabled)
15
+
16
+ ## 0.5.0 - 2026-03-05
17
+
18
+ ### Added
19
+
20
+ - **Verified Mongoid support** — operations (transactions, recording, async) and queries now have dedicated Mongoid test coverage running against a MongoDB replica set
21
+ - **CI workflow for Mongoid** — GitHub Actions matrix includes a MongoDB replica-set job that runs Mongoid-specific tests
22
+
23
+ ### Breaking
24
+
25
+ - **`after_commit` now always defers** — non-transactional operations queue callbacks in memory and flush them after the operation pipeline succeeds, matching the behavior of transactional operations. Previously, `after_commit` fired immediately inline when no transaction was active — code that relied on immediate execution (e.g., reading side effects of the callback later in `perform`) must account for the new deferred timing. Callbacks are discarded on `error!` or exception. Nested operations flush once at the outermost successful boundary. Ambient database transactions (e.g., `ActiveRecord::Base.transaction { op.call }`) are still respected via the adapter.
26
+
27
+ ### Fixed
28
+
29
+ - **Mongoid async recording** — `record_id` is now serialized with `.to_s` so BSON::ObjectId values pass through ActiveJob correctly
30
+ - **Mongoid transaction adapter** — simplified nesting logic; the outermost `Mongoid.transaction` block now reliably owns callback flushing, fixing edge cases where nested rollbacks could leak callbacks
31
+
32
+ ## [0.4.1] - 2026-03-04
33
+
34
+ ### Added
35
+
36
+ - **`after_commit` in operations** — `after_commit { ... }` defers a block until the surrounding database transaction commits
37
+ - ActiveRecord adapter uses `ActiveRecord.after_all_transactions_commit` (requires Rails 7.2+)
38
+ - Mongoid adapter manually tracks callbacks and fires them after the outermost `Mongoid.transaction` commits
39
+ - If called outside a transaction, the block executes immediately
40
+
41
+ ## [0.4.0] - 2026-03-04
42
+
43
+ ### Added
44
+
45
+ - **Query objects** — `Dex::Query` base class for encapsulating database queries with filtering, sorting, and parameter binding
46
+ - Typed properties via `prop`/`prop?` DSL (same as Operation and Event)
47
+ - `scope { Model.all }` DSL for declaring the base scope
48
+ - **Filter DSL** — `filter :name, :strategy` with built-in strategies: `eq`, `not_eq`, `contains`, `starts_with`, `ends_with`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`
49
+ - Custom filter blocks: `filter(:name) { |scope, value| ... }`
50
+ - Optional filters (nilable props) are automatically skipped when `nil`
51
+ - **Sort DSL** — `sort :col1, :col2` for column sorts with automatic `asc`/`desc` via `"-col"` prefix convention
52
+ - Custom sort blocks: `sort(:name) { |scope| ... }`
53
+ - Default sort: `sort :name, default: "-created_at"`
54
+ - **Backend adapters** for both ActiveRecord and Mongoid (auto-detected from scope)
55
+ - `scope:` injection for pre-scoping (e.g., `current_user.posts`)
56
+ - `from_params` for binding from controller params with automatic type coercion and sort validation
57
+ - `to_params` for round-tripping query state back to URL params
58
+ - `param_key` DSL for customizing the params namespace
59
+ - ActiveModel::Naming / ActiveModel::Conversion for Rails form compatibility
60
+ - Convenience class methods: `.call`, `.count`, `.exists?`, `.any?`
61
+ - Inheritance support — filters, sorts, and scope are inherited by subclasses
62
+ - `Dex::Match` is now included in `Dex::Form` — `Ok`/`Err` are available without prefix inside forms
63
+
3
64
  ## [0.3.0] - 2026-03-03
4
65
 
5
66
  ### Added
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Dexkit
1
+ # dexkit
2
2
 
3
3
  Rails patterns toolbelt. Equip to gain +4 DEX.
4
4
 
@@ -9,26 +9,28 @@ Rails patterns toolbelt. Equip to gain +4 DEX.
9
9
  Service objects with typed properties, transactions, error handling, and more.
10
10
 
11
11
  ```ruby
12
- class CreateUser < Dex::Operation
13
- prop :name, String
14
- prop :email, String
12
+ class Order::Place < Dex::Operation
13
+ prop :customer, _Ref(Customer)
14
+ prop :product, _Ref(Product)
15
+ prop :quantity, _Integer(1..)
16
+ prop? :note, String
15
17
 
16
- success _Ref(User)
17
- error :email_taken
18
+ success _Ref(Order)
19
+ error :out_of_stock
18
20
 
19
21
  def perform
20
- error!(:email_taken) if User.exists?(email: email)
22
+ error!(:out_of_stock) unless product.in_stock?
21
23
 
22
- user = User.create!(name: name, email: email)
24
+ order = Order.create!(customer: customer, product: product, quantity: quantity, note: note)
23
25
 
24
- after_commit { WelcomeMailer.with(user: user).deliver_later }
26
+ after_commit { Order::Placed.publish(order_id: order.id, total: order.total) }
25
27
 
26
- user
28
+ order
27
29
  end
28
30
  end
29
31
 
30
- user = CreateUser.call(name: "Alice", email: "alice@example.com")
31
- user.name # => "Alice"
32
+ order = Order::Place.call(customer: 42, product: 7, quantity: 2)
33
+ order.id # => 1
32
34
  ```
33
35
 
34
36
  ### What you get out of the box
@@ -36,35 +38,35 @@ user.name # => "Alice"
36
38
  **Typed properties** – powered by [literal](https://github.com/joeldrapper/literal). Plain classes, ranges, unions, arrays, nilable, and model references with auto-find:
37
39
 
38
40
  ```ruby
39
- prop :amount, _Integer(1..)
41
+ prop :quantity, _Integer(1..)
40
42
  prop :currency, _Union("USD", "EUR", "GBP")
41
- prop :user, _Ref(User) # accepts User instance or ID
42
- prop? :note, String # optional (nil by default)
43
+ prop :customer, _Ref(Customer) # accepts Customer instance or ID
44
+ prop? :note, String # optional (nil by default)
43
45
  ```
44
46
 
45
47
  **Structured errors** with `error!`, `assert!`, and `rescue_from`:
46
48
 
47
49
  ```ruby
48
- user = assert!(:not_found) { User.find_by(id: user_id) }
50
+ product = assert!(:not_found) { Product.find_by(id: product_id) }
49
51
 
50
- rescue_from Stripe::CardError, as: :card_declined
52
+ rescue_from Stripe::CardError, as: :payment_declined
51
53
  ```
52
54
 
53
55
  **Ok / Err** – pattern match on operation outcomes with `.safe.call`:
54
56
 
55
57
  ```ruby
56
- case CreateUser.new(email: email).safe.call
57
- in Ok(name:)
58
- puts "Welcome, #{name}!"
59
- in Err(code: :email_taken)
60
- puts "Already registered"
58
+ case Order::Place.new(customer: 42, product: 7, quantity: 2).safe.call
59
+ in Ok => result
60
+ redirect_to order_path(result.id)
61
+ in Err(code: :out_of_stock)
62
+ flash[:error] = "Product is out of stock"
61
63
  end
62
64
  ```
63
65
 
64
66
  **Async execution** via ActiveJob:
65
67
 
66
68
  ```ruby
67
- SendWelcomeEmail.new(user_id: 123).async(queue: "mailers").call
69
+ Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
68
70
  ```
69
71
 
70
72
  **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
@@ -74,15 +76,16 @@ SendWelcomeEmail.new(user_id: 123).async(queue: "mailers").call
74
76
  First-class test helpers for Minitest:
75
77
 
76
78
  ```ruby
77
- class CreateUserTest < Minitest::Test
78
- testing CreateUser
79
+ class PlaceOrderTest < Minitest::Test
80
+ testing Order::Place
79
81
 
80
- def test_creates_user
81
- assert_operation(name: "Alice", email: "alice@example.com")
82
+ def test_places_order
83
+ assert_operation(customer: customer.id, product: product.id, quantity: 2)
82
84
  end
83
85
 
84
- def test_rejects_duplicate_email
85
- assert_operation_error(:email_taken, name: "Alice", email: "taken@example.com")
86
+ def test_rejects_out_of_stock
87
+ assert_operation_error(:out_of_stock, customer: customer.id,
88
+ product: out_of_stock_product.id, quantity: 1)
86
89
  end
87
90
  end
88
91
  ```
@@ -92,14 +95,14 @@ end
92
95
  Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
93
96
 
94
97
  ```ruby
95
- class OrderPlaced < Dex::Event
98
+ class Order::Placed < Dex::Event
96
99
  prop :order_id, Integer
97
100
  prop :total, BigDecimal
98
101
  prop? :coupon_code, String
99
102
  end
100
103
 
101
104
  class NotifyWarehouse < Dex::Event::Handler
102
- on OrderPlaced
105
+ on Order::Placed
103
106
  retries 3
104
107
 
105
108
  def perform
@@ -107,7 +110,7 @@ class NotifyWarehouse < Dex::Event::Handler
107
110
  end
108
111
  end
109
112
 
110
- OrderPlaced.publish(order_id: 1, total: 99.99)
113
+ Order::Placed.publish(order_id: 1, total: 99.99)
111
114
  ```
112
115
 
113
116
  ### What you get out of the box
@@ -120,22 +123,26 @@ OrderPlaced.publish(order_id: 1, total: 99.99)
120
123
 
121
124
  ```ruby
122
125
  order_placed.trace do
123
- InventoryReserved.publish(order_id: 1)
126
+ Shipment::Reserved.publish(order_id: 1)
124
127
  end
125
128
  ```
126
129
 
130
+ **Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
131
+
132
+ **Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
133
+
127
134
  **Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
128
135
 
129
136
  ### Testing
130
137
 
131
138
  ```ruby
132
- class CreateOrderTest < Minitest::Test
139
+ class PlaceOrderTest < Minitest::Test
133
140
  include Dex::Event::TestHelpers
134
141
 
135
142
  def test_publishes_order_placed
136
143
  capture_events do
137
- CreateOrder.call(item_id: 1)
138
- assert_event_published(OrderPlaced, order_id: 1)
144
+ Order::Place.call(customer: customer.id, product: product.id, quantity: 2)
145
+ assert_event_published(Order::Placed)
139
146
  end
140
147
  end
141
148
  end
@@ -146,8 +153,8 @@ end
146
153
  Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
147
154
 
148
155
  ```ruby
149
- class OnboardingForm < Dex::Form
150
- model User
156
+ class Employee::Form < Dex::Form
157
+ model Employee
151
158
 
152
159
  attribute :first_name, :string
153
160
  attribute :last_name, :string
@@ -165,7 +172,7 @@ class OnboardingForm < Dex::Form
165
172
  end
166
173
  end
167
174
 
168
- form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
175
+ form = Employee::Form.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
169
176
  form.email # => "alice@example.com"
170
177
  form.valid?
171
178
  ```
@@ -177,10 +184,10 @@ form.valid?
177
184
  **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
178
185
 
179
186
  ```ruby
180
- nested_many :documents do
181
- attribute :document_type, :string
182
- attribute :document_number, :string
183
- validates :document_type, :document_number, presence: true
187
+ nested_many :emergency_contacts do
188
+ attribute :name, :string
189
+ attribute :phone, :string
190
+ validates :name, :phone, presence: true
184
191
  end
185
192
  ```
186
193
 
@@ -188,7 +195,7 @@ end
188
195
 
189
196
  **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
190
197
 
191
- **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`:
198
+ **Multi-model forms** — when a form spans Employee, Department, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
192
199
 
193
200
  ```ruby
194
201
  def save
@@ -203,24 +210,24 @@ end
203
210
 
204
211
  ## Queries
205
212
 
206
- Declarative query objects for filtering and sorting ActiveRecord relations.
213
+ Declarative query objects for filtering and sorting ActiveRecord relations and Mongoid criteria.
207
214
 
208
215
  ```ruby
209
- class UserSearch < Dex::Query
210
- scope { User.all }
216
+ class Order::Query < Dex::Query
217
+ scope { Order.all }
211
218
 
212
- prop? :name, String
213
- prop? :role, _Array(String)
214
- prop? :age_min, Integer
219
+ prop? :status, String
220
+ prop? :customer, _Ref(Customer)
221
+ prop? :total_min, Integer
215
222
 
216
- filter :name, :contains
217
- filter :role, :in
218
- filter :age_min, :gte, column: :age
223
+ filter :status
224
+ filter :customer
225
+ filter :total_min, :gte, column: :total
219
226
 
220
- sort :name, :created_at, default: "-created_at"
227
+ sort :created_at, :total, default: "-created_at"
221
228
  end
222
229
 
223
- users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
230
+ orders = Order::Query.call(status: "pending", sort: "-total")
224
231
  ```
225
232
 
226
233
  ### What you get out of the box
@@ -232,10 +239,10 @@ users = UserSearch.call(name: "ali", role: %w[admin], sort: "name")
232
239
  **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
233
240
 
234
241
  ```ruby
235
- class UsersController < ApplicationController
242
+ class OrdersController < ApplicationController
236
243
  def index
237
- query = UserSearch.from_params(params, scope: policy_scope(User))
238
- @users = pagy(query.resolve)
244
+ query = Order::Query.from_params(params, scope: policy_scope(Order))
245
+ @orders = pagy(query.resolve)
239
246
  end
240
247
  end
241
248
  ```
@@ -256,7 +263,7 @@ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
256
263
 
257
264
  ## AI Coding Assistant Setup
258
265
 
259
- Dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
266
+ dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
260
267
 
261
268
  ```bash
262
269
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
data/guides/llm/EVENT.md CHANGED
@@ -111,6 +111,70 @@ end
111
111
 
112
112
  When retries exhausted, exception propagates normally.
113
113
 
114
+ ### Callbacks
115
+
116
+ Same `before`/`after`/`around` DSL as operations:
117
+
118
+ ```ruby
119
+ class ProcessPayment < Dex::Event::Handler
120
+ on PaymentReceived
121
+
122
+ before :log_start
123
+ after :log_end
124
+
125
+ around ->(cont) {
126
+ Instrumentation.measure("payment") { cont.call }
127
+ }
128
+
129
+ def perform
130
+ PaymentGateway.charge(event.amount)
131
+ end
132
+
133
+ private
134
+
135
+ def log_start = Rails.logger.info("Processing payment...")
136
+ def log_end = Rails.logger.info("Payment processed")
137
+ end
138
+ ```
139
+
140
+ Callbacks are inherited. Child handlers run parent callbacks first.
141
+
142
+ ### Transactions
143
+
144
+ Handlers can opt into database transactions and deferred `after_commit`:
145
+
146
+ ```ruby
147
+ class FulfillOrder < Dex::Event::Handler
148
+ on OrderPlaced
149
+ transaction
150
+
151
+ def perform
152
+ order = Order.find(event.order_id)
153
+ order.update!(status: "fulfilled")
154
+
155
+ after_commit { Shipment::Ship.new(order_id: order.id).async.call }
156
+ end
157
+ end
158
+ ```
159
+
160
+ Transactions are **disabled by default** on handlers (unlike operations). Opt in with `transaction`. The `after_commit` block defers until the transaction commits; on exception, deferred blocks are discarded.
161
+
162
+ ### Custom Pipeline
163
+
164
+ Handlers support the same `use` DSL as operations for adding custom wrappers:
165
+
166
+ ```ruby
167
+ class Monitored < Dex::Event::Handler
168
+ use MetricsWrapper, as: :metrics
169
+
170
+ def perform
171
+ # ...
172
+ end
173
+ end
174
+ ```
175
+
176
+ Default handler pipeline: `[:transaction, :callback]`.
177
+
114
178
  ---
115
179
 
116
180
  ## Tracing (Causality)
@@ -273,11 +273,18 @@ def perform
273
273
  end
274
274
  ```
275
275
 
276
- On rollback (`error!` or exception), callbacks are discarded. When no transaction is open anywhere, executes immediately. Multiple blocks run in registration order.
276
+ Callbacks are always deferred they run after the outermost operation boundary succeeds:
277
277
 
278
- **ActiveRecord:** fully nesting-aware callbacks are deferred until the outermost transaction commits, even across nested operations or ambient `ActiveRecord::Base.transaction` blocks. Requires Rails 7.2+.
278
+ - **Transactional operations:** deferred until the DB transaction commits.
279
+ - **Non-transactional operations:** queued in memory, flushed after the operation pipeline completes successfully.
280
+ - **Nested operations:** callbacks queue up and flush once at the outermost successful boundary.
281
+ - **On error (`error!` or exception):** queued callbacks are discarded.
279
282
 
280
- **Mongoid:** callbacks are deferred across nested Dex operations. Ambient `Mongoid.transaction` blocks opened outside Dex are not detected — callbacks will fire immediately in that case.
283
+ Multiple blocks run in registration order.
284
+
285
+ **ActiveRecord:** requires Rails 7.2+ (`after_all_transactions_commit`).
286
+
287
+ **Mongoid:** deferred across nested Dex operations. Ambient `Mongoid.transaction` blocks opened outside Dex are not detected — callbacks will fire immediately in that case.
281
288
 
282
289
  ---
283
290
 
@@ -340,7 +347,7 @@ record params: false # response only
340
347
 
341
348
  Recording happens inside the transaction — rolled back on `error!`/`assert!`. Missing columns silently skipped.
342
349
 
343
- When both async and recording are enabled, Dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
350
+ When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
344
351
 
345
352
  ---
346
353
 
@@ -371,6 +378,8 @@ end
371
378
 
372
379
  Not autoloaded — stays out of production. TestLog and stubs are auto-cleared in `setup`.
373
380
 
381
+ For Mongoid-backed operation tests, run against a MongoDB replica set (MongoDB transactions require it).
382
+
374
383
  ### Subject & Execution
375
384
 
376
385
  ```ruby
data/guides/llm/QUERY.md CHANGED
@@ -159,7 +159,7 @@ Only one default per class. Applied when no sort is provided.
159
159
 
160
160
  ### `.call`
161
161
 
162
- Returns an ActiveRecord relation:
162
+ Returns a queryable scope (`ActiveRecord::Relation` or `Mongoid::Criteria`):
163
163
 
164
164
  ```ruby
165
165
  users = UserSearch.call(name: "ali", role: %w[admin], sort: "-name")
@@ -295,7 +295,7 @@ end
295
295
 
296
296
  ## Testing
297
297
 
298
- Queries return standard ActiveRecord relations. Test them with plain Minitest:
298
+ Queries return standard scopes (`ActiveRecord::Relation` or `Mongoid::Criteria`). Test them with plain Minitest:
299
299
 
300
300
  ```ruby
301
301
  class UserSearchTest < Minitest::Test
@@ -3,6 +3,8 @@
3
3
  module Dex
4
4
  class Event
5
5
  class Handler
6
+ include Dex::Executable
7
+
6
8
  attr_reader :event
7
9
 
8
10
  def self.on(*event_classes)
@@ -36,7 +38,7 @@ module Dex
36
38
  def self._event_handle(event)
37
39
  instance = new
38
40
  instance.instance_variable_set(:@event, event)
39
- instance.perform
41
+ instance.send(:call)
40
42
  end
41
43
 
42
44
  def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
@@ -69,6 +71,23 @@ module Dex
69
71
  end
70
72
  end
71
73
 
74
+ use TransactionWrapper
75
+ use CallbackWrapper
76
+
77
+ transaction false
78
+ private :call
79
+
80
+ # Guard must be defined after `include Executable` (which defines #call).
81
+ def self.method_added(method_name)
82
+ super
83
+
84
+ if method_name == :call
85
+ raise ArgumentError, "#{name || "Handler"} must not define #call — define #perform instead"
86
+ end
87
+
88
+ private :perform if method_name == :perform
89
+ end
90
+
72
91
  def perform
73
92
  raise NotImplementedError, "#{self.class.name} must implement #perform"
74
93
  end
data/lib/dex/event.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  # Modules loaded before class body (no reference to Dex::Event needed)
6
4
  require_relative "event/execution_state"
7
5
  require_relative "event/metadata"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Executable
5
+ def self.included(base)
6
+ base.include(Dex::Settings)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def inherited(subclass)
12
+ subclass.instance_variable_set(:@_pipeline, pipeline.dup)
13
+ super
14
+ end
15
+
16
+ def pipeline
17
+ @_pipeline ||= Pipeline.new
18
+ end
19
+
20
+ def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
21
+ step_name = as || _derive_step_name(mod)
22
+ wrap_method = wrap || :"_#{step_name}_wrap"
23
+ pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
24
+ include mod
25
+ end
26
+
27
+ private
28
+
29
+ def _derive_step_name(mod)
30
+ base = mod.name&.split("::")&.last
31
+ raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
32
+
33
+ base.sub(/Wrapper\z/, "")
34
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
35
+ .downcase
36
+ .to_sym
37
+ end
38
+ end
39
+
40
+ def call
41
+ self.class.pipeline.execute(self) { perform }
42
+ end
43
+ end
44
+ end
@@ -32,7 +32,7 @@ module Dex
32
32
  )
33
33
  begin
34
34
  job = apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: operation_class_name, record_id: record.id)
35
+ job.perform_later(class_name: operation_class_name, record_id: record.id.to_s)
36
36
  rescue => e
37
37
  begin
38
38
  record.destroy
@@ -53,10 +53,23 @@ module Dex
53
53
  raise LoadError, "Mongoid is required for transactions"
54
54
  end
55
55
 
56
- outermost = !Thread.current[AFTER_COMMIT_KEY]
57
- Thread.current[AFTER_COMMIT_KEY] ||= []
58
- snapshot = Thread.current[AFTER_COMMIT_KEY].length
56
+ callbacks = Thread.current[AFTER_COMMIT_KEY]
57
+ outermost = callbacks.nil?
58
+
59
+ unless outermost
60
+ snapshot = callbacks.length
61
+ begin
62
+ return block.call
63
+ rescue rollback_exception_class
64
+ callbacks.slice!(snapshot..)
65
+ return nil
66
+ rescue StandardError # rubocop:disable Style/RescueStandardError
67
+ callbacks.slice!(snapshot..)
68
+ raise
69
+ end
70
+ end
59
71
 
72
+ Thread.current[AFTER_COMMIT_KEY] = []
60
73
  block_completed = false
61
74
  result = Mongoid.transaction do
62
75
  value = block.call
@@ -64,17 +77,11 @@ module Dex
64
77
  value
65
78
  end
66
79
 
67
- if outermost && block_completed
80
+ if block_completed
68
81
  Thread.current[AFTER_COMMIT_KEY].each(&:call)
69
- elsif !block_completed
70
- # Mongoid swallowed a Rollback exception — discard callbacks from this level
71
- Thread.current[AFTER_COMMIT_KEY].slice!(snapshot..)
72
82
  end
73
83
 
74
84
  result
75
- rescue StandardError # rubocop:disable Style/RescueStandardError
76
- Thread.current[AFTER_COMMIT_KEY]&.slice!(snapshot..) unless outermost
77
- raise
78
85
  ensure
79
86
  Thread.current[AFTER_COMMIT_KEY] = nil if outermost
80
87
  end
@@ -4,26 +4,37 @@ module Dex
4
4
  module TransactionWrapper
5
5
  extend Dex::Concern
6
6
 
7
+ DEFERRED_CALLBACKS_KEY = :_dex_after_commit_queue
8
+
7
9
  def _transaction_wrap
8
- return yield unless _transaction_enabled?
10
+ deferred = Thread.current[DEFERRED_CALLBACKS_KEY]
11
+ outermost = deferred.nil?
12
+ Thread.current[DEFERRED_CALLBACKS_KEY] = [] if outermost
13
+ snapshot = Thread.current[DEFERRED_CALLBACKS_KEY].length
9
14
 
10
- interceptor = nil
11
- result = _transaction_execute do
12
- interceptor = Operation::HaltInterceptor.new { yield }
13
- raise _transaction_adapter.rollback_exception_class if interceptor.error?
14
- interceptor.result
15
+ result, interceptor = if _transaction_enabled?
16
+ _transaction_run_adapter(snapshot) { yield }
17
+ else
18
+ _transaction_run_deferred(snapshot) { yield }
15
19
  end
16
20
 
21
+ _transaction_flush_deferred if outermost
22
+
17
23
  interceptor&.rethrow!
18
24
  result
25
+ rescue # rubocop:disable Style/RescueStandardError -- explicit for clarity
26
+ Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
27
+ raise
28
+ ensure
29
+ Thread.current[DEFERRED_CALLBACKS_KEY] = nil if outermost
19
30
  end
20
31
 
21
32
  def after_commit(&block)
22
33
  raise ArgumentError, "after_commit requires a block" unless block
23
34
 
24
- adapter = _transaction_adapter
25
- if adapter
26
- adapter.after_commit(&block)
35
+ deferred = Thread.current[DEFERRED_CALLBACKS_KEY]
36
+ if deferred
37
+ deferred << block
27
38
  else
28
39
  block.call
29
40
  end
@@ -65,6 +76,33 @@ module Dex
65
76
 
66
77
  private
67
78
 
79
+ def _transaction_run_adapter(snapshot)
80
+ interceptor = nil
81
+ result = _transaction_execute do
82
+ interceptor = Operation::HaltInterceptor.new { yield }
83
+ raise _transaction_adapter.rollback_exception_class if interceptor.error?
84
+ interceptor.result
85
+ end
86
+
87
+ if interceptor&.error?
88
+ Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
89
+ interceptor.rethrow!
90
+ end
91
+
92
+ [result, interceptor]
93
+ end
94
+
95
+ def _transaction_run_deferred(snapshot)
96
+ interceptor = Operation::HaltInterceptor.new { yield }
97
+
98
+ if interceptor.error?
99
+ Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
100
+ interceptor.rethrow!
101
+ end
102
+
103
+ [interceptor.result, interceptor]
104
+ end
105
+
68
106
  def _transaction_enabled?
69
107
  settings = self.class.settings_for(:transaction)
70
108
  return false unless settings.fetch(:enabled, true)
@@ -78,6 +116,20 @@ module Dex
78
116
  Operation::TransactionAdapter.for(adapter_name)
79
117
  end
80
118
 
119
+ def _transaction_flush_deferred
120
+ callbacks = Thread.current[DEFERRED_CALLBACKS_KEY]
121
+ return if callbacks.empty?
122
+
123
+ flush = -> { callbacks.each(&:call) }
124
+ enabled = self.class.settings_for(:transaction).fetch(:enabled, true)
125
+ adapter = enabled && _transaction_adapter
126
+ if adapter
127
+ adapter.after_commit(&flush)
128
+ else
129
+ flush.call
130
+ end
131
+ end
132
+
81
133
  def _transaction_execute(&block)
82
134
  _transaction_adapter.wrap(&block)
83
135
  end
data/lib/dex/operation.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Wrapper modules (loaded before class body so `include`/`use` can find them)
4
- require_relative "operation/settings"
5
4
  require_relative "operation/result_wrapper"
6
5
  require_relative "operation/record_wrapper"
7
6
  require_relative "operation/transaction_wrapper"
@@ -11,9 +10,6 @@ require_relative "operation/safe_wrapper"
11
10
  require_relative "operation/rescue_wrapper"
12
11
  require_relative "operation/callback_wrapper"
13
12
 
14
- # Pipeline (referenced inside class body)
15
- require_relative "operation/pipeline"
16
-
17
13
  module Dex
18
14
  class Operation
19
15
  Halt = Struct.new(:type, :value, :error_code, :error_message, :error_details, keyword_init: true) do
@@ -46,6 +42,7 @@ module Dex
46
42
 
47
43
  RESERVED_PROP_NAMES = %i[call perform async safe initialize].to_set.freeze
48
44
 
45
+ include Executable
49
46
  include PropsSetup
50
47
  include TypeCoercion
51
48
 
@@ -60,22 +57,6 @@ module Dex
60
57
  )
61
58
  end
62
59
 
63
- def inherited(subclass)
64
- subclass.instance_variable_set(:@_pipeline, pipeline.dup)
65
- super
66
- end
67
-
68
- def pipeline
69
- @_pipeline ||= Pipeline.new
70
- end
71
-
72
- def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
73
- step_name = as || _derive_step_name(mod)
74
- wrap_method = wrap || :"_#{step_name}_wrap"
75
- pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
76
- include mod
77
- end
78
-
79
60
  private
80
61
 
81
62
  def _contract_params
@@ -85,25 +66,11 @@ module Dex
85
66
  hash[prop.name] = prop.type
86
67
  end
87
68
  end
88
-
89
- def _derive_step_name(mod)
90
- base = mod.name&.split("::")&.last
91
- raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
92
-
93
- base.sub(/Wrapper\z/, "")
94
- .gsub(/([a-z])([A-Z])/, '\1_\2')
95
- .downcase
96
- .to_sym
97
- end
98
69
  end
99
70
 
100
71
  def perform(*, **)
101
72
  end
102
73
 
103
- def call
104
- self.class.pipeline.execute(self) { perform }
105
- end
106
-
107
74
  def self.method_added(method_name)
108
75
  super
109
76
  return unless method_name == :perform
@@ -117,7 +84,6 @@ module Dex
117
84
  new(**kwargs).call
118
85
  end
119
86
 
120
- include Settings
121
87
  include AsyncWrapper
122
88
  include SafeWrapper
123
89
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Pipeline
5
+ Step = Data.define(:name, :method)
6
+
7
+ def initialize(steps = [])
8
+ @steps = steps.dup
9
+ end
10
+
11
+ def dup
12
+ self.class.new(@steps)
13
+ end
14
+
15
+ def steps
16
+ @steps.dup.freeze
17
+ end
18
+
19
+ def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
20
+ validate_positioning!(before, after, at)
21
+ step = Step.new(name: name, method: method)
22
+
23
+ if at == :outer then @steps.unshift(step)
24
+ elsif at == :inner then @steps.push(step)
25
+ elsif before then @steps.insert(find_index!(before), step)
26
+ elsif after then @steps.insert(find_index!(after) + 1, step)
27
+ else @steps.push(step)
28
+ end
29
+ self
30
+ end
31
+
32
+ def remove(name)
33
+ @steps.reject! { |s| s.name == name }
34
+ self
35
+ end
36
+
37
+ def execute(target)
38
+ chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
39
+ -> { target.send(step.method, &next_step) }
40
+ end
41
+ chain.call
42
+ end
43
+
44
+ private
45
+
46
+ def validate_positioning!(before, after, at)
47
+ count = [before, after, at].count { |v| !v.nil? }
48
+ raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
49
+ raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
50
+ end
51
+
52
+ def find_index!(name)
53
+ idx = @steps.index { |s| s.name == name }
54
+ raise ArgumentError, "pipeline step :#{name} not found" unless idx
55
+ idx
56
+ end
57
+ end
58
+ end
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -15,6 +15,9 @@ require_relative "dex/ref_type"
15
15
  require_relative "dex/type_coercion"
16
16
  require_relative "dex/props_setup"
17
17
  require_relative "dex/error"
18
+ require_relative "dex/settings"
19
+ require_relative "dex/pipeline"
20
+ require_relative "dex/executable"
18
21
  require_relative "dex/operation"
19
22
  require_relative "dex/event"
20
23
  require_relative "dex/form"
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.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '6.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ostruct
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: actionpack
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +121,20 @@ dependencies:
107
121
  - - ">="
108
122
  - !ruby/object:Gem::Version
109
123
  version: '6.1'
124
+ - !ruby/object:Gem::Dependency
125
+ name: mongoid
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '8.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '8.0'
110
138
  - !ruby/object:Gem::Dependency
111
139
  name: sqlite3
112
140
  requirement: !ruby/object:Gem::Requirement
@@ -149,6 +177,7 @@ files:
149
177
  - lib/dex/event/trace.rb
150
178
  - lib/dex/event_test_helpers.rb
151
179
  - lib/dex/event_test_helpers/assertions.rb
180
+ - lib/dex/executable.rb
152
181
  - lib/dex/form.rb
153
182
  - lib/dex/form/nesting.rb
154
183
  - lib/dex/form/uniqueness_validator.rb
@@ -160,21 +189,21 @@ files:
160
189
  - lib/dex/operation/jobs.rb
161
190
  - lib/dex/operation/lock_wrapper.rb
162
191
  - lib/dex/operation/outcome.rb
163
- - lib/dex/operation/pipeline.rb
164
192
  - lib/dex/operation/record_backend.rb
165
193
  - lib/dex/operation/record_wrapper.rb
166
194
  - lib/dex/operation/rescue_wrapper.rb
167
195
  - lib/dex/operation/result_wrapper.rb
168
196
  - lib/dex/operation/safe_wrapper.rb
169
- - lib/dex/operation/settings.rb
170
197
  - lib/dex/operation/transaction_adapter.rb
171
198
  - lib/dex/operation/transaction_wrapper.rb
199
+ - lib/dex/pipeline.rb
172
200
  - lib/dex/props_setup.rb
173
201
  - lib/dex/query.rb
174
202
  - lib/dex/query/backend.rb
175
203
  - lib/dex/query/filtering.rb
176
204
  - lib/dex/query/sorting.rb
177
205
  - lib/dex/ref_type.rb
206
+ - lib/dex/settings.rb
178
207
  - lib/dex/test_helpers.rb
179
208
  - lib/dex/test_helpers/assertions.rb
180
209
  - lib/dex/test_helpers/execution.rb
@@ -183,14 +212,15 @@ files:
183
212
  - lib/dex/type_coercion.rb
184
213
  - lib/dex/version.rb
185
214
  - lib/dexkit.rb
186
- homepage: https://github.com/razorjack/dexkit
215
+ homepage: https://dex.razorjack.net/
187
216
  licenses:
188
217
  - MIT
189
218
  metadata:
190
219
  allowed_push_host: https://rubygems.org
191
- homepage_uri: https://github.com/razorjack/dexkit
220
+ homepage_uri: https://dex.razorjack.net/
192
221
  source_code_uri: https://github.com/razorjack/dexkit
193
222
  changelog_uri: https://github.com/razorjack/dexkit/blob/master/CHANGELOG.md
223
+ documentation_uri: https://dex.razorjack.net/
194
224
  rdoc_options: []
195
225
  require_paths:
196
226
  - lib
@@ -207,5 +237,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
207
237
  requirements: []
208
238
  rubygems_version: 4.0.3
209
239
  specification_version: 4
210
- summary: 'Dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
240
+ summary: 'dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
211
241
  test_files: []
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dex
4
- class Operation
5
- class Pipeline
6
- Step = Data.define(:name, :method)
7
-
8
- def initialize(steps = [])
9
- @steps = steps.dup
10
- end
11
-
12
- def dup
13
- self.class.new(@steps)
14
- end
15
-
16
- def steps
17
- @steps.dup.freeze
18
- end
19
-
20
- def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
21
- validate_positioning!(before, after, at)
22
- step = Step.new(name: name, method: method)
23
-
24
- if at == :outer then @steps.unshift(step)
25
- elsif at == :inner then @steps.push(step)
26
- elsif before then @steps.insert(find_index!(before), step)
27
- elsif after then @steps.insert(find_index!(after) + 1, step)
28
- else @steps.push(step)
29
- end
30
- self
31
- end
32
-
33
- def remove(name)
34
- @steps.reject! { |s| s.name == name }
35
- self
36
- end
37
-
38
- def execute(operation)
39
- chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
40
- -> { operation.send(step.method, &next_step) }
41
- end
42
- chain.call
43
- end
44
-
45
- private
46
-
47
- def validate_positioning!(before, after, at)
48
- count = [before, after, at].count { |v| !v.nil? }
49
- raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
50
- raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
51
- end
52
-
53
- def find_index!(name)
54
- idx = @steps.index { |s| s.name == name }
55
- raise ArgumentError, "pipeline step :#{name} not found" unless idx
56
- idx
57
- end
58
- end
59
- end
60
- end
File without changes