diff --git a/Gemfile b/Gemfile index d2d068e59..7eea98818 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ gem 'statsd-ruby', '~> 1.2.1' gem 'rspec', '~> 3.0' gem 'rack-test', '~> 0.6.3' gem 'activesupport', '~> 4.2.0' +gem 'sqlite3', '~> 1.3.11' group(:guard) do gem 'guard', '~> 2.12.5' diff --git a/flipper-active_record.gemspec b/flipper-active_record.gemspec new file mode 100644 index 000000000..3c7bd2dbd --- /dev/null +++ b/flipper-active_record.gemspec @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/flipper/version', __FILE__) + +flipper_mongo_files = lambda { |file| + file =~ /active_record/ +} + +Gem::Specification.new do |gem| + gem.authors = ["John Nunemaker"] + gem.email = ["nunemaker@gmail.com"] + gem.summary = "ActiveRecord adapter for Flipper" + gem.description = "ActiveRecord adapter for Flipper" + gem.license = "MIT" + gem.homepage = "https://github.com/jnunemaker/flipper" + + gem.files = `git ls-files`.split("\n").select(&flipper_mongo_files) + ["lib/flipper/version.rb"] + gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_mongo_files) + gem.name = "flipper-active_record" + gem.require_paths = ["lib"] + gem.version = Flipper::VERSION + + gem.add_dependency 'flipper', "~> #{Flipper::VERSION}" + gem.add_dependency 'activerecord', '~> 4.2.0' +end diff --git a/lib/flipper-active_record.rb b/lib/flipper-active_record.rb new file mode 100644 index 000000000..49a71a755 --- /dev/null +++ b/lib/flipper-active_record.rb @@ -0,0 +1 @@ +require 'flipper/adapters/active_record' diff --git a/lib/flipper/adapters/active_record.rb b/lib/flipper/adapters/active_record.rb new file mode 100644 index 000000000..dedca6859 --- /dev/null +++ b/lib/flipper/adapters/active_record.rb @@ -0,0 +1,142 @@ +require 'set' +require 'flipper' +require 'active_record' + +module Flipper + module Adapters + class ActiveRecord + include Flipper::Adapter + + class Feature < ::ActiveRecord::Base + self.table_name = "flipper_features" + end + + class Gate < ::ActiveRecord::Base + self.table_name = "flipper_gates" + end + + # Public: The name of the adapter. + attr_reader :name + + def initialize + @name = :active_record + end + + # Public: The set of known features. + def features + Feature.select(:key).map(&:key).to_set + end + + # Public: Adds a feature to the set of known features. + def add(feature) + attributes = {key: feature.key} + # race condition, but add is only used by enable/disable which happen + # super rarely, so it shouldn't matter in practice + Feature.where(attributes).first || Feature.create!(attributes) + true + end + + # Public: Removes a feature from the set of known features. + def remove(feature) + Feature.transaction do + Feature.delete_all(key: feature.key) + clear(feature) + end + true + end + + # Public: Clears the gate values for a feature. + def clear(feature) + Gate.delete_all(feature_key: feature.key) + true + end + + # Public: Gets the values for all gates for a given feature. + # + # Returns a Hash of Flipper::Gate#key => value. + def get(feature) + result = {} + + db_gates = Gate.where(feature_key: feature.key) + + feature.gates.each do |gate| + result[gate.key] = case gate.data_type + when :boolean + if db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } + db_gate.value + end + when :integer + if db_gate = db_gates.detect { |db_gate| db_gate.key == gate.key.to_s } + db_gate.value + end + when :set + db_gates.select { |db_gate| db_gate.key == gate.key.to_s }.map(&:value).to_set + else + unsupported_data_type gate.data_type + end + 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, :integer + Gate.create!({ + feature_key: feature.key, + key: gate.key, + value: thing.value.to_s, + }) + when :set + Gate.create!({ + feature_key: feature.key, + key: gate.key, + value: thing.value.to_s, + }) + 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 + Gate.create!({ + feature_key: feature.key, + key: gate.key, + value: thing.value.to_s, + }) + when :set + Gate.delete_all(feature_key: feature.key, key: gate.key, value: thing.value) + 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 + end + end +end diff --git a/spec/flipper/adapters/active_record_spec.rb b/spec/flipper/adapters/active_record_spec.rb new file mode 100644 index 000000000..36a8beb53 --- /dev/null +++ b/spec/flipper/adapters/active_record_spec.rb @@ -0,0 +1,50 @@ +require 'helper' +require 'flipper/adapters/active_record' +require 'flipper/spec/shared_adapter_specs' + +# Turn off migration logging for specs +ActiveRecord::Migration.verbose = false + +class CreateFlipperTables < ActiveRecord::Migration + def self.up + create_table :flipper_features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :flipper_features, :key, unique: true + + create_table :flipper_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.string :value + t.timestamps null: false + end + add_index :flipper_gates, [:feature_key, :key, :value], unique: true + end + + def self.down + drop_table :flipper_gates + drop_table :flipper_features + end +end + +RSpec.describe Flipper::Adapters::ActiveRecord do + subject { described_class.new } + + before(:all) do + ActiveRecord::Base.establish_connection({ + adapter: "sqlite3", + database: ":memory:", + }) + end + + before(:each) do + CreateFlipperTables.up + end + + after(:each) do + CreateFlipperTables.down + end + + it_should_behave_like 'a flipper adapter' +end