Skip to content

Commit

Permalink
feat: kotlin eval engine (#21)
Browse files Browse the repository at this point in the history
Add Kotlin Evaluator implementation
  • Loading branch information
rapatao authored Mar 25, 2024
1 parent 1db40b0 commit 2301a46
Show file tree
Hide file tree
Showing 14 changed files with 397 additions and 54 deletions.
46 changes: 42 additions & 4 deletions JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file is automatically generated during the test phase.

To see more details, check its source: [here](src/test/kotlin/com/rapatao/projects/ruleset/SerializationExamplesBuilder.kt).

* boolean:
* equalsTo:

```json
{
Expand All @@ -18,12 +18,10 @@ To see more details, check its source: [here](src/test/kotlin/com/rapatao/projec
{
"left" : "field",
"operator" : "EQUALS",
"right" : false
"right" : "true"
}
```

* equalsTo:

```json
{
"left" : "field",
Expand All @@ -50,6 +48,22 @@ To see more details, check its source: [here](src/test/kotlin/com/rapatao/projec

* notEqualsTo:

```json
{
"left" : "field",
"operator" : "NOT_EQUALS",
"right" : true
}
```

```json
{
"left" : "field",
"operator" : "NOT_EQUALS",
"right" : "true"
}
```

```json
{
"left" : "field",
Expand Down Expand Up @@ -327,6 +341,30 @@ To see more details, check its source: [here](src/test/kotlin/com/rapatao/projec
}
```

```json
{
"left" : "item.trueValue",
"operator" : "EQUALS",
"right" : "true"
}
```

```json
{
"left" : "item.trueValue",
"operator" : "NOT_EQUALS",
"right" : "false"
}
```

```json
{
"left" : "item.trueValue",
"operator" : "NOT_EQUALS",
"right" : false
}
```

```json
{
"left" : "item.field.that.dont.exist",
Expand Down
70 changes: 47 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,29 @@
[![Maven Central](https://img.shields.io/maven-central/v/com.rapatao.ruleset/ruleset-engine.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.rapatao.ruleset%20AND%20a:ruleset-engine)
[![Sonatype OSS](https://img.shields.io/nexus/r/com.rapatao.ruleset/ruleset-engine?label=Sonatype%20OSS&server=https%3A%2F%2Foss.sonatype.org)](https://ossindex.sonatype.org/component/pkg:maven/com.rapatao.ruleset/ruleset-engine)

A simple rule engine that uses [Rhino](https://github.com/mozilla/rhino) implementation of JavaScript to evaluate
expressions.
Simple yet powerful rules engine that offers the flexibility of using the built-in engine and creating a custom one.

## Available Engines

Below are the available engines that can be used to evaluate expressions:

### Mozilla Rhino (JavaScript) engine implementation

[Rhino](https://github.com/mozilla/rhino) is an open-source, embeddable JavaScript interpreter from Mozilla.
This engine implementation supports using JavaScript expressions inside the rule operands.

### Kotlin (internal) engine implementation

This engine only uses Kotlin code to support all Operator functions., supporting all Operator functions.
Although it provides an expressive performance, it doesn't support Kotlin expression into the expression operands.

Supported types:

1. primitive Java types, boolean, string, number (extends)
2. custom objects (reflection)
3. maps
4. lists
5. arrays

## Get started

Expand All @@ -27,6 +48,30 @@ implementation "com.rapatao.ruleset:ruleset-engine:$rulesetVersion"
</dependency>
```

### Usage

```kotlin

import com.rapatao.projects.ruleset.engine.Evaluator
import com.rapatao.projects.ruleset.engine.types.builder.equalsTo

val rule = "item.price" equalsTo 0

// val engine = RhinoEvalEngine() // default engine
// val engine = KotlinEvalEngine()
val evaluator = Evaluator(/* engine = engine */)

val result = evaluator.evaluate(rule, mapOf("item" to mapOf("price" to 0)))
println(result) // true


data class Item(val price: Double)
data class Input(val item: Item)

val result2 = evaluator.evaluate(rule, Input(item = Item(price = 0.0)))
println(result) // true
```

## Supported operations (expressions)

The engine only supports `boolean` evaluations, which means that all operations must results in a boolean value.
Expand Down Expand Up @@ -144,24 +189,3 @@ val asMatcher: Expression = mapper.readValue(json)
```

Serialized examples can be checked [here](JSON.md)

## Usage example

```kotlin
import com.rapatao.projects.ruleset.engine.Evaluator
import com.rapatao.projects.ruleset.engine.types.builder.equalsTo

val rule = "item.price" equalsTo 0

val evaluator = Evaluator()

val result = evaluator.evaluate(rule, mapOf("item" to mapOf("price" to 0)))
println(result) // true


data class Item(val price: Double)
data class Input(val item: Item)

val result2 = evaluator.evaluate(rule, Input(item = Item(price = 0.0)))
println(result) // true
```
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ class Evaluator(
}
}

/**
* Returns the evaluation engine being used by the Evaluator.
*
* @return The instance of EvalEngine that is used to create a context for evaluating expressions.
*/
fun engine() = engine

private fun List<Expression>.processNoneMatch(context: EvalContext): Boolean {
return this.none {
val isTrue = it.takeIf { v -> v.parseable() }?.processExpression(context) == true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
package com.rapatao.projects.ruleset.engine.context

/**
* The EvalEngine interface defines the contract for an evaluation engine that can execute
* a block of code with given input data and return a result.
* Implementations of this interface are responsible for providing the execution environment
* and handling the invocation of the provided block of code.
*/
interface EvalEngine {

/**
* Executes the provided block of code with the given input data and returns a boolean value indicating
* the success or failure of the execution.
*
* @param inputData The input data to be used in the execution.
* @param block A lambda function that takes in a context and a scope as parameters and returns a boolean value.
* The context represents the context in which the execution takes place, and the scope represents
* the scope of the execution.
* @return The result of the execution.
*/
fun <T> call(inputData: Any, block: EvalContext.() -> T): T

/**
* Returns the name of the evaluation engine. This can be used to identify the engine in logging or debugging.
*
* @return The name of the evaluation engine.
*/
fun name(): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.rapatao.projects.ruleset.engine.evaluator.kotlin

import com.rapatao.projects.ruleset.engine.context.EvalContext
import com.rapatao.projects.ruleset.engine.evaluator.kotlin.Parser.parse
import com.rapatao.projects.ruleset.engine.types.Expression
import java.math.BigDecimal

/**
* KotlinContext is a class that implements the EvalContext interface.
* It provides the ability to process expressions using Kotlin operations.
*
* @param inputData the map containing the input data to be used during expression evaluation
*/
class KotlinContext(
private val inputData: Map<String, Any?>
) : EvalContext {

override fun process(expression: Expression): Boolean {
return parse(
expression,
expression.left.asValue(),
expression.right.asValue()
)
}

private fun Any?.asValue(): Any? {
val result = when {
this is String && !this.trim().matches(Regex("^\".*\"$")) -> rawValue()
this is String && this.trim().matches(Regex("\".*\"")) -> this.unwrap()
else -> this
}

return when {
result is Number && (result is Double || result is Float) -> BigDecimal(result.toDouble())
result is Number && result !is Byte -> BigDecimal.valueOf(result.toLong())
else -> result
}
}

private fun String.rawValue(): Any? {
val key = this.unwrap()

return listOf(
{
key.toBigIntegerOrNull()
},
{
key.toBigDecimalOrNull()
},
{
key.toBooleanStrictOrNull()
},
{
inputData.getOrElse(key) {
throw NoSuchElementException("$key not found")
}
},
).firstNotNullOfOrNull { it() }
}

private fun String.unwrap() = this.trim()
.replace(Regex("^\""), "")
.replace(Regex("\"$"), "")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.rapatao.projects.ruleset.engine.evaluator.kotlin

import com.rapatao.projects.ruleset.engine.context.EvalContext
import com.rapatao.projects.ruleset.engine.context.EvalEngine
import kotlin.reflect.full.memberProperties

/**
* An evaluator engine implementation that uses Kotlin to process expressions.
*
* Supported types: Java primitive types, boolean, string, number types, maps, lists and arrays.
*/
open class KotlinEvalEngine : EvalEngine {
override fun <T> call(inputData: Any, block: (context: EvalContext) -> T): T {
val params: MutableMap<String, Any?> = mutableMapOf()

parseKeys("", params, inputData)

return block(KotlinContext(params))
}

override fun name(): String = "KotlinEval"

private fun parseKeys(node: String, params: MutableMap<String, Any?>, input: Any?) {
when {
input.isValue() -> {
params[node] = input
}

input is Collection<*> -> {
params[node] = input

input.forEachIndexed { index, value ->
parseKeys("${node}[${index}]", params, value)
}
}

input is Array<*> -> {
parseKeys(node, params, input)
}

input is Map<*, *> -> {
val currNode = if (node.isNotBlank()) {
"${node}."
} else {
node
}
input.forEach { key, value ->
parseKeys("${currNode}${key}", params, value)
}
}

else -> {
val currNode = if (node.isNotBlank()) {
"${node}."
} else {
node
}

input?.javaClass?.kotlin?.memberProperties?.map {
parseKeys("${currNode}${it.name}", params, it.get(input))
}
}
}
}

private fun Any?.isValue(): Boolean =
this == null ||
this.javaClass.isPrimitive ||
this is Boolean ||
this is String ||
this is Number
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.rapatao.projects.ruleset.engine.evaluator.kotlin

import com.rapatao.projects.ruleset.engine.types.Expression
import com.rapatao.projects.ruleset.engine.types.Operator

internal object Parser {
fun parse(expression: Expression, left: Any?, right: Any?) =
when (expression.operator) {
Operator.EQUALS -> left == right
Operator.NOT_EQUALS -> left != right
Operator.GREATER_THAN -> left.comparable() > right
Operator.GREATER_OR_EQUAL_THAN -> left.comparable() >= right
Operator.LESS_THAN -> left.comparable() < right
Operator.LESS_OR_EQUAL_THAN -> left.comparable() <= right
Operator.STARTS_WITH -> left.toString().startsWith(right.toString())
Operator.ENDS_WITH -> left.toString().endsWith(right.toString())
Operator.CONTAINS -> left.checkContains(right)
null -> error("when evaluation an expression, the operator cannot be null")
}

@Suppress("UNCHECKED_CAST")
private fun <T> T.comparable() = this as Comparable<T>

private fun Any?.checkContains(value: Any?): Boolean {
return when {
this is String && value is String -> this.contains(value)
this is Collection<*> -> this.contains(value)
this is Array<*> -> this.contains(value)
else -> throw UnsupportedOperationException("contains doesn't support ${this?.javaClass} type")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ import com.rapatao.projects.ruleset.engine.types.Operator
internal object Parser {
fun parse(expression: Expression): String {
return when (expression.operator) {
Operator.EQUALS -> "==".formatComparison(expression)
Operator.NOT_EQUALS -> "!=".formatComparison(expression)
Operator.GREATER_THAN -> ">".formatComparison(expression)
Operator.GREATER_OR_EQUAL_THAN -> ">=".formatComparison(expression)
Operator.LESS_THAN -> "<".formatComparison(expression)
Operator.LESS_OR_EQUAL_THAN -> "<=".formatComparison(expression)
Operator.NOT_EQUALS -> "!=".formatComparison(expression)
Operator.STARTS_WITH -> "startsWith".formatWithOperation(expression)
Operator.ENDS_WITH -> "endsWith".formatWithOperation(expression)
Operator.CONTAINS -> formatContainsOperation(expression)
else -> "==".formatComparison(expression)
null -> error("when evaluation an expression, the operator cannot be null")
}
}

Expand Down
Loading

0 comments on commit 2301a46

Please sign in to comment.