Skip to content

Instantly share code, notes, and snippets.

@johncox00
Created December 26, 2016 12:30
Show Gist options
  • Save johncox00/850c6b805ad8379ee588c8798535c01d to your computer and use it in GitHub Desktop.
Save johncox00/850c6b805ad8379ee588c8798535c01d to your computer and use it in GitHub Desktop.
This is code underlying a system for driving offer/discount qualification in an ecom system. Details here: https://docs.google.com/presentation/d/1beY49QzNJuwD_m_fJLdEo_MxWXCzcMQCHT-I968VIII/edit?usp=sharing
class OfferRule
def initialize(user, field_or_method, field_type, threshold_value)
@user = user
@field_or_method = field_or_method.to_sym
@field_type = field_type
@threshold_value = threshold_value
end
def self.create_from_hash(user, rule_hash)
new(user, rule_hash["user_method"], rule_hash["field_type"], rule_hash["threshold"])
end
def self.create_and_evaluate_from_hash(user, rule_hash)
rule_hash.present? ? OfferRule.create_from_hash(user, rule_hash).evaluate(rule_hash["operator"]) : false
end
# THE NEXT METHOD IS GENERALLY THE ENTRY POINT.
# WILL RETURN A BOOLEAN SAYING WHETHER OR NOT A USER MEETS ALL QUALIFICATIONS.
#
# example input...
# rule_id_array: [1,2]
# rule_hashes:
# [{
# "id": 1,
# "user_method": "account_status",
# "operator": "equal",
# "threshold": "expired",
# "field_type": "string"
# }
# {
# "id": 2,
# "user_method": "qualified_prepaid_plans",
# "operator": "contains_subset",
# "threshold": "['three-month-pre-pay']",
# "field_type": "array"
# }]
def self.evaluate_offer_rules(user, rule_id_array, rule_hashes)
rule_id_array.each do |rule_id|
rule_hash = find_rule(rule_hashes, rule_id)
if rule_hash['operator'] == "or"
return false if !OrOfferRule.new(user, rule_hashes, rule_id).evaluate
else
return false if !create_and_evaluate_from_hash(user, rule_hash)
end
end
true
end
def self.find_rule(rule_hashes, id)
rule_hashes.select{|r| r['id'] == id}.first
end
def maybe_negate(operator, result)
if operator[0...4] == "not_"
negator = true
operator = operator[4..-1]
end
negator ? !result : result
end
def evaluator
return @threshold_value.to_sym if @field_type.downcase == "symbol"
begin
Kernel.try(@field_type.classify.to_sym, @threshold_value)
rescue
@field_type.classify.constantize.new(@threshold_value)
end
end
def evaluate(operator)
begin
"#{operator}_offer_rule".classify.constantize.new(@user, @field_or_method, @field_type, @threshold_value).evaluate
rescue StandardError => e
puts "Error evaluating #{self.class} : #{[@user, @field_or_method, @field_type, @threshold_value].join(', ')}"
false
end
end
end
class ComparisonOfferRule < OfferRule
def evaluators
{
"equal" => :==,
"greater_than" => :>,
"greater_than_equal" => :>=,
"less_than" => :<,
"less_than_equal" => :<=
}
end
def base_operator
operator[0...4] == "not_" ? operator[4..-1] : operator
end
def evaluate
result = @user.send(@field_or_method).send(evaluators[base_operator.to_s],evaluator)
maybe_negate(operator, result)
end
end
class TrueOfferRule < OfferRule
def evaluate
true
end
end
class FalseOfferRule < OfferRule
def evaluate
false
end
end
# can be used on string, numeric and boolean
class EqualOfferRule < ComparisonOfferRule
def operator
"equal"
end
end
# can be used on string, numeric and boolean
class NotEqualOfferRule < ComparisonOfferRule
def operator
"not_equal"
end
end
# can be used on numeric only
class GreaterThanOfferRule < ComparisonOfferRule
def operator
"greater_than"
end
end
# can be used on numeric only
class GreaterThanEqualOfferRule < ComparisonOfferRule
def operator
"greater_than_equal"
end
end
# can be used on numeric only
class LessThanOfferRule < ComparisonOfferRule
def operator
"less_than"
end
end
# can be used on numeric only
class LessThanEqualOfferRule < ComparisonOfferRule
def operator
"less_than_equal"
end
end
# only usable on methods that return an array
class InOfferRule < OfferRule
def evaluate
@threshold_value.map{|x| x.to_s}.include?(@user.send(@field_or_method).to_s)
end
end
# only usable on methods that return an array
class NotInOfferRule < InOfferRule
def evaluate
!super
end
end
# only usable on methods that return an array
class IncludesAnyOfferRule < OfferRule
def evaluate
(@user.send(@field_or_method) & @threshold_value).count > 0
end
end
# only usable on methods that return an array
class DoesNotIncludeAnyOfferRule < IncludesAnyOfferRule
def evaluate
!super
end
end
# only usable on methods that return an array and with @threshold_value that is an array
class ContainsSubsetOfferRule < OfferRule
def evaluate
(@user.send(@field_or_method) & @threshold_value).uniq.sort == @threshold_value.uniq.sort
end
end
# only usable on methods that return an array and with @threshold_value that is an array
class DoesNotContainSubsetOfferRule < ContainsSubsetOfferRule
def evaluate
!super
end
end
# only usable on methods that return strings
class MatchesOfferRule < OfferRule
def evaluate
(@user.send(@field_or_method.to_sym) =~ Regexp.new(@threshold_value)) != nil
end
end
class DoesNotMatchOfferRule < MatchesOfferRule
def evaluate
!super
end
end
class OrOfferRule < OfferRule
def initialize(user, rule_hashes, rule_id)
@user = user
@rules = rule_hashes
@my_rule = OfferRule.find_rule(rule_hashes, rule_id)
end
def evaluate
@my_rule["threshold"].each do |rule_id|
return true if OfferRule.create_and_evaluate_from_hash(@user, OfferRule.find_rule(@rules, rule_id))
end
return false
end
end
# Create a way to dynamically create methods that query into entities related to User (i.e. has_many).
#...
def method_missing(method_sym, *arguments, &block)
if method_sym.to_s =~ /(.*)_where_(.*)_grab_(.*)/
# Old way: (.*)_where_(.*)_(gt|lt|gte|lte|eq)_(.*)_grab_(.*)
relation = $1
mapper = $3
queries = $2
define_dynamic_query(method_sym, relation, queries, mapper)
send(method_sym)
else
super
end
end
protected
def define_dynamic_query(finder, relationship, queries, mapper)
query_array = [].tap do |aq|
queries.split('_and_').each{|q| aq << create_query(q, relationship)}
end
query = "#{relationship}.#{query_array.compact.uniq.join('.')}"
class_eval <<-RUBY
def #{finder}
#{query}.map(&:#{mapper})
end
RUBY
end
def create_query(query_string, relationship)
query = ""
if query_string =~ /(.*)_(gt|lt|gte|lte|eq)_(.*)/
threshold = $3
property = $1
if threshold == 'null'
query = "where('#{property} IS NULL')"
else
operator = query_operators[$2]
threshold = interpret_threshold(threshold, relationship, property)
query = "where('#{property} #{operator} ?', #{threshold})"
end
end
return query
end
def query_operators
{'eq' => '=', 'gt' => '>', 'gte' => '>=', 'lt' => '<', 'lte' => '<='}
end
def interpret_threshold(t, r, p)
klass = r.singularize.classify.constantize.columns_hash[p].type.to_s.classify
return "'#{t}'" if klass == 'String'
if klass == 'Datetime'
ret = "'#{t.gsub!('_','-')}'"
else
begin
ret = Kernel.try(klass.to_sym, t)
rescue
ret = klass.constantize.new(t)
end
end
return ret
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment