funes-rails 0.2.4 → 0.3.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/README.md +2 -0
- data/Rakefile +23 -0
- data/app/models/funes/event.rb +19 -0
- data/app/projections/funes/projection.rb +10 -19
- data/lib/funes/associations.rb +149 -0
- data/lib/funes/unknown_event.rb +2 -2
- data/lib/funes/version.rb +1 -1
- data/lib/funes.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a03d8c22f06c54db21f30e95b22db605a0ba5ab95f6d271d822ab1b925ea1547
|
|
4
|
+
data.tar.gz: b2a8d7a5205e0bdd508ab3cc98d28048b2d4d53508b12790751dd4e92d98c410
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a54c80af6790e8500449c6e71e46117da93b00fdbdc6694142ac7ae8603732d2671fe8455c1410d953e8b8538e1d6cdc188861ebddfd77bf247a40abf791baa
|
|
7
|
+
data.tar.gz: 462fae650d472c157aa75cbad863569936939ce6540e4f120d333a95a4221aea4ea59750c7bb21098d58e15e8dcbe7a74fdd8be498057eb1c291be1ed060acf2
|
data/README.md
CHANGED
|
@@ -103,6 +103,8 @@ Funes gives you fine-grained control over when and how projections run:
|
|
|
103
103
|
|
|
104
104
|
Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
|
|
105
105
|
|
|
106
|
+
For a hands-on example, see the [Funes Workshop](https://github.com/viniciusalmeida/funes_workshop) — a small Rails app that models a debt lifecycle (loan issuance, daily interest accrual, and payments) to demonstrate how to use Events, Streams, and Projections in a realistic financial domain.
|
|
107
|
+
|
|
106
108
|
## Performance
|
|
107
109
|
|
|
108
110
|
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.
|
data/Rakefile
CHANGED
|
@@ -7,6 +7,29 @@ load "rails/tasks/statistics.rake"
|
|
|
7
7
|
|
|
8
8
|
require "bundler/gem_tasks"
|
|
9
9
|
|
|
10
|
+
# Safeguard against releasing from a fork. `rake release` (bundler/gem_tasks)
|
|
11
|
+
# pushes the tag and commits to the remote tracked by the current branch
|
|
12
|
+
# (falling back to "origin"). This guard inspects that same remote and aborts
|
|
13
|
+
# before any push if it does not point at the canonical funes-org/funes repo.
|
|
14
|
+
CANONICAL_RELEASE_REPO = %r{[:/]funes-org/funes(\.git)?\z}
|
|
15
|
+
|
|
16
|
+
task "release:guard_canonical_remote" do
|
|
17
|
+
branch = `git rev-parse --abbrev-ref HEAD`.strip
|
|
18
|
+
remote = `git config --get branch.#{branch}.remote`.strip
|
|
19
|
+
remote = "origin" if remote.empty?
|
|
20
|
+
url = `git config --get remote.#{remote}.url`.strip
|
|
21
|
+
|
|
22
|
+
unless url.match?(CANONICAL_RELEASE_REPO)
|
|
23
|
+
abort <<~MSG
|
|
24
|
+
Refusing to release: the '#{remote}' remote is #{url.inspect}, which is
|
|
25
|
+
not funes-org/funes. Releases must be cut from the canonical repository,
|
|
26
|
+
not a fork. Check out the canonical repo (or fix the remote) and retry.
|
|
27
|
+
MSG
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Rake::Task["release:source_control_push"].enhance([ "release:guard_canonical_remote" ])
|
|
32
|
+
|
|
10
33
|
namespace :docs do
|
|
11
34
|
desc "Generate YARD documentation"
|
|
12
35
|
task :generate do
|
data/app/models/funes/event.rb
CHANGED
|
@@ -36,6 +36,24 @@ module Funes
|
|
|
36
36
|
# event = Order::Placed.new(total: 99.99, customer_id: "cust-123")
|
|
37
37
|
# stream.append(event)
|
|
38
38
|
#
|
|
39
|
+
# ## Associating Events With Other Models
|
|
40
|
+
#
|
|
41
|
+
# Use +refers_to+ to relate an event to another model. Only the referenced record's id is stored
|
|
42
|
+
# in the event payload; the record itself is loaded lazily and is readable at interpretation time:
|
|
43
|
+
#
|
|
44
|
+
# @example Reference another model and read it in an interpretation
|
|
45
|
+
# class Order::Placed < Funes::Event
|
|
46
|
+
# refers_to :customer
|
|
47
|
+
# attribute :total, :decimal
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# stream.append(Order::Placed.new(customer: customer, total: 99.99))
|
|
51
|
+
#
|
|
52
|
+
# interpretation_for Order::Placed do |state, event, _at|
|
|
53
|
+
# state.customer = event.customer # loaded by id from props
|
|
54
|
+
# state
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
39
57
|
# @example Handling validation errors
|
|
40
58
|
# event = stream.append(Order::Placed.new(total: -10))
|
|
41
59
|
# unless event.valid?
|
|
@@ -47,6 +65,7 @@ module Funes
|
|
|
47
65
|
include ActiveModel::Model
|
|
48
66
|
include ActiveModel::Attributes
|
|
49
67
|
include Funes::Inspection
|
|
68
|
+
include Funes::Associations
|
|
50
69
|
|
|
51
70
|
# @!attribute [rw] adjacent_state_errors
|
|
52
71
|
# @return [ActiveModel::Errors] Validation errors from consistency projections.
|
|
@@ -158,41 +158,32 @@ module Funes
|
|
|
158
158
|
#
|
|
159
159
|
# @example
|
|
160
160
|
# class YourProjection < Funes::Projection
|
|
161
|
-
#
|
|
161
|
+
# strict_mode!
|
|
162
162
|
# end
|
|
163
|
-
def
|
|
163
|
+
def strict_mode!
|
|
164
164
|
@throws_on_unknown_events = true
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
# @!visibility private
|
|
168
168
|
def process_events(events_collection, at: nil, consistency: false)
|
|
169
|
-
new
|
|
170
|
-
self.instance_variable_get(:@materialization_model),
|
|
171
|
-
self.instance_variable_get(:@throws_on_unknown_events),
|
|
172
|
-
self.instance_variable_get(:@persist_method))
|
|
173
|
-
.process_events(events_collection, at: at, consistency: consistency)
|
|
169
|
+
new.process_events(events_collection, at: at, consistency: consistency)
|
|
174
170
|
end
|
|
175
171
|
|
|
176
172
|
# @!visibility private
|
|
177
173
|
def materialize!(events_collection, idx, at: nil)
|
|
178
|
-
new
|
|
179
|
-
self.instance_variable_get(:@materialization_model),
|
|
180
|
-
self.instance_variable_get(:@throws_on_unknown_events),
|
|
181
|
-
self.instance_variable_get(:@persist_method))
|
|
182
|
-
.materialize!(events_collection, idx, at: at)
|
|
174
|
+
new.materialize!(events_collection, idx, at: at)
|
|
183
175
|
end
|
|
184
176
|
end
|
|
185
177
|
|
|
186
178
|
# @!visibility private
|
|
187
|
-
def initialize
|
|
188
|
-
@interpretations = interpretations
|
|
189
|
-
@materialization_model = materialization_model
|
|
179
|
+
def initialize
|
|
180
|
+
@interpretations = self.class.instance_variable_get(:@interpretations)
|
|
181
|
+
@materialization_model = self.class.instance_variable_get(:@materialization_model)
|
|
182
|
+
@throws_on_unknown_events = self.class.instance_variable_get(:@throws_on_unknown_events)
|
|
183
|
+
@persist_method = self.class.instance_variable_get(:@persist_method)
|
|
184
|
+
|
|
190
185
|
raise Funes::UnknownMaterializationModel,
|
|
191
186
|
"There is no materialization model configured on #{self.class.name}" unless @materialization_model.present?
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
@throws_on_unknown_events = throws_on_unknown_events
|
|
195
|
-
@persist_method = persist_method
|
|
196
187
|
end
|
|
197
188
|
|
|
198
189
|
# @!visibility private
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Funes
|
|
4
|
+
# Declares references to other models, accessible at interpretation time.
|
|
5
|
+
#
|
|
6
|
+
# Events are immutable facts serialized as plain JSON in the +props+ column, so an event cannot
|
|
7
|
+
# store another model directly. The +refers_to+ macro stores only the referenced record's id as a
|
|
8
|
+
# regular event attribute (and therefore in +props+), while exposing a reader that lazily loads the
|
|
9
|
+
# record by id and a writer that accepts the record itself.
|
|
10
|
+
#
|
|
11
|
+
# The reader returns the record's *current* state (a live lookup via +find_by+), not a snapshot of
|
|
12
|
+
# how it looked when the event was recorded. A reference whose record no longer exists reads as
|
|
13
|
+
# +nil+ rather than raising. The loaded record is memoized per reference and reloaded whenever the
|
|
14
|
+
# foreign key attribute changes, matching Active Record's staleness handling.
|
|
15
|
+
#
|
|
16
|
+
# == Example
|
|
17
|
+
#
|
|
18
|
+
# class Deposit::Created < Funes::Event
|
|
19
|
+
# refers_to :customer
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# event = Deposit::Created.new(customer: some_customer)
|
|
23
|
+
# event.customer_id # => some_customer.id (this is what gets serialized)
|
|
24
|
+
# event.customer # => some_customer
|
|
25
|
+
#
|
|
26
|
+
# # Inside an interpretation block, the reference is read straight off the event:
|
|
27
|
+
# interpretation_for Deposit::Created do |state, event, _at|
|
|
28
|
+
# state.customer = event.customer
|
|
29
|
+
# state
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# == Options
|
|
33
|
+
#
|
|
34
|
+
# * +class_name+ - The name of the referenced class, as a string (or symbol) — passing the class
|
|
35
|
+
# itself raises +ArgumentError+, as in Active Record. Defaults to the camelized reference name
|
|
36
|
+
# (e.g. +refers_to :customer+ infers +"Customer"+). Resolved lazily, so it plays well with
|
|
37
|
+
# autoloading and namespaced constants.
|
|
38
|
+
# * +foreign_key+ - The attribute that stores the id. Defaults to +"#{name}_id"+.
|
|
39
|
+
# * +required+ - When +true+, adds a presence validation on the foreign key so an event without the
|
|
40
|
+
# reference is invalid and will not be persisted. Defaults to +false+ (ActiveRecord-style).
|
|
41
|
+
#
|
|
42
|
+
# class Loan::Granted < Funes::Event
|
|
43
|
+
# refers_to :borrower, class_name: "User", required: true
|
|
44
|
+
# refers_to :account, foreign_key: :account_uuid
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# == Requirements
|
|
48
|
+
#
|
|
49
|
+
# The including class must provide +ActiveModel::Attributes+, which backs the foreign key
|
|
50
|
+
# attribute. Passing <tt>required: true</tt> additionally needs +ActiveModel::Validations+.
|
|
51
|
+
# {Funes::Event} provides both.
|
|
52
|
+
#
|
|
53
|
+
# == Use on materialization models
|
|
54
|
+
#
|
|
55
|
+
# A projection's state is an instance of its materialization model, so a materialization model
|
|
56
|
+
# that declares +refers_to+ offers interpretation blocks the same API events do — the reference
|
|
57
|
+
# reads and writes identically on both sides of the block:
|
|
58
|
+
#
|
|
59
|
+
# class CustomerSnapshot
|
|
60
|
+
# include ActiveModel::Model
|
|
61
|
+
# include ActiveModel::Attributes
|
|
62
|
+
# include Funes::Associations
|
|
63
|
+
#
|
|
64
|
+
# refers_to :customer, class_name: "Examples::Customer"
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# interpretation_for Deposit::Created do |state, event, _at|
|
|
68
|
+
# state.customer = event.customer
|
|
69
|
+
# state
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# Only the id travels in +attributes+, so the reference survives the rebuild that +materialize!+
|
|
73
|
+
# performs. This works for both virtual materialization models and those persisted through
|
|
74
|
+
# +persist_materialization_model_with+.
|
|
75
|
+
#
|
|
76
|
+
# For an ActiveRecord-backed materialization model, declare a regular +belongs_to+ association
|
|
77
|
+
# instead. That is the idiomatic tool there, and it brings preloading and +inverse_of+ with it.
|
|
78
|
+
#
|
|
79
|
+
# +refers_to+ can work on such a model, but only when the foreign key is already a column in the
|
|
80
|
+
# database — and even then +belongs_to+ remains the better choice. Without that column the failure
|
|
81
|
+
# is late and misleading: interpretation blocks read and write the reference correctly and the
|
|
82
|
+
# foreign key is populated, then +materialize!+ feeds +attributes+ into an upsert and raises
|
|
83
|
+
#
|
|
84
|
+
# ActiveModel::UnknownAttributeError: unknown attribute 'customer_id' for CustomerSnapshot.
|
|
85
|
+
#
|
|
86
|
+
# naming the attribute without mentioning the missing column or the +refers_to+ call that created
|
|
87
|
+
# it, so the error surfaces far from its cause.
|
|
88
|
+
#
|
|
89
|
+
# == Namespaced models
|
|
90
|
+
#
|
|
91
|
+
# +class_name+ must be fully qualified, e.g. <tt>class_name: "Examples::Customer"</tt>. It is
|
|
92
|
+
# resolved lazily, on the first load by id — assigning a record caches it directly, so a wrong
|
|
93
|
+
# +class_name+ surfaces only when a freshly built object reads the reference, not when it is
|
|
94
|
+
# assigned.
|
|
95
|
+
module Associations
|
|
96
|
+
extend ActiveSupport::Concern
|
|
97
|
+
|
|
98
|
+
module ClassMethods
|
|
99
|
+
# Declares a reference to another model. See {Funes::Associations} for details.
|
|
100
|
+
#
|
|
101
|
+
# @param name [Symbol] The reference name (defines +name+ / +name=+ accessors).
|
|
102
|
+
# @param class_name [String, Symbol, nil] Name of the referenced class (not the class itself).
|
|
103
|
+
# Defaults to +name.camelize+.
|
|
104
|
+
# @param foreign_key [Symbol, String, nil] Attribute storing the id. Defaults to +"#{name}_id"+.
|
|
105
|
+
# @param required [Boolean] Whether to validate presence of the foreign key. Defaults to +false+.
|
|
106
|
+
# @return [void]
|
|
107
|
+
def refers_to(name, class_name: nil, foreign_key: nil, required: false)
|
|
108
|
+
if class_name.instance_of?(Class)
|
|
109
|
+
raise ArgumentError, "A class was passed to `:class_name` but we are expecting a string."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
fk = (foreign_key || "#{name}_id").to_sym
|
|
113
|
+
klass_str = (class_name || name.to_s.camelize).to_s
|
|
114
|
+
|
|
115
|
+
# Untyped (pass-through Value) attribute so integer and string/UUID ids both round-trip
|
|
116
|
+
# through JSON unchanged. This is what gets serialized into +props+.
|
|
117
|
+
attribute fk
|
|
118
|
+
|
|
119
|
+
define_method(name) do
|
|
120
|
+
id = public_send(fk)
|
|
121
|
+
return nil if id.nil?
|
|
122
|
+
|
|
123
|
+
# Cache entries are [id, record] pairs so a direct write to the foreign key attribute
|
|
124
|
+
# invalidates the memoized record, as in ActiveRecord's stale-target handling.
|
|
125
|
+
@__reference_cache ||= {}
|
|
126
|
+
cached_id, cached_record = @__reference_cache[name]
|
|
127
|
+
return cached_record if cached_id == id
|
|
128
|
+
|
|
129
|
+
record = klass_str.constantize.find_by(id: id)
|
|
130
|
+
@__reference_cache[name] = [ id, record ]
|
|
131
|
+
record
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
define_method("#{name}=") do |record|
|
|
135
|
+
@__reference_cache ||= {}
|
|
136
|
+
if record.nil?
|
|
137
|
+
public_send("#{fk}=", nil)
|
|
138
|
+
@__reference_cache.delete(name)
|
|
139
|
+
else
|
|
140
|
+
public_send("#{fk}=", record.id)
|
|
141
|
+
@__reference_cache[name] = [ record.id, record ]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
validates fk, presence: true if required
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/funes/unknown_event.rb
CHANGED
|
@@ -2,12 +2,12 @@ module Funes
|
|
|
2
2
|
# Raised when a projection encounters an event type it doesn't know how to interpret.
|
|
3
3
|
#
|
|
4
4
|
# By default, projections silently ignore unknown events. This error is only raised when
|
|
5
|
-
# a projection is configured with `
|
|
5
|
+
# a projection is configured with `strict_mode!`, which enforces strict event
|
|
6
6
|
# handling to catch missing interpretation blocks during development.
|
|
7
7
|
#
|
|
8
8
|
# @example Configure a projection to raise on unknown events
|
|
9
9
|
# class StrictProjection < Funes::Projection
|
|
10
|
-
#
|
|
10
|
+
# strict_mode!
|
|
11
11
|
#
|
|
12
12
|
# interpretation_for OrderPlaced do |state, event, _at|
|
|
13
13
|
# # ...
|
data/lib/funes/version.rb
CHANGED
data/lib/funes.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vinícius Almeida da Silva
|
|
@@ -210,6 +210,7 @@ files:
|
|
|
210
210
|
- config/routes.rb
|
|
211
211
|
- lib/funes-rails.rb
|
|
212
212
|
- lib/funes.rb
|
|
213
|
+
- lib/funes/associations.rb
|
|
213
214
|
- lib/funes/configuration.rb
|
|
214
215
|
- lib/funes/conflicting_actual_time_error.rb
|
|
215
216
|
- lib/funes/engine.rb
|
|
@@ -234,7 +235,7 @@ licenses:
|
|
|
234
235
|
metadata:
|
|
235
236
|
homepage_uri: https://funes.org/
|
|
236
237
|
source_code_uri: https://github.com/funes-org/funes
|
|
237
|
-
changelog_uri: https://github.com/funes-org/funes
|
|
238
|
+
changelog_uri: https://github.com/funes-org/funes/blob/main/CHANGELOG.md
|
|
238
239
|
rdoc_options: []
|
|
239
240
|
require_paths:
|
|
240
241
|
- lib
|