alarmable 1.4.0 → 1.5.1

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: 488b705d3ad253dd224c5918b1e3d4cc9485883e03a58484c20284c195e846b4
4
- data.tar.gz: f6e9a9187839493404543e12cb16a5108d7a602cd89365e0b543559e24e93c76
3
+ metadata.gz: 577dcfa9cb1b37ba1ee9a4025ce045d19f36ae61d371270049310e696c5ee32c
4
+ data.tar.gz: 38bccc14a3f155e50d1397a23a3582b7546c0995785b47da3dd8598e83743c4c
5
5
  SHA512:
6
- metadata.gz: c3415ce0216a968361d899b1d5c348430915f6e3cea437bf919958ed969a92566a9159314afcbf556c3ef9df0deb94538ed84db6f1063b9732c6a496735aed7b
7
- data.tar.gz: 8a1383152dca9d66814529f676d205511f3d3bcd3c8b3975303bf2781a9be240d3695ef64e11541e3cd00f674dfa14789e699c17519fab968fe335b7608b7dfb
6
+ metadata.gz: '087f42cdcbe1e7fcd64b88ef7f8c7ec60607296cc071861974692b3cafbefbfd22943d4432962a6e78dfb7d170d06ae0fae5e32f23d2b4bb082c14b75b1b6049'
7
+ data.tar.gz: 36c38cacac483c1a242e2f21fd033d0bdb6e226fbcd452d4596d3aae55c7550340c11cfb7ba643daf43699796f041f6a4bacd0a8c4f5bc954537ea8dbfb0069c
data/CHANGELOG.md CHANGED
@@ -2,9 +2,17 @@
2
2
 
3
3
  * TODO: Replace this bullet point with an actual description of a change.
4
4
 
5
+ ### 1.5.1 (17 January 2025)
6
+
7
+ * Added the logger dependency (#15)
8
+
9
+ ### 1.5.0 (14 January 2025)
10
+
11
+ * Switched to Zeitwerk as autoloader (#14)
12
+
5
13
  ### 1.4.0 (12 January 2025)
6
14
 
7
- * TODO: Replace this bullet point with an actual description of a change.
15
+ * Just a retag of 1.3.0
8
16
 
9
17
  ### 1.3.0 (3 January 2025)
10
18
 
data/alarmable.gemspec CHANGED
@@ -41,4 +41,5 @@ Gem::Specification.new do |spec|
41
41
  spec.add_dependency 'activerecord', '>= 6.1'
42
42
  spec.add_dependency 'activesupport', '>= 6.1'
43
43
  spec.add_dependency 'hashdiff', '~> 1.0'
44
+ spec.add_dependency 'zeitwerk', '~> 2.6'
44
45
  end
@@ -3,7 +3,7 @@
3
3
  # The gem version details.
4
4
  module Alarmable
5
5
  # The version of the +alarmable+ gem
6
- VERSION = '1.4.0'
6
+ VERSION = '1.5.1'
7
7
 
8
8
  class << self
9
9
  # Returns the version of gem as a string.
data/lib/alarmable.rb CHANGED
@@ -1,10 +1,222 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'zeitwerk'
4
+ require 'logger'
3
5
  require 'active_support'
4
6
  require 'active_record'
5
7
  require 'active_job'
6
8
  require 'active_job/cancel'
7
9
  require 'hashdiff'
8
10
 
9
- require 'alarmable/version'
10
- require 'alarmable/concern'
11
+ # A reusable alarm extension to Active Record models. It adds support for the
12
+ # maintenance of Active Job's (create, update (cancel)) which are schedules
13
+ # for the given alarms. We check for changes on the alarms hash and perform
14
+ # updates accordingly.
15
+ #
16
+ # This concern requires the persistence (and availability) of two properties.
17
+ #
18
+ # * The first is the JSONB array which holds the alarms. (+alarms+)
19
+ # * The seconds is the JSONB array which holds the ids of
20
+ # scheduled alarm jobs. (+alarm_jobs+)
21
+ #
22
+ # rails generate migration AddAlarmsAndAlarmJobsToEntity \
23
+ # alarms:jsonb alarm_jobs:jsonb
24
+ #
25
+ # Furthermore a Active Record model which uses this concern must define the
26
+ # Active Job class which will be scheduled. (+alarm_job+) The user must also
27
+ # define the base date property of the owning side.
28
+ # (+alarm_base_date_property+) This base date is mandatory to calculate the
29
+ # correct alarm date/time. When the base date is not set (+nil+) no new
30
+ # notification job will be enqueued. When the base date is unset on an
31
+ # update, the previously enqueued job will be canceled.
32
+ #
33
+ # The alarms hash needs to be an array in the following format:
34
+ #
35
+ # [
36
+ # {
37
+ # "channel": "email", # email, push, web_notification, etc..
38
+ # "before_minutes": 15 # start_at - before_minutes, >= 1
39
+ # }
40
+ # ]
41
+ #
42
+ # The given alarm job class will be scheduled with the following two
43
+ # arguments.
44
+ #
45
+ # * id - The class/instance id of the record which owns the alarm
46
+ # * alarm - The alarm hash itself (see the format above)
47
+ #
48
+ # A suitable alarm job perform method should look like this:
49
+ #
50
+ # # @param id [String] The entity id
51
+ # # @param alarm [Hash] The alarm object
52
+ # def perform(id, alarm)
53
+ # # Do something special for +alarm.channel+ ..
54
+ # end
55
+ module Alarmable
56
+ # Setup a Zeitwerk autoloader instance and configure it
57
+ loader = Zeitwerk::Loader.for_gem
58
+
59
+ # Finish the auto loader configuration
60
+ loader.setup
61
+
62
+ # Make sure to eager load all constants
63
+ loader.eager_load
64
+
65
+ extend ActiveSupport::Concern
66
+
67
+ class_methods do
68
+ # Getter/Setter
69
+ #
70
+ # :reek:Attribute because thats what this thing is about
71
+ attr_accessor :alarm_job, :alarm_base_date_property
72
+ end
73
+
74
+ # rubocop:disable Metrics/BlockLength because Active Support like it
75
+ included do
76
+ # Hooks
77
+ after_initialize :validate_alarm_settings, :alarm_defaults
78
+
79
+ # Here comes a little cheat sheet when and what action is performed
80
+ # on the alarm jobs.
81
+ #
82
+ # create | [ time check, reschedule]
83
+ # update | dirty check, [cancel job, time check, reschedule]
84
+ # destroy | [cancel job ]
85
+ after_create :reschedule_alarm_jobs
86
+ before_update :alarms_update_callback
87
+ before_destroy :alarms_destroy_callback
88
+
89
+ # Getter for the alarm job class.
90
+ #
91
+ # @return [Class] The alarm job class
92
+ def alarm_job
93
+ self.class.alarm_job
94
+ end
95
+
96
+ # Getter for the alarm base date property.
97
+ #
98
+ # @return [Symbol] The user defined base date property
99
+ def alarm_base_date_property
100
+ self.class.alarm_base_date_property
101
+ end
102
+
103
+ # Set some defaults on the relevant alarm properties.
104
+ def alarm_defaults
105
+ self.alarms ||= []
106
+ self.alarm_jobs ||= {}
107
+ end
108
+
109
+ # Validate the presence of the +alarm_job+ property and the accessibility
110
+ # of the specified class. Also validate the +alarm_base_date_property+
111
+ # setting.
112
+ #
113
+ # rubocop:disable Style/GuardClause because its fine like this
114
+ # :reek:NilCheck because we validate concern usage
115
+ def validate_alarm_settings
116
+ raise 'Alarmable +alarm_job+ is not configured' if alarm_job.nil?
117
+ unless alarm_job.is_a? Class
118
+ raise 'Alarmable +alarm_job+ is not instantiable'
119
+ end
120
+ if alarm_base_date_property.nil?
121
+ raise 'Alarmable +alarm_base_date_property+ is not configured'
122
+ end
123
+ unless has_attribute? alarm_base_date_property
124
+ raise 'Alarmable +alarm_base_date_property+ is not usable'
125
+ end
126
+ end
127
+ # rubocop:enable Style/GuardClause
128
+
129
+ # Generate a unique and recalculatable identifier for a given alarm
130
+ # object. We build a hash of the primary keys (before_minutes and
131
+ # channel) to achive this. Afterwards, this alarm id is used to
132
+ # reference dedicated scheduled jobs and track their updates. (Or cancel
133
+ # them accordingly)
134
+ #
135
+ # @param channel [String] The alarm channel
136
+ # @param before_minutes [Integer] The minutes before the alarm starts
137
+ # @return [String] The unique alarm id
138
+ #
139
+ # :reek:UtilityFunction because its a utility, for sure
140
+ def alarm_id(channel, before_minutes)
141
+ (Digest::MD5.new << "#{channel}#{before_minutes}").to_s
142
+ end
143
+
144
+ # Schedule a new Active Job for the alarm notification. This method takes
145
+ # care of the notification time (+date) and will not touch anything when
146
+ # the desired time already passed. It cancels the correct job for the
147
+ # given combination, when it is present. In the end it schedules a new
148
+ # (renewed) job for the given alarm settings.
149
+ #
150
+ # @param alarm [Hash] The alarm object
151
+ # @return [Object] The new alarm_jobs instance (partial)
152
+ # Example: { "alarm id": "job id" }
153
+ #
154
+ # rubocop:disable Metrics/AbcSize because its already broken down
155
+ # :reek:TooManyStatements because see above
156
+ # :reek:NilCheck because we dont want to cancel 'nil' job id
157
+ # :reek:DuplicateMethodCall because hash access is fast
158
+ def reschedule_alarm_job(alarm)
159
+ # Symbolize the hash keys (just to be sure).
160
+ alarm = alarm.symbolize_keys
161
+
162
+ # Calculate the alarm id for job canceling and cancel a found job.
163
+ id = alarm_id(alarm[:channel], alarm[:before_minutes])
164
+ previous_job_id = alarm_jobs.try(:[], id)
165
+ alarm_job.cancel(previous_job_id) unless previous_job_id.nil?
166
+
167
+ base_date = self[alarm_base_date_property]
168
+
169
+ # When the base date is not set, we schedule not a new notification job.
170
+ return {} if base_date.nil?
171
+
172
+ # Calculate the time when the job should run.
173
+ notify_at = base_date - alarm[:before_minutes].minutes
174
+
175
+ # Do nothing when the notification date already passed.
176
+ return {} if Time.current >= notify_at
177
+
178
+ # Put a new job to the queue with the new (current) job execution date.
179
+ job = alarm_job.set(wait_until: notify_at).perform_later(self.id, alarm)
180
+
181
+ # Construct a new alarm_jobs partial instance for this job
182
+ { id => job.job_id }
183
+ end
184
+ # rubocop:enable Metrics/AbcSize
185
+
186
+ # Initiate a reschedule for each alarm in the alarm settings and
187
+ # cancel all left-overs.
188
+ #
189
+ # :reek:TooManyStatements because its already broken down
190
+ def reschedule_alarm_jobs
191
+ # Perform the reschedule of all the current alarms.
192
+ new_alarm_jobs = alarms.each_with_object({}) do |alarm, memo|
193
+ memo.merge!(reschedule_alarm_job(alarm))
194
+ end
195
+
196
+ # Detect the differences from the original alarm_jobs hash to the new
197
+ # built (by partials) alarm_jobs hash. The jobs from negative
198
+ # differences must be canceled.
199
+ diff = Hashdiff.diff(alarm_jobs, new_alarm_jobs)
200
+
201
+ diff.select { |prop| prop.first == '-' }.each do |prop|
202
+ alarm_job.cancel(prop.last)
203
+ end
204
+
205
+ # Update the alarm_jobs reference pool with our fresh hash. Bypass the
206
+ # regular validation and callbacks here, this is required to not stuck
207
+ # in endless create-update loops.
208
+ update_columns(alarm_jobs: new_alarm_jobs)
209
+ end
210
+
211
+ # Reschedule only on updates when the alarm settings are changed.
212
+ def alarms_update_callback
213
+ reschedule_alarm_jobs if alarms_changed?
214
+ end
215
+
216
+ # Cancel all alarm notification jobs on parent destroy.
217
+ def alarms_destroy_callback
218
+ alarm_jobs.each_value { |job_id| alarm_job.cancel(job_id) }
219
+ end
220
+ end
221
+ # rubocop:enable Metrics/BlockLength
222
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alarmable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hermann Mayer
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-01-12 00:00:00.000000000 Z
11
+ date: 2025-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: zeitwerk
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.6'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.6'
83
97
  description: This is a reusable alarm concern for Active Recordmodels. It adds support
84
98
  for the automatic maintenanceof Active Job's which are scheduled for the givenalarms.
85
99
  email:
@@ -118,7 +132,6 @@ files:
118
132
  - gemfiles/rails_6.1.gemfile
119
133
  - gemfiles/rails_7.1.gemfile
120
134
  - lib/alarmable.rb
121
- - lib/alarmable/concern.rb
122
135
  - lib/alarmable/version.rb
123
136
  homepage:
124
137
  licenses:
@@ -1,203 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # A reusable alarm extension to Active Record models. It adds support for the
4
- # maintenance of Active Job's (create, update (cancel)) which are schedules for
5
- # the given alarms. We check for changes on the alarms hash and perform
6
- # updates accordingly.
7
- #
8
- # This concern requires the persistence (and availability) of two properties.
9
- #
10
- # * The first is the JSONB array which holds the alarms. (+alarms+)
11
- # * The seconds is the JSONB array which holds the ids of
12
- # scheduled alarm jobs. (+alarm_jobs+)
13
- #
14
- # rails generate migration AddAlarmsAndAlarmJobsToEntity \
15
- # alarms:jsonb alarm_jobs:jsonb
16
- #
17
- # Furthermore a Active Record model which uses this concern must define the
18
- # Active Job class which will be scheduled. (+alarm_job+) The user must also
19
- # define the base date property of the owning side.
20
- # (+alarm_base_date_property+) This base date is mandatory to calculate the
21
- # correct alarm date/time. When the base date is not set (+nil+) no new
22
- # notification job will be enqueued. When the base date is unset on an update,
23
- # the previously enqueued job will be canceled.
24
- #
25
- # The alarms hash needs to be an array in the following format:
26
- #
27
- # [
28
- # {
29
- # "channel": "email", # email, push, web_notification, etc..
30
- # "before_minutes": 15 # start_at - before_minutes, >= 1
31
- # }
32
- # ]
33
- #
34
- # The given alarm job class will be scheduled with the following two arguments.
35
- #
36
- # * id - The class/instance id of the record which owns the alarm
37
- # * alarm - The alarm hash itself (see the format above)
38
- #
39
- # A suitable alarm job perform method should look like this:
40
- #
41
- # # @param id [String] The entity id
42
- # # @param alarm [Hash] The alarm object
43
- # def perform(id, alarm)
44
- # # Do something special for +alarm.channel+ ..
45
- # end
46
- module Alarmable
47
- extend ActiveSupport::Concern
48
-
49
- class_methods do
50
- # Getter/Setter
51
- #
52
- # :reek:Attribute because thats what this thing is about
53
- attr_accessor :alarm_job, :alarm_base_date_property
54
- end
55
-
56
- # rubocop:disable Metrics/BlockLength because Active Support like it
57
- included do
58
- # Hooks
59
- after_initialize :validate_alarm_settings, :alarm_defaults
60
-
61
- # Here comes a little cheat sheet when and what action is performed
62
- # on the alarm jobs.
63
- #
64
- # create | [ time check, reschedule]
65
- # update | dirty check, [cancel job, time check, reschedule]
66
- # destroy | [cancel job ]
67
- after_create :reschedule_alarm_jobs
68
- before_update :alarms_update_callback
69
- before_destroy :alarms_destroy_callback
70
-
71
- # Getter for the alarm job class.
72
- #
73
- # @return [Class] The alarm job class
74
- def alarm_job
75
- self.class.alarm_job
76
- end
77
-
78
- # Getter for the alarm base date property.
79
- #
80
- # @return [Symbol] The user defined base date property
81
- def alarm_base_date_property
82
- self.class.alarm_base_date_property
83
- end
84
-
85
- # Set some defaults on the relevant alarm properties.
86
- def alarm_defaults
87
- self.alarms ||= []
88
- self.alarm_jobs ||= {}
89
- end
90
-
91
- # Validate the presence of the +alarm_job+ property and the accessibility
92
- # of the specified class. Also validate the +alarm_base_date_property+
93
- # setting.
94
- #
95
- # rubocop:disable Style/GuardClause because its fine like this
96
- # :reek:NilCheck because we validate concern usage
97
- def validate_alarm_settings
98
- raise 'Alarmable +alarm_job+ is not configured' if alarm_job.nil?
99
- unless alarm_job.is_a? Class
100
- raise 'Alarmable +alarm_job+ is not instantiable'
101
- end
102
- if alarm_base_date_property.nil?
103
- raise 'Alarmable +alarm_base_date_property+ is not configured'
104
- end
105
- unless has_attribute? alarm_base_date_property
106
- raise 'Alarmable +alarm_base_date_property+ is not usable'
107
- end
108
- end
109
- # rubocop:enable Style/GuardClause
110
-
111
- # Generate a unique and recalculatable identifier for a given alarm object.
112
- # We build a hash of the primary keys (before_minutes and channel) to
113
- # achive this. Afterwards, this alarm id is used to reference dedicated
114
- # scheduled jobs and track their updates. (Or cancel them accordingly)
115
- #
116
- # @param channel [String] The alarm channel
117
- # @param before_minutes [Integer] The minutes before the alarm starts
118
- # @return [String] The unique alarm id
119
- #
120
- # :reek:UtilityFunction because its a utility, for sure
121
- def alarm_id(channel, before_minutes)
122
- (Digest::MD5.new << "#{channel}#{before_minutes}").to_s
123
- end
124
-
125
- # Schedule a new Active Job for the alarm notification. This method takes
126
- # care of the notification time (+date) and will not touch anything when
127
- # the desired time already passed. It cancels the correct job for the
128
- # given combination, when it is present. In the end it schedules a new
129
- # (renewed) job for the given alarm settings.
130
- #
131
- # @param alarm [Hash] The alarm object
132
- # @return [Object] The new alarm_jobs instance (partial)
133
- # Example: { "alarm id": "job id" }
134
- #
135
- # rubocop:disable Metrics/AbcSize because its already broken down
136
- # :reek:TooManyStatements because see above
137
- # :reek:NilCheck because we dont want to cancel 'nil' job id
138
- # :reek:DuplicateMethodCall because hash access is fast
139
- def reschedule_alarm_job(alarm)
140
- # Symbolize the hash keys (just to be sure).
141
- alarm = alarm.symbolize_keys
142
-
143
- # Calculate the alarm id for job canceling and cancel a found job.
144
- id = alarm_id(alarm[:channel], alarm[:before_minutes])
145
- previous_job_id = alarm_jobs.try(:[], id)
146
- alarm_job.cancel(previous_job_id) unless previous_job_id.nil?
147
-
148
- base_date = self[alarm_base_date_property]
149
-
150
- # When the base date is not set, we schedule not a new notification job.
151
- return {} if base_date.nil?
152
-
153
- # Calculate the time when the job should run.
154
- notify_at = base_date - alarm[:before_minutes].minutes
155
-
156
- # Do nothing when the notification date already passed.
157
- return {} if Time.current >= notify_at
158
-
159
- # Put a new job to the queue with the new (current) job execution date.
160
- job = alarm_job.set(wait_until: notify_at).perform_later(self.id, alarm)
161
-
162
- # Construct a new alarm_jobs partial instance for this job
163
- { id => job.job_id }
164
- end
165
- # rubocop:enable Metrics/AbcSize
166
-
167
- # Initiate a reschedule for each alarm in the alarm settings and
168
- # cancel all left-overs.
169
- #
170
- # :reek:TooManyStatements because its already broken down
171
- def reschedule_alarm_jobs
172
- # Perform the reschedule of all the current alarms.
173
- new_alarm_jobs = alarms.each_with_object({}) do |alarm, memo|
174
- memo.merge!(reschedule_alarm_job(alarm))
175
- end
176
-
177
- # Detect the differences from the original alarm_jobs hash to the new
178
- # built (by partials) alarm_jobs hash. The jobs from negative differences
179
- # must be canceled.
180
- diff = Hashdiff.diff(alarm_jobs, new_alarm_jobs)
181
-
182
- diff.select { |prop| prop.first == '-' }.each do |prop|
183
- alarm_job.cancel(prop.last)
184
- end
185
-
186
- # Update the alarm_jobs reference pool with our fresh hash. Bypass the
187
- # regular validation and callbacks here, this is required to not stuck in
188
- # endless create-update loops.
189
- update_columns(alarm_jobs: new_alarm_jobs)
190
- end
191
-
192
- # Reschedule only on updates when the alarm settings are changed.
193
- def alarms_update_callback
194
- reschedule_alarm_jobs if alarms_changed?
195
- end
196
-
197
- # Cancel all alarm notification jobs on parent destroy.
198
- def alarms_destroy_callback
199
- alarm_jobs.each_value { |job_id| alarm_job.cancel(job_id) }
200
- end
201
- end
202
- # rubocop:enable Metrics/BlockLength
203
- end