rails_ops 1.7.7 → 1.8.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: dcc75b6ab31d484ef579cb73510e759c4b5f85074a491954b991648e9c9d678a
4
- data.tar.gz: 6c1b1fec40de9cc92ac28b417acd33cc7ca9a61d936a3ecef9b9131758005cd2
3
+ metadata.gz: d983d20a0a0bf3e1b7e967d0dbd16aec64f253c8a7a12e7c6b72f9f6f676b604
4
+ data.tar.gz: 00a7b268b2337ba5b2cf57855b9d611a7c9dbe3492cd9484345ad1f7956b2b27
5
5
  SHA512:
6
- metadata.gz: 369d58ccd929715e12b62cea2b38e42bd629accf5f1b1ed7c00bd9b4e8e58eb947b999c7410959262c647052faac33c3ae84cf502cf1d724954c3e8c51189690
7
- data.tar.gz: 7eddc5780ab3daa372af54140b03d7c80d51d6bb2465b6022e3b1eb4a605af11e97b47f9a681be79fe8c12b8f60e0abc87eee42cda5e86dd51e2885a3efaadc5
6
+ metadata.gz: 9a50ef1e82a5a82e1adddb76435389babd2f34a6fe3e4f80c06bf3ec061b26235fa80541cfbfdab6fdc43f253245b388b1b376d8032e090c510fe1a4f2edc591
7
+ data.tar.gz: fca76d3c3c65cc564744596dd298e8a52e69320cd0eb5952750e689dc879b21dce5dab13dd6372456108ea367aa536a62e40a58d4c069af5ab78eb214167c3be
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 (2026-05-07)
4
+
5
+ * `Operation#run` (non-bang) now wraps the call to `run!` in a
6
+ SAVEPOINT whenever a database transaction is already open. This
7
+ ensures that partial writes performed before a validation error are
8
+ rolled back even though `run` swallows the error and returns
9
+ `false`. `run_sub` benefits from the same protection transitively.
10
+ Behavior outside of an open transaction is unchanged, and the
11
+ behavior of `run!` / `run_sub!` is unchanged.
12
+ * Deprecate `with_rollback_on_exception`. The helper now emits an
13
+ `ActiveSupport::Deprecation` warning via `RailsOps.deprecator` and
14
+ will be removed in RailsOps 2.0. The savepoint added to `run` makes
15
+ it obsolete for the common "save then do more work" pattern. For
16
+ non-validation errors that should trigger a rollback, raise
17
+ `RailsOps::Exceptions::RollbackRequired` directly.
18
+
19
+ ## 1.7.8 (2026-03-12)
20
+
21
+ * Add practical examples throughout README covering non-model operations,
22
+ find-or-create patterns, state validation policies, sub-operation
23
+ composition, complete CRUD sets, model defaults, custom authorization
24
+ actions, virtual datetime fields, operation inheritance, and load
25
+ operations for show pages.
26
+
3
27
  ## 1.7.7 (2026-02-24)
4
28
 
5
29
  * Fix `find_model_relation` to merge the returned relation's conditions
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rails_ops (1.7.7)
4
+ rails_ops (1.8.0)
5
5
  active_type (>= 1.3.0)
6
6
  minitest
7
7
  rails (> 4)
@@ -132,7 +132,7 @@ GEM
132
132
  mini_mime (1.1.5)
133
133
  minitest (5.27.0)
134
134
  mutex_m (0.3.0)
135
- net-imap (0.4.20)
135
+ net-imap (0.4.24)
136
136
  date
137
137
  net-protocol
138
138
  net-pop (0.1.2)
@@ -158,8 +158,8 @@ GEM
158
158
  psych (5.1.2)
159
159
  stringio
160
160
  racc (1.8.1)
161
- rack (3.2.5)
162
- rack-session (2.1.1)
161
+ rack (3.2.6)
162
+ rack-session (2.1.2)
163
163
  base64 (>= 0.1.0)
164
164
  rack (>= 3.0.0)
165
165
  rack-test (2.1.0)
@@ -241,7 +241,7 @@ GEM
241
241
  sqlite3 (1.6.2-x86_64-linux)
242
242
  stringio (3.1.0)
243
243
  thor (1.3.1)
244
- timeout (0.6.0)
244
+ timeout (0.6.1)
245
245
  tzinfo (2.0.6)
246
246
  concurrent-ruby (~> 1.0)
247
247
  unicode-display_width (3.1.4)
data/README.md CHANGED
@@ -245,6 +245,78 @@ end
245
245
  puts Operations::GenerateHelloWorld.run!(name: 'John Doe').result
246
246
  ```
247
247
 
248
+ #### Practical Example: Non-Model Business Logic Operation
249
+
250
+ Operations don't have to involve models. Use the base `RailsOps::Operation`
251
+ class for business logic, background tasks, or service calls:
252
+
253
+ ```ruby
254
+ module Operations::Cache
255
+ class Rebuild < RailsOps::Operation
256
+ schema3 do
257
+ boo? :include_archived, default: false
258
+ boo? :rebuild_counters, default: true
259
+ end
260
+
261
+ # Internal use only (called from background jobs)
262
+ without_authorization
263
+
264
+ protected
265
+
266
+ def perform
267
+ rebuild_counters if osparams.rebuild_counters
268
+ rebuild_search_index
269
+ end
270
+
271
+ private
272
+
273
+ def rebuild_counters
274
+ Category.find_each do |category|
275
+ Category.reset_counters(category.id, :articles)
276
+ end
277
+ end
278
+
279
+ def rebuild_search_index
280
+ Article.where(indexed: false).find_each(&:update_search_index!)
281
+ end
282
+ end
283
+ end
284
+ ```
285
+
286
+ #### Practical Example: Find-or-Create Pattern
287
+
288
+ A common pattern for idempotent operations that either find an existing record
289
+ or create a new one. Since this doesn't fit neatly into `Model::Create` or
290
+ `Model::Load`, use the base `RailsOps::Operation` and expose the result via
291
+ `attr_reader`:
292
+
293
+ ```ruby
294
+ module Operations::Tag
295
+ class FindOrCreate < RailsOps::Operation
296
+ schema3 do
297
+ str! :name
298
+ str? :color
299
+ end
300
+
301
+ without_authorization
302
+
303
+ attr_reader :model
304
+
305
+ protected
306
+
307
+ def perform
308
+ @model = ::Tag.find_or_create_by!(name: osparams.name) do |tag|
309
+ tag.color = osparams.color || '#000000'
310
+ end
311
+ rescue ActiveRecord::RecordNotUnique
312
+ # Race condition: another process created the record between
313
+ # our SELECT and INSERT. Just find the existing one.
314
+ @model = ::Tag.find_by!(name: osparams.name)
315
+ end
316
+ end
317
+ end
318
+ ```
319
+
248
320
  ## Params Handling
249
321
 
250
322
  ### Passing Params to Operations
@@ -526,6 +598,51 @@ In this case the model is not yet set. That will happen later in the `:on_init`
526
598
  It is also important to note, that this block is
527
599
  not guaranteed to be run first in the chain, if multiple blocks have set `:prepend_action` to true.
528
600
 
601
+ #### Practical Example: State Validation Policies
602
+
603
+ Use policies to validate preconditions based on the model's state. This is
604
+ particularly useful in model operations where you want to reject the operation
605
+ before `perform` runs:
606
+
607
+ ```ruby
608
+ module Operations::Article
609
+ class Publish < RailsOps::Operation::Model::Update
610
+ schema3 do
611
+ int! :id
612
+ end
613
+
614
+ model ::Article
615
+
616
+ # Ensure the article is still a draft before publishing.
617
+ # Runs at instantiation time (i.e. when the model is loaded).
618
+ policy :on_init do
619
+ unless model.draft?
620
+ fail 'Only draft articles can be published.'
621
+ end
622
+ end
623
+
624
+ # Ensure the article has required content.
625
+ # Runs just before perform is called.
626
+ policy do
627
+ if model.body.blank?
628
+ fail RailsOps::Exceptions::ValidationFailed,
629
+ 'Article body cannot be empty.'
630
+ end
631
+ end
632
+
633
+ protected
634
+
635
+ def perform
636
+ model.status = 'published'
637
+ model.published_at = Time.current
638
+ super
639
+ end
640
+ end
641
+ end
642
+ ```
643
+
644
+ Use `:on_init` for checks that should prevent even *displaying* a form, and
645
+ `:before_perform` (the default) for checks that should prevent *submitting* it.
529
646
 
530
647
  ## Calling Sub-Operations
531
648
 
@@ -579,6 +696,47 @@ catches any validation errors and re-throws them as
579
696
  {RailsOps::Exceptions::SubOpValidationFailed} which is not caught by the
580
697
  surrounding op.
581
698
 
699
+ #### Practical Example: Composing Operations
700
+
701
+ Here is a realistic example of an operation that uses sub-operations to compose
702
+ a complex workflow:
703
+
704
+ Building on the `Article::Publish` example from the *Policies* section, here
705
+ is a version that also triggers sub-operations after publishing:
706
+
707
+ ```ruby
708
+ module Operations::Article
709
+ class PublishWithNotification < RailsOps::Operation::Model::Update
710
+ schema3 do
711
+ int! :id
712
+ end
713
+
714
+ model ::Article
715
+
716
+ protected
717
+
718
+ def perform
719
+ model.status = 'published'
720
+ model.published_at = Time.current
721
+ super # Save the article
722
+
723
+ run_sub! Operations::Cache::Rebuild, rebuild_counters: true
724
+ run_sub! Operations::Notification::Send,
725
+ template: 'article_published',
726
+ record_id: model.id,
727
+ record_type: 'Article'
728
+ end
729
+ end
730
+ end
731
+ ```
732
+
733
+ Validation errors raised by either sub-operation propagate up — `run_sub!`
734
+ re-raises any `validation_errors` as
735
+ `RailsOps::Exceptions::SubOpValidationFailed`, which escapes the parent's
736
+ `run` and rolls back the surrounding transaction. If the parent is invoked
737
+ via `run` (non-bang), the savepoint added in 1.8.0 ensures `super`'s save
738
+ is rolled back too. See section *Transactions* for more details.
739
+
582
740
  ## Contexts
583
741
 
584
742
  Most operations make use of generic parameters like the current user or an
@@ -1046,7 +1204,6 @@ Rails Ops offers multiple ways of disabling authorization:
1046
1204
  end
1047
1205
  ```
1048
1206
 
1049
-
1050
1207
  ## Model Operations
1051
1208
 
1052
1209
  One of the key features of RailsOps is model operations. RailsOps provides
@@ -1360,6 +1517,124 @@ end
1360
1517
  As this base class is very minimalistic, it is recommended to fully read and
1361
1518
  comprehend its source code.
1362
1519
 
1520
+ ### Practical Example: Complete CRUD Operation Set
1521
+
1522
+ Here is a complete, minimal CRUD set for a single model using `schema3`:
1523
+
1524
+ ```ruby
1525
+ # app/operations/category/load.rb
1526
+ module Operations::Category
1527
+ class Load < RailsOps::Operation::Model::Load
1528
+ schema3 do
1529
+ int! :id
1530
+ end
1531
+
1532
+ model ::Category
1533
+ end
1534
+ end
1535
+
1536
+ # app/operations/category/create.rb
1537
+ module Operations::Category
1538
+ class Create < RailsOps::Operation::Model::Create
1539
+ schema3 do
1540
+ hsh? :category do
1541
+ str? :name
1542
+ str? :description
1543
+ end
1544
+ end
1545
+
1546
+ model ::Category
1547
+ end
1548
+ end
1549
+
1550
+ # app/operations/category/update.rb
1551
+ module Operations::Category
1552
+ class Update < RailsOps::Operation::Model::Update
1553
+ schema3 do
1554
+ int! :id
1555
+ hsh? :category do
1556
+ str? :name
1557
+ str? :description
1558
+ end
1559
+ end
1560
+
1561
+ model ::Category
1562
+ end
1563
+ end
1564
+
1565
+ # app/operations/category/destroy.rb
1566
+ module Operations::Category
1567
+ class Destroy < RailsOps::Operation::Model::Destroy
1568
+ schema3 do
1569
+ int! :id
1570
+ end
1571
+
1572
+ model ::Category
1573
+ end
1574
+ end
1575
+ ```
1576
+
1577
+ For `Create` and `Update`, parameter extraction happens automatically: the
1578
+ params nested under the model's `param_key` (`:category`) are assigned to the
1579
+ model. No `perform` method is needed — the base class handles `save!`.
1580
+
1581
+ ### Practical Example: Setting Defaults on Create
1582
+
1583
+ Override `build_model` to set default values or assign associations that aren't
1584
+ part of the user's input:
1585
+
1586
+ ```ruby
1587
+ module Operations::Article
1588
+ class Create < RailsOps::Operation::Model::Create
1589
+ schema3 do
1590
+ hsh? :article do
1591
+ str? :title
1592
+ str? :body
1593
+ int? :category_id
1594
+ end
1595
+ end
1596
+
1597
+ model ::Article
1598
+
1599
+ protected
1600
+
1601
+ def build_model
1602
+ super # Builds the model and assigns params from :article key
1603
+ model.author = context.user
1604
+ model.status = 'draft'
1605
+ end
1606
+ end
1607
+ end
1608
+ ```
1609
+
1610
+ `super` in `build_model` creates a new model instance and assigns the
1611
+ attributes from params. After `super`, you can set additional attributes.
1612
+
1613
+ ### Practical Example: Overriding `build_model` in Update
1614
+
1615
+ In `Update` operations, `build_model` first loads the record (via `Load`), then
1616
+ assigns the params. Override it to modify the model after loading:
1617
+
1618
+ ```ruby
1619
+ module Operations::Token
1620
+ class MarkUsed < RailsOps::Operation::Model::Update
1621
+ schema3 do
1622
+ int! :id
1623
+ end
1624
+
1625
+ model ::Token
1626
+ without_authorization
1627
+
1628
+ protected
1629
+
1630
+ def build_model
1631
+ super # Loads the record and assigns params
1632
+ model.used_at = Time.current
1633
+ end
1634
+ end
1635
+ end
1636
+ ```
1637
+
1363
1638
  ### Including Associated Records
1364
1639
 
1365
1640
  Normally, when inheriting from `RailsOps::Operation::Model::Load` (as well as from the
@@ -1388,6 +1663,46 @@ class Operations::User::Load < RailsOps::Operation::Model::Load
1388
1663
  end
1389
1664
  ```
1390
1665
 
1666
+ #### Practical Example: Load Operation for Show Pages
1667
+
1668
+ A common pattern for "show" pages: a `Load` operation that provides helper
1669
+ methods for loading related data in the view:
1670
+
1671
+ ```ruby
1672
+ module Operations::Frontend::Articles
1673
+ class Show < RailsOps::Operation::Model::Load
1674
+ model ::Article
1675
+ model_includes [:tags, :category, { comments: :author }]
1676
+
1677
+ def recent_comments(limit: 10)
1678
+ model.comments.order(created_at: :desc).limit(limit)
1679
+ end
1680
+
1681
+ def related_articles
1682
+ @related_articles ||= ::Article
1683
+ .where(category_id: model.category_id)
1684
+ .where.not(id: model.id)
1685
+ .limit(5)
1686
+ end
1687
+
1688
+ protected
1689
+
1690
+ # Load operations don't need perform logic, but the base class
1691
+ # raises NotImplementedError, so we override with a no-op.
1692
+ def perform; end
1693
+ end
1694
+ end
1695
+ ```
1696
+
1697
+ In the controller:
1698
+
1699
+ ```ruby
1700
+ def show
1701
+ op Operations::Frontend::Articles::Show
1702
+ # In the view: op.model, op.recent_comments, op.related_articles
1703
+ end
1704
+ ```
1705
+
1391
1706
  ### Parameter Extraction for Create and Update
1392
1707
 
1393
1708
  As mentioned before, the `Create` and `Update` base classes provide an
@@ -1483,6 +1798,43 @@ class Operations::User::Update < RailsOps::Operation::Model::Update
1483
1798
  end
1484
1799
  ```
1485
1800
 
1801
+ #### Practical Example: Custom Authorization Actions
1802
+
1803
+ For operations beyond standard CRUD (e.g., archiving, classifying, publishing),
1804
+ specify custom authorization actions:
1805
+
1806
+ ```ruby
1807
+ module Operations::Article
1808
+ class Archive < RailsOps::Operation::Model::Update
1809
+ schema3 do
1810
+ int! :id
1811
+ end
1812
+
1813
+ load_model_authorization_action :read
1814
+ model_authorization_action :archive
1815
+
1816
+ model ::Article
1817
+
1818
+ protected
1819
+
1820
+ def perform
1821
+ model.archived = true
1822
+ model.archived_at = Time.current
1823
+ model.archived_by = context.user
1824
+ super
1825
+ end
1826
+ end
1827
+ end
1828
+ ```
1829
+
1830
+ In your ability file, define the custom action:
1831
+
1832
+ ```ruby
1833
+ can :archive, Article do |article|
1834
+ article.author_id == user.id || user.admin?
1835
+ end
1836
+ ```
1837
+
1486
1838
  ### Model Nesting
1487
1839
 
1488
1840
  Using active record, multiple nested models can be saved at once by using
@@ -1738,6 +2090,51 @@ class Operations::Order::Checkout < RailsOps::Operation::Model::Update
1738
2090
  end
1739
2091
  ```
1740
2092
 
2093
+ #### Practical Example: Virtual Datetime Fields for Forms
2094
+
2095
+ A very common use case is adding virtual datetime attributes for form inputs
2096
+ that need to be transformed before saving:
2097
+
2098
+ ```ruby
2099
+ module Operations::Event
2100
+ class Create < RailsOps::Operation::Model::Create
2101
+ schema3 do
2102
+ hsh? :event do
2103
+ str? :title
2104
+ str? :virtual_start_datetime
2105
+ str? :virtual_end_datetime
2106
+ boo? :all_day
2107
+ end
2108
+ end
2109
+
2110
+ model ::Event do
2111
+ attribute :virtual_start_datetime, :datetime
2112
+ attribute :virtual_end_datetime, :datetime
2113
+
2114
+ validates :virtual_start_datetime, presence: true, unless: :all_day?
2115
+ validates :virtual_end_datetime, presence: true, unless: :all_day?
2116
+ validates :virtual_end_datetime,
2117
+ comparison: { greater_than_or_equal_to: :virtual_start_datetime },
2118
+ if: -> { !all_day? && virtual_start_datetime.present? }
2119
+ end
2120
+
2121
+ protected
2122
+
2123
+ def build_model
2124
+ super
2125
+
2126
+ if model.all_day?
2127
+ model.start_date = model.virtual_start_datetime&.beginning_of_day
2128
+ model.end_date = model.virtual_end_datetime&.end_of_day
2129
+ else
2130
+ model.start_date = model.virtual_start_datetime
2131
+ model.end_date = model.virtual_end_datetime
2132
+ end
2133
+ end
2134
+ end
2135
+ end
2136
+ ```
2137
+
1741
2138
  ### Combining Real and Virtual Models
1742
2139
 
1743
2140
  You can create operations that work with both persisted and virtual data:
@@ -1811,28 +2208,67 @@ end
1811
2208
  Typically though, transactions are opened on a higher level and outside of
1812
2209
  operations, e.g. in controller methods.
1813
2210
 
1814
- ### Rollback on Exception
2211
+ ### Automatic Savepoint Around `run`
1815
2212
 
1816
- When using `run` (without bang), validation errors are caught and may not cause
1817
- transaction rollback. The `with_rollback_on_exception` helper ensures that
1818
- exceptions within its block are re-raised as `RollbackRequired`, which will
1819
- cause a rollback even when using `run`:
2213
+ Since version 1.8.0, `RailsOps::Operation#run` (the non-bang variant)
2214
+ automatically wraps the operation in a SAVEPOINT whenever a database
2215
+ transaction is already open. If the operation raises a validation error
2216
+ partway through `perform`, the savepoint is rolled back before `run`
2217
+ catches the error and returns `false`. This eliminates the most common
2218
+ reason to reach for `with_rollback_on_exception`.
1820
2219
 
1821
2220
  ```ruby
1822
- class Operations::User::ComplexUpdate < RailsOps::Operation::Model::Update
2221
+ class Operations::User::Create < RailsOps::Operation::Model::Create
1823
2222
  def perform
1824
- ActiveRecord::Base.transaction do
1825
- super # Saves the user
2223
+ super # Saves the user
2224
+ something_else! # If this raises ActiveRecord::RecordInvalid, the
2225
+ # save above is rolled back automatically when the
2226
+ # operation is invoked through `run`.
2227
+ end
2228
+ end
1826
2229
 
1827
- # Without with_rollback_on_exception, validation errors here won't
1828
- # roll back the transaction when the operation is called with run
1829
- with_rollback_on_exception do
1830
- model.profile.bio = params[:bio]
1831
- model.profile.save! # If this fails, transaction is rolled back
2230
+ # Caller (controller, job, etc.):
2231
+ ActiveRecord::Base.transaction do
2232
+ if Operations::User::Create.run(user: { name: 'Alice' })
2233
+ # success path
2234
+ else
2235
+ # validation error — partial writes have already been rolled back
2236
+ end
2237
+ end
2238
+ ```
1832
2239
 
1833
- model.settings.notifications = params[:notifications]
1834
- model.settings.save! # If this fails, transaction is rolled back
1835
- end
2240
+ A savepoint is only created if a transaction is already open at the time
2241
+ of the call. Without an outer transaction (rake tasks, console sessions,
2242
+ some background jobs), `run` calls `run!` directly and behavior is
2243
+ unchanged: the caller is expected to open a transaction if it wants
2244
+ atomicity. `run!` itself is never wrapped — exceptions propagate naturally
2245
+ and the surrounding transaction (if any) rolls back.
2246
+
2247
+ `run_sub` (non-bang) benefits from the same protection transitively, since
2248
+ it delegates to `.run` on the sub-operation.
2249
+
2250
+ ### Rollback on Exception (Non-Validation Errors)
2251
+
2252
+ The savepoint above only protects against **validation errors**
2253
+ (`RailsOps::Exceptions::ValidationFailed` and
2254
+ `ActiveRecord::RecordInvalid`). For other `StandardError` subclasses that
2255
+ should also trigger a rollback when `run` is used, the
2256
+ `with_rollback_on_exception` helper re-raises them as
2257
+ `RailsOps::Exceptions::RollbackRequired`, which is not part of
2258
+ `validation_errors` and therefore propagates up through `run` and rolls
2259
+ back the surrounding transaction:
2260
+
2261
+ ```ruby
2262
+ class Operations::User::ComplexUpdate < RailsOps::Operation::Model::Update
2263
+ def perform
2264
+ super # Saves the user — validation errors here are handled by the
2265
+ # automatic savepoint in `run`.
2266
+
2267
+ with_rollback_on_exception do
2268
+ ExternalApi.call!(model) # raises a custom StandardError on failure;
2269
+ # converted into RollbackRequired so the
2270
+ # outer transaction rolls back even when
2271
+ # the operation is invoked via `run`.
1836
2272
  end
1837
2273
  end
1838
2274
  end
@@ -1851,7 +2287,7 @@ end
1851
2287
  ```
1852
2288
 
1853
2289
  **Important**: `with_rollback_on_exception` only works within an existing
1854
- transaction. It doesn't create a transaction - it just ensures exceptions
2290
+ transaction. It doesn't create a transaction it just ensures exceptions
1855
2291
  cause rollback:
1856
2292
 
1857
2293
  ```ruby
@@ -1861,7 +2297,9 @@ class Operations::Order::Process < RailsOps::Operation::Model::Update
1861
2297
  super # Order is saved and committed in its own transaction
1862
2298
 
1863
2299
  with_rollback_on_exception do
1864
- model.line_items.each { |item| item.update!(status: 'processed') }
2300
+ ExternalApi.charge!(model) # Raises a custom StandardError on failure;
2301
+ # the order has already been committed and
2302
+ # cannot be rolled back.
1865
2303
  end
1866
2304
  end
1867
2305
  end
@@ -1870,10 +2308,13 @@ end
1870
2308
  class Operations::Order::Process < RailsOps::Operation::Model::Update
1871
2309
  def perform
1872
2310
  ActiveRecord::Base.transaction do
1873
- super # Order is saved
2311
+ super # Order is saved within the surrounding transaction
1874
2312
 
1875
2313
  with_rollback_on_exception do
1876
- model.line_items.each { |item| item.update!(status: 'processed') }
2314
+ ExternalApi.charge!(model) # Custom StandardError is converted into
2315
+ # RollbackRequired, which propagates
2316
+ # through `run` and rolls back the
2317
+ # transaction including `super`.
1877
2318
  end
1878
2319
  end
1879
2320
  end
@@ -1923,9 +2364,13 @@ complete.
1923
2364
 
1924
2365
  ### Important Notes on Transactions
1925
2366
 
1926
- 1. **Validation Errors**: When using `run` (without bang), validation errors
1927
- are caught and won't roll back the transaction. Use `run!` for
1928
- sub-operations to ensure transaction rollback on validation errors.
2367
+ 1. **Validation Errors**: When `run` (without bang) is called inside an
2368
+ open transaction, RailsOps wraps the operation in a SAVEPOINT so that
2369
+ any partial writes are rolled back before the caught validation error
2370
+ is converted into a `false` return value. `run_sub` benefits from the
2371
+ same protection. Use `run!` / `run_sub!` when you want validation
2372
+ errors to propagate and roll back the surrounding transaction
2373
+ directly.
1929
2374
 
1930
2375
  2. **External Services**: Be careful when calling external services within
1931
2376
  transactions. Long-running external calls can cause database locks:
@@ -2127,6 +2572,103 @@ sub-operations, see section *Calling sub-operations* for more information.
2127
2572
 
2128
2573
  ## Operation Inheritance
2129
2574
 
2575
+ Operations support standard Ruby class inheritance. This is useful when multiple
2576
+ models share the same operation pattern. Create an abstract base operation and
2577
+ then inherit from it for each model:
2578
+
2579
+ ```ruby
2580
+ # app/operations/base/toggle_active.rb
2581
+ module Operations::Base
2582
+ class ToggleActive < RailsOps::Operation::Model::Update
2583
+ schema3 do
2584
+ int! :id
2585
+ end
2586
+
2587
+ protected
2588
+
2589
+ def perform
2590
+ model.active = !model.active
2591
+ super
2592
+ end
2593
+ end
2594
+ end
2595
+
2596
+ # app/operations/category/toggle_active.rb
2597
+ module Operations::Category
2598
+ class ToggleActive < Operations::Base::ToggleActive
2599
+ model ::Category
2600
+ end
2601
+ end
2602
+
2603
+ # app/operations/tag/toggle_active.rb
2604
+ module Operations::Tag
2605
+ class ToggleActive < Operations::Base::ToggleActive
2606
+ model ::Tag
2607
+ end
2608
+ end
2609
+ ```
2610
+
2611
+ The base class defines the common schema and behavior. Subclasses only need to
2612
+ specify the `model`. This avoids duplicating logic across many operations.
2613
+
2614
+ Schemas, policies, and authorization settings are all inherited. Subclasses can
2615
+ add additional policies or override methods as needed.
2616
+
2617
+ ### Practical Example: Bulk Insert Operation Base
2618
+
2619
+ Another common base class is for bulk insert operations:
2620
+
2621
+ ```ruby
2622
+ module Operations::Base
2623
+ class BulkCreate < RailsOps::Operation
2624
+ BATCH_SIZE = 500
2625
+
2626
+ without_authorization
2627
+
2628
+ protected
2629
+
2630
+ def perform
2631
+ unique_ids = ids_to_insert.uniq
2632
+ return if unique_ids.empty?
2633
+
2634
+ now = Time.current
2635
+
2636
+ unique_ids.each_slice(self.class::BATCH_SIZE) do |batch|
2637
+ records = build_records(batch, now)
2638
+ target_class.insert_all!(records)
2639
+ rescue ActiveRecord::RecordNotUnique
2640
+ # Race condition: filter out already-existing records and retry
2641
+ existing = existing_ids_for(batch)
2642
+ new_records = records.reject { |r| existing.include?(r[id_column]) }
2643
+ target_class.insert_all!(new_records) if new_records.any?
2644
+ end
2645
+ end
2646
+
2647
+ private
2648
+
2649
+ def ids_to_insert
2650
+ fail NotImplementedError
2651
+ end
2652
+
2653
+ def build_records(_batch, _now)
2654
+ fail NotImplementedError
2655
+ end
2656
+
2657
+ def target_class
2658
+ fail NotImplementedError
2659
+ end
2660
+
2661
+ def id_column
2662
+ :id
2663
+ end
2664
+
2665
+ def existing_ids_for(_batch)
2666
+ fail NotImplementedError
2667
+ end
2668
+ end
2669
+ end
2670
+ ```
2671
+
2130
2672
  ## Generators
2131
2673
 
2132
2674
  RailsOps features a generator to easily create a structure for common CRUD-style
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.7.7
1
+ 1.8.0
@@ -14,6 +14,12 @@ class RailsOps::Operation
14
14
  Symbol
15
15
  ].freeze
16
16
 
17
+ # Tracks which call sites have already emitted the deprecation warning
18
+ # for {with_rollback_on_exception}. Keyed by `"<path>:<lineno>"` so the
19
+ # warning fires at most once per call location across the process,
20
+ # avoiding log spam in long-running servers and per-request hot paths.
21
+ WITH_ROLLBACK_DEPRECATION_SEEN = Concurrent::Map.new
22
+
17
23
  attr_reader :params
18
24
  attr_reader :context
19
25
 
@@ -102,8 +108,26 @@ class RailsOps::Operation
102
108
 
103
109
  # Runs the operation using {run!} but rescues certain exceptions. Returns
104
110
  # `true` on success, otherwise `false`.
111
+ #
112
+ # If a database transaction is already open when {run} is called, the call
113
+ # to {run!} is wrapped in a savepoint via
114
+ # `ActiveRecord::Base.transaction(requires_new: true)`. This ensures that
115
+ # any database writes performed by the operation are rolled back if a
116
+ # validation error is raised, even though that error is then caught here
117
+ # and converted into a `false` return value. This eliminates the most
118
+ # common reason for using {with_rollback_on_exception}.
119
+ #
120
+ # When no transaction is open, behavior is identical to calling {run!}
121
+ # directly: the caller is responsible for atomicity.
105
122
  def run
106
- run!
123
+ if ActiveRecord::Base.connection.transaction_open?
124
+ ActiveRecord::Base.transaction(requires_new: true) do
125
+ run!
126
+ end
127
+ else
128
+ run!
129
+ end
130
+
107
131
  return true
108
132
  rescue *validation_errors
109
133
  return false
@@ -186,44 +210,37 @@ class RailsOps::Operation
186
210
  end
187
211
  end
188
212
 
189
- # Yields the given block and rethrows any possible exception as a
213
+ # Yields the given block and rethrows any possible `StandardError` as a
190
214
  # {RailsOps::Exceptions::RollbackRequired} exception.
191
215
  #
192
- # For illustration of potential use cases, consider the following example:
216
+ # @deprecated Since 1.8.0, validation errors raised inside {run} no
217
+ # longer leak partial database writes: {run} wraps the call to
218
+ # {run!} in a SAVEPOINT whenever an outer transaction is open, so
219
+ # any prior `model.save!` is rolled back automatically before
220
+ # {run} returns `false`. This helper is therefore obsolete for
221
+ # the common "save then do more work" pattern and will be
222
+ # removed in RailsOps 2.0.
193
223
  #
194
- # class User::Create < RailsOps::Operation::Model::Create
195
- # def perform
196
- # super # Saves the user
224
+ # To convert a non-validation `StandardError` into a rollback
225
+ # signal that escapes {run}'s rescue, raise
226
+ # {RailsOps::Exceptions::RollbackRequired} directly, e.g.
227
+ # `fail RailsOps::Exceptions::RollbackRequired, e, e.backtrace`.
197
228
  #
198
- # model.some_field = 'some value'
199
- # model.save! # Throws validation error
200
- # end
201
- # end
202
- #
203
- # User::Create.run(user: { some: :values })
204
- #
205
- # Since this operation is run without the bang method, validation errors are
206
- # caught and won't result in the transaction beeing rolled back. However, the
207
- # `super` call already saved the user while the exception happens only at
208
- # the manual call to `model.save!`. Thus the user will still be in the DB,
209
- # despite the fact that the second update didn't run.
210
- #
211
- # The correct example would therefore be:
212
- #
213
- # class User::Create < RailsOps::Operation::Model::Create
214
- # def perform
215
- # super # Saves the user
216
- #
217
- # with_rollback_on_exception do
218
- # model.some_field = 'some value'
219
- # model.save! # Throws validation error
220
- # end
221
- # end
222
- # end
223
- #
224
- # This method is one possible solution for issue #28535. There might be a more
225
- # elegant and transparent approach as explained in the issue.
229
+ # Originally introduced for issue #28535.
226
230
  def with_rollback_on_exception(&_block)
231
+ location = caller_locations(1, 1)&.first
232
+ location_key = location && "#{location.path}:#{location.lineno}"
233
+ if location_key.nil? || WITH_ROLLBACK_DEPRECATION_SEEN.put_if_absent(location_key, true).nil?
234
+ RailsOps.deprecator.warn(
235
+ '`with_rollback_on_exception` is deprecated and will be removed ' \
236
+ 'in RailsOps 2.0. Validation errors raised inside `run` are now ' \
237
+ 'rolled back automatically via a SAVEPOINT, so this helper is no ' \
238
+ 'longer required for the common "save then do more work" pattern. ' \
239
+ 'For non-validation errors that should trigger a rollback, raise ' \
240
+ '`RailsOps::Exceptions::RollbackRequired` directly.',
241
+ caller_locations(1)
242
+ )
243
+ end
227
244
  yield
228
245
  rescue StandardError => e
229
246
  fail RailsOps::Exceptions::RollbackRequired, e, e.backtrace
@@ -1,14 +1,16 @@
1
1
  module RailsOps
2
2
  # @private
3
3
  class Railtie < Rails::Railtie
4
- initializer 'rails_ops' do |app|
5
- # ---------------------------------------------------------------
6
- # Register deprecator
7
- # ---------------------------------------------------------------
4
+ # Register the deprecator early so that app-level deprecation config
5
+ # (`config.active_support.deprecation`,
6
+ # `config.active_support.report_deprecations`, …) is applied to it.
7
+ initializer 'rails_ops.deprecator', before: :load_environment_config do |app|
8
8
  if app.respond_to?(:deprecators)
9
9
  app.deprecators[:rails_ops] = RailsOps.deprecator
10
10
  end
11
+ end
11
12
 
13
+ initializer 'rails_ops' do
12
14
  # ---------------------------------------------------------------
13
15
  # Load hookup config eagerly at application startup unless
14
16
  # in development mode.
data/lib/rails_ops.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'active_type'
2
+ require 'concurrent/map'
2
3
  require 'schemacop'
3
4
  require 'request_store'
4
5
  require 'ostruct'
data/rails_ops.gemspec CHANGED
@@ -1,20 +1,20 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: rails_ops 1.7.7 ruby lib
2
+ # stub: rails_ops 1.8.0 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "rails_ops".freeze
6
- s.version = "1.7.7"
6
+ s.version = "1.8.0"
7
7
 
8
8
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
9
9
  s.require_paths = ["lib".freeze]
10
10
  s.authors = ["Sitrox".freeze]
11
- s.date = "2026-02-24"
12
- s.files = [".github/workflows/rubocop.yml".freeze, ".github/workflows/ruby.yml".freeze, ".gitignore".freeze, ".releaser_config".freeze, ".rubocop.yml".freeze, "Appraisals".freeze, "CHANGELOG.md".freeze, "CLAUDE.md".freeze, "Gemfile".freeze, "Gemfile.lock".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "VERSION".freeze, "gemfiles/rails_6.0.gemfile".freeze, "gemfiles/rails_6.1.gemfile".freeze, "gemfiles/rails_7.0.gemfile".freeze, "gemfiles/rails_7.1.gemfile".freeze, "gemfiles/rails_7.2.gemfile".freeze, "gemfiles/rails_8.0.gemfile".freeze, "lib/generators/operation/USAGE".freeze, "lib/generators/operation/operation_generator.rb".freeze, "lib/generators/operation/templates/controller.erb".freeze, "lib/generators/operation/templates/controller_wrapper.erb".freeze, "lib/generators/operation/templates/create.erb".freeze, "lib/generators/operation/templates/destroy.erb".freeze, "lib/generators/operation/templates/load.erb".freeze, "lib/generators/operation/templates/update.erb".freeze, "lib/generators/operation/templates/view.erb".freeze, "lib/rails_ops.rb".freeze, "lib/rails_ops/authorization_backend/abstract.rb".freeze, "lib/rails_ops/authorization_backend/can_can_can.rb".freeze, "lib/rails_ops/configuration.rb".freeze, "lib/rails_ops/context.rb".freeze, "lib/rails_ops/controller_mixin.rb".freeze, "lib/rails_ops/exceptions.rb".freeze, "lib/rails_ops/hooked_job.rb".freeze, "lib/rails_ops/hookup.rb".freeze, "lib/rails_ops/hookup/dsl.rb".freeze, "lib/rails_ops/hookup/dsl_validator.rb".freeze, "lib/rails_ops/hookup/hook.rb".freeze, "lib/rails_ops/log_subscriber.rb".freeze, "lib/rails_ops/mixins.rb".freeze, "lib/rails_ops/mixins/authorization.rb".freeze, "lib/rails_ops/mixins/log_settings.rb".freeze, "lib/rails_ops/mixins/model.rb".freeze, "lib/rails_ops/mixins/model/authorization.rb".freeze, "lib/rails_ops/mixins/model/nesting.rb".freeze, "lib/rails_ops/mixins/param_authorization.rb".freeze, "lib/rails_ops/mixins/policies.rb".freeze, "lib/rails_ops/mixins/require_context.rb".freeze, "lib/rails_ops/mixins/routes.rb".freeze, "lib/rails_ops/mixins/schema_validation.rb".freeze, "lib/rails_ops/mixins/sub_ops.rb".freeze, "lib/rails_ops/model_mixins.rb".freeze, "lib/rails_ops/model_mixins/ar_extension.rb".freeze, "lib/rails_ops/model_mixins/marshalling.rb".freeze, "lib/rails_ops/model_mixins/parent_op.rb".freeze, "lib/rails_ops/model_mixins/sti_fixes.rb".freeze, "lib/rails_ops/model_mixins/virtual_attributes.rb".freeze, "lib/rails_ops/model_mixins/virtual_attributes/virtual_column_wrapper.rb".freeze, "lib/rails_ops/model_mixins/virtual_has_one.rb".freeze, "lib/rails_ops/model_mixins/virtual_model_name.rb".freeze, "lib/rails_ops/operation.rb".freeze, "lib/rails_ops/operation/model.rb".freeze, "lib/rails_ops/operation/model/create.rb".freeze, "lib/rails_ops/operation/model/destroy.rb".freeze, "lib/rails_ops/operation/model/load.rb".freeze, "lib/rails_ops/operation/model/update.rb".freeze, "lib/rails_ops/profiler.rb".freeze, "lib/rails_ops/profiler/node.rb".freeze, "lib/rails_ops/railtie.rb".freeze, "lib/rails_ops/scoped_env.rb".freeze, "lib/rails_ops/virtual_model.rb".freeze, "rails_ops.gemspec".freeze, "test/db/models.rb".freeze, "test/db/schema.rb".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/assets/config/manifest.js".freeze, "test/dummy/app/assets/images/.keep".freeze, "test/dummy/app/assets/javascripts/application.js".freeze, "test/dummy/app/assets/javascripts/cable.js".freeze, "test/dummy/app/assets/javascripts/channels/.keep".freeze, "test/dummy/app/assets/stylesheets/application.css".freeze, "test/dummy/app/channels/application_cable/channel.rb".freeze, "test/dummy/app/channels/application_cable/connection.rb".freeze, "test/dummy/app/controllers/application_controller.rb".freeze, "test/dummy/app/controllers/concerns/.keep".freeze, "test/dummy/app/controllers/group_controller.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/jobs/application_job.rb".freeze, "test/dummy/app/mailers/application_mailer.rb".freeze, "test/dummy/app/models/ability.rb".freeze, "test/dummy/app/models/animal.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/bird.rb".freeze, "test/dummy/app/models/cat.rb".freeze, "test/dummy/app/models/computer.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/cpu.rb".freeze, "test/dummy/app/models/dog.rb".freeze, "test/dummy/app/models/flower.rb".freeze, "test/dummy/app/models/group.rb".freeze, "test/dummy/app/models/mainboard.rb".freeze, "test/dummy/app/models/nightingale.rb".freeze, "test/dummy/app/models/phoenix.rb".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/app/views/layouts/application.html.erb".freeze, "test/dummy/app/views/layouts/mailer.html.erb".freeze, "test/dummy/app/views/layouts/mailer.text.erb".freeze, "test/dummy/bin/bundle".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/bin/update".freeze, "test/dummy/bin/yarn".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/cable.yml".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/development.rb".freeze, "test/dummy/config/environments/production.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/hookup.rb".freeze, "test/dummy/config/initializers/application_controller_renderer.rb".freeze, "test/dummy/config/initializers/assets.rb".freeze, "test/dummy/config/initializers/backtrace_silencers.rb".freeze, "test/dummy/config/initializers/cookies_serializer.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/mime_types.rb".freeze, "test/dummy/config/initializers/rails_ops.rb".freeze, "test/dummy/config/initializers/wrap_parameters.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/config/secrets.yml".freeze, "test/dummy/config/spring.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/lib/assets/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/package.json".freeze, "test/dummy/public/404.html".freeze, "test/dummy/public/422.html".freeze, "test/dummy/public/500.html".freeze, "test/dummy/public/apple-touch-icon-precomposed.png".freeze, "test/dummy/public/apple-touch-icon.png".freeze, "test/dummy/public/favicon.ico".freeze, "test/dummy/tmp/.keep".freeze, "test/test_helper.rb".freeze, "test/unit/rails_ops/generators/operation_generator_test.rb".freeze, "test/unit/rails_ops/hookup_test.rb".freeze, "test/unit/rails_ops/mixins/controller_test.rb".freeze, "test/unit/rails_ops/mixins/model/deep_nesting_test.rb".freeze, "test/unit/rails_ops/mixins/model/marshalling_test.rb".freeze, "test/unit/rails_ops/mixins/param_authorization_test.rb".freeze, "test/unit/rails_ops/mixins/policies_test.rb".freeze, "test/unit/rails_ops/operation/auth_test.rb".freeze, "test/unit/rails_ops/operation/model/create_test.rb".freeze, "test/unit/rails_ops/operation/model/destroy_test.rb".freeze, "test/unit/rails_ops/operation/model/load_test.rb".freeze, "test/unit/rails_ops/operation/model/sti_test.rb".freeze, "test/unit/rails_ops/operation/model/update_test.rb".freeze, "test/unit/rails_ops/operation/model_test.rb".freeze, "test/unit/rails_ops/operation/update_lazy_auth_test.rb".freeze, "test/unit/rails_ops/operation_test.rb".freeze, "test/unit/rails_ops/profiler_test.rb".freeze]
11
+ s.date = "2026-05-07"
12
+ s.files = [".github/workflows/rubocop.yml".freeze, ".github/workflows/ruby.yml".freeze, ".gitignore".freeze, ".releaser_config".freeze, ".rubocop.yml".freeze, "Appraisals".freeze, "CHANGELOG.md".freeze, "CLAUDE.md".freeze, "Gemfile".freeze, "Gemfile.lock".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "VERSION".freeze, "gemfiles/rails_6.0.gemfile".freeze, "gemfiles/rails_6.1.gemfile".freeze, "gemfiles/rails_7.0.gemfile".freeze, "gemfiles/rails_7.1.gemfile".freeze, "gemfiles/rails_7.2.gemfile".freeze, "gemfiles/rails_8.0.gemfile".freeze, "lib/generators/operation/USAGE".freeze, "lib/generators/operation/operation_generator.rb".freeze, "lib/generators/operation/templates/controller.erb".freeze, "lib/generators/operation/templates/controller_wrapper.erb".freeze, "lib/generators/operation/templates/create.erb".freeze, "lib/generators/operation/templates/destroy.erb".freeze, "lib/generators/operation/templates/load.erb".freeze, "lib/generators/operation/templates/update.erb".freeze, "lib/generators/operation/templates/view.erb".freeze, "lib/rails_ops.rb".freeze, "lib/rails_ops/authorization_backend/abstract.rb".freeze, "lib/rails_ops/authorization_backend/can_can_can.rb".freeze, "lib/rails_ops/configuration.rb".freeze, "lib/rails_ops/context.rb".freeze, "lib/rails_ops/controller_mixin.rb".freeze, "lib/rails_ops/exceptions.rb".freeze, "lib/rails_ops/hooked_job.rb".freeze, "lib/rails_ops/hookup.rb".freeze, "lib/rails_ops/hookup/dsl.rb".freeze, "lib/rails_ops/hookup/dsl_validator.rb".freeze, "lib/rails_ops/hookup/hook.rb".freeze, "lib/rails_ops/log_subscriber.rb".freeze, "lib/rails_ops/mixins.rb".freeze, "lib/rails_ops/mixins/authorization.rb".freeze, "lib/rails_ops/mixins/log_settings.rb".freeze, "lib/rails_ops/mixins/model.rb".freeze, "lib/rails_ops/mixins/model/authorization.rb".freeze, "lib/rails_ops/mixins/model/nesting.rb".freeze, "lib/rails_ops/mixins/param_authorization.rb".freeze, "lib/rails_ops/mixins/policies.rb".freeze, "lib/rails_ops/mixins/require_context.rb".freeze, "lib/rails_ops/mixins/routes.rb".freeze, "lib/rails_ops/mixins/schema_validation.rb".freeze, "lib/rails_ops/mixins/sub_ops.rb".freeze, "lib/rails_ops/model_mixins.rb".freeze, "lib/rails_ops/model_mixins/ar_extension.rb".freeze, "lib/rails_ops/model_mixins/marshalling.rb".freeze, "lib/rails_ops/model_mixins/parent_op.rb".freeze, "lib/rails_ops/model_mixins/sti_fixes.rb".freeze, "lib/rails_ops/model_mixins/virtual_attributes.rb".freeze, "lib/rails_ops/model_mixins/virtual_attributes/virtual_column_wrapper.rb".freeze, "lib/rails_ops/model_mixins/virtual_has_one.rb".freeze, "lib/rails_ops/model_mixins/virtual_model_name.rb".freeze, "lib/rails_ops/operation.rb".freeze, "lib/rails_ops/operation/model.rb".freeze, "lib/rails_ops/operation/model/create.rb".freeze, "lib/rails_ops/operation/model/destroy.rb".freeze, "lib/rails_ops/operation/model/load.rb".freeze, "lib/rails_ops/operation/model/update.rb".freeze, "lib/rails_ops/profiler.rb".freeze, "lib/rails_ops/profiler/node.rb".freeze, "lib/rails_ops/railtie.rb".freeze, "lib/rails_ops/scoped_env.rb".freeze, "lib/rails_ops/virtual_model.rb".freeze, "rails_ops.gemspec".freeze, "test/db/models.rb".freeze, "test/db/schema.rb".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/assets/config/manifest.js".freeze, "test/dummy/app/assets/images/.keep".freeze, "test/dummy/app/assets/javascripts/application.js".freeze, "test/dummy/app/assets/javascripts/cable.js".freeze, "test/dummy/app/assets/javascripts/channels/.keep".freeze, "test/dummy/app/assets/stylesheets/application.css".freeze, "test/dummy/app/channels/application_cable/channel.rb".freeze, "test/dummy/app/channels/application_cable/connection.rb".freeze, "test/dummy/app/controllers/application_controller.rb".freeze, "test/dummy/app/controllers/concerns/.keep".freeze, "test/dummy/app/controllers/group_controller.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/jobs/application_job.rb".freeze, "test/dummy/app/mailers/application_mailer.rb".freeze, "test/dummy/app/models/ability.rb".freeze, "test/dummy/app/models/animal.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/bird.rb".freeze, "test/dummy/app/models/cat.rb".freeze, "test/dummy/app/models/computer.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/cpu.rb".freeze, "test/dummy/app/models/dog.rb".freeze, "test/dummy/app/models/flower.rb".freeze, "test/dummy/app/models/group.rb".freeze, "test/dummy/app/models/mainboard.rb".freeze, "test/dummy/app/models/nightingale.rb".freeze, "test/dummy/app/models/phoenix.rb".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/app/views/layouts/application.html.erb".freeze, "test/dummy/app/views/layouts/mailer.html.erb".freeze, "test/dummy/app/views/layouts/mailer.text.erb".freeze, "test/dummy/bin/bundle".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/bin/update".freeze, "test/dummy/bin/yarn".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/cable.yml".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/development.rb".freeze, "test/dummy/config/environments/production.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/hookup.rb".freeze, "test/dummy/config/initializers/application_controller_renderer.rb".freeze, "test/dummy/config/initializers/assets.rb".freeze, "test/dummy/config/initializers/backtrace_silencers.rb".freeze, "test/dummy/config/initializers/cookies_serializer.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/mime_types.rb".freeze, "test/dummy/config/initializers/rails_ops.rb".freeze, "test/dummy/config/initializers/wrap_parameters.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/config/secrets.yml".freeze, "test/dummy/config/spring.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/lib/assets/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/package.json".freeze, "test/dummy/public/404.html".freeze, "test/dummy/public/422.html".freeze, "test/dummy/public/500.html".freeze, "test/dummy/public/apple-touch-icon-precomposed.png".freeze, "test/dummy/public/apple-touch-icon.png".freeze, "test/dummy/public/favicon.ico".freeze, "test/dummy/tmp/.keep".freeze, "test/test_helper.rb".freeze, "test/unit/rails_ops/generators/operation_generator_test.rb".freeze, "test/unit/rails_ops/hookup_test.rb".freeze, "test/unit/rails_ops/mixins/controller_test.rb".freeze, "test/unit/rails_ops/mixins/model/deep_nesting_test.rb".freeze, "test/unit/rails_ops/mixins/model/marshalling_test.rb".freeze, "test/unit/rails_ops/mixins/param_authorization_test.rb".freeze, "test/unit/rails_ops/mixins/policies_test.rb".freeze, "test/unit/rails_ops/operation/auth_test.rb".freeze, "test/unit/rails_ops/operation/model/create_test.rb".freeze, "test/unit/rails_ops/operation/model/destroy_test.rb".freeze, "test/unit/rails_ops/operation/model/load_test.rb".freeze, "test/unit/rails_ops/operation/model/sti_test.rb".freeze, "test/unit/rails_ops/operation/model/update_test.rb".freeze, "test/unit/rails_ops/operation/model_test.rb".freeze, "test/unit/rails_ops/operation/update_lazy_auth_test.rb".freeze, "test/unit/rails_ops/operation_test.rb".freeze, "test/unit/rails_ops/profiler_test.rb".freeze, "test/unit/rails_ops/railtie_test.rb".freeze]
13
13
  s.homepage = "https://github.com/sitrox/rails_ops".freeze
14
14
  s.licenses = ["MIT".freeze]
15
15
  s.rubygems_version = "3.4.6".freeze
16
16
  s.summary = "An operations service layer for rails projects.".freeze
17
- s.test_files = ["test/db/models.rb".freeze, "test/db/schema.rb".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/assets/config/manifest.js".freeze, "test/dummy/app/assets/images/.keep".freeze, "test/dummy/app/assets/javascripts/application.js".freeze, "test/dummy/app/assets/javascripts/cable.js".freeze, "test/dummy/app/assets/javascripts/channels/.keep".freeze, "test/dummy/app/assets/stylesheets/application.css".freeze, "test/dummy/app/channels/application_cable/channel.rb".freeze, "test/dummy/app/channels/application_cable/connection.rb".freeze, "test/dummy/app/controllers/application_controller.rb".freeze, "test/dummy/app/controllers/concerns/.keep".freeze, "test/dummy/app/controllers/group_controller.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/jobs/application_job.rb".freeze, "test/dummy/app/mailers/application_mailer.rb".freeze, "test/dummy/app/models/ability.rb".freeze, "test/dummy/app/models/animal.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/bird.rb".freeze, "test/dummy/app/models/cat.rb".freeze, "test/dummy/app/models/computer.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/cpu.rb".freeze, "test/dummy/app/models/dog.rb".freeze, "test/dummy/app/models/flower.rb".freeze, "test/dummy/app/models/group.rb".freeze, "test/dummy/app/models/mainboard.rb".freeze, "test/dummy/app/models/nightingale.rb".freeze, "test/dummy/app/models/phoenix.rb".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/app/views/layouts/application.html.erb".freeze, "test/dummy/app/views/layouts/mailer.html.erb".freeze, "test/dummy/app/views/layouts/mailer.text.erb".freeze, "test/dummy/bin/bundle".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/bin/update".freeze, "test/dummy/bin/yarn".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/cable.yml".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/development.rb".freeze, "test/dummy/config/environments/production.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/hookup.rb".freeze, "test/dummy/config/initializers/application_controller_renderer.rb".freeze, "test/dummy/config/initializers/assets.rb".freeze, "test/dummy/config/initializers/backtrace_silencers.rb".freeze, "test/dummy/config/initializers/cookies_serializer.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/mime_types.rb".freeze, "test/dummy/config/initializers/rails_ops.rb".freeze, "test/dummy/config/initializers/wrap_parameters.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/config/secrets.yml".freeze, "test/dummy/config/spring.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/lib/assets/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/package.json".freeze, "test/dummy/public/404.html".freeze, "test/dummy/public/422.html".freeze, "test/dummy/public/500.html".freeze, "test/dummy/public/apple-touch-icon-precomposed.png".freeze, "test/dummy/public/apple-touch-icon.png".freeze, "test/dummy/public/favicon.ico".freeze, "test/dummy/tmp/.keep".freeze, "test/test_helper.rb".freeze, "test/unit/rails_ops/generators/operation_generator_test.rb".freeze, "test/unit/rails_ops/hookup_test.rb".freeze, "test/unit/rails_ops/mixins/controller_test.rb".freeze, "test/unit/rails_ops/mixins/model/deep_nesting_test.rb".freeze, "test/unit/rails_ops/mixins/model/marshalling_test.rb".freeze, "test/unit/rails_ops/mixins/param_authorization_test.rb".freeze, "test/unit/rails_ops/mixins/policies_test.rb".freeze, "test/unit/rails_ops/operation/auth_test.rb".freeze, "test/unit/rails_ops/operation/model/create_test.rb".freeze, "test/unit/rails_ops/operation/model/destroy_test.rb".freeze, "test/unit/rails_ops/operation/model/load_test.rb".freeze, "test/unit/rails_ops/operation/model/sti_test.rb".freeze, "test/unit/rails_ops/operation/model/update_test.rb".freeze, "test/unit/rails_ops/operation/model_test.rb".freeze, "test/unit/rails_ops/operation/update_lazy_auth_test.rb".freeze, "test/unit/rails_ops/operation_test.rb".freeze, "test/unit/rails_ops/profiler_test.rb".freeze]
17
+ s.test_files = ["test/db/models.rb".freeze, "test/db/schema.rb".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/assets/config/manifest.js".freeze, "test/dummy/app/assets/images/.keep".freeze, "test/dummy/app/assets/javascripts/application.js".freeze, "test/dummy/app/assets/javascripts/cable.js".freeze, "test/dummy/app/assets/javascripts/channels/.keep".freeze, "test/dummy/app/assets/stylesheets/application.css".freeze, "test/dummy/app/channels/application_cable/channel.rb".freeze, "test/dummy/app/channels/application_cable/connection.rb".freeze, "test/dummy/app/controllers/application_controller.rb".freeze, "test/dummy/app/controllers/concerns/.keep".freeze, "test/dummy/app/controllers/group_controller.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/jobs/application_job.rb".freeze, "test/dummy/app/mailers/application_mailer.rb".freeze, "test/dummy/app/models/ability.rb".freeze, "test/dummy/app/models/animal.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/bird.rb".freeze, "test/dummy/app/models/cat.rb".freeze, "test/dummy/app/models/computer.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/cpu.rb".freeze, "test/dummy/app/models/dog.rb".freeze, "test/dummy/app/models/flower.rb".freeze, "test/dummy/app/models/group.rb".freeze, "test/dummy/app/models/mainboard.rb".freeze, "test/dummy/app/models/nightingale.rb".freeze, "test/dummy/app/models/phoenix.rb".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/app/views/layouts/application.html.erb".freeze, "test/dummy/app/views/layouts/mailer.html.erb".freeze, "test/dummy/app/views/layouts/mailer.text.erb".freeze, "test/dummy/bin/bundle".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/bin/update".freeze, "test/dummy/bin/yarn".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/cable.yml".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/development.rb".freeze, "test/dummy/config/environments/production.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/hookup.rb".freeze, "test/dummy/config/initializers/application_controller_renderer.rb".freeze, "test/dummy/config/initializers/assets.rb".freeze, "test/dummy/config/initializers/backtrace_silencers.rb".freeze, "test/dummy/config/initializers/cookies_serializer.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/mime_types.rb".freeze, "test/dummy/config/initializers/rails_ops.rb".freeze, "test/dummy/config/initializers/wrap_parameters.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/config/secrets.yml".freeze, "test/dummy/config/spring.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/lib/assets/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/package.json".freeze, "test/dummy/public/404.html".freeze, "test/dummy/public/422.html".freeze, "test/dummy/public/500.html".freeze, "test/dummy/public/apple-touch-icon-precomposed.png".freeze, "test/dummy/public/apple-touch-icon.png".freeze, "test/dummy/public/favicon.ico".freeze, "test/dummy/tmp/.keep".freeze, "test/test_helper.rb".freeze, "test/unit/rails_ops/generators/operation_generator_test.rb".freeze, "test/unit/rails_ops/hookup_test.rb".freeze, "test/unit/rails_ops/mixins/controller_test.rb".freeze, "test/unit/rails_ops/mixins/model/deep_nesting_test.rb".freeze, "test/unit/rails_ops/mixins/model/marshalling_test.rb".freeze, "test/unit/rails_ops/mixins/param_authorization_test.rb".freeze, "test/unit/rails_ops/mixins/policies_test.rb".freeze, "test/unit/rails_ops/operation/auth_test.rb".freeze, "test/unit/rails_ops/operation/model/create_test.rb".freeze, "test/unit/rails_ops/operation/model/destroy_test.rb".freeze, "test/unit/rails_ops/operation/model/load_test.rb".freeze, "test/unit/rails_ops/operation/model/sti_test.rb".freeze, "test/unit/rails_ops/operation/model/update_test.rb".freeze, "test/unit/rails_ops/operation/model_test.rb".freeze, "test/unit/rails_ops/operation/update_lazy_auth_test.rb".freeze, "test/unit/rails_ops/operation_test.rb".freeze, "test/unit/rails_ops/profiler_test.rb".freeze, "test/unit/rails_ops/railtie_test.rb".freeze]
18
18
 
19
19
  s.specification_version = 4
20
20
 
@@ -193,8 +193,168 @@ class RailsOps::OperationTest < ActiveSupport::TestCase
193
193
  end
194
194
  end
195
195
  end.new
196
- assert_raises RailsOps::Exceptions::RollbackRequired do
196
+ RailsOps.deprecator.silence do
197
+ assert_raises RailsOps::Exceptions::RollbackRequired do
198
+ op.run
199
+ end
200
+ end
201
+ end
202
+
203
+ def test_with_rollback_on_exception_emits_deprecation_warning
204
+ # Use a fresh call site to bypass the per-process deduplication cache
205
+ # used by `with_rollback_on_exception`.
206
+ op = Class.new(RailsOps::Operation) do
207
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
208
+ def perform
209
+ with_rollback_on_exception do
210
+ # No-op; we only care about the deprecation warning being
211
+ # emitted on entry.
212
+ end
213
+ end
214
+ RUBY
215
+ end.new
216
+
217
+ messages = []
218
+ previous_behavior = RailsOps.deprecator.behavior
219
+ RailsOps.deprecator.behavior = ->(msg, _callstack, _name, _gem) { messages << msg }
220
+ begin
197
221
  op.run
222
+ ensure
223
+ RailsOps.deprecator.behavior = previous_behavior
224
+ end
225
+
226
+ assert_match(/with_rollback_on_exception.*deprecated/, messages.join("\n"))
227
+ end
228
+
229
+ def test_with_rollback_on_exception_deduplicates_per_call_site
230
+ op = Class.new(RailsOps::Operation) do
231
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
232
+ def perform
233
+ with_rollback_on_exception do
234
+ # No-op
235
+ end
236
+ end
237
+ RUBY
238
+ end
239
+
240
+ messages = []
241
+ previous_behavior = RailsOps.deprecator.behavior
242
+ RailsOps.deprecator.behavior = ->(msg, _callstack, _name, _gem) { messages << msg }
243
+ begin
244
+ op.new.run
245
+ op.new.run
246
+ op.new.run
247
+ ensure
248
+ RailsOps.deprecator.behavior = previous_behavior
249
+ end
250
+
251
+ assert_equal 1, messages.size, 'expected the warning to fire once per call site'
252
+ end
253
+
254
+ # When `run` is called inside an outer transaction and the operation does a
255
+ # save followed by a validation error, the save must be rolled back even
256
+ # though `run` swallows the error and returns `false`. Without the savepoint
257
+ # wrapping inside `run`, the partial write would leak into the outer
258
+ # transaction.
259
+ def test_run_rolls_back_partial_writes_when_inside_outer_transaction
260
+ op_class = Class.new(RailsOps::Operation::Model::Create) do
261
+ model Group
262
+
263
+ def perform
264
+ super
265
+ fail RailsOps::Exceptions::ValidationFailed, 'post-save check failed'
266
+ end
267
+ end
268
+
269
+ ActiveRecord::Base.transaction do
270
+ count_before = Group.count
271
+ refute op_class.run(group: { name: 'partial', color: 'red' })
272
+ assert_equal count_before, Group.count, 'expected save to be rolled back'
273
+ end
274
+ end
275
+
276
+ # `run!` keeps its existing behavior: validation errors propagate, the
277
+ # savepoint logic in `run` is not on the call path.
278
+ def test_run_bang_still_raises_inside_transaction
279
+ op_class = Class.new(RailsOps::Operation::Model::Create) do
280
+ model Group
281
+
282
+ def perform
283
+ super
284
+ fail RailsOps::Exceptions::ValidationFailed, 'post-save check failed'
285
+ end
286
+ end
287
+
288
+ assert_raises RailsOps::Exceptions::ValidationFailed do
289
+ ActiveRecord::Base.transaction do
290
+ op_class.run!(group: { name: 'partial', color: 'red' })
291
+ end
292
+ end
293
+ end
294
+
295
+ # Successful `run` calls inside a transaction still commit the model save.
296
+ def test_run_persists_model_on_success_inside_transaction
297
+ op_class = Class.new(RailsOps::Operation::Model::Create) do
298
+ model Group
299
+ end
300
+
301
+ ActiveRecord::Base.transaction do
302
+ count_before = Group.count
303
+ assert op_class.run(group: { name: 'fine', color: 'green' })
304
+ assert_equal count_before + 1, Group.count
305
+ end
306
+ end
307
+
308
+ # `run_sub` (non-bang) delegates to `run`, so a child operation that saves
309
+ # and then fails validation must not leak partial state into the parent
310
+ # transaction.
311
+ def test_run_sub_rolls_back_partial_writes_in_child_op
312
+ child_op = Class.new(RailsOps::Operation::Model::Create) do
313
+ model Group
314
+
315
+ def perform
316
+ super
317
+ fail RailsOps::Exceptions::ValidationFailed, 'post-save check failed'
318
+ end
319
+ end
320
+
321
+ parent_op = Class.new(RailsOps::Operation) do
322
+ attr_reader :sub_result
323
+
324
+ define_method(:perform) do
325
+ @sub_result = run_sub(child_op, group: { name: 'child', color: 'blue' })
326
+ end
327
+ end
328
+
329
+ ActiveRecord::Base.transaction do
330
+ count_before = Group.count
331
+ op = parent_op.new
332
+ op.run!
333
+ refute op.sub_result, 'expected run_sub to return false on child validation error'
334
+ assert_equal count_before, Group.count, 'expected child op save to be rolled back'
335
+ end
336
+ end
337
+
338
+ # The same protection applies when the failing exception is
339
+ # `ActiveRecord::RecordInvalid` (raised by `model.save!` on a real
340
+ # model validation failure), which is the more common production case.
341
+ def test_run_rolls_back_partial_writes_on_active_record_record_invalid
342
+ op_class = Class.new(RailsOps::Operation::Model::Create) do
343
+ model Group
344
+
345
+ def perform
346
+ super
347
+ # Trigger a real ActiveRecord::RecordInvalid on a separate record.
348
+ invalid = Group.new
349
+ invalid.errors.add(:base, 'invalid')
350
+ fail ActiveRecord::RecordInvalid, invalid
351
+ end
352
+ end
353
+
354
+ ActiveRecord::Base.transaction do
355
+ count_before = Group.count
356
+ refute op_class.run(group: { name: 'partial', color: 'red' })
357
+ assert_equal count_before, Group.count, 'expected save to be rolled back'
198
358
  end
199
359
  end
200
360
 
@@ -0,0 +1,29 @@
1
+ require 'test_helper'
2
+
3
+ class RailsOps::RailtieTest < ActiveSupport::TestCase
4
+ include TestHelper
5
+
6
+ def test_deprecator_is_registered_with_rails
7
+ skip 'Rails.application.deprecators is unavailable on this Rails version' \
8
+ unless Rails.application.respond_to?(:deprecators)
9
+
10
+ registered = Rails.application.deprecators[:rails_ops]
11
+ assert_same RailsOps.deprecator, registered,
12
+ 'expected RailsOps.deprecator to be registered as Rails.application.deprecators[:rails_ops]'
13
+ end
14
+
15
+ def test_deprecator_honors_silenced_flag_set_via_app_config
16
+ skip 'Rails.application.deprecators is unavailable on this Rails version' \
17
+ unless Rails.application.respond_to?(:deprecators)
18
+
19
+ previous_silenced = RailsOps.deprecator.silenced
20
+ begin
21
+ Rails.application.deprecators.silenced = true
22
+ assert RailsOps.deprecator.silenced,
23
+ 'expected RailsOps.deprecator to be silenced when ' \
24
+ 'Rails.application.deprecators.silenced = true'
25
+ ensure
26
+ Rails.application.deprecators.silenced = previous_silenced
27
+ end
28
+ end
29
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_ops
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.7
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-24 00:00:00.000000000 Z
10
+ date: 2026-05-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: active_type
@@ -258,6 +258,7 @@ files:
258
258
  - test/unit/rails_ops/operation/update_lazy_auth_test.rb
259
259
  - test/unit/rails_ops/operation_test.rb
260
260
  - test/unit/rails_ops/profiler_test.rb
261
+ - test/unit/rails_ops/railtie_test.rb
261
262
  homepage: https://github.com/sitrox/rails_ops
262
263
  licenses:
263
264
  - MIT
@@ -374,3 +375,4 @@ test_files:
374
375
  - test/unit/rails_ops/operation/update_lazy_auth_test.rb
375
376
  - test/unit/rails_ops/operation_test.rb
376
377
  - test/unit/rails_ops/profiler_test.rb
378
+ - test/unit/rails_ops/railtie_test.rb