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 +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: []
|