standard_ledger 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 +7 -0
- data/CHANGELOG.md +260 -0
- data/MIT-LICENSE +21 -0
- data/README.md +287 -0
- data/Rakefile +6 -0
- data/lib/generators/standard_ledger/install/install_generator.rb +34 -0
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +66 -0
- data/lib/standard_ledger/config.rb +62 -0
- data/lib/standard_ledger/engine.rb +19 -0
- data/lib/standard_ledger/entry.rb +253 -0
- data/lib/standard_ledger/errors.rb +33 -0
- data/lib/standard_ledger/event_emitter.rb +50 -0
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +28 -0
- data/lib/standard_ledger/modes/inline.rb +180 -0
- data/lib/standard_ledger/modes/matview.rb +115 -0
- data/lib/standard_ledger/modes/sql.rb +132 -0
- data/lib/standard_ledger/projection.rb +41 -0
- data/lib/standard_ledger/projector.rb +361 -0
- data/lib/standard_ledger/result.rb +51 -0
- data/lib/standard_ledger/rspec/helpers.rb +15 -0
- data/lib/standard_ledger/rspec/matchers.rb +148 -0
- data/lib/standard_ledger/rspec.rb +44 -0
- data/lib/standard_ledger/version.rb +3 -0
- data/lib/standard_ledger.rb +620 -0
- metadata +184 -0
|
@@ -0,0 +1,361 @@
|
|
|
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, :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
|
+
# @yield optional block-DSL form: register per-kind handlers via
|
|
66
|
+
# `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
|
|
67
|
+
# @return [Definition] the registered projection.
|
|
68
|
+
def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false, view: nil, refresh: nil, **options, &block)
|
|
69
|
+
guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
|
|
70
|
+
|
|
71
|
+
if mode == :sql
|
|
72
|
+
if via
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"projects_onto :#{target_association} got `via:` with mode: :sql; " \
|
|
75
|
+
"`:sql` mode's contract is the recompute SQL itself, declared via `recompute \"...\"` " \
|
|
76
|
+
"in the block — use a different mode if you want a `via:` projector class"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
unless lock.nil?
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"projects_onto :#{target_association} got `lock:` with mode: :sql; " \
|
|
82
|
+
"`lock:` is not supported by :sql mode — recompute SQL doesn't dispatch through " \
|
|
83
|
+
"with_lock; use :inline mode if you need pessimistic locking"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if permissive
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"projects_onto :#{target_association} got `permissive:` with mode: :sql; " \
|
|
89
|
+
"`permissive:` is not supported by :sql mode — recompute SQL doesn't dispatch " \
|
|
90
|
+
"through per-kind handlers; use :inline mode if you need permissive dispatch"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless block
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"projects_onto :#{target_association} requires a block with `recompute \"...\"` for mode: :sql"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
dsl = SqlDsl.new
|
|
99
|
+
dsl.instance_eval(&block)
|
|
100
|
+
|
|
101
|
+
if dsl.recompute_sql.nil?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"projects_onto :#{target_association} block is empty; mode: :sql requires a `recompute \"...\"` clause"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unless dsl.recompute_sql.include?(":target_id")
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"projects_onto :#{target_association} recompute SQL must include the `:target_id` placeholder; " \
|
|
109
|
+
"it's bound to the entry's foreign key for this projection's target"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
definition = Definition.new(
|
|
113
|
+
target_association: target_association,
|
|
114
|
+
mode: mode,
|
|
115
|
+
projector_class: nil,
|
|
116
|
+
handlers: {},
|
|
117
|
+
guard: guard,
|
|
118
|
+
lock: lock,
|
|
119
|
+
permissive: permissive,
|
|
120
|
+
recompute_sql: dsl.recompute_sql,
|
|
121
|
+
view: nil,
|
|
122
|
+
refresh_options: nil,
|
|
123
|
+
options: options
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
127
|
+
install_mode_callbacks_for(definition)
|
|
128
|
+
return definition
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if mode == :matview
|
|
132
|
+
if block
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"projects_onto :#{target_association} mode: :matview does not accept a block; " \
|
|
135
|
+
"matview projections have no per-kind handlers — they refresh on a schedule"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if view.nil?
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"projects_onto :#{target_association} mode: :matview requires `view: \"name\"` " \
|
|
141
|
+
"(the materialized view name to refresh)"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
definition = Definition.new(
|
|
145
|
+
target_association: target_association,
|
|
146
|
+
mode: mode,
|
|
147
|
+
projector_class: nil,
|
|
148
|
+
handlers: {},
|
|
149
|
+
guard: guard,
|
|
150
|
+
lock: lock,
|
|
151
|
+
permissive: permissive,
|
|
152
|
+
recompute_sql: nil,
|
|
153
|
+
view: view.to_s,
|
|
154
|
+
refresh_options: refresh || {},
|
|
155
|
+
options: options
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
159
|
+
install_mode_callbacks_for(definition)
|
|
160
|
+
return definition
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if block && via
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"projects_onto :#{target_association} got both a block and `via:`; the two forms are mutually exclusive"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless block || via
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"projects_onto :#{target_association} requires either a block of `on(:kind) { ... }` handlers or `via: ProjectorClass`"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if via && permissive
|
|
174
|
+
raise ArgumentError,
|
|
175
|
+
"projects_onto :#{target_association} got `permissive: true` with `via:`; " \
|
|
176
|
+
"`permissive:` is only meaningful with the block form, where it falls back " \
|
|
177
|
+
"to the `:_` wildcard handler when no specific-kind handler matches. Class " \
|
|
178
|
+
"form (`via: ProjectorClass`) runs `apply(target, entry)` unconditionally, " \
|
|
179
|
+
"so there's nothing to be permissive about."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
handlers = {}
|
|
183
|
+
if block
|
|
184
|
+
dsl = HandlerDsl.new
|
|
185
|
+
dsl.instance_eval(&block)
|
|
186
|
+
handlers = dsl.handlers
|
|
187
|
+
|
|
188
|
+
if handlers.empty?
|
|
189
|
+
raise ArgumentError,
|
|
190
|
+
"projects_onto :#{target_association} block is empty; at least one `on(:kind) { ... }` handler is required"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
definition = Definition.new(
|
|
195
|
+
target_association: target_association,
|
|
196
|
+
mode: mode,
|
|
197
|
+
projector_class: via,
|
|
198
|
+
handlers: handlers,
|
|
199
|
+
guard: guard,
|
|
200
|
+
lock: lock,
|
|
201
|
+
permissive: permissive,
|
|
202
|
+
recompute_sql: nil,
|
|
203
|
+
view: nil,
|
|
204
|
+
refresh_options: nil,
|
|
205
|
+
options: options
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self.standard_ledger_projections = standard_ledger_projections + [ definition ]
|
|
209
|
+
install_mode_callbacks_for(definition)
|
|
210
|
+
definition
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Delegate per-mode callback installation to the matching strategy
|
|
214
|
+
# class. Keeps `Modes::*` from reaching into `Projector`'s internals
|
|
215
|
+
# — each strategy gets a chance to wire up `after_create`,
|
|
216
|
+
# `after_create_commit`, or whatever lifecycle hook it needs the first
|
|
217
|
+
# time a projection of its mode is registered on this class.
|
|
218
|
+
#
|
|
219
|
+
# @param definition [Definition]
|
|
220
|
+
# @return [void]
|
|
221
|
+
def install_mode_callbacks_for(definition)
|
|
222
|
+
case definition.mode
|
|
223
|
+
when :inline
|
|
224
|
+
StandardLedger::Modes::Inline.install!(self)
|
|
225
|
+
when :sql
|
|
226
|
+
StandardLedger::Modes::Sql.install!(self)
|
|
227
|
+
when :matview
|
|
228
|
+
StandardLedger::Modes::Matview.install!(self)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Filter the registered projections by mode. Used by the per-mode
|
|
233
|
+
# strategy classes (`Modes::Inline`, `Modes::Async`, ...) to discover
|
|
234
|
+
# which projections they own for a given entry class.
|
|
235
|
+
#
|
|
236
|
+
# @param mode [Symbol] one of `:inline`, `:async`, `:sql`, `:trigger`,
|
|
237
|
+
# `:matview`.
|
|
238
|
+
# @return [Array<Definition>] the matching definitions, in declared order.
|
|
239
|
+
def standard_ledger_projections_for(mode)
|
|
240
|
+
standard_ledger_projections.select { |definition| definition.mode == mode }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
included do
|
|
245
|
+
class_attribute :standard_ledger_projections, instance_writer: false
|
|
246
|
+
self.standard_ledger_projections = []
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Apply a single projection definition to this entry. Resolves the
|
|
250
|
+
# target association, evaluates the optional `if:` guard, looks up the
|
|
251
|
+
# per-kind handler (or falls back to the projector class), and invokes
|
|
252
|
+
# it.
|
|
253
|
+
#
|
|
254
|
+
# `lock:` is interpreted by the mode strategies (`Modes::Inline`, ...)
|
|
255
|
+
# — not here. The inline strategy needs the lock to span both the
|
|
256
|
+
# handler invocation *and* the coalesced `target.save!`, so it wraps
|
|
257
|
+
# an entire per-target group rather than a single `apply_projection!`
|
|
258
|
+
# call. See `Modes::Inline#call` for the lock-spans-save guarantee.
|
|
259
|
+
#
|
|
260
|
+
# The mode strategies call this method; hosts typically do not call it
|
|
261
|
+
# directly.
|
|
262
|
+
#
|
|
263
|
+
# @param definition [Definition] one of the entry class's registered
|
|
264
|
+
# projections.
|
|
265
|
+
# @return [Boolean] `true` when the handler (or projector class) ran;
|
|
266
|
+
# `false` when the projection short-circuited (guard returned false,
|
|
267
|
+
# target was nil, or permissive mode found no matching handler).
|
|
268
|
+
# @raise [StandardLedger::Error] when the entry's kind column is nil.
|
|
269
|
+
# @raise [StandardLedger::UnhandledKind] when no handler matches and
|
|
270
|
+
# `permissive: false`.
|
|
271
|
+
def apply_projection!(definition)
|
|
272
|
+
if definition.mode == :sql
|
|
273
|
+
raise Error,
|
|
274
|
+
"apply_projection! is not supported for mode: :sql; " \
|
|
275
|
+
"the recompute SQL runs through `Modes::Sql#call` directly with no per-kind dispatch"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
return false if definition.guard && !instance_exec(&definition.guard)
|
|
279
|
+
|
|
280
|
+
target = public_send(definition.target_association)
|
|
281
|
+
return false if target.nil?
|
|
282
|
+
|
|
283
|
+
if definition.projector_class
|
|
284
|
+
definition.projector_class.new.apply(target, self)
|
|
285
|
+
return true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
kind = resolve_kind!
|
|
289
|
+
handler = definition.handlers[kind.to_sym]
|
|
290
|
+
|
|
291
|
+
if handler.nil?
|
|
292
|
+
if definition.permissive
|
|
293
|
+
handler = definition.handlers[:_]
|
|
294
|
+
return false if handler.nil?
|
|
295
|
+
else
|
|
296
|
+
raise UnhandledKind,
|
|
297
|
+
"#{self.class.name} has no handler for kind=#{kind.inspect} on projection :#{definition.target_association}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
handler.call(target, self)
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def resolve_kind!
|
|
308
|
+
unless self.class.respond_to?(:standard_ledger_entry_config)
|
|
309
|
+
raise Error,
|
|
310
|
+
"#{self.class.name} includes Projector but not Entry; " \
|
|
311
|
+
"`ledger_entry` must be called before apply_projection! can dispatch"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
kind_column = self.class.standard_ledger_entry_config&.[](:kind) || :kind
|
|
315
|
+
kind = public_send(kind_column)
|
|
316
|
+
if kind.nil?
|
|
317
|
+
raise Error,
|
|
318
|
+
"#{self.class.name} entry has nil kind (column #{kind_column.inspect}); cannot dispatch projection"
|
|
319
|
+
end
|
|
320
|
+
kind
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Internal collector for the block-DSL form. Captures `on(:kind)` calls
|
|
324
|
+
# into a hash keyed by kind. Wildcard `on(:_)` is reserved as a catch-all
|
|
325
|
+
# — its handler runs only when no specific-kind handler matched.
|
|
326
|
+
class HandlerDsl
|
|
327
|
+
attr_reader :handlers
|
|
328
|
+
|
|
329
|
+
def initialize
|
|
330
|
+
@handlers = {}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def on(kind, &block)
|
|
334
|
+
raise ArgumentError, "on(:#{kind}) requires a block" unless block
|
|
335
|
+
@handlers[kind.to_sym] = block
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Internal collector for the `:sql`-mode block-DSL form. Captures the
|
|
340
|
+
# `recompute "..."` clause's SQL string. Unlike `HandlerDsl` there are
|
|
341
|
+
# no per-kind handlers — the recompute SQL is the entire contract: it
|
|
342
|
+
# must be expressible as a single statement with `:target_id` bound
|
|
343
|
+
# from the entry's foreign key, and it serves both the after-create
|
|
344
|
+
# path and `StandardLedger.rebuild!`.
|
|
345
|
+
class SqlDsl
|
|
346
|
+
attr_reader :recompute_sql
|
|
347
|
+
|
|
348
|
+
def recompute(sql)
|
|
349
|
+
unless sql.is_a?(String)
|
|
350
|
+
raise ArgumentError, "recompute requires a SQL string; got #{sql.class}"
|
|
351
|
+
end
|
|
352
|
+
unless @recompute_sql.nil?
|
|
353
|
+
raise ArgumentError,
|
|
354
|
+
"recompute called more than once in the same projects_onto block; " \
|
|
355
|
+
":sql mode supports exactly one recompute clause per projection"
|
|
356
|
+
end
|
|
357
|
+
@recompute_sql = sql
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
# The gem's default result type, returned by `StandardLedger.post` and
|
|
3
|
+
# `StandardLedger.rebuild!` when the host has not configured an adapter
|
|
4
|
+
# for its own Result class.
|
|
5
|
+
#
|
|
6
|
+
# Hosts with their own Result type (e.g. `ApplicationOperation::Result`)
|
|
7
|
+
# register a translator via `StandardLedger.config.result_adapter` so the
|
|
8
|
+
# gem returns the host's type instead — see `Config#result_adapter`.
|
|
9
|
+
class Result
|
|
10
|
+
attr_reader :value, :errors, :entry, :projections
|
|
11
|
+
|
|
12
|
+
# @param success [Boolean]
|
|
13
|
+
# @param value [Object, nil] typically the persisted entry, or whatever the
|
|
14
|
+
# host operation wishes to surface.
|
|
15
|
+
# @param errors [Array<String>] human-readable error messages.
|
|
16
|
+
# @param entry [ActiveRecord::Base, nil] the persisted entry record.
|
|
17
|
+
# @param idempotent [Boolean] true when the create was a no-op because an
|
|
18
|
+
# existing row already satisfied the idempotency key.
|
|
19
|
+
# @param projections [Hash] split by mode: `{ inline: [...], async: [...], matview: [...] }`.
|
|
20
|
+
def initialize(success:, value: nil, errors: [], entry: nil, idempotent: false, projections: {})
|
|
21
|
+
@success = success
|
|
22
|
+
@value = value
|
|
23
|
+
@errors = errors
|
|
24
|
+
@entry = entry
|
|
25
|
+
@idempotent = idempotent
|
|
26
|
+
@projections = projections
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def success?
|
|
30
|
+
@success
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def failure?
|
|
34
|
+
!@success
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def idempotent?
|
|
38
|
+
@idempotent
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build a successful result. Convenience for `StandardLedger.post` and
|
|
42
|
+
# internal callers; not intended as the host's primary construction path.
|
|
43
|
+
def self.success(entry:, idempotent: false, projections: {})
|
|
44
|
+
new(success: true, value: entry, entry: entry, idempotent: idempotent, projections: projections)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.failure(errors:, entry: nil, projections: {})
|
|
48
|
+
new(success: false, errors: Array(errors), entry: entry, projections: projections)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "rspec/expectations"
|
|
2
|
+
require "active_support/notifications"
|
|
3
|
+
|
|
4
|
+
# `post_ledger_entry` — assert that a block of code wrote a ledger entry.
|
|
5
|
+
#
|
|
6
|
+
# expect {
|
|
7
|
+
# Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
|
|
8
|
+
# }.to post_ledger_entry(VoucherRecord)
|
|
9
|
+
#
|
|
10
|
+
# expect {
|
|
11
|
+
# Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
|
|
12
|
+
# }.to post_ledger_entry(VoucherRecord).with(kind: :grant)
|
|
13
|
+
#
|
|
14
|
+
# expect {
|
|
15
|
+
# Vouchers::IssueOperation.call(scheme: scheme, profile: profile)
|
|
16
|
+
# }.to post_ledger_entry(VoucherRecord).with(
|
|
17
|
+
# kind: :grant,
|
|
18
|
+
# targets: { voucher_scheme: scheme },
|
|
19
|
+
# attrs: { serial_no: "v-123" }
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# expect { ... }.to_not post_ledger_entry(VoucherRecord)
|
|
23
|
+
#
|
|
24
|
+
# The matcher subscribes to `<namespace>.entry.created` for the duration of
|
|
25
|
+
# the block, captures every fired event, and asserts that at least one event
|
|
26
|
+
# matched the expected class (and, when chained, the expected `kind`,
|
|
27
|
+
# `targets`, and `attrs`). The notification namespace is read from
|
|
28
|
+
# `StandardLedger.config.notification_namespace`, so a host that customised
|
|
29
|
+
# the namespace before the block runs is honored automatically.
|
|
30
|
+
RSpec::Matchers.define :post_ledger_entry do |entry_class|
|
|
31
|
+
supports_block_expectations
|
|
32
|
+
|
|
33
|
+
chain :with do |options = {}|
|
|
34
|
+
@expected_kind = options[:kind] if options.key?(:kind)
|
|
35
|
+
@expected_targets = options[:targets] if options.key?(:targets)
|
|
36
|
+
@expected_attrs = options[:attrs] if options.key?(:attrs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
match do |block|
|
|
40
|
+
@expected_class = entry_class
|
|
41
|
+
@captured_events = capture_entry_created_events(&block)
|
|
42
|
+
|
|
43
|
+
@captured_events.any? { |payload| event_matches?(payload) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
match_when_negated do |block|
|
|
47
|
+
@expected_class = entry_class
|
|
48
|
+
@captured_events = capture_entry_created_events(&block)
|
|
49
|
+
|
|
50
|
+
@captured_events.none? { |payload| event_matches?(payload) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
failure_message do
|
|
54
|
+
if @captured_events.empty?
|
|
55
|
+
"expected block to post a #{@expected_class} ledger entry, but no " \
|
|
56
|
+
"`#{notification_event_name}` events fired"
|
|
57
|
+
else
|
|
58
|
+
"expected block to post a #{@expected_class} ledger entry " \
|
|
59
|
+
"#{describe_expectations}, but got: #{describe_captured_events}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
failure_message_when_negated do
|
|
64
|
+
matched = @captured_events.select { |payload| event_matches?(payload) }
|
|
65
|
+
"expected block not to post a #{@expected_class} ledger entry " \
|
|
66
|
+
"#{describe_expectations}, but #{matched.size} matching event(s) fired: " \
|
|
67
|
+
"#{describe_captured_events(matched)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------------------
|
|
71
|
+
# Helpers
|
|
72
|
+
# ----------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def capture_entry_created_events(&block)
|
|
75
|
+
events = []
|
|
76
|
+
name = notification_event_name
|
|
77
|
+
subscriber = ActiveSupport::Notifications.subscribe(name) do |*args|
|
|
78
|
+
events << ActiveSupport::Notifications::Event.new(*args).payload
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
block.call
|
|
83
|
+
ensure
|
|
84
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
events
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def notification_event_name
|
|
91
|
+
"#{StandardLedger.config.notification_namespace}.entry.created"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def event_matches?(payload)
|
|
95
|
+
entry = payload[:entry]
|
|
96
|
+
return false unless entry.is_a?(@expected_class)
|
|
97
|
+
|
|
98
|
+
if defined?(@expected_kind)
|
|
99
|
+
return false unless kind_matches?(payload[:kind], @expected_kind)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if defined?(@expected_targets)
|
|
103
|
+
return false unless hash_includes?(payload[:targets] || {}, @expected_targets)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if defined?(@expected_attrs)
|
|
107
|
+
return false unless attrs_match?(entry, @expected_attrs)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# `kind` arrives in the payload as whatever the entry stored — usually a
|
|
114
|
+
# string, since that's what a string column reads back as. Allow specs to
|
|
115
|
+
# pass either symbol or string and compare loosely.
|
|
116
|
+
def kind_matches?(actual, expected)
|
|
117
|
+
actual.to_s == expected.to_s
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def hash_includes?(actual, expected)
|
|
121
|
+
expected.all? { |key, value| actual[key] == value }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def attrs_match?(entry, expected)
|
|
125
|
+
expected.all? do |key, value|
|
|
126
|
+
next false unless entry.respond_to?(key)
|
|
127
|
+
|
|
128
|
+
entry.public_send(key) == value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def describe_expectations
|
|
133
|
+
parts = []
|
|
134
|
+
parts << "with kind: #{@expected_kind.inspect}" if defined?(@expected_kind)
|
|
135
|
+
parts << "targets: #{@expected_targets.inspect}" if defined?(@expected_targets)
|
|
136
|
+
parts << "attrs: #{@expected_attrs.inspect}" if defined?(@expected_attrs)
|
|
137
|
+
parts.empty? ? "" : "(#{parts.join(', ')})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def describe_captured_events(events = @captured_events)
|
|
141
|
+
return "(none)" if events.empty?
|
|
142
|
+
|
|
143
|
+
events.map { |payload|
|
|
144
|
+
"<#{payload[:entry].class}: kind=#{payload[:kind].inspect}, " \
|
|
145
|
+
"targets=#{(payload[:targets] || {}).keys.inspect}>"
|
|
146
|
+
}.join(", ")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "rspec/core"
|
|
2
|
+
require "rspec/expectations"
|
|
3
|
+
|
|
4
|
+
require "standard_ledger"
|
|
5
|
+
require "standard_ledger/rspec/matchers"
|
|
6
|
+
require "standard_ledger/rspec/helpers"
|
|
7
|
+
|
|
8
|
+
# Opt-in test support for host apps. Hosts add this require to their
|
|
9
|
+
# `spec/rails_helper.rb` (or equivalent):
|
|
10
|
+
#
|
|
11
|
+
# require "standard_ledger/rspec"
|
|
12
|
+
#
|
|
13
|
+
# Loading this file:
|
|
14
|
+
#
|
|
15
|
+
# - Registers a `before(:each)` hook that calls
|
|
16
|
+
# `StandardLedger.reset_mode_overrides!` so the thread-local `with_modes`
|
|
17
|
+
# override map doesn't leak between examples. We deliberately do *not* call
|
|
18
|
+
# the full `reset!` here: hosts often configure the gem from a Rails
|
|
19
|
+
# initializer (`StandardLedger.configure { |c| c.result_adapter = ... }`),
|
|
20
|
+
# and wiping `@config` between examples would silently undo that
|
|
21
|
+
# configuration for every spec. The reset is wired via `RSpec.configure`
|
|
22
|
+
# rather than a custom shared context so it applies to every example group
|
|
23
|
+
# automatically.
|
|
24
|
+
# - Defines the `post_ledger_entry` matcher (see
|
|
25
|
+
# `StandardLedger::RSpec::Matchers`) for assertions of the form
|
|
26
|
+
# `expect { ... }.to post_ledger_entry(EntryClass).with(kind: ...)`.
|
|
27
|
+
# - Includes `StandardLedger::RSpec::Helpers` into every example group so
|
|
28
|
+
# specs can call `with_modes(...)` directly without the module prefix.
|
|
29
|
+
#
|
|
30
|
+
# We intentionally avoid touching subscribers, AR connections, or any host
|
|
31
|
+
# state — the gem only owns its own configuration. Hosts that need additional
|
|
32
|
+
# cleanup wire their own hooks alongside this one.
|
|
33
|
+
module StandardLedger
|
|
34
|
+
module RSpec
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
::RSpec.configure do |config|
|
|
39
|
+
config.before(:each) do
|
|
40
|
+
StandardLedger.reset_mode_overrides!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
config.include StandardLedger::RSpec::Helpers
|
|
44
|
+
end
|