vinistock/sail

View on GitHub
app/models/sail/setting.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require "fugit"

module Sail
  # Setting
  # This is the model used for settings
  # it contains all data definitions,
  # validations, scopes and methods
  class Setting < ApplicationRecord
    class UnexpectedCastType < StandardError; end

    FULL_RANGE = (0...100).freeze
    AVAILABLE_MODELS = Dir[Rails.root.join("app/models/*.rb")]
                       .map { |dir| dir.split("/").last.camelize.gsub(".rb", "") }.freeze

    has_many :entries, dependent: :destroy
    attr_reader :caster

    validates :value, :cast_type, presence: true
    validates :name, presence: true, uniqueness: { case_sensitive: false }
    enum cast_type: %i[integer string boolean range array float
                       ab_test cron obj_model date uri throttle
                       locales set].freeze

    validate :value_is_within_range, if: -> { range? }
    validate :value_is_true_or_false, if: -> { boolean? || ab_test? }
    validate :cron_is_valid, if: -> { cron? }
    validate :model_exists, if: -> { obj_model? }
    validate :date_is_valid, if: -> { date? }
    validate :uri_is_valid, if: -> { uri? }

    after_initialize :instantiate_caster

    scope :paginated, ->(page, per_page) { offset(page.to_i * per_page).limit(per_page) }

    scope :by_query, lambda { |query|
      if cast_types.key?(query) || query == Sail::ConstantCollection::STALE
        send(query)
      elsif select(:id).by_group(query).exists?
        by_group(query)
      elsif query.to_s.include?(Sail::ConstantCollection::RECENT)
        recently_updated(query.delete("recent ").strip.to_i)
      else
        by_name(query)
      end
    }

    scope :by_group, ->(group) { where(group: group) }
    scope :by_name, ->(name) { name.present? ? where("name LIKE ?", "%#{name}%") : all }
    scope :stale, -> { where("updated_at < ?", Sail.configuration.days_until_stale.days.ago) }
    scope :recently_updated, ->(amount) { where("updated_at >= ?", amount.to_i.hours.ago) }
    scope :ordered_by, ->(field) { column_names.include?(field) ? order("#{field}": :desc) : all }
    scope :for_value_by_name, ->(name) { select(:value, :cast_type).where(name: name) }

    def self.get(name)
      Sail.instrumenter.increment_usage_of(name)

      cached_setting = Rails.cache.read("setting_get_#{name}")
      return cached_setting unless cached_setting.nil?

      setting = Setting.for_value_by_name(name).first
      return if setting.nil?

      setting_value = setting.safe_cast

      unless setting.should_not_cache?
        Rails.cache.write(
          "setting_get_#{name}", setting_value,
          expires_in: Sail.configuration.cache_life_span
        )
      end

      setting_value
    end

    def self.set(name, value)
      setting = Setting.find_by(name: name)
      value_cast = setting.caster.from(value)
      success = setting.update(value: value_cast)
      Rails.cache.delete("setting_get_#{name}") if success
      [setting, success]
    end

    def self.switcher(positive:, negative:, throttled_by:)
      setting = select(:cast_type).find_by(name: throttled_by)
      raise ActiveRecord::RecordNotFound, "Can't find throttle setting" if setting.nil?

      raise UnexpectedCastType unless setting.throttle?

      get(throttled_by) ? get(positive) : get(negative)
    end

    def self.reset(name)
      if File.exist?(config_file_path)
        defaults = YAML.load_file(config_file_path)
        set(name, defaults[name]["value"])
      end
    end

    def self.load_defaults(override = false)
      if File.exist?(config_file_path) &&
         ActiveRecord::Base.connection.table_exists?(table_name)

        destroy_all if override
        config = YAML.load_file(config_file_path)
        find_or_create_settings(config)
        destroy_missing_settings(config.keys)
      end
    end

    def self.config_file_path
      Sail::ConstantCollection::CONFIG_FILE_PATH
    end

    def self.destroy_missing_settings(keys)
      deleted_settings = pluck(:name) - keys
      where(name: deleted_settings).destroy_all
    end

    def self.find_or_create_settings(config)
      config.each do |name, attrs|
        string_attrs = attrs.merge(name: name)
        string_attrs.update(string_attrs) { |_, v| v.to_s }
        where(name: name).first_or_create(string_attrs)
      end
    end

    def self.database_to_file
      attributes = {}

      Setting.all.find_each do |setting|
        setting_attrs = setting.attributes.except("id", "name", "created_at", "updated_at", "cast_type")
        attributes[setting.name] = setting_attrs.merge("cast_type" => setting.cast_type)
      end

      File.write(config_file_path, attributes.to_yaml)
    end

    private_class_method :config_file_path, :destroy_missing_settings,
                         :find_or_create_settings

    def display_name
      name.gsub(/[^a-zA-Z\d]/, " ").titleize
    end

    def stale?
      return if Sail.configuration.days_until_stale.blank?

      updated_at < Sail.configuration.days_until_stale.days.ago
    end

    def relevancy
      Sail.instrumenter.relevancy_of(name)
    end

    def should_not_cache?
      ab_test? || cron? || throttle?
    end

    def safe_cast
      try(:caster).try(:to_value)
    end

    # cache_index
    #
    # Used in _setting.html.erb for the cache_key
    def cache_index
      Sail.instrumenter[name][:usages] / Instrumenter::USAGES_UNTIL_CACHE_EXPIRE
    end

    private

    def instantiate_caster
      return unless has_attribute?(:cast_type)

      @caster = "Sail::Types::#{cast_type.camelize}"
                .constantize
                .new(self)
    end

    def model_exists
      errors.add(:invalid_model, "Model does not exist") unless AVAILABLE_MODELS.include?(value)
    end

    def value_is_true_or_false
      if Sail::ConstantCollection::STRING_BOOLEANS.exclude?(value)
        errors.add(:not_a_boolean_error,
                   "Boolean settings only take values inside #{Sail::ConstantCollection::STRING_BOOLEANS}")
      end
    end

    def value_is_within_range
      unless FULL_RANGE.cover?(caster.to_value)
        errors.add(:outside_range_error,
                   "Range settings only take values inside range #{FULL_RANGE}")
      end
    end

    def date_is_valid
      DateTime.parse(value).utc
    rescue ArgumentError
      errors.add(:invalid_date, "Date format is invalid")
    end

    def cron_is_valid
      if Fugit::Cron.new(value).nil?
        errors.add(:invalid_cron_string,
                   "Setting value is not a valid cron")
      end
    end

    def uri_is_valid
      URI(value)
    rescue URI::InvalidURIError
      errors.add(:invalid_uri, "URI value is invalid")
    end
  end
end