No release in over 3 years
ContextualConfig provides a flexible framework for managing configurations that can be applied based on contextual rules, priorities, and scoping. Perfect for complex applications requiring dynamic configuration resolution.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

>= 6.0
~> 3.0
~> 1.0
>= 2.1

Runtime

 Project Readme

ContextualConfig

A Ruby gem for context-aware configuration management. ContextualConfig provides a flexible framework for managing configurations that can be applied based on contextual rules, priorities, and scoping. Perfect for complex applications requiring dynamic configuration resolution.

Features

  • Context-Aware Matching: Define rules to match configurations based on runtime context
  • Priority-Based Resolution: Handle conflicts through configurable priority systems
  • Schema Validation: Optional JSON schema validation for configuration data
  • Rails Integration: Built-in Rails generators and ActiveRecord concerns
  • Flexible Scoping: Support for complex scoping rules including timing constraints
  • STI Support: Single Table Inheritance support for different configuration types

Installation

Add this line to your application's Gemfile:

gem 'contextual_config'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install contextual_config

Quick Start

1. Generate a Configuration Table

rails generate contextual_config:configurable_table YourConfig
rails db:migrate

2. Create Your Model

class YourConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation # Optional
end

3. Create Configuration Records

# Global configuration
YourConfig.create!(
  key: "notification_settings",
  config_data: { email_enabled: true, sms_enabled: false },
  scoping_rules: {}, # Empty rules = applies to everyone
  priority: 100
)

# Department-specific override
YourConfig.create!(
  key: "notification_settings", 
  config_data: { email_enabled: true, sms_enabled: true },
  scoping_rules: { department_id: "engineering" },
  priority: 50 # Higher priority (lower number)
)

# Time-based configuration
YourConfig.create!(
  key: "notification_settings",
  config_data: { email_enabled: false, sms_enabled: false },
  scoping_rules: { 
    timing: { 
      start_date: "2024-12-25", 
      end_date: "2024-12-26" 
    } 
  },
  priority: 10 # Highest priority
)

4. Lookup Configurations

# Find best matching configuration
context = { 
  department_id: "engineering", 
  current_date: Date.today 
}

config = YourConfig.find_applicable_config(
  key: "notification_settings", 
  context: context
)

# Use the configuration
if config
  settings = config.config_data
  puts "Email enabled: #{settings['email_enabled']}"
  puts "SMS enabled: #{settings['sms_enabled']}"
end

Core Concepts

Configuration Structure

Each configuration record contains:

  • key: Identifier for the configuration type
  • config_data: The actual configuration payload (JSONB)
  • scoping_rules: Rules defining when this config applies (JSONB)
  • priority: Lower numbers = higher priority (integer)
  • deleted_at: Soft delete timestamp (datetime, null for active)
  • type: For Single Table Inheritance (string, optional)

Context Matching

Configurations are matched based on context:

context = {
  department_id: "sales",
  employee_level: "senior", 
  current_date: Date.today,
  location_country: "US"
}

# This will match configs where scoping_rules contain matching values
config = YourConfig.find_applicable_config(
  key: "expense_policy", 
  context: context
)

Scoping Rules

Scoping rules define when configurations apply:

# Simple department scoping
scoping_rules: { department_id: "engineering" }

# Multiple criteria
scoping_rules: { 
  department_id: "sales", 
  employee_level: "senior" 
}

# Time-based rules
scoping_rules: { 
  timing: { 
    start_date: "2024-01-01", 
    end_date: "2024-12-31" 
  } 
}

# Global rule (matches everything)
scoping_rules: {}

Priority Resolution

When multiple configurations match:

  1. Specificity: More specific rules (more matching criteria) win
  2. Priority: Lower priority numbers win when specificity is equal
  3. Order: First match wins when priority and specificity are equal

Advanced Usage

Schema Validation

Define JSON schemas for your configuration data:

class PayrollConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    @_config_data_schema ||= {
      "type" => "object",
      "properties" => {
        "base_salary" => { "type" => "number", "minimum" => 0 },
        "currency" => { "type" => "string", "enum" => ["USD", "EUR", "SAR"] },
        "overtime_rate" => { "type" => "number", "minimum" => 1.0 }
      },
      "required" => ["base_salary", "currency"]
    }
  end
end

Custom Matchers

Extend the ContextualMatcher for custom logic:

# In your application
module MyApp
  class CustomMatcher < ContextualConfig::Services::ContextualMatcher
    private_class_method

    def self.evaluate_location_rule(rule_value, context_value)
      # Custom location matching logic
      allowed_locations = rule_value.is_a?(Array) ? rule_value : [rule_value]
      allowed_locations.include?(context_value)
    end
  end
end

Module-Specific Extensions

ContextualConfig is designed to be highly extensible for modular applications. Modules can add their own fields and customize the gem's behavior in several ways:

1. Additional Database Columns

Add module-specific columns to your configuration table:

# In migration
def change
  create_table :configurations do |t|
    # Standard ContextualConfig columns (from generator)
    t.string :key, null: false
    t.jsonb :config_data, null: false, default: {}
    t.jsonb :scoping_rules, null: false, default: {}
    t.integer :priority, null: false, default: 100
    t.datetime :deleted_at
    t.text :description
    t.string :type
    
    # Custom module-specific columns
    t.string :module_name
    t.string :created_by_user_id
    t.timestamp :effective_from
    t.timestamp :expires_at
    t.text :approval_notes
    t.jsonb :audit_log, default: {}
    
    t.timestamps null: false
  end
end

2. Module-Specific Configuration Models

Create specialized models for different modules using STI:

# Base configuration
class BaseConfiguration < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation
end

# HR Module configurations
class HR::PolicyConfiguration < BaseConfiguration
  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'leave_approval_workflow' => { 'type' => 'string' },
        'probation_period_days' => { 'type' => 'integer', 'minimum' => 30, 'maximum' => 365 },
        'performance_review_frequency' => { 'type' => 'string', 'enum' => ['quarterly', 'biannual', 'annual'] }
      }
    }
  end

  def requires_manager_approval?
    config_data['leave_approval_workflow'] == 'manager_required'
  end
end

# Finance Module configurations
class Finance::PayrollConfiguration < BaseConfiguration
  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'overtime_rate' => { 'type' => 'number', 'minimum' => 1.0, 'maximum' => 3.0 },
        'tax_calculation_method' => { 'type' => 'string', 'enum' => ['standard', 'accelerated', 'deferred'] },
        'accounting_code' => { 'type' => 'string' },
        'cost_center_allocation' => {
          'type' => 'object',
          'properties' => {
            'department_percentage' => { 'type' => 'number' },
            'project_percentage' => { 'type' => 'number' }
          }
        }
      }
    }
  end

  def calculate_overtime_pay(base_salary, hours)
    base_rate = base_salary / 160 # assuming 160 hours per month
    overtime_multiplier = config_data['overtime_rate'] || 1.5
    base_rate * overtime_multiplier * hours
  end
end

# Benefits Module configurations
class Benefits::InsuranceConfiguration < BaseConfiguration
  def self.resolve_scoping_rules_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        # Standard scoping
        'department_id' => { 'type' => 'string' },
        'employee_level' => { 'type' => 'string' },
        
        # Benefits-specific scoping
        'family_size' => { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10 },
        'age_group' => { 'type' => 'string', 'enum' => ['under_30', '30_to_50', 'over_50'] },
        'employment_tenure_months' => { 'type' => 'integer', 'minimum' => 0 },
        'previous_claims_count' => { 'type' => 'integer', 'minimum' => 0 }
      }
    }
  end

  def self.find_for_employee_insurance(employee, family_size: 1)
    context = {
      department_id: employee.department_id.to_s,
      employee_level: employee.level,
      family_size: family_size,
      age_group: employee.age_group,
      employment_tenure_months: employee.tenure_in_months
    }
    
    find_applicable_config(key: 'insurance_policy', context: context)
  end
end

3. Extended JSONB Schema

Modules can extend the config_data schema to include their specific fields:

class MultiModuleConfiguration < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        # Core fields
        'description' => { 'type' => 'string' },
        
        # HR Module fields
        'hr_approval_required' => { 'type' => 'boolean' },
        'hr_notification_emails' => { 
          'type' => 'array', 
          'items' => { 'type' => 'string', 'format' => 'email' }
        },
        
        # Finance Module fields
        'accounting_code' => { 'type' => 'string' },
        'budget_impact' => { 'type' => 'number' },
        
        # Benefits Module fields
        'benefits_integration' => {
          'type' => 'object',
          'properties' => {
            'medical_coverage_level' => { 'type' => 'string', 'enum' => ['basic', 'premium', 'executive'] },
            'vacation_carryover_allowed' => { 'type' => 'boolean' }
          }
        },
        
        # Attendance Module fields
        'attendance_tracking' => {
          'type' => 'object',
          'properties' => {
            'flexible_hours_enabled' => { 'type' => 'boolean' },
            'remote_work_percentage' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 100 }
          }
        }
      },
      'additionalProperties' => false
    }
  end
end

4. Module Registration Pattern

Create a registry system for modules:

# config/initializers/contextual_config_modules.rb
module ContextualConfig
  class ModuleRegistry
    @modules = {}
    
    def self.register(module_name, &block)
      config = ModuleConfig.new
      block.call(config) if block_given?
      @modules[module_name] = config
    end
    
    def self.get(module_name)
      @modules[module_name]
    end
    
    def self.all
      @modules
    end
  end
  
  class ModuleConfig
    attr_accessor :model_class, :default_priority, :schema_file, :key_prefix
  end
end

# Register modules
ContextualConfig::ModuleRegistry.register(:hr) do |config|
  config.model_class = HR::PolicyConfiguration
  config.default_priority = 50
  config.key_prefix = 'hr'
  config.schema_file = Rails.root.join('config/schemas/hr_policies.json')
end

ContextualConfig::ModuleRegistry.register(:finance) do |config|
  config.model_class = Finance::PayrollConfiguration  
  config.default_priority = 25
  config.key_prefix = 'finance'
  config.schema_file = Rails.root.join('config/schemas/finance_payroll.json')
end

5. Best Practices for Module Extensions

  1. Use meaningful key prefixes: hr.leave_policy, finance.overtime_rules, benefits.insurance_tiers

  2. Leverage JSON schema validation: Each module should define comprehensive schemas

  3. Create module-specific helper methods: Add convenience methods for common operations

  4. Use consistent scoping patterns: Define standard context fields that work across modules

  5. Plan for schema evolution: Design schemas that can evolve as modules add new features

STI (Single Table Inheritance)

Use STI for different configuration types:

class BaseConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
end

class PayrollConfig < BaseConfig
  validates :key, uniqueness: { scope: :type }
end

class NotificationConfig < BaseConfig
  validates :key, uniqueness: { scope: :type }
end

Real-World Examples

Example 1: Payroll Configuration System

# Global overtime policy (lowest priority)
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 1.5,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.0,
    max_overtime_hours_per_month: 40
  },
  scoping_rules: {},
  priority: 100,
  description: 'Standard company overtime policy'
)

# Engineering department gets better rates
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 1.75,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.2,
    max_overtime_hours_per_month: 60
  },
  scoping_rules: { department_id: 'engineering' },
  priority: 50,
  description: 'Engineering overtime policy'
)

# Senior engineers get premium rates
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 2.0,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.5,
    max_overtime_hours_per_month: 80
  },
  scoping_rules: { 
    department_id: 'engineering',
    employee_level: 'senior'
  },
  priority: 25,
  description: 'Senior engineering overtime policy'
)

# Holiday season enhanced rates (highest priority)
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 2.5,
    overtime_threshold_hours: 6,
    weekend_rate_multiplier: 3.0,
    max_overtime_hours_per_month: 100
  },
  scoping_rules: {
    timing: {
      start_date: '2024-12-01',
      end_date: '2024-12-31'
    }
  },
  priority: 10,
  description: 'Holiday season enhanced overtime'
)

# Usage in payroll calculation
def calculate_employee_overtime(employee, hours_worked)
  context = {
    department_id: employee.department_id.to_s,
    employee_level: employee.level,
    current_date: Date.current
  }
  
  policy = PayrollConfig.find_applicable_config(
    key: 'overtime_policy',
    context: context
  )
  
  return 0 unless policy
  
  base_rate = employee.hourly_rate
  overtime_rate = policy.config_data['overtime_rate']
  
  base_rate * overtime_rate * hours_worked
end

Example 2: Dynamic Working Hours Policy

# Standard working hours
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 8,
    weekly_hours: 40,
    break_minutes: 60,
    flexible_hours: false,
    remote_work_allowed: false
  },
  scoping_rules: {},
  priority: 100
)

# Senior staff get reduced hours
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 7,
    weekly_hours: 35,
    flexible_hours: true,
    remote_work_allowed: true
  },
  scoping_rules: { employee_level: 'senior' },
  priority: 50
)

# Individual employee exception
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 6,
    weekly_hours: 30,
    flexible_hours: true,
    remote_work_allowed: true
  },
  scoping_rules: { employee_id: 'EMP001' },
  priority: 10
)

Example 3: Benefits Configuration by Demographics

class Benefits::InsuranceConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'medical_premium' => { 'type' => 'number', 'minimum' => 0 },
        'dental_included' => { 'type' => 'boolean' },
        'vision_included' => { 'type' => 'boolean' },
        'family_coverage_multiplier' => { 'type' => 'number', 'minimum' => 1.0 },
        'max_annual_coverage' => { 'type' => 'number', 'minimum' => 0 }
      }
    }
  end

  def self.resolve_scoping_rules_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'age_group' => { 'type' => 'string', 'enum' => ['under_30', '30_to_50', 'over_50'] },
        'family_size' => { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10 },
        'employment_tenure_months' => { 'type' => 'integer', 'minimum' => 0 },
        'employee_level' => { 'type' => 'string' }
      }
    }
  end
end

# Young employees get basic coverage
Benefits::InsuranceConfig.create!(
  key: 'health_insurance',
  config_data: {
    medical_premium: 200,
    dental_included: false,
    vision_included: false,
    family_coverage_multiplier: 2.0,
    max_annual_coverage: 50000
  },
  scoping_rules: { age_group: 'under_30' },
  priority: 50
)

# Families get enhanced coverage
Benefits::InsuranceConfig.create!(
  key: 'health_insurance',
  config_data: {
    medical_premium: 150, # Discounted rate for families
    dental_included: true,
    vision_included: true,
    family_coverage_multiplier: 1.5,
    max_annual_coverage: 100000
  },
  scoping_rules: { family_size: 3 }, # 3 or more family members
  priority: 30
)

Performance Considerations

Caching System

ContextualConfig includes a sophisticated two-level caching system for optimal performance:

1. Candidates Cache

Caches the database query results (list of potential configurations) to avoid repeated database hits for the same key/context combinations.

2. Results Cache

Caches the final matched configuration after processing all matching logic, providing the fastest possible lookups for repeated requests.

Cache Configuration

# Enable caching globally
ContextualConfig.configure do |config|
  config.cache_enabled = true
  config.cache_ttl = 300 # 5 minutes
  config.cache_store = Rails.cache
end

# Fine-grained cache control in your models
class CustomConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable

  protected

  # Override to disable candidate caching for specific scenarios
  def self.cache_candidates?(key:, context:)
    # Only cache for stable contexts, not time-sensitive ones
    !context.key?(:current_timestamp)
  end

  # Override to disable result caching for specific scenarios  
  def self.cache_results?(key:, context:)
    # Cache results except for admin users who need real-time data
    context[:user_role] != 'admin'
  end

  # Override to customize candidate selection
  def self.lookup_candidates(key:, context:)
    # Custom logic for fetching candidates
    active.where(key: key.to_s, department: context[:department]).order_by_priority
  end
end

Benchmarks

Based on testing with the JisrHR application:

  • Average lookup time: ~3ms per configuration lookup (uncached)
  • Cached lookup time: ~0.1ms per configuration lookup
  • 100 concurrent lookups: Completed in ~0.3 seconds (uncached), ~0.03 seconds (cached)
  • Database indexes: Automatically created by the generator for optimal performance
  • Memory usage: Minimal - configurations are loaded on-demand with intelligent caching

Optimization Tips

  1. Enable caching: Use the built-in two-level caching system for production deployments
  2. Use appropriate indexes: The generator creates optimal indexes for common queries
  3. Customize cache behavior: Override cache control methods for fine-grained performance tuning
  4. Limit scoping rule complexity: Simpler rules = faster matching
  5. Use database-level constraints: Leverage PostgreSQL JSONB indexes for complex queries
  6. Monitor cache hit rates: Use logging to monitor cache effectiveness

Production Deployment

# config/initializers/contextual_config.rb
if Rails.env.production?
  # Configure the gem for production use
  ContextualConfig.configure do |config|
    config.cache_enabled = true
    config.cache_ttl = 300 # 5 minutes in seconds
    config.cache_store = Rails.cache
    config.enable_logging = true
    config.logger = Rails.logger
  end
end

Global Configuration

ContextualConfig provides global configuration options:

# Configure the gem
ContextualConfig.configure do |config|
  config.cache_enabled = true           # Enable caching
  config.cache_ttl = 300                # Cache TTL in seconds
  config.cache_store = Rails.cache      # Cache store to use
  config.default_priority = 100         # Default priority for configs
  config.enable_logging = true          # Enable gem logging
  config.logger = Rails.logger          # Logger to use
  config.timing_evaluation_enabled = true # Enable timing rule evaluation
end

# Access current configuration
config = ContextualConfig.configuration
puts "Cache enabled: #{config.cache_enabled?}"

# Reset configuration (useful for testing)
ContextualConfig.reset_configuration!

API Reference

Concerns

ContextualConfig::Concern::Configurable

Provides basic configuration functionality:

  • Validations for key, priority, is_active
  • Scopes: active, order_by_priority
  • Database expectations for required columns

ContextualConfig::Concern::Lookupable

Provides configuration lookup methods:

  • find_applicable_config(key:, context:) - Find best match
  • find_all_applicable_configs(context:) - Find all matches

Protected Methods for Customization:

  • lookup_candidates(key:, context:) - Override to customize candidate selection for specific keys
  • lookup_all_candidates(context:) - Override to customize candidate selection for all configurations
  • cache_candidates?(key:, context:) - Override to control candidate caching behavior
  • cache_results?(key:, context:) - Override to control result caching behavior

ContextualConfig::Concern::SchemaDrivenValidation

Provides JSON schema validation:

  • Validates config_data and scoping_rules against schemas
  • Override resolve_config_data_schema and resolve_scoping_rules_schema

Services

ContextualConfig::Services::ContextualMatcher

Core matching logic:

  • find_best_match(candidates:, context:) - Find single best match
  • find_all_matches(candidates:, context:) - Find all matches

Generators

contextual_config:configurable_table

Generate migration for configuration table:

rails generate contextual_config:configurable_table ModelName
rails generate contextual_config:configurable_table Finance::Config --table-name=finance_configs

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/bazinga012/contextual_config.

License

The gem is available as open source under the terms of the MIT License.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.