rails_ops 1.7.6 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/rubocop.yml +1 -1
- data/CHANGELOG.md +18 -3
- data/Gemfile.lock +1 -1
- data/README.md +513 -12
- data/VERSION +1 -1
- data/lib/rails_ops/operation/model/load.rb +15 -11
- data/rails_ops.gemspec +3 -3
- data/test/dummy/app/models/group.rb +1 -0
- data/test/dummy/db/schema.rb +5 -0
- data/test/unit/rails_ops/operation/model/load_test.rb +46 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d40d2ec6a8a45a3725040a4f9fd0837ca7e42d36625827d1253e1722589e2e21
|
|
4
|
+
data.tar.gz: 53c235fe982863f62f0e6a756f6d7be9f127ad7b7693b1f46f4100b7b914bec8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d226ffc592c1c46ed0c536d6e33e3f54342afc59ef6e162670437b7d2847df8d05d213b62ff128de3cb1084e53470a8bd5c7fa37ac875c9e65610ab54585633d
|
|
7
|
+
data.tar.gz: 611fbd338f724d311d32d300677bf378d031891543bced3f074134793a45e106c337ea8a07958a36d6696f355965d0f23f004f14e2b881605cb68c1de58ac257
|
data/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.8 (2026-03-12)
|
|
4
|
+
|
|
5
|
+
* Add practical examples throughout README covering non-model operations,
|
|
6
|
+
find-or-create patterns, state validation policies, sub-operation
|
|
7
|
+
composition, complete CRUD sets, model defaults, custom authorization
|
|
8
|
+
actions, virtual datetime fields, operation inheritance, and load
|
|
9
|
+
operations for show pages.
|
|
10
|
+
|
|
11
|
+
## 1.7.7 (2026-02-24)
|
|
12
|
+
|
|
13
|
+
* Fix `find_model_relation` to merge the returned relation's conditions
|
|
14
|
+
into the operation's model class. Previously, overriding the method
|
|
15
|
+
with a relation on the base class (e.g. `User.where(...)`) would
|
|
16
|
+
bypass model extensions defined via `model do ... end`. The loaded
|
|
17
|
+
record is now always an instance of the correct (possibly extended)
|
|
18
|
+
model type.
|
|
19
|
+
|
|
3
20
|
## 1.7.6 (2026-02-24)
|
|
4
21
|
|
|
5
22
|
* Add `find_model_relation` hook to `RailsOps::Operation::Model::Load`.
|
|
6
23
|
This protected method can be overridden in subclasses to customize the
|
|
7
24
|
relation used for looking up the model, e.g. to apply scopes or use a
|
|
8
25
|
different base query. Since `Update` and `Destroy` inherit from `Load`,
|
|
9
|
-
the hook is available in all model operations that load a record.
|
|
10
|
-
default implementation returns `self.class.model`, preserving existing
|
|
11
|
-
behavior.
|
|
26
|
+
the hook is available in all model operations that load a record.
|
|
12
27
|
|
|
13
28
|
## 1.7.5 (2026-02-18)
|
|
14
29
|
|
data/Gemfile.lock
CHANGED
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,49 @@ 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
|
+
with_rollback_on_exception do
|
|
724
|
+
run_sub! Operations::Cache::Rebuild, rebuild_counters: true
|
|
725
|
+
run_sub! Operations::Notification::Send,
|
|
726
|
+
template: 'article_published',
|
|
727
|
+
record_id: model.id,
|
|
728
|
+
record_type: 'Article'
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
Note the use of `with_rollback_on_exception`: if any sub-operation fails after
|
|
736
|
+
`super` has already saved the article, the exception is re-raised as
|
|
737
|
+
`RailsOps::Exceptions::RollbackRequired`, which is not caught by `run` and
|
|
738
|
+
therefore causes a surrounding transaction to roll back. Without it, the article
|
|
739
|
+
could remain saved even if the notification fails (since `run` catches standard
|
|
740
|
+
validation errors). See section *Transactions* for more details.
|
|
741
|
+
|
|
582
742
|
## Contexts
|
|
583
743
|
|
|
584
744
|
Most operations make use of generic parameters like the current user or an
|
|
@@ -1046,7 +1206,6 @@ Rails Ops offers multiple ways of disabling authorization:
|
|
|
1046
1206
|
end
|
|
1047
1207
|
```
|
|
1048
1208
|
|
|
1049
|
-
|
|
1050
1209
|
## Model Operations
|
|
1051
1210
|
|
|
1052
1211
|
One of the key features of RailsOps is model operations. RailsOps provides
|
|
@@ -1169,15 +1328,21 @@ end
|
|
|
1169
1328
|
|
|
1170
1329
|
#### Customizing the Lookup Relation
|
|
1171
1330
|
|
|
1172
|
-
By default, `Load` operations look up the model using the
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1331
|
+
By default, `Load` operations look up the model using the class specified
|
|
1332
|
+
via the `model` DSL method. If you need to customize the lookup — for
|
|
1333
|
+
example to apply a scope, join additional tables, or restrict visibility
|
|
1334
|
+
— you can override the `protected` method `find_model_relation`.
|
|
1335
|
+
|
|
1336
|
+
The conditions from the returned relation are **merged** into the
|
|
1337
|
+
operation's model class, so the loaded record is always an instance of
|
|
1338
|
+
the correct (possibly extended) model type. This means model extensions
|
|
1339
|
+
defined via `model do ... end` (e.g. validations, callbacks) are always
|
|
1340
|
+
preserved.
|
|
1177
1341
|
|
|
1178
|
-
Since `Update` and `Destroy` operations inherit from `Load`, this hook
|
|
1179
|
-
available in all of them. For example, you can scope an `Update`
|
|
1180
|
-
so that it only finds records belonging to the current user's
|
|
1342
|
+
Since `Update` and `Destroy` operations inherit from `Load`, this hook
|
|
1343
|
+
is available in all of them. For example, you can scope an `Update`
|
|
1344
|
+
operation so that it only finds records belonging to the current user's
|
|
1345
|
+
organization:
|
|
1181
1346
|
|
|
1182
1347
|
```ruby
|
|
1183
1348
|
class Operations::User::Update < RailsOps::Operation::Model::Update
|
|
@@ -1191,9 +1356,8 @@ class Operations::User::Update < RailsOps::Operation::Model::Update
|
|
|
1191
1356
|
end
|
|
1192
1357
|
```
|
|
1193
1358
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
are applied on top of whatever relation this method returns.
|
|
1359
|
+
Locking and eager loading via `model_includes` are applied on top of
|
|
1360
|
+
the merged relation.
|
|
1197
1361
|
|
|
1198
1362
|
#### Locking
|
|
1199
1363
|
|
|
@@ -1355,6 +1519,124 @@ end
|
|
|
1355
1519
|
As this base class is very minimalistic, it is recommended to fully read and
|
|
1356
1520
|
comprehend its source code.
|
|
1357
1521
|
|
|
1522
|
+
### Practical Example: Complete CRUD Operation Set
|
|
1523
|
+
|
|
1524
|
+
Here is a complete, minimal CRUD set for a single model using `schema3`:
|
|
1525
|
+
|
|
1526
|
+
```ruby
|
|
1527
|
+
# app/operations/category/load.rb
|
|
1528
|
+
module Operations::Category
|
|
1529
|
+
class Load < RailsOps::Operation::Model::Load
|
|
1530
|
+
schema3 do
|
|
1531
|
+
int! :id
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
model ::Category
|
|
1535
|
+
end
|
|
1536
|
+
end
|
|
1537
|
+
|
|
1538
|
+
# app/operations/category/create.rb
|
|
1539
|
+
module Operations::Category
|
|
1540
|
+
class Create < RailsOps::Operation::Model::Create
|
|
1541
|
+
schema3 do
|
|
1542
|
+
hsh? :category do
|
|
1543
|
+
str? :name
|
|
1544
|
+
str? :description
|
|
1545
|
+
end
|
|
1546
|
+
end
|
|
1547
|
+
|
|
1548
|
+
model ::Category
|
|
1549
|
+
end
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
# app/operations/category/update.rb
|
|
1553
|
+
module Operations::Category
|
|
1554
|
+
class Update < RailsOps::Operation::Model::Update
|
|
1555
|
+
schema3 do
|
|
1556
|
+
int! :id
|
|
1557
|
+
hsh? :category do
|
|
1558
|
+
str? :name
|
|
1559
|
+
str? :description
|
|
1560
|
+
end
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
model ::Category
|
|
1564
|
+
end
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1567
|
+
# app/operations/category/destroy.rb
|
|
1568
|
+
module Operations::Category
|
|
1569
|
+
class Destroy < RailsOps::Operation::Model::Destroy
|
|
1570
|
+
schema3 do
|
|
1571
|
+
int! :id
|
|
1572
|
+
end
|
|
1573
|
+
|
|
1574
|
+
model ::Category
|
|
1575
|
+
end
|
|
1576
|
+
end
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
For `Create` and `Update`, parameter extraction happens automatically: the
|
|
1580
|
+
params nested under the model's `param_key` (`:category`) are assigned to the
|
|
1581
|
+
model. No `perform` method is needed — the base class handles `save!`.
|
|
1582
|
+
|
|
1583
|
+
### Practical Example: Setting Defaults on Create
|
|
1584
|
+
|
|
1585
|
+
Override `build_model` to set default values or assign associations that aren't
|
|
1586
|
+
part of the user's input:
|
|
1587
|
+
|
|
1588
|
+
```ruby
|
|
1589
|
+
module Operations::Article
|
|
1590
|
+
class Create < RailsOps::Operation::Model::Create
|
|
1591
|
+
schema3 do
|
|
1592
|
+
hsh? :article do
|
|
1593
|
+
str? :title
|
|
1594
|
+
str? :body
|
|
1595
|
+
int? :category_id
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
model ::Article
|
|
1600
|
+
|
|
1601
|
+
protected
|
|
1602
|
+
|
|
1603
|
+
def build_model
|
|
1604
|
+
super # Builds the model and assigns params from :article key
|
|
1605
|
+
model.author = context.user
|
|
1606
|
+
model.status = 'draft'
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
end
|
|
1610
|
+
```
|
|
1611
|
+
|
|
1612
|
+
`super` in `build_model` creates a new model instance and assigns the
|
|
1613
|
+
attributes from params. After `super`, you can set additional attributes.
|
|
1614
|
+
|
|
1615
|
+
### Practical Example: Overriding `build_model` in Update
|
|
1616
|
+
|
|
1617
|
+
In `Update` operations, `build_model` first loads the record (via `Load`), then
|
|
1618
|
+
assigns the params. Override it to modify the model after loading:
|
|
1619
|
+
|
|
1620
|
+
```ruby
|
|
1621
|
+
module Operations::Token
|
|
1622
|
+
class MarkUsed < RailsOps::Operation::Model::Update
|
|
1623
|
+
schema3 do
|
|
1624
|
+
int! :id
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
model ::Token
|
|
1628
|
+
without_authorization
|
|
1629
|
+
|
|
1630
|
+
protected
|
|
1631
|
+
|
|
1632
|
+
def build_model
|
|
1633
|
+
super # Loads the record and assigns params
|
|
1634
|
+
model.used_at = Time.current
|
|
1635
|
+
end
|
|
1636
|
+
end
|
|
1637
|
+
end
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1358
1640
|
### Including Associated Records
|
|
1359
1641
|
|
|
1360
1642
|
Normally, when inheriting from `RailsOps::Operation::Model::Load` (as well as from the
|
|
@@ -1383,6 +1665,46 @@ class Operations::User::Load < RailsOps::Operation::Model::Load
|
|
|
1383
1665
|
end
|
|
1384
1666
|
```
|
|
1385
1667
|
|
|
1668
|
+
#### Practical Example: Load Operation for Show Pages
|
|
1669
|
+
|
|
1670
|
+
A common pattern for "show" pages: a `Load` operation that provides helper
|
|
1671
|
+
methods for loading related data in the view:
|
|
1672
|
+
|
|
1673
|
+
```ruby
|
|
1674
|
+
module Operations::Frontend::Articles
|
|
1675
|
+
class Show < RailsOps::Operation::Model::Load
|
|
1676
|
+
model ::Article
|
|
1677
|
+
model_includes [:tags, :category, { comments: :author }]
|
|
1678
|
+
|
|
1679
|
+
def recent_comments(limit: 10)
|
|
1680
|
+
model.comments.order(created_at: :desc).limit(limit)
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
def related_articles
|
|
1684
|
+
@related_articles ||= ::Article
|
|
1685
|
+
.where(category_id: model.category_id)
|
|
1686
|
+
.where.not(id: model.id)
|
|
1687
|
+
.limit(5)
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
protected
|
|
1691
|
+
|
|
1692
|
+
# Load operations don't need perform logic, but the base class
|
|
1693
|
+
# raises NotImplementedError, so we override with a no-op.
|
|
1694
|
+
def perform; end
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
In the controller:
|
|
1700
|
+
|
|
1701
|
+
```ruby
|
|
1702
|
+
def show
|
|
1703
|
+
op Operations::Frontend::Articles::Show
|
|
1704
|
+
# In the view: op.model, op.recent_comments, op.related_articles
|
|
1705
|
+
end
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1386
1708
|
### Parameter Extraction for Create and Update
|
|
1387
1709
|
|
|
1388
1710
|
As mentioned before, the `Create` and `Update` base classes provide an
|
|
@@ -1478,6 +1800,43 @@ class Operations::User::Update < RailsOps::Operation::Model::Update
|
|
|
1478
1800
|
end
|
|
1479
1801
|
```
|
|
1480
1802
|
|
|
1803
|
+
#### Practical Example: Custom Authorization Actions
|
|
1804
|
+
|
|
1805
|
+
For operations beyond standard CRUD (e.g., archiving, classifying, publishing),
|
|
1806
|
+
specify custom authorization actions:
|
|
1807
|
+
|
|
1808
|
+
```ruby
|
|
1809
|
+
module Operations::Article
|
|
1810
|
+
class Archive < RailsOps::Operation::Model::Update
|
|
1811
|
+
schema3 do
|
|
1812
|
+
int! :id
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1815
|
+
load_model_authorization_action :read
|
|
1816
|
+
model_authorization_action :archive
|
|
1817
|
+
|
|
1818
|
+
model ::Article
|
|
1819
|
+
|
|
1820
|
+
protected
|
|
1821
|
+
|
|
1822
|
+
def perform
|
|
1823
|
+
model.archived = true
|
|
1824
|
+
model.archived_at = Time.current
|
|
1825
|
+
model.archived_by = context.user
|
|
1826
|
+
super
|
|
1827
|
+
end
|
|
1828
|
+
end
|
|
1829
|
+
end
|
|
1830
|
+
```
|
|
1831
|
+
|
|
1832
|
+
In your ability file, define the custom action:
|
|
1833
|
+
|
|
1834
|
+
```ruby
|
|
1835
|
+
can :archive, Article do |article|
|
|
1836
|
+
article.author_id == user.id || user.admin?
|
|
1837
|
+
end
|
|
1838
|
+
```
|
|
1839
|
+
|
|
1481
1840
|
### Model Nesting
|
|
1482
1841
|
|
|
1483
1842
|
Using active record, multiple nested models can be saved at once by using
|
|
@@ -1733,6 +2092,51 @@ class Operations::Order::Checkout < RailsOps::Operation::Model::Update
|
|
|
1733
2092
|
end
|
|
1734
2093
|
```
|
|
1735
2094
|
|
|
2095
|
+
#### Practical Example: Virtual Datetime Fields for Forms
|
|
2096
|
+
|
|
2097
|
+
A very common use case is adding virtual datetime attributes for form inputs
|
|
2098
|
+
that need to be transformed before saving:
|
|
2099
|
+
|
|
2100
|
+
```ruby
|
|
2101
|
+
module Operations::Event
|
|
2102
|
+
class Create < RailsOps::Operation::Model::Create
|
|
2103
|
+
schema3 do
|
|
2104
|
+
hsh? :event do
|
|
2105
|
+
str? :title
|
|
2106
|
+
str? :virtual_start_datetime
|
|
2107
|
+
str? :virtual_end_datetime
|
|
2108
|
+
boo? :all_day
|
|
2109
|
+
end
|
|
2110
|
+
end
|
|
2111
|
+
|
|
2112
|
+
model ::Event do
|
|
2113
|
+
attribute :virtual_start_datetime, :datetime
|
|
2114
|
+
attribute :virtual_end_datetime, :datetime
|
|
2115
|
+
|
|
2116
|
+
validates :virtual_start_datetime, presence: true, unless: :all_day?
|
|
2117
|
+
validates :virtual_end_datetime, presence: true, unless: :all_day?
|
|
2118
|
+
validates :virtual_end_datetime,
|
|
2119
|
+
comparison: { greater_than_or_equal_to: :virtual_start_datetime },
|
|
2120
|
+
if: -> { !all_day? && virtual_start_datetime.present? }
|
|
2121
|
+
end
|
|
2122
|
+
|
|
2123
|
+
protected
|
|
2124
|
+
|
|
2125
|
+
def build_model
|
|
2126
|
+
super
|
|
2127
|
+
|
|
2128
|
+
if model.all_day?
|
|
2129
|
+
model.start_date = model.virtual_start_datetime&.beginning_of_day
|
|
2130
|
+
model.end_date = model.virtual_end_datetime&.end_of_day
|
|
2131
|
+
else
|
|
2132
|
+
model.start_date = model.virtual_start_datetime
|
|
2133
|
+
model.end_date = model.virtual_end_datetime
|
|
2134
|
+
end
|
|
2135
|
+
end
|
|
2136
|
+
end
|
|
2137
|
+
end
|
|
2138
|
+
```
|
|
2139
|
+
|
|
1736
2140
|
### Combining Real and Virtual Models
|
|
1737
2141
|
|
|
1738
2142
|
You can create operations that work with both persisted and virtual data:
|
|
@@ -2122,6 +2526,103 @@ sub-operations, see section *Calling sub-operations* for more information.
|
|
|
2122
2526
|
|
|
2123
2527
|
## Operation Inheritance
|
|
2124
2528
|
|
|
2529
|
+
Operations support standard Ruby class inheritance. This is useful when multiple
|
|
2530
|
+
models share the same operation pattern. Create an abstract base operation and
|
|
2531
|
+
then inherit from it for each model:
|
|
2532
|
+
|
|
2533
|
+
```ruby
|
|
2534
|
+
# app/operations/base/toggle_active.rb
|
|
2535
|
+
module Operations::Base
|
|
2536
|
+
class ToggleActive < RailsOps::Operation::Model::Update
|
|
2537
|
+
schema3 do
|
|
2538
|
+
int! :id
|
|
2539
|
+
end
|
|
2540
|
+
|
|
2541
|
+
protected
|
|
2542
|
+
|
|
2543
|
+
def perform
|
|
2544
|
+
model.active = !model.active
|
|
2545
|
+
super
|
|
2546
|
+
end
|
|
2547
|
+
end
|
|
2548
|
+
end
|
|
2549
|
+
|
|
2550
|
+
# app/operations/category/toggle_active.rb
|
|
2551
|
+
module Operations::Category
|
|
2552
|
+
class ToggleActive < Operations::Base::ToggleActive
|
|
2553
|
+
model ::Category
|
|
2554
|
+
end
|
|
2555
|
+
end
|
|
2556
|
+
|
|
2557
|
+
# app/operations/tag/toggle_active.rb
|
|
2558
|
+
module Operations::Tag
|
|
2559
|
+
class ToggleActive < Operations::Base::ToggleActive
|
|
2560
|
+
model ::Tag
|
|
2561
|
+
end
|
|
2562
|
+
end
|
|
2563
|
+
```
|
|
2564
|
+
|
|
2565
|
+
The base class defines the common schema and behavior. Subclasses only need to
|
|
2566
|
+
specify the `model`. This avoids duplicating logic across many operations.
|
|
2567
|
+
|
|
2568
|
+
Schemas, policies, and authorization settings are all inherited. Subclasses can
|
|
2569
|
+
add additional policies or override methods as needed.
|
|
2570
|
+
|
|
2571
|
+
### Practical Example: Bulk Insert Operation Base
|
|
2572
|
+
|
|
2573
|
+
Another common base class is for bulk insert operations:
|
|
2574
|
+
|
|
2575
|
+
```ruby
|
|
2576
|
+
module Operations::Base
|
|
2577
|
+
class BulkCreate < RailsOps::Operation
|
|
2578
|
+
BATCH_SIZE = 500
|
|
2579
|
+
|
|
2580
|
+
without_authorization
|
|
2581
|
+
|
|
2582
|
+
protected
|
|
2583
|
+
|
|
2584
|
+
def perform
|
|
2585
|
+
unique_ids = ids_to_insert.uniq
|
|
2586
|
+
return if unique_ids.empty?
|
|
2587
|
+
|
|
2588
|
+
now = Time.current
|
|
2589
|
+
|
|
2590
|
+
unique_ids.each_slice(self.class::BATCH_SIZE) do |batch|
|
|
2591
|
+
records = build_records(batch, now)
|
|
2592
|
+
target_class.insert_all!(records)
|
|
2593
|
+
rescue ActiveRecord::RecordNotUnique
|
|
2594
|
+
# Race condition: filter out already-existing records and retry
|
|
2595
|
+
existing = existing_ids_for(batch)
|
|
2596
|
+
new_records = records.reject { |r| existing.include?(r[id_column]) }
|
|
2597
|
+
target_class.insert_all!(new_records) if new_records.any?
|
|
2598
|
+
end
|
|
2599
|
+
end
|
|
2600
|
+
|
|
2601
|
+
private
|
|
2602
|
+
|
|
2603
|
+
def ids_to_insert
|
|
2604
|
+
fail NotImplementedError
|
|
2605
|
+
end
|
|
2606
|
+
|
|
2607
|
+
def build_records(_batch, _now)
|
|
2608
|
+
fail NotImplementedError
|
|
2609
|
+
end
|
|
2610
|
+
|
|
2611
|
+
def target_class
|
|
2612
|
+
fail NotImplementedError
|
|
2613
|
+
end
|
|
2614
|
+
|
|
2615
|
+
def id_column
|
|
2616
|
+
:id
|
|
2617
|
+
end
|
|
2618
|
+
|
|
2619
|
+
def existing_ids_for(_batch)
|
|
2620
|
+
fail NotImplementedError
|
|
2621
|
+
end
|
|
2622
|
+
end
|
|
2623
|
+
end
|
|
2624
|
+
```
|
|
2625
|
+
|
|
2125
2626
|
## Generators
|
|
2126
2627
|
|
|
2127
2628
|
RailsOps features a generator to easily create a structure for common CRUD-style
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.7.
|
|
1
|
+
1.7.8
|
|
@@ -79,8 +79,10 @@ class RailsOps::Operation::Model::Load < RailsOps::Operation::Model
|
|
|
79
79
|
fail "Param #{model_id_field.inspect} must be given."
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# Obtain relation
|
|
83
|
-
|
|
82
|
+
# Obtain relation, always starting from the operation's model class
|
|
83
|
+
# to ensure the loaded record is of the correct (possibly extended)
|
|
84
|
+
# type, then merge in any custom conditions from find_model_relation.
|
|
85
|
+
relation = self.class.model.all.merge(find_model_relation)
|
|
84
86
|
|
|
85
87
|
# Express intention to lock if required
|
|
86
88
|
relation = lock_relation(relation)
|
|
@@ -112,23 +114,25 @@ class RailsOps::Operation::Model::Load < RailsOps::Operation::Model
|
|
|
112
114
|
|
|
113
115
|
protected
|
|
114
116
|
|
|
115
|
-
# Returns
|
|
116
|
-
# record. Override this method in
|
|
117
|
-
#
|
|
117
|
+
# Returns a relation whose conditions are merged into the operation's
|
|
118
|
+
# model class when looking up the record. Override this method in
|
|
119
|
+
# subclasses to customize the lookup, e.g. to apply scopes or
|
|
120
|
+
# restrict visibility.
|
|
118
121
|
#
|
|
119
|
-
# The
|
|
120
|
-
# `
|
|
121
|
-
#
|
|
122
|
-
#
|
|
122
|
+
# The conditions from this relation are merged onto
|
|
123
|
+
# `self.class.model`, so the loaded record is always an instance of
|
|
124
|
+
# the operation's (possibly extended) model class. This means model
|
|
125
|
+
# extensions defined via `model do ... end` are preserved.
|
|
123
126
|
#
|
|
124
|
-
# @return [ActiveRecord::Relation] the relation
|
|
127
|
+
# @return [ActiveRecord::Relation] the relation whose conditions are
|
|
128
|
+
# merged into the model class
|
|
125
129
|
#
|
|
126
130
|
# @example Scoping to the current user's organization
|
|
127
131
|
# def find_model_relation
|
|
128
132
|
# User.where(organization: context.user.organization)
|
|
129
133
|
# end
|
|
130
134
|
def find_model_relation
|
|
131
|
-
self.class.model
|
|
135
|
+
self.class.model.all
|
|
132
136
|
end
|
|
133
137
|
|
|
134
138
|
private
|
data/rails_ops.gemspec
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
# stub: rails_ops 1.7.
|
|
2
|
+
# stub: rails_ops 1.7.8 ruby lib
|
|
3
3
|
|
|
4
4
|
Gem::Specification.new do |s|
|
|
5
5
|
s.name = "rails_ops".freeze
|
|
6
|
-
s.version = "1.7.
|
|
6
|
+
s.version = "1.7.8"
|
|
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-
|
|
11
|
+
s.date = "2026-03-12"
|
|
12
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]
|
|
13
13
|
s.homepage = "https://github.com/sitrox/rails_ops".freeze
|
|
14
14
|
s.licenses = ["MIT".freeze]
|
data/test/dummy/db/schema.rb
CHANGED
|
@@ -15,6 +15,11 @@ ActiveRecord::Schema.define do
|
|
|
15
15
|
t.boolean :planted, null: false, default: true
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
create_table :users, force: true do |t|
|
|
19
|
+
t.string :name
|
|
20
|
+
t.references :group
|
|
21
|
+
end
|
|
22
|
+
|
|
18
23
|
create_table :computers, force: true do |t|
|
|
19
24
|
t.string :name
|
|
20
25
|
t.references :mainboard
|
|
@@ -74,7 +74,7 @@ class RailsOps::Operation::Model::LoadTest < ActiveSupport::TestCase
|
|
|
74
74
|
assert_equal g, op.model
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def test_find_model_relation_with_where
|
|
78
78
|
g1 = Group.create(name: 'visible')
|
|
79
79
|
g2 = Group.create(name: 'hidden')
|
|
80
80
|
|
|
@@ -94,4 +94,49 @@ class RailsOps::Operation::Model::LoadTest < ActiveSupport::TestCase
|
|
|
94
94
|
cls.new(id: g2.id)
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
|
+
|
|
98
|
+
def test_find_model_relation_with_joins
|
|
99
|
+
g1 = Group.create(name: 'with_users')
|
|
100
|
+
g2 = Group.create(name: 'empty')
|
|
101
|
+
User.create(name: 'Alice', group: g1)
|
|
102
|
+
|
|
103
|
+
cls = Class.new(RailsOps::Operation::Model::Load) do
|
|
104
|
+
model Group
|
|
105
|
+
|
|
106
|
+
protected
|
|
107
|
+
|
|
108
|
+
def find_model_relation
|
|
109
|
+
Group.joins(:users).where(users: { name: 'Alice' })
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
assert_equal g1, cls.new(id: g1.id).model
|
|
114
|
+
|
|
115
|
+
assert_raise ActiveRecord::RecordNotFound do
|
|
116
|
+
cls.new(id: g2.id)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_find_model_relation_preserves_model_extensions
|
|
121
|
+
g = Group.create(name: 'test')
|
|
122
|
+
|
|
123
|
+
cls = Class.new(RailsOps::Operation::Model::Load) do
|
|
124
|
+
model Group do
|
|
125
|
+
def custom_method
|
|
126
|
+
'extended'
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
protected
|
|
131
|
+
|
|
132
|
+
def find_model_relation
|
|
133
|
+
Group.where(name: 'test')
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
op = cls.new(id: g.id)
|
|
138
|
+
assert_equal 'extended', op.model.custom_method
|
|
139
|
+
assert op.model.class < Group
|
|
140
|
+
assert_not_equal Group, op.model.class
|
|
141
|
+
end
|
|
97
142
|
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.
|
|
4
|
+
version: 1.7.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sitrox
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-12 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: active_type
|