funes-rails 0.2.2 → 0.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa0d0b4e8fb74b347944d1cd0796b57e545a2bcd5387afb0d4c1291fd80f70db
4
- data.tar.gz: 4a1ce3b83983e298c27ff627a4510ab04eaf23108a64fc4988b51c683efdd892
3
+ metadata.gz: 2c51e21ab14ee2a2a3994320929a81e9e06a5eae4247c3ef3415f4c86f901b93
4
+ data.tar.gz: aa84a44d6e3d9b920c55952a7fefee48d186d185a5e6718b4c22577d3bcfef02
5
5
  SHA512:
6
- metadata.gz: 14aa532ec93bd4513e462394ba9b88043e99cacb1f903020bec50f4f9678aa9ee405640fee98e8470cc734260007b31e659608bc411843bc112f9b383480fd14
7
- data.tar.gz: 97c2655d30a097463aa8549faef89763421c629bbb2a67d9c456c2a1a9d551c8ab011e9207f571419412e04659816816f11fc010c03cc90d6534f9b2082dab70
6
+ metadata.gz: a28819da667269a81dbc569f1deffdb9b1fbb2d48308106b1662f1abfddbb55d8ef7df6ca3cec4007c19088b6980559725a2f1187dcee577fd847e3dc95f811b
7
+ data.tar.gz: 1811203127bd94c7537355ccb974b1b7dd29dcbd40291075bcd43c5c5e9408929aa8bf4eb2b559de56b978666e05fa3baa99ba443b0498458f5f6ca9bb3417f7
data/README.md CHANGED
@@ -90,25 +90,6 @@ Funes gives you fine-grained control over when and how projections run:
90
90
  * **Validation before persistence:** before upserting the materialization, Funes runs ActiveRecord validations on the materialization model. If the model is invalid, an `ActiveRecord::RecordInvalid` exception is raised, the transaction rolls back, and the event is not persisted.
91
91
  * **Fail-loud on errors:** if a projection fails with a database error (e.g., constraint violation) or a validation error, the transaction rolls back, the event is marked as not persisted (`persisted?` returns `false`), and the exception (`ActiveRecord::StatementInvalid` or `ActiveRecord::RecordInvalid`) propagates. This ensures bugs are immediately visible rather than silently hidden, while keeping the event in a consistent state for any rescue logic in your application.
92
92
 
93
- ### Host-managed transactions with `append!`
94
-
95
- When you need to wrap `append` inside your own `ActiveRecord::Base.transaction` — for example, to keep a sibling update in lockstep with the event, or to append to two streams atomically — use `append!`. It mirrors Rails' `save` / `save!` pair: on any failure it raises `ActiveRecord::RecordInvalid`, which rolls back the enclosing transaction.
96
-
97
- ```ruby
98
- event = Order::Placed.new(total: 99.99)
99
- begin
100
- ActiveRecord::Base.transaction do
101
- customer.update!(last_ordered_at: Time.current)
102
- OrderEventStream.for(order_id).append!(event)
103
- end
104
- rescue ActiveRecord::RecordInvalid
105
- event.persisted? # => false
106
- event.errors.any? # => true
107
- end
108
- ```
109
-
110
- The failed event stays queryable after the rescue (`persisted?`, `errors`), just like a record that failed `save!`. Async projections are only enqueued once the outer transaction commits, so a rolled-back transaction never schedules a job.
111
-
112
93
  ### Async projections
113
94
 
114
95
  * **Background processing:** these are offloaded to `ActiveJob`, ensuring that heavy computations don't slow down the write path.
@@ -122,12 +103,22 @@ The failed event stays queryable after the rescue (`persisted?`, `errors`), just
122
103
 
123
104
  Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
124
105
 
106
+ ## Performance
107
+
108
+ Precise benchmarks are notoriously hard to pin down: workloads vary, and absolute numbers depend heavily on hardware, configuration, and the shape of the data. So treat the figures we publish as directional rather than definitive.
109
+
110
+ That said, our measurements consistently show that the complexity of event stream operations stays **sub-linear, comfortably within `O(n)`** as the log grows — which is an important property for medium/long-lived streams.
111
+
112
+ You can browse the latest measurements and join the conversation in the [Performance Measurements](https://github.com/funes-org/funes/discussions/categories/performance-measurements) discussions.
113
+
125
114
  ## Compatibility
126
115
 
127
- - **Ruby:** 3.1+
128
- - **Rails:** 7.2+
116
+ Funes supports the following runtimes:
117
+
118
+ - **Ruby** 3.1 or newer
119
+ - **Rails** 7.2 or newer
129
120
 
130
- Rails 8.0+ requires Ruby 3.2 or higher.
121
+ If you're on Rails 8.0 or above, you'll need Ruby 3.2 or newer — that's a Rails 8 requirement, not a Funes one.
131
122
 
132
123
  ## License
133
124
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,112 +1,111 @@
1
1
  module Funes
2
- # Test helper for testing projections in isolation.
2
+ # Test helper for exercising projections in isolation.
3
3
  #
4
- # Include this module in your test classes to access helper methods that allow you to test
5
- # individual projection interpretations without needing to process entire event streams.
4
+ # Include this module in your test class to access methods that interpret a
5
+ # single event, build the initial state, or apply the final state of a
6
+ # projection — without processing an entire event stream.
6
7
  #
7
- # @example Include in your test
8
+ # Bind the projection class once with the +projection+ macro and then call
9
+ # {#interpret}, {#initial_state}, or {#final_state} without repeating it.
10
+ #
11
+ # @example Bind the projection at the class level
8
12
  # class InventorySnapshotProjectionTest < ActiveSupport::TestCase
9
13
  # include Funes::ProjectionTestHelper
14
+ # projection InventorySnapshotProjection
10
15
  #
11
16
  # test "receiving items increases quantity on hand" do
12
- # initial_state = InventorySnapshot.new(quantity_on_hand: 10)
17
+ # state = InventorySnapshot.new(quantity_on_hand: 10)
13
18
  # event = Inventory::ItemReceived.new(quantity: 5, unit_cost: 9.99)
14
19
  #
15
- # result = interpret_event_based_on(InventorySnapshotProjection, event, initial_state)
20
+ # result = interpret(event, given: state)
16
21
  #
17
22
  # assert_equal 15, result.quantity_on_hand
18
23
  # end
19
24
  # end
25
+ #
26
+ # @example Override the bound projection per call
27
+ # interpret(event, given: state, projection: AnotherProjection)
20
28
  module ProjectionTestHelper
21
- # Test a single event interpretation in isolation.
22
- #
23
- # This method extracts and executes a single interpretation block from a projection,
24
- # allowing you to test how specific events transform state without processing entire
25
- # event streams.
26
- #
27
- # @param [Class<Funes::Projection>] projection_class The projection class being tested.
28
- # @param [Funes::Event] event_instance The event to interpret.
29
- # @param [ActiveModel::Model, ActiveRecord::Base] previous_state The state before applying the event.
30
- # @param [Time] at The temporal reference point. Defaults to Time.current.
31
- # @return [ActiveModel::Model, ActiveRecord::Base] The new state after applying the interpretation.
29
+ extend ActiveSupport::Concern
30
+
31
+ included do
32
+ class_attribute :projection_under_test, instance_accessor: false
33
+ end
34
+
35
+ class_methods do
36
+ # Binds the projection class exercised by this test class. Subclasses
37
+ # inherit the binding and may override it with another call to
38
+ # +projection+.
39
+ #
40
+ # @param [Class<Funes::Projection>] projection_class
41
+ # @return [void]
42
+ def projection(projection_class)
43
+ self.projection_under_test = projection_class
44
+ end
45
+ end
46
+
47
+ # Interprets a single event in isolation and returns the resulting state.
32
48
  #
33
- # @example Test a single interpretation
34
- # initial_state = OrderSnapshot.new(total: 100)
35
- # event = Order::ItemAdded.new(amount: 50)
49
+ # If the event carries an +occurred_at+, it takes precedence over +at+ —
50
+ # mirroring how {Funes::Projection} replays persisted events. A +Date+
51
+ # passed as +at+ is coerced to its +beginning_of_day+.
36
52
  #
37
- # result = interpret_event_based_on(OrderSnapshotProjection, event, initial_state)
53
+ # @param [Funes::Event] event The event to interpret.
54
+ # @param [ActiveModel::Model, ActiveRecord::Base] given The state before applying the event.
55
+ # @param [Time, Date] at The temporal reference point. Defaults to +Time.current+.
56
+ # @param [Class<Funes::Projection>, nil] projection Overrides the class bound via +projection+.
57
+ # @return [ActiveModel::Model, ActiveRecord::Base] The state after the interpretation.
38
58
  #
59
+ # @example
60
+ # result = interpret(Order::ItemAdded.new(amount: 50), given: OrderSnapshot.new(total: 100))
39
61
  # assert_equal 150, result.total
40
- #
41
- # @example Test with validations
42
- # state = InventorySnapshot.new(quantity_on_hand: 5)
43
- # event = Inventory::ItemShipped.new(quantity: 10)
44
- #
45
- # result = interpret_event_based_on(InventorySnapshotProjection, event, state)
46
- #
47
- # assert_equal -5, result.quantity_on_hand
48
- # refute result.valid?
49
- def interpret_event_based_on(projection_class, event_instance, previous_state, at = Time.current)
50
- at = at.beginning_of_day if at.is_a?(Date) && !at.is_a?(Time)
51
- event_at = event_instance.occurred_at || at
52
- projection_class.instance_variable_get(:@interpretations)[event_instance.class]
53
- .call(previous_state, event_instance, event_at)
62
+ def interpret(event, given:, at: Time.current, projection: nil)
63
+ projection_class = resolve_projection(projection)
64
+ coerced_at = coerce_at(at)
65
+ event_at = event.occurred_at || coerced_at
66
+ projection_class.instance_variable_get(:@interpretations)[event.class]
67
+ .call(given, event, event_at)
54
68
  end
55
69
 
56
- # Test an initial_state block in isolation.
57
- #
58
- # This method extracts and executes the initial_state block from a projection,
59
- # allowing you to test how the projection builds its starting state without
60
- # processing entire event streams.
70
+ # Builds the initial state of the bound projection.
61
71
  #
62
- # @param [Class<Funes::Projection>] projection_class The projection class being tested.
63
- # @param [Time] at The temporal reference point. Defaults to Time.current.
64
- # @return [ActiveModel::Model, ActiveRecord::Base] The initial state produced by the projection.
72
+ # @param [Time, Date] at The temporal reference point. Defaults to +Time.current+.
73
+ # @param [Class<Funes::Projection>, nil] projection Overrides the class bound via +projection+.
74
+ # @return [ActiveModel::Model, ActiveRecord::Base] The state produced by the +initial_state+ block.
65
75
  #
66
- # @example Test initial state construction
67
- # result = build_initial_state_based_on(InventorySnapshotProjection)
68
- #
69
- # assert_equal 0, result.quantity_on_hand
70
- #
71
- # @example Test with a specific timestamp
72
- # at = Time.new(2023, 5, 10)
73
- #
74
- # result = build_initial_state_based_on(InventorySnapshotProjection, at)
75
- #
76
- # assert_equal at, result.created_at
77
- def build_initial_state_based_on(projection_class, at = Time.current)
76
+ # @example
77
+ # assert_equal 0, initial_state.quantity_on_hand
78
+ def initial_state(at: Time.current, projection: nil)
79
+ projection_class = resolve_projection(projection)
78
80
  projection_class.instance_variable_get(:@interpretations)[:init]
79
- .call(projection_class.instance_variable_get(:@materialization_model), at)
81
+ .call(projection_class.instance_variable_get(:@materialization_model), coerce_at(at))
80
82
  end
81
83
 
82
- # Test a final_state block in isolation.
83
- #
84
- # This method extracts and executes the final_state block from a projection,
85
- # allowing you to test how the projection transforms state after all event
86
- # interpretations have been applied.
87
- #
88
- # @param [Class<Funes::Projection>] projection_class The projection class being tested.
89
- # @param [ActiveModel::Model, ActiveRecord::Base] previous_state The state before applying the final transformation.
90
- # @param [Time] at The temporal reference point. Defaults to Time.current.
91
- # @return [ActiveModel::Model, ActiveRecord::Base] The final state after applying the transformation.
84
+ # Applies the final-state transformation of the bound projection.
92
85
  #
93
- # @example Test final state transformation
94
- # state = OrderSnapshot.new(total: 100, item_count: 3)
86
+ # @param [ActiveModel::Model, ActiveRecord::Base] given The state to finalize.
87
+ # @param [Time, Date] at The temporal reference point. Defaults to +Time.current+.
88
+ # @param [Class<Funes::Projection>, nil] projection Overrides the class bound via +projection+.
89
+ # @return [ActiveModel::Model, ActiveRecord::Base] The state after the +final_state+ block.
95
90
  #
96
- # result = apply_final_state_based_on(OrderSnapshotProjection, state)
97
- #
98
- # assert_equal 33.33, result.average_item_price
99
- #
100
- # @example Test with a specific timestamp
101
- # state = InventorySnapshot.new(quantity_on_hand: 10)
102
- # at = Time.new(2023, 5, 10)
103
- #
104
- # result = apply_final_state_based_on(InventorySnapshotProjection, state, at)
105
- #
106
- # assert_equal at, result.finalized_at
107
- def apply_final_state_based_on(projection_class, previous_state, at = Time.current)
91
+ # @example
92
+ # assert_equal 33.33, final_state(given: OrderSnapshot.new(total: 100, item_count: 3)).average_item_price
93
+ def final_state(given:, at: Time.current, projection: nil)
94
+ projection_class = resolve_projection(projection)
108
95
  projection_class.instance_variable_get(:@interpretations)[:final]
109
- .call(previous_state, at)
96
+ .call(given, coerce_at(at))
110
97
  end
98
+
99
+ private
100
+ def resolve_projection(explicit)
101
+ explicit || self.class.projection_under_test ||
102
+ raise(ArgumentError,
103
+ "No projection bound. Declare `projection ProjectionClass` in your test class " \
104
+ "or pass `projection:` explicitly.")
105
+ end
106
+
107
+ def coerce_at(at)
108
+ at.is_a?(Date) && !at.is_a?(Time) ? at.beginning_of_day : at
109
+ end
111
110
  end
112
111
  end
@@ -1,5 +1,6 @@
1
1
  require "funes/unknown_event"
2
2
  require "funes/unknown_materialization_model"
3
+ require "funes/invalid_materialization_state"
3
4
 
4
5
  module Funes
5
6
  # Projections perform the necessary pattern-matching to compute and aggregate the interpretations that the system
@@ -122,6 +123,31 @@ module Funes
122
123
  @materialization_model = active_record_or_model
123
124
  end
124
125
 
126
+ # Registers an instance method on the materialization model that owns the persistence step,
127
+ # replacing the framework's default upsert.
128
+ #
129
+ # When set, the named method is invoked on the in-memory state after all interpretations have
130
+ # run and +idx+ has been assigned. The method takes no arguments and is expected to raise on
131
+ # failure. The framework still calls +state.valid?+ and raises
132
+ # +Funes::InvalidMaterializationState+ before delegating, so the custom method only runs
133
+ # against valid state.
134
+ #
135
+ # Declaring +persist_materialization_model_with+ also lifts the requirement that the
136
+ # materialization model be an +ActiveRecord::Base+ subclass: a plain +ActiveModel+ class is
137
+ # enough as long as it exposes +assign_attributes+, +attributes+, +valid?+ and +errors+.
138
+ #
139
+ # @param [Symbol] method_name The instance method on the materialization model that performs the write.
140
+ # @return [void]
141
+ #
142
+ # @example Persisting a projection to a JSON file
143
+ # class YourProjection < Funes::Projection
144
+ # materialization_model YourMaterializationModel
145
+ # persist_materialization_model_with :save_to_object_storage!
146
+ # end
147
+ def persist_materialization_model_with(method_name)
148
+ @persist_method = method_name
149
+ end
150
+
125
151
  # It changes the sensibility of the projection about events that it doesn't know how to interpret
126
152
  #
127
153
  # By default, a projection ignores events that it doesn't have interpretations for. This method informs the
@@ -142,7 +168,8 @@ module Funes
142
168
  def process_events(events_collection, at: nil, consistency: false)
143
169
  new(self.instance_variable_get(:@interpretations),
144
170
  self.instance_variable_get(:@materialization_model),
145
- self.instance_variable_get(:@throws_on_unknown_events))
171
+ self.instance_variable_get(:@throws_on_unknown_events),
172
+ self.instance_variable_get(:@persist_method))
146
173
  .process_events(events_collection, at: at, consistency: consistency)
147
174
  end
148
175
 
@@ -150,13 +177,14 @@ module Funes
150
177
  def materialize!(events_collection, idx, at: nil)
151
178
  new(self.instance_variable_get(:@interpretations),
152
179
  self.instance_variable_get(:@materialization_model),
153
- self.instance_variable_get(:@throws_on_unknown_events))
180
+ self.instance_variable_get(:@throws_on_unknown_events),
181
+ self.instance_variable_get(:@persist_method))
154
182
  .materialize!(events_collection, idx, at: at)
155
183
  end
156
184
  end
157
185
 
158
186
  # @!visibility private
159
- def initialize(interpretations, materialization_model, throws_on_unknown_events)
187
+ def initialize(interpretations, materialization_model, throws_on_unknown_events, persist_method = nil)
160
188
  @interpretations = interpretations
161
189
  @materialization_model = materialization_model
162
190
  raise Funes::UnknownMaterializationModel,
@@ -164,6 +192,7 @@ module Funes
164
192
 
165
193
 
166
194
  @throws_on_unknown_events = throws_on_unknown_events
195
+ @persist_method = persist_method
167
196
  end
168
197
 
169
198
  # @!visibility private
@@ -191,13 +220,13 @@ module Funes
191
220
  # @!visibility private
192
221
  def materialize!(events_collection, idx, at: nil)
193
222
  state = process_events(events_collection, at: at)
194
- materialized_model_instance = materialized_model_instance_based_on(state)
195
- if materialization_model_is_persistable?
223
+ if persistable?
196
224
  state.assign_attributes(idx:)
197
225
  persist_based_on!(state)
226
+ return state if @persist_method
198
227
  end
199
228
 
200
- materialized_model_instance
229
+ materialized_model_instance_based_on(state)
201
230
  end
202
231
 
203
232
  private
@@ -213,13 +242,23 @@ module Funes
213
242
  @materialization_model.new(state.attributes)
214
243
  end
215
244
 
216
- def materialization_model_is_persistable?
217
- @materialization_model.present? && @materialization_model <= ActiveRecord::Base
245
+ def persistable?
246
+ return false unless @materialization_model.present?
247
+ return true if @persist_method
248
+ @materialization_model <= ActiveRecord::Base
218
249
  end
219
250
 
220
251
  def persist_based_on!(state)
221
- raise ActiveRecord::RecordInvalid.new(state) unless state.valid?
222
- @materialization_model.upsert(state.attributes, unique_by: :idx)
252
+ unless state.valid?
253
+ raise Funes::InvalidMaterializationState.new(state) if @persist_method
254
+ raise ActiveRecord::RecordInvalid.new(state)
255
+ end
256
+
257
+ if @persist_method
258
+ state.public_send(@persist_method)
259
+ else
260
+ @materialization_model.upsert(state.attributes, unique_by: :idx)
261
+ end
223
262
  end
224
263
 
225
264
  def warn_about_ineffective_errors(event)
@@ -0,0 +1,31 @@
1
+ module Funes
2
+ # Raised when a projection configured with `persist_materialization_model_with` produces an
3
+ # invalid materialization state.
4
+ #
5
+ # When a projection declares a custom persistence method via `persist_materialization_model_with`,
6
+ # Funes still validates the materialization (via `state.valid?`) before delegating the write.
7
+ # If validation fails, this exception is raised and the custom persist method is *not* invoked.
8
+ #
9
+ # The failed materialization is exposed via `#record`, mirroring the ergonomics of
10
+ # `ActiveRecord::RecordInvalid` — but without coupling the custom-persist path to ActiveRecord.
11
+ # Like any unrescued exception, it propagates out of `materialize!` and rolls back any enclosing
12
+ # `ActiveRecord::Base.transaction`, which is the desired behaviour for transactional projections.
13
+ #
14
+ # @example Handling an invalid materialization state
15
+ # begin
16
+ # YourProjection.materialize!(events, "some-id", at: Time.current)
17
+ # rescue Funes::InvalidMaterializationState => e
18
+ # Rails.logger.error "Invalid materialization: #{e.message}"
19
+ # e.record.errors.full_messages # the validation errors on the materialization
20
+ # end
21
+ #
22
+ # @see Funes::Projection.persist_materialization_model_with
23
+ class InvalidMaterializationState < StandardError
24
+ attr_reader :record
25
+
26
+ def initialize(record)
27
+ @record = record
28
+ super("Invalid materialization state: #{record.errors.full_messages.to_sentence}")
29
+ end
30
+ end
31
+ end
data/lib/funes/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Funes
2
- VERSION = "0.2.2"
2
+ VERSION = "0.2.4"
3
3
  end
data/lib/funes.rb CHANGED
@@ -5,6 +5,7 @@ require "funes/event_metainformation_builder"
5
5
  require "funes/invalid_event_metainformation"
6
6
  require "funes/conflicting_actual_time_error"
7
7
  require "funes/missing_actual_time_attribute_error"
8
+ require "funes/invalid_materialization_state"
8
9
  require "funes/engine"
9
10
 
10
11
  # Funes is an event sourcing framework for Ruby on Rails.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: funes-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vinícius Almeida da Silva
@@ -216,6 +216,7 @@ files:
216
216
  - lib/funes/event_metainformation_builder.rb
217
217
  - lib/funes/inspection.rb
218
218
  - lib/funes/invalid_event_metainformation.rb
219
+ - lib/funes/invalid_materialization_state.rb
219
220
  - lib/funes/missing_actual_time_attribute_error.rb
220
221
  - lib/funes/unknown_event.rb
221
222
  - lib/funes/unknown_materialization_model.rb