standard_ledger 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -1
- data/README.md +142 -353
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +13 -29
- data/lib/standard_ledger/config.rb +14 -29
- data/lib/standard_ledger/entry.rb +6 -10
- data/lib/standard_ledger/errors.rb +6 -26
- data/lib/standard_ledger/event_emitter.rb +2 -2
- data/lib/standard_ledger/matview.rb +162 -0
- data/lib/standard_ledger/projection.rb +17 -24
- data/lib/standard_ledger/result.rb +2 -2
- data/lib/standard_ledger/rspec.rb +6 -28
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +67 -505
- metadata +9 -48
- data/lib/standard_ledger/engine.rb +0 -28
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +0 -28
- data/lib/standard_ledger/jobs/projection_job.rb +0 -94
- data/lib/standard_ledger/modes/async.rb +0 -141
- data/lib/standard_ledger/modes/inline.rb +0 -180
- data/lib/standard_ledger/modes/matview.rb +0 -146
- data/lib/standard_ledger/modes/sql.rb +0 -132
- data/lib/standard_ledger/modes/trigger.rb +0 -50
- data/lib/standard_ledger/projector.rb +0 -638
- data/lib/standard_ledger/rspec/helpers.rb +0 -15
- data/lib/tasks/standard_ledger.rake +0 -60
|
@@ -1,638 +0,0 @@
|
|
|
1
|
-
require "active_support/concern"
|
|
2
|
-
require "active_support/core_ext/class/attribute"
|
|
3
|
-
|
|
4
|
-
module StandardLedger
|
|
5
|
-
# Adds the `projects_onto` DSL to an Entry class. Each `projects_onto`
|
|
6
|
-
# declaration registers a single (target_association, mode, projector)
|
|
7
|
-
# tuple; multi-target fan-out is two declarations.
|
|
8
|
-
#
|
|
9
|
-
# @example block form
|
|
10
|
-
# class VoucherRecord < ApplicationRecord
|
|
11
|
-
# include StandardLedger::Entry
|
|
12
|
-
# include StandardLedger::Projector
|
|
13
|
-
#
|
|
14
|
-
# ledger_entry kind: :action, idempotency_key: :serial_no, scope: :organisation_id
|
|
15
|
-
#
|
|
16
|
-
# projects_onto :voucher_scheme, mode: :inline do
|
|
17
|
-
# on(:grant) { |scheme, _| scheme.increment(:granted_vouchers_count) }
|
|
18
|
-
# on(:redeem) { |scheme, _| scheme.increment(:redeemed_vouchers_count) }
|
|
19
|
-
# on(:consume) { |scheme, _| scheme.increment(:consumed_vouchers_count) }
|
|
20
|
-
# on(:clawback) { |scheme, _| scheme.increment(:clawed_back_vouchers_count) }
|
|
21
|
-
# end
|
|
22
|
-
# end
|
|
23
|
-
#
|
|
24
|
-
# @example class form
|
|
25
|
-
# projects_onto :order, mode: :async, via: Orders::FulfillableProjector
|
|
26
|
-
module Projector
|
|
27
|
-
extend ActiveSupport::Concern
|
|
28
|
-
|
|
29
|
-
# Captures the per-projection configuration declared by `projects_onto`.
|
|
30
|
-
# Stored on the entry class so `StandardLedger.post` and
|
|
31
|
-
# `StandardLedger.rebuild!` can iterate over them at runtime.
|
|
32
|
-
#
|
|
33
|
-
# `:view` and `:refresh_options` are populated only for `:matview`-mode
|
|
34
|
-
# projections; they're `nil` for every other mode.
|
|
35
|
-
Definition = Struct.new(
|
|
36
|
-
:target_association, :mode, :projector_class, :handlers, :guard, :lock, :permissive,
|
|
37
|
-
:recompute_sql, :trigger_name, :view, :refresh_options, :options,
|
|
38
|
-
keyword_init: true
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
class_methods do
|
|
42
|
-
# Declare a projection from this entry onto a single target.
|
|
43
|
-
#
|
|
44
|
-
# @param target_association [Symbol] the `belongs_to` association name on
|
|
45
|
-
# this entry pointing at the projection target.
|
|
46
|
-
# @param mode [Symbol] one of `:inline`, `:async`, `:sql`, `:trigger`,
|
|
47
|
-
# `:matview`. See the design doc §5.3 for selection guidance.
|
|
48
|
-
# @param via [Class, nil] optional `Projection` subclass; required for
|
|
49
|
-
# `:async`/`:trigger`/`:sql` modes when the projector is non-trivial,
|
|
50
|
-
# optional for `:inline` when a block is given.
|
|
51
|
-
# @param if [Proc, nil] optional guard; the projection is skipped when
|
|
52
|
-
# the proc (evaluated in the entry's instance context) returns false.
|
|
53
|
-
# @param lock [Symbol, nil] `:pessimistic` to wrap inline updates in
|
|
54
|
-
# `target.with_lock { ... }`. Default: `nil` (optimistic).
|
|
55
|
-
# @param permissive [Boolean] when true, an entry with a kind not
|
|
56
|
-
# handled by `on(:kind)` is silently skipped instead of raising
|
|
57
|
-
# `UnhandledKind`. Default: false.
|
|
58
|
-
# @param view [String, Symbol, nil] for `mode: :matview` only — the name
|
|
59
|
-
# of the host-owned materialized view. Required when mode is
|
|
60
|
-
# `:matview`; ignored otherwise.
|
|
61
|
-
# @param refresh [Hash, nil] for `mode: :matview` only — refresh
|
|
62
|
-
# metadata, e.g. `{ every: 5.minutes, concurrently: true }`. The
|
|
63
|
-
# gem records this on the Definition for hosts to read when wiring
|
|
64
|
-
# their scheduler; the gem does NOT auto-schedule.
|
|
65
|
-
# @param trigger_name [String, Symbol, nil] for `mode: :trigger` only —
|
|
66
|
-
# the name of the host-owned database trigger. Required when mode is
|
|
67
|
-
# `:trigger`; ignored otherwise. The gem does NOT create or manage
|
|
68
|
-
# the trigger; it records the name so `standard_ledger:doctor` can
|
|
69
|
-
# verify the trigger's presence in the connected schema.
|
|
70
|
-
# @yield optional block-DSL form: register per-kind handlers via
|
|
71
|
-
# `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
|
|
72
|
-
# @return [Definition] the registered projection.
|
|
73
|
-
def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false,
|
|
74
|
-
view: nil, refresh: nil, trigger_name: nil, counters: nil,
|
|
75
|
-
rebuild_sql: nil, **options, &block)
|
|
76
|
-
guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
|
|
77
|
-
|
|
78
|
-
# `counters:` is sugar for the most common :inline shape — a hash
|
|
79
|
-
# mapping `kind => column` that synthesises one
|
|
80
|
-
# `on(kind) { |t, _| t.class.increment_counter(col, t.id) }` per
|
|
81
|
-
# entry. Direct UPDATE (the class-method form) is intentional: it
|
|
82
|
-
# invalidates the SQL query cache for the target table on each
|
|
83
|
-
# call, which keeps multiple sibling-entry creates in a single
|
|
84
|
-
# transaction (e.g. via `accepts_nested_attributes_for`) from
|
|
85
|
-
# losing updates against stale cached reads. Block form remains
|
|
86
|
-
# available for non-counter projections.
|
|
87
|
-
if counters
|
|
88
|
-
if mode != :inline
|
|
89
|
-
raise ArgumentError,
|
|
90
|
-
"projects_onto :#{target_association} got `counters:` with mode: #{mode.inspect}; " \
|
|
91
|
-
"the counters shortcut is :inline-only — counter caches don't fit the async/sql/" \
|
|
92
|
-
"trigger/matview contracts"
|
|
93
|
-
end
|
|
94
|
-
if block || via
|
|
95
|
-
raise ArgumentError,
|
|
96
|
-
"projects_onto :#{target_association} got `counters:` together with a block or `via:`; " \
|
|
97
|
-
"the counters shortcut synthesises handlers automatically — use one form or the other"
|
|
98
|
-
end
|
|
99
|
-
unless counters.is_a?(Hash) && counters.all? { |k, v| k.is_a?(Symbol) && v.is_a?(Symbol) }
|
|
100
|
-
raise ArgumentError,
|
|
101
|
-
"projects_onto :#{target_association} `counters:` must be a Hash of Symbol kind => Symbol column"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
block = ->(*) {
|
|
105
|
-
counters.each do |kind, column|
|
|
106
|
-
on(kind) { |target, _| target.class.increment_counter(column, target.id) }
|
|
107
|
-
end
|
|
108
|
-
}
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# `rebuild_sql:` keyword form for `:trigger` mode — equivalent to
|
|
112
|
-
# the legacy block-DSL `rebuild_sql "..."` clause but doesn't
|
|
113
|
-
# require a block. The block-DSL form is still supported.
|
|
114
|
-
if rebuild_sql && mode == :trigger
|
|
115
|
-
if block
|
|
116
|
-
raise ArgumentError,
|
|
117
|
-
"projects_onto :#{target_association} got both `rebuild_sql:` and a block — " \
|
|
118
|
-
"use one form or the other"
|
|
119
|
-
end
|
|
120
|
-
# Capture the parameter value into a local before building the
|
|
121
|
-
# block: when the synthesised block is `instance_eval`'d on
|
|
122
|
-
# `TriggerDsl`, the bare name `rebuild_sql` resolves to
|
|
123
|
-
# `TriggerDsl#rebuild_sql` (the writer), not the keyword
|
|
124
|
-
# parameter we want to pass in.
|
|
125
|
-
rebuild_sql_value = rebuild_sql
|
|
126
|
-
block = ->(*) { rebuild_sql(rebuild_sql_value) }
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
if mode == :manual
|
|
130
|
-
# `:manual` records the projection contract (target + projector
|
|
131
|
-
# class) without installing any callback. Use this when the
|
|
132
|
-
# entry's interesting lifecycle event is not the create itself
|
|
133
|
-
# — typically an AASM/state-machine model where the projection
|
|
134
|
-
# should fire on a transition (e.g. `validate_disbursement!`)
|
|
135
|
-
# rather than `after_create`. Hosts invoke the projector
|
|
136
|
-
# explicitly from operation code; the gem's role is to make
|
|
137
|
-
# the contract introspectable (via `standard_ledger_projections`)
|
|
138
|
-
# and to give `StandardLedger.rebuild!` a class handle for log
|
|
139
|
-
# replay.
|
|
140
|
-
if block
|
|
141
|
-
raise ArgumentError,
|
|
142
|
-
"projects_onto :#{target_association} mode: :manual does not accept a block — " \
|
|
143
|
-
"the entry's lifecycle is owned by the host, so per-kind handlers can't fire " \
|
|
144
|
-
"automatically. Use `via: ProjectorClass` and invoke the projector explicitly " \
|
|
145
|
-
"from the operation that drives the lifecycle event."
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
if via.nil?
|
|
149
|
-
raise ArgumentError,
|
|
150
|
-
"projects_onto :#{target_association} mode: :manual requires `via: ProjectorClass` " \
|
|
151
|
-
"whose `apply(target, entry)` is invoked explicitly by host code on the lifecycle " \
|
|
152
|
-
"event the gem cannot observe (e.g. an AASM transition)."
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
unless lock.nil?
|
|
156
|
-
raise ArgumentError,
|
|
157
|
-
"projects_onto :#{target_association} got `lock:` with mode: :manual; " \
|
|
158
|
-
"the host owns the dispatch boundary and is responsible for any locking it needs."
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
if permissive
|
|
162
|
-
raise ArgumentError,
|
|
163
|
-
"projects_onto :#{target_association} got `permissive: true` with mode: :manual; " \
|
|
164
|
-
"`permissive:` is only meaningful with the block form."
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
definition = Definition.new(
|
|
168
|
-
target_association: target_association,
|
|
169
|
-
mode: mode,
|
|
170
|
-
projector_class: via,
|
|
171
|
-
handlers: {},
|
|
172
|
-
guard: guard,
|
|
173
|
-
lock: lock,
|
|
174
|
-
permissive: permissive,
|
|
175
|
-
recompute_sql: nil,
|
|
176
|
-
trigger_name: nil,
|
|
177
|
-
view: nil,
|
|
178
|
-
refresh_options: nil,
|
|
179
|
-
options: options
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
183
|
-
# No `install_mode_callbacks_for` — the host owns the dispatch.
|
|
184
|
-
return definition
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
if mode == :async
|
|
188
|
-
if block
|
|
189
|
-
raise ArgumentError,
|
|
190
|
-
"projects_onto :#{target_association} mode: :async does not accept a block — " \
|
|
191
|
-
"use `via: ProjectorClass` whose `apply(target, entry)` recomputes from the log " \
|
|
192
|
-
"inside `with_lock` for retry-safety. Block-form per-kind handlers aren't safe " \
|
|
193
|
-
"under async retry."
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
if via.nil?
|
|
197
|
-
raise ArgumentError,
|
|
198
|
-
"projects_onto :#{target_association} mode: :async requires `via: ProjectorClass` " \
|
|
199
|
-
"(class-form only — see the registration error message for block-form for the why)"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
unless lock.nil?
|
|
203
|
-
raise ArgumentError,
|
|
204
|
-
"projects_onto :#{target_association} got `lock:` with mode: :async; " \
|
|
205
|
-
":async always wraps the projector in `target.with_lock` for retry-safety, " \
|
|
206
|
-
"so the option is redundant. Drop it."
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
if permissive
|
|
210
|
-
raise ArgumentError,
|
|
211
|
-
"projects_onto :#{target_association} got `permissive: true` with mode: :async; " \
|
|
212
|
-
"`permissive:` is only meaningful with the block form, and `:async` mode rejects " \
|
|
213
|
-
"block-form for retry-safety reasons"
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
definition = Definition.new(
|
|
217
|
-
target_association: target_association,
|
|
218
|
-
mode: mode,
|
|
219
|
-
projector_class: via,
|
|
220
|
-
handlers: {},
|
|
221
|
-
guard: guard,
|
|
222
|
-
lock: lock,
|
|
223
|
-
permissive: permissive,
|
|
224
|
-
recompute_sql: nil,
|
|
225
|
-
trigger_name: nil,
|
|
226
|
-
view: nil,
|
|
227
|
-
refresh_options: nil,
|
|
228
|
-
options: options
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
232
|
-
install_mode_callbacks_for(definition)
|
|
233
|
-
return definition
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
if mode == :trigger
|
|
237
|
-
if via
|
|
238
|
-
raise ArgumentError,
|
|
239
|
-
"projects_onto :#{target_association} got `via:` with mode: :trigger; " \
|
|
240
|
-
"the trigger is the contract — there is no projector class. The host " \
|
|
241
|
-
"owns the trigger via a Rails migration; the gem only records the " \
|
|
242
|
-
"trigger's name and rebuild SQL"
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
unless lock.nil?
|
|
246
|
-
raise ArgumentError,
|
|
247
|
-
"projects_onto :#{target_association} got `lock:` with mode: :trigger; " \
|
|
248
|
-
"`lock:` is not supported by :trigger mode — the database trigger handles " \
|
|
249
|
-
"concurrency atomically"
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
if permissive
|
|
253
|
-
raise ArgumentError,
|
|
254
|
-
"projects_onto :#{target_association} got `permissive:` with mode: :trigger; " \
|
|
255
|
-
"`permissive:` is not supported by :trigger mode — the trigger doesn't " \
|
|
256
|
-
"dispatch through per-kind handlers"
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
if guard
|
|
260
|
-
raise ArgumentError,
|
|
261
|
-
"projects_onto :#{target_association} got `if:` with mode: :trigger; " \
|
|
262
|
-
"`if:` is not supported by :trigger mode — the database trigger fires " \
|
|
263
|
-
"unconditionally from the DB on INSERT, so a Ruby-side guard would " \
|
|
264
|
-
"silently never run"
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
if trigger_name.nil? || trigger_name.to_s.empty?
|
|
268
|
-
raise ArgumentError,
|
|
269
|
-
"projects_onto :#{target_association} requires `trigger_name: \"...\"` for mode: :trigger; " \
|
|
270
|
-
"the gem records the trigger's name so `standard_ledger:doctor` can verify its presence"
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
unless block
|
|
274
|
-
raise ArgumentError,
|
|
275
|
-
"projects_onto :#{target_association} requires a block with `rebuild_sql \"...\"` for mode: :trigger"
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
dsl = TriggerDsl.new
|
|
279
|
-
dsl.instance_eval(&block)
|
|
280
|
-
|
|
281
|
-
if dsl.rebuild_sql_text.nil?
|
|
282
|
-
raise ArgumentError,
|
|
283
|
-
"projects_onto :#{target_association} block is empty; mode: :trigger requires a `rebuild_sql \"...\"` clause"
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
unless dsl.rebuild_sql_text.include?(":target_id")
|
|
287
|
-
raise ArgumentError,
|
|
288
|
-
"projects_onto :#{target_association} rebuild SQL must include the `:target_id` placeholder; " \
|
|
289
|
-
"it's bound to each target's id when `StandardLedger.rebuild!` walks the log"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
definition = Definition.new(
|
|
293
|
-
target_association: target_association,
|
|
294
|
-
mode: mode,
|
|
295
|
-
projector_class: nil,
|
|
296
|
-
handlers: {},
|
|
297
|
-
guard: guard,
|
|
298
|
-
lock: lock,
|
|
299
|
-
permissive: permissive,
|
|
300
|
-
recompute_sql: dsl.rebuild_sql_text,
|
|
301
|
-
trigger_name: trigger_name.to_s,
|
|
302
|
-
view: nil,
|
|
303
|
-
refresh_options: nil,
|
|
304
|
-
options: options
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
308
|
-
install_mode_callbacks_for(definition)
|
|
309
|
-
return definition
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
if mode == :sql
|
|
313
|
-
if via
|
|
314
|
-
raise ArgumentError,
|
|
315
|
-
"projects_onto :#{target_association} got `via:` with mode: :sql; " \
|
|
316
|
-
"`:sql` mode's contract is the recompute SQL itself, declared via `recompute \"...\"` " \
|
|
317
|
-
"in the block — use a different mode if you want a `via:` projector class"
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
unless lock.nil?
|
|
321
|
-
raise ArgumentError,
|
|
322
|
-
"projects_onto :#{target_association} got `lock:` with mode: :sql; " \
|
|
323
|
-
"`lock:` is not supported by :sql mode — recompute SQL doesn't dispatch through " \
|
|
324
|
-
"with_lock; use :inline mode if you need pessimistic locking"
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
if permissive
|
|
328
|
-
raise ArgumentError,
|
|
329
|
-
"projects_onto :#{target_association} got `permissive:` with mode: :sql; " \
|
|
330
|
-
"`permissive:` is not supported by :sql mode — recompute SQL doesn't dispatch " \
|
|
331
|
-
"through per-kind handlers; use :inline mode if you need permissive dispatch"
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
unless block
|
|
335
|
-
raise ArgumentError,
|
|
336
|
-
"projects_onto :#{target_association} requires a block with `recompute \"...\"` for mode: :sql"
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
dsl = SqlDsl.new
|
|
340
|
-
dsl.instance_eval(&block)
|
|
341
|
-
|
|
342
|
-
if dsl.recompute_sql.nil?
|
|
343
|
-
raise ArgumentError,
|
|
344
|
-
"projects_onto :#{target_association} block is empty; mode: :sql requires a `recompute \"...\"` clause"
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
unless dsl.recompute_sql.include?(":target_id")
|
|
348
|
-
raise ArgumentError,
|
|
349
|
-
"projects_onto :#{target_association} recompute SQL must include the `:target_id` placeholder; " \
|
|
350
|
-
"it's bound to the entry's foreign key for this projection's target"
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
definition = Definition.new(
|
|
354
|
-
target_association: target_association,
|
|
355
|
-
mode: mode,
|
|
356
|
-
projector_class: nil,
|
|
357
|
-
handlers: {},
|
|
358
|
-
guard: guard,
|
|
359
|
-
lock: lock,
|
|
360
|
-
permissive: permissive,
|
|
361
|
-
recompute_sql: dsl.recompute_sql,
|
|
362
|
-
trigger_name: nil,
|
|
363
|
-
view: nil,
|
|
364
|
-
refresh_options: nil,
|
|
365
|
-
options: options
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
369
|
-
install_mode_callbacks_for(definition)
|
|
370
|
-
return definition
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
if mode == :matview
|
|
374
|
-
if block
|
|
375
|
-
raise ArgumentError,
|
|
376
|
-
"projects_onto :#{target_association} mode: :matview does not accept a block; " \
|
|
377
|
-
"matview projections have no per-kind handlers — they refresh on a schedule"
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
if view.nil?
|
|
381
|
-
raise ArgumentError,
|
|
382
|
-
"projects_onto :#{target_association} mode: :matview requires `view: \"name\"` " \
|
|
383
|
-
"(the materialized view name to refresh)"
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
definition = Definition.new(
|
|
387
|
-
target_association: target_association,
|
|
388
|
-
mode: mode,
|
|
389
|
-
projector_class: nil,
|
|
390
|
-
handlers: {},
|
|
391
|
-
guard: guard,
|
|
392
|
-
lock: lock,
|
|
393
|
-
permissive: permissive,
|
|
394
|
-
recompute_sql: nil,
|
|
395
|
-
trigger_name: nil,
|
|
396
|
-
view: view.to_s,
|
|
397
|
-
refresh_options: refresh || {},
|
|
398
|
-
options: options
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
402
|
-
install_mode_callbacks_for(definition)
|
|
403
|
-
return definition
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
if block && via
|
|
407
|
-
raise ArgumentError,
|
|
408
|
-
"projects_onto :#{target_association} got both a block and `via:`; the two forms are mutually exclusive"
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
unless block || via
|
|
412
|
-
raise ArgumentError,
|
|
413
|
-
"projects_onto :#{target_association} requires either a block of `on(:kind) { ... }` handlers or `via: ProjectorClass`"
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
if via && permissive
|
|
417
|
-
raise ArgumentError,
|
|
418
|
-
"projects_onto :#{target_association} got `permissive: true` with `via:`; " \
|
|
419
|
-
"`permissive:` is only meaningful with the block form, where it falls back " \
|
|
420
|
-
"to the `:_` wildcard handler when no specific-kind handler matches. Class " \
|
|
421
|
-
"form (`via: ProjectorClass`) runs `apply(target, entry)` unconditionally, " \
|
|
422
|
-
"so there's nothing to be permissive about."
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
handlers = {}
|
|
426
|
-
if block
|
|
427
|
-
dsl = HandlerDsl.new
|
|
428
|
-
dsl.instance_eval(&block)
|
|
429
|
-
handlers = dsl.handlers
|
|
430
|
-
|
|
431
|
-
if handlers.empty?
|
|
432
|
-
raise ArgumentError,
|
|
433
|
-
"projects_onto :#{target_association} block is empty; at least one `on(:kind) { ... }` handler is required"
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
definition = Definition.new(
|
|
438
|
-
target_association: target_association,
|
|
439
|
-
mode: mode,
|
|
440
|
-
projector_class: via,
|
|
441
|
-
handlers: handlers,
|
|
442
|
-
guard: guard,
|
|
443
|
-
lock: lock,
|
|
444
|
-
permissive: permissive,
|
|
445
|
-
recompute_sql: nil,
|
|
446
|
-
trigger_name: nil,
|
|
447
|
-
view: nil,
|
|
448
|
-
refresh_options: nil,
|
|
449
|
-
options: options
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
453
|
-
install_mode_callbacks_for(definition)
|
|
454
|
-
definition
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Delegate per-mode callback installation to the matching strategy
|
|
458
|
-
# class. Keeps `Modes::*` from reaching into `Projector`'s internals
|
|
459
|
-
# — each strategy gets a chance to wire up `after_create`,
|
|
460
|
-
# `after_create_commit`, or whatever lifecycle hook it needs the first
|
|
461
|
-
# time a projection of its mode is registered on this class.
|
|
462
|
-
#
|
|
463
|
-
# @param definition [Definition]
|
|
464
|
-
# @return [void]
|
|
465
|
-
def install_mode_callbacks_for(definition)
|
|
466
|
-
case definition.mode
|
|
467
|
-
when :inline
|
|
468
|
-
StandardLedger::Modes::Inline.install!(self)
|
|
469
|
-
when :async
|
|
470
|
-
StandardLedger::Modes::Async.install!(self)
|
|
471
|
-
when :sql
|
|
472
|
-
StandardLedger::Modes::Sql.install!(self)
|
|
473
|
-
when :matview
|
|
474
|
-
StandardLedger::Modes::Matview.install!(self)
|
|
475
|
-
when :trigger
|
|
476
|
-
StandardLedger::Modes::Trigger.install!(self)
|
|
477
|
-
end
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
# Filter the registered projections by mode. Used by the per-mode
|
|
481
|
-
# strategy classes (`Modes::Inline`, `Modes::Async`, ...) to discover
|
|
482
|
-
# which projections they own for a given entry class.
|
|
483
|
-
#
|
|
484
|
-
# @param mode [Symbol] one of `:inline`, `:async`, `:sql`, `:trigger`,
|
|
485
|
-
# `:matview`.
|
|
486
|
-
# @return [Array<Definition>] the matching definitions, in declared order.
|
|
487
|
-
def standard_ledger_projections_for(mode)
|
|
488
|
-
standard_ledger_projections.select { |definition| definition.mode == mode }
|
|
489
|
-
end
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
included do
|
|
493
|
-
class_attribute :standard_ledger_projections, instance_writer: false
|
|
494
|
-
self.standard_ledger_projections = []
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Apply a single projection definition to this entry. Resolves the
|
|
498
|
-
# target association, evaluates the optional `if:` guard, looks up the
|
|
499
|
-
# per-kind handler (or falls back to the projector class), and invokes
|
|
500
|
-
# it.
|
|
501
|
-
#
|
|
502
|
-
# `lock:` is interpreted by the mode strategies (`Modes::Inline`, ...)
|
|
503
|
-
# — not here. The inline strategy needs the lock to span both the
|
|
504
|
-
# handler invocation *and* the coalesced `target.save!`, so it wraps
|
|
505
|
-
# an entire per-target group rather than a single `apply_projection!`
|
|
506
|
-
# call. See `Modes::Inline#call` for the lock-spans-save guarantee.
|
|
507
|
-
#
|
|
508
|
-
# The mode strategies call this method; hosts typically do not call it
|
|
509
|
-
# directly.
|
|
510
|
-
#
|
|
511
|
-
# @param definition [Definition] one of the entry class's registered
|
|
512
|
-
# projections.
|
|
513
|
-
# @return [Boolean] `true` when the handler (or projector class) ran;
|
|
514
|
-
# `false` when the projection short-circuited (guard returned false,
|
|
515
|
-
# target was nil, or permissive mode found no matching handler).
|
|
516
|
-
# @raise [StandardLedger::Error] when the entry's kind column is nil.
|
|
517
|
-
# @raise [StandardLedger::UnhandledKind] when no handler matches and
|
|
518
|
-
# `permissive: false`.
|
|
519
|
-
def apply_projection!(definition)
|
|
520
|
-
if definition.mode == :sql
|
|
521
|
-
raise Error,
|
|
522
|
-
"apply_projection! is not supported for mode: :sql; " \
|
|
523
|
-
"the recompute SQL runs through `Modes::Sql#call` directly with no per-kind dispatch"
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
if definition.mode == :trigger
|
|
527
|
-
raise Error,
|
|
528
|
-
"apply_projection! is not supported for mode: :trigger; " \
|
|
529
|
-
"the database trigger fires from the DB on INSERT, not from Ruby"
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
return false if definition.guard && !instance_exec(&definition.guard)
|
|
533
|
-
|
|
534
|
-
target = public_send(definition.target_association)
|
|
535
|
-
return false if target.nil?
|
|
536
|
-
|
|
537
|
-
if definition.projector_class
|
|
538
|
-
definition.projector_class.new.apply(target, self)
|
|
539
|
-
return true
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
kind = resolve_kind!
|
|
543
|
-
handler = definition.handlers[kind.to_sym]
|
|
544
|
-
|
|
545
|
-
if handler.nil?
|
|
546
|
-
if definition.permissive
|
|
547
|
-
handler = definition.handlers[:_]
|
|
548
|
-
return false if handler.nil?
|
|
549
|
-
else
|
|
550
|
-
raise UnhandledKind,
|
|
551
|
-
"#{self.class.name} has no handler for kind=#{kind.inspect} on projection :#{definition.target_association}"
|
|
552
|
-
end
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
handler.call(target, self)
|
|
556
|
-
true
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
private
|
|
560
|
-
|
|
561
|
-
def resolve_kind!
|
|
562
|
-
unless self.class.respond_to?(:standard_ledger_entry_config)
|
|
563
|
-
raise Error,
|
|
564
|
-
"#{self.class.name} includes Projector but not Entry; " \
|
|
565
|
-
"`ledger_entry` must be called before apply_projection! can dispatch"
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
kind_column = self.class.standard_ledger_entry_config&.[](:kind) || :kind
|
|
569
|
-
kind = public_send(kind_column)
|
|
570
|
-
if kind.nil?
|
|
571
|
-
raise Error,
|
|
572
|
-
"#{self.class.name} entry has nil kind (column #{kind_column.inspect}); cannot dispatch projection"
|
|
573
|
-
end
|
|
574
|
-
kind
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
# Internal collector for the block-DSL form. Captures `on(:kind)` calls
|
|
578
|
-
# into a hash keyed by kind. Wildcard `on(:_)` is reserved as a catch-all
|
|
579
|
-
# — its handler runs only when no specific-kind handler matched.
|
|
580
|
-
class HandlerDsl
|
|
581
|
-
attr_reader :handlers
|
|
582
|
-
|
|
583
|
-
def initialize
|
|
584
|
-
@handlers = {}
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
def on(kind, &block)
|
|
588
|
-
raise ArgumentError, "on(:#{kind}) requires a block" unless block
|
|
589
|
-
@handlers[kind.to_sym] = block
|
|
590
|
-
end
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
# Internal collector for the `:sql`-mode block-DSL form. Captures the
|
|
594
|
-
# `recompute "..."` clause's SQL string. Unlike `HandlerDsl` there are
|
|
595
|
-
# no per-kind handlers — the recompute SQL is the entire contract: it
|
|
596
|
-
# must be expressible as a single statement with `:target_id` bound
|
|
597
|
-
# from the entry's foreign key, and it serves both the after-create
|
|
598
|
-
# path and `StandardLedger.rebuild!`.
|
|
599
|
-
class SqlDsl
|
|
600
|
-
attr_reader :recompute_sql
|
|
601
|
-
|
|
602
|
-
def recompute(sql)
|
|
603
|
-
unless sql.is_a?(String)
|
|
604
|
-
raise ArgumentError, "recompute requires a SQL string; got #{sql.class}"
|
|
605
|
-
end
|
|
606
|
-
unless @recompute_sql.nil?
|
|
607
|
-
raise ArgumentError,
|
|
608
|
-
"recompute called more than once in the same projects_onto block; " \
|
|
609
|
-
":sql mode supports exactly one recompute clause per projection"
|
|
610
|
-
end
|
|
611
|
-
@recompute_sql = sql
|
|
612
|
-
end
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
# Internal collector for the `:trigger`-mode block-DSL form. Captures
|
|
616
|
-
# the `rebuild_sql "..."` clause's SQL string. The trigger itself is
|
|
617
|
-
# owned by the host (created in a Rails migration); the gem only
|
|
618
|
-
# records this rebuild SQL so `StandardLedger.rebuild!` can recompute
|
|
619
|
-
# the projection from the log when invoked. Like `:sql` mode, the SQL
|
|
620
|
-
# must be expressible as a single statement with `:target_id` bound
|
|
621
|
-
# from each target's id at rebuild time.
|
|
622
|
-
class TriggerDsl
|
|
623
|
-
attr_reader :rebuild_sql_text
|
|
624
|
-
|
|
625
|
-
def rebuild_sql(sql)
|
|
626
|
-
unless sql.is_a?(String)
|
|
627
|
-
raise ArgumentError, "rebuild_sql requires a SQL string; got #{sql.class}"
|
|
628
|
-
end
|
|
629
|
-
unless @rebuild_sql_text.nil?
|
|
630
|
-
raise ArgumentError,
|
|
631
|
-
"rebuild_sql called more than once in the same projects_onto block; " \
|
|
632
|
-
":trigger mode supports exactly one rebuild_sql clause per projection"
|
|
633
|
-
end
|
|
634
|
-
@rebuild_sql_text = sql
|
|
635
|
-
end
|
|
636
|
-
end
|
|
637
|
-
end
|
|
638
|
-
end
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
module RSpec
|
|
3
|
-
# Convenience methods auto-included into every RSpec example group when
|
|
4
|
-
# the host loads `require "standard_ledger/rspec"`. The actual override
|
|
5
|
-
# map lives on `StandardLedger` itself so non-RSpec callers (e.g. a
|
|
6
|
-
# background job spec running outside RSpec) can use the same API.
|
|
7
|
-
module Helpers
|
|
8
|
-
# Forwards to `StandardLedger.with_modes` so specs can write
|
|
9
|
-
# `with_modes(...) { ... }` instead of the fully-qualified form.
|
|
10
|
-
def with_modes(overrides, &block)
|
|
11
|
-
StandardLedger.with_modes(overrides, &block)
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|