standard_ledger 0.5.1 → 0.7.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.
@@ -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