SpeciesFileGroup/taxonworks

View on GitHub
lib/soft_validation.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# require_relative 'soft_validation/soft_validation'
# require_relative 'soft_validation/soft_validations'
# require_relative 'soft_validation/soft_validation_method'
# require_relative 'utilities/params'
# require "active_support/all"

# Vaguely inspired by concepts from by svn://rubyforge.org/var/svn/softvalidations, but not as elegant.
#
# Soft validations are a means to tie warnings or suggestions to instances of data.
# Soft validations do not prevent an instance from being saved.  They are not intended
# to be bound to AR callbacks, but this may be possible ultimately. They may be used to
# alert the user to data issues that need to be addressed, or alert the programmer
# who is batch parsing data as to the quality of the incoming data, etc..
#
# There are 2 stages to defining a soft validation. First index and provide an option general description
# of the soft validation itself using the `soft_validate` macro in the class.  Second, add the method (logic)
# that is called, and a set of message that the user will see when the logic passes or fails.  To state in another way:
#
# * The name and description (intent) of the soft validation is optionally provided with the macro setting the soft validation (`Klass.soft_validate()`.
# * The human messages ('there is a problem here!', 'the problem is fixed', 'we tried to fix, but failed!') are defined with the method logic itself.  This is intentionally done to
# keep the intent of the logic close to the consequences of the logic.
#
# Devloper tips:
# 
# - Protonym.soft_validation( ) <- all technical metadata and a gross description (the intent), optionally, goes here
# - @protonym.sv_xyz( ) <- all human guidance (warning, outcomes) goes here, including the attribute to point to in the UI
# - *fix* method names should not be exposed to the UI
#
#
# Usage:
#
#   class Foo < ApplicationRecord
#     include SoftValidation
#     soft_validate(:a_soft_validation_method, fix: :cook_cheezburgers)
#
#     # Validations can be assigned to a set (only one), and validations in a set
#     # can be called individually.
#     soft_validate(:other_soft_validation_method, set: :some_set)
#     soft_validate(:yet_another_method, set: :some_other_set )
#     soft_validate(:described_method, name: 'the validation for X', description: 'this validation does Z')
#     soft_validate(:a_third_method, resolution: [:route_name, route_name2]) # Resolution is route name (without _path/_url), id: and model_id: are added to the route.
#
#     soft_validate(:a_fourth_example, fix: :fix_method) # the detected issue can be fully resolved by calling this instance method
#
#     $hungry = true # demo only, don't use $globals
#
#     def a_soft_validation_method
#       soft_validations.add(:base, 'hungry!',                          # :base or a model attribute (column)
#         success_message: 'no longer hungry, cooked a cheezeburger',
#         failure_message: 'oh no, cat ate your cheezeburger'
#       ) if $hungry
#     end
#
#     def cook_cheezburgers
#       $hungry = false
#     end
#   end
#
#   f = Foo.new
#
#   f.soft_validations.validated?             # => false
#   f.soft_validations.fixes_run?             # => false
#   f.soft_validations.fixed?                 # => false
#   f.soft_validations.complete?              # => false
#
#   f.soft_validate                           # => true
#   f.soft_validated?                         # => true
#   f.soft_fixed?                             # => false
#   f.soft_valid?                             # => false  # true if there are no SoftValidations produced
#
#   f.soft_validations.soft_validations                        # => [soft_validation, soft_validation1 ... ]
#   f.soft_validations.soft_validations.size                   # => 1
#   f.soft_validations.soft_validations.first                  # => A SoftValidation instance
#
#   # SoftValidation attributes
#   f.soft_validations.soft_validations.first.attribute          # => :base
#   f.soft_validations.soft_validations.first.message            # => 'hungry!'
#   f.soft_validations.soft_validations.first.success_message    # => 'no longer hungry, cooked a cheezeburger'
#   f.soft_validations.soft_validations.first.failure_message    # => 'oh no, cat ate your cheezeburger'
#
#   f.soft_validations.soft_validations.first.fixed?           # => false
#   f.soft_validations.soft_validations.first.result_message     # => 'fix not yet run'
#
#   f.fix_soft_validations                    # => true
#   f.soft_fixed?                             # => true
#   f.soft_valid?                             # => false !! There is still a SoftValidation generated, will be true next time it's run
#
#   f.soft_validations.fixes_run                               # => true
#   f.soft_validations.soft_validations.first.fixed?           # => true
#   f.soft_validations.soft_validations.first.result_message   # => 'no longer hungry, cooked a cheezeburger'
#   f.soft_validations.on(:base)               # => [soft_validation, ... ]
#   f.soft_validations.messages                # => ['hungry!']
#   f.soft_validations.messages_on(:base)      # => ['hungry!']
#
#   f.clear_soft_validations
#
#   f.soft_validate(only_sets: [:default])           # only run this set, the set of soft validations not assigned to a set
#   f.soft_validate(only_sets: [:some_other_set])    # only run these sets of validations
#   f.soft_validate(except_set: [:some_other_set])   # run non-flagged except soft validations in these sets
#   f.soft_validate(only_methods: :some_method)      # run only this soft validation (all other params ignored)
#   f.soft_validate(except_methods: [:some_method])  # run result except these soft validation methods
#   f.soft_validate(fixable: true)                   # run all soft validations that have a fix
#   f.soft_validate(fixable: false)                  # run all soft validations without a fix
#   f.soft_validate(flagged: true)                   # run all, *including* methods flagged by developers as "a-typical", there is no flagged: false, as it is default)
#
module SoftValidation

  class SoftValidationError < StandardError; end

  # An index of the soft validators in superclasses
  ANCESTORS_WITH_SOFT_VALIDATIONS =
    Hash.new do |h, klass|
      h[klass.name] = (klass.ancestors.select {|a| a.respond_to?(:soft_validates?) && a.soft_validates?} - [klass]) # a < ApplicationRecord && would be faster but requires AR in spec
    end

  extend ActiveSupport::Concern

  included do
    attr_accessor :soft_validation_result

    # @return [Hash]
    #   An index of soft validation methods, keys are all methods
    #    `{ method_name: @method_instance, ... }`
    class_attribute :soft_validation_methods, default: {} # http://api.rubyonrails.org/classes/Class.html

    # @return [Hash]
    #   An index of soft validation methods by ClassName by set
    #   ' { ClassName' => { set: [ :method_name, ], ...}
    class_attribute :soft_validation_sets, default: { self.name =>  { default: []} }
  end

  module ClassMethods

    # @param [Symbol] method
    # @param [Hash] options
    # @return [Boolean]
    # self.name is the class name, e.g. Otu
    def soft_validate(method, options = {})
      options[:klass] = self
      options[:method] = method
      add_method(method, options)
      add_to_set(method, options)
      true
    end

    # @param method [Symbol]
    #   the name of the method with the soft validation logic, in TW like `sv_foo`
    # @param [Hash] options
    # @return [SoftValidationMethod]
    def add_method(method, options)
      # Yes, this has to be self.
      #
      # The critical insight is to use the `=` to access the setter method.  This allows the subclasses to have their own copy of `soft_validation_methods`
      # See https://api.rubyonrails.org/classes/Class.html
      # b
      self.soft_validation_methods = self.soft_validation_methods.merge(method =>  SoftValidationMethod.new(options))
    end

    # @param [Hash] method
    # @param [Hash] options
    def add_to_set(method, options)
      # TODO: update this to use setters?  Might not
      # be required because we are subgrouping by set.
      n = self.name
      set = options[:set]

      soft_validation_sets[n] ||= {}

      if set
        soft_validation_sets[n][set] ||= []
        soft_validation_sets[n][set] << method
      else
        soft_validation_sets[n][:default] ||= []
        soft_validation_sets[n][:default] << method
      end
    end

    # @return [Boolean] always true
    #   indicates that this class has included SoftValidation
    def soft_validates?
      true
    end

    # @return [Boolean]
    #   true if at least on soft_validate() exists in *this* class
    def has_self_soft_validations?
      soft_validation_methods_on_self.any?
    end

    # @return [Array]
    #   all methods from all sets from self (not superclasses)
    def soft_validation_methods_on_self
      a = soft_validation_sets[name]&.keys 
      return [] if a.nil?
      a.collect{|s| soft_validation_sets[name][s] }.flatten
    end

    # @return [Hash]
    def soft_validation_descriptions
      result = {}
      soft_validators.each do |v|
        result[v]
      end
      result
    end

    # @return [Hash]
    def ancestor_klasses_with_validation
      ANCESTORS_WITH_SOFT_VALIDATIONS[self]
    end

    # @param only_sets [Array]
    #   names (symbols) of sets to run
    #
    # @param except_sets [Array]
    #   names (symbols]
    #
    # @param only_methods [Array]
    #   Names (symbols) of soft validation methods (not fix methods) to run. _If provided all other params are ignored._
    #
    # @param except_methods [Array]
    #   Names (symbols) of soft validation methods to exclude.  Ignored if only_methods is provided.
    #   
    # @param include_superclass [Boolean]
    #   include validations on superclasses, default is `true`
    #
    # @param include_flagged [Boolean]
    #   some soft validations have more consequences, these are flagged, default `false`
    #
    # @param fixable [Boolean]
    #   run soft validations only on fixable records, default is `false`
    #
    # @return [Array] of Symbol
    #   the names of the soft validation methods
    #
    #  An internal accessor for self.soft_validation_methods.  If nothing is provided all possible specs, excluding those flagged are returned.
    def soft_validators(only_sets: [], except_sets: [], only_methods: [], except_methods: [], include_flagged: false, fixable: nil, include_superclass: true)
      only_methods = Utilities::Params.arrayify(only_methods)
      return only_methods if !only_methods.empty?

      except_methods = Utilities::Params.arrayify(except_methods)

      # Get sets
      sets = get_sets(
        Utilities::Params.arrayify(only_sets),
        Utilities::Params.arrayify(except_sets)
      )

      methods = []
      klass_validators = []

      # Return "Local" (this class only) validators
      if has_self_soft_validations?

        a = []
        if sets.empty? && only_sets.empty? && except_sets.empty? # no sets provided, default to all methods
          a = self.soft_validation_methods.keys # self.soft_validation_method_names
        else
          sets.each do |s|
            a += self.soft_validation_sets[self.name][s]
          end
        end

        a.delete_if{|n| !self.soft_validation_methods[n].send(:matches?, fixable, include_flagged) }
        methods += a
      end

      # Add the rest of the validators, from Superclasses
      if include_superclass
        ancestor_klasses_with_validation.each do |klass|
          methods += klass.soft_validators(include_superclass: false, only_sets: only_sets, except_sets: except_sets, except_methods: except_methods, include_flagged: include_flagged, fixable: fixable)
        end
      end

      # Get rid of explicitly excluded
      methods.delete_if{|m| except_methods.include?(m) }

      methods
    end

    private

    def reset_soft_validation!
      self.soft_validation_methods = { }
      self.soft_validation_sets = { self.name => { default: []}}
    end

    def get_sets(only_sets = [], except_sets = [])
      all_sets = soft_validation_sets[name]&.keys
      return [] if all_sets.nil?
      a = (all_sets - except_sets)
      only_sets.empty? ? a : a & only_sets
    end

  end

  # Instance methods

  # @return [SoftValidations]
  def soft_validations
    @soft_validation_result ||= SoftValidations.new(self)
  end

  # @return [Nil]
  def clear_soft_validations
    @soft_validation_result = nil
  end

  # Run a set of soft validations.
  # * by default all validations except those with `flagged: true` are run
  # * when only|except_methods are set then these further restrict the scope of tests run
  # * except_methods will exclude methods from *any* result (i.e. sets are allowed)
  #
  # @param (see SoftValidation#soft_validators)
  #
  # @return [true]
  def soft_validate(**options)
    clear_soft_validations
    soft_validations

    soft_validators(**options).each do |sv_method|
      self.send(sv_method)
    end

    soft_validations.validated = true
    true
  end

  # @see Class.soft_validators
  def soft_validators(**options)
    self.class.soft_validators(**options)
  end

  # The validation set to fix is set prior to running the fix, at the first step.
  # It can be refined/restricted there as needed, letting specific contexts (e.g. 
  # access in controller) defined the scope.
  def fix_soft_validations
    return false if !soft_validated?
    soft_validations.soft_validations.each do |v|
      if fix = fix_for(v.soft_validation_method)
        if self.send(fix)
          v.fixed = :fixed
        else
          v.fixed = :fix_error
        end
      else
        v.fixed = :no_fix_available
      end
    end
    soft_validations.fixes_run = true
    true
  end

  # @return [Boolean]
  def soft_validated?
    soft_validations.validated?
  end

  # @return [Boolean]
  def soft_fixed?
    soft_validations.fixes_run?
  end

  # @return [Boolean]
  def soft_valid?
    soft_validations.complete?
  end

  # TODO: should be here?
  def fix_for(soft_validation_method)
    soft_validation_methods[soft_validation_method]&.fix
  end

end

# Original version was an AR extension, might revert to this at some point.
# class ApplicationRecord
#   include SoftValidation
# end