FORMULATION IN C#

This article has been written by Francisco Refoyo Andrés, software developer at Ilitia Technologies.

The full code referenced by the article is available at GitHub.

Introduction

All applications which intensively use numeric data may need to make calculations configured by the user.

Due to this situation, if the pre-calculated operations are not enough, a mechanism will be needed for the user to indicate what operations the system needs to do, in order to meet their requirements.

Goal and premises

Definition of an object structure in C#, which can provide a working framework for calculating the result of a formula according to the input parameters and the operations that have been defined.

  • The formulation is typed. It will be able to generate errors at compile time.
  • The input data are generic.
  • The result will be a primitive type.
  • The system will be open to extension and closed to modification. This principle must always be considered since new operators can be needed to implement, according to the requirements of the information system.
  • The formulas have to be able to persist on disk and be rebuilt from their text representation. The format will be XML.
  • The formulas are independent of the execution context. The formulation represents different blocks with deterministic operations. If we always have the same input data, we will always obtain the same result.

Definition of basic concepts

The elementary units are the blocks. The base structure is made of:

  • BaseBlock. It is the root of the hierarchy. It is not typed. It contains the necessary mechanisms to access the container block, and it contains the signature for building the expressions on the basis of the blocks; this is essential, because every block includes a C# Expression.
  • Block<TPrimitiveType>. It is the entry point for the typed blocks. The generic type will restrict the possible blocks which can be placed inside the concerned block. The fact that the generic blocks reference a primitive type (PrimitiveType) is an established restriction.
  • ProjectionBlock<TPrimiveType>. Every block which needs the selection of a data context property must inherit from this block. It exposes the property which will be selected.
  • Binary<TOperands, TPrimitiveType>. It provides the possibility of having blocks formed by two operands. TOperands restricts the type of the underlying blocks so that both of them must be Block<TOperands>.

As shown before, the blocks are restricted by the concept of PrimitiveType. It allows to type the blocks with more than a C# basic type. The goal is to be able to use different types of blocks, so that the access by defined capabilities through interfaces is limited.

All non-abstract PrimitiveType references and contains a primitive type C#:

  • PrimitiveType. It is an abstract base type. It is useful to indicate that any primitive type is permitted without using a specific type. It abstractly exposes the underlying type.
  • BaseType<TResult>. It is a typed base element. It has the implementation for knowing the underlying type.
  • BoolType. Encapsulation of a Boolean.
  • NumericType. It is a numeric double primitive type.
  • StringType. It references the type string.

Formulas

The process of defining a formula is simple. It is necessary to previously know the type of value that the formula must return, establish the PrimitiveType associated with the returned type and indicate the data context which will be used.

A simple scenario to obtain a Boolean with value ‘true’ is shown here:

Formula<bool, BoolType, TestDataContext> formula = new Formula<bool, BoolType, TestDataContext>()
{
Operations = new BoolConstant(true),
};
formula.Calculate(TestDataContext.Instance);

As you can be seen from the source code above, the generic parameters of the formula are:

  • Bool. A Boolean will be obtained.
  • BoolType. PrimitiveType associated with bool. It is the entry point for typing the blocks and reducing errors at compile time.
  • TestDataContext. Data context used for the formula.

All formulas contain a property Operations of type Block<TPrimitiveType>, which serves as entry point for the definition of the formula blocks. In the previously shown case, Operations is of type Block<BoolType>, so the whole block we are going to assign shall inherit from Block<BoolType> and it ensures us that we directly or indirectly are going to obtain a value of Boolean type.

In order to obtain a value from a formula, the only thing to do is execute the method Calculate and get the returned value.

Blocks

When we know the basic elements and how to define a formula, it is necessary to know which of the possible blocks are needed for building complex formulas.

The classification, according to the type, is as follows:

Constant blocks:

They are useful for defining constants in formulas. These may be numeric (Double), Boolean or the type string.

Arithmetic operators:

They can be divided into two groups:

Binary blocks:

Add<TArithmeticType>. It allows us to add two blocks, which implement the interface IArithmeticType (NumericType).

Example: 15.3 + 2.5

new Add<NumericType>(new NumericConstant(15.3D), 
new NumericConstant(2.5D))

Subtract<TArithmeticType>. It subtracts from two blocks of IArithmeticType type.

Example: 15.3–2.3

new Subtract<NumericType>(new NumericConstant(15.3D), 
new NumericConstant(2.3D))

Divide. It is the division of blocks IArithmeticType.

Example: 10 / 2

new Divide(new NumericConstant(10D), new NumericConstant(2D))

Multiply. It is the multiplication of blocks IArithmeticType.

Example: 10 * 2

new Multiply(new NumericConstant(10D), new NumericConstant(2D))

Pow. It raises the first operand to the value indicated by the second operand. These must be of IArithmeticType type.

Example: 2 ^ 3

new Pow(new NumericConstant(2D), new NumericConstant(3D))

Non-binary blocks:

The following blocks only work under NumericType.

Abs. It allows to calculate the absolute value of a numeric block.

Example: Abs(-10) = 10

new Abs(new NumericConstant(-10D))

Interpolation. It is a mathematical operation of interpolation. According to its own definition, it needs five parameters.

Example: Interpolation(1, 2, 3, 4, 5) = 6

new Interpolation(new NumericConstant(1D),
new NumericConstant(2D),
new NumericConstant(3D),
new NumericConstant(4D),
new NumericConstant(5D))

Round. It uses the rounding operator to the first block, using the integral value from the second block as precision.

Example: Round(9.5698, 2)

new Round(new NumericConstant(9.5698D), 2)

Sqrt. It is an operator, which generates the square root of a numeric block.

Example: √9

new Sqrt(new NumericConstant(9D))

logical operators

There are two logical blocks defined and both are binary: And and Or.

And. It returns true if and only if the two operands are true.

Example: true && true

new And(new BoolConstant(true), new BoolConstant(true))

Or. It returns true if one of the two operands is true.

Example: true && false

new Or(new BoolConstant(true), new BoolConstant(false))

Comparison blocks

All formulation systems must have the capacity to assess comparisons. All the defined comparison blocks are binary and the underlying type that must be returned is Boolean. The comparison operations are defined according to all the TPrimitiveType and it is required that both operands are of the same type.

Equal<TOperands>. It is an equal comparison.

Example: true == true

new Equal<BoolType>(new BoolConstant(true), new BoolConstant(true))

NotEqual<TOperands>. It returns true if the value is different to the other value.

Example: false == true

new NotEqual<BoolType>(new BoolConstant(false), 
new BoolConstant(true))

GreaterThan<TOperands>. It is the greater-than operator.

Example: 50 > 10

new GreaterThan<NumericType>(new NumericConstant(50D), 
new NumericConstant(10D)

GreaterThanOrEqual<TOperands>. It is the greater-than or equal to operator.

Example: 50 >= 50

new GreaterThanOrEqual<NumericType>(new NumericConstant(50D), 
new NumericConstant(50D))

LessThan<TOperands>. It is the less-than operator.

Example: 3 < 10

new LessThan<NumericType>(new NumericConstant(3D), 
new NumericConstant(10D))

LessThanOrEqual<TOperands>. It is the less-than or equal to operator.

Example: 50 <= 50

new LessThanOrEqual<NumericType>(new NumericConstant(50D), 
new NumericConstant(50D))

Conditional blocks

A conditional block which will be assigned to one or another block, depending on the assessment of a condition, has been defined: IfElse.

Example: If(3 == 5) { true } else { false }

new IfElse<NumericType, BoolType>(
new Equal<NumericType>(new NumericConstant(3D),
new NumericConstant(5D)),
new BoolConstant(true),
new BoolConstant(false))

Access to the data context

When a formula is defined, one of the parameters is the data context. The system has different blocks, which allow to scroll through the data context and obtain information from it.

  • DataNavigation<TPrimitiveType>. It is used for moving around the data context with a property. It is indispensable that the type and name of the property indicated by the Property PropertyName of the block are consistent with the data context, since the formulation system does not perform verification at runtime.

Example: Scroll through the Boss property and read the Email field.

new DataNavigation<StringType>(
nameof(TestDataContext.Boss),
new ReadData<StringType>(nameof(Person.Email))),
  • ReadData<TPrimitiveType>. It is the reading of a Property from the data context. Its use is shown in the DataNavigation example.
  • GlobalDataNavigation<TPrimitiveType>. It establishes the data context in the root, in the object indicated in the execution time of the formula. The action is done regardless of the current position of the context.

Example: Reading of the property Department which is placed in the root of the data context.

new GlobalDataNavigation<StringType>(
new ReadData<StringType>(nameof(TestDataContext.Department))

Collection blocks

There are two groups of operations defined in the collections: the operations which project a field and inherit from ProjectionBlock<TPrimitiveType> and the operations which do not. This block type operates on a data context, which references a collection.

The blocks inheriting from ProjectionBlock<TPrimitiveType> can work with a C# primitive type collection or with an object collection referencing the Property, which is indicated through SelectedProperty.

The interface IArithmeticType has been defined to allow us to indicate what types have the ability of operating in arithmetic blocks of collections. Only the type NumericType has this option, but we remind that the system is extensible.

  • Average<TArithmeticType>. It calculates the average.

Example: Average remaining vacation of the subordinates.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new Average<NumericType>(nameof(Person.RemainingVacation)))
  • Concat. It concatenates a list of string.

Example: Concatenation of the names of subordinates, separated by a semi colon.

new DataNavigation<StringType>(
nameof(TestDataContext.Subordinates),
new Concat(nameof(Person.Name), ";"))
  • First<TPrimitiveType>. It recovers the first value of a list.

Example: The identifier of the first subordinate.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new First<NumericType>(nameof(Person.Id)))
  • GeometricMean<TArithmeticType>. It is the geometric mean.

Example: Geometric mean of the remaining vacation of the subordinates.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new GeometricMean<NumericType>(nameof(Person.RemainingVacation)))
  • Max<TArithmeticType>. It calculates the maximum value of a list.

Example: maximum amount of remaining vacation of the subordinates.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new Max<NumericType>(nameof(Person.RemainingVacation)))
  • Min<TArithmeticType>. It is the minimum value of a list.

Example: minimum number of vacation of the subordinates.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new Min<NumericType>(nameof(Person.RemainingVacation)))
  • Sum<TArithmeticType>. It is the sum of all the values of a list.

Example: the sum of the salary of all subordinates.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new Sum<NumericType>(nameof(Person.Salary)))
  • Count. It calculates the number of elements of a list.

Example: the number of subordinates in a department.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates), new Count())
  • Where<TComparison, TPrimitiveType>. It applies a condition for filtering the number of elements of a list.

Example: the sum of the salaries of the subordinates earning more than 40000.

new DataNavigation<NumericType>(
nameof(TestDataContext.Subordinates),
new Where<NumericType, NumericType>(
new GreaterThan<NumericType>(
new ReadData<NumericType>(nameof(Person.Salary)),
new NumericConstant(40000D)),
new Sum<NumericType>(nameof(Person.Salary))))

Examples of use

Now that we know all the possible blocks to use, we are going to show several examples of real situations.

To simplify the shown code, the creation of the formula and the corresponding assignment of the formula to the Property Operations are not shown.

Example 1. Name of the subordinate who has the greatest number of vacation.

Explanation. A Where filter is applied to all the subordinates, to recover the subordinate whose remaining vacation is equal to the maximum of remaining vacation of all the subordinates. A First is applied to the result of the previous operation, in order to recover the name of the first result in the list.

If there are several subordinates with the same remaining vacation, the name of the first of them is obtained. We allow this, because the aim of this example is just didactic.

Example 2. Sum of the remaining vacation of the active subordinates.

Explanation. A Where filter is applied to all subordinates to obtain the active subordinates. The addition operator is executed over the property RemainingVacation to reach the goal.

Example 3. Number of subordinates who are married.

Explanation. A Where is applied to the subordinates to filter the subordinates who are married (IsMarried == true). A Count is applied to the outcome, to obtain the desired value.

Persistence

The formulation system has a persistence mechanism, which is based on XML serialization.

For generating an XML representing the formula, all that is needed is to execute the static procedure SerializeFormula, using the instance of the formula as argument:

string xml = Formula<TResult, TPrimitiveType, TestDataContext>
.SerializeFormula(formula);

The regeneration process of the formula’s instance from the XML that represents it, is based on the execution of the method DeserializeFormula, as shown in the following snippet:

Formula<TResult, TPrimitiveType, TestDataContext> newFormula =        
Formula<TResult, TPrimitiveType, TestDataContext>
.DeserializeFormula(xml);

In this way, we can persist the formulas on disk when they are defined, in order to reuse them in future executions.

The main advantage of having a model of objects encapsulating operations, is that it makes easy to generate another abstraction in the visual layer, so the user can interact with the formulas defined at runtime.

Other techniques such as Serialize.Linq may be used, but we would have to use a much poorer semantic context, which would end up affecting the implementation time of the visual layer and its maintenance.

As an example, the result of the serialization of a formula is shown:

Extension of the system

The formulation system, which has been explained, has a large number of blocks to use. Since every context is potentially different from all the other contexts, it is possible that the construction of new blocks is needed. In this case, the following three steps must be taken:

  1. Identify the type of block from which another block will inherit. To that end, several questions must be answered.

2. Know the type restrictions which must be included in the new block. It will depend on the implementation which will be done and on the returned value.

3. Create the class that represents the block and implement the BuildExpression operation.

Use cases

The proposed solution is useful when it is not possible to set a series of operations to apply on a dataset to meet the requirements of the user.

There are some domain models which need freedom to perform operations, because these operations change according to the context and it is not possible to limit these to a finite set of operations.

Example 1: Software for performing feasibility studies with reference indexes established by personal experience of users. Some of these indexes cannot be established at compile time, so it is needed to be able to modify the calculation process according to the user’s criteria (evolves over time).

Example 2: Creation of templates for sending emails. In the real world, the need of sending notifications by email is very common. If these notifications must be dynamic and the responsibility is delegated to the user, a procedure is needed to obtain and transform the data which generates the output text together with the templates.

Conclusions

Thanks to the explained model, we can integrate the extraction and manipulation of dynamic data without building a complex system of operations which would probably need assistance of computer programmers for its evolution and maintenance.