statisfy 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []