From 733c69a6b4eeba62c9cb0c29e5a908edb522a176 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Wed, 21 May 2025 21:02:24 -0300 Subject: [PATCH 01/17] Start making more parts of the gem Rails version proof --- app/models/togglefy/feature.rb | 16 +++++++++++---- lib/togglefy/services/bulk_toggler.rb | 28 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/models/togglefy/feature.rb b/app/models/togglefy/feature.rb index f9e164f..0e8175b 100644 --- a/app/models/togglefy/feature.rb +++ b/app/models/togglefy/feature.rb @@ -12,7 +12,7 @@ class Feature < (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord: if Rails::VERSION::MAJOR >= 7 enum :status, %i[inactive active] else - enum status: %i[inactive active] + enum status: { inactive: 0, active: 1 } end # Associations @@ -59,11 +59,19 @@ class Feature < (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord: # Finds features with an inactive status. # @return [ActiveRecord::Relation] The features with an inactive status. - scope :inactive, -> { where(status: :inactive) } + if Rails::VERSION::MAJOR >= 7 + scope :inactive, -> { where(status: :inactive) } + else + scope :inactive, -> { where(status: 0) } + end # Finds features with an active status. # @return [ActiveRecord::Relation] The features with an active status. - scope :active, -> { where(status: :active) } + if Rails::VERSION::MAJOR >= 7 + scope :active, -> { where(status: :active) } + else + scope :active, -> { where(status: 1) } + end # Finds features by their status. # @param status [Symbol, String, Integer] The status to search @@ -108,7 +116,7 @@ def assignables_for_type(klass) # The identifier is generated by parameterizing the name with underscores. # @return [void] def build_identifier - self.identifier = name.underscore.parameterize(separator: "_") + self.identifier = name.parameterize.gsub(/-/, "_") end end end diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 761321a..73cbef1 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -140,7 +140,7 @@ def mass_insert(rows, identifiers) return unless rows.any? ActiveRecord::Base.transaction do - Togglefy::FeatureAssignment.insert_all(rows) + Rails::VERSION::MAJOR => 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all(rows) end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( @@ -149,6 +149,32 @@ def mass_insert(rows, identifiers) ) end + rows = [ + { assignable_id: "23b61788-657d-445a-aef5-578e96dbbcdb", assignable_type: "Account", feature_id: 1 }, + { assignable_id: "f80fc3b8-d2b5-4fc7-9bd9-a8231244c8c0", assignable_type: "Account", feature_id: 1 } + ] + Togglefy::FeatureAssignment.create([rows]) + + def insert_all(rows) + sql = <<-SQL + INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) + VALUES (#{rows.map { |row| "(#{row[:assignable_id]}, '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) + SQL + + ActiveRecord::Base.connection.execute(sql) + end + + def insert_all(rows) + sql = <<-SQL + INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) + VALUES #{rows.join(", ")}) + ON CONFLICT (assignable_id, assignable_type, feature_id) DO NOTHING + SQL + + ActiveRecord::Base.connection.execute(sql) + end + + # Disables features for assignables. # # @param assignables [Array] The assignables to update. From 7551dc93195a24a55d26c98fb0093f3024be213a Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Wed, 21 May 2025 21:04:28 -0300 Subject: [PATCH 02/17] Improve --- lib/togglefy/services/bulk_toggler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 73cbef1..559b4fa 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -158,7 +158,7 @@ def mass_insert(rows, identifiers) def insert_all(rows) sql = <<-SQL INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) - VALUES (#{rows.map { |row| "(#{row[:assignable_id]}, '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) + VALUES (#{rows.map { |row| "('#{row[:assignable_id]}', '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) SQL ActiveRecord::Base.connection.execute(sql) From 346ee485bde29b37572e3e95749d04ce161e8f2f Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 01:40:08 -0300 Subject: [PATCH 03/17] Improvements on version proof --- app/models/togglefy/feature.rb | 125 +----------------- app/models/togglefy/feature_assignment.rb | 25 +--- app/models/togglefy/models/rails_5/feature.rb | 104 +++++++++++++++ .../models/rails_5/feature_assignment.rb | 22 +++ app/models/togglefy/models/rails_7/feature.rb | 104 +++++++++++++++ .../models/rails_7/feature_assignment.rb | 22 +++ .../togglefy/models/rails_legacy/feature.rb | 104 +++++++++++++++ .../models/rails_legacy/feature_assignment.rb | 22 +++ lib/togglefy/services/bulk_toggler.rb | 60 ++++++--- 9 files changed, 432 insertions(+), 156 deletions(-) create mode 100644 app/models/togglefy/models/rails_5/feature.rb create mode 100644 app/models/togglefy/models/rails_5/feature_assignment.rb create mode 100644 app/models/togglefy/models/rails_7/feature.rb create mode 100644 app/models/togglefy/models/rails_7/feature_assignment.rb create mode 100644 app/models/togglefy/models/rails_legacy/feature.rb create mode 100644 app/models/togglefy/models/rails_legacy/feature_assignment.rb diff --git a/app/models/togglefy/feature.rb b/app/models/togglefy/feature.rb index 0e8175b..cbd7b47 100644 --- a/app/models/togglefy/feature.rb +++ b/app/models/togglefy/feature.rb @@ -1,122 +1,9 @@ # frozen_string_literal: true -module Togglefy - # Represents a feature in the Togglefy system. - # A feature can have various attributes such as name, identifier, status, and associations with assignables. - class Feature < (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base) - # Enum for feature status. - # If Rails version is 7 or higher, use the new enum syntax. - # If Rails version is lower, use the old enum syntax. - # @!attribute [r] status - # @return [Symbol] The status of the feature (:inactive or :active). - if Rails::VERSION::MAJOR >= 7 - enum :status, %i[inactive active] - else - enum status: { inactive: 0, active: 1 } - end - - # Associations - # @!attribute [rw] feature_assignments - # @return [ActiveRecord::Relation] The feature assignments associated with this feature. - has_many :feature_assignments, dependent: :destroy - - # Callbacks - # Builds an identifier for the feature before validation if the name is present and identifier is blank. - before_validation :build_identifier, if: proc { |f| f.name.present? && f.identifier.blank? } - - # Scopes - # Finds features by their identifier. - # @param identifier [Symbol, String, Array] The identifier to search for. - # @return [ActiveRecord::Relation] The features matching the identifier. - scope :identifier, ->(identifier) { where(identifier: identifier) } - - # Finds features by their group. - # @param group [String] The group to search for. - # @return [ActiveRecord::Relation] The features matching the group. - scope :for_group, ->(group) { where(group: group) } - - # Finds features without a group. - # @return [ActiveRecord::Relation] The features without a group. - scope :without_group, -> { where(group: nil) } - - # Finds features by their environment. - # @param environment [String] The environment to search for. - # @return [ActiveRecord::Relation] The features matching the environment. - scope :for_environment, ->(environment) { where(environment: environment) } - - # Finds features without an environment. - # @return [ActiveRecord::Relation] The features without an environment. - scope :without_environment, -> { where(environment: nil) } - - # Finds features by their tenant ID. - # @param tenant_id [String] The tenant ID to search for. - # @return [ActiveRecord::Relation] The features matching the tenant ID. - scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) } - - # Finds features without a tenant. - # @return [ActiveRecord::Relation] The features without a tenant. - scope :without_tenant, -> { where(tenant_id: nil) } - - # Finds features with an inactive status. - # @return [ActiveRecord::Relation] The features with an inactive status. - if Rails::VERSION::MAJOR >= 7 - scope :inactive, -> { where(status: :inactive) } - else - scope :inactive, -> { where(status: 0) } - end - - # Finds features with an active status. - # @return [ActiveRecord::Relation] The features with an active status. - if Rails::VERSION::MAJOR >= 7 - scope :active, -> { where(status: :active) } - else - scope :active, -> { where(status: 1) } - end - - # Finds features by their status. - # @param status [Symbol, String, Integer] The status to search - # (:inactive || "inactive" || 0) or (:active || "active" || 1). - # @return [ActiveRecord::Relation] The features matching the status. - scope :with_status, ->(status) { where(status: status) } - - # Validations - # Validates the presence and uniqueness of the name and identifier attributes. - validates :name, :identifier, presence: true, uniqueness: true - validates :identifier, format: { - with: /\A[a-z]+(_[a-z0-9]+)*\z/, - message: "must be in snake_case (lowercase letters and underscores only)" - } - - # This method retrieves all assignables linked to the feature through feature assignments. - # @return [ActiveRecord::Relation] The assignables associated with the feature. - # @example - # feature.assignables - # Togglefy.feature(:super_powers).assignables - # Togglefy::Feature.find_by(identifier: :super_powers).assignables - # @note This method includes all assignables, regardless of their class. - def assignables - feature_assignments.includes(:assignable).map(&:assignable) - end - - # This method retrieves assignables of a specific class linked to the feature through feature assignments. - # @param klass [String, Class] The class name or class of the assignable class. - # @return [ActiveRecord::Relation] The assignables of the specified class associated with the feature. - # @example - # feature.assignables_for_klass("User") - # feature.assignables_for_klass(User) - # Togglefy.feature(:super_powers).assignables_for_klass(User) - # Togglefy::Feature.find_by(identifier: :super_powers).assignables_for_klass(User) - def assignables_for_type(klass) - feature_assignments.includes(:assignable).where(assignable_type: klass.to_s).map(&:assignable) - end - - private - - # Builds a unique identifier for the feature based on its name. - # The identifier is generated by parameterizing the name with underscores. - # @return [void] - def build_identifier - self.identifier = name.parameterize.gsub(/-/, "_") - end - end +if Rails::VERSION::MAJOR >= 7 + require_relative "models/rails_7/feature" +elsif Rails::VERSION::MAJOR >= 5 + require_relative "models/rails_5/feature" +else + require_relative "models/rails_legacy/feature" end diff --git a/app/models/togglefy/feature_assignment.rb b/app/models/togglefy/feature_assignment.rb index 973d54c..778c480 100644 --- a/app/models/togglefy/feature_assignment.rb +++ b/app/models/togglefy/feature_assignment.rb @@ -1,22 +1,9 @@ # frozen_string_literal: true -module Togglefy - # Represents the assignment of a feature to an assignable entity. - # A feature assignment links a feature to an assignable object, which can be of any polymorphic type. - class FeatureAssignment < ApplicationRecord - # Associations - # @!attribute [rw] feature - # @return [Feature] The feature associated with this assignment. - belongs_to :feature, class_name: "Togglefy::Feature" - - # @!attribute [rw] assignable - # @return [Object] The polymorphic assignable object associated with this assignment. - belongs_to :assignable, polymorphic: true - - # Scopes - # Finds feature assignments for a specific assignable type. - # @param klass [Class] The class type of the assignable. - # @return [ActiveRecord::Relation] The feature assignments for the given type. - scope :for_type, ->(klass) { where(assignable_type: klass.to_s) } - end +if Rails::VERSION::MAJOR >= 7 + require_relative "models/rails_7/feature_assignment" +elsif Rails::VERSION::MAJOR >= 5 + require_relative "models/rails_5/feature_assignment" +else + require_relative "models/rails_legacy/feature_assignment" end diff --git a/app/models/togglefy/models/rails_5/feature.rb b/app/models/togglefy/models/rails_5/feature.rb new file mode 100644 index 0000000..d7188cd --- /dev/null +++ b/app/models/togglefy/models/rails_5/feature.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Togglefy + # Represents a feature in the Togglefy system. + # A feature can have various attributes such as name, identifier, status, and associations with assignables. + class Feature < ApplicationRecord + # Enum for feature status. + # @!attribute [r] status + # @return [Symbol] The status of the feature (:inactive or :active). + enum status: { inactive: 0, active: 1 } + + # Associations + # @!attribute [rw] feature_assignments + # @return [ActiveRecord::Relation] The feature assignments associated with this feature. + has_many :feature_assignments, dependent: :destroy + + # Callbacks + # Builds an identifier for the feature before validation if the name is present and identifier is blank. + before_validation :build_identifier, if: proc { |f| f.name.present? && f.identifier.blank? } + + # Scopes + # Finds features by their identifier. + # @param identifier [Symbol, String, Array] The identifier to search for. + # @return [ActiveRecord::Relation] The features matching the identifier. + scope :identifier, ->(identifier) { where(identifier: identifier) } + + # Finds features by their group. + # @param group [String] The group to search for. + # @return [ActiveRecord::Relation] The features matching the group. + scope :for_group, ->(group) { where(group: group) } + + # Finds features without a group. + # @return [ActiveRecord::Relation] The features without a group. + scope :without_group, -> { where(group: nil) } + + # Finds features by their environment. + # @param environment [String] The environment to search for. + # @return [ActiveRecord::Relation] The features matching the environment. + scope :for_environment, ->(environment) { where(environment: environment) } + + # Finds features without an environment. + # @return [ActiveRecord::Relation] The features without an environment. + scope :without_environment, -> { where(environment: nil) } + + # Finds features by their tenant ID. + # @param tenant_id [String] The tenant ID to search for. + # @return [ActiveRecord::Relation] The features matching the tenant ID. + scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) } + + # Finds features without a tenant. + # @return [ActiveRecord::Relation] The features without a tenant. + scope :without_tenant, -> { where(tenant_id: nil) } + + # Finds features by their status. + # @param status [Symbol, String, Integer] The status to search + # (:inactive || "inactive" || 0) or (:active || "active" || 1). + # @return [ActiveRecord::Relation] The features matching the status. + scope :with_status, ->(status) { where(status: status) } + + # Validations + # Validates the presence and uniqueness of the name and identifier attributes. + validates :name, :identifier, presence: true, uniqueness: true + validates :identifier, format: { + with: /\A[a-z]+(_[a-z0-9]+)*\z/, + message: "must be in snake_case (lowercase letters and underscores only)" + } + + # This method retrieves all assignables linked to the feature through feature assignments. + # @return [ActiveRecord::Relation] The assignables associated with the feature. + # @example + # feature.assignables + # Togglefy.feature(:super_powers).assignables + # Togglefy::Feature.find_by(identifier: :super_powers).assignables + # @note This method includes all assignables, regardless of their class. + def assignables + feature_assignments.includes(:assignable).map(&:assignable) + end + + # This method retrieves assignables of a specific class linked to the feature through feature assignments. + # @param klass [String, Class] The class name or class of the assignable class. + # @return [ActiveRecord::Relation] The assignables of the specified class associated with the feature. + # @example + # feature.assignables_for_klass("User") + # feature.assignables_for_klass(User) + # Togglefy.feature(:super_powers).assignables_for_klass(User) + # Togglefy::Feature.find_by(identifier: :super_powers).assignables_for_klass(User) + def assignables_for_type(klass) + feature_assignments.includes(:assignable).where(assignable_type: klass.to_s).map(&:assignable) + end + + def rails_version + "5+" + end + + private + + # Builds a unique identifier for the feature based on its name. + # The identifier is generated by parameterizing the name with underscores. + # @return [void] + def build_identifier + self.identifier = name.parameterize.gsub(/-/, "_") + end + end +end diff --git a/app/models/togglefy/models/rails_5/feature_assignment.rb b/app/models/togglefy/models/rails_5/feature_assignment.rb new file mode 100644 index 0000000..973d54c --- /dev/null +++ b/app/models/togglefy/models/rails_5/feature_assignment.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Togglefy + # Represents the assignment of a feature to an assignable entity. + # A feature assignment links a feature to an assignable object, which can be of any polymorphic type. + class FeatureAssignment < ApplicationRecord + # Associations + # @!attribute [rw] feature + # @return [Feature] The feature associated with this assignment. + belongs_to :feature, class_name: "Togglefy::Feature" + + # @!attribute [rw] assignable + # @return [Object] The polymorphic assignable object associated with this assignment. + belongs_to :assignable, polymorphic: true + + # Scopes + # Finds feature assignments for a specific assignable type. + # @param klass [Class] The class type of the assignable. + # @return [ActiveRecord::Relation] The feature assignments for the given type. + scope :for_type, ->(klass) { where(assignable_type: klass.to_s) } + end +end diff --git a/app/models/togglefy/models/rails_7/feature.rb b/app/models/togglefy/models/rails_7/feature.rb new file mode 100644 index 0000000..0256428 --- /dev/null +++ b/app/models/togglefy/models/rails_7/feature.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Togglefy + # Represents a feature in the Togglefy system. + # A feature can have various attributes such as name, identifier, status, and associations with assignables. + class Feature < ApplicationRecord + # Enum for feature status. + # @!attribute [r] status + # @return [Symbol] The status of the feature (:inactive or :active). + enum :status, %i[inactive active] + + # Associations + # @!attribute [rw] feature_assignments + # @return [ActiveRecord::Relation] The feature assignments associated with this feature. + has_many :feature_assignments, dependent: :destroy + + # Callbacks + # Builds an identifier for the feature before validation if the name is present and identifier is blank. + before_validation :build_identifier, if: proc { |f| f.name.present? && f.identifier.blank? } + + # Scopes + # Finds features by their identifier. + # @param identifier [Symbol, String, Array] The identifier to search for. + # @return [ActiveRecord::Relation] The features matching the identifier. + scope :identifier, ->(identifier) { where(identifier: identifier) } + + # Finds features by their group. + # @param group [String] The group to search for. + # @return [ActiveRecord::Relation] The features matching the group. + scope :for_group, ->(group) { where(group: group) } + + # Finds features without a group. + # @return [ActiveRecord::Relation] The features without a group. + scope :without_group, -> { where(group: nil) } + + # Finds features by their environment. + # @param environment [String] The environment to search for. + # @return [ActiveRecord::Relation] The features matching the environment. + scope :for_environment, ->(environment) { where(environment: environment) } + + # Finds features without an environment. + # @return [ActiveRecord::Relation] The features without an environment. + scope :without_environment, -> { where(environment: nil) } + + # Finds features by their tenant ID. + # @param tenant_id [String] The tenant ID to search for. + # @return [ActiveRecord::Relation] The features matching the tenant ID. + scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) } + + # Finds features without a tenant. + # @return [ActiveRecord::Relation] The features without a tenant. + scope :without_tenant, -> { where(tenant_id: nil) } + + # Finds features by their status. + # @param status [Symbol, String, Integer] The status to search + # (:inactive || "inactive" || 0) or (:active || "active" || 1). + # @return [ActiveRecord::Relation] The features matching the status. + scope :with_status, ->(status) { where(status: status) } + + # Validations + # Validates the presence and uniqueness of the name and identifier attributes. + validates :name, :identifier, presence: true, uniqueness: true + validates :identifier, format: { + with: /\A[a-z]+(_[a-z0-9]+)*\z/, + message: "must be in snake_case (lowercase letters and underscores only)" + } + + # This method retrieves all assignables linked to the feature through feature assignments. + # @return [ActiveRecord::Relation] The assignables associated with the feature. + # @example + # feature.assignables + # Togglefy.feature(:super_powers).assignables + # Togglefy::Feature.find_by(identifier: :super_powers).assignables + # @note This method includes all assignables, regardless of their class. + def assignables + feature_assignments.includes(:assignable).map(&:assignable) + end + + # This method retrieves assignables of a specific class linked to the feature through feature assignments. + # @param klass [String, Class] The class name or class of the assignable class. + # @return [ActiveRecord::Relation] The assignables of the specified class associated with the feature. + # @example + # feature.assignables_for_klass("User") + # feature.assignables_for_klass(User) + # Togglefy.feature(:super_powers).assignables_for_klass(User) + # Togglefy::Feature.find_by(identifier: :super_powers).assignables_for_klass(User) + def assignables_for_type(klass) + feature_assignments.includes(:assignable).where(assignable_type: klass.to_s).map(&:assignable) + end + + def rails_version + "7+" + end + + private + + # Builds a unique identifier for the feature based on its name. + # The identifier is generated by parameterizing the name with underscores. + # @return [void] + def build_identifier + self.identifier = name.parameterize.gsub(/-/, "_") + end + end +end diff --git a/app/models/togglefy/models/rails_7/feature_assignment.rb b/app/models/togglefy/models/rails_7/feature_assignment.rb new file mode 100644 index 0000000..973d54c --- /dev/null +++ b/app/models/togglefy/models/rails_7/feature_assignment.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Togglefy + # Represents the assignment of a feature to an assignable entity. + # A feature assignment links a feature to an assignable object, which can be of any polymorphic type. + class FeatureAssignment < ApplicationRecord + # Associations + # @!attribute [rw] feature + # @return [Feature] The feature associated with this assignment. + belongs_to :feature, class_name: "Togglefy::Feature" + + # @!attribute [rw] assignable + # @return [Object] The polymorphic assignable object associated with this assignment. + belongs_to :assignable, polymorphic: true + + # Scopes + # Finds feature assignments for a specific assignable type. + # @param klass [Class] The class type of the assignable. + # @return [ActiveRecord::Relation] The feature assignments for the given type. + scope :for_type, ->(klass) { where(assignable_type: klass.to_s) } + end +end diff --git a/app/models/togglefy/models/rails_legacy/feature.rb b/app/models/togglefy/models/rails_legacy/feature.rb new file mode 100644 index 0000000..de7f56e --- /dev/null +++ b/app/models/togglefy/models/rails_legacy/feature.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Togglefy + # Represents a feature in the Togglefy system. + # A feature can have various attributes such as name, identifier, status, and associations with assignables. + class Feature < ActiveRecord::Base + # Enum for feature status. + # @!attribute [r] status + # @return [Symbol] The status of the feature (:inactive or :active). + enum status: { inactive: 0, active: 1 } + + # Associations + # @!attribute [rw] feature_assignments + # @return [ActiveRecord::Relation] The feature assignments associated with this feature. + has_many :feature_assignments, dependent: :destroy + + # Callbacks + # Builds an identifier for the feature before validation if the name is present and identifier is blank. + before_validation :build_identifier, if: proc { |f| f.name.present? && f.identifier.blank? } + + # Scopes + # Finds features by their identifier. + # @param identifier [Symbol, String, Array] The identifier to search for. + # @return [ActiveRecord::Relation] The features matching the identifier. + scope :identifier, ->(identifier) { where(identifier: identifier) } + + # Finds features by their group. + # @param group [String] The group to search for. + # @return [ActiveRecord::Relation] The features matching the group. + scope :for_group, ->(group) { where(group: group) } + + # Finds features without a group. + # @return [ActiveRecord::Relation] The features without a group. + scope :without_group, -> { where(group: nil) } + + # Finds features by their environment. + # @param environment [String] The environment to search for. + # @return [ActiveRecord::Relation] The features matching the environment. + scope :for_environment, ->(environment) { where(environment: environment) } + + # Finds features without an environment. + # @return [ActiveRecord::Relation] The features without an environment. + scope :without_environment, -> { where(environment: nil) } + + # Finds features by their tenant ID. + # @param tenant_id [String] The tenant ID to search for. + # @return [ActiveRecord::Relation] The features matching the tenant ID. + scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) } + + # Finds features without a tenant. + # @return [ActiveRecord::Relation] The features without a tenant. + scope :without_tenant, -> { where(tenant_id: nil) } + + # Finds features by their status. + # @param status [Symbol, String, Integer] The status to search + # (:inactive || "inactive" || 0) or (:active || "active" || 1). + # @return [ActiveRecord::Relation] The features matching the status. + scope :with_status, ->(status) { where(status: status) } + + # Validations + # Validates the presence and uniqueness of the name and identifier attributes. + validates :name, :identifier, presence: true, uniqueness: true + validates :identifier, format: { + with: /\A[a-z]+(_[a-z0-9]+)*\z/, + message: "must be in snake_case (lowercase letters and underscores only)" + } + + # This method retrieves all assignables linked to the feature through feature assignments. + # @return [ActiveRecord::Relation] The assignables associated with the feature. + # @example + # feature.assignables + # Togglefy.feature(:super_powers).assignables + # Togglefy::Feature.find_by(identifier: :super_powers).assignables + # @note This method includes all assignables, regardless of their class. + def assignables + feature_assignments.includes(:assignable).map(&:assignable) + end + + # This method retrieves assignables of a specific class linked to the feature through feature assignments. + # @param klass [String, Class] The class name or class of the assignable class. + # @return [ActiveRecord::Relation] The assignables of the specified class associated with the feature. + # @example + # feature.assignables_for_klass("User") + # feature.assignables_for_klass(User) + # Togglefy.feature(:super_powers).assignables_for_klass(User) + # Togglefy::Feature.find_by(identifier: :super_powers).assignables_for_klass(User) + def assignables_for_type(klass) + feature_assignments.includes(:assignable).where(assignable_type: klass.to_s).map(&:assignable) + end + + def rails_version + "5-" + end + + private + + # Builds a unique identifier for the feature based on its name. + # The identifier is generated by parameterizing the name with underscores. + # @return [void] + def build_identifier + self.identifier = name.parameterize.gsub(/-/, "_") + end + end +end diff --git a/app/models/togglefy/models/rails_legacy/feature_assignment.rb b/app/models/togglefy/models/rails_legacy/feature_assignment.rb new file mode 100644 index 0000000..6967dee --- /dev/null +++ b/app/models/togglefy/models/rails_legacy/feature_assignment.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Togglefy + # Represents the assignment of a feature to an assignable entity. + # A feature assignment links a feature to an assignable object, which can be of any polymorphic type. + class FeatureAssignment < ActiveRecord::Base + # Associations + # @!attribute [rw] feature + # @return [Feature] The feature associated with this assignment. + belongs_to :feature, class_name: "Togglefy::Feature" + + # @!attribute [rw] assignable + # @return [Object] The polymorphic assignable object associated with this assignment. + belongs_to :assignable, polymorphic: true + + # Scopes + # Finds feature assignments for a specific assignable type. + # @param klass [Class] The class type of the assignable. + # @return [ActiveRecord::Relation] The feature assignments for the given type. + scope :for_type, ->(klass) { where(assignable_type: klass.to_s) } + end +end diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 559b4fa..82f6d53 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -140,7 +140,12 @@ def mass_insert(rows, identifiers) return unless rows.any? ActiveRecord::Base.transaction do - Rails::VERSION::MAJOR => 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all(rows) + # Togglefy::FeatureAssignment.insert_all(rows) + if Rails::VERSION::MAJOR => 6 + Togglefy::FeatureAssignment.insert_all(rows) + else + insert_all(rows) + end end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( @@ -149,31 +154,50 @@ def mass_insert(rows, identifiers) ) end - rows = [ - { assignable_id: "23b61788-657d-445a-aef5-578e96dbbcdb", assignable_type: "Account", feature_id: 1 }, - { assignable_id: "f80fc3b8-d2b5-4fc7-9bd9-a8231244c8c0", assignable_type: "Account", feature_id: 1 } - ] - Togglefy::FeatureAssignment.create([rows]) - def insert_all(rows) - sql = <<-SQL - INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) - VALUES (#{rows.map { |row| "('#{row[:assignable_id]}', '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) - SQL + return if rows.empty? + + columns = rows.first.keys + values = rows.map do |row| + "(" + columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ") + ")" + end + + sql = insert_all_query(columns, values) ActiveRecord::Base.connection.execute(sql) end - def insert_all(rows) - sql = <<-SQL - INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) - VALUES #{rows.join(", ")}) - ON CONFLICT (assignable_id, assignable_type, feature_id) DO NOTHING + def insert_all_query(columns, values) + <<-SQL.squish + INSERT INTO togglefy_feature_assignments (#{columns.join(", ")}) + VALUES #{values.join(", ")} SQL - - ActiveRecord::Base.connection.execute(sql) end + # rows = [ + # { assignable_id: "23b61788-657d-445a-aef5-578e96dbbcdb", assignable_type: "Account", feature_id: 1 }, + # { assignable_id: "f80fc3b8-d2b5-4fc7-9bd9-a8231244c8c0", assignable_type: "Account", feature_id: 1 } + # ] + # Togglefy::FeatureAssignment.create([rows]) + # + # def insert_all(rows) + # sql = <<-SQL + # INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) + # VALUES (#{rows.map { |row| "('#{row[:assignable_id]}', '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) + # SQL + # + # ActiveRecord::Base.connection.execute(sql) + # end + # + # def insert_all(rows) + # sql = <<-SQL + # INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) + # VALUES #{rows.join(", ")}) + # ON CONFLICT (assignable_id, assignable_type, feature_id) DO NOTHING + # SQL + # + # ActiveRecord::Base.connection.execute(sql) + # end # Disables features for assignables. # From bf6237c45950195781160dc026c291227cae8992 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 01:45:36 -0300 Subject: [PATCH 04/17] Fixes comparison --- lib/togglefy/services/bulk_toggler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 82f6d53..2415cae 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -141,7 +141,7 @@ def mass_insert(rows, identifiers) ActiveRecord::Base.transaction do # Togglefy::FeatureAssignment.insert_all(rows) - if Rails::VERSION::MAJOR => 6 + if Rails::VERSION::MAJOR >= 6 Togglefy::FeatureAssignment.insert_all(rows) else insert_all(rows) From b2ae4377e556c84e1ef669fea13f0ed9ea6f40f1 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 01:46:49 -0300 Subject: [PATCH 05/17] Fixes comparison --- lib/togglefy/services/bulk_toggler.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 2415cae..3e7d125 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -141,11 +141,7 @@ def mass_insert(rows, identifiers) ActiveRecord::Base.transaction do # Togglefy::FeatureAssignment.insert_all(rows) - if Rails::VERSION::MAJOR >= 6 - Togglefy::FeatureAssignment.insert_all(rows) - else - insert_all(rows) - end + Rails::VERSION::MAJOR >= 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all(rows) end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( From 80bd3cc1cd9880667fe8d5e1f66dba901e904d09 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 01:57:42 -0300 Subject: [PATCH 06/17] Test new insert_all --- lib/togglefy/services/bulk_toggler.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 3e7d125..d7b89e1 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -155,7 +155,8 @@ def insert_all(rows) columns = rows.first.keys values = rows.map do |row| - "(" + columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ") + ")" + "(#{columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ")})" + # "(" + columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ") + ")" end sql = insert_all_query(columns, values) From 5a874470c6b01afe7ee8e2b2cf8967610438b1f4 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 17:43:46 -0300 Subject: [PATCH 07/17] Change model location --- app/models/togglefy/feature.rb | 8 +++++--- app/models/togglefy/feature_assignment.rb | 8 +++++--- {app/models/togglefy => lib}/models/rails_5/feature.rb | 2 +- .../togglefy => lib}/models/rails_5/feature_assignment.rb | 0 {app/models/togglefy => lib}/models/rails_7/feature.rb | 2 +- .../togglefy => lib}/models/rails_7/feature_assignment.rb | 0 .../togglefy => lib}/models/rails_legacy/feature.rb | 2 +- .../models/rails_legacy/feature_assignment.rb | 0 8 files changed, 13 insertions(+), 9 deletions(-) rename {app/models/togglefy => lib}/models/rails_5/feature.rb (99%) rename {app/models/togglefy => lib}/models/rails_5/feature_assignment.rb (100%) rename {app/models/togglefy => lib}/models/rails_7/feature.rb (99%) rename {app/models/togglefy => lib}/models/rails_7/feature_assignment.rb (100%) rename {app/models/togglefy => lib}/models/rails_legacy/feature.rb (99%) rename {app/models/togglefy => lib}/models/rails_legacy/feature_assignment.rb (100%) diff --git a/app/models/togglefy/feature.rb b/app/models/togglefy/feature.rb index cbd7b47..9820893 100644 --- a/app/models/togglefy/feature.rb +++ b/app/models/togglefy/feature.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +ROOT_PATH = "../../.." + if Rails::VERSION::MAJOR >= 7 - require_relative "models/rails_7/feature" + require_relative "#{ROOT_PATH}/lib/models/rails_7/feature" elsif Rails::VERSION::MAJOR >= 5 - require_relative "models/rails_5/feature" + require_relative "#{ROOT_PATH}/lib/models/rails_5/feature" else - require_relative "models/rails_legacy/feature" + require_relative "#{ROOT_PATH}/lib/models/rails_legacy/feature" end diff --git a/app/models/togglefy/feature_assignment.rb b/app/models/togglefy/feature_assignment.rb index 778c480..850b35d 100644 --- a/app/models/togglefy/feature_assignment.rb +++ b/app/models/togglefy/feature_assignment.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +ROOT_PATH = "../../.." + if Rails::VERSION::MAJOR >= 7 - require_relative "models/rails_7/feature_assignment" + require_relative "#{ROOT_PATH}/lib/models/rails_7/feature_assignment" elsif Rails::VERSION::MAJOR >= 5 - require_relative "models/rails_5/feature_assignment" + require_relative "#{ROOT_PATH}/lib/models/rails_5/feature_assignment" else - require_relative "models/rails_legacy/feature_assignment" + require_relative "#{ROOT_PATH}/lib/models/rails_legacy/feature_assignment" end diff --git a/app/models/togglefy/models/rails_5/feature.rb b/lib/models/rails_5/feature.rb similarity index 99% rename from app/models/togglefy/models/rails_5/feature.rb rename to lib/models/rails_5/feature.rb index d7188cd..d0dd57e 100644 --- a/app/models/togglefy/models/rails_5/feature.rb +++ b/lib/models/rails_5/feature.rb @@ -89,7 +89,7 @@ def assignables_for_type(klass) end def rails_version - "5+" + ">= 5" end private diff --git a/app/models/togglefy/models/rails_5/feature_assignment.rb b/lib/models/rails_5/feature_assignment.rb similarity index 100% rename from app/models/togglefy/models/rails_5/feature_assignment.rb rename to lib/models/rails_5/feature_assignment.rb diff --git a/app/models/togglefy/models/rails_7/feature.rb b/lib/models/rails_7/feature.rb similarity index 99% rename from app/models/togglefy/models/rails_7/feature.rb rename to lib/models/rails_7/feature.rb index 0256428..b280939 100644 --- a/app/models/togglefy/models/rails_7/feature.rb +++ b/lib/models/rails_7/feature.rb @@ -89,7 +89,7 @@ def assignables_for_type(klass) end def rails_version - "7+" + ">= 7" end private diff --git a/app/models/togglefy/models/rails_7/feature_assignment.rb b/lib/models/rails_7/feature_assignment.rb similarity index 100% rename from app/models/togglefy/models/rails_7/feature_assignment.rb rename to lib/models/rails_7/feature_assignment.rb diff --git a/app/models/togglefy/models/rails_legacy/feature.rb b/lib/models/rails_legacy/feature.rb similarity index 99% rename from app/models/togglefy/models/rails_legacy/feature.rb rename to lib/models/rails_legacy/feature.rb index de7f56e..dc980d2 100644 --- a/app/models/togglefy/models/rails_legacy/feature.rb +++ b/lib/models/rails_legacy/feature.rb @@ -89,7 +89,7 @@ def assignables_for_type(klass) end def rails_version - "5-" + "< 5" end private diff --git a/app/models/togglefy/models/rails_legacy/feature_assignment.rb b/lib/models/rails_legacy/feature_assignment.rb similarity index 100% rename from app/models/togglefy/models/rails_legacy/feature_assignment.rb rename to lib/models/rails_legacy/feature_assignment.rb From 9c3a453e7684e11736a5f86176a675f2b920bc71 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 17:44:50 -0300 Subject: [PATCH 08/17] Implement insert_all for Rails older than 6 --- lib/togglefy/services/bulk_toggler.rb | 31 ++------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index d7b89e1..1101473 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -140,7 +140,6 @@ def mass_insert(rows, identifiers) return unless rows.any? ActiveRecord::Base.transaction do - # Togglefy::FeatureAssignment.insert_all(rows) Rails::VERSION::MAJOR >= 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all(rows) end rescue Togglefy::Error => e @@ -155,8 +154,7 @@ def insert_all(rows) columns = rows.first.keys values = rows.map do |row| - "(#{columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ")})" - # "(" + columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ") + ")" + "(#{columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ")}, #{ActiveRecord::Base.sanitize(Time.zone.now)}, #{ActiveRecord::Base.sanitize(Time.zone.now)})" end sql = insert_all_query(columns, values) @@ -166,36 +164,11 @@ def insert_all(rows) def insert_all_query(columns, values) <<-SQL.squish - INSERT INTO togglefy_feature_assignments (#{columns.join(", ")}) + INSERT INTO togglefy_feature_assignments (#{columns.push(:created_at, :updated_at).join(", ")}) VALUES #{values.join(", ")} SQL end - # rows = [ - # { assignable_id: "23b61788-657d-445a-aef5-578e96dbbcdb", assignable_type: "Account", feature_id: 1 }, - # { assignable_id: "f80fc3b8-d2b5-4fc7-9bd9-a8231244c8c0", assignable_type: "Account", feature_id: 1 } - # ] - # Togglefy::FeatureAssignment.create([rows]) - # - # def insert_all(rows) - # sql = <<-SQL - # INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) - # VALUES (#{rows.map { |row| "('#{row[:assignable_id]}', '#{row[:assignable_type]}', #{row[:feature_id]})" }.join(", ")}) - # SQL - # - # ActiveRecord::Base.connection.execute(sql) - # end - # - # def insert_all(rows) - # sql = <<-SQL - # INSERT INTO togglefy_feature_assignments (assignable_id, assignable_type, feature_id) - # VALUES #{rows.join(", ")}) - # ON CONFLICT (assignable_id, assignable_type, feature_id) DO NOTHING - # SQL - # - # ActiveRecord::Base.connection.execute(sql) - # end - # Disables features for assignables. # # @param assignables [Array] The assignables to update. From feacd2efcc88e9bb97737ab4eb87737da8f1d987 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 19:56:56 -0300 Subject: [PATCH 09/17] Improve assignable scope with_features --- lib/togglefy/assignable.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/togglefy/assignable.rb b/lib/togglefy/assignable.rb index ae5dbb0..adca887 100644 --- a/lib/togglefy/assignable.rb +++ b/lib/togglefy/assignable.rb @@ -16,8 +16,18 @@ module Assignable # Scope to retrieve assignables with specific features. # # @param feature_ids [Array] The IDs of the features to filter by. + # scope :with_features, lambda { |feature_ids| + # joins(:feature_assignments) + # .where(feature_assignments: { + # feature_id: feature_ids + # }) + # .distinct + # } + # + # Had to change this scope to a manual SQL join because older Rails versions was losing context/alias + # of the join, causing the where clause to not work properly. scope :with_features, lambda { |feature_ids| - joins(:feature_assignments) + joins("INNER JOIN togglefy_feature_assignments AS feature_assignments ON feature_assignments.assignable_id = #{table_name}.id AND feature_assignments.assignable_type = '#{name}'") .where(feature_assignments: { feature_id: feature_ids }) From 1cf0244cf8a8a1d5a10027717b31886225fbd33e Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 21:07:03 -0300 Subject: [PATCH 10/17] Fixes Robocop complexity offense --- lib/togglefy/services/bulk_toggler.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 1101473..d54d2b9 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -140,7 +140,7 @@ def mass_insert(rows, identifiers) return unless rows.any? ActiveRecord::Base.transaction do - Rails::VERSION::MAJOR >= 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all(rows) + Rails::VERSION::MAJOR >= 6 ? Togglefy::FeatureAssignment.insert_all(rows) : insert_all_flow(rows) end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( @@ -149,24 +149,24 @@ def mass_insert(rows, identifiers) ) end - def insert_all(rows) - return if rows.empty? - + def insert_all_flow(rows) columns = rows.first.keys values = rows.map do |row| - "(#{columns.map { |col| ActiveRecord::Base.connection.quote(row[col]) }.join(", ")}, #{ActiveRecord::Base.sanitize(Time.zone.now)}, #{ActiveRecord::Base.sanitize(Time.zone.now)})" + "(#{columns.map do |col| + ActiveRecord::Base.connection.quote(row[col]) + end.join(", ")}, #{ActiveRecord::Base.sanitize(Time.zone.now)}, #{ActiveRecord::Base.sanitize(Time.zone.now)})" end - sql = insert_all_query(columns, values) - - ActiveRecord::Base.connection.execute(sql) + insert_all(columns, values) end - def insert_all_query(columns, values) - <<-SQL.squish - INSERT INTO togglefy_feature_assignments (#{columns.push(:created_at, :updated_at).join(", ")}) - VALUES #{values.join(", ")} + def insert_all(columns, values) + sql = <<-SQL.squish + INSERT INTO togglefy_feature_assignments (#{columns.push(:created_at, :updated_at).join(", ")}) + VALUES #{values.join(", ")} SQL + + ActiveRecord::Base.connection.execute(sql) end # Disables features for assignables. From 6076604de1692d82351d7059195570d6f015087c Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 21:35:26 -0300 Subject: [PATCH 11/17] Ignore Rubocop offense --- lib/togglefy/services/bulk_toggler.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index d54d2b9..8f79a1d 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -3,7 +3,7 @@ module Togglefy # The BulkToggler class provides functionality to enable or disable features # in bulk for assignables, such as users or accounts. - class BulkToggler + class BulkToggler # rubocop:disable Metrics/ClassLength # List of allowed filters for assignables. ALLOWED_ASSIGNABLE_FILTERS = %i[group role environment env tenant_id].freeze @@ -60,7 +60,6 @@ def toggle(action, identifiers, filters) feature_ids = features.map(&:id) assignables = get_assignables(action, feature_ids) - assignables = sample_assignables(assignables, filters[:percentage]) if filters[:percentage] enable_flow(assignables, features, identifiers) if action == :enable From f7b24fb35c9d0423f7b55f7d2fe25814faa30493 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 21:39:42 -0300 Subject: [PATCH 12/17] Format --- lib/togglefy/assignable.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/togglefy/assignable.rb b/lib/togglefy/assignable.rb index adca887..eec8d5b 100644 --- a/lib/togglefy/assignable.rb +++ b/lib/togglefy/assignable.rb @@ -27,7 +27,9 @@ module Assignable # Had to change this scope to a manual SQL join because older Rails versions was losing context/alias # of the join, causing the where clause to not work properly. scope :with_features, lambda { |feature_ids| - joins("INNER JOIN togglefy_feature_assignments AS feature_assignments ON feature_assignments.assignable_id = #{table_name}.id AND feature_assignments.assignable_type = '#{name}'") + joins("INNER JOIN togglefy_feature_assignments AS feature_assignments + ON feature_assignments.assignable_id = #{table_name}.id AND + feature_assignments.assignable_type = '#{name}'") .where(feature_assignments: { feature_id: feature_ids }) From a53127fd67338005a5e48e0b8a0ea1c5d1d76845 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Thu, 22 May 2025 21:47:14 -0300 Subject: [PATCH 13/17] Update Gemfile.lock --- Gemfile.lock | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f57956a..5caec0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,19 +81,20 @@ GEM base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) - bootsnap (1.18.4) + bootsnap (1.18.6) msgpack (~> 1.2) builder (3.3.0) concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) - database_cleaner-active_record (2.2.0) + database_cleaner-active_record (2.2.1) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.4.1) - diff-lcs (1.6.1) - drb (2.2.1) + diff-lcs (1.6.2) + drb (2.2.3) + erb (5.0.1) erubi (1.13.1) globalid (1.2.1) activesupport (>= 6.1) @@ -104,11 +105,11 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.11.3) - language_server-protocol (3.17.0.4) + json (2.12.0) + language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -154,12 +155,12 @@ GEM prettyprint prettyprint (0.2.0) prism (1.4.0) - psych (5.2.3) + psych (5.2.6) date stringio racc (1.8.1) - rack (3.1.13) - rack-session (2.1.0) + rack (3.1.15) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -180,7 +181,7 @@ GEM activesupport (= 8.0.2) bundler (>= 1.15.0) railties (= 8.0.2) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -197,7 +198,8 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.13.1) + rdoc (6.14.0) + erb psych (>= 4.0.0) redcarpet (3.6.1) regexp_parser (2.10.0) @@ -212,7 +214,7 @@ GEM rspec-expectations (3.13.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.3) + rspec-mocks (3.13.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (7.1.1) @@ -224,7 +226,7 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.3) - rubocop (1.75.4) + rubocop (1.75.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -263,7 +265,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yard (0.9.37) - zeitwerk (2.7.2) + zeitwerk (2.7.3) PLATFORMS aarch64-linux-gnu From e1cdf1ab9779914911aa877708c46379231b597f Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Mon, 26 May 2025 17:38:34 -0300 Subject: [PATCH 14/17] Make analytics version proof and add feature identifier to the return --- lib/togglefy/analytics.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/togglefy/analytics.rb b/lib/togglefy/analytics.rb index d13b7ad..31acc5f 100644 --- a/lib/togglefy/analytics.rb +++ b/lib/togglefy/analytics.rb @@ -57,14 +57,14 @@ def fetch_feature # # @return [Array] Tracking data for each assignable. def build_tracking_data - assignables.filter_map do |assignable| + assignables.map do |assignable| assignable_class = safe_constantize(assignable) next unless assignable_class assignable_data = build_assignables_data(assignable_class) assignments_data = build_assignments_data - [{ assignable: assignable }, assignable_data, assignments_data].reduce(:merge) + [{ assignable: assignable, feature: @identifier }, assignable_data, assignments_data].reduce(:merge) end end From 9fd2d29a6da99cb03ceaf1505c98a4c2c1603c15 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Mon, 26 May 2025 18:19:24 -0300 Subject: [PATCH 15/17] Adds new info to the analytics documentation --- README.md | 77 ++++++++++--------- .../docs/reference/api/togglefy-analytics.mdx | 43 ++++++++++- docs/src/content/docs/usage/analytics.mdx | 58 +++++++------- lib/togglefy/analytics.rb | 2 + 4 files changed, 113 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index f15e657..e121980 100644 --- a/README.md +++ b/README.md @@ -457,32 +457,34 @@ Here’s what the result might look like: ```ruby [ - { - assignable: "User", - total: 50, - enabled_count: 20, - disabled_count: 30, - percentage_enabled: "40.0%", - percentage_disabled: "60.0%", - first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, - last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, - past_7: 9, - past_14: 11, - past_30: 0 - }, - { - assignable: "Account", - total: 4, - enabled_count: 2, - disabled_count: 0, - percentage_enabled: "100.0%", - percentage_disabled: "0.0%", - first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, - last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, - past7: 2, - past14: 0, - past30: 0 - } + { + assignable: "User", + feature: :teleportation, + total: 50, + enabled_count: 20, + disabled_count: 30, + percentage_enabled: "40.0%", + percentage_disabled: "60.0%", + first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, + last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, + past_7: 9, + past_14: 11, + past_30: 0 + }, + { + assignable: "Account", + feature: :teleportation, + total: 4, + enabled_count: 2, + disabled_count: 0, + percentage_enabled: "100.0%", + percentage_disabled: "0.0%", + first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, + last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, + past_7: 2, + past_14: 0, + past_30: 0 + } ] ``` @@ -490,17 +492,18 @@ So, how you should interpret that data? Here’s what each field means: -- `assignable`: The model that includes Togglefy::Assignable. -- `total`: Total number of assignables in your system. -- `enabled_count`: Number of assignables that have the feature enabled. -- `disabled_count`: Number of assignables that have the feature disabled. -- `percentage_enabled`: Percentage of assignables with the feature enabled. -- `percentage_disabled`: Percentage of assignables with the feature disabled. -- `first_created`: The first time the feature was toggled on for this assignable. -- `last_created`: The most recent time the feature was toggled on for this assignable. -- `past_7`: Number of times the feature was toggled on in the past 7 days. -- `past_14`: Number of times the feature was toggled on in the past 14 days. -- `past_30`: Number of times the feature was toggled on in the past 30 days. +* `assignable`: The model that includes Togglefy::Assignable. +* `feature`: The identifier of the feature being tracked (e.g., `:teleportation`). +* `total`: Total number of assignables in your system. +* `enabled_count`: Number of assignables that have the feature enabled. +* `disabled_count`: Number of assignables that have the feature disabled. +* `percentage_enabled`: Percentage of assignables with the feature enabled. +* `percentage_disabled`: Percentage of assignables with the feature disabled. +* `first_created`: The first time the feature was toggled on for this assignable. +* `last_created`: The most recent time the feature was toggled on for this assignable. +* `past_7`: Number of times the feature was toggled on in the past 7 days. +* `past_14`: Number of times the feature was toggled on in the past 14 days. +* `past_30`: Number of times the feature was toggled on in the past 30 days. ## Aliases diff --git a/docs/src/content/docs/reference/api/togglefy-analytics.mdx b/docs/src/content/docs/reference/api/togglefy-analytics.mdx index 3724f39..64997ef 100644 --- a/docs/src/content/docs/reference/api/togglefy-analytics.mdx +++ b/docs/src/content/docs/reference/api/togglefy-analytics.mdx @@ -7,7 +7,43 @@ This module provides analytics functionality for Togglefy. It analyzes feature u ## Methods -#### track +### analytics_for + +**Description** + +--- + +This method is used to create a new instance of the `Togglefy::Analytics` with a specific feature identifier and track data. + +It allows you to track and analyze the usage of that feature across different assignables. + +After creating the instance, it automatically calls the [`track`](#track) method to track the feature's usage data. + +**Parameters** + +--- + +```ruby +feature_identifier: feature_identifier # Required +``` + +**Usage Examples** + +--- + +```ruby +Togglefy.analytics_for(:feature_identifier) +``` + +**Return** + +--- + +```ruby +[Array(Hash)] +``` + +### track **Description** @@ -36,5 +72,6 @@ identifier: Symbol || String # Required --- ```ruby -[Array] -``` \ No newline at end of file +[Array(Hash)] +``` + diff --git a/docs/src/content/docs/usage/analytics.mdx b/docs/src/content/docs/usage/analytics.mdx index 1a66711..320f8f4 100644 --- a/docs/src/content/docs/usage/analytics.mdx +++ b/docs/src/content/docs/usage/analytics.mdx @@ -27,32 +27,34 @@ Here’s what the result might look like: ```ruby [ - { - assignable: "User", - total: 50, - enabled_count: 20, - disabled_count: 30, - percentage_enabled: "40.0%", - percentage_disabled: "60.0%", - first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, - last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, - past_7: 9, - past_14: 11, - past_30: 0 - }, - { - assignable: "Account", - total: 4, - enabled_count: 2, - disabled_count: 0, - percentage_enabled: "100.0%", - percentage_disabled: "0.0%", - first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, - last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, - past7: 2, - past14: 0, - past30: 0 - } + { + assignable: "User", + feature: :teleportation, + total: 50, + enabled_count: 20, + disabled_count: 30, + percentage_enabled: "40.0%", + percentage_disabled: "60.0%", + first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, + last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, + past_7: 9, + past_14: 11, + past_30: 0 + }, + { + assignable: "Account", + feature: :teleportation, + total: 4, + enabled_count: 2, + disabled_count: 0, + percentage_enabled: "100.0%", + percentage_disabled: "0.0%", + first_created: 2025-04-21 01:43:28.266664000 UTC +00:00, + last_created: 2025-05-21 19:18:37.772172000 UTC +00:00, + past_7: 2, + past_14: 0, + past_30: 0 + } ] ``` @@ -61,6 +63,7 @@ So, how you should interpret that data? Here’s what each field means: - `assignable`: The model that includes Togglefy::Assignable. +- `feature`: The identifier of the feature being tracked (e.g., `:teleportation`). - `total`: Total number of assignables in your system. - `enabled_count`: Number of assignables that have the feature enabled. - `disabled_count`: Number of assignables that have the feature disabled. @@ -70,4 +73,5 @@ Here’s what each field means: - `last_created`: The most recent time the feature was toggled on for this assignable. - `past_7`: Number of times the feature was toggled on in the past 7 days. - `past_14`: Number of times the feature was toggled on in the past 14 days. -- `past_30`: Number of times the feature was toggled on in the past 30 days. \ No newline at end of file +- `past_30`: Number of times the feature was toggled on in the past 30 days. + diff --git a/lib/togglefy/analytics.rb b/lib/togglefy/analytics.rb index 31acc5f..145d101 100644 --- a/lib/togglefy/analytics.rb +++ b/lib/togglefy/analytics.rb @@ -12,6 +12,7 @@ # # => [ # # { # # assignable: "User", +# # feature: "dark_mode", # # enabled_count: 120, # # disabled_count: 30, # # total_count: 150, @@ -35,6 +36,7 @@ def initialize(identifier = nil) # # @return [Array] An array of hashes, each containing: # - assignable type + # - feature identifier # - counts of enabled/disabled # - percentages # - assignment metadata (created_at timestamps, activity windows) From 997882d5064ed0a88922bb263687a42e3c903a66 Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Mon, 26 May 2025 22:13:45 -0300 Subject: [PATCH 16/17] YARD doc for custom insert_all for Rails < 6 --- lib/togglefy/services/bulk_toggler.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/togglefy/services/bulk_toggler.rb b/lib/togglefy/services/bulk_toggler.rb index 8f79a1d..8b16521 100644 --- a/lib/togglefy/services/bulk_toggler.rb +++ b/lib/togglefy/services/bulk_toggler.rb @@ -148,6 +148,9 @@ def mass_insert(rows, identifiers) ) end + # Build values to send to custom insert_all method for Rails versions below 6 + # + # @param rows [Array] The rows to insert. def insert_all_flow(rows) columns = rows.first.keys values = rows.map do |row| @@ -159,6 +162,10 @@ def insert_all_flow(rows) insert_all(columns, values) end + # Implements the insert_all method for Rails versions below 6. + # + # @param columns [Array] The columns to insert. + # @param values [Array] The values of the columns to insert. def insert_all(columns, values) sql = <<-SQL.squish INSERT INTO togglefy_feature_assignments (#{columns.push(:created_at, :updated_at).join(", ")}) From 604a36b56943a976377c4dfbb98e99f5841b48be Mon Sep 17 00:00:00 2001 From: Gabriel Azevedo Date: Mon, 26 May 2025 22:40:47 -0300 Subject: [PATCH 17/17] Bump gem version --- lib/togglefy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/togglefy/version.rb b/lib/togglefy/version.rb index 5a4dd53..0668cdc 100644 --- a/lib/togglefy/version.rb +++ b/lib/togglefy/version.rb @@ -2,5 +2,5 @@ module Togglefy # The VERSION constant defines the current version of the Togglefy gem. - VERSION = "1.2.1" + VERSION = "1.3.0" end