funes-rails 0.2.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80126eb5004573c77d1afaf99180039d61ed7492ea1d956bbfe29f49ddceb578
4
- data.tar.gz: 8d57b3d190b7eca6ea60139d9502b20adaaa2e17e7610b6a79853f1053ab5418
3
+ metadata.gz: a03d8c22f06c54db21f30e95b22db605a0ba5ab95f6d271d822ab1b925ea1547
4
+ data.tar.gz: b2a8d7a5205e0bdd508ab3cc98d28048b2d4d53508b12790751dd4e92d98c410
5
5
  SHA512:
6
- metadata.gz: a8f37f4883b7f09018caa2658bc4eed31d32fd838bfc0d1ffe23ed690ff972e5f92505387800bf3ac450f848339f6f4e679b1e023c9ccf68a2aee9ead78469d0
7
- data.tar.gz: 00f7eefbb42e827ed063d075bd1f199063e7c581a0aa9ea09830db7e1930de4e41c028b1adf9dc35c5c22c59bf32f7f76814a09bdb07a73871c9db956e128675
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
@@ -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.
@@ -166,33 +166,24 @@ module Funes
166
166
 
167
167
  # @!visibility private
168
168
  def process_events(events_collection, at: nil, consistency: false)
169
- new(self.instance_variable_get(:@interpretations),
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(self.instance_variable_get(:@interpretations),
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(interpretations, materialization_model, throws_on_unknown_events, persist_method = nil)
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/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Funes
2
- VERSION = "0.2.5"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/funes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "funes/version"
2
2
  require "funes/configuration"
3
3
  require "funes/inspection"
4
+ require "funes/associations"
4
5
  require "funes/event_metainformation_builder"
5
6
  require "funes/invalid_event_metainformation"
6
7
  require "funes/conflicting_actual_time_error"
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.5
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