Class: ContextualConfig::Services::ContextualMatcher

Inherits:
Object
  • Object
show all
Defined in:
lib/contextual_config/services/contextual_matcher.rb

Class Method Summary collapse

Class Method Details

.calculate_specificity_score(scoping_rules, context) ⇒ Integer

Calculates a specificity score for a set of scoping_rules against a context. A simple score: the number of explicitly defined (and matching) dimensions in the rules.

Parameters:

  • scoping_rules (Hash, nil)

    The rules from a configuration record.

  • context (Hash)

    The runtime context (used here to ensure we only score dimensions that could match).

Returns:

  • (Integer)

    The specificity score.



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/contextual_config/services/contextual_matcher.rb', line 109

def self.calculate_specificity_score(scoping_rules, context)
  return 0 if scoping_rules.blank? # Global rules have the lowest specificity.

  # Score is the number of rules that actively match a corresponding key in the context.
  scoping_rules.count do |dimension_key, rule_value|
    dimension_sym = dimension_key.to_sym
    context_value = context[dimension_sym]

    if dimension_sym == :timing
      # Timing rule contributes to specificity if it's valid and matches
      context.key?(:current_date) && evaluate_timing_rule(rule_value, context[:current_date])
    else
      # Other rules contribute if context has the key and values match
      context.key?(dimension_sym) && context_value == rule_value
    end
  end
end

.evaluate_timing_rule(timing_rule_value, current_date) ⇒ Boolean

Evaluates a 'timing' rule. Assumes rule_value is a hash like { "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD" }. Dates are inclusive. current_date must be provided in the context.

Parameters:

  • timing_rule_value (Hash)

    The timing rule definition.

  • current_date (Date, String, nil)

    The current date from the context.

Returns:

  • (Boolean)

    True if the current_date falls within the rule's range (or if range is unbounded appropriately).



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/contextual_config/services/contextual_matcher.rb', line 134

def self.evaluate_timing_rule(timing_rule_value, current_date)
  return false unless timing_rule_value.is_a?(Hash) # Rule must be a hash
  return false if current_date.nil? # Context must provide current_date

  begin
    # Ensure current_date is a Date object
    effective_current_date = current_date.is_a?(Date) ? current_date : Date.parse(current_date.to_s)

    rule_start_date_str = timing_rule_value['start_date'] || timing_rule_value[:start_date]
    rule_end_date_str   = timing_rule_value['end_date']   || timing_rule_value[:end_date]

    # Parse rule dates if they exist
    start_date = rule_start_date_str ? Date.parse(rule_start_date_str.to_s) : nil
    end_date   = rule_end_date_str   ? Date.parse(rule_end_date_str.to_s)   : nil

    # Perform checks
    matches = true
    matches &&= (effective_current_date >= start_date) if start_date
    matches &&= (effective_current_date <= end_date)   if end_date
    matches
  rescue ArgumentError # Invalid date string
    log_timing_error(timing_rule_value, current_date)
    false
  end
end

.find_all_matches(candidates:, context:) ⇒ Array<ActiveRecord::Base>

Finds all configuration records that match the given context.

Parameters:

  • candidates (ActiveRecord::Relation, Array<ActiveRecord::Base>)

    A collection of candidate configuration records.

  • context (Hash)

    The current runtime context.

Returns:

  • (Array<ActiveRecord::Base>)

    An array of all matching records, maintaining original sort order (priority).



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/contextual_config/services/contextual_matcher.rb', line 53

def self.find_all_matches(candidates:, context:)
  log_matching_attempt('find_all_matches', candidates.count, context)

  matches = candidates.select do |candidate_record|
    candidate_record.respond_to?(:scoping_rules) &&
      matches_context?(candidate_record.scoping_rules, context)
  end

  log_matching_result('find_all_matches', matches)
  matches
end

.find_best_match(candidates:, context:) ⇒ ActiveRecord::Base?

Finds the single best matching configuration record from a list of candidates. Candidates are expected to be pre-sorted by their 'priority' (higher priority first).

Parameters:

  • candidates (ActiveRecord::Relation, Array<ActiveRecord::Base>)

    A collection of candidate configuration records.

  • context (Hash)

    The current runtime context.

Returns:

  • (ActiveRecord::Base, nil)

    The best matching record, or nil if no suitable match.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/contextual_config/services/contextual_matcher.rb', line 16

def self.find_best_match(candidates:, context:)
  log_matching_attempt('find_best_match', candidates.count, context)

  best_match_record = nil
  highest_specificity_score = -1 # Initialize with a value lower than any possible score

  candidates.each do |candidate_record|
    next unless candidate_record.respond_to?(:scoping_rules) # Ensure it's a configurable record

    next unless matches_context?(candidate_record.scoping_rules, context)

    current_specificity_score = calculate_specificity_score(candidate_record.scoping_rules, context)

    # Higher specificity wins.
    # If specificity is the same, the one encountered first wins due to pre-sorting by priority.
    if current_specificity_score > highest_specificity_score
      highest_specificity_score = current_specificity_score
      best_match_record = candidate_record
    elsif current_specificity_score == highest_specificity_score && best_match_record.nil?
      # This case handles if the very first matching record has a specificity of 0 (e.g. global rule)
      # and becomes the initial best_match_record.
      best_match_record = candidate_record
    end
    # If current_specificity_score == highest_specificity_score and best_match_record is not nil,
    # the existing best_match_record (which came earlier in the pre-sorted list,
    # thus having higher or equal priority) is preferred.
  end

  log_matching_result('find_best_match', best_match_record)
  best_match_record
end

.log_matching_attempt(operation, candidate_count, context) ⇒ Object

Log matching attempt details



161
162
163
164
165
166
167
168
# File 'lib/contextual_config/services/contextual_matcher.rb', line 161

def self.log_matching_attempt(operation, candidate_count, context)
  return unless ContextualConfig.configuration.enable_logging?

  logger = ContextualConfig.configuration.effective_logger
  logger.info(
    "ContextualMatcher: #{operation} - Candidates: #{candidate_count}, Context: #{context}"
  )
end

.log_matching_result(operation, result) ⇒ Object

Log matching results



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/contextual_config/services/contextual_matcher.rb', line 171

def self.log_matching_result(operation, result)
  return unless ContextualConfig.configuration.enable_logging?

  logger = ContextualConfig.configuration.effective_logger
  result_description = if result.is_a?(Array)
                         "#{result.count} matches found"
                       elsif result
                         "Match found: #{result.key} (Priority: #{result.priority})"
                       else
                         'No match found'
                       end

  logger.info("ContextualMatcher: #{operation} - #{result_description}")
end

.log_timing_error(timing_rule_value, current_date) ⇒ Object

Log timing rule evaluation errors



187
188
189
190
191
192
193
194
# File 'lib/contextual_config/services/contextual_matcher.rb', line 187

def self.log_timing_error(timing_rule_value, current_date)
  return unless ContextualConfig.configuration.enable_logging?

  logger = ContextualConfig.configuration.effective_logger
  logger.warn(
    "ContextualMatcher: Invalid date format in timing rule: #{timing_rule_value} or current_date: #{current_date}"
  )
end

.matches_context?(scoping_rules, context) ⇒ Boolean

Checks if a given set of scoping_rules matches the provided context.

Parameters:

  • scoping_rules (Hash, nil)

    The rules from a configuration record.

  • context (Hash)

    The runtime context.

Returns:

  • (Boolean)

    True if all rules match or if rules are empty/nil, false otherwise.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/contextual_config/services/contextual_matcher.rb', line 72

def self.matches_context?(scoping_rules, context)
  return true if scoping_rules.blank? # An empty rule set is a global match.

  scoping_rules.all? do |dimension_key, rule_value|
    dimension_sym = dimension_key.to_sym
    context_value = context[dimension_sym]

    # --- Special Handling for 'timing' Dimension ---
    if dimension_sym == :timing
      # Check if timing evaluation is enabled
      if ContextualConfig.configuration.timing_evaluation_enabled?
        evaluate_timing_rule(rule_value, context[:current_date])
      else
        true # Skip timing evaluation when disabled
      end
    # --- Add other special dimension handlers here as elif/else if ---
    # Example:
    # elsif dimension_sym == :employee_location_country
    #   # Assuming rule_value is an array of allowed countries and context_value is the employee's country
    #   evaluate_location_country_rule(rule_value, context_value)
    else
      # --- Default Generic Dimension Matching ---
      # If the rule specifies a dimension, the context *must* have a value for that dimension,
      # and the values must match.
      # If you want to treat a missing key in context as "doesn't matter for this rule",
      # this logic would need to change. Current logic: rule implies context must provide.
      context.key?(dimension_sym) && context_value == rule_value
    end
  end
end