Click here to Skip to main content
Click here to Skip to main content

Custom Rule Engine in WPF - MVVM

, 11 Sep 2010
Rate this:
Please Sign up or sign in to vote.
Custom Rule Engine approach which can evaluate complex combinations of business rules. A sample application is provided in WPF following the MVVM pattern.

Table of contents

Introduction

Business rules are part of any large enterprise applications. In one of my previous projects, a lot of business rules needed to be evaluated to perform some specific business actions. So I developed a rule engine which is easy to use, can be configured easily, and is scalable. The advantages of this rule engine approach are:

  • Once the basic tables and classes are ready, adding new rules require no development/little development effort. Most of the time, adding new rules would be just inserting a few entries in the corresponding tables.
  • As introducing/changing rules involves populating tables, new rules can be added or existing rules can be modified without much development effort; a business analyst can do the population of tables to match the business requirement. This can be independent of releases.
  • This approach is scalable to handle most complex combinations of business rules.

Prerequisites

This rule engine approach is explained using a simple WPF application showing different possible combinations of rules. The sample application (CustomRulesMVVM) is developed using VS2010 and Entity Framework 4. Please download VS2010 Express edition and SQL Server 2005/2008 Express from Microsoft.

Below, I have shown the steps to connect to SQL Server from Visual Studio for those who are not familiar with it. In Visual Studio, click on "Connect to Database" in "Tools". The following window will be shown. Specify the data source and the database file name in the screen.

AddConnection.jpg

Now, click on Test Connection, and you should get the following message if SQL Express is installed in your machine.

ConnSuccess.jpg

Open Database Explorer and right click on the "Tables" folder and click "Add Query". A query pane will be opened. Paste "Table Scripts" (from the DB Scripts folder) in the query window and execute it. This will create and populate the tables required for the sample application. Click on the "Stored Procedures" folder and click on "Add New Stored Procedure". Paste the Stored Procedure given in "Stored Procedure" (in the DB Scripts folder) in the sample application. Select the query, and right click and Run Selection. The Stored Procedure will now be executed. After successfully executing all the scripts, the Database Explorer should have the following tables and procedures:

DBExplorer.jpg

Database design

The image below shows the relations between the different tables:

As shown in the entity diagram, each row in Rule table corresponds to a single business rule. Sample values in the table are given below.

Data in Rule table

RuleData.jpg

Data in Source table

SourceData.jpg

Here, RuleID is the Primary Key of the table. ValueRHS (Right Hand Side) is the value against which we are doing the comparison. Operator is the type of comparison we are making. Source comes from the Source table which denotes the object source used to retrieve the value at the LHS (Left Hand Side) of the equation. CodeLHS is used to evaluate the value from Source. RuleDescription gives the description of the rule.

E.g.: For RuleID=1, we are checking if country is USA. So, ValueRHS is 'USA', Operator is '= '. Source identifies which object has the value of country (here, 1 corresponds to Country as given in the Source table), and CountryName is the property in the Country object which has the name of the country entered by the user in the application.

RuleGroup has a combination of two or more rules:

Data in RuleGroup table

RuleGroupData.jpg

Here, you can see RuleGroupID = 2 is a combination of two rules (RuleIDs 2 and 3 in the Rule table). It is checking if City is 'NY' AND Temperature < 20. The relation between Rule and RuleGroup is given in the RuleGroupRelations table. This is the joining table for Rule and RuleGroup.

Data in RuleGroupRelations table

RuleGroupRelationsData.jpg

RuleSeqNum gives the sequence in which individual rules are joined to form a RuleGroup.

Similarly, RuleGroupGrouping has a combination of two or more RuleGroups.

Data in RuleGroupGrouping table

RuleGroupGroupingData.jpg

Here, RuleGroupGroupingID = 3 is the combination of two RuleGroups. It checks if (City is LA AND Temperature > 30) OR (State is CA AND Temperature is < 15). The relation between the RuleGroup and RuleGroupGrouping table is given in RuleGroupGroupingRelations.

Data in RuleGroupGroupingRelations table

RuleGroupGroupingRelationsData.jpg

Here, RuleGroups 3 and 4 are joined using the OR operator to form RuleGroupGroupingID = 3, whereas RuleGroup 3 is the combination of RuleIDs 4 and 5, and RuleGroup 4 is the combination of RuleIDs 6 and 7.

For a given RuleGroupGroupingID, the corresponding rules can be found by joining these tables.

In the sample application, we have three tabs to get data from the user to evaluate rules. In the first tab, the user can enter a CountryName and check whether it is USA. If it is USA, the result text will be displayed on the screen as shown below:

CountryCheck.jpg

From our table design, we know that RuleGroupGroupingID = 1 will check whether CountryName entered is USA. So we have one more table, CountryDetails, joining RuleGroupGroupingID with screenID and ResultText to be displayed.

Data in CountryDetails table

CountryDetailsData.jpg

Now all the tables are ready and populated. We have a Stored Procedure which will select the set of rules for a given screenID, as shown below:

CREATE PROCEDURE dbo.SelectRules
@screenID INT
AS
SELECT        CD.ScreenID, CD.ResultText, R.CodeLHS, R.Operator, 
              R.ValueRHS, R.Source, RGR.RuleGroupID, 
              RGR.RuleJoinOperator, RGR.RuleSeqNum, 
              RGGR.RuleGroupJoinOperator,  RGGR.RuleGroupSeqNum,
R.RuleDescription FROM            CountryDetails AS CD INNER JOIN 
              RulesGroupGroupingRelations AS RGGR ON 
              CD.RuleGroupGroupingID = RGGR.RuleGroupGroupingID 
              INNER JOIN  RulesGroupRelations AS RGR ON 
              RGGR.RuleGroupID = RGR.RuleGroupID INNER JOIN          
Rules AS R ON R.RuleID = RGR.RuleID WHERE CD.ScreenID = @screenID 
   ORDER BY RGGR.RuleGroupGroupingID, RGGR.RuleGroupSeqNum, 
   RGR.RuleGroupID, RGR.RuleSeqNum

For @ScreenID = 3, the result set of the Stored Procedure is shown below:

From this result, we can see that the first two rules to check city = LA and Temperature > 30 (RuleGroupID = 3) are joined using the AND operator, and last two rules to check state = CA and Temperature < 15 (RuleGroupID = 4) are joined using the AND operator. These two RuleGroups are joined using the OR operator. RuleSeqNum and RuleGroupSeqNum give the order in which Rules and RuleGroups are joined. Source = 2 denotes that the value of CityName and Temperature will be checked in the City object, and Source = 3 denotes that the value of StateName and Temperature will be checked in the State object. If this combination of rules are evaluated to True, the ResultText "Hi...City is LA and it is hot OR State is CA and it is cool" will be displayed on the screen as shown below:

CityStateSuccesslatest.jpg

CityStateSuccessNew.jpg

If the entered values are wrong, no result text will be displayed. Here, Temperature = 22, but our rule is: state = CA and Temperature < 20. So it is not showing any results.

CityStateFail.jpg

C# code design

Similar to the database design, in our C# code, we have CustomRule, CustomRules, and CustomRuleGroups to have mapping entries in the Rule, RuleGroup, and RuleGroupGrouping tables. All these classes implement the IRule interface.

interface IRule
{
    bool IsSelected { get; set; }
    string SelectedItem { get; set; }
    bool Eval(Dictionary<string, > collection);
}

IsSelected will have the result of the rules evaluation. SelectedItem is the result when the rules are evaluated to true. Eval() evaluates Rule/Rules or RuleGroups.

The CustomRule class is given below.

class CustomRule:IRule
{
    #region Members
    private const string COUNTRY = "COUNTRY";
    private const string CITY = "CITY";
    private const string STATE = "STATE"; 
    Country country;
    City city;
    State state;
    #endregion

    #region Properties
    public bool IsSelected { get; set; }
    public string SelectedItem { get; set; }        
    public string CodeLHS { get; set; }
    public string  Operator { get; set; }
    public string  ValueRHS { get; set; }
    public int Source { get; set; }
    public string RuleJoinOperator { get; set; }
    public int? RuleSeqNum { get; set; }       
    public string RuleDescription { get; set; }
    #endregion

    #region Public methods
    /// This function evluates each custom rule by calling method in RuleHelper       
    public bool Eval(Dictionary<string, > collection)
    {            
      //Implementation…      
    }
}

CustomRules and CustomRulesGroup implement CollectionBase in addition to IRule.

class CustomRules : CollectionBase, IRule
{
    #region Properties
    public bool IsSelected { get; set; }
    public string SelectedItem { get; set; }
    public string RuleGroupJoinOperator { get; set; }
    public int? RuleGroupSeqNum { get; set; }
    #endregion

    //Other members……

    #region CollectionBase methods
    public void Add(CustomRule item)
    {
        this.List.Add(item);
    }

    public void Remove(CustomRule item)
    {
        this.List.Remove(item);
    }
    public CustomRule Item(int index)
    {
        return this.List[index] as CustomRule;
    }
    #endregion
}

The CustomRules class has RuleGroupJoinOperator and RuleGroupSeqNum, as these are properties of RuleGroup.

class CustomRulesGroups:CollectionBase,IRule 
{
    #region Properties
    public bool IsSelected { get; set; }
    public string SelectedItem { get; set; }
    #endregion

    //Other members……
    #region CollectionBase methods
    public void Add(CustomRules item)
    {
        this.List.Add(item);
    }

    public void Remove(CustomRules item)
    {
        this.List.Remove(item);
    }
    public CustomRules Item(int index)
    {
        return this.List[index] as CustomRules;
    }
    #endregion
}

In the Converter class, we have methods to populate CustomRule, CustomRules, and CustomRulesGroups. The BuildCustomRule extension method is given below:

public static CustomRule BuildCustomRule(this SelectRules_Result entity)
{
    CustomRule custRule = new CustomRule();
    if (null != entity)
    {
        custRule.SelectedItem = entity.ResultText;
        custRule.CodeLHS = entity.CodeLHS;
        custRule.Operator = entity.Operator;
        custRule.ValueRHS = entity.ValueRHS;
        custRule.Source = entity.Source;
        custRule.RuleJoinOperator = entity.RuleJoinOperator;
        custRule.RuleSeqNum = entity.RuleSeqNum;
        custRule.RuleDescription = entity.RuleDescription;
    }

    return custRule;
}

CustomRules will be populated by calling each customrule.BuildCustomRule() method.

public static CustomRules BuildCustomRules(IList<selectrules_result /> entities)
{
    CustomRules custRules = new CustomRules();
    if (null != entities)
    {
        foreach (SelectRules_Result item in entities)
        {
            custRules.Add(item.BuildCustomRule());
            custRules.SelectedItem = item.ResultText;
            custRules.RuleGroupJoinOperator = item.RuleGroupJoinOperator;
            custRules.RuleGroupSeqNum = item.RuleGroupSeqNum;
        }
    }
    return custRules;
}

Similarly, CustomRulesGroup will be populated by calling each group's BuildCustomRules() method.

public static IRule BuildCustomRulesGroups(IList<selectrules_result > entities, 
                                           string resultText)
{
    CustomRulesGroups customRulesGroups = new CustomRulesGroups();

    if (null != entities)
    {
        //Get distinct group ids from collection
        var list = (from r in entities
                    select r.RuleGroupID).Distinct();
        foreach (int groupID in list.ToList ())
        { 
            var listRules = from rule in entities
                        where rule.RuleGroupID == groupID
                        select rule;
            customRulesGroups.Add (Converter.BuildCustomRules(listRules.ToList()));
            customRulesGroups.SelectedItem = resultText;
        }
    }
    return customRulesGroups;
}

Evaluation of CustomRulesGroup is done by calling the Eval() method of CustomRules which will in turn call the Eval() method of CustomRule.

public bool Eval(Dictionary<string,> collection)
{
    return EvaluateCustomRulesGroups(collection);
}

/// Evaluate two or more rules groups
private bool EvaluateCustomRulesGroups(Dictionary<string, > collection)
{
    //In collection of RuleGroups evaluate between first
    //two rules groups by calling Eval method of each rule group.
    if (null != this.Item(0).RuleGroupJoinOperator)
    {
        switch (this.Item(0).RuleGroupJoinOperator)
        {
            case AND:
                if (this.Item(0).Eval(collection) && this.Item(1).Eval(collection))
                    this.IsSelected = true;
                break;
            case OR:
                if (this.Item(0).Eval(collection) || this.Item(1).Eval(collection))
                    this.IsSelected = true;
                break;
            default:
                this.IsSelected = false;
                break;
        }
        //Evaluate till last rule group if more than two rules groups present 
        if (this.Count > 2)
        {
            for (int i = 1; i < this.Count - 2; i++)
            //Loop till count-2 as last rule will not have RuleJoinOperator
            {
                switch (this.Item(i).RuleGroupJoinOperator)
                {
                    case AND:
                        if (this.IsSelected && this.Item(i).Eval(collection))
                            this.IsSelected = true;
                        else
                            this.IsSelected = false;
                        break;
                    case OR:
                        if (this.IsSelected || this.Item(i).Eval(collection))
                            this.IsSelected = true;
                        else
                            this.IsSelected = false;
                        break;
                    default:
                        this.IsSelected = false;
                        break;
                }
            }
        }
    }
    else
    {
        this.IsSelected = this.Item(0).Eval(collection);
    }
    return this.IsSelected;
}

Eval() for CustomRules is:

public bool Eval(Dictionary<string, > collection)
{
    return EvaluateCustomRules(collection);
}

#endregion

#region Private Methods
/// Evaluate Two or More rules joined by AND/OR operator
private bool EvaluateCustomRules(Dictionary<string, > collection)
{
    //In Rule collection evaluate between first
    //two rules by calling Eval method of each rule.
    if (null != this.Item(0).RuleJoinOperator)
    {
        switch (this.Item(0).RuleJoinOperator)
        {
            case AND:
                if (this.Item(0).Eval(collection) && this.Item(1).Eval(collection))
                    this.IsSelected = true;
                break;
            case OR:
                if (this.Item(0).Eval(collection) || this.Item(1).Eval(collection))
                    this.IsSelected = true;
                break;
            default:
                this.IsSelected = false;
                break;
        }

        //Evaluate if more than two rules present till last rule
        if (this.Count > 2)
        {
            for (int i = 1; i < this.Count - 2; i++)
            //Loop till count-2 as last rule will not have RuleJoinOperator
            {
                switch (this.Item(i).RuleJoinOperator)
                {
                    case AND:
                        if (this.IsSelected && this.Item(i).Eval(collection))
                            this.IsSelected = true;
                        else
                            this.IsSelected = false;
                        break;
                    case OR:
                        if (this.IsSelected || this.Item(i).Eval(collection))
                            this.IsSelected = true;
                        else
                            this.IsSelected = false;
                        break;
                    default:
                        this.IsSelected = false;
                        break;
                }
            }
        }
    }
    else
    {
        this.IsSelected = this.Item(0).Eval(collection);
    }
    return this.IsSelected;
}

The evaluation of each Rule happens in the Eval() method:

public bool Eval(Dictionary<string, > collection)
{
    if (collection.ContainsKey(COUNTRY))
        country = (Country)collection[COUNTRY];
    if (collection.ContainsKey(CITY))
        city = (City)collection[CITY];
    if (collection.ContainsKey(STATE))
        state = (State)collection[STATE];

    switch (this.Source)
    {
        case 1 :
            this.IsSelected = RuleHelper.EvaluatePropertyValue(this,country);
            break;
        case 2:
            this.IsSelected = RuleHelper.EvaluatePropertyValue(this, city);
            break;
        case 3:
            this.IsSelected = RuleHelper.EvaluatePropertyValue(this, state);
            break;
        default:
            this.IsSelected = false;
            break;
    }
    return this.IsSelected;
}

EvalutePropertyValue() in the RuleHelper class is given below:

public static bool EvaluatePropertyValue(CustomRule customRule,object objSource)
{
    object valueLHS = GetValueFromObject(objSource, customRule.CodeLHS);
    return ComapareValues(Convert.ToString(valueLHS), 
                          customRule.ValueRHS, customRule.Operator);
}

private static object GetValueFromObject(object objSource, object propertyName)
{
    if (null != objSource)
    {
        PropertyInfo[] properties = objSource.GetType().GetProperties();
        foreach (PropertyInfo info in properties)
        {
            if (info.Name.ToUpper() == propertyName.ToString().ToUpper())
            {
                return info.GetValue(objSource, null);
            }
        }
    }
    return null;
}

The CompareValues method in RuleHelper does the actual comparison of ValueLHS and ValueRHS. This method will compare any two types of values based on the operator code passed.

private static bool ComapareValues(string valueLHS,string valueRHS,
                                   string operatorCode)
{
    bool isBool, isNumeric, isDateTime;
    bool boolValue1, boolValue2 = false;
    double numericValue1, numericValue2 = 0.0;
    DateTime dateValue1, dateValue2 = DateTime.Today;

    try
    {
        //Check if incoming values are boolean/Numeric/Date else it is string
        isBool = Boolean.TryParse(valueLHS, out boolValue1) && 
                 Boolean.TryParse(valueRHS, out  boolValue2);
        isNumeric = Double.TryParse(valueLHS, out numericValue1) && 
                    Double.TryParse(valueRHS, out numericValue2);
        isDateTime = DateTime.TryParse(valueLHS, out dateValue1) && 
                     DateTime.TryParse(valueRHS, out dateValue2);

        //Do comparisons based on value types
        if (operatorCode == EQUAL && 
           (!isBool && !isNumeric && !isDateTime))
        //Comparing equal condition of two string values
            return valueLHS.Equals(valueRHS, 
                   StringComparison.InvariantCultureIgnoreCase);
        else if (operatorCode == EQUAL && isNumeric)
        //Comparing if two numbers are equal
            return numericValue1 == numericValue2;
        else if (operatorCode == GREATER_THAN && isNumeric)
        //Checking if Number 1> Number 2
            return numericValue1 > numericValue2;
        else if (operatorCode == LESSER_THAN && isNumeric)
        //Checking if Number 1 < Number 2
            return numericValue1 < numericValue2;
        else if (operatorCode == EQUAL && isBool)
        //Checkingif two boolean values are equal
            return boolValue1 == boolValue2;
        else if (operatorCode == EQUAL && isDateTime)
        //Checking if two datetime values are equal
            return dateValue1.Equals(dateValue2);
        else
            return false;
    }
    catch (Exception)
    {
        return false; 
    }
}

If other evaluation conditions (like >=, <= for numbers/datetime etc.) are required, the corresponding conditions need to be added in this method.

Adding a Stored Procedure in Entity Framework

The following steps describe how to add a Stored Procedure in Entity Framework:

AddNew.jpg

ChooseModel.jpg

ChooseData.jpg

On clicking Next, a popup will ask whether the database files need to be added to the project. Select Yes if you want the .mdf files in the solution.

localdata.jpg

This window lets you select your database objects:

ChooseObjects.jpg

The following window will be displayed with Model Explorer:

Open Model Browser and select Function Imports:

ModelExplorer.jpg

Right click on "Function Imports" and click on "Add Function Import". Give a Function Import Name and select the Stored Procedure name. Inside the Stored Procedure Column Information section, click on "Get Column Information". This will populate the return type of the Stored Procedure in the window. Now, click on "Create New Complex Type". This will create a new complex return type SelectRules_Result.

AddFunctionImport.jpg

Now, SelectRules can be called as follows:

IList<selectrules_result /> rulesCollection = new List<selectrules_result />();

//Populate custom rules by calling stored procedure for a screenID
using (CustomRuleEntities context = new CustomRuleEntities())
{
    var rules = context.SelectRules(screenID);

    foreach (var item in rules)
    {
        rulesCollection.Add(item);
    }
}

Making the application follow the MVVM pattern

The sample application has a Tab control with three tab items. As shown previously, the first tab item checks if the CountryName entered is USA. This is a single Rule evaluation. The Second tab item checks if the entered CityName is NY AND Temperature is < 20, as shown below. This is a RuleGroup evaluation where two rules are joined with an AND condition.

CityTemp.jpg

The third tab contains RuleGroups where two RuleGroups are joined with an OR condition.

The MVVM pattern is used where separate ViewModel classes are created for each tab item. The View is a single XAML file, MainWindow.xaml. The structure of the application is shown below. Models have objects to hold data from each screen, and classes to hold custom rules.

SolnExplorer.jpg

Controls in each tab item are bound to properties in the corresponding ViewModels. So MainWindow.xaml.cs contains only the constructor:

using System.Collections.Generic;
using System.Windows;

namespace CustomRulesMVVM
{
    /// <summary >
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {        
        public MainWindow()
        {
            InitializeComponent();            
        }              
    }
}

Instead of the click event of buttons, a Command is used. The Command in CountryViewModel is as shown below. For Command, RelayCommand explained here is used.

/// Command for button to search country        
public ICommand SearchCountry
{
    get
    {
        if (null == this._searchCountry)
        {
            this._searchCountry = 
               new RelayCommand(param => this.SearchCountryDetails());
        }
        return this._searchCountry;
    }
}
#endregion

#region Private Methods
/// Searching country by first populating the custom rules
/// and evaluating rules by passing the Country object        
private void SearchCountryDetails()
{ 
    //Clear the result 
    this.SearchCountryResult = string.Empty;
    //Populate custom rules applicable for this
    //screen by passing the ScreenID (here ScreenID =1)
    this.PopulateCustomRules(1);
    collection[COUNTRY] = _country;
    //Evaluate the rules for Country screen
    this.SearchCountryResult = this.EvaluateCustomRules(collection); 
}               
#endregion

Conclusion

This Rule Engine approach can be used for a different business scenario by replacing the CountryDetails table with a suitable table based on the business requirements. The rest of the tables will remain the same. Similarly, populating and evaluating rules will be the same for any business scenario. So this approach can be used to evaluate any number of business rule combinations. Happy coding...

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

You may also be interested in...

About the Author

Renil Joy, Bangalore
Software Developer (Senior) Societe Generale
India India
I am a .Net developer, currently working with Societe Generale Global Solution Centre, Bangalore and was previously with Cognizant.I have more than 8 years of .Net experience in BFSI domain. I am an experienced developer in C#, VB.Net, Silverlight, WPF, WCF, LINQ, Entity Framework, SSIS, NHibernate, ASP.Net and SQL Server.
Follow on   LinkedIn

Comments and Discussions

 
SuggestionAdministration of rules table Pinmemberjrssnyder18-Feb-13 6:53 
GeneralRe: Administration of rules table PinmemberRenil Joy, Bangalore21-Mar-13 8:17 
Questionfrom where to get sqlexpress.mdf file PinmemberMember 899506428-Jun-12 20:02 
Hi,
I am new to wpf using ef with mvvm. i went through ur ur code. It's simple and easily understandable, but cannot run it. I don't know from where to get this file or how to make this application run.
AttachDbFilename=C:\Users\shruthy\Documents\sqlexpress.mdf;
 
Thanks for your effort.
Preeti
AnswerRe: from where to get sqlexpress.mdf file PinmemberRenil Joy, Bangalore29-Jun-12 13:56 
GeneralMy vote of 5 PinmemberMark Treadwell21-Sep-10 14:10 
GeneralRe: My vote of 5 PinmemberRenil Joy, Bangalore22-Sep-10 4:52 
GeneralMy vote of 5 PinmemberMarcelo Ricardo de Oliveira19-Sep-10 9:43 
GeneralRe: My vote of 5 PinmemberRenil Joy, Bangalore19-Sep-10 16:08 
GeneralGreat job PinmvpPete O'Hanlon16-Sep-10 8:17 
GeneralRe: Great job PinmemberRenil Joy, Bangalore16-Sep-10 19:58 
GeneralMy vote of 5 PinmemberJustin Mathew @ British Standard Institute (BSi)16-Sep-10 0:36 
GeneralRe: My vote of 5 PinmemberRenil Joy, Bangalore16-Sep-10 19:55 
GeneralMy vote of 5 Pinmembersam.hill13-Sep-10 17:48 
GeneralRe: My vote of 5 PinmemberRenil Joy, Bangalore14-Sep-10 7:20 
GeneralMy vote of 5 PinmentorKunalChowdhury12-Sep-10 22:51 
GeneralRe: My vote of 5 PinmemberRenil Joy, Bangalore13-Sep-10 5:00 
GeneralPretty cool, we use metadata a lot at work and its cool what can be done with it PinmvpSacha Barber11-Sep-10 21:10 
GeneralRe: Pretty cool, we use metadata a lot at work and its cool what can be done with it PinmemberRenil Joy, Bangalore12-Sep-10 3:12 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web02 | 2.8.140827.1 | Last Updated 12 Sep 2010
Article Copyright 2010 by Renil Joy, Bangalore
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid