# frozen_string_literal: true module Togglefy # The BulkToggler class provides functionality to enable or disable features # in bulk for assignables, such as users or accounts. class BulkToggler # List of allowed filters for assignables. ALLOWED_ASSIGNABLE_FILTERS = %i[group role environment env tenant_id].freeze # Initializes a new BulkToggler instance. # # @param klass [Class] The assignable class (e.g., User, Account). def initialize(klass) @klass = klass end # Enables features for assignables based on filters. # @note All parameters but the first (identifiers) should be passed as keyword arguments. # # @param identifiers [Array<String>, String] The feature identifiers to enable. # @param group [String] The group name to filter assignables by. # @param role [String] The role name to filter assignables by. # @param environment [String] The environment name to filter assignables by. # @param env [String] The environment name to filter assignables by. # @param tenant_id [String] The tenant_id to filter assignables by. # @param percentage [Integer] The percentage of assignables to include. def enable(identifiers, **filters) toggle(:enable, identifiers, filters) true end # Disables features for assignables based on filters. # @note All parameters but the first (identifiers) should be passed as keyword arguments. # # @param identifiers [Array<String>, String] The feature identifiers to disable. # @param group [String] The group name to filter assignables by. # @param role [String] The role name to filter assignables by. # @param environment [String] The environment name to filter assignables by. # @param env [String] The environment name to filter assignables by. # @param tenant_id [String] The tenant_id to filter assignables by. # @param percentage [Integer] The percentage of assignables to include. def disable(identifiers, **filters) toggle(:disable, identifiers, filters) true end private attr_reader :klass # Toggles features for assignables based on the action. # # @param action [Symbol] The action to perform (:enable or :disable). # @param identifiers [Array<String>, String] The feature identifiers. # @param filters [Hash] Additional filters for assignables. def toggle(action, identifiers, filters) identifiers = Array(identifiers) features = get_features(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 disable_flow(assignables, features, identifiers) if action == :disable end # Retrieves features based on identifiers and filters. # # @param identifiers [Array<String>] The feature identifiers. # @param filters [Hash] Additional filters for features. # @return [Array<Togglefy::Feature>] The matching features. # @raise [Togglefy::FeatureNotFound] If no features are found. def get_features(identifiers, filters) features = Togglefy.for_filters(filters: { identifier: identifiers }.merge(build_scope_filters(filters))).to_a raise Togglefy::FeatureNotFound if features.empty? features end # Retrieves assignables based on the action and feature IDs. # # @param action [Symbol] The action to perform (:enable or :disable). # @param feature_ids [Array<Integer>] The feature IDs. # @return [Array<Assignable>] The matching assignables. # @raise [Togglefy::AssignablesNotFound] If no assignables are found. def get_assignables(action, feature_ids) assignables = klass.without_features(feature_ids) if action == :enable assignables = klass.with_features(feature_ids) if action == :disable raise Togglefy::AssignablesNotFound, klass if assignables.empty? assignables end # Builds scope filters for assignables. # # @param filters [Hash] The filters to process. # @return [Hash] The processed filters. def build_scope_filters(filters) filters.slice(*ALLOWED_ASSIGNABLE_FILTERS).compact end # Samples assignables based on a percentage. # # @param assignables [Array<Assignable>] The assignables to sample. # @param percentage [Float] The percentage of assignables to include. # @return [Array<Assignable>] The sampled assignables. def sample_assignables(assignables, percentage) count = (assignables.size * percentage.to_f / 100).round assignables.sample(count) end # Enables features for assignables. # # @param assignables [Array<Assignable>] The assignables to update. # @param features [Array<Togglefy::Feature>] The features to enable. # @param identifiers [Array<String>] The feature identifiers. def enable_flow(assignables, features, identifiers) rows = [] assignables.each do |assignable| features.each do |feature| rows << { assignable_id: assignable.id, assignable_type: assignable.class.name, feature_id: feature.id } end end mass_insert(rows, identifiers) end # Inserts feature assignments in bulk. # # @param rows [Array<Hash>] The rows to insert. # @param identifiers [Array<String>] The feature identifiers. # @raise [Togglefy::BulkToggleFailed] If the bulk insert fails. def mass_insert(rows, identifiers) return unless rows.any? ActiveRecord::Base.transaction do Togglefy::FeatureAssignment.insert_all(rows) end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( "Bulk toggle enable failed for #{klass.name} with identifiers #{identifiers.inspect}", e ) end # Disables features for assignables. # # @param assignables [Array<Assignable>] The assignables to update. # @param features [Array<Togglefy::Feature>] The features to disable. # @param identifiers [Array<String>] The feature identifiers. def disable_flow(assignables, features, identifiers) ids_to_remove = [] assignables.each do |assignable| features.each do |feature| ids_to_remove << [assignable.id, feature.id] end end mass_delete(ids_to_remove, identifiers) end # Deletes feature assignments in bulk. # # @param ids_to_remove [Array<Array>] The IDs to remove. # @param identifiers [Array<String>] The feature identifiers. # @raise [Togglefy::BulkToggleFailed] If the bulk delete fails. def mass_delete(ids_to_remove, identifiers) return unless ids_to_remove.any? ActiveRecord::Base.transaction do Togglefy::FeatureAssignment.where(mass_delete_scope(ids_to_remove, klass.name)).delete_all end rescue Togglefy::Error => e raise Togglefy::BulkToggleFailed.new( "Bulk toggle disable failed for #{klass.name} with identifiers #{identifiers.inspect}", e ) end # Builds the scope for mass deletion. # # @param ids [Array<Array>] The IDs of features to delete from the assignables. # @param klass_name [String] The class name of the assignable. # @return [Hash] The scope for mass deletion to be used in the query. def mass_delete_scope(ids, klass_name) { assignable_id: ids.map(&:first), assignable_type: klass_name, feature_id: ids.map(&:last) } end end end