From bbb7a673e1084c5da3969fc2ac8772c61a17e109 Mon Sep 17 00:00:00 2001 From: Taras Viyatyk Date: Mon, 29 May 2023 09:00:25 +0300 Subject: [PATCH] Add ability to define custom data types (#235) * move attributes converting into separate files * remove redundant file import * move saving inside field * fix initialization * fix styles * add ability to serialize and deserialize fields separately * add test for custom item --- lib/rails-settings-cached.rb | 11 ++ lib/rails-settings/base.rb | 104 +++--------------- lib/rails-settings/fields/array.rb | 15 +++ lib/rails-settings/fields/base.rb | 71 ++++++++++++ lib/rails-settings/fields/big_decimal.rb | 13 +++ lib/rails-settings/fields/boolean.rb | 13 +++ lib/rails-settings/fields/float.rb | 13 +++ lib/rails-settings/fields/hash.rb | 21 ++++ lib/rails-settings/fields/integer.rb | 13 +++ lib/rails-settings/fields/string.rb | 13 +++ test/base_test.rb | 24 +++- .../models/rails_settings/fields/custom.rb | 23 ++++ test/dummy/app/models/setting.rb | 1 + 13 files changed, 242 insertions(+), 93 deletions(-) create mode 100644 lib/rails-settings/fields/array.rb create mode 100644 lib/rails-settings/fields/base.rb create mode 100644 lib/rails-settings/fields/big_decimal.rb create mode 100644 lib/rails-settings/fields/boolean.rb create mode 100644 lib/rails-settings/fields/float.rb create mode 100644 lib/rails-settings/fields/hash.rb create mode 100644 lib/rails-settings/fields/integer.rb create mode 100644 lib/rails-settings/fields/string.rb create mode 100644 test/dummy/app/models/rails_settings/fields/custom.rb diff --git a/lib/rails-settings-cached.rb b/lib/rails-settings-cached.rb index 1969e04..37da54e 100644 --- a/lib/rails-settings-cached.rb +++ b/lib/rails-settings-cached.rb @@ -1,5 +1,14 @@ # frozen_string_literal: true +require_relative "rails-settings/fields/base" +require_relative "rails-settings/fields/array" +require_relative "rails-settings/fields/big_decimal" +require_relative "rails-settings/fields/boolean" +require_relative "rails-settings/fields/float" +require_relative "rails-settings/fields/hash" +require_relative "rails-settings/fields/integer" +require_relative "rails-settings/fields/string" + require_relative "rails-settings/base" require_relative "rails-settings/request_cache" require_relative "rails-settings/middleware" @@ -7,4 +16,6 @@ require_relative "rails-settings/version" module RailsSettings + module Fields + end end diff --git a/lib/rails-settings/base.rb b/lib/rails-settings/base.rb index c119eb3..95ad180 100644 --- a/lib/rails-settings/base.rb +++ b/lib/rails-settings/base.rb @@ -8,7 +8,6 @@ def initialize(key) end class Base < ActiveRecord::Base - SEPARATOR_REGEXP = /[\n,;]+/ PROTECTED_KEYS = %w[var value] self.table_name = table_name_prefix + "settings" @@ -55,7 +54,7 @@ def scope(*args, &block) end def get_field(key) - @defined_fields.find { |field| field[:key] == key.to_s } || {} + @defined_fields.find { |field| field.key == key.to_s }.to_h || {} end def cache_prefix(&block) @@ -69,15 +68,15 @@ def cache_key end def keys - @defined_fields.map { |field| field[:key] } + @defined_fields.map(&:key) end def editable_keys - @defined_fields.reject { |field| field[:readonly] }.map { |field| field[:key] } + @defined_fields.reject(&:readonly).map(&:key) end def readonly_keys - @defined_fields.select { |field| field[:readonly] }.map { |field| field[:key] } + @defined_fields.select(&:readonly).map(&:key) end attr_reader :defined_fields @@ -89,56 +88,24 @@ def _define_field(key, default: nil, type: :string, readonly: false, separator: raise ProtectedKeyError.new(key) if PROTECTED_KEYS.include?(key) + field = ::RailsSettings::Fields::Base.generate( + scope: @scope, key: key, default: default, + type: type, readonly: readonly, options: opts, + separator: separator, parent: self + ) @defined_fields ||= [] - @defined_fields << { - scope: @scope, - key: key, - default: default, - type: type || :string, - readonly: readonly.nil? ? false : readonly, - options: opts - } + @defined_fields << field if readonly - define_singleton_method(key) do - result = default.is_a?(Proc) ? default.call : default - send(:_convert_string_to_typeof_value, type, result, separator: separator) - end + define_singleton_method(key) { field.read } else - define_singleton_method(key) do - val = send(:_value_of, key) - result = nil - if !val.nil? - result = val - else - result = default - result = default.call if default.is_a?(Proc) - end - - result = send(:_convert_string_to_typeof_value, type, result, separator: separator) - - result - end - - define_singleton_method("#{key}=") do |value| - var_name = key - - record = find_by(var: var_name) || new(var: var_name) - value = send(:_convert_string_to_typeof_value, type, value, separator: separator) - - record.value = value - record.save! - - value - end + define_singleton_method(key) { field.read } + define_singleton_method("#{key}=") { |value| field.save!(value: value) } if validates validates[:if] = proc { |item| item.var.to_s == key } send(:validates, key, **validates) - - define_method(:read_attribute_for_validation) do |_key| - self.value - end + define_method(:read_attribute_for_validation) { |_key| self.value } end end @@ -156,49 +123,6 @@ def _define_field(key, default: nil, type: :string, readonly: false, separator: end end - def _convert_string_to_typeof_value(type, value, separator: nil) - return value unless [String, Integer, Float, BigDecimal].include?(value.class) - - case type - when :boolean - ["true", "1", 1, true].include?(value) - when :array - value.split(separator || SEPARATOR_REGEXP).reject { |str| str.empty? }.map(&:strip) - when :hash - value = begin - YAML.safe_load(value).to_h - rescue - {} - end - value.deep_stringify_keys! - ActiveSupport::HashWithIndifferentAccess.new(value) - when :integer - value.to_i - when :float - value.to_f - when :big_decimal - value.to_d - else - value - end - end - - def _value_of(var_name) - unless _table_exists? - # Fallback to default value if table was not ready (before migrate) - puts "WARNING: table: \"#{table_name}\" does not exist or not database connection, `#{name}.#{var_name}` fallback to returns the default value." - return nil - end - - _all_settings[var_name] - end - - def _table_exists? - table_exists? - rescue - false - end - def rails_initialized? Rails.application&.initialized? end diff --git a/lib/rails-settings/fields/array.rb b/lib/rails-settings/fields/array.rb new file mode 100644 index 0000000..22f9a1b --- /dev/null +++ b/lib/rails-settings/fields/array.rb @@ -0,0 +1,15 @@ +module RailsSettings + module Fields + class Array < ::RailsSettings::Fields::Base + def deserialize(value) + return value unless value.is_a?(::String) + + value.split(separator).reject(&:empty?).map(&:strip) + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/lib/rails-settings/fields/base.rb b/lib/rails-settings/fields/base.rb new file mode 100644 index 0000000..e8114bc --- /dev/null +++ b/lib/rails-settings/fields/base.rb @@ -0,0 +1,71 @@ +module RailsSettings + module Fields + class Base < Struct.new(:scope, :key, :default, :parent, :readonly, :separator, :type, :options, keyword_init: true) + SEPARATOR_REGEXP = /[\n,;]+/ + + def initialize(scope:, key:, default:, parent:, readonly:, separator:, type:, options:) + super + self.readonly = !!readonly + self.type ||= :string + self.separator ||= SEPARATOR_REGEXP + end + + def save!(value:) + serialized_value = serialize(value) + parent_record = parent.find_by(var: key) || parent.new(var: key) + parent_record.value = serialized_value + parent_record.save! + parent_record.value + end + + def saved_value + return parent.send(:_all_settings)[key] if table_exists? + + # Fallback to default value if table was not ready (before migrate) + puts "WARNING: table: \"#{parent.table_name}\" does not exist or not database connection, `#{parent.name}.#{key}` fallback to returns the default value." + nil + end + + def default_value + default.is_a?(Proc) ? default.call : default + end + + def read + return deserialize(default_value) if readonly || saved_value.nil? + + deserialize(saved_value) + end + + def deserialize(value) + raise NotImplementedError + end + + def serialize(value) + raise NotImplementedError + end + + def to_h + super.slice(:scope, :key, :default, :type, :readonly, :options) + end + + def table_exists? + parent.table_exists? + rescue StandardError + false + end + + class << self + def generate(**args) + fetch_field_class(args[:type]).new(**args) + end + + private + + def fetch_field_class(type) + field_class_name = type.to_s.split('_').map(&:capitalize).join('') + const_get("::RailsSettings::Fields::#{field_class_name}") + end + end + end + end +end diff --git a/lib/rails-settings/fields/big_decimal.rb b/lib/rails-settings/fields/big_decimal.rb new file mode 100644 index 0000000..696b1ba --- /dev/null +++ b/lib/rails-settings/fields/big_decimal.rb @@ -0,0 +1,13 @@ +module RailsSettings + module Fields + class BigDecimal < ::RailsSettings::Fields::Base + def deserialize(value) + value.to_d + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/lib/rails-settings/fields/boolean.rb b/lib/rails-settings/fields/boolean.rb new file mode 100644 index 0000000..5ccf546 --- /dev/null +++ b/lib/rails-settings/fields/boolean.rb @@ -0,0 +1,13 @@ +module RailsSettings + module Fields + class Boolean < ::RailsSettings::Fields::Base + def deserialize(value) + ["true", "1", 1, true].include?(value) + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/lib/rails-settings/fields/float.rb b/lib/rails-settings/fields/float.rb new file mode 100644 index 0000000..5b059e3 --- /dev/null +++ b/lib/rails-settings/fields/float.rb @@ -0,0 +1,13 @@ +module RailsSettings + module Fields + class Float < ::RailsSettings::Fields::Base + def deserialize(value) + value.to_f + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/lib/rails-settings/fields/hash.rb b/lib/rails-settings/fields/hash.rb new file mode 100644 index 0000000..80842ec --- /dev/null +++ b/lib/rails-settings/fields/hash.rb @@ -0,0 +1,21 @@ +module RailsSettings + module Fields + class Hash < ::RailsSettings::Fields::Base + def deserialize(value) + return value unless value.is_a?(::String) + + load_value(value).deep_stringify_keys.with_indifferent_access + end + + def serialize(value) + deserialize(value) + end + + def load_value(value) + YAML.safe_load(value).to_h + rescue StandardError + {} + end + end + end +end diff --git a/lib/rails-settings/fields/integer.rb b/lib/rails-settings/fields/integer.rb new file mode 100644 index 0000000..a273275 --- /dev/null +++ b/lib/rails-settings/fields/integer.rb @@ -0,0 +1,13 @@ +module RailsSettings + module Fields + class Integer < ::RailsSettings::Fields::Base + def deserialize(value) + value.to_i + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/lib/rails-settings/fields/string.rb b/lib/rails-settings/fields/string.rb new file mode 100644 index 0000000..d2e5917 --- /dev/null +++ b/lib/rails-settings/fields/string.rb @@ -0,0 +1,13 @@ +module RailsSettings + module Fields + class String < ::RailsSettings::Fields::Base + def deserialize(value) + value + end + + def serialize(value) + deserialize(value) + end + end + end +end diff --git a/test/base_test.rb b/test/base_test.rb index ef2cb57..d2deca0 100644 --- a/test/base_test.rb +++ b/test/base_test.rb @@ -51,13 +51,13 @@ class NewSetting < RailsSettings::Base end test "setting_keys" do - assert_equal 15, Setting.keys.size + assert_equal 16, Setting.keys.size assert_includes(Setting.keys, "host") assert_includes(Setting.keys, "readonly_item") assert_includes(Setting.keys, "default_tags") assert_includes(Setting.keys, "omniauth_google_options") - assert_equal 12, Setting.editable_keys.size + assert_equal 13, Setting.editable_keys.size assert_includes(Setting.editable_keys, "host") assert_includes(Setting.editable_keys, "default_tags") @@ -84,7 +84,7 @@ class NewSetting < RailsSettings::Base # assert_equal 2, groups.length assert_equal %i[application contents mailer none], scopes.keys assert_equal 4, scopes[:application].length - assert_equal 5, scopes[:contents].length + assert_equal 6, scopes[:contents].length assert_equal 2, scopes[:mailer].length end @@ -307,6 +307,24 @@ class NewSetting < RailsSettings::Base assert_equal true, Setting.captcha_enable? end + test "custom field" do + assert_equal 1, Setting.custom_item + assert_instance_of Integer, Setting.custom_item + assert_no_record :custom_item + + Setting.custom_item = 2 + assert_equal 2, Setting.custom_item + assert_instance_of Integer, Setting.custom_item + assert_record_value :custom_item, 'b' + + Setting.custom_item = 3 + assert_equal 3, Setting.custom_item + assert_instance_of Integer, Setting.custom_item + assert_record_value :custom_item, 'c' + + assert_raise(StandardError) { Setting.custom_item = 4 } + end + test "string value in db compatible" do # array direct_update_record(:admin_emails, "foo@gmail.com,bar@dar.com\naaa@bbb.com") diff --git a/test/dummy/app/models/rails_settings/fields/custom.rb b/test/dummy/app/models/rails_settings/fields/custom.rb new file mode 100644 index 0000000..daeb10b --- /dev/null +++ b/test/dummy/app/models/rails_settings/fields/custom.rb @@ -0,0 +1,23 @@ +module RailsSettings + module Fields + class Custom < ::RailsSettings::Fields::Base + def serialize(value) + case value + when 'a', 1 then 'a' + when 'b', 2 then 'b' + when 'c', 3 then 'c' + else raise StandardError, 'invalid value' + end + end + + def deserialize(value) + case value + when 'a', 1 then 1 + when 'b', 2 then 2 + when 'c', 3 then 3 + else nil + end + end + end + end +end diff --git a/test/dummy/app/models/setting.rb b/test/dummy/app/models/setting.rb index 3a70b2f..6010f41 100644 --- a/test/dummy/app/models/setting.rb +++ b/test/dummy/app/models/setting.rb @@ -23,6 +23,7 @@ def foo field :float_item, type: :float, default: 7 field :big_decimal_item, type: :big_decimal, default: 9 field :default_value_with_block, type: :integer, default: -> { 1 + 1 } + field :custom_item, type: :custom, default: 1 end scope :mailer do