require 'set' require 'flipper' require 'sequel' require 'flipper/model/sequel' module Flipper module Adapters class Sequel include ::Flipper::Adapter begin old = ::Sequel::Model.require_valid_table ::Sequel::Model.require_valid_table = false # Private: Do not use outside of this adapter. class Feature < ::Sequel::Model(:flipper_features) unrestrict_primary_key plugin :timestamps, update_on_create: true end # Private: Do not use outside of this adapter. class Gate < ::Sequel::Model(:flipper_gates) unrestrict_primary_key plugin :timestamps, update_on_create: true end ensure ::Sequel::Model.require_valid_table = old end VALUE_TO_TEXT_WARNING = <<-EOS Your database needs migrated to use the latest Flipper features. See https://github.com/flippercloud/flipper/issues/557 EOS # Public: Initialize a new Sequel adapter instance. # # name - The Symbol name for this adapter. Optional (default :sequel) # feature_class - The AR class responsible for the features table. # gate_class - The AR class responsible for the gates table. # # Allowing the overriding of name is so you can differentiate multiple # instances of this adapter from each other, if, for some reason, that is # a thing you do. # # Allowing the overriding of the default feature/gate classes means you # can roll your own tables and what not, if you so desire. def initialize(options = {}) @name = options.fetch(:name, :sequel) @feature_class = options.fetch(:feature_class) { Feature } @gate_class = options.fetch(:gate_class) { Gate } warn VALUE_TO_TEXT_WARNING if value_not_text? end # Public: The set of known features. def features @feature_class.all.map(&:key).to_set end # Public: Adds a feature to the set of known features. def add(feature) # race condition, but add is only used by enable/disable which happen # super rarely, so it shouldn't matter in practice @feature_class.find_or_create(key: feature.key.to_s) true end # Public: Removes a feature from the set of known features. def remove(feature) @feature_class.db.transaction do @feature_class.where(key: feature.key.to_s).delete clear(feature) end true end # Public: Clears the gate values for a feature. def clear(feature) @gate_class.where(feature_key: feature.key.to_s).delete true end # Public: Gets the values for all gates for a given feature. # # Returns a Hash of Flipper::Gate#key => value. def get(feature) db_gates = @gate_class.where(feature_key: feature.key.to_s).all result_for_feature(feature, db_gates) end def get_multi(features) db_gates = @gate_class.where(feature_key: features.map(&:key)).to_a grouped_db_gates = db_gates.group_by(&:feature_key) result = {} features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end def get_all feature_table = @feature_class.table_name.to_sym gate_table = @gate_class.table_name.to_sym features_sql = @feature_class.select(:key.qualify(feature_table).as(:feature_key)) .select_append(:key.qualify(gate_table)) .select_append(:value.qualify(gate_table)) .left_join(@gate_class.table_name.to_sym, feature_key: :key) .sql db_gates = @gate_class.fetch(features_sql).to_a grouped_db_gates = db_gates.group_by(&:feature_key) result = Hash.new { |hash, key| hash[key] = default_config } features = grouped_db_gates.keys.map { |key| Flipper::Feature.new(key, self) } features.each do |feature| result[feature.key] = result_for_feature(feature, grouped_db_gates[feature.key]) end result end # Public: Enables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def enable(feature, gate, thing) case gate.data_type when :boolean set(feature, gate, thing, clear: true) when :integer set(feature, gate, thing) when :set enable_multi(feature, gate, thing) when :json set(feature, gate, thing, json: true) else unsupported_data_type gate.data_type end true end # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. # gate - The Flipper::Gate to disable. # thing - The Flipper::Type being disabled for the gate. # # Returns true. def disable(feature, gate, thing) case gate.data_type when :boolean clear(feature) when :integer set(feature, gate, thing) when :json delete(feature, gate) when :set @gate_class.where(gate_attrs(feature, gate, thing, json: gate.data_type == :json)).delete else unsupported_data_type gate.data_type end true end private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end def set(feature, gate, thing, options = {}) clear_feature = options.fetch(:clear, false) json_feature = options.fetch(:json, false) raise VALUE_TO_TEXT_WARNING if json_feature && value_not_text? @gate_class.db.transaction do clear(feature) if clear_feature delete(feature, gate) begin @gate_class.create(gate_attrs(feature, gate, thing, json: json_feature)) rescue ::Sequel::UniqueConstraintViolation end end end def delete(feature, gate) @gate_class.where(feature_key: feature.key, key: gate.key.to_s).delete end def enable_multi(feature, gate, thing) begin @gate_class.create(gate_attrs(feature, gate, thing, json: gate.data_type == :json)) rescue ::Sequel::UniqueConstraintViolation end end def gate_attrs(feature, gate, thing, json: false) { feature_key: feature.key.to_s, key: gate.key.to_s, value: json ? Typecast.to_json(thing.value) : thing.value.to_s, } end def result_for_feature(feature, db_gates) db_gates ||= [] feature.gates.each_with_object({}) do |gate, result| result[gate.key] = case gate.data_type when :boolean if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :integer if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } detected_db_gate.value end when :set db_gates.select { |db_gate| db_gate.key == gate.key.to_s }.map(&:value).to_set when :json if detected_db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } Typecast.from_json(detected_db_gate.value) end else unsupported_data_type gate.data_type end end end # Check if value column is text instead of string # See https://github.com/flippercloud/flipper/pull/692 def value_not_text? "text".casecmp(@gate_class.db_schema[:value][:db_type]) != 0 end end end end Flipper.configure do |config| config.adapter { Flipper::Adapters::Sequel.new } end Sequel::Model.include Flipper::Model::Sequel