statisfy 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/statisfy/aggregate.rb +35 -0
- data/lib/statisfy/configuration.rb +15 -0
- data/lib/statisfy/counter.rb +215 -0
- data/lib/statisfy/monthly.rb +41 -0
- data/lib/statisfy/subscriber.rb +74 -0
- data/lib/statisfy.rb +20 -0
- metadata +147 -0
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,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: []
|