diff --git a/.rubocop.yml b/.rubocop.yml index e1d557c..f355bcf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,7 @@ AllCops: TargetRubyVersion: 3.2.2 + Exclude: + - 'test/**/*' Style/StringLiterals: Enabled: true @@ -17,6 +19,9 @@ Metrics/CyclomaticComplexity: Style/FrozenStringLiteralComment: Enabled: false +Metrics/PerceivedComplexity: + Enabled: false + Metrics/ModuleLength: Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index 0aca73b..5ed4a80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 diff --git a/Rakefile b/Rakefile index 1924143..cc27eab 100644 --- a/Rakefile +++ b/Rakefile @@ -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 \ No newline at end of file diff --git a/lib/statisfy/configuration.rb b/lib/statisfy/configuration.rb index 43b24b0..dd37d73 100644 --- a/lib/statisfy/configuration.rb +++ b/lib/statisfy/configuration.rb @@ -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 diff --git a/lib/statisfy/counter.rb b/lib/statisfy/counter.rb index e8d664c..22f3e3d 100644 --- a/lib/statisfy/counter.rb +++ b/lib/statisfy/counter.rb @@ -32,6 +32,10 @@ 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 @@ -39,7 +43,7 @@ def count(args = {}) # 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] || -> {}) @@ -56,23 +60,31 @@ 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 # @@ -80,9 +92,10 @@ def elements_in(scope:, month: nil, group: nil) # 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? @@ -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 @@ -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 @@ -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 @@ -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 @@ -208,22 +217,22 @@ 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 @@ -231,8 +240,8 @@ def decrement(group: nil) # # 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 diff --git a/statisfy.gemspec b/statisfy.gemspec index 0872278..5f09695 100644 --- a/statisfy.gemspec +++ b/statisfy.gemspec @@ -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" @@ -11,4 +12,11 @@ Gem::Specification.new do |s| s.email = "contact@michaelvilleneuve.fr" 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 diff --git a/test/factories/organisation.rb b/test/factories/organisation.rb new file mode 100644 index 0000000..0fc119c --- /dev/null +++ b/test/factories/organisation.rb @@ -0,0 +1,5 @@ +require "active_record" + +class Organisation < ActiveRecord::Base + has_many :users +end \ No newline at end of file diff --git a/test/factories/user.rb b/test/factories/user.rb new file mode 100644 index 0000000..4673337 --- /dev/null +++ b/test/factories/user.rb @@ -0,0 +1,6 @@ +require "active_record" +require_relative "organisation" + +class User < ActiveRecord::Base + belongs_to :organisation +end \ No newline at end of file diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..03328cf --- /dev/null +++ b/test/helper.rb @@ -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 diff --git a/test/statisfy_test.rb b/test/statisfy_test.rb new file mode 100644 index 0000000..393221f --- /dev/null +++ b/test/statisfy_test.rb @@ -0,0 +1,219 @@ +$VERBOSE = nil + +require "minitest/autorun" +require "statisfy" +require "active_support" +require_relative "helper" + +require_relative "factories/user" +require_relative "factories/organisation" + +class StatisfyTest < ActiveSupport::TestCase + setup do + Redis.new.flushall + User.delete_all + Organisation.delete_all + end + + test "it is a module" do + assert_kind_of Module, Statisfy + end + + test "it can count a resource" do + class UserCounter + include Statisfy::Counter + + count every: :user_created + end + + User.create! + assert UserCounter.value == 1 + end + + test "it can count a resource with a scope" do + class UserCounter + include Statisfy::Counter + + count every: :user_created, scopes: -> { [user.organisation] } + end + + apple = Organisation.create!(name: "Apple") + microsoft = Organisation.create!(name: "Microsoft") + + User.create!(name: "Steve", organisation: apple) + User.create!(name: "Bill", organisation: microsoft) + + assert UserCounter.value(scope: apple) == 1 + assert UserCounter.value(scope: microsoft) == 1 + assert UserCounter.value == 2 + end + + test "it can count a resource with a scope and a month" do + class UserCounter + include Statisfy::Counter + + count every: :user_created, scopes: -> { [user.organisation] } + end + + apple = Organisation.create!(name: "Apple") + microsoft = Organisation.create!(name: "Microsoft") + + User.create!(name: "Steve", organisation: apple) + User.create!(name: "Bill", organisation: microsoft) + + assert UserCounter.value(scope: apple, month: Date.today) == 1 + assert UserCounter.value(scope: microsoft, month: Date.today) == 1 + assert UserCounter.value(month: Date.today) == 2 + end + + test "#values_grouped_by_month creates a hash of values grouped by month" do + class UserCounter + include Statisfy::Counter + + count every: :user_created, scopes: -> { [user.organisation] } + end + + every_month = (0..24).map do |i| + creation_date = (24 - i).months.ago.beginning_of_month + between_2_and_5 = rand(2..5) + + between_2_and_5.times do + User.create!(created_at: creation_date) + end + end + + assert UserCounter.values_grouped_by_month(stop_at: 1.month.ago).values.all? { |v| v >= 2 && v <= 5 } + end + + test "if option prevents running counter" do + class UserCounterFalse + include Statisfy::Counter + + count every: :user_created, if: -> { false } + end + + class UserCounter + include Statisfy::Counter + + count every: :user_created, if: -> { true } + end + + User.create! + assert UserCounterFalse.value == 0 + assert UserCounter.value == 1 + end + + test "if_async option prevents running counter" do + class UserCounterFalse + include Statisfy::Counter + + count every: :user_created, if_async: -> { false } + end + + class UserCounter + include Statisfy::Counter + + count every: :user_created, if_async: -> { true } + end + + User.create! + assert UserCounterFalse.value == 0 + assert UserCounter.value == 1 + end + + test "uniq_by option allows to create group of values" do + class NumberOfSteveCounter + include Statisfy::Counter + + count every: :user_created, uniq_by: -> { user.name == "Steve" } + end + + User.create!(name: "Steve") + User.create!(name: "Steve") + User.create!(name: "Bill") + + assert_equal NumberOfSteveCounter.value, 2 + end + + test "initialize_with option allows to initialize a counter when a table wasn't empty" do + 5.times { User.create! } + class UserCreated + include Statisfy::Counter + + count every: :user_created + end + + User.find_each do |user| + UserCreated.initialize_with(user) + end + + assert_equal UserCreated.value, 5 + end + + test "initialize_with can skip if validations" do + 3.times { User.create!(name: "Steve") } + 2.times { User.create!(name: "Bill") } + Redis.new.flushall + + class UserCreated + include Statisfy::Counter + + count every: :user_updated, if: -> { user.previous_changes[:name] && user.name == "Steve" } + end + assert_equal UserCreated.value, 0 + + User.where(name: "Steve").find_each do |user| + UserCreated.initialize_with(user, skip_validation: false) + end + + # Since previous_changes is missing when initializing, validation fails + assert_equal UserCreated.value, 0 + + User.where(name: "Steve").find_each do |user| + UserCreated.initialize_with(user, skip_validation: true) + end + + # Skipping validations allows to initialize the counter + assert_equal UserCreated.value, 3 + end + + test "decrement_if option allows to decrement a counter" do + paul = User.create!(name: "Paul") + jean = User.create!(name: "Jean") + marc = User.create!(name: "Marc") + + class UserCounter + include Statisfy::Counter + + count every: :user_updated, + if: -> { user.previous_changes[:name] }, + decrement_if: -> { user.name != "Steve" } + end + + paul.update!(name: "Steve") + jean.update!(name: "Steve") + marc.update!(name: "Steve") + + assert_equal UserCounter.value, 3 + + paul.update!(name: "Paul") + jean.update!(name: "Jean") + + assert_equal UserCounter.value, 1 + end + + test "aggregate option allows to aggregate instead of increment and get an average" do + class SalaryPerUser + include Statisfy::Counter + + aggregate every: :user_created, value: -> { user.salary } + end + + User.create!(salary: 2000) + User.create!(salary: 3000) + User.create!(salary: 4000) + + assert_equal 3000, SalaryPerUser.average + assert_equal 9000, SalaryPerUser.sum + end +end