Skip to content

Commit

Permalink
Lot of testing and some improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
Michaelvilleneuve committed Dec 8, 2023
1 parent 02a6765 commit d0f4fba
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
AllCops:
TargetRubyVersion: 3.2.2
Exclude:
- 'test/**/*'

Style/StringLiterals:
Enabled: true
Expand All @@ -17,6 +19,9 @@ Metrics/CyclomaticComplexity:
Style/FrozenStringLiteralComment:
Enabled: false

Metrics/PerceivedComplexity:
Enabled: false

Metrics/ModuleLength:
Enabled: false

Expand Down
32 changes: 32 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,36 @@ PATH
GEM
remote: https://rubygems.org/
specs:
activemodel (7.0.4.3)
activesupport (= 7.0.4.3)
activerecord (7.0.4.3)
activemodel (= 7.0.4.3)
activesupport (= 7.0.4.3)
activesupport (7.0.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
ast (2.4.2)
coderay (1.1.3)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.6.3)
method_source (1.0.0)
minitest (5.20.0)
parallel (1.22.1)
parser (3.2.2.0)
ast (~> 2.4.1)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
rainbow (3.1.1)
rake (13.0.6)
redis (4.8.1)
redis-client (0.17.0)
connection_pool
regexp_parser (2.8.2)
rexml (3.2.6)
rubocop (1.49.0)
Expand All @@ -28,14 +51,23 @@ GEM
rubocop-ast (1.28.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
sqlite3 (1.6.9-arm64-darwin)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2)

PLATFORMS
arm64-darwin-22

DEPENDENCIES
activerecord (~> 7.0.4.3)
activesupport (~> 7.0.4.3)
pry (~> 0.14.1)
rake (~> 13.0)
redis (~> 4.8.1)
redis-client (~> 0.17.0)
rubocop (~> 1.21)
sqlite3 (~> 1.6.9)
statisfy!

BUNDLED WITH
Expand Down
9 changes: 8 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

require "bundler/gem_tasks"
require "rubocop/rake_task"
require "rake/testtask"

RuboCop::RakeTask.new

task default: :rubocop
Rake::TestTask.new do |t|
t.libs << "test"
t.test_files = FileList["test/**/**.rb"]
end

desc "Run tests"
task default: :test
8 changes: 7 additions & 1 deletion lib/statisfy/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
module Statisfy
class Configuration
attr_accessor :redis_client, :append_to_counters, :default_async_method, :counters_path
attr_accessor(
:default_scopes,
:redis_client,
:append_to_counters,
:default_async_method,
:counters_path
)

def initialize
@default_async_method = :perform_async
Expand Down
85 changes: 47 additions & 38 deletions lib/statisfy/counter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ def count(args = {})
class_eval(&Statisfy.configuration.append_to_counters) if Statisfy.configuration.append_to_counters.present?
end

def aggregate(args = {})
count(args.merge(type: :average))
end

#
# This method serves as a syntactic sugar
# The below methods could be written directly in the class definition
# but the `count` DSL defines them automatically based on the options provided
#
def apply_default_counter_options(args)
define_method(:identifier, args[:uniq_by] || -> { params["id"] })
define_method(:scopes, args[:scopes]) if args[:scopes].present?
define_method(:scopes, args[:scopes] || Statisfy.configuration.default_scopes || -> { [] })
define_method(:if_async, args[:if_async] || -> { true })
define_method(:decrement?, args[:decrement_if] || -> { false })
define_method(:value, args[:value] || -> {})
Expand All @@ -56,33 +60,42 @@ def apply_default_counter_options(args)
# @param scope: the scope of the counter (an Organisation or a Department)
# @param month: the month for which you want the value of the counter (optional)
#
def value(scope:, month: nil)
def value(scope: nil, month: nil)
month = month&.strftime("%Y-%m") if month.present?
if const_get(:COUNTER_TYPE) == :average
average_for(scope:, month:)
average(scope:, month:)
else
number_of_elements_in(scope:, month:)
size(scope:, month:)
end
end

def number_of_elements_in(scope:, month: nil, group: nil)
redis_client.scard(key_for(group:, scope:, month:))
def size(scope: nil, month: nil)
redis_client.scard(key_for(scope:, month:))
end

#
# Returns the list of elements in the set (in case you use .append and not .increment)
#
def elements_in(scope:, month: nil, group: nil)
redis_client.lrange(key_for(group:, scope:, month:), 0, -1)
def elements_in(scope: nil, month: nil)
redis_client.lrange(key_for(scope:, month:), 0, -1)
end

def sum(scope: nil, month: nil)
stored_values = elements_in(scope:, month:)
return 0 if stored_values.empty?

stored_values.map(&:to_i).reduce(:+)
end

#
# Returns the average of the elements in the set
# Example:
# append(value: 1)
# append(value: 2)
# average_for(scope: Organisation.first) # => 1.5
# average
# => 1.5
#
def average_for(scope:, month: nil)
def average(scope: nil, month: nil)
stored_values = elements_in(scope:, month:)
return 0 if stored_values.empty?

Expand All @@ -92,13 +105,12 @@ def average_for(scope:, month: nil)
#
# This is the name of the Redis key that will be used to store the counter
#
def key_for(scope:, month: nil, group: nil)
def key_for(scope:, month: nil)
{
counter: name.demodulize.underscore,
group:,
month:,
scope_type: scope.class.name,
scope_id: scope.id
scope_type: scope&.class&.name,
scope_id: scope&.id
}.to_json
end

Expand All @@ -118,22 +130,22 @@ def redis_client
# @param start_at: the date from which you want to start counting (optional)
# @param stop_at: the date at which you want to stop counting (optional)
#
def values_grouped_by_month(scope:, start_at: nil, stop_at: nil)
x_months = 24
def values_grouped_by_month(scope: nil, start_at: nil, stop_at: nil)
n_months = 24

if start_at.present? || scope&.created_at.present?
start_at ||= scope.created_at
x_months = ((Time.zone.today.year * 12) + Time.zone.today.month) - ((start_at.year * 12) + start_at.month)
n_months = (Time.zone.today.year + Time.zone.today.month) - (start_at.year + start_at.month)
end

relevant_months = (0..x_months).map do |i|
(x_months - i).months.ago.beginning_of_month
relevant_months = (0..n_months).map do |i|
(n_months - i).months.ago.beginning_of_month
end

relevant_months
.filter { |month| stop_at.blank? || month < stop_at }
.to_h do |month|
[month.strftime("%m/%Y"), value(scope:, month: month.strftime("%Y-%m")).round(2)]
[month.strftime("%m/%Y"), value(scope:, month:).round(2)]
end
end
# rubocop:enable Metrics/AbcSize
Expand All @@ -155,24 +167,25 @@ def initialize_with(resource, options = {})
# Returns the list of all the keys of this counter for a given scope (optional)
# and a given month (optional)
#
def all_keys(scope: nil, month: nil, group: nil)
# rubocop:disable Metrics/AbcSize
def all_keys(scope: nil, month: nil)
redis_client.keys("*\"counter\":\"#{name.demodulize.underscore}\"*").filter do |json|
key = JSON.parse(json)

scope_matches = scope.nil? || (key["scope_type"] == scope.class.name && key["scope_id"] == scope.id)
month_matches = month.nil? || key["month"] == month
group_matches = group.nil? || key["group"] == group

scope_matches && month_matches && group_matches
scope_matches && month_matches
end
end
# rubocop:enable Metrics/AbcSize

#
# This allows to reset all the counters for a given scope (optional)
# and a given month (optional)
#
def reset(scope: nil, month: nil, group: nil)
all_keys(scope:, month:, group:).each do |key|
def reset(scope: nil, month: nil)
all_keys(scope:, month:).each do |key|
redis_client.del(key)
end

Expand All @@ -183,17 +196,13 @@ def reset(scope: nil, month: nil, group: nil)
protected

def scopes_with_global
(scopes + [Department.new]).flatten.compact
scopes.flatten.compact << nil
end

def month_to_set
params["created_at"].to_date.strftime("%Y-%m")
end

def scopes
[subject.department, subject.organisation]
end

def process_event
return unless if_async

Expand All @@ -208,31 +217,31 @@ def process_event
# This allows to iterate over all the counters that need to be updated
# (in general the Department(s) and Organisation(s) for both the current month and the global counter)
#
def all_counters_of(group:)
def all_counters
[month_to_set, nil].each do |month|
scopes_with_global.each do |scope|
yield self.class.key_for(group:, scope:, month:), identifier
yield self.class.key_for(scope:, month:), identifier
end
end
end

def increment(group: nil)
all_counters_of(group:) do |key, id|
def increment
all_counters do |key, id|
self.class.redis_client.sadd?(key, id)
end
end

def decrement(group: nil)
all_counters_of(group:) do |key, id|
def decrement
all_counters do |key, id|
self.class.redis_client.srem?(key, id)
end
end

#
# To be used to store a list of values instead of a basic counter
#
def append(value:, group: nil)
all_counters_of(group:) do |key|
def append(value:)
all_counters do |key|
self.class.redis_client.rpush(key, value)
end
end
Expand Down
8 changes: 8 additions & 0 deletions statisfy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Gem::Specification.new do |s|
s.name = "statisfy"
s.version = "0.0.1"
s.required_ruby_version = ">= 3.2.0"
s.date = "2023-12-07"
s.summary = "A performant and flexible counter solution"
s.description = "A performant and flexible counter solution that allows to make statistics on your models"
Expand All @@ -11,4 +12,11 @@ Gem::Specification.new do |s|
s.email = "[email protected]"
s.files = Dir["lib/**/*"]
s.license = "MIT"
s.add_development_dependency "activerecord", "~> 7.0.4.3"
s.add_development_dependency "activesupport", "~> 7.0.4.3"
s.add_development_dependency "redis", "~> 4.8.1"
s.add_development_dependency "redis-client", "~> 0.17.0"
s.add_development_dependency "rubocop", "~> 1.49.0"
s.add_development_dependency "sqlite3", "~> 1.6.9"
s.add_development_dependency "pry", "~> 0.14.1"
end
5 changes: 5 additions & 0 deletions test/factories/organisation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require "active_record"

class Organisation < ActiveRecord::Base
has_many :users
end
6 changes: 6 additions & 0 deletions test/factories/user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require "active_record"
require_relative "organisation"

class User < ActiveRecord::Base
belongs_to :organisation
end
21 changes: 21 additions & 0 deletions test/helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require "active_record"
require "redis"
require "statisfy"

ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"

ActiveRecord::Base.connection.create_table :users, force: true do |t|
t.string(:name)
t.integer(:organisation_id)
t.integer(:salary)
t.timestamps
end

ActiveRecord::Base.connection.create_table :organisations, force: true do |t|
t.string(:name)
t.timestamps
end

Statisfy.configure do |config|
config.redis_client = Redis.new
end
Loading

0 comments on commit d0f4fba

Please sign in to comment.