recordables 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +182 -0
- data/lib/generators/recordables/backfill/backfill_generator.rb +55 -0
- data/lib/generators/recordables/backfill/templates/backfill_migration.rb.tt +35 -0
- data/lib/generators/recordables/install/templates/recording.rb.tt +0 -2
- data/lib/recordables/has_children.rb +60 -0
- data/lib/recordables/immutable.rb +66 -0
- data/lib/recordables/macros.rb +108 -0
- data/lib/recordables/nested_recordable_attributes.rb +100 -0
- data/lib/recordables/recordable.rb +8 -0
- data/lib/recordables/recording.rb +25 -0
- data/lib/recordables/repoint_on_revise.rb +58 -0
- data/lib/recordables/testing.rb +30 -0
- data/lib/recordables/trashable.rb +79 -0
- data/lib/recordables/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7355a9bfb7d1440a8b7746b13b4fa810304eb837c97d8dc850011d12091241b6
|
|
4
|
+
data.tar.gz: 4f13296e03d0467244273af7f96566d21129d48013ec3279d2967236806181a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8053edc2e2c5cfa59c71c19658f2faba977bbc29e48b933d6eccd3086a548bb5d76ebcb6a0f65e8e8468f60694587a9c47b76ce2ee8c47a286f3fb631318868b
|
|
7
|
+
data.tar.gz: 504f8827f44890f43442e17f3af80874feb8dcdd88685f362e7e6bd78a00266e2e7710c387ad331480bc67e741facffdc897d97b9bc66eba13749f80b306fcab
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,57 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `trashable` — `default_scope` + `with_trashed` for a recordable type whose Recording
|
|
12
|
+
can be trashed, so a trashed row stops showing up in normal queries. Paired with
|
|
13
|
+
`Recording.active` and `Recording#trash!`.
|
|
14
|
+
- `recordable_belongs_to` — a `belongs_to` replacement for a target that's `trashable`.
|
|
15
|
+
Plain `belongs_to` builds its own `WHERE id = ...` directly against the target class,
|
|
16
|
+
bypassing `trashable`'s `default_scope` entirely, and silently returns a stale,
|
|
17
|
+
superseded row instead of the current one. Resolves through the append-only Event log
|
|
18
|
+
instead, which always names the current version.
|
|
19
|
+
- `immutable` — raises if `update`, `update!`, `update_column(s)`, `save` (with pending
|
|
20
|
+
changes), `destroy`, `delete`, or class-level `update_all` are called directly against
|
|
21
|
+
a persisted recordable, instead of silently mutating a row that's supposed to be an
|
|
22
|
+
immutable snapshot. `record`/`revise` are unaffected — they only ever save a fresh,
|
|
23
|
+
not-yet-persisted instance.
|
|
24
|
+
- `has_children` — the child-Recording pattern (a Task under its Routine) as two
|
|
25
|
+
generated methods per name: a read-only, ordered listing and an `add_*` that creates
|
|
26
|
+
the child with its own Recording, parented to the current one.
|
|
27
|
+
- `nested_recordable_attributes_for` — the `accepts_nested_attributes_for` shape for a
|
|
28
|
+
`has_children` association, since the real thing can't work here (it writes straight
|
|
29
|
+
to association rows at `assign_attributes` time; a child needs a Recording, which
|
|
30
|
+
needs the parent to be saved/revised first). Two-phase: `#{plural}_attributes=` stores
|
|
31
|
+
the submitted rows, `apply_#{plural}_attributes!` creates/revises/trashes them once
|
|
32
|
+
the parent has a current Recording.
|
|
33
|
+
- `repoint_on_revise` — for a real `has_many` pointed at a recordable type by a plain
|
|
34
|
+
foreign key (not a child Recording): repoints every row at the new id `revise()`
|
|
35
|
+
creates, in the same transaction, instead of leaving them FK'd to what just became
|
|
36
|
+
obsolete. Also defines `copy_content_to` as a no-op, since these associations were
|
|
37
|
+
never content to copy forward in the first place.
|
|
38
|
+
- `Recordables::Testing#current_recordable` — the fix for the most common test mistake
|
|
39
|
+
this gem's pattern invites: `#reload` on a recordable after an action that revised it
|
|
40
|
+
re-fetches by the object's own, now-superseded primary key, silently returning what
|
|
41
|
+
the row looked like *before*. Capture the Recording first, resolve through it after.
|
|
42
|
+
- `recordables:backfill` generator — scaffolds the migration for adopting
|
|
43
|
+
`recordable`/`trashable` on a table that already has rows. Exists because of one
|
|
44
|
+
specific trap: once a model has `trashable`, a bare `Model.find_each` inside the very
|
|
45
|
+
migration meant to create that model's first Recordings iterates zero rows — every
|
|
46
|
+
row is invisible under a scope that hides anything without an active Recording yet,
|
|
47
|
+
which at backfill time is all of them. `Model.with_trashed.find_each` fixes it, but
|
|
48
|
+
the migration "succeeds" having touched nothing if it's missing, and nothing about
|
|
49
|
+
that looks wrong until much later.
|
|
50
|
+
- `Recordable#recording` — the current Recording pointing at a snapshot
|
|
51
|
+
(`recordings.last`). Previously left for every consumer to define for themselves.
|
|
52
|
+
- `delete_all` / `destroy_all` on a `trashable` type now raise instead of silently only
|
|
53
|
+
clearing rows with an active Recording — the ambiguity that trips up a test teardown's
|
|
54
|
+
`delete_all`, a `DatabaseCleaner` truncation strategy, or a rake task clearing a table,
|
|
55
|
+
all expecting the table to end up actually empty. `with_trashed.delete_all` (or scoping
|
|
56
|
+
down first, e.g. `Model.where(...).delete_all`) says which is meant.
|
|
57
|
+
|
|
58
|
+
## [0.1.0]
|
|
59
|
+
|
|
9
60
|
### Fixed
|
|
10
61
|
|
|
11
62
|
- `revise` silently discarded edits to rich text: changes were merged into the column
|
data/README.md
CHANGED
|
@@ -114,11 +114,187 @@ than the snapshot, so revising content never touches them.
|
|
|
114
114
|
|---|---|
|
|
115
115
|
| Migrations, `Recording`, `Event`, `Bucket`, the concerns, each content type | **Generated into your app.** You own and edit these; the gem never touches them again |
|
|
116
116
|
| `records` / `recordable` macros, `revise`, `revert_to`, `versions`, `recordable_at`, `copy_content_to` and its guard | **Kept in the gem** |
|
|
117
|
+
| `trashable`, `recordable_belongs_to`, `immutable`, `has_children`, `repoint_on_revise`, `nested_recordable_attributes_for`, `Recordables::Testing` | **Kept in the gem** — all opt-in, none of it changes behavior for a model that doesn't call it |
|
|
117
118
|
|
|
118
119
|
The rule: **generate what's opinionated, keep what fails silently.** Permissions, controllers
|
|
119
120
|
and tree semantics are deliberately yours — that is why the generated files are plain Rails
|
|
120
121
|
you can rewrite freely.
|
|
121
122
|
|
|
123
|
+
## Trashing, and the belongs_to trap
|
|
124
|
+
|
|
125
|
+
`recordable` alone doesn't give you soft-delete — a "trashed" Recording still leaves its
|
|
126
|
+
row visible to every plain query, since `trash!` only changes the *Recording's* status,
|
|
127
|
+
nothing about the row itself. `trashable` closes that gap:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class Article < ApplicationRecord
|
|
131
|
+
recordable
|
|
132
|
+
trashable
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
article.recording.trash!(actor: current_user)
|
|
136
|
+
|
|
137
|
+
Article.count # doesn't see it
|
|
138
|
+
Article.with_trashed.count # does
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The one thing this doesn't fix by itself is `belongs_to`. Rails' association reader builds
|
|
142
|
+
its own `WHERE id = ...` directly against the target class, bypassing `trashable`'s
|
|
143
|
+
`default_scope` entirely — so a plain `belongs_to :article` on some other model silently
|
|
144
|
+
returns the stale, trashed-or-superseded row instead of nil or the current version. This is
|
|
145
|
+
the sharpest edge in the whole pattern, and it fails silently: nothing raises, the wrong data
|
|
146
|
+
just quietly comes back. `recordable_belongs_to` is the fix:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class Comment < ApplicationRecord
|
|
150
|
+
recordable_belongs_to :article
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
It resolves through the append-only `Event` log instead of the raw foreign key — an event's
|
|
155
|
+
`recordable_id` is set once, when that event happened, and never rewritten, so it still names
|
|
156
|
+
the right lineage no matter how many times the target's been revised since.
|
|
157
|
+
|
|
158
|
+
## Enforcing immutability
|
|
159
|
+
|
|
160
|
+
Nothing about `recordable` stops a `template.update!(name: "x")` from working even after
|
|
161
|
+
you've adopted `revise()` everywhere else — it just silently mutates the row in place. No new
|
|
162
|
+
snapshot, no history entry, no way to revert, and anything reading `cache_version` or
|
|
163
|
+
`updated_at` through the Recording never sees the change. `immutable` turns that into a hard
|
|
164
|
+
failure instead of a silent one:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class Article < ApplicationRecord
|
|
168
|
+
recordable
|
|
169
|
+
immutable
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
article.update!(title: "x") # raises Recordables::ImmutableRecordable
|
|
173
|
+
article.update_column(:title, "x") # raises too — callbacks alone don't catch this
|
|
174
|
+
Article.update_all(title: "x") # raises at the class level
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`record`/`revise` are unaffected — they only ever save a fresh, not-yet-persisted instance,
|
|
178
|
+
never a second write against a row that's already there.
|
|
179
|
+
|
|
180
|
+
## Children: a real has_many, or a child Recording?
|
|
181
|
+
|
|
182
|
+
Two different shapes both look like "this recordable owns other rows," and mixing them up
|
|
183
|
+
either loses data or silently orphans it.
|
|
184
|
+
|
|
185
|
+
**A child Recording** (`has_children`) is for content that's genuinely part of the parent's
|
|
186
|
+
lineage — a `Task` under its `Routine`, a line item under an order. A plain foreign key would
|
|
187
|
+
orphan every child the moment the parent is revised (a new row, a new id) — the fix is
|
|
188
|
+
letting the *child's own Recording* carry the parent link instead, via `parent_id`, which
|
|
189
|
+
survives revisions because a Recording's own id never changes:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
class Routine < ApplicationRecord
|
|
193
|
+
recordable
|
|
194
|
+
has_children :tasks
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
routine.tasks # current children, ordered
|
|
198
|
+
routine.add_task(actor: current_user, name: "Stretch")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**A real `has_many`** (`repoint_on_revise`) is for join rows that point *at* a recordable by a
|
|
202
|
+
plain foreign key, and need to follow a revision rather than travel with the child-Recording
|
|
203
|
+
tree — a tagging table, an assignment. `revise()` alone would leave every one of those rows
|
|
204
|
+
FK'd to the old, now-superseded id. `repoint_on_revise` moves them to the new one in the same
|
|
205
|
+
transaction, and also defines `copy_content_to` as a no-op — these associations were never
|
|
206
|
+
content to *copy forward*, they're pointers *at* the row, so the default
|
|
207
|
+
`UncopyableAssociation` guard doesn't apply here:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class RoutineTemplate < ApplicationRecord
|
|
211
|
+
recordable
|
|
212
|
+
repoint_on_revise :routine_template_people
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
If in doubt: does the association exist to organize the recordable's own content (child
|
|
217
|
+
Recording), or does something external point at it (repoint_on_revise)?
|
|
218
|
+
|
|
219
|
+
## Nested forms
|
|
220
|
+
|
|
221
|
+
`accepts_nested_attributes_for` can't work on a `has_children` association — it writes
|
|
222
|
+
straight to association rows at `assign_attributes` time, but a new child needs its own
|
|
223
|
+
Recording, which needs the parent to already be saved or revised. `nested_recordable_attributes_for`
|
|
224
|
+
does the same job (a form posts an array of `{id:, ...fields, _destroy:}` hashes) through
|
|
225
|
+
`revise()`/`trash!` instead of a raw write:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class Routine < ApplicationRecord
|
|
229
|
+
recordable
|
|
230
|
+
has_children :tasks
|
|
231
|
+
nested_recordable_attributes_for :tasks
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```erb
|
|
236
|
+
<%= form_with model: @routine do |f| %>
|
|
237
|
+
<%= f.fields_for :tasks, @routine.tasks_for_form do |task_fields| %>
|
|
238
|
+
...
|
|
239
|
+
<% end %>
|
|
240
|
+
<% end %>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Two-phase, matching how a controller/interactor already has to split "assign" from "save"
|
|
244
|
+
here: `tasks_attributes=` stores the submitted rows without writing anything, then
|
|
245
|
+
`apply_tasks_attributes!(actor:)` — called once the routine itself has a current Recording —
|
|
246
|
+
creates, revises, or trashes each one.
|
|
247
|
+
|
|
248
|
+
## Testing
|
|
249
|
+
|
|
250
|
+
The most common mistake this whole pattern invites: calling `#reload` on a recordable after
|
|
251
|
+
an action that revised it.
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
task = create_a_task
|
|
255
|
+
complete_the_task(task)
|
|
256
|
+
task.reload.status # => "initial", not "complete"
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`revise()` gives an edit a brand new row — a new primary key — and repoints the Recording at
|
|
260
|
+
it. `task` still holds the *old* primary key, so `#reload` re-fetches by that key: not an
|
|
261
|
+
error, not nil, just exactly the row the action was supposed to replace. A test asserting on
|
|
262
|
+
`task` after the action silently checks what things looked like *before*.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
class ActiveSupport::TestCase
|
|
266
|
+
include Recordables::Testing
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
recording = task.recording # capture before — a Recording's id never changes
|
|
272
|
+
complete_the_task(task)
|
|
273
|
+
assert_equal "complete", current_recordable(recording).status
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Adopting recordable on an existing table
|
|
277
|
+
|
|
278
|
+
Once a model has `trashable`, its `default_scope` hides any row with no active
|
|
279
|
+
Recording — which, the moment you first add `trashable`, is every existing row. The
|
|
280
|
+
migration that's supposed to create each row's first Recording has to bypass that scope to
|
|
281
|
+
see them at all:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
bin/rails generate recordables:backfill RoutineTemplate
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# generated: db/migrate/..._backfill_routine_templates_recordings.rb
|
|
289
|
+
RoutineTemplate.with_trashed.find_each do |routine_template|
|
|
290
|
+
Recording.record(routine_template, actor: routine_template.actor, created_at: routine_template.created_at)
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Miss `.with_trashed` here — plain `find_each` — and the migration doesn't error. It "succeeds"
|
|
295
|
+
in milliseconds, having created zero Recordings, and nothing about that looks wrong until
|
|
296
|
+
something downstream (a query, a destroy cascade) turns up rows with no history at all.
|
|
297
|
+
|
|
122
298
|
## Caveats
|
|
123
299
|
|
|
124
300
|
- Never put a `uniqueness` validation on a recordable. Old snapshots still hold the old
|
|
@@ -128,6 +304,12 @@ you can rewrite freely.
|
|
|
128
304
|
- `events.details` is a `json` column. Ruby 4 ships json 3.x, whose `JSON.parse` moved to
|
|
129
305
|
keyword arguments while ActiveSupport 8.1 still calls it positionally. Pin
|
|
130
306
|
`gem "json", "~> 2.7"` until that is fixed upstream.
|
|
307
|
+
- On a `trashable` type, `Model.delete_all` / `Model.destroy_all` raise rather than run.
|
|
308
|
+
Under `default_scope` they'd only ever touch rows with an active Recording — anything
|
|
309
|
+
already trashed survives, silently — which is exactly backwards from what a test
|
|
310
|
+
teardown's `delete_all` or a `DatabaseCleaner` truncation strategy expects. Call
|
|
311
|
+
`Model.with_trashed.delete_all` if you mean it, or scope down first
|
|
312
|
+
(`Model.where(...).delete_all`) if you meant only some of the active rows.
|
|
131
313
|
|
|
132
314
|
## Development
|
|
133
315
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
require "recordables/generator_helpers"
|
|
4
|
+
|
|
5
|
+
module Recordables
|
|
6
|
+
module Generators
|
|
7
|
+
# Scaffolds the migration for adopting recordable/trashable on a table
|
|
8
|
+
# that already has rows: create the first Recording (and "created"
|
|
9
|
+
# Event) for each one.
|
|
10
|
+
#
|
|
11
|
+
# bin/rails generate recordables:backfill RoutineTemplate
|
|
12
|
+
#
|
|
13
|
+
# Exists because of one specific, easy-to-miss trap: once a model has
|
|
14
|
+
# trashable's default_scope, `Model.find_each` inside the very
|
|
15
|
+
# migration meant to create that model's first Recordings iterates
|
|
16
|
+
# zero rows — every row is invisible under a scope that hides anything
|
|
17
|
+
# without an active Recording yet, which at backfill time is all of
|
|
18
|
+
# them. The fix (`Model.with_trashed.find_each`) is one word, but
|
|
19
|
+
# silent when missing: the migration "succeeds" in milliseconds having
|
|
20
|
+
# touched nothing, and nothing about that looks wrong until much later.
|
|
21
|
+
class BackfillGenerator < Rails::Generators::NamedBase
|
|
22
|
+
include ActiveRecord::Generators::Migration
|
|
23
|
+
include Recordables::GeneratorHelpers
|
|
24
|
+
|
|
25
|
+
source_root File.expand_path("templates", __dir__)
|
|
26
|
+
|
|
27
|
+
desc "Generate a migration that backfills the first Recording for every existing row of a type."
|
|
28
|
+
|
|
29
|
+
class_option :creator, type: :string, default: nil,
|
|
30
|
+
desc: "Attribute on the row holding who created it, e.g. person_id"
|
|
31
|
+
class_option :account, type: :string, default: nil,
|
|
32
|
+
desc: "Attribute on the row holding its account/tenant, e.g. account_id"
|
|
33
|
+
|
|
34
|
+
def validate!
|
|
35
|
+
validate_name!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_backfill_migration
|
|
39
|
+
migration_template "backfill_migration.rb.tt", "db/migrate/backfill_#{table_name}_recordings.rb"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def generator_example = "bin/rails generate recordables:backfill RoutineTemplate"
|
|
45
|
+
|
|
46
|
+
def creator_expression
|
|
47
|
+
options[:creator].presence || "actor"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def account_expression
|
|
51
|
+
options[:account].presence || "account"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class Backfill<%= class_name.pluralize %>Recordings < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
<%= class_name %>.reset_column_information
|
|
4
|
+
|
|
5
|
+
say_with_time "Creating a Recording for every existing <%= file_name %>" do
|
|
6
|
+
count = 0
|
|
7
|
+
|
|
8
|
+
# trashable's default_scope hides any row with no active Recording —
|
|
9
|
+
# exactly every row this backfill is about to create the first one
|
|
10
|
+
# for. Without with_trashed this silently iterates zero rows: the
|
|
11
|
+
# migration "succeeds" in milliseconds having touched nothing, which
|
|
12
|
+
# is easy to miss since nothing about it raises or looks wrong.
|
|
13
|
+
<%= class_name %>.with_trashed.find_each do |<%= file_name %>|
|
|
14
|
+
Recording.record(
|
|
15
|
+
<%= file_name %>,
|
|
16
|
+
actor: <%= file_name %>.<%= creator_expression %>,
|
|
17
|
+
<% if account_expression != "nil" -%>
|
|
18
|
+
account: <%= file_name %>.<%= account_expression %>,
|
|
19
|
+
<% end -%>
|
|
20
|
+
created_at: <%= file_name %>.created_at
|
|
21
|
+
)
|
|
22
|
+
count += 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
"#{count} <%= table_name %>"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def down
|
|
30
|
+
Recording.where(recordable_type: "<%= class_name %>").with_trashed.find_each do |recording|
|
|
31
|
+
recording.events.delete_all
|
|
32
|
+
recording.delete
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Recordables
|
|
4
|
+
# Opt-in alongside `recordable` for a type whose Recording parents other
|
|
5
|
+
# Recordings — a Task under its Routine, a TaskTemplate under its
|
|
6
|
+
# RoutineTemplate. Not a real has_many: a child's own Recording carries
|
|
7
|
+
# the parent link (parent_id), not a foreign key on the child row itself
|
|
8
|
+
# — a plain FK would silently orphan every child the moment its parent
|
|
9
|
+
# is revised, since revise() replaces the parent row with a new id.
|
|
10
|
+
#
|
|
11
|
+
# class RoutineTemplate < ApplicationRecord
|
|
12
|
+
# recordable
|
|
13
|
+
# has_children :task_templates, class_name: "TaskTemplate"
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Defines two methods per name:
|
|
17
|
+
#
|
|
18
|
+
# template.task_templates
|
|
19
|
+
# # => current children, ordered
|
|
20
|
+
# template.add_task_template(actor:, recording: {}, **attrs)
|
|
21
|
+
# # => Recording.record(TaskTemplate.new(attrs), actor:, parent:, **recording)
|
|
22
|
+
#
|
|
23
|
+
# Read-heavy chaining (.count, .where, .order, .each, .find, ...) works
|
|
24
|
+
# the same as a real has_many. Building/creating a child goes through
|
|
25
|
+
# add_* instead — "create a child" is a recording operation (it needs
|
|
26
|
+
# its own Recording, parented to this one), not a plain attribute
|
|
27
|
+
# assignment a bare .build could do.
|
|
28
|
+
#
|
|
29
|
+
# attrs become the child model's own attributes; recording: carries
|
|
30
|
+
# anything that belongs on the Recording itself instead (account:,
|
|
31
|
+
# bucket:, or any other extra column the host app's Recording has) —
|
|
32
|
+
# kept as an explicit, separate argument rather than guessed at by
|
|
33
|
+
# inspecting attrs, since a model attribute happening to share a name
|
|
34
|
+
# with a Recording column would otherwise route to the wrong place
|
|
35
|
+
# silently.
|
|
36
|
+
module HasChildren
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
class_methods do
|
|
40
|
+
def __recordables_define_children(plural_name, class_name: nil)
|
|
41
|
+
singular_name = plural_name.to_s.singularize
|
|
42
|
+
target_class_name = class_name&.to_s || plural_name.to_s.classify
|
|
43
|
+
|
|
44
|
+
define_method(plural_name) do
|
|
45
|
+
target = target_class_name.constantize
|
|
46
|
+
return target.none unless recording
|
|
47
|
+
|
|
48
|
+
target.joins(:recordings).merge(::Recording.active.where(parent_id: recording.id))
|
|
49
|
+
.order("recordings.position")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
define_method(:"add_#{singular_name}") do |actor: nil, recording: {}, **attrs|
|
|
53
|
+
target = target_class_name.constantize
|
|
54
|
+
|
|
55
|
+
::Recording.record(target.new(attrs), actor: actor, parent: self.recording, **recording)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Recordables
|
|
4
|
+
# Raised by a write attempted directly against a persisted immutable
|
|
5
|
+
# recordable — see Immutable for why, and what to call instead.
|
|
6
|
+
class ImmutableRecordable < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Opt-in alongside `recordable` for a type that should never be updated
|
|
9
|
+
# in place after it's first saved — only revise() (a new row) or trash!
|
|
10
|
+
# (on its Recording) should ever change what a caller sees.
|
|
11
|
+
#
|
|
12
|
+
# class RoutineTemplate < ApplicationRecord
|
|
13
|
+
# recordable
|
|
14
|
+
# immutable
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Nothing in Rails stops a plain `template.update!(name: "x")` from
|
|
18
|
+
# working even after adopting the revise() pattern — it just silently
|
|
19
|
+
# mutates the row in place instead of raising or inserting a new one.
|
|
20
|
+
# The row still looks "current" to every reader, but no new Recording
|
|
21
|
+
# snapshot was created: no history entry, no way to revert, and any
|
|
22
|
+
# cache_version or updated_at logic that reads through the Recording
|
|
23
|
+
# never sees the change. That's exactly the failure this gem exists to
|
|
24
|
+
# prevent, and nothing about #recordable on its own stops it.
|
|
25
|
+
#
|
|
26
|
+
# Only guards persisted rows: the record/revise flow itself calls
|
|
27
|
+
# `.new(...).save!` on a fresh, not-yet-persisted instance, which is
|
|
28
|
+
# the one write path this is not meant to catch.
|
|
29
|
+
module Immutable
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
included do
|
|
33
|
+
before_update { raise_immutable!(:update) }
|
|
34
|
+
before_destroy { raise_immutable!(:destroy) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# update_column/update_columns/delete bypass callbacks entirely (that's
|
|
38
|
+
# their whole point), so before_update/before_destroy above never see
|
|
39
|
+
# them — each needs its own override to still be caught.
|
|
40
|
+
def update_column(...) = raise_immutable!(:update_column)
|
|
41
|
+
def update_columns(...) = raise_immutable!(:update_columns)
|
|
42
|
+
def delete = raise_immutable!(:delete)
|
|
43
|
+
|
|
44
|
+
class_methods do
|
|
45
|
+
def update_all(...)
|
|
46
|
+
raise Recordables::ImmutableRecordable, <<~MESSAGE.squish
|
|
47
|
+
#{name}.update_all writes directly to the row instead of calling
|
|
48
|
+
revise() on each recordable's Recording — no new snapshot, no
|
|
49
|
+
history entry, nothing to revert to. Loop and call
|
|
50
|
+
recording.revise(actor:, **changes) per row instead.
|
|
51
|
+
MESSAGE
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def raise_immutable!(verb)
|
|
58
|
+
raise Recordables::ImmutableRecordable, <<~MESSAGE.squish
|
|
59
|
+
#{self.class.name}##{verb} was called directly on a persisted
|
|
60
|
+
recordable — #{verb == :destroy ? "trash! its Recording" : "call revise(actor:, **changes) on its Recording"}
|
|
61
|
+
instead, so the change goes through the versioning this gem
|
|
62
|
+
exists to provide.
|
|
63
|
+
MESSAGE
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/recordables/macros.rb
CHANGED
|
@@ -22,5 +22,113 @@ module Recordables
|
|
|
22
22
|
def recordable
|
|
23
23
|
include Recordables::Recordable
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Adds default_scope + with_trashed to a recordable type, so a trashed
|
|
27
|
+
# Recording's row stops showing up in normal queries. See
|
|
28
|
+
# Recordables::Trashable for the mechanics.
|
|
29
|
+
#
|
|
30
|
+
# class RoutineTemplate < ApplicationRecord
|
|
31
|
+
# recordable
|
|
32
|
+
# trashable
|
|
33
|
+
# end
|
|
34
|
+
def trashable
|
|
35
|
+
include Recordables::Trashable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raises if anything tries to write directly to a persisted recordable
|
|
39
|
+
# row instead of going through revise()/trash! on its Recording. See
|
|
40
|
+
# Recordables::Immutable for the mechanics.
|
|
41
|
+
#
|
|
42
|
+
# class RoutineTemplate < ApplicationRecord
|
|
43
|
+
# recordable
|
|
44
|
+
# immutable
|
|
45
|
+
# end
|
|
46
|
+
def immutable
|
|
47
|
+
include Recordables::Immutable
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Repoints real has_many associations (plain FK, not a child Recording)
|
|
51
|
+
# at each new row a revision creates, instead of leaving them pointed
|
|
52
|
+
# at what revise() just made obsolete. See Recordables::RepointOnRevise
|
|
53
|
+
# for the mechanics.
|
|
54
|
+
#
|
|
55
|
+
# class RoutineTemplate < ApplicationRecord
|
|
56
|
+
# recordable
|
|
57
|
+
# repoint_on_revise :routine_template_people
|
|
58
|
+
# end
|
|
59
|
+
def repoint_on_revise(*association_names)
|
|
60
|
+
include Recordables::RepointOnRevise
|
|
61
|
+
self.repointed_association_names = association_names
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A recordable type whose Recording parents other Recordings — a Task
|
|
65
|
+
# under its Routine. See Recordables::HasChildren for the mechanics.
|
|
66
|
+
#
|
|
67
|
+
# class RoutineTemplate < ApplicationRecord
|
|
68
|
+
# recordable
|
|
69
|
+
# has_children :task_templates
|
|
70
|
+
# end
|
|
71
|
+
def has_children(plural_name, class_name: nil)
|
|
72
|
+
include Recordables::HasChildren
|
|
73
|
+
__recordables_define_children(plural_name, class_name: class_name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The accepts_nested_attributes_for shape for a has_children
|
|
77
|
+
# association — a fields_for form editing a recordable parent's
|
|
78
|
+
# children. See Recordables::NestedRecordableAttributes for the
|
|
79
|
+
# mechanics. Requires has_children for the same name first.
|
|
80
|
+
#
|
|
81
|
+
# class RoutineTemplate < ApplicationRecord
|
|
82
|
+
# recordable
|
|
83
|
+
# has_children :task_templates, class_name: "TaskTemplate"
|
|
84
|
+
# nested_recordable_attributes_for :task_templates, class_name: "TaskTemplate"
|
|
85
|
+
# end
|
|
86
|
+
def nested_recordable_attributes_for(plural_name, class_name: nil)
|
|
87
|
+
include Recordables::NestedRecordableAttributes
|
|
88
|
+
target_class_name = class_name&.to_s || plural_name.to_s.classify
|
|
89
|
+
__recordables_define_nested_attributes(plural_name, class_name: target_class_name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A belongs_to whose target is trashable, resolved through the append-only
|
|
93
|
+
# Event log instead of the raw foreign key.
|
|
94
|
+
#
|
|
95
|
+
# Rails' belongs_to association reader builds its own `WHERE id = ...`
|
|
96
|
+
# query directly against the target class — bypassing trashable's
|
|
97
|
+
# default_scope entirely, since that scope is added to the class's own
|
|
98
|
+
# query interface, not baked into every possible query Rails might build
|
|
99
|
+
# against it. A plain belongs_to therefore silently returns the stale,
|
|
100
|
+
# trashed-or-superseded row instead of nil once the target's been revised
|
|
101
|
+
# or trashed — exactly the case this exists to handle.
|
|
102
|
+
#
|
|
103
|
+
# An Event's recordable_id is set once, at the moment that event
|
|
104
|
+
# happened, and never rewritten — so the "created" event for a given
|
|
105
|
+
# target id still names that id no matter how many times the target's
|
|
106
|
+
# been revised since. That event's Recording is the stable pointer for
|
|
107
|
+
# the whole lineage, so walking through it reaches whatever version is
|
|
108
|
+
# current right now: the original row if it still is, or the latest
|
|
109
|
+
# revision if the original has since been revised or trashed.
|
|
110
|
+
#
|
|
111
|
+
# class Routine < ApplicationRecord
|
|
112
|
+
# recordable_belongs_to :routine_template
|
|
113
|
+
# end
|
|
114
|
+
#
|
|
115
|
+
# Accepts the same options as belongs_to (class_name, foreign_key,
|
|
116
|
+
# optional, etc) and still defines the plain association under the hood
|
|
117
|
+
# — for eager loading, FK validation, forms — only the reader method is
|
|
118
|
+
# overridden.
|
|
119
|
+
def recordable_belongs_to(name, class_name: nil, foreign_key: nil, **options)
|
|
120
|
+
belongs_to name, class_name: class_name, foreign_key: foreign_key, **options
|
|
121
|
+
|
|
122
|
+
target_class_name = class_name&.to_s || name.to_s.camelize
|
|
123
|
+
fk = foreign_key&.to_s || "#{name}_id"
|
|
124
|
+
|
|
125
|
+
define_method(name) do
|
|
126
|
+
id = public_send(fk)
|
|
127
|
+
next nil if id.blank?
|
|
128
|
+
|
|
129
|
+
::Event.where(recordable_type: target_class_name, recordable_id: id)
|
|
130
|
+
.first&.recording&.recordable
|
|
131
|
+
end
|
|
132
|
+
end
|
|
25
133
|
end
|
|
26
134
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Recordables
|
|
4
|
+
# Opt-in alongside `has_children` for the accepts_nested_attributes_for
|
|
5
|
+
# shape (a form with fields_for) on a recordable parent's children.
|
|
6
|
+
#
|
|
7
|
+
# class RoutineTemplate < ApplicationRecord
|
|
8
|
+
# recordable
|
|
9
|
+
# has_children :task_templates, class_name: "TaskTemplate"
|
|
10
|
+
# nested_recordable_attributes_for :task_templates, class_name: "TaskTemplate"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# accepts_nested_attributes_for can't work here: it writes at
|
|
14
|
+
# assign_attributes time, straight to a real association's rows — but
|
|
15
|
+
# task_templates isn't a real has_many (see HasChildren), and a child
|
|
16
|
+
# needs its own Recording the moment it's created, which a plain
|
|
17
|
+
# attribute assignment can't set up. This does the same job (a form
|
|
18
|
+
# posts an array of {id:, ...fields, _destroy:} hashes; existing rows
|
|
19
|
+
# get updated or trashed, new ones get created) through revise()/trash!
|
|
20
|
+
# instead of a raw write.
|
|
21
|
+
#
|
|
22
|
+
# Two-phase, same as accepts_nested_attributes_for from the caller's
|
|
23
|
+
# side but not under the hood: fields_for-style form submission first
|
|
24
|
+
# calls #{plural}_attributes= (stores the raw rows, writes nothing yet),
|
|
25
|
+
# then the interactor/controller explicitly calls
|
|
26
|
+
# apply_#{plural}_attributes! once the parent itself is saved/revised —
|
|
27
|
+
# a new child needs a *current* Recording to parent under, which only
|
|
28
|
+
# exists after that.
|
|
29
|
+
module NestedRecordableAttributes
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
def __recordables_define_nested_attributes(plural_name, class_name:)
|
|
34
|
+
singular_name = plural_name.to_s.singularize
|
|
35
|
+
target_class_name = class_name.to_s
|
|
36
|
+
pending_ivar = :"@pending_#{plural_name}_attributes"
|
|
37
|
+
rendered_ivar = :"@rendered_#{plural_name}"
|
|
38
|
+
|
|
39
|
+
attr_reader :"pending_#{plural_name}_attributes"
|
|
40
|
+
|
|
41
|
+
define_method(:"#{plural_name}_attributes=") do |attributes|
|
|
42
|
+
instance_variable_set(pending_ivar, attributes.is_a?(Hash) ? attributes.values : attributes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Blank row for an "add" button in the form — mirrors what
|
|
46
|
+
# `children.build` would do on a real has_many. In-memory only.
|
|
47
|
+
define_method(:"build_#{singular_name}") do
|
|
48
|
+
send(:"rendered_#{plural_name}") << target_class_name.constantize.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What the form actually iterates via fields_for: the just-submitted
|
|
52
|
+
# rows if the form was just posted (so a validation error re-renders
|
|
53
|
+
# what the user typed, the same as accepts_nested_attributes_for
|
|
54
|
+
# would), otherwise the persisted children.
|
|
55
|
+
define_method(:"rendered_#{plural_name}") do
|
|
56
|
+
instance_variable_get(rendered_ivar) || instance_variable_set(rendered_ivar, begin
|
|
57
|
+
pending = instance_variable_get(pending_ivar)
|
|
58
|
+
|
|
59
|
+
if pending
|
|
60
|
+
pending.filter_map do |attrs|
|
|
61
|
+
attrs = attrs.to_h.symbolize_keys
|
|
62
|
+
next if ActiveModel::Type::Boolean.new.cast(attrs[:_destroy])
|
|
63
|
+
|
|
64
|
+
target_class_name.constantize.new(attrs.except(:id, :_destroy))
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
public_send(plural_name).to_a
|
|
68
|
+
end
|
|
69
|
+
end)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
define_method(:"apply_#{plural_name}_attributes!") do |actor: nil|
|
|
73
|
+
pending = instance_variable_get(pending_ivar)
|
|
74
|
+
next unless pending
|
|
75
|
+
|
|
76
|
+
current_by_id = public_send(plural_name).index_by { |child| child.id.to_s }
|
|
77
|
+
|
|
78
|
+
pending.each do |attrs|
|
|
79
|
+
attrs = attrs.to_h.symbolize_keys
|
|
80
|
+
id = attrs[:id].presence
|
|
81
|
+
destroy = ActiveModel::Type::Boolean.new.cast(attrs[:_destroy])
|
|
82
|
+
changes = attrs.except(:id, :_destroy)
|
|
83
|
+
|
|
84
|
+
if id && (child = current_by_id[id.to_s])
|
|
85
|
+
if destroy
|
|
86
|
+
child.recording.trash!(actor: actor)
|
|
87
|
+
else
|
|
88
|
+
child.recording.revise(actor: actor, **changes)
|
|
89
|
+
end
|
|
90
|
+
elsif !destroy && changes.present?
|
|
91
|
+
public_send(:"add_#{singular_name}", actor: actor, **changes)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
instance_variable_set(pending_ivar, nil)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -17,6 +17,14 @@ module Recordables
|
|
|
17
17
|
def publishable? = false
|
|
18
18
|
def nestable? = false
|
|
19
19
|
|
|
20
|
+
# The one Recording currently pointing at this snapshot. A row can end
|
|
21
|
+
# up with more than one recordings row over its lifetime only in the
|
|
22
|
+
# sense that revise()/revert_to repoint a Recording between many rows
|
|
23
|
+
# — but at any moment, exactly one Recording points at this specific
|
|
24
|
+
# row (or, once it's been superseded, none). .last is a reasonable
|
|
25
|
+
# proxy for "the current one" without a dedicated column to query.
|
|
26
|
+
def recording = recordings.last
|
|
27
|
+
|
|
20
28
|
def summary = model_name.human
|
|
21
29
|
|
|
22
30
|
def revisable_attributes = attributes.except("id", "created_at")
|
|
@@ -6,6 +6,14 @@ module Recordables
|
|
|
6
6
|
|
|
7
7
|
VERSION_ACTIONS = %w[created updated reverted].freeze
|
|
8
8
|
|
|
9
|
+
included do
|
|
10
|
+
# The install generator's migration creates the status column
|
|
11
|
+
# (integer, default 0, not null) but doesn't declare the enum itself
|
|
12
|
+
# — active/trash! need this exact mapping to exist, so it lives here
|
|
13
|
+
# rather than being left for every consumer to redeclare correctly.
|
|
14
|
+
enum :status, { active: 0, archived: 1, trashed: 2 }
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
class_methods do
|
|
10
18
|
def record(recordable, actor:, parent: nil, **attributes)
|
|
11
19
|
transaction do
|
|
@@ -15,6 +23,12 @@ module Recordables
|
|
|
15
23
|
recording
|
|
16
24
|
end
|
|
17
25
|
end
|
|
26
|
+
|
|
27
|
+
# Every other scope on this table should build on this one rather than
|
|
28
|
+
# querying status directly — trashable's default_scope depends on
|
|
29
|
+
# filtering through exactly this relation so a model swap here (e.g.
|
|
30
|
+
# adding a soft-delete concern upstream) only has one place to change.
|
|
31
|
+
def active = where(status: :active)
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def revise(actor:, **changes)
|
|
@@ -44,5 +58,16 @@ module Recordables
|
|
|
44
58
|
def log!(action, snapshot, actor:, details: {})
|
|
45
59
|
events.create!(recordable: snapshot, actor: actor, action: action, details: details)
|
|
46
60
|
end
|
|
61
|
+
|
|
62
|
+
# "Deleting" a recordable never removes a row — it marks this pointer
|
|
63
|
+
# trashed and logs the transition, the same way revise/revert_to never
|
|
64
|
+
# delete either. A trashed Recording keeps recordable/versions/events
|
|
65
|
+
# working exactly as before; only .active-scoped lookups stop seeing it.
|
|
66
|
+
def trash!(actor: nil)
|
|
67
|
+
transaction do
|
|
68
|
+
update!(status: :trashed)
|
|
69
|
+
log!("trashed", recordable, actor: actor)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
47
72
|
end
|
|
48
73
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Recordables
|
|
4
|
+
# Opt-in alongside `recordable` for a type that owns real has_many
|
|
5
|
+
# associations pointed at it by a plain foreign key (not a child
|
|
6
|
+
# Recording) — join rows that need to follow a revision, not vanish
|
|
7
|
+
# when the row they FK to gets replaced.
|
|
8
|
+
#
|
|
9
|
+
# class RoutineTemplate < ApplicationRecord
|
|
10
|
+
# recordable
|
|
11
|
+
# trashable
|
|
12
|
+
# repoint_on_revise :routine_template_people, :goal_routine_templates
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Two problems, one macro:
|
|
16
|
+
#
|
|
17
|
+
# 1. #copy_content_to's default UncopyableAssociation guard exists to
|
|
18
|
+
# stop a real association from silently vanishing off a new snapshot
|
|
19
|
+
# — but these associations were never content to copy in the first
|
|
20
|
+
# place, they're FK pointers *at* the row. Nothing to carry forward;
|
|
21
|
+
# they need to be repointed instead. This overrides copy_content_to
|
|
22
|
+
# to a no-op, same as hand-rolling `def copy_content_to(r) = r` would.
|
|
23
|
+
#
|
|
24
|
+
# 2. revise() alone leaves every one of those rows FK'd to the OLD,
|
|
25
|
+
# now-superseded id — invisible to anything that reads the
|
|
26
|
+
# association off the new row, and (worse) silently orphaned if that
|
|
27
|
+
# old row is later trashed or garbage collected. This overrides
|
|
28
|
+
# revise() to repoint each named association's foreign key at the new
|
|
29
|
+
# row in the same transaction as the revision itself.
|
|
30
|
+
#
|
|
31
|
+
# update_all skips touch callbacks, so updated_at is bumped by hand —
|
|
32
|
+
# otherwise any fragment cache keyed on those rows (e.g. one that reads
|
|
33
|
+
# `join_row.routine_template.name`) would invalidate late.
|
|
34
|
+
module RepointOnRevise
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
included do
|
|
38
|
+
class_attribute :repointed_association_names, default: []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def copy_content_to(revision) = revision
|
|
42
|
+
|
|
43
|
+
def revise(actor: nil, **changes)
|
|
44
|
+
transaction do
|
|
45
|
+
new_row = recording.revise(actor: actor, **changes)
|
|
46
|
+
|
|
47
|
+
self.class.repointed_association_names.each do |association_name|
|
|
48
|
+
reflection = self.class.reflect_on_association(association_name)
|
|
49
|
+
foreign_key = reflection.foreign_key
|
|
50
|
+
|
|
51
|
+
public_send(association_name).update_all(foreign_key => new_row.id, :updated_at => Time.current)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
new_row
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Recordables
|
|
2
|
+
# Include in a test base class to get current_recordable, the fix for the
|
|
3
|
+
# single most common test mistake this gem's pattern invites: calling
|
|
4
|
+
# `#reload` on a recordable after an action that revised it.
|
|
5
|
+
#
|
|
6
|
+
# class ActiveSupport::TestCase
|
|
7
|
+
# include Recordables::Testing
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# revise() gives a change a brand new row — a new primary key — and
|
|
11
|
+
# repoints the Recording at it. The Ruby object still on hand still
|
|
12
|
+
# carries the OLD primary key, so `#reload` re-fetches by that key: not
|
|
13
|
+
# an error, not nil, just the exact row the action was supposed to
|
|
14
|
+
# replace. A test asserting on that object after the action reads
|
|
15
|
+
# whatever the row looked like BEFORE, and can pass while checking
|
|
16
|
+
# nothing about what actually happened.
|
|
17
|
+
#
|
|
18
|
+
# Capture the Recording before the action runs — its id is the one
|
|
19
|
+
# thing revise() never changes — then resolve through it afterward:
|
|
20
|
+
#
|
|
21
|
+
# recording = task.recording
|
|
22
|
+
# complete_the_task(task)
|
|
23
|
+
# assert_equal "complete", current_recordable(recording).status
|
|
24
|
+
module Testing
|
|
25
|
+
def current_recordable(recording_or_id)
|
|
26
|
+
recording = recording_or_id.is_a?(::Recording) ? recording_or_id : ::Recording.find(recording_or_id)
|
|
27
|
+
recording.reload.recordable
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Recordables
|
|
4
|
+
# Raised by delete_all/destroy_all on a trashable model — see Trashable
|
|
5
|
+
# for why those are ambiguous under a default_scope that hides trashed
|
|
6
|
+
# rows, and what to call instead.
|
|
7
|
+
class AmbiguousBulkDelete < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Opt-in alongside `recordable` for a type whose Recording can be trashed.
|
|
10
|
+
# Without this, a trashed Recording's recordable row stays visible to every
|
|
11
|
+
# plain query — trash! only changes the Recording's own status, nothing
|
|
12
|
+
# about the row a bare Model.find or Model.where sees. This scopes normal
|
|
13
|
+
# queries down to rows with an active Recording, the same way a real DELETE
|
|
14
|
+
# would look to callers that never think about trashing at all.
|
|
15
|
+
#
|
|
16
|
+
# class RoutineTemplate < ApplicationRecord
|
|
17
|
+
# recordable
|
|
18
|
+
# trashable
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Uses a subquery, not a join, so it composes safely everywhere — including
|
|
22
|
+
# inside Recording#recordable itself, which must still resolve a trashed
|
|
23
|
+
# row's own class via a plain Model.find under the hood.
|
|
24
|
+
module Trashable
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
# ::Recording, not Recording — inside this module's lexical scope,
|
|
29
|
+
# plain `Recording` resolves to Recordables::Recording (the concern
|
|
30
|
+
# mixed into the host app's Recording class) rather than the host
|
|
31
|
+
# app's top-level Recording class itself.
|
|
32
|
+
default_scope {
|
|
33
|
+
where(primary_key => ::Recording.active.where(recordable_type: name).select(:recordable_id))
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
# Lifts the trashed-row filter only — unlike .unscoped, every other
|
|
39
|
+
# condition already built on the relation stays intact (an account's
|
|
40
|
+
# scoping association chain, an explicit .find(id), etc). Needed
|
|
41
|
+
# anywhere that has to see trashed rows on purpose: an admin trash-can
|
|
42
|
+
# view, a cascading destroy that has to clean up what it can still see,
|
|
43
|
+
# or a backfill migration creating the very first Recording a row will
|
|
44
|
+
# ever have (see the docs on that gotcha below).
|
|
45
|
+
def with_trashed
|
|
46
|
+
unscope(where: primary_key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A bare Model.delete_all/destroy_all under default_scope only ever
|
|
50
|
+
# touches rows with an active Recording — anything already trashed is
|
|
51
|
+
# invisible to it and survives untouched. That's silent and easy to
|
|
52
|
+
# mistake for "the table's empty now" (a test teardown's delete_all,
|
|
53
|
+
# a DatabaseCleaner truncation strategy, a rake task clearing a
|
|
54
|
+
# table) when it's actually only ever cleared the active subset.
|
|
55
|
+
# Raising forces the caller to say which they mean; scoping down
|
|
56
|
+
# first (e.g. Model.where(...).delete_all) still works exactly like
|
|
57
|
+
# any other ActiveRecord relation.
|
|
58
|
+
def delete_all(...)
|
|
59
|
+
raise Recordables::AmbiguousBulkDelete, <<~MESSAGE.squish
|
|
60
|
+
#{name}.delete_all only deletes rows with an active Recording —
|
|
61
|
+
anything already trashed stays behind, silently. Call
|
|
62
|
+
#{name}.with_trashed.delete_all if that's really what you want,
|
|
63
|
+
or scope down first (e.g. #{name}.where(...).delete_all) if you
|
|
64
|
+
meant only the active rows matching some condition.
|
|
65
|
+
MESSAGE
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def destroy_all(...)
|
|
69
|
+
raise Recordables::AmbiguousBulkDelete, <<~MESSAGE.squish
|
|
70
|
+
#{name}.destroy_all only destroys rows with an active Recording —
|
|
71
|
+
anything already trashed stays behind, silently. Call
|
|
72
|
+
#{name}.with_trashed.destroy_all if that's really what you want,
|
|
73
|
+
or scope down first (e.g. #{name}.where(...).destroy_all) if you
|
|
74
|
+
meant only the active rows matching some condition.
|
|
75
|
+
MESSAGE
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/recordables/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: recordables
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonas Medeiros
|
|
@@ -54,6 +54,8 @@ files:
|
|
|
54
54
|
- CHANGELOG.md
|
|
55
55
|
- LICENSE.txt
|
|
56
56
|
- README.md
|
|
57
|
+
- lib/generators/recordables/backfill/backfill_generator.rb
|
|
58
|
+
- lib/generators/recordables/backfill/templates/backfill_migration.rb.tt
|
|
57
59
|
- lib/generators/recordables/bucket/bucket_generator.rb
|
|
58
60
|
- lib/generators/recordables/bucket/templates/migration.rb.tt
|
|
59
61
|
- lib/generators/recordables/bucket/templates/model.rb.tt
|
|
@@ -69,10 +71,16 @@ files:
|
|
|
69
71
|
- lib/generators/recordables/type/type_generator.rb
|
|
70
72
|
- lib/recordables.rb
|
|
71
73
|
- lib/recordables/generator_helpers.rb
|
|
74
|
+
- lib/recordables/has_children.rb
|
|
75
|
+
- lib/recordables/immutable.rb
|
|
72
76
|
- lib/recordables/macros.rb
|
|
77
|
+
- lib/recordables/nested_recordable_attributes.rb
|
|
73
78
|
- lib/recordables/railtie.rb
|
|
74
79
|
- lib/recordables/recordable.rb
|
|
75
80
|
- lib/recordables/recording.rb
|
|
81
|
+
- lib/recordables/repoint_on_revise.rb
|
|
82
|
+
- lib/recordables/testing.rb
|
|
83
|
+
- lib/recordables/trashable.rb
|
|
76
84
|
- lib/recordables/version.rb
|
|
77
85
|
homepage: https://github.com/jonasmedeiros/recordables
|
|
78
86
|
licenses:
|