├── lib ├── ledermann-rails-settings.rb ├── rails-settings │ ├── version.rb │ ├── scopes.rb │ ├── configuration.rb │ ├── base.rb │ └── setting_object.rb ├── generators │ └── rails_settings │ │ └── migration │ │ ├── templates │ │ └── migration.rb │ │ └── migration_generator.rb └── rails-settings.rb ├── .gitignore ├── spec ├── database.yml ├── support │ ├── query_counter.rb │ └── matchers │ │ └── perform_queries.rb ├── scopes_spec.rb ├── serialize_spec.rb ├── queries_spec.rb ├── spec_helper.rb ├── configuration_spec.rb ├── setting_object_spec.rb └── settings_spec.rb ├── Gemfile ├── Rakefile ├── gemfiles ├── rails_6_1.gemfile ├── rails_7_0.gemfile ├── rails_7_1.gemfile ├── rails_7_2.gemfile └── rails_8_0.gemfile ├── MIT-LICENSE ├── .github └── workflows │ └── main.yml ├── rails-settings.gemspec └── README.md /lib/ledermann-rails-settings.rb: -------------------------------------------------------------------------------- 1 | require 'rails-settings' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | coverage/* 6 | -------------------------------------------------------------------------------- /spec/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | adapter: sqlite3 3 | database: ':memory:' 4 | -------------------------------------------------------------------------------- /lib/rails-settings/version.rb: -------------------------------------------------------------------------------- 1 | module RailsSettings 2 | VERSION = '2.6.2' 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rails-settings.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~> 6.1.2' 4 | gem "sqlite3", "~> 1.4" 5 | 6 | gemspec :path => "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~> 7.0.0' 4 | gem "sqlite3", "~> 1.4" 5 | 6 | gemspec :path => "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~> 7.1.5' 4 | gem "sqlite3", ">= 1.4" 5 | 6 | gemspec :path => "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~> 7.2.2' 4 | gem "sqlite3", ">= 1.4" 5 | 6 | gemspec :path => "../" 7 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activerecord', '~> 8.0.2' 4 | gem "sqlite3", ">= 2.1" 5 | 6 | gemspec :path => "../" 7 | -------------------------------------------------------------------------------- /spec/support/query_counter.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | class QueryCounter 3 | attr_reader :query_count 4 | 5 | def initialize 6 | @query_count = 0 7 | end 8 | 9 | def to_proc 10 | lambda(&method(:callback)) 11 | end 12 | 13 | def callback(name, start, finish, message_id, values) 14 | @query_count += 1 unless %w[CACHE SCHEMA].include?(values[:name]) || 15 | values[:sql] =~ /^begin/i || values[:sql] =~ /^commit/i 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/generators/rails_settings/migration/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class RailsSettingsMigration < ActiveRecord::Migration[5.0] 2 | def self.up 3 | create_table :settings do |t| 4 | t.string :var, null: false 5 | t.text :value 6 | t.references :target, null: false, polymorphic: true 7 | t.timestamps null: true 8 | end 9 | add_index :settings, %i[target_type target_id var], unique: true 10 | end 11 | 12 | def self.down 13 | drop_table :settings 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails-settings.rb: -------------------------------------------------------------------------------- 1 | require 'rails-settings/configuration' 2 | require 'rails-settings/base' 3 | require 'rails-settings/scopes' 4 | 5 | ActiveSupport.on_load(:active_record) do 6 | require 'rails-settings/setting_object' 7 | 8 | ActiveRecord::Base.class_eval do 9 | def self.has_settings(*args, &block) 10 | RailsSettings::Configuration.new(*args.unshift(self), &block) 11 | 12 | include RailsSettings::Base 13 | extend RailsSettings::Scopes 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/matchers/perform_queries.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :perform_queries do |expected| 2 | match { |block| query_count(&block) == expected } 3 | 4 | failure_message do |actual| 5 | "Expected to run #{expected} queries, got #{@counter.query_count}" 6 | end 7 | 8 | def query_count(&block) 9 | @counter = ActiveRecord::QueryCounter.new 10 | ActiveSupport::Notifications.subscribe( 11 | 'sql.active_record', 12 | @counter.to_proc, 13 | ) 14 | yield 15 | ActiveSupport::Notifications.unsubscribe(@counter.to_proc) 16 | 17 | @counter.query_count 18 | end 19 | 20 | def supports_block_expectations? 21 | true 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/generators/rails_settings/migration/migration_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | 4 | module RailsSettings 5 | class MigrationGenerator < Rails::Generators::Base 6 | include Rails::Generators::Migration 7 | 8 | desc 'Generates migration for rails-settings' 9 | source_root File.expand_path('../templates', __FILE__) 10 | 11 | def create_migration_file 12 | migration_template 'migration.rb', 13 | 'db/migrate/rails_settings_migration.rb' 14 | end 15 | 16 | def self.next_migration_number(dirname) 17 | if timestamped_migrations? 18 | Time.now.utc.strftime('%Y%m%d%H%M%S') 19 | else 20 | '%.3d' % (current_migration_number(dirname) + 1) 21 | end 22 | end 23 | 24 | def self.timestamped_migrations? 25 | (ActiveRecord::Base.respond_to?(:timestamped_migrations) && ActiveRecord::Base.timestamped_migrations) || 26 | (ActiveRecord.respond_to?(:timestamped_migrations) && ActiveRecord.timestamped_migrations) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails-settings/scopes.rb: -------------------------------------------------------------------------------- 1 | module RailsSettings 2 | module Scopes 3 | def with_settings 4 | result = joins("INNER JOIN settings ON #{settings_join_condition}") 5 | result.distinct 6 | end 7 | 8 | def with_settings_for(var) 9 | raise ArgumentError.new('Symbol expected!') unless var.is_a?(Symbol) 10 | joins( 11 | "INNER JOIN settings ON #{settings_join_condition} AND settings.var = '#{var}'", 12 | ) 13 | end 14 | 15 | def without_settings 16 | joins("LEFT JOIN settings ON #{settings_join_condition}").where( 17 | 'settings.id IS NULL', 18 | ) 19 | end 20 | 21 | def without_settings_for(var) 22 | raise ArgumentError.new('Symbol expected!') unless var.is_a?(Symbol) 23 | joins( 24 | "LEFT JOIN settings ON #{settings_join_condition} AND settings.var = '#{var}'", 25 | ).where('settings.id IS NULL') 26 | end 27 | 28 | def settings_join_condition 29 | "settings.target_id = #{table_name}.#{primary_key} AND 30 | settings.target_type = '#{base_class.name}'" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2024 Georg Ledermann 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby_version: 15 | - '3.2' 16 | - '3.3' 17 | - '3.4' 18 | gemfile: 19 | - gemfiles/rails_6_1.gemfile 20 | - gemfiles/rails_7_0.gemfile 21 | - gemfiles/rails_7_1.gemfile 22 | - gemfiles/rails_7_2.gemfile 23 | - gemfiles/rails_8_0.gemfile 24 | exclude: 25 | - ruby_version: '3.4' 26 | gemfile: gemfiles/rails_6_1.gemfile 27 | - ruby_version: '3.4' 28 | gemfile: gemfiles/rails_7_0.gemfile 29 | 30 | name: Ruby ${{ matrix.ruby_version }} / Gemfile ${{ matrix.gemfile }} 31 | 32 | env: 33 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 34 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Setup Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby_version }} 43 | bundler-cache: true 44 | 45 | - name: RSpec 46 | run: bundle exec rake 47 | -------------------------------------------------------------------------------- /spec/scopes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'scopes' do 4 | let!(:user1) do 5 | User.create! name: 'Mr. White' do |user| 6 | user.settings(:dashboard).theme = 'white' 7 | end 8 | end 9 | let!(:user2) { User.create! name: 'Mr. Blue' } 10 | 11 | it 'should find objects with existing settings' do 12 | expect(User.with_settings).to eq([user1]) 13 | end 14 | 15 | it 'should find objects with settings for key' do 16 | expect(User.with_settings_for(:dashboard)).to eq([user1]) 17 | expect(User.with_settings_for(:foo)).to eq([]) 18 | end 19 | 20 | it 'should records without settings' do 21 | expect(User.without_settings).to eq([user2]) 22 | end 23 | 24 | it 'should records without settings for key' do 25 | expect(User.without_settings_for(:foo)).to eq([user1, user2]) 26 | expect(User.without_settings_for(:dashboard)).to eq([user2]) 27 | end 28 | 29 | it 'should require symbol as key' do 30 | [nil, 'string', 42].each do |invalid_key| 31 | expect { User.without_settings_for(invalid_key) }.to raise_error( 32 | ArgumentError, 33 | ) 34 | expect { User.with_settings_for(invalid_key) }.to raise_error( 35 | ArgumentError, 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /rails-settings.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'rails-settings/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'ledermann-rails-settings' 8 | gem.version = RailsSettings::VERSION 9 | gem.licenses = ['MIT'] 10 | gem.authors = ['Georg Ledermann'] 11 | gem.email = ['georg@ledermann.dev'] 12 | gem.description = 'Settings gem for Ruby on Rails' 13 | gem.summary = 14 | 'Ruby gem to handle settings for ActiveRecord instances by storing them as serialized Hash in a separate database table. Namespaces and defaults included.' 15 | gem.homepage = 'https://github.com/ledermann/rails-settings' 16 | gem.required_ruby_version = '>= 3.2' 17 | 18 | gem.files = `git ls-files`.split($/) 19 | gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) } 20 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 21 | gem.require_paths = ['lib'] 22 | 23 | gem.add_dependency 'activerecord', '>= 6.1' 24 | 25 | gem.add_development_dependency 'rake' 26 | gem.add_development_dependency 'sqlite3' 27 | gem.add_development_dependency 'rspec' 28 | gem.add_development_dependency 'coveralls_reborn' 29 | gem.add_development_dependency 'simplecov', '>= 0.11.2' 30 | end 31 | -------------------------------------------------------------------------------- /spec/serialize_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Serialization' do 4 | let!(:user) do 5 | User.create! name: 'Mr. White' do |user| 6 | user.settings(:dashboard).theme = 'white' 7 | user.settings(:calendar).scope = 'all' 8 | end 9 | end 10 | 11 | describe 'created settings' do 12 | it 'should be serialized' do 13 | user.reload 14 | 15 | dashboard_settings = user.setting_objects.where(var: 'dashboard').first 16 | calendar_settings = user.setting_objects.where(var: 'calendar').first 17 | 18 | expect(dashboard_settings.var).to eq('dashboard') 19 | expect(dashboard_settings.value).to eq({ 'theme' => 'white' }) 20 | 21 | expect(calendar_settings.var).to eq('calendar') 22 | expect(calendar_settings.value).to eq({ 'scope' => 'all' }) 23 | end 24 | end 25 | 26 | describe 'updated settings' do 27 | it 'should be serialized' do 28 | user.settings(:dashboard).update! smart: true 29 | 30 | dashboard_settings = user.setting_objects.where(var: 'dashboard').first 31 | calendar_settings = user.setting_objects.where(var: 'calendar').first 32 | 33 | expect(dashboard_settings.var).to eq('dashboard') 34 | expect(dashboard_settings.value).to eq( 35 | { 'theme' => 'white', 'smart' => true }, 36 | ) 37 | 38 | expect(calendar_settings.var).to eq('calendar') 39 | expect(calendar_settings.value).to eq({ 'scope' => 'all' }) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/rails-settings/configuration.rb: -------------------------------------------------------------------------------- 1 | module RailsSettings 2 | class Configuration 3 | def initialize(*args, &block) 4 | options = args.extract_options! 5 | klass = args.shift 6 | keys = args 7 | 8 | raise ArgumentError unless klass 9 | 10 | @klass = klass 11 | 12 | if options[:persistent] 13 | unless @klass.methods.include?(:default_settings) 14 | @klass.class_attribute :default_settings 15 | end 16 | else 17 | @klass.class_attribute :default_settings 18 | end 19 | 20 | @klass.class_attribute :setting_object_class_name 21 | @klass.default_settings ||= {} 22 | @klass.setting_object_class_name = 23 | options[:class_name] || 'RailsSettings::SettingObject' 24 | 25 | if block_given? 26 | yield(self) 27 | else 28 | keys.each { |k| key(k) } 29 | end 30 | 31 | if @klass.default_settings.blank? 32 | raise ArgumentError.new('has_settings: No keys defined') 33 | end 34 | end 35 | 36 | def key(name, options = {}) 37 | unless name.is_a?(Symbol) 38 | raise ArgumentError.new( 39 | "has_settings: Symbol expected, but got a #{name.class}", 40 | ) 41 | end 42 | unless options.blank? || (options.keys == [:defaults]) 43 | raise ArgumentError.new( 44 | "has_settings: Option :defaults expected, but got #{options.keys.join(', ')}", 45 | ) 46 | end 47 | @klass.default_settings[name] = ( 48 | options[:defaults] || {} 49 | ).stringify_keys.freeze 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rails-settings/base.rb: -------------------------------------------------------------------------------- 1 | module RailsSettings 2 | module Base 3 | def self.included(base) 4 | base.class_eval do 5 | has_many :setting_objects, 6 | as: :target, 7 | autosave: true, 8 | dependent: :delete_all, 9 | class_name: self.setting_object_class_name 10 | 11 | def settings(var) 12 | raise ArgumentError unless var.is_a?(Symbol) 13 | unless self.class.default_settings[var] 14 | raise ArgumentError.new("Unknown key: #{var}") 15 | end 16 | 17 | setting_objects.detect { |s| s.var == var.to_s } || 18 | setting_objects.build(var: var.to_s, target: self) 19 | end 20 | 21 | def settings=(value) 22 | if value.nil? 23 | setting_objects.each(&:mark_for_destruction) 24 | else 25 | raise ArgumentError 26 | end 27 | end 28 | 29 | def settings?(var = nil) 30 | if var.nil? 31 | setting_objects.any? do |setting_object| 32 | !setting_object.marked_for_destruction? && 33 | setting_object.value.present? 34 | end 35 | else 36 | settings(var).value.present? 37 | end 38 | end 39 | 40 | def to_settings_hash 41 | settings_hash = self.class.default_settings.dup 42 | settings_hash.each do |var, vals| 43 | settings_hash[var] = settings_hash[var].merge( 44 | settings(var.to_sym).value, 45 | ) 46 | end 47 | settings_hash 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rails-settings/setting_object.rb: -------------------------------------------------------------------------------- 1 | module RailsSettings 2 | class SettingObject < ActiveRecord::Base 3 | self.table_name = 'settings' 4 | 5 | belongs_to :target, polymorphic: true 6 | 7 | validates_presence_of :var, :target_type 8 | validate do 9 | errors.add(:value, 'Invalid setting value') unless value.is_a? Hash 10 | 11 | unless _target_class.default_settings[var.to_sym] 12 | errors.add(:var, "#{var} is not defined!") 13 | end 14 | end 15 | 16 | if ActiveRecord.version >= Gem::Version.new('7.1.0.beta1') 17 | serialize :value, type: Hash 18 | else 19 | serialize :value, Hash 20 | end 21 | 22 | REGEX_SETTER = /\A([a-z]\w*)=\Z/i 23 | REGEX_GETTER = /\A([a-z]\w*)\Z/i 24 | 25 | def respond_to?(method_name, include_priv = false) 26 | super || method_name.to_s =~ REGEX_SETTER || _setting?(method_name) 27 | end 28 | 29 | def method_missing(method_name, *args, &block) 30 | if block_given? 31 | super 32 | else 33 | if attribute_names.include?(method_name.to_s.sub('=', '')) 34 | super 35 | elsif method_name.to_s =~ REGEX_SETTER && args.size == 1 36 | _set_value($1, args.first) 37 | elsif method_name.to_s =~ REGEX_GETTER && args.size == 0 38 | _get_value($1) 39 | else 40 | super 41 | end 42 | end 43 | end 44 | 45 | protected 46 | 47 | private 48 | 49 | def _get_value(name) 50 | if value[name].nil? 51 | default_value = _get_default_value(name) 52 | _deep_dup(default_value) 53 | else 54 | value[name] 55 | end 56 | end 57 | 58 | def _get_default_value(name) 59 | default_value = _target_class.default_settings[var.to_sym][name] 60 | 61 | if default_value.respond_to?(:call) 62 | default_value.call(target) 63 | else 64 | default_value 65 | end 66 | end 67 | 68 | def _deep_dup(nested_hashes_and_or_arrays) 69 | Marshal.load(Marshal.dump(nested_hashes_and_or_arrays)) 70 | end 71 | 72 | def _set_value(name, v) 73 | if value[name] != v 74 | value_will_change! 75 | 76 | if v.nil? 77 | value.delete(name) 78 | else 79 | value[name] = v 80 | end 81 | end 82 | end 83 | 84 | def _target_class 85 | target_type.constantize 86 | end 87 | 88 | def _setting?(method_name) 89 | _target_class.default_settings[var.to_sym].keys.include?(method_name.to_s) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/queries_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Queries performed' do 4 | context 'New record' do 5 | let!(:user) { User.new name: 'Mr. Pink' } 6 | 7 | it 'should be saved by one SQL query' do 8 | expect { user.save! }.to perform_queries(1) 9 | end 10 | 11 | it 'should be saved with settings for one key by two SQL queries' do 12 | expect { 13 | user.settings(:dashboard).foo = 42 14 | user.settings(:dashboard).bar = 'string' 15 | user.save! 16 | }.to perform_queries(2) 17 | end 18 | 19 | it 'should be saved with settings for two keys by three SQL queries' do 20 | expect { 21 | user.settings(:dashboard).foo = 42 22 | user.settings(:dashboard).bar = 'string' 23 | user.settings(:calendar).bar = 'string' 24 | user.save! 25 | }.to perform_queries(3) 26 | end 27 | end 28 | 29 | context 'Existing record without settings' do 30 | let!(:user) { User.create! name: 'Mr. Pink' } 31 | 32 | it 'should be saved without SQL queries' do 33 | expect { user.save! }.to perform_queries(0) 34 | end 35 | 36 | it 'should be saved with settings for one key by two SQL queries' do 37 | expect { 38 | user.settings(:dashboard).foo = 42 39 | user.settings(:dashboard).bar = 'string' 40 | user.save! 41 | }.to perform_queries(2) 42 | end 43 | 44 | it 'should be saved with settings for two keys by three SQL queries' do 45 | expect { 46 | user.settings(:dashboard).foo = 42 47 | user.settings(:dashboard).bar = 'string' 48 | user.settings(:calendar).bar = 'string' 49 | user.save! 50 | }.to perform_queries(3) 51 | end 52 | end 53 | 54 | context 'Existing record with settings' do 55 | let!(:user) do 56 | User.create! name: 'Mr. Pink' do |user| 57 | user.settings(:dashboard).theme = 'pink' 58 | user.settings(:calendar).scope = 'all' 59 | end 60 | end 61 | 62 | it 'should be saved without SQL queries' do 63 | expect { user.save! }.to perform_queries(0) 64 | end 65 | 66 | it 'should be saved with settings for one key by one SQL queries' do 67 | expect { 68 | user.settings(:dashboard).foo = 42 69 | user.settings(:dashboard).bar = 'string' 70 | user.save! 71 | }.to perform_queries(1) 72 | end 73 | 74 | it 'should be saved with settings for two keys by two SQL queries' do 75 | expect { 76 | user.settings(:dashboard).foo = 42 77 | user.settings(:dashboard).bar = 'string' 78 | user.settings(:calendar).bar = 'string' 79 | user.save! 80 | }.to perform_queries(2) 81 | end 82 | 83 | it 'should be destroyed by two SQL queries' do 84 | expect { user.destroy }.to perform_queries(2) 85 | end 86 | 87 | it 'should update settings by one SQL query' do 88 | expect { 89 | user.settings(:dashboard).update! foo: 'bar' 90 | }.to perform_queries(1) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'coveralls' 3 | 4 | SimpleCov.formatter = 5 | SimpleCov::Formatter::MultiFormatter.new( 6 | [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter], 7 | ) 8 | SimpleCov.start { add_filter '/spec/' } 9 | 10 | # Requires supporting ruby files with custom matchers and macros, etc, 11 | # in spec/support/ and its subdirectories. 12 | Dir[ 13 | File.expand_path(File.join(File.dirname(__FILE__), 'support', '**', '*.rb')) 14 | ].each { |f| require f } 15 | 16 | # This file was generated by the `rspec --init` command. Conventionally, all 17 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 18 | # Require this file using `require "spec_helper"` to ensure that it is only 19 | # loaded once. 20 | # 21 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 22 | RSpec.configure do |config| 23 | # Run specs in random order to surface order dependencies. If you find an 24 | # order dependency and want to debug it, you can fix the order by providing 25 | # the seed, which is printed after each run. 26 | # --seed 1234 27 | # config.order = 'random' 28 | 29 | config.before(:each) { clear_db } 30 | 31 | config.after :suite do 32 | RailsSettingsMigration.migrate(:down) 33 | end 34 | end 35 | 36 | # Ensure stdlib Logger is loaded before ActiveSupport (needed for Rails < 7.1 on Ruby 3.2+) 37 | require 'logger' 38 | require 'active_record' 39 | require 'protected_attributes' if ENV['PROTECTED_ATTRIBUTES'] == 'true' 40 | require 'rails-settings' 41 | 42 | if I18n.respond_to?(:enforce_available_locales=) 43 | I18n.enforce_available_locales = false 44 | end 45 | 46 | class User < ActiveRecord::Base 47 | has_settings do |s| 48 | s.key :dashboard, 49 | defaults: { 50 | theme: 'blue', 51 | view: 'monthly', 52 | a: 'b', 53 | filter: true, 54 | owner_name: ->(target) { target.name }, 55 | } 56 | s.key :calendar, defaults: { scope: 'company', events: [], profile: {} } 57 | end 58 | end 59 | 60 | class GuestUser < User 61 | has_settings do |s| 62 | s.key :dashboard, defaults: { theme: 'red', view: 'monthly', filter: true } 63 | end 64 | end 65 | 66 | class Account < ActiveRecord::Base 67 | has_settings :portal 68 | end 69 | 70 | class Project < ActiveRecord::Base 71 | has_settings :info, class_name: 'ProjectSettingObject' 72 | end 73 | 74 | class ProjectSettingObject < RailsSettings::SettingObject 75 | validate do 76 | unless self.owner_name.present? && self.owner_name.is_a?(String) 77 | errors.add(:base, 'Owner name is missing') 78 | end 79 | end 80 | end 81 | 82 | def setup_db 83 | ActiveRecord::Base.configurations = 84 | YAML.load_file(File.dirname(__FILE__) + '/database.yml') 85 | ActiveRecord::Base.establish_connection(:sqlite) 86 | ActiveRecord::Migration.verbose = false 87 | 88 | print "Testing with ActiveRecord #{ActiveRecord::VERSION::STRING}" 89 | puts 90 | 91 | require File.expand_path( 92 | '../../lib/generators/rails_settings/migration/templates/migration.rb', 93 | __FILE__, 94 | ) 95 | RailsSettingsMigration.migrate(:up) 96 | 97 | ActiveRecord::Schema.define(version: 1) do 98 | create_table :users do |t| 99 | t.string :type 100 | t.string :name 101 | end 102 | 103 | create_table :accounts do |t| 104 | t.string :subdomain 105 | end 106 | 107 | create_table :projects do |t| 108 | t.string :name 109 | end 110 | end 111 | end 112 | 113 | def clear_db 114 | User.delete_all 115 | Account.delete_all 116 | RailsSettings::SettingObject.delete_all 117 | end 118 | 119 | setup_db 120 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module RailsSettings 4 | class Dummy 5 | end 6 | 7 | describe Configuration, 'successful' do 8 | it 'should define single key' do 9 | Configuration.new(Dummy, :dashboard) 10 | 11 | expect(Dummy.default_settings).to eq({ dashboard: {} }) 12 | expect(Dummy.setting_object_class_name).to eq( 13 | 'RailsSettings::SettingObject', 14 | ) 15 | end 16 | 17 | it 'should define multiple keys' do 18 | Configuration.new(Dummy, :dashboard, :calendar) 19 | 20 | expect(Dummy.default_settings).to eq({ dashboard: {}, calendar: {} }) 21 | expect(Dummy.setting_object_class_name).to eq( 22 | 'RailsSettings::SettingObject', 23 | ) 24 | end 25 | 26 | it 'should define single key with class_name' do 27 | Configuration.new(Dummy, :dashboard, class_name: 'MyClass') 28 | expect(Dummy.default_settings).to eq({ dashboard: {} }) 29 | expect(Dummy.setting_object_class_name).to eq('MyClass') 30 | end 31 | 32 | it 'should define multiple keys with class_name' do 33 | Configuration.new(Dummy, :dashboard, :calendar, class_name: 'MyClass') 34 | 35 | expect(Dummy.default_settings).to eq({ dashboard: {}, calendar: {} }) 36 | expect(Dummy.setting_object_class_name).to eq('MyClass') 37 | end 38 | 39 | it 'should define using block' do 40 | Configuration.new(Dummy) do |c| 41 | c.key :dashboard 42 | c.key :calendar 43 | end 44 | 45 | expect(Dummy.default_settings).to eq({ dashboard: {}, calendar: {} }) 46 | expect(Dummy.setting_object_class_name).to eq( 47 | 'RailsSettings::SettingObject', 48 | ) 49 | end 50 | 51 | it 'should define using block with defaults' do 52 | Configuration.new(Dummy) do |c| 53 | c.key :dashboard, defaults: { theme: 'red' } 54 | c.key :calendar, defaults: { scope: 'all' } 55 | end 56 | 57 | expect(Dummy.default_settings).to eq( 58 | { dashboard: { 'theme' => 'red' }, calendar: { 'scope' => 'all' } }, 59 | ) 60 | expect(Dummy.setting_object_class_name).to eq( 61 | 'RailsSettings::SettingObject', 62 | ) 63 | end 64 | 65 | it 'should define using block and class_name' do 66 | Configuration.new(Dummy, class_name: 'MyClass') do |c| 67 | c.key :dashboard 68 | c.key :calendar 69 | end 70 | 71 | expect(Dummy.default_settings).to eq({ dashboard: {}, calendar: {} }) 72 | expect(Dummy.setting_object_class_name).to eq('MyClass') 73 | end 74 | 75 | context 'persistent' do 76 | it 'should keep settings between multiple configurations initialization' do 77 | Configuration.new(Dummy, persistent: true) do |c| 78 | c.key :dashboard, defaults: { theme: 'red' } 79 | end 80 | 81 | Configuration.new(Dummy, :calendar, persistent: true) 82 | 83 | expect(Dummy.default_settings).to eq( 84 | { dashboard: { 'theme' => 'red' }, calendar: {} }, 85 | ) 86 | end 87 | end 88 | end 89 | 90 | describe Configuration, 'failure' do 91 | it 'should fail without args' do 92 | expect { Configuration.new }.to raise_error(ArgumentError) 93 | end 94 | 95 | it 'should fail without keys' do 96 | expect { Configuration.new(Dummy) }.to raise_error(ArgumentError) 97 | end 98 | 99 | it 'should fail without keys in block' do 100 | expect { Configuration.new(Dummy) { |c| } }.to raise_error(ArgumentError) 101 | end 102 | 103 | it 'should fail with keys not being symbols' do 104 | expect { Configuration.new(Dummy, 42, 'string') }.to raise_error( 105 | ArgumentError, 106 | ) 107 | end 108 | 109 | it 'should fail with keys not being symbols' do 110 | expect { 111 | Configuration.new(Dummy) { |c| c.key 42, 'string' } 112 | }.to raise_error(ArgumentError) 113 | end 114 | 115 | it 'should fail with unknown option' do 116 | expect { 117 | Configuration.new(Dummy) { |c| c.key :dashboard, foo: {} } 118 | }.to raise_error(ArgumentError) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Settings for Rails 2 | 3 | [![Build Status](https://github.com/ledermann/rails-settings/actions/workflows/main.yml/badge.svg)](https://github.com/ledermann/rails-settings/actions) 4 | [![Code Climate](https://codeclimate.com/github/ledermann/rails-settings.svg)](https://codeclimate.com/github/ledermann/rails-settings) 5 | [![Coverage Status](https://coveralls.io/repos/ledermann/rails-settings/badge.svg?branch=master)](https://coveralls.io/r/ledermann/rails-settings?branch=master) 6 | 7 | Ruby gem to handle settings for ActiveRecord instances by storing them as serialized Hash in a separate database table. Namespaces and defaults included. 8 | 9 | ## Requirements 10 | 11 | - Ruby 3.2 or newer 12 | - Rails 6.1 or newer (including Rails 8.0) 13 | 14 | ## Installation 15 | 16 | Include the gem in your Gemfile and run `bundle` to install it: 17 | 18 | ```ruby 19 | gem 'ledermann-rails-settings' 20 | ``` 21 | 22 | Generate and run the migration: 23 | 24 | ```shell 25 | rails g rails_settings:migration 26 | rake db:migrate 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Define settings 32 | 33 | ```ruby 34 | class User < ActiveRecord::Base 35 | has_settings do |s| 36 | s.key :dashboard, :defaults => { :theme => 'blue', :view => 'monthly', :filter => false } 37 | s.key :calendar, :defaults => { :scope => 'company'} 38 | end 39 | end 40 | ``` 41 | 42 | If no defaults are needed, a simplified syntax can be used: 43 | 44 | ```ruby 45 | class User < ActiveRecord::Base 46 | has_settings :dashboard, :calendar 47 | end 48 | ``` 49 | 50 | Every setting is handled by the class `RailsSettings::SettingObject`. You can use your own class, e.g. for validations: 51 | 52 | ```ruby 53 | class Project < ActiveRecord::Base 54 | has_settings :info, :class_name => 'ProjectSettingObject' 55 | end 56 | 57 | class ProjectSettingObject < RailsSettings::SettingObject 58 | validate do 59 | unless self.owner_name.present? && self.owner_name.is_a?(String) 60 | errors.add(:base, "Owner name is missing") 61 | end 62 | end 63 | end 64 | ``` 65 | 66 | In case you need to define settings separatedly for the same models, you can use the persistent option 67 | 68 | ```ruby 69 | module UserDashboardConcern 70 | extend ActiveSupport::Concern 71 | 72 | included do 73 | has_settings persistent: true do |s| 74 | s.key :dashboard 75 | end 76 | end 77 | end 78 | 79 | class User < ActiveRecord::Base 80 | has_settings persistent: true do |s| 81 | s.key :calendar 82 | end 83 | end 84 | ``` 85 | 86 | ### Set settings 87 | 88 | ```ruby 89 | user = User.find(1) 90 | user.settings(:dashboard).theme = 'black' 91 | user.settings(:calendar).scope = 'all' 92 | user.settings(:calendar).display = 'daily' 93 | user.save! # saves new or changed settings, too 94 | ``` 95 | 96 | or 97 | 98 | ```ruby 99 | user = User.find(1) 100 | user.settings(:dashboard).update! :theme => 'black' 101 | user.settings(:calendar).update! :scope => 'all', :display => 'daily' 102 | ``` 103 | 104 | ### Get settings 105 | 106 | ```ruby 107 | user = User.find(1) 108 | user.settings(:dashboard).theme 109 | # => 'black 110 | 111 | user.settings(:dashboard).view 112 | # => 'monthly' (it's the default) 113 | 114 | user.settings(:calendar).scope 115 | # => 'all' 116 | ``` 117 | 118 | ### Delete settings 119 | 120 | ```ruby 121 | user = User.find(1) 122 | user.settings(:dashboard).update! :theme => nil 123 | 124 | user.settings(:dashboard).view = nil 125 | user.settings(:dashboard).save! 126 | ``` 127 | 128 | ### Using scopes 129 | 130 | ```ruby 131 | User.with_settings 132 | # => all users having any setting 133 | 134 | User.without_settings 135 | # => all users without having any setting 136 | 137 | User.with_settings_for(:calendar) 138 | # => all users having a setting for 'calendar' 139 | 140 | User.without_settings_for(:calendar) 141 | # => all users without having settings for 'calendar' 142 | ``` 143 | 144 | ### Eager Loading 145 | 146 | ```ruby 147 | User.includes(:setting_objects) 148 | # => Eager load setting_objects when querying many users 149 | ``` 150 | 151 | ## Compatibility 152 | 153 | Version 2 is a complete rewrite and has a new DSL, so it's **not** compatible with Version 1. In addition, Rails 2.3 is not supported anymore. But the database schema is unchanged, so you can continue to use the data created by 1.x, no conversion is needed. 154 | 155 | If you don't want to upgrade, you find the old version in the [1.x](https://github.com/ledermann/rails-settings/commits/1.x) branch. But don't expect any updates there. 156 | 157 | ## Changelog 158 | 159 | See https://github.com/ledermann/rails-settings/releases 160 | 161 | ## License 162 | 163 | MIT License 164 | 165 | Copyright (c) 2012-2024 [Georg Ledermann](https://ledermann.dev) 166 | 167 | This gem is a complete rewrite of [rails-settings](https://github.com/Squeegy/rails-settings) by [Alex Wayne](https://github.com/Squeegy) 168 | -------------------------------------------------------------------------------- /spec/setting_object_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe RailsSettings::SettingObject do 4 | let(:user) { User.create! name: 'Mr. Pink' } 5 | 6 | let(:new_setting_object) do 7 | user.setting_objects.build({ var: 'dashboard' }) 8 | end 9 | let(:saved_setting_object) do 10 | user.setting_objects.create!( 11 | { var: 'dashboard', value: { 'theme' => 'pink', 'filter' => false } }, 12 | ) 13 | end 14 | 15 | describe 'serialization' do 16 | it 'should have a hash default' do 17 | expect(RailsSettings::SettingObject.new.value).to eq({}) 18 | end 19 | end 20 | 21 | describe 'Getter and Setter' do 22 | context 'on unsaved settings' do 23 | it 'should respond to setters' do 24 | expect(new_setting_object).to respond_to(:foo=) 25 | expect(new_setting_object).to respond_to(:bar=) 26 | expect(new_setting_object).to respond_to(:x=) 27 | end 28 | 29 | it 'should not respond to some getters' do 30 | expect { new_setting_object.foo! }.to raise_error(NoMethodError) 31 | expect { new_setting_object.foo? }.to raise_error(NoMethodError) 32 | end 33 | 34 | it 'should not respond if a block is given' do 35 | expect { new_setting_object.foo {} }.to raise_error(NoMethodError) 36 | end 37 | 38 | it 'should not respond if params are given' do 39 | expect { new_setting_object.foo(42) }.to raise_error(NoMethodError) 40 | expect { new_setting_object.foo(42, 43) }.to raise_error(NoMethodError) 41 | end 42 | 43 | it 'should return nil for unknown attribute' do 44 | expect(new_setting_object.foo).to eq(nil) 45 | expect(new_setting_object.bar).to eq(nil) 46 | expect(new_setting_object.c).to eq(nil) 47 | end 48 | 49 | it 'should return defaults' do 50 | expect(new_setting_object.theme).to eq('blue') 51 | expect(new_setting_object.view).to eq('monthly') 52 | expect(new_setting_object.filter).to eq(true) 53 | expect(new_setting_object.a).to eq('b') 54 | end 55 | 56 | it 'should return defaults when using `try`' do 57 | expect(new_setting_object.try(:theme)).to eq('blue') 58 | expect(new_setting_object.try(:view)).to eq('monthly') 59 | expect(new_setting_object.try(:filter)).to eq(true) 60 | end 61 | 62 | it 'should return value from target method if proc is a default value' do 63 | expect(new_setting_object.owner_name).to eq('Mr. Pink') 64 | end 65 | 66 | it 'should store different objects to value hash' do 67 | new_setting_object.integer = 42 68 | new_setting_object.float = 1.234 69 | new_setting_object.string = 'Hello, World!' 70 | new_setting_object.array = [1, 2, 3] 71 | new_setting_object.symbol = :foo 72 | 73 | expect(new_setting_object.value).to eq( 74 | 'integer' => 42, 75 | 'float' => 1.234, 76 | 'string' => 'Hello, World!', 77 | 'array' => [1, 2, 3], 78 | 'symbol' => :foo, 79 | ) 80 | end 81 | 82 | it 'should set and return attributes' do 83 | new_setting_object.theme = 'pink' 84 | new_setting_object.foo = 42 85 | new_setting_object.bar = 'hello' 86 | 87 | expect(new_setting_object.theme).to eq('pink') 88 | expect(new_setting_object.foo).to eq(42) 89 | expect(new_setting_object.bar).to eq('hello') 90 | end 91 | 92 | it 'should set dirty trackers on change' do 93 | new_setting_object.theme = 'pink' 94 | expect(new_setting_object).to be_value_changed 95 | expect(new_setting_object).to be_changed 96 | end 97 | end 98 | 99 | context 'on saved settings' do 100 | it 'should not set dirty trackers on setting same value' do 101 | saved_setting_object.theme = 'pink' 102 | expect(saved_setting_object).not_to be_value_changed 103 | expect(saved_setting_object).not_to be_changed 104 | end 105 | 106 | it 'should delete key on assigning nil' do 107 | saved_setting_object.theme = nil 108 | expect(saved_setting_object.value).to eq({ 'filter' => false }) 109 | end 110 | end 111 | end 112 | 113 | describe 'update' do 114 | it 'should save' do 115 | expect(new_setting_object.update(foo: 42, bar: 'string')).to be_truthy 116 | new_setting_object.reload 117 | 118 | expect(new_setting_object.foo).to eq(42) 119 | expect(new_setting_object.bar).to eq('string') 120 | expect(new_setting_object).not_to be_new_record 121 | expect(new_setting_object.id).not_to be_zero 122 | end 123 | 124 | it 'should not save blank hash' do 125 | expect(new_setting_object.update({})).to be_truthy 126 | end 127 | end 128 | 129 | describe 'save' do 130 | it 'should save' do 131 | new_setting_object.foo = 42 132 | new_setting_object.bar = 'string' 133 | expect(new_setting_object.save).to be_truthy 134 | new_setting_object.reload 135 | 136 | expect(new_setting_object.foo).to eq(42) 137 | expect(new_setting_object.bar).to eq('string') 138 | expect(new_setting_object).not_to be_new_record 139 | expect(new_setting_object.id).not_to be_zero 140 | end 141 | end 142 | 143 | describe 'validation' do 144 | it 'should not validate for unknown var' do 145 | new_setting_object.var = 'unknown-var' 146 | 147 | expect(new_setting_object).not_to be_valid 148 | expect(new_setting_object.errors[:var]).to be_present 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/settings_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Defaults' do 4 | it 'should be stored for simple class' do 5 | expect(Account.default_settings).to eq(portal: {}) 6 | end 7 | 8 | # Modifying spec because it gives like this output on hash value 9 | # "owner_name"=># 10 | it 'should be stored for parent class' do 11 | expect(User.default_settings.keys).to eq(%i[dashboard calendar]) 12 | end 13 | 14 | it 'should be stored for child class' do 15 | expect(GuestUser.default_settings).to eq( 16 | dashboard: { 17 | 'theme' => 'red', 18 | 'view' => 'monthly', 19 | 'filter' => true, 20 | }, 21 | ) 22 | end 23 | end 24 | 25 | describe 'Getter/Setter' do 26 | let(:account) { Account.new subdomain: 'foo' } 27 | 28 | it 'should handle method syntax' do 29 | account.settings(:portal).enabled = true 30 | account.settings(:portal).template = 'black' 31 | 32 | expect(account.settings(:portal).enabled).to eq(true) 33 | expect(account.settings(:portal).template).to eq('black') 34 | end 35 | 36 | it 'should return nil for not existing key' do 37 | expect(account.settings(:portal).foo).to eq(nil) 38 | end 39 | end 40 | 41 | describe 'Objects' do 42 | context 'without defaults' do 43 | let(:account) { Account.new subdomain: 'foo' } 44 | 45 | it 'should have blank settings' do 46 | expect(account.settings(:portal).value).to eq({}) 47 | end 48 | 49 | it 'should allow saving a blank value' do 50 | account.save! 51 | expect(account.settings(:portal).save).to be_truthy 52 | end 53 | 54 | it 'should allow removing all values' do 55 | account.settings(:portal).premium = true 56 | account.settings(:portal).fee = 42.5 57 | account.save! 58 | 59 | account.settings(:portal).premium = nil 60 | expect(account.save).to be_truthy 61 | 62 | account.settings(:portal).fee = nil 63 | expect(account.save).to be_truthy 64 | end 65 | 66 | it 'should not add settings on saving' do 67 | account.save! 68 | expect(RailsSettings::SettingObject.count).to eq(0) 69 | end 70 | 71 | it 'should save object with settings' do 72 | account.settings(:portal).premium = true 73 | account.settings(:portal).fee = 42.5 74 | account.save! 75 | 76 | account.reload 77 | expect(account.settings(:portal).premium).to eq(true) 78 | expect(account.settings(:portal).fee).to eq(42.5) 79 | 80 | expect(RailsSettings::SettingObject.count).to eq(1) 81 | expect(RailsSettings::SettingObject.first.value).to eq( 82 | { 'premium' => true, 'fee' => 42.5 }, 83 | ) 84 | end 85 | 86 | it 'should save settings separated' do 87 | account.save! 88 | 89 | settings = account.settings(:portal) 90 | settings.enabled = true 91 | settings.template = 'black' 92 | settings.save! 93 | 94 | account.reload 95 | expect(account.settings(:portal).enabled).to eq(true) 96 | expect(account.settings(:portal).template).to eq('black') 97 | end 98 | end 99 | 100 | context 'with defaults' do 101 | let(:user) { User.new name: 'Mr. Brown' } 102 | 103 | it 'should have default settings' do 104 | expect(user.settings(:dashboard).theme).to eq('blue') 105 | expect(user.settings(:dashboard).view).to eq('monthly') 106 | expect(user.settings(:dashboard).owner_name).to eq('Mr. Brown') 107 | expect(user.settings(:dashboard).filter).to eq(true) 108 | expect(user.settings(:calendar).scope).to eq('company') 109 | end 110 | 111 | it 'should have default settings after changing one' do 112 | user.settings(:dashboard).theme = 'gray' 113 | 114 | expect(user.settings(:dashboard).theme).to eq('gray') 115 | expect(user.settings(:dashboard).view).to eq('monthly') 116 | expect(user.settings(:dashboard).owner_name).to eq('Mr. Brown') 117 | expect(user.settings(:dashboard).filter).to eq(true) 118 | expect(user.settings(:calendar).scope).to eq('company') 119 | end 120 | 121 | it 'should overwrite settings' do 122 | user.settings(:dashboard).theme = 'brown' 123 | user.settings(:dashboard).filter = false 124 | user.settings(:dashboard).owner_name = 'Mr. Vishal' 125 | user.save! 126 | 127 | user.reload 128 | expect(user.settings(:dashboard).theme).to eq('brown') 129 | expect(user.settings(:dashboard).filter).to eq(false) 130 | expect(user.settings(:dashboard).owner_name).to eq('Mr. Vishal') 131 | expect(RailsSettings::SettingObject.count).to eq(1) 132 | expect(RailsSettings::SettingObject.first.value).to eq( 133 | { 'filter' => false, 'owner_name' => 'Mr. Vishal', 'theme' => 'brown' }, 134 | ) 135 | end 136 | 137 | it 'should merge settings with defaults' do 138 | user.settings(:dashboard).theme = 'brown' 139 | user.save! 140 | 141 | user.reload 142 | expect(user.settings(:dashboard).theme).to eq('brown') 143 | expect(user.settings(:dashboard).filter).to eq(true) 144 | expect(RailsSettings::SettingObject.count).to eq(1) 145 | expect(RailsSettings::SettingObject.first.value).to eq( 146 | { 'theme' => 'brown' }, 147 | ) 148 | end 149 | 150 | context 'when default value is an Array' do 151 | it 'should not mutate default_settings' do 152 | expected_return_value = User.default_settings[:calendar]['events'].dup 153 | 154 | user.settings(:calendar).events.push('new_value') 155 | expect(User.default_settings[:calendar]['events']).to eq( 156 | expected_return_value, 157 | ) 158 | end 159 | end 160 | 161 | context 'when default value is a Hash' do 162 | it 'should not mutate default_settings' do 163 | expected_return_value = User.default_settings[:calendar]['profile'].dup 164 | 165 | user.settings(:calendar).profile.update('new_key' => 'new_value') 166 | expect(User.default_settings[:calendar]['profile']).to eq( 167 | expected_return_value, 168 | ) 169 | end 170 | end 171 | end 172 | end 173 | 174 | describe 'Object without settings' do 175 | let!(:user) { User.create! name: 'Mr. White' } 176 | 177 | it 'should respond to #settings?' do 178 | expect(user.settings?).to eq(false) 179 | expect(user.settings?(:dashboard)).to eq(false) 180 | end 181 | 182 | it 'should have no setting objects' do 183 | expect(RailsSettings::SettingObject.count).to eq(0) 184 | end 185 | 186 | it 'should add settings' do 187 | user.settings(:dashboard).update! smart: true 188 | 189 | user.reload 190 | expect(user.settings(:dashboard).smart).to eq(true) 191 | end 192 | 193 | it 'should not save settings if assigned nil' do 194 | expect { 195 | user.settings = nil 196 | user.save! 197 | }.to_not change(RailsSettings::SettingObject, :count) 198 | end 199 | end 200 | 201 | describe 'Object with settings' do 202 | let!(:user) do 203 | User.create! name: 'Mr. White' do |user| 204 | user.settings(:dashboard).theme = 'white' 205 | user.settings(:calendar).scope = 'all' 206 | end 207 | end 208 | 209 | it 'should respond to #settings?' do 210 | expect(user.settings?).to eq(true) 211 | 212 | expect(user.settings?(:dashboard)).to eq(true) 213 | expect(user.settings?(:calendar)).to eq(true) 214 | end 215 | 216 | it 'should have two setting objects' do 217 | expect(RailsSettings::SettingObject.count).to eq(2) 218 | end 219 | 220 | it 'should update settings' do 221 | user.settings(:dashboard).update! smart: true 222 | user.reload 223 | 224 | expect(user.settings(:dashboard).smart).to eq(true) 225 | expect(user.settings(:dashboard).theme).to eq('white') 226 | expect(user.settings(:calendar).scope).to eq('all') 227 | end 228 | 229 | it 'should update settings by saving object' do 230 | user.settings(:dashboard).smart = true 231 | user.save! 232 | 233 | user.reload 234 | expect(user.settings(:dashboard).smart).to eq(true) 235 | end 236 | 237 | it 'should destroy settings with nil' do 238 | expect { 239 | user.settings = nil 240 | user.save! 241 | }.to change(RailsSettings::SettingObject, :count).by(-2) 242 | 243 | expect(user.settings?).to eq(false) 244 | end 245 | 246 | it 'should raise exception on assigning other than nil' do 247 | expect { 248 | user.settings = :foo 249 | user.save! 250 | }.to raise_error(ArgumentError) 251 | end 252 | end 253 | 254 | describe 'Customized SettingObject' do 255 | let(:project) { Project.create! name: 'Heist' } 256 | 257 | it 'should not accept invalid attributes' do 258 | project.settings(:info).owner_name = 42 259 | expect(project.settings(:info)).not_to be_valid 260 | 261 | project.settings(:info).owner_name = '' 262 | expect(project.settings(:info)).not_to be_valid 263 | end 264 | 265 | it 'should accept valid attributes' do 266 | project.settings(:info).owner_name = 'Mr. Brown' 267 | expect(project.settings(:info)).to be_valid 268 | end 269 | end 270 | 271 | describe 'to_settings_hash' do 272 | let(:user) do 273 | User.new name: 'Mrs. Fin' do |user| 274 | user.settings(:dashboard).theme = 'green' 275 | user.settings(:dashboard).owner_name = 'Mr. Vishal' 276 | user.settings(:dashboard).sound = 11 277 | user.settings(:calendar).scope = 'some' 278 | end 279 | end 280 | 281 | # Modifying spec because it gives like this output on hash value 282 | # "owner_name"=># 283 | it 'should return defaults' do 284 | expect(User.new.to_settings_hash.keys).to eq(%i[dashboard calendar]) 285 | end 286 | 287 | it 'should return merged settings' do 288 | expect(user.to_settings_hash).to eq( 289 | { 290 | dashboard: { 291 | 'a' => 'b', 292 | 'filter' => true, 293 | 'owner_name' => 'Mr. Vishal', 294 | 'sound' => 11, 295 | 'theme' => 'green', 296 | 'view' => 'monthly', 297 | }, 298 | calendar: { 299 | 'scope' => 'some', 300 | 'events' => [], 301 | 'profile' => { 302 | }, 303 | }, 304 | }, 305 | ) 306 | end 307 | end 308 | --------------------------------------------------------------------------------