beaconable 0.3.3 → 1.0.0.alpha
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/.github/workflows/test.yml +42 -0
- data/.rubocop.yml +32 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +3 -1
- data/Gemfile.lock +98 -44
- data/README.md +161 -4
- data/Rakefile +3 -1
- data/beaconable-0.3.4.gem +0 -0
- data/beaconable.gemspec +10 -8
- data/lib/beaconable/attribute_snapshot.rb +36 -0
- data/lib/beaconable/base_beacon.rb +2 -2
- data/lib/beaconable/object_was.rb +34 -5
- data/lib/beaconable/version.rb +3 -1
- data/lib/beaconable.rb +8 -4
- data/lib/generators/beacon/beacon_generator.rb +3 -3
- metadata +32 -29
- data/.travis.yml +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9802334bbbb09aa5c2e3645edccd67de7a0a275b90db63378bea6c1109db6cd9
|
|
4
|
+
data.tar.gz: '08c9da8e967b8db445922ff804d7ea9f1e61e47ff2a680bad5747a1f46e583ca'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e9f16be4ca9624b41e3763e40241709401a0bbcf530d5b3ee1b40006793bfba7187904b4eb732d51202d110b49187484954fc27d24a81a11997a5f46755b5f7
|
|
7
|
+
data.tar.gz: 331fe6033b16a1739f05ff33b509073b088640defe59144d4b010be30696d9f841593b208b77a11239c76fa4bfbc008d23c5f5f70e0d50e31c7152312e6a7c0d
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
ruby-version: ['3.2', '3.3']
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
|
21
|
+
uses: ruby/setup-ruby@v1
|
|
22
|
+
with:
|
|
23
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
24
|
+
bundler-cache: true
|
|
25
|
+
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: bundle exec rake test
|
|
28
|
+
|
|
29
|
+
lint:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
- name: Set up Ruby
|
|
36
|
+
uses: ruby/setup-ruby@v1
|
|
37
|
+
with:
|
|
38
|
+
ruby-version: '3.3'
|
|
39
|
+
bundler-cache: true
|
|
40
|
+
|
|
41
|
+
- name: Run RuboCop
|
|
42
|
+
run: bundle exec rubocop
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 3.2
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "vendor/**/*"
|
|
7
|
+
- "bin/*"
|
|
8
|
+
- "*.gemspec"
|
|
9
|
+
|
|
10
|
+
Style/StringLiterals:
|
|
11
|
+
EnforcedStyle: double_quotes
|
|
12
|
+
|
|
13
|
+
Style/StringLiteralsInInterpolation:
|
|
14
|
+
EnforcedStyle: double_quotes
|
|
15
|
+
|
|
16
|
+
Style/Documentation:
|
|
17
|
+
Enabled: false
|
|
18
|
+
|
|
19
|
+
Metrics/BlockLength:
|
|
20
|
+
Exclude:
|
|
21
|
+
- 'test/**/*'
|
|
22
|
+
|
|
23
|
+
Metrics/ClassLength:
|
|
24
|
+
Exclude:
|
|
25
|
+
- 'test/**/*'
|
|
26
|
+
|
|
27
|
+
Metrics/MethodLength:
|
|
28
|
+
Max: 15
|
|
29
|
+
|
|
30
|
+
# We intentionally memoize @object_was in save_for_beacon
|
|
31
|
+
Naming/MemoizedInstanceVariableName:
|
|
32
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 1.0.0.alpha (2025-12-17)
|
|
2
|
+
|
|
3
|
+
### Breaking Changes
|
|
4
|
+
|
|
5
|
+
- **Minimum Ruby version:** 3.2.0
|
|
6
|
+
- **Minimum Rails version:** 7.0
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- Replaced `OpenStruct` with new `AttributeSnapshot` class for better performance and explicit immutability
|
|
11
|
+
- `ObjectWas` now uses modern Rails 7+ dirty tracking API (`changes_to_save`) instead of iterating columns with `_was` methods
|
|
12
|
+
- Updated development dependencies to modern versions
|
|
13
|
+
- Fixed typo in gemspec: "patern" → "pattern"
|
|
14
|
+
|
|
15
|
+
### Why this change?
|
|
16
|
+
|
|
17
|
+
- `OpenStruct` is no longer auto-loaded in Ruby 3.2+ and shows deprecation warnings
|
|
18
|
+
- Modern Rails provides cleaner dirty tracking APIs
|
|
19
|
+
- This release marks the gem as stable and production-ready
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 0.3.4 (2022-03-14)
|
|
24
|
+
|
|
25
|
+
### Improvements
|
|
26
|
+
|
|
27
|
+
- Add `#beacon_metadata` & `#beacon_metadata=` method to included classes
|
|
28
|
+
|
|
1
29
|
## 0.3.3 (2020-11-02)
|
|
2
30
|
|
|
3
31
|
- Fixes a bug that causes an error when a beaconable was touch by an association without changes
|
data/Gemfile
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
source "https://rubygems.org"
|
|
2
4
|
|
|
3
|
-
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
|
|
5
|
+
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
4
6
|
|
|
5
7
|
# Specify your gem's dependencies in beaconable.gemspec
|
|
6
8
|
gemspec
|
data/Gemfile.lock
CHANGED
|
@@ -1,69 +1,123 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
beaconable (0.
|
|
5
|
-
activerecord (>=
|
|
4
|
+
beaconable (1.0.0.alpha)
|
|
5
|
+
activerecord (>= 7.0)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
8
8
|
remote: https://rubygems.org/
|
|
9
9
|
specs:
|
|
10
|
-
activemodel (
|
|
11
|
-
activesupport (=
|
|
12
|
-
activerecord (
|
|
13
|
-
activemodel (=
|
|
14
|
-
activesupport (=
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
activemodel (8.1.1)
|
|
11
|
+
activesupport (= 8.1.1)
|
|
12
|
+
activerecord (8.1.1)
|
|
13
|
+
activemodel (= 8.1.1)
|
|
14
|
+
activesupport (= 8.1.1)
|
|
15
|
+
timeout (>= 0.4.0)
|
|
16
|
+
activesupport (8.1.1)
|
|
17
|
+
base64
|
|
18
|
+
bigdecimal
|
|
19
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
20
|
+
connection_pool (>= 2.2.5)
|
|
21
|
+
drb
|
|
22
|
+
i18n (>= 1.6, < 2)
|
|
23
|
+
json
|
|
24
|
+
logger (>= 1.4.2)
|
|
25
|
+
minitest (>= 5.1)
|
|
26
|
+
securerandom (>= 0.3)
|
|
27
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
|
28
|
+
uri (>= 0.13.1)
|
|
21
29
|
ansi (1.5.0)
|
|
22
|
-
ast (2.4.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
ast (2.4.3)
|
|
31
|
+
base64 (0.3.0)
|
|
32
|
+
bigdecimal (4.0.0)
|
|
33
|
+
builder (3.3.0)
|
|
34
|
+
concurrent-ruby (1.3.6)
|
|
35
|
+
connection_pool (3.0.2)
|
|
36
|
+
date (3.5.1)
|
|
37
|
+
debug (1.11.0)
|
|
38
|
+
irb (~> 1.10)
|
|
39
|
+
reline (>= 0.3.8)
|
|
40
|
+
drb (2.2.3)
|
|
41
|
+
erb (6.0.1)
|
|
42
|
+
i18n (1.14.7)
|
|
27
43
|
concurrent-ruby (~> 1.0)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
io-console (0.8.2)
|
|
45
|
+
irb (1.16.0)
|
|
46
|
+
pp (>= 0.6.0)
|
|
47
|
+
rdoc (>= 4.0.0)
|
|
48
|
+
reline (>= 0.4.2)
|
|
49
|
+
json (2.18.0)
|
|
50
|
+
language_server-protocol (3.17.0.5)
|
|
51
|
+
lint_roller (1.1.0)
|
|
52
|
+
logger (1.7.0)
|
|
53
|
+
minitest (5.27.0)
|
|
54
|
+
minitest-reporters (1.7.1)
|
|
31
55
|
ansi
|
|
32
56
|
builder
|
|
33
57
|
minitest (>= 5.0)
|
|
34
58
|
ruby-progressbar
|
|
35
|
-
parallel (1.
|
|
36
|
-
parser (
|
|
37
|
-
ast (~> 2.4.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
parallel (1.27.0)
|
|
60
|
+
parser (3.3.10.0)
|
|
61
|
+
ast (~> 2.4.1)
|
|
62
|
+
racc
|
|
63
|
+
pp (0.6.3)
|
|
64
|
+
prettyprint
|
|
65
|
+
prettyprint (0.2.0)
|
|
66
|
+
prism (1.6.0)
|
|
67
|
+
psych (5.3.1)
|
|
68
|
+
date
|
|
69
|
+
stringio
|
|
70
|
+
racc (1.8.1)
|
|
71
|
+
rainbow (3.1.1)
|
|
72
|
+
rake (13.3.1)
|
|
73
|
+
rdoc (6.17.0)
|
|
74
|
+
erb
|
|
75
|
+
psych (>= 4.0.0)
|
|
76
|
+
tsort
|
|
77
|
+
regexp_parser (2.11.3)
|
|
78
|
+
reline (0.6.3)
|
|
79
|
+
io-console (~> 0.5)
|
|
80
|
+
rubocop (1.82.0)
|
|
81
|
+
json (~> 2.3)
|
|
82
|
+
language_server-protocol (~> 3.17.0.2)
|
|
83
|
+
lint_roller (~> 1.1.0)
|
|
42
84
|
parallel (~> 1.10)
|
|
43
|
-
parser (>= 2
|
|
85
|
+
parser (>= 3.3.0.2)
|
|
44
86
|
rainbow (>= 2.2.2, < 4.0)
|
|
87
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
88
|
+
rubocop-ast (>= 1.48.0, < 2.0)
|
|
45
89
|
ruby-progressbar (~> 1.7)
|
|
46
|
-
unicode-display_width (>=
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
91
|
+
rubocop-ast (1.48.0)
|
|
92
|
+
parser (>= 3.3.7.2)
|
|
93
|
+
prism (~> 1.4)
|
|
94
|
+
ruby-progressbar (1.13.0)
|
|
95
|
+
securerandom (0.4.1)
|
|
96
|
+
sqlite3 (2.8.1-arm64-darwin)
|
|
97
|
+
sqlite3 (2.8.1-x86_64-linux-gnu)
|
|
98
|
+
stringio (3.2.0)
|
|
99
|
+
timeout (0.6.0)
|
|
100
|
+
tsort (0.2.0)
|
|
101
|
+
tzinfo (2.0.6)
|
|
102
|
+
concurrent-ruby (~> 1.0)
|
|
103
|
+
unicode-display_width (3.2.0)
|
|
104
|
+
unicode-emoji (~> 4.1)
|
|
105
|
+
unicode-emoji (4.1.0)
|
|
106
|
+
uri (1.1.1)
|
|
54
107
|
|
|
55
108
|
PLATFORMS
|
|
56
|
-
|
|
109
|
+
arm64-darwin-24
|
|
110
|
+
x86_64-linux
|
|
57
111
|
|
|
58
112
|
DEPENDENCIES
|
|
59
113
|
beaconable!
|
|
60
|
-
bundler (
|
|
61
|
-
|
|
114
|
+
bundler (>= 2.0)
|
|
115
|
+
debug (~> 1.8)
|
|
62
116
|
minitest (~> 5.0)
|
|
63
|
-
minitest-reporters (~> 1.
|
|
117
|
+
minitest-reporters (~> 1.6)
|
|
64
118
|
rake (~> 13.0)
|
|
65
|
-
rubocop (~>
|
|
66
|
-
sqlite3 (~>
|
|
119
|
+
rubocop (~> 1.50)
|
|
120
|
+
sqlite3 (~> 2.0)
|
|
67
121
|
|
|
68
122
|
BUNDLED WITH
|
|
69
|
-
|
|
123
|
+
2.4.19
|
data/README.md
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
# Beaconable
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/beaconable) [](https://github.com/Lastimoso/beaconable/actions/workflows/test.yml) [](https://github.com/Lastimoso/beaconable/issues)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A lightweight Ruby gem that provides an elegant, object-oriented pattern for isolating side-effects and callbacks from your ActiveRecord models.
|
|
6
|
+
|
|
7
|
+
## Why Beaconable?
|
|
8
|
+
|
|
9
|
+
ActiveRecord callbacks (`after_save`, `after_commit`, etc.) are convenient, but they can quickly turn your models into tangled messes of business logic, external API calls, and notification triggers. Before you know it, your `User` model is sending emails, syncing with Salesforce, updating Elasticsearch, and making your tests take forever.
|
|
10
|
+
|
|
11
|
+
**Beaconable solves this by:**
|
|
12
|
+
|
|
13
|
+
- **Separating concerns** — Side-effects live in dedicated Beacon classes, not your models
|
|
14
|
+
- **Improving testability** — Test your model logic and side-effects independently
|
|
15
|
+
- **Providing context** — Your Beacon knows exactly what changed (`object_was` vs `object`)
|
|
16
|
+
- **Keeping models lean** — Your ActiveRecord models stay focused on data and validations
|
|
17
|
+
- **Offering flexibility** — Skip beacons when needed, pass metadata for conditional logic
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Ruby >= 3.2.0
|
|
23
|
+
- Rails >= 7.0
|
|
6
24
|
|
|
7
25
|
## Installation
|
|
8
26
|
|
|
@@ -58,6 +76,145 @@ class UserBeacon < Beaconable::BaseBeacon
|
|
|
58
76
|
end
|
|
59
77
|
```
|
|
60
78
|
|
|
79
|
+
### Avoid firing a beacon
|
|
80
|
+
|
|
81
|
+
You can skip beacon calls if you pass true to the method `#skip_beacon`. I.E:
|
|
82
|
+
```
|
|
83
|
+
...
|
|
84
|
+
user.update(user_params)
|
|
85
|
+
user.skip_beacon = true
|
|
86
|
+
user.save # The user beacon won't be fired.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Beacon metadata
|
|
90
|
+
|
|
91
|
+
You can pass `beacon_metadata` to the `object` that will be available on the **Beacon**.
|
|
92
|
+
|
|
93
|
+
Some uses might be:
|
|
94
|
+
|
|
95
|
+
- to determine whether a certain action should be performed or not. For example when creating users in batch actions or in through the console you might want to skip just the welcome email but still perform all the other side effects associated with the user creation.
|
|
96
|
+
- to pass information that is generated / available in memory, will not be persisted in the model but is relevant in the side effects. For example, if you want to implement your own event logging system you could pass the current user id from the controller action to the beacon where you are going to create the Event.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
User.create(
|
|
100
|
+
email: "new_user@email.com",
|
|
101
|
+
beacon_metadata: {
|
|
102
|
+
skip_welcome_user_job: true,
|
|
103
|
+
triggered_by: "admin@myapp.com"
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# app/beacons/user_beacon.rb
|
|
108
|
+
class UserBeacon < Beaconable::BaseBeacon
|
|
109
|
+
alias user object
|
|
110
|
+
alias user_was object_was
|
|
111
|
+
|
|
112
|
+
def call
|
|
113
|
+
WelcomeUserJob.perform_later(self.id) if should_perform_welcome_user_job?
|
|
114
|
+
UpdateExternalServiceJob.perform_later(self.id) if field_changed? :email
|
|
115
|
+
Event.create do |event|
|
|
116
|
+
event.content = UserSerializer.new(user).event_content
|
|
117
|
+
event.ocurred_at = user.updated_at
|
|
118
|
+
if beacon_metadata.present?
|
|
119
|
+
event.triggered_by = beacon_metadata.dig(:triggered_by)
|
|
120
|
+
event.source = beacon_metadata.dig(:source)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def should_perform_welcome_user_job?
|
|
128
|
+
new_entry? && !skip_welcome_user_job?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def skip_welcome_user_job?
|
|
132
|
+
beacon_metadata[:skip_welcome_user_job] if beacon_metadata.present?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Important**: once the beacon has been _fired_ the `beacon_metadata` will be cleared.
|
|
138
|
+
|
|
139
|
+
### Multiple saves in a transaction (a.k.a. "Please don't do this")
|
|
140
|
+
|
|
141
|
+
> **Fair warning:** If you're doing multiple saves on the same record within a single transaction, you might want to reconsider your life choices. But hey, we don't judge — we just handle it gracefully.
|
|
142
|
+
|
|
143
|
+
Beaconable captures the state of your object at the **first** `before_save` and holds onto it until `after_commit`. This means that if you do something like this:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
ActiveRecord::Base.transaction do
|
|
147
|
+
user.update!(status: 'pending')
|
|
148
|
+
user.update!(status: 'verified')
|
|
149
|
+
user.update!(status: 'active')
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Your beacon will see:
|
|
154
|
+
- `object.status` → `'active'` (the final value)
|
|
155
|
+
- `object_was.status` → whatever it was **before** the transaction started
|
|
156
|
+
|
|
157
|
+
This is intentional! We track the change from the **original state** to the **final committed state**, not the intermediate chaos in between.
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# In your beacon:
|
|
161
|
+
def call
|
|
162
|
+
if field_changed?(:status)
|
|
163
|
+
# This will be true if status changed from its original value
|
|
164
|
+
# to the final committed value — regardless of how many
|
|
165
|
+
# intermediate updates happened
|
|
166
|
+
NotifyStatusChange.perform_later(user.id)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Why?** Because Rails' native `saved_changes` only shows the last save, which would miss the fact that `status` changed at all if only the first update modified it. We've got your back.
|
|
172
|
+
|
|
173
|
+
## Known Limitations
|
|
174
|
+
|
|
175
|
+
### Encrypted columns with non-deterministic encryption
|
|
176
|
+
|
|
177
|
+
If you're using Rails encrypted attributes with a non-deterministic algorithm, `field_changed?` will return `true` even when the plaintext value hasn't changed. This happens because the ciphertext changes on every save:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
bank_account.routing_number = bank_account.routing_number # Same value!
|
|
181
|
+
bank_account.changes_to_save
|
|
182
|
+
# => {"routing_number_ciphertext" => ["QnPUYDD...", "IDZIchs..."]}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The underlying ciphertext is different, so Rails (and Beaconable) sees it as a change. If this is a problem for your use case, consider using deterministic encryption for those columns, or handle the comparison manually in your beacon.
|
|
186
|
+
|
|
187
|
+
### Store accessor attributes
|
|
188
|
+
|
|
189
|
+
Attributes defined via `store_accessor` are not directly supported by `field_changed?`. Rails tracks changes on the underlying store column, not on individual accessor keys:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Given: store_accessor :data_store, :a_store_attribute
|
|
193
|
+
|
|
194
|
+
user.a_store_attribute = "hello"
|
|
195
|
+
user.changes_to_save
|
|
196
|
+
# => {"data_store" => [{}, {"a_store_attribute" => "hello"}]}
|
|
197
|
+
|
|
198
|
+
# In your beacon:
|
|
199
|
+
field_changed?(:a_store_attribute) # => false (won't work)
|
|
200
|
+
field_changed?(:data_store) # => true (works, but less precise)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Workaround:** Check the underlying store column and inspect its contents:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
def call
|
|
207
|
+
if field_changed?(:data_store)
|
|
208
|
+
old_store = object_was[:data_store] || {}
|
|
209
|
+
new_store = object.data_store || {}
|
|
210
|
+
|
|
211
|
+
if old_store["a_store_attribute"] != new_store["a_store_attribute"]
|
|
212
|
+
# Handle the change
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
61
218
|
## Development
|
|
62
219
|
|
|
63
220
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -66,7 +223,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
66
223
|
|
|
67
224
|
## Contributing
|
|
68
225
|
|
|
69
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
226
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Lastimoso/beaconable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
70
227
|
|
|
71
228
|
## License
|
|
72
229
|
|
|
@@ -74,4 +231,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
74
231
|
|
|
75
232
|
## Code of Conduct
|
|
76
233
|
|
|
77
|
-
Everyone interacting in the Beaconable project
|
|
234
|
+
Everyone interacting in the Beaconable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/Lastimoso/beaconable/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
CHANGED
|
Binary file
|
data/beaconable.gemspec
CHANGED
|
@@ -9,8 +9,8 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.authors = ["Gerardo Raiden"]
|
|
10
10
|
spec.email = ["gerardoraiden@gmail.com"]
|
|
11
11
|
|
|
12
|
-
spec.summary = %q{Small OO
|
|
13
|
-
spec.description = %q{Small OO
|
|
12
|
+
spec.summary = %q{Small OO pattern to isolate side-effects and callbacks for your ActiveRecord Models}
|
|
13
|
+
spec.description = %q{Small OO pattern to isolate side-effects and callbacks for your ActiveRecord Models}
|
|
14
14
|
spec.homepage = "https://github.com/Lastimoso/beaconable"
|
|
15
15
|
spec.license = "MIT"
|
|
16
16
|
|
|
@@ -30,13 +30,15 @@ Gem::Specification.new do |spec|
|
|
|
30
30
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
31
31
|
spec.require_paths = ["lib"]
|
|
32
32
|
|
|
33
|
-
spec.
|
|
33
|
+
spec.required_ruby_version = '>= 3.2.0'
|
|
34
34
|
|
|
35
|
-
spec.
|
|
35
|
+
spec.add_dependency 'activerecord', '>= 7.0'
|
|
36
|
+
|
|
37
|
+
spec.add_development_dependency 'bundler', '>= 2.0'
|
|
36
38
|
spec.add_development_dependency 'minitest', '~> 5.0'
|
|
37
|
-
spec.add_development_dependency 'minitest-reporters', '~> 1.
|
|
39
|
+
spec.add_development_dependency 'minitest-reporters', '~> 1.6'
|
|
38
40
|
spec.add_development_dependency 'rake', '~> 13.0'
|
|
39
|
-
spec.add_development_dependency '
|
|
40
|
-
spec.add_development_dependency '
|
|
41
|
-
spec.add_development_dependency '
|
|
41
|
+
spec.add_development_dependency 'sqlite3', '~> 2.0'
|
|
42
|
+
spec.add_development_dependency 'debug', '~> 1.8'
|
|
43
|
+
spec.add_development_dependency 'rubocop', '~> 1.50'
|
|
42
44
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beaconable
|
|
4
|
+
# Immutable snapshot of an ActiveRecord object's attributes at a point in time.
|
|
5
|
+
# Replaces OpenStruct for better performance and explicit immutability.
|
|
6
|
+
class AttributeSnapshot
|
|
7
|
+
def initialize(attributes)
|
|
8
|
+
@attributes = attributes.freeze
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def method_missing(method_name, *args)
|
|
12
|
+
key = method_name.to_sym
|
|
13
|
+
return @attributes[key] if @attributes.key?(key)
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
19
|
+
@attributes.key?(method_name.to_sym) || super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Direct hash-style access for attribute values
|
|
23
|
+
def [](key)
|
|
24
|
+
@attributes[key.to_sym]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns a copy of the attributes hash (for debugging/inspection)
|
|
28
|
+
def to_h
|
|
29
|
+
@attributes.dup
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect
|
|
33
|
+
"#<#{self.class.name} #{@attributes.inspect}>"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Beaconable
|
|
4
4
|
class BaseBeacon
|
|
5
|
-
attr_reader :object, :object_was
|
|
5
|
+
attr_reader :object, :object_was, :beacon_metadata
|
|
6
6
|
|
|
7
7
|
def initialize(object, object_was)
|
|
8
8
|
@object = object
|
|
9
9
|
@object_was = object_was
|
|
10
|
+
@beacon_metadata = object.beacon_metadata
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def field_changed(field)
|
|
@@ -46,6 +47,5 @@ module Beaconable
|
|
|
46
47
|
def new_entry?
|
|
47
48
|
object_was.created_at.nil?
|
|
48
49
|
end
|
|
49
|
-
|
|
50
50
|
end
|
|
51
51
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Beaconable
|
|
4
|
+
# Captures the database state of an ActiveRecord object before changes.
|
|
5
|
+
# Used to track what changed during a save/transaction.
|
|
4
6
|
class ObjectWas
|
|
5
7
|
attr_reader :object
|
|
6
8
|
|
|
@@ -8,13 +10,40 @@ module Beaconable
|
|
|
8
10
|
@object = object
|
|
9
11
|
end
|
|
10
12
|
|
|
13
|
+
# Captures the current database state and returns an immutable snapshot.
|
|
14
|
+
# For new records, returns a snapshot with nil values for all columns.
|
|
11
15
|
def call
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
AttributeSnapshot.new(build_attributes)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def build_attributes
|
|
22
|
+
if object.new_record?
|
|
23
|
+
build_new_record_attributes
|
|
24
|
+
else
|
|
25
|
+
build_persisted_record_attributes
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# For new records, all "was" values are nil
|
|
30
|
+
# This preserves backward compatibility with new_entry? detection
|
|
31
|
+
def build_new_record_attributes
|
|
32
|
+
object.class.column_names.each_with_object({}) do |column, hash|
|
|
33
|
+
hash[column.to_sym] = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# For persisted records, capture database values before current changes
|
|
38
|
+
# Uses modern Rails 7+ dirty tracking API
|
|
39
|
+
def build_persisted_record_attributes
|
|
40
|
+
# Start with current attributes (which equal DB values for unchanged attrs)
|
|
41
|
+
result = object.attributes.symbolize_keys
|
|
42
|
+
# Replace changed attributes with their original database values
|
|
43
|
+
object.changes_to_save.each do |attr, (old_value, _new_value)|
|
|
44
|
+
result[attr.to_sym] = old_value
|
|
16
45
|
end
|
|
17
|
-
|
|
46
|
+
result
|
|
18
47
|
end
|
|
19
48
|
end
|
|
20
49
|
end
|
data/lib/beaconable/version.rb
CHANGED
data/lib/beaconable.rb
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require "beaconable/version"
|
|
4
|
+
require "beaconable/attribute_snapshot"
|
|
5
|
+
require "beaconable/object_was"
|
|
6
|
+
require "beaconable/base_beacon"
|
|
7
|
+
require "active_record"
|
|
7
8
|
|
|
8
9
|
module Beaconable
|
|
9
10
|
extend ActiveSupport::Concern
|
|
11
|
+
|
|
10
12
|
included do
|
|
13
|
+
attr_accessor :beacon_metadata
|
|
11
14
|
attr_accessor :skip_beacon
|
|
12
15
|
|
|
13
16
|
before_save :save_for_beacon, unless: :skip_beacon
|
|
@@ -25,5 +28,6 @@ module Beaconable
|
|
|
25
28
|
def fire_beacon
|
|
26
29
|
"#{self.class.name}Beacon".constantize.new(self, @object_was).call
|
|
27
30
|
@object_was = nil
|
|
31
|
+
self.beacon_metadata = nil
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "rails/generators/base"
|
|
4
4
|
|
|
5
5
|
class BeaconGenerator < Rails::Generators::NamedBase
|
|
6
|
-
source_root File.expand_path(
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
7
|
|
|
8
8
|
def create_beacon_file
|
|
9
|
-
template
|
|
9
|
+
template "beacon.rb.tt", File.join("app", "beacons", class_path, "#{file_name}_beacon.rb")
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def insert_inclusion_into_model_file
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: beaconable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0.alpha
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gerardo Raiden
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2025-12-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -16,28 +16,28 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '7.0'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '7.0'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: bundler
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
|
-
- - "
|
|
31
|
+
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '2.0'
|
|
34
34
|
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
|
-
- - "
|
|
38
|
+
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '2.0'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: minitest
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -58,14 +58,14 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '1.
|
|
61
|
+
version: '1.6'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '1.
|
|
68
|
+
version: '1.6'
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: rake
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -81,48 +81,48 @@ dependencies:
|
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '13.0'
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
|
-
name:
|
|
84
|
+
name: sqlite3
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
86
86
|
requirements:
|
|
87
87
|
- - "~>"
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
|
-
version:
|
|
89
|
+
version: '2.0'
|
|
90
90
|
type: :development
|
|
91
91
|
prerelease: false
|
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
94
|
- - "~>"
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
|
-
version:
|
|
96
|
+
version: '2.0'
|
|
97
97
|
- !ruby/object:Gem::Dependency
|
|
98
|
-
name:
|
|
98
|
+
name: debug
|
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
|
100
100
|
requirements:
|
|
101
101
|
- - "~>"
|
|
102
102
|
- !ruby/object:Gem::Version
|
|
103
|
-
version: '1.
|
|
103
|
+
version: '1.8'
|
|
104
104
|
type: :development
|
|
105
105
|
prerelease: false
|
|
106
106
|
version_requirements: !ruby/object:Gem::Requirement
|
|
107
107
|
requirements:
|
|
108
108
|
- - "~>"
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
|
-
version: '1.
|
|
110
|
+
version: '1.8'
|
|
111
111
|
- !ruby/object:Gem::Dependency
|
|
112
|
-
name:
|
|
112
|
+
name: rubocop
|
|
113
113
|
requirement: !ruby/object:Gem::Requirement
|
|
114
114
|
requirements:
|
|
115
115
|
- - "~>"
|
|
116
116
|
- !ruby/object:Gem::Version
|
|
117
|
-
version: '
|
|
117
|
+
version: '1.50'
|
|
118
118
|
type: :development
|
|
119
119
|
prerelease: false
|
|
120
120
|
version_requirements: !ruby/object:Gem::Requirement
|
|
121
121
|
requirements:
|
|
122
122
|
- - "~>"
|
|
123
123
|
- !ruby/object:Gem::Version
|
|
124
|
-
version: '
|
|
125
|
-
description: Small OO
|
|
124
|
+
version: '1.50'
|
|
125
|
+
description: Small OO pattern to isolate side-effects and callbacks for your ActiveRecord
|
|
126
126
|
Models
|
|
127
127
|
email:
|
|
128
128
|
- gerardoraiden@gmail.com
|
|
@@ -130,8 +130,9 @@ executables: []
|
|
|
130
130
|
extensions: []
|
|
131
131
|
extra_rdoc_files: []
|
|
132
132
|
files:
|
|
133
|
+
- ".github/workflows/test.yml"
|
|
133
134
|
- ".gitignore"
|
|
134
|
-
- ".
|
|
135
|
+
- ".rubocop.yml"
|
|
135
136
|
- CHANGELOG.md
|
|
136
137
|
- CODE_OF_CONDUCT.md
|
|
137
138
|
- Gemfile
|
|
@@ -139,10 +140,12 @@ files:
|
|
|
139
140
|
- LICENSE.txt
|
|
140
141
|
- README.md
|
|
141
142
|
- Rakefile
|
|
143
|
+
- beaconable-0.3.4.gem
|
|
142
144
|
- beaconable.gemspec
|
|
143
145
|
- bin/console
|
|
144
146
|
- bin/setup
|
|
145
147
|
- lib/beaconable.rb
|
|
148
|
+
- lib/beaconable/attribute_snapshot.rb
|
|
146
149
|
- lib/beaconable/base_beacon.rb
|
|
147
150
|
- lib/beaconable/object_was.rb
|
|
148
151
|
- lib/beaconable/version.rb
|
|
@@ -154,7 +157,7 @@ licenses:
|
|
|
154
157
|
- MIT
|
|
155
158
|
metadata:
|
|
156
159
|
allowed_push_host: https://rubygems.org
|
|
157
|
-
post_install_message:
|
|
160
|
+
post_install_message:
|
|
158
161
|
rdoc_options: []
|
|
159
162
|
require_paths:
|
|
160
163
|
- lib
|
|
@@ -162,16 +165,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
162
165
|
requirements:
|
|
163
166
|
- - ">="
|
|
164
167
|
- !ruby/object:Gem::Version
|
|
165
|
-
version:
|
|
168
|
+
version: 3.2.0
|
|
166
169
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
167
170
|
requirements:
|
|
168
|
-
- - "
|
|
171
|
+
- - ">"
|
|
169
172
|
- !ruby/object:Gem::Version
|
|
170
|
-
version:
|
|
173
|
+
version: 1.3.1
|
|
171
174
|
requirements: []
|
|
172
|
-
rubygems_version: 3.
|
|
173
|
-
signing_key:
|
|
175
|
+
rubygems_version: 3.4.19
|
|
176
|
+
signing_key:
|
|
174
177
|
specification_version: 4
|
|
175
|
-
summary: Small OO
|
|
178
|
+
summary: Small OO pattern to isolate side-effects and callbacks for your ActiveRecord
|
|
176
179
|
Models
|
|
177
180
|
test_files: []
|