statisfy 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5bf901bb209f43e88099e395db0b4651e1c9b49aa119fdcbe0d941b8434e058a
4
+ data.tar.gz: 5330f8c09b29c28c16117c9ef633cd94cd9af12df70f299a4ef71bb730cde91c
5
+ SHA512:
6
+ metadata.gz: 4d2ad92ebf235e4114f424e770487d29d78940cba1d6b590840bb15610446125d7713623e692f9817d3b4cc5a34eafb2cbe3930509e1b20d07bdcd7a06dbf701
7
+ data.tar.gz: 3e4e9141247f501ca1c7a7aeb0fff50a63c27020e5035b4a3c21d5e0ebcd482ab6ce4146c1d1c7834144e630ec39b574973ec1721b3d96e541d468d8c6df6ea7
@@ -0,0 +1,35 @@
1
+ require_relative "counter"
2
+
3
+ module Statisfy
4
+ module Aggregate
5
+ def self.included(klass)
6
+ klass.extend(ClassMethods)
7
+ klass.class_eval do
8
+ include Counter
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ #
14
+ # Simply a shortcut for declaring an aggregation type counter
15
+ #
16
+ def aggregate(args = {})
17
+ raise ArgumentError, "You must provide the value to aggregate" if args[:value].blank?
18
+
19
+ count(args.merge(type: :aggregate))
20
+ end
21
+
22
+ #
23
+ # Average type counters ret
24
+ #
25
+ # @param scope: the scope of the counter (an Organisation or a Department)
26
+ # @param month: the month for which you want the value of the counter (optional)
27
+ #
28
+ def value(scope: nil, month: nil)
29
+ p "HEIN???"
30
+ month = month&.strftime("%Y-%m") if month.present?
31
+ average(scope:, month:)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ module Statisfy
2
+ class Configuration
3
+ attr_accessor(
4
+ :default_scopes,
5
+ :redis_client,
6
+ :append_to_counters,
7
+ :default_async_method,
8
+ :counters_path
9
+ )
10
+
11
+ def initialize
12
+ @default_async_method = :perform_async
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,215 @@
1
+ require_relative "subscriber"
2
+ require_relative "monthly"
3
+
4
+ module Statisfy
5
+ module Counter
6
+ def self.included(klass)
7
+ klass.extend(ClassMethods)
8
+ klass.class_eval do
9
+ include Subscriber, Monthly
10
+ attr_accessor :params, :subject
11
+ end
12
+ end
13
+
14
+ module ClassMethods
15
+ #
16
+ # This is a DSL method that helps you define a counter
17
+ # It will create a method that will be called when the event is triggered
18
+ # It will also create a method that will be called when you want to get the value of the counter
19
+ #
20
+ # @param every: the event(s) that will trigger the counter
21
+ # @param type: by default it increments, but you can also use :average
22
+ # @param if: a block that returns a condition that must be met for the counter to be incremented (optional)
23
+ # @param if_async: same as if option but runs async to avoid slowing down inserts and updates (optional)
24
+ # @param uniq_by: a block to get the identifier of the element to be counted (optional)
25
+ # @param scopes: a block to get the list of scopes for which the counter must be incremented (optional)
26
+ #
27
+ def count(args = {})
28
+ raise ArgumentError, "You must provide at least one event" if args[:every].blank?
29
+
30
+ catch_events(*args[:every], if: args[:if] || -> { true })
31
+ apply_default_counter_options(args)
32
+ const_set(:COUNTER_TYPE, args[:type] || :increment)
33
+ class_eval(&Statisfy.configuration.append_to_counters) if Statisfy.configuration.append_to_counters.present?
34
+ end
35
+
36
+ #
37
+ # This method serves as a syntactic sugar
38
+ # The below methods could be written directly in the class definition
39
+ # but the `count` DSL defines them automatically based on the options provided
40
+ #
41
+ def apply_default_counter_options(args)
42
+ define_method(:identifier, args[:uniq_by] || -> { params["id"] })
43
+ define_method(:scopes, args[:scopes] || Statisfy.configuration.default_scopes || -> { [] })
44
+ define_method(:if_async, args[:if_async] || -> { true })
45
+ define_method(:decrement?, args[:decrement_if] || -> { false })
46
+ define_method(:value, args[:value] || -> {})
47
+ define_method(:should_run?, args[:if] || -> { true })
48
+ end
49
+
50
+ #
51
+ # This is the method that is called when you want to get the value of a counter.
52
+ #
53
+ # By default it returns the number of elements in the set.
54
+ # You can override it if the counter requires more complex logic
55
+ # see RateOfAutonomousUsers for example
56
+ #
57
+ # @param scope: the scope of the counter (an Organisation or a Department)
58
+ # @param month: the month for which you want the value of the counter (optional)
59
+ #
60
+ def value(scope: nil, month: nil)
61
+ month = month&.strftime("%Y-%m") if month.present?
62
+ if const_get(:COUNTER_TYPE) == :aggregate
63
+ average(scope:, month:)
64
+ else
65
+ size(scope:, month:)
66
+ end
67
+ end
68
+
69
+ def size(scope: nil, month: nil)
70
+ redis_client.scard(key_for(scope:, month:))
71
+ end
72
+
73
+ #
74
+ # Returns the list of elements in the set (in case you use .append and not .increment)
75
+ #
76
+ def elements_in(scope: nil, month: nil)
77
+ redis_client.lrange(key_for(scope:, month:), 0, -1)
78
+ end
79
+
80
+ def sum(scope: nil, month: nil)
81
+ stored_values = elements_in(scope:, month:)
82
+ return 0 if stored_values.empty?
83
+
84
+ stored_values.map(&:to_i).reduce(:+)
85
+ end
86
+
87
+ #
88
+ # Returns the average of the elements in the set
89
+ # Example:
90
+ # append(value: 1)
91
+ # append(value: 2)
92
+ # average
93
+ # => 1.5
94
+ #
95
+ def average(scope: nil, month: nil)
96
+ stored_values = elements_in(scope:, month:)
97
+ return 0 if stored_values.empty?
98
+
99
+ stored_values.map(&:to_i).reduce(:+) / stored_values.length.to_f
100
+ end
101
+
102
+ #
103
+ # This is the name of the Redis key that will be used to store the counter
104
+ #
105
+ def key_for(scope:, month: nil)
106
+ {
107
+ counter: name.demodulize.underscore,
108
+ month:,
109
+ scope_type: scope&.class&.name,
110
+ scope_id: scope&.id
111
+ }.to_json
112
+ end
113
+
114
+ def redis_client
115
+ Statisfy.configuration.redis_client
116
+ end
117
+
118
+ #
119
+ # This allows to run a counter increment manually
120
+ # It is useful when you want to backfill counters
121
+ #
122
+ def initialize_with(resource, options = {})
123
+ counter = new
124
+ counter.params = resource
125
+
126
+ return unless options[:skip_validation] || counter.should_run?
127
+
128
+ counter.perform(resource)
129
+ end
130
+
131
+ #
132
+ # Returns the list of all the keys of this counter for a given scope (optional)
133
+ # and a given month (optional)
134
+ #
135
+ def all_keys(scope: nil, month: nil)
136
+ redis_client.keys("*\"counter\":\"#{name.demodulize.underscore}\"*").filter do |json|
137
+ key = JSON.parse(json)
138
+
139
+ scope_matches = scope.nil? || (key["scope_type"] == scope.class.name && key["scope_id"] == scope.id)
140
+ month_matches = month.nil? || key["month"] == month
141
+
142
+ scope_matches && month_matches
143
+ end
144
+ end
145
+ # rubocop:enable Metrics/AbcSize
146
+
147
+ #
148
+ # This allows to reset all the counters for a given scope (optional)
149
+ # and a given month (optional)
150
+ #
151
+ def reset(scope: nil, month: nil)
152
+ all_keys(scope:, month:).each do |key|
153
+ redis_client.del(key)
154
+ end
155
+
156
+ true
157
+ end
158
+ end
159
+
160
+ protected
161
+
162
+ def scopes_with_global
163
+ scopes.flatten.compact << nil
164
+ end
165
+
166
+ def month_to_set
167
+ params["created_at"].to_date.strftime("%Y-%m")
168
+ end
169
+
170
+ def process_event
171
+ return unless if_async
172
+
173
+ if value.present?
174
+ append(value:)
175
+ else
176
+ decrement? ? decrement : increment
177
+ end
178
+ end
179
+
180
+ #
181
+ # This allows to iterate over all the counters that need to be updated
182
+ # (in general the Department(s) and Organisation(s) for both the current month and the global counter)
183
+ #
184
+ def all_counters
185
+ [month_to_set, nil].each do |month|
186
+ scopes_with_global.each do |scope|
187
+ yield self.class.key_for(scope:, month:), identifier
188
+ end
189
+ end
190
+ end
191
+
192
+ def increment
193
+ all_counters do |key, id|
194
+ self.class.redis_client.sadd?(key, id)
195
+ end
196
+ end
197
+
198
+ def decrement
199
+ all_counters do |key, id|
200
+ self.class.redis_client.srem?(key, id)
201
+ end
202
+ end
203
+
204
+ #
205
+ # To be used to store a list of values instead of a basic counter
206
+ #
207
+ def append(value:)
208
+ all_counters do |key|
209
+ self.class.redis_client.rpush(key, value)
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ # rubocop:enable Metrics/ModuleLength
@@ -0,0 +1,41 @@
1
+ module Statisfy
2
+ module Monthly
3
+ def self.included(klass)
4
+ klass.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ #
9
+ # Returns a hash of values grouped by month:
10
+ # {
11
+ # "01/2024" => 33.3,
12
+ # "02/2024" => 36.6,
13
+ # "03/2024" => 38.2,
14
+ # }
15
+ #
16
+ # @param scope: the scope of the counter (an Organisation or a Department)
17
+ # @param start_at: the date from which you want to start counting (optional)
18
+ # @param stop_at: the date at which you want to stop counting (optional)
19
+ #
20
+ def values_grouped_by_month(scope: nil, start_at: nil, stop_at: nil)
21
+ n_months = 24
22
+
23
+ if start_at.present? || scope&.created_at.present?
24
+ start_at ||= scope.created_at
25
+ n_months = (Time.zone.today.year + Time.zone.today.month) - (start_at.year + start_at.month)
26
+ end
27
+
28
+ relevant_months = (0..n_months).map do |i|
29
+ (n_months - i).months.ago.beginning_of_month
30
+ end
31
+
32
+ relevant_months
33
+ .filter { |month| stop_at.blank? || month < stop_at }
34
+ .to_h do |month|
35
+ [month.strftime("%m/%Y"), value(scope:, month:).round(2)]
36
+ end
37
+ end
38
+ # rubocop:enable Metrics/AbcSize
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,74 @@
1
+ module Statisfy
2
+ module Subscriber
3
+ def self.included(klass)
4
+ klass.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def catch_events(*event_names, **options)
9
+ define_method(:should_run?, &options[:if] || -> { true })
10
+ [*event_names].flatten.map do |event_name|
11
+ model_and_event_from_event_name(event_name).tap do |model, event|
12
+ append_callback_to_model(model, event)
13
+ define_subject_method(model)
14
+ end
15
+ end
16
+ end
17
+
18
+ def append_callback_to_model(model, event)
19
+ listener = self
20
+ model.class_eval do
21
+ after_commit on: event do
22
+ counter = listener.new
23
+ counter.subject = self
24
+
25
+ next unless counter.should_run?
26
+
27
+ if listener.respond_to?(Statisfy.configuration.default_async_method)
28
+ listener.send(Statisfy.configuration.default_async_method, attributes)
29
+ else
30
+ counter.perform(attributes)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def model_and_event_from_event_name(event_name)
37
+ model_with_event = event_name.to_s.split("_")
38
+ event = {
39
+ "created": :create,
40
+ "updated": :update,
41
+ "destroyed": :destroy
42
+ }[model_with_event.pop.to_sym]
43
+
44
+ model_name = model_with_event.join("_").camelize
45
+
46
+ [Object.const_get(model_name), event]
47
+ rescue NameError
48
+ raise Statisfy::Error, "The model #{model_name} does not exist"
49
+ end
50
+
51
+ def define_subject_method(model)
52
+ instance_name = model.name.underscore
53
+ return if method_defined?(instance_name)
54
+
55
+ define_method(instance_name) do
56
+ model = instance_name.camelize.constantize
57
+ @subject ||= model.find_by(id: params["id"])
58
+ end
59
+ alias_method :subject, instance_name
60
+ end
61
+ end
62
+
63
+ #
64
+ # This is the method that will be called when an event is triggered
65
+ # It will be executed in the background by Sidekiq
66
+ #
67
+ # @resource_or_hash [Hash] The attributes of the model that triggered the event + the previous_changes
68
+ #
69
+ def perform(resource_or_hash)
70
+ @params = resource_or_hash
71
+ process_event
72
+ end
73
+ end
74
+ end
data/lib/statisfy.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "statisfy/configuration"
4
+ require_relative "statisfy/counter"
5
+ require_relative "statisfy/aggregate"
6
+ require_relative "statisfy/monthly"
7
+
8
+ module Statisfy
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,147 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: statisfy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Michaël Villeneuve
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-12-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.4.3
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.4.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 7.0.4.3
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 7.0.4.3
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.14.1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.14.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: redis
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 4.8.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 4.8.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: redis-client
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.17.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.17.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.49.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.49.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: sqlite3
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 1.6.9
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 1.6.9
111
+ description: A performant and flexible counter solution that allows to make statistics
112
+ on your models
113
+ email: contact@michaelvilleneuve.fr
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - lib/statisfy.rb
119
+ - lib/statisfy/aggregate.rb
120
+ - lib/statisfy/configuration.rb
121
+ - lib/statisfy/counter.rb
122
+ - lib/statisfy/monthly.rb
123
+ - lib/statisfy/subscriber.rb
124
+ homepage: https://github.com/Michaelvilleneuve/statisfy
125
+ licenses:
126
+ - MIT
127
+ metadata: {}
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: 3.2.0
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubygems_version: 3.4.10
144
+ signing_key:
145
+ specification_version: 4
146
+ summary: A performant and flexible counter solution
147
+ test_files: []