module Nodepile # Represents a single rule record as represernted by a KeyedArrayAccessor object. # Note that this class does not rely on metadata of the KeyedArrayAccessor and doesn't # even verify that the object it represents actually contains any formulas. # # Generally speaking, any field whose first character is a question mark is considered # to have a calculation/formula in it. Formulas (not counting the question mark) are simply # ruby expressions. The formulas in the id fields ('_id', '_links_to', and '_links_from') # are given special treatment as described below. # # Dynamic calculations must start with the question mark character '?'. They # will use the Ruby language itself with a tightly constrained binding that # defines one primary object simply named "v" (standing for values). That object # only supports a handful of operations including: # v['fielaname'] to evaluate a field # v.include?('fieldname') to determine whether the field exists # v[:this] to evaluate this field without having to explicitly name it # of fields. Note that blank "fields" will often return nil and also non-existent # fields will be nil. # # Note, that some exceptional calculation rules may be triggered if this # calculation is for an id field. See the #uses_id_calcs?() method. class RuleRecordEvaluator EDGE_ID_FIELDS = ['_links_from','_links_to'].freeze NODE_ID_FIELDS = ['_id'].freeze ID_FIELD_NAMES = (NODE_ID_FIELDS + EDGE_ID_FIELDS).freeze # @param rule_record_kaa [KeyedArrayAccessor] This ia a rule record that # contains one or more formulas in its fields as indicated # by a leading question mark in either _id, _links_from, or _links_to def initialize(rule_record_kaa) @kaa = rule_record_kaa @match_fields = @kaa['_id'].nil? ? EDGE_ID_FIELDS : NODE_ID_FIELDS # assuming it's well formed @match_type = nil end # Confirm that this particular rule applies to the given node or edge # @param otr [KeyedArrayAccessor] confirms that # @return [Boolean] true if the identifying fields match. For node entities # the '_id' field is the key. For edges, the '_links_from' # and '_links_to' fields are the identifying fields. def match_record?(otr) return @match_fields.all?{|key| myval = @kaa[key] if myval.start_with?('?') self.class.eval_calc(myval,key,otr) else myval == otr[key] end } end # Returns true if the calculation used by #match_record?() uses complex # calculation logic def uses_dynamic_match? @match_type ||= @match_fields.any?{|k| @kaa[k].then{|v| v.start_with?('?') && v.include?('v[')} } ? :dynamic : :static return @match_type == :dynamic end # Calculates the "value" of the rule when applied to a specific record # NOTE: this does not test for #match_record?() which you probably want # to do first. Note that the three id fields are ALWAYS left as nil after this # method. Calculated values will be coerced to string via #to_s so if the # default behavior isn't the right one for you, you should convert to string yourself # This also doesn't test whether the records conform. # # @param otr [KeyedArrayAccessor] Rules must be calculated against a given # record # @return [KeyedArrayAccessor] Calculates an appropriate overlay from the rule def calculate_rule(otr) kaa = @kaa.dup kaa.kv_map!{|k,v| if v.nil? #no-op elsif ID_FIELD_NAMES.include?(k) nil # we never overlay key fields elsif v.start_with?('?') self.class.eval_calc(v,k,otr)&.to_s else v # leave field unaltered by calculation logic end } return kaa end # Evaluate a calculation using standard logic. Formulas may use syntax to # reference the values of the eval_against_key_value_map object. For example # "?v['column X'].to_f > 17.2 ? 'red' : 'black'" calcs based on 'column X' contents def self.eval_calc(rule_field_defn,this_field_name,eval_against_key_value_map) return rule_field_defn unless rule_field_defn.start_with?('?') begin ruby_code = rule_field_defn.dup.tap{|s| s[0] = ' '} # get rid of leading question mark val = EvalFrame.evaluate(ruby_code,this_field_name,eval_against_key_value_map) rescue StandardError => e #TODO: Probably will need to remove this and replace with more fault # tolerant strategy that fails gracefully (perhaps by nil-valuing the field) raise "Error attempting to evaluate this formula { #{rule_field_defn} } : #{e.message}" end case val when true,false,nil #no-op when Regexp if /^?\s*\//.match?(rule_field_defn) && ID_FIELD_NAMES.include?(this_field_name) val = val.match?(eval_against_key_value_map[this_field_name]) else raise "Rule should evaluate to a 'boolean' except when triggering Regex/glob matching on an id field." end when String if /^?\s*['"]/.match?(rule_field_defn) && ID_FIELD_NAMES.include?(this_field_name) val = File.fnmatch?(val,eval_against_key_value_map[this_field_name]) else #no-op... returning a string is a good behavior for calculations end else raise "For field [#{this_field_name}] the rule expression must evaluate to true, false, or nil except when triggering regex/glob matching on an id field." end #case return val end private # Utility class class HashMask def initialize(hashlike,this_key) @h = hashlike @tk = this_key end def include?(k) = @h.include?(k) def [](k) raise "Unable to use v[:this] because :this was not set" if k == :this && @tk.nil? @h[k == :this ? @tk : k] end end #class HashMask # Utility class class EvalFrame def initialize(test_hashlike) = @__hm = test_hashlike def v = @__hm def self.evaluate(defn,this_fieldname,test_hashlike) frame = new(HashMask.new(test_hashlike,this_fieldname)) #restrict access as tightly as reasonable frame.instance_eval(defn) # return the result end end #class EvalFrame end #class RuleRecordEvaluator end # module Nodepile