Writing Rules
Rules are classes that contain instance of Input
filled with UnaryTest
s and instance of Output
filled
with OutputValue
s.
class Rule[Input[_[_]], Output[_[_]]](
matching: Input[UnaryTest],
output: Output[OutputValue]
)
UnaryTest
is a simple predicate that loosely follows
the FEEL model. OutputValue
exposes implicit conversions for better UX. Both are specialized views on Expr
.
trait UnaryTest[-T] extends Expr[T => Boolean]
opaque type OutputValue[T] <: Expr[T] = Expr[T]
object OutputValue {
implicit def toLiteral[T](t: T)(using LiteralShow[T]): OutputValue[T] = Literal(t)
implicit def fromExpr[T](expr: Expr[T]): OutputValue[T] = expr
}
So what is Expr
? It's an expression that can be statically rendered into a string.
trait Expr[+Out] {
def evaluate: Out
def renderExpression: String
}
All the most common ways of building UnaryTest
s are accessible through it
object.
Implicit conversion between Expr[Boolean]
and UnaryTest
is also defined.
Built-in Expressions
Decisions4s
provides basic numeric and boolean expressions that can be used by invoking methods on
expressions.
Raw values can be converted into expression through Literal
and asLiteral
extension method.
import decisions4s.*
val a: Expr[Int] = 1.asLiteral
val b: Expr[Boolean] = a > 0
val lowerThan5: UnaryTest[Int] = it < 5
val equalFoo: UnaryTest[String] = it.equalsTo("foo")
val complex: UnaryTest[Int] = it.satisfies(v => v > 1 && v < 5)
Custom Expressions
To define a custom expression its enough to extend Expr
trait.
case class EndsWithFoo(argument: Expr[String]) extends Expr[Boolean] {
override def evaluate: Boolean = argument.evaluate.endsWith("foo")
override def renderExpression: String = s"endsWithFoo(${argument.renderExpression})"
}
val endsWithFoo: UnaryTest[String] = it.satisfies(EndsWithFoo.apply)
val endsWithFoo2: Expr[Boolean] = EndsWithFoo(Literal("myfoo"))
This can be further streamlined by using Function
helper
extension (arg1: Expr[String]) {
def endsWith(arg2: Expr[String]) = Function.autoNamed[Boolean](arg1, arg2)(_.endsWith(_))
}
val endsWithFoo: UnaryTest[String] = it.satisfies(_.endsWith("foo".asLiteral))
FEEL Compatibility
The expressions provided by the library guarantee compatibility with FEEL. This means their rendered form, when evaluated, yields the same result as direct evaluation. This guarantee is provided to lower the mental load so that we can rely on a properly specified format instead of defining our own. Having said that, it's important to remember that rendered form is not intended to be evaluated. Decisions4s will use direct evaluation when evaluating decision tables.
User-defined expressions don't have to keep FEEL compatibility.
Accessing Other Inputs
By default, all matching logic operates on the input it is defined for.
To access other pieces of input one can use wholeInput
method.
The same way can be used to build the output value based on inputs.
The example below compares a
with b
and returns their sum if they are equal.
Rule(
matching = Input(
a = it.equalsTo(wholeInput.b),
b = it.catchAll,
),
output = Output(
c = wholeInput.a + wholeInput.b,
),
)
Using Data Structures As Inputs And Outputs
This feature is experimental and might come with significantly rough edges around its API.
For more complicated decisions, it might be useful to group inputs or outputs into dedicated objects. Decisions4s supports this scenario through nested higher kinded data structures.
case class Name[F[_]](first: F[String], last: F[String]) derives HKD
case class Input[F[_]](motherName: F[Name[F]], fatherName: F[Name[F]]) derives HKD
case class Output[F[_]](childName: F[Name[F]]) derives HKD
Rule(
matching = Input(
motherName = it.catchAll,
fatherName = wholeInput[Input].fatherName.projection.last === "Smith",
),
output = ctx ?=>
Output(
childName = Name[OutputValue](
wholeInput.fatherName.projection.first,
wholeInput.motherName.projection.last,
),
),
)
As of now, matching has to be done through wholeInput
and no method of it
will work.
Nested data structures will be rendered as FEEL Context Expressions.