standard_ledger 0.2.0 → 0.4.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 +170 -1
- data/README.md +102 -4
- data/lib/standard_ledger/config.rb +3 -2
- data/lib/standard_ledger/engine.rb +7 -0
- data/lib/standard_ledger/entry.rb +99 -10
- data/lib/standard_ledger/errors.rb +10 -0
- data/lib/standard_ledger/jobs/projection_job.rb +94 -0
- data/lib/standard_ledger/modes/async.rb +141 -0
- data/lib/standard_ledger/modes/matview.rb +35 -4
- data/lib/standard_ledger/modes/trigger.rb +50 -0
- data/lib/standard_ledger/projector.rb +279 -2
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +38 -15
- data/lib/tasks/standard_ledger.rake +60 -0
- metadata +10 -5
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
module Modes
|
|
3
|
+
# `:async` mode: applies the projection in a background job enqueued
|
|
4
|
+
# from the entry's `after_create_commit` callback. The job runs after
|
|
5
|
+
# the outer transaction has committed, so the entry is durable before
|
|
6
|
+
# the projection runs — and a job failure does NOT roll back the entry.
|
|
7
|
+
#
|
|
8
|
+
# Used when the projection is too expensive or stateful for the entry's
|
|
9
|
+
# transaction (jsonb rebuild, multi-row aggregate). The canonical
|
|
10
|
+
# example is nutripod's `Order#payable_balance` / `Order#fulfillable_balance`
|
|
11
|
+
# jsonb columns, which need to be recomputed from every PaymentRecord /
|
|
12
|
+
# FulfillmentRecord against the order — work that's safe to defer past
|
|
13
|
+
# the originating transaction.
|
|
14
|
+
#
|
|
15
|
+
# Class-form only: `:async` projections must declare `via: ProjectorClass`,
|
|
16
|
+
# whose `apply(target, entry)` should recompute from the log inside
|
|
17
|
+
# `with_lock` rather than apply a delta. Block-form per-kind handlers
|
|
18
|
+
# aren't safe under retry — incrementing a counter twice on retry is a
|
|
19
|
+
# silent data corruption bug — so block-form is rejected at registration
|
|
20
|
+
# time (see `Projector#projects_onto`).
|
|
21
|
+
#
|
|
22
|
+
# The strategy installs an `after_create_commit` callback once per entry
|
|
23
|
+
# class. The callback walks every `:async`-mode projection registered on
|
|
24
|
+
# the class and enqueues `StandardLedger::ProjectionJob` per (entry,
|
|
25
|
+
# projection) pair, honoring the optional `if:` guard.
|
|
26
|
+
#
|
|
27
|
+
# ## with_modes interop
|
|
28
|
+
#
|
|
29
|
+
# `StandardLedger.with_modes(EntryClass => :inline) { ... }` forces async
|
|
30
|
+
# projections to run synchronously inside the block — useful in unit
|
|
31
|
+
# specs that want end-to-end coverage without standing up a job runner.
|
|
32
|
+
# Inline-override mode skips the enqueue and runs `target.with_lock {
|
|
33
|
+
# projector.apply(target, entry) }` directly. The override is read in
|
|
34
|
+
# `#call`; specs can capture the empty job queue via `have_enqueued_job`
|
|
35
|
+
# / `perform_enqueued_jobs` and still observe the projection's effects.
|
|
36
|
+
class Async
|
|
37
|
+
# Install the `after_create_commit` callback on `entry_class` exactly
|
|
38
|
+
# once. Subsequent calls (e.g. when a second `:async` projection is
|
|
39
|
+
# added later in the class body) are no-ops — the same callback
|
|
40
|
+
# handles all `:async` projections registered on the class.
|
|
41
|
+
#
|
|
42
|
+
# @param entry_class [Class] the host entry class.
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
|
|
45
|
+
# (no `after_create_commit` hook available). `:async` mode requires
|
|
46
|
+
# AR transactional callbacks; non-AR entry classes can't dispatch
|
|
47
|
+
# the post-commit enqueue.
|
|
48
|
+
def self.install!(entry_class)
|
|
49
|
+
return if entry_class.instance_variable_get(:@_standard_ledger_async_installed)
|
|
50
|
+
|
|
51
|
+
unless entry_class.respond_to?(:after_create_commit)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"Modes::Async requires an ActiveRecord-backed entry class on " \
|
|
54
|
+
"#{entry_class.name || entry_class.inspect} — the entry class must inherit " \
|
|
55
|
+
"from ActiveRecord::Base. Use :inline mode for plain-Ruby entry classes."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
entry_class.after_create_commit { StandardLedger::Modes::Async.new.call(self) }
|
|
59
|
+
entry_class.instance_variable_set(:@_standard_ledger_async_installed, true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Enqueue `ProjectionJob` for every `:async` projection registered on
|
|
63
|
+
# the entry's class. Called from the `after_create_commit` callback
|
|
64
|
+
# installed by `.install!`.
|
|
65
|
+
#
|
|
66
|
+
# Honors the optional `if:` guard (skips enqueue when guard returns
|
|
67
|
+
# false) and `StandardLedger.mode_override_for(entry_class)` (when set
|
|
68
|
+
# to `:inline`, runs the projection synchronously inside `with_lock`
|
|
69
|
+
# instead of enqueueing).
|
|
70
|
+
#
|
|
71
|
+
# A nil target at enqueue time skips silently — the entry's FK was
|
|
72
|
+
# unset for this projection's association, so there's nothing to
|
|
73
|
+
# project onto. (The job has its own nil-target guard; this short-
|
|
74
|
+
# circuit just avoids the wasted enqueue.)
|
|
75
|
+
#
|
|
76
|
+
# @param entry [ActiveRecord::Base] the just-committed entry.
|
|
77
|
+
# @return [void]
|
|
78
|
+
def call(entry)
|
|
79
|
+
definitions = async_definitions_for(entry.class)
|
|
80
|
+
return if definitions.empty?
|
|
81
|
+
|
|
82
|
+
override = StandardLedger.mode_override_for(entry.class)
|
|
83
|
+
|
|
84
|
+
definitions.each do |definition|
|
|
85
|
+
next if definition.guard && !entry.instance_exec(&definition.guard)
|
|
86
|
+
|
|
87
|
+
if override == :inline
|
|
88
|
+
run_inline(entry, definition)
|
|
89
|
+
else
|
|
90
|
+
target = entry.public_send(definition.target_association)
|
|
91
|
+
next if target.nil?
|
|
92
|
+
|
|
93
|
+
job_class = StandardLedger.config.default_async_job || StandardLedger::ProjectionJob
|
|
94
|
+
job_class.perform_later(entry, definition.target_association.to_s)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def async_definitions_for(entry_class)
|
|
102
|
+
return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
|
|
103
|
+
|
|
104
|
+
entry_class.standard_ledger_projections_for(:async)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `with_modes(EntryClass => :inline)` short-circuit: run the projector
|
|
108
|
+
# synchronously inside `with_lock`, mirroring the job's behavior
|
|
109
|
+
# without the enqueue. Skips silently when the target is nil so the
|
|
110
|
+
# nil-FK contract matches the enqueue path.
|
|
111
|
+
def run_inline(entry, definition)
|
|
112
|
+
target = entry.public_send(definition.target_association)
|
|
113
|
+
return if target.nil?
|
|
114
|
+
|
|
115
|
+
prefix = StandardLedger.config.notification_namespace
|
|
116
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
target.with_lock do
|
|
120
|
+
definition.projector_class.new.apply(target, entry)
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
124
|
+
StandardLedger::EventEmitter.emit(
|
|
125
|
+
"#{prefix}.projection.failed",
|
|
126
|
+
entry: entry, target: target, projection: definition.target_association,
|
|
127
|
+
mode: :async, error: e, duration_ms: duration_ms, attempt: 1
|
|
128
|
+
)
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
133
|
+
StandardLedger::EventEmitter.emit(
|
|
134
|
+
"#{prefix}.projection.applied",
|
|
135
|
+
entry: entry, target: target, projection: definition.target_association,
|
|
136
|
+
mode: :async, duration_ms: duration_ms, attempt: 1
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -63,6 +63,7 @@ module StandardLedger
|
|
|
63
63
|
# the SQL — re-raised after the `failed` event fires.
|
|
64
64
|
def self.refresh!(view_name, concurrently:)
|
|
65
65
|
validate_view_name!(view_name)
|
|
66
|
+
check_transaction_state!(view_name, concurrently: concurrently)
|
|
66
67
|
|
|
67
68
|
prefix = StandardLedger.config.notification_namespace
|
|
68
69
|
sql = build_refresh_sql(view_name, concurrently: concurrently)
|
|
@@ -76,9 +77,10 @@ module StandardLedger
|
|
|
76
77
|
view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
|
|
77
78
|
)
|
|
78
79
|
rescue StandardError => e
|
|
79
|
-
# ArgumentError from the validator
|
|
80
|
-
# the
|
|
81
|
-
|
|
80
|
+
# ArgumentError from the validator and RefreshInsideTransaction from
|
|
81
|
+
# the boundary check should propagate without firing the failed
|
|
82
|
+
# notification — the SQL was never issued.
|
|
83
|
+
raise if e.is_a?(ArgumentError) || e.is_a?(StandardLedger::RefreshInsideTransaction)
|
|
82
84
|
|
|
83
85
|
StandardLedger::EventEmitter.emit(
|
|
84
86
|
"#{prefix}.projection.failed",
|
|
@@ -95,13 +97,42 @@ module StandardLedger
|
|
|
95
97
|
# injection isn't possible even when a host carelessly pipes a config
|
|
96
98
|
# value into the call.
|
|
97
99
|
def self.validate_view_name!(view_name)
|
|
98
|
-
|
|
100
|
+
# Bare identifier OR exactly one schema-qualified `schema.view` part.
|
|
101
|
+
# The previous shape `\A[a-zA-Z_][a-zA-Z0-9_.]*\z` allowed trailing
|
|
102
|
+
# dots (`reporting.`) and unlimited qualification (`a.b.c.d`); both
|
|
103
|
+
# would round-trip to a Postgres syntax error at `connection.execute`
|
|
104
|
+
# time rather than the gem boundary.
|
|
105
|
+
return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/)
|
|
99
106
|
|
|
100
107
|
raise ArgumentError,
|
|
101
108
|
"view_name must be a valid SQL identifier; got #{view_name.inspect}"
|
|
102
109
|
end
|
|
103
110
|
private_class_method :validate_view_name!
|
|
104
111
|
|
|
112
|
+
# Reject `refresh!` calls issued from inside an open transaction when
|
|
113
|
+
# `concurrently: true`. Postgres rejects
|
|
114
|
+
# `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a transaction block
|
|
115
|
+
# (and would otherwise raise `PG::ActiveSqlTransaction` mid-call); we
|
|
116
|
+
# catch it at the gem boundary so the failure is attributable.
|
|
117
|
+
#
|
|
118
|
+
# The non-concurrent (`concurrently: false`) form *is* permitted inside
|
|
119
|
+
# a transaction by Postgres, so we only guard the concurrent path.
|
|
120
|
+
#
|
|
121
|
+
# Use `connection.add_transaction_record { … }` to defer to after-commit
|
|
122
|
+
# if you need read-your-write semantics from inside a transactional
|
|
123
|
+
# operation; otherwise, move the refresh outside the transaction.
|
|
124
|
+
def self.check_transaction_state!(view_name, concurrently:)
|
|
125
|
+
return unless concurrently
|
|
126
|
+
return unless ActiveRecord::Base.connection.transaction_open?
|
|
127
|
+
|
|
128
|
+
raise StandardLedger::RefreshInsideTransaction,
|
|
129
|
+
"StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
|
|
130
|
+
"concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
|
|
131
|
+
"transaction blocks. Move the call outside the transaction, or defer it via " \
|
|
132
|
+
"`connection.add_transaction_record { ... }` for after-commit execution."
|
|
133
|
+
end
|
|
134
|
+
private_class_method :check_transaction_state!
|
|
135
|
+
|
|
105
136
|
def self.build_refresh_sql(view_name, concurrently:)
|
|
106
137
|
if concurrently
|
|
107
138
|
"REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
module Modes
|
|
3
|
+
# `:trigger` mode: the host owns a database trigger (created in a Rails
|
|
4
|
+
# migration) that updates the projection target's columns on every
|
|
5
|
+
# entry INSERT. The gem records the trigger's name and the equivalent
|
|
6
|
+
# rebuild SQL, but does **not** create or manage the trigger itself —
|
|
7
|
+
# giving a Ruby DSL the power to install/replace triggers is a deploy
|
|
8
|
+
# footgun (silent re-creation on `db:schema:load` against a non-empty
|
|
9
|
+
# DB), and triggers are versioned by `db/schema.rb` like any other DDL.
|
|
10
|
+
#
|
|
11
|
+
# Two consumers read the recorded metadata:
|
|
12
|
+
#
|
|
13
|
+
# - `StandardLedger.rebuild!` runs the rebuild SQL when invoked,
|
|
14
|
+
# binding `:target_id` to each target the log references. The same
|
|
15
|
+
# recompute path as `:sql` mode — the only difference is that the
|
|
16
|
+
# after-create application is performed by the database trigger
|
|
17
|
+
# rather than a Ruby callback.
|
|
18
|
+
# - The `standard_ledger:doctor` rake task verifies that the named
|
|
19
|
+
# trigger exists in the connected schema. Migration drift (a missing
|
|
20
|
+
# or renamed trigger) is caught at deploy time, not at runtime.
|
|
21
|
+
#
|
|
22
|
+
# Unlike `:inline` and `:sql`, this strategy does NOT install an
|
|
23
|
+
# `after_create` callback — the trigger fires from the database. The
|
|
24
|
+
# `install!` no-op only marks the entry class as having at least one
|
|
25
|
+
# `:trigger` projection registered (mirrors the `Modes::Matview` shape).
|
|
26
|
+
#
|
|
27
|
+
# See `standard_ledger-design.md` §5.3.4 for the full contract.
|
|
28
|
+
class Trigger
|
|
29
|
+
# Mark the entry class as having at least one `:trigger` projection
|
|
30
|
+
# registered. The actual trigger metadata lives on the entry class's
|
|
31
|
+
# `standard_ledger_projections` array; this method exists only to
|
|
32
|
+
# mirror the `Modes::*.install!` shape so `Projector#install_mode_callbacks_for`
|
|
33
|
+
# can dispatch uniformly across modes.
|
|
34
|
+
#
|
|
35
|
+
# No `after_create` callback is installed — the trigger runs in the
|
|
36
|
+
# database, not Ruby. Idempotent across multiple `:trigger`
|
|
37
|
+
# projections on the same entry class.
|
|
38
|
+
#
|
|
39
|
+
# @param entry_class [Class] the host entry class (unused — kept for
|
|
40
|
+
# parity with the other strategy classes).
|
|
41
|
+
# @return [void]
|
|
42
|
+
def self.install!(_entry_class)
|
|
43
|
+
# Intentionally empty: trigger projections fire from the DB, so
|
|
44
|
+
# there's no Ruby-side callback to wire. The DSL has already
|
|
45
|
+
# captured `trigger_name` and `recompute_sql` on the Definition;
|
|
46
|
+
# `rebuild!` and the `doctor` rake task consume those directly.
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -34,7 +34,7 @@ module StandardLedger
|
|
|
34
34
|
# projections; they're `nil` for every other mode.
|
|
35
35
|
Definition = Struct.new(
|
|
36
36
|
:target_association, :mode, :projector_class, :handlers, :guard, :lock, :permissive,
|
|
37
|
-
:recompute_sql, :view, :refresh_options, :options,
|
|
37
|
+
:recompute_sql, :trigger_name, :view, :refresh_options, :options,
|
|
38
38
|
keyword_init: true
|
|
39
39
|
)
|
|
40
40
|
|
|
@@ -62,12 +62,253 @@ module StandardLedger
|
|
|
62
62
|
# metadata, e.g. `{ every: 5.minutes, concurrently: true }`. The
|
|
63
63
|
# gem records this on the Definition for hosts to read when wiring
|
|
64
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.
|
|
65
70
|
# @yield optional block-DSL form: register per-kind handlers via
|
|
66
71
|
# `on(:kind) { |target, entry| ... }`. Not allowed for `mode: :matview`.
|
|
67
72
|
# @return [Definition] the registered projection.
|
|
68
|
-
def projects_onto(target_association, mode:, via: nil, if: nil, lock: nil, permissive: false,
|
|
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)
|
|
69
76
|
guard = binding.local_variable_get(:if) # `if:` is a reserved keyword
|
|
70
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
|
+
|
|
71
312
|
if mode == :sql
|
|
72
313
|
if via
|
|
73
314
|
raise ArgumentError,
|
|
@@ -118,6 +359,7 @@ module StandardLedger
|
|
|
118
359
|
lock: lock,
|
|
119
360
|
permissive: permissive,
|
|
120
361
|
recompute_sql: dsl.recompute_sql,
|
|
362
|
+
trigger_name: nil,
|
|
121
363
|
view: nil,
|
|
122
364
|
refresh_options: nil,
|
|
123
365
|
options: options
|
|
@@ -150,6 +392,7 @@ module StandardLedger
|
|
|
150
392
|
lock: lock,
|
|
151
393
|
permissive: permissive,
|
|
152
394
|
recompute_sql: nil,
|
|
395
|
+
trigger_name: nil,
|
|
153
396
|
view: view.to_s,
|
|
154
397
|
refresh_options: refresh || {},
|
|
155
398
|
options: options
|
|
@@ -200,6 +443,7 @@ module StandardLedger
|
|
|
200
443
|
lock: lock,
|
|
201
444
|
permissive: permissive,
|
|
202
445
|
recompute_sql: nil,
|
|
446
|
+
trigger_name: nil,
|
|
203
447
|
view: nil,
|
|
204
448
|
refresh_options: nil,
|
|
205
449
|
options: options
|
|
@@ -222,10 +466,14 @@ module StandardLedger
|
|
|
222
466
|
case definition.mode
|
|
223
467
|
when :inline
|
|
224
468
|
StandardLedger::Modes::Inline.install!(self)
|
|
469
|
+
when :async
|
|
470
|
+
StandardLedger::Modes::Async.install!(self)
|
|
225
471
|
when :sql
|
|
226
472
|
StandardLedger::Modes::Sql.install!(self)
|
|
227
473
|
when :matview
|
|
228
474
|
StandardLedger::Modes::Matview.install!(self)
|
|
475
|
+
when :trigger
|
|
476
|
+
StandardLedger::Modes::Trigger.install!(self)
|
|
229
477
|
end
|
|
230
478
|
end
|
|
231
479
|
|
|
@@ -275,6 +523,12 @@ module StandardLedger
|
|
|
275
523
|
"the recompute SQL runs through `Modes::Sql#call` directly with no per-kind dispatch"
|
|
276
524
|
end
|
|
277
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
|
+
|
|
278
532
|
return false if definition.guard && !instance_exec(&definition.guard)
|
|
279
533
|
|
|
280
534
|
target = public_send(definition.target_association)
|
|
@@ -357,5 +611,28 @@ module StandardLedger
|
|
|
357
611
|
@recompute_sql = sql
|
|
358
612
|
end
|
|
359
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
|
|
360
637
|
end
|
|
361
638
|
end
|