diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 979cb22..478dd7e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '11' + java-version: '17' cache: 'gradle' - name: install gpg diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f4e8827..af3a2f4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '11' + java-version: '17' cache: 'gradle' - name: run project tests diff --git a/.gitignore b/.gitignore index c2f84a2..06e8f5d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,4 @@ nbdist/ classes out/ .scannerwork/ - -create-order-db.sql -create-sku-db.sql +bench_*.txt diff --git a/README.md b/README.md index 613369b..5a115e5 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,7 @@ Simple yet powerful rules engine that offers the flexibility of using the built- Below are the available engines that can be used to evaluate expressions: -### Mozilla Rhino (JavaScript) engine implementation - -[Mozilla 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 and is particularly useful -when rules contain complex logic or when you want to leverage JavaScript's extensive library of functions. - -### Kotlin (internal) engine implementation +### Kotlin engine implementation This engine uses only Kotlin code to support all Operator functions, offering expressive performance. Although it doesn't support Kotlin expressions inside the expression operands, it can be a suitable choice for simpler rule sets or @@ -29,41 +23,104 @@ Supported types: 4. lists 5. arrays -## Get started +```kotlin +val engine = KotlinEvalEngine() +``` + +#### Gradle + +```groovy +implementation "com.rapatao.ruleset:kotlin-evaluator:$rulesetVersion" +``` + +#### Maven + +```xml + + + com.rapatao.ruleset + kotlin-evaluator + $rulesetVersion + +``` + +### Mozilla Rhino (JavaScript) engine implementation + +[Mozilla 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 and is particularly useful +when rules contain complex logic or when you want to leverage JavaScript's extensive library of functions. -To get started, add the following dependency: +```kotlin +val engine = RhinoEvalEngine() +``` + +#### Gradle + +```groovy +implementation "com.rapatao.ruleset:rhino-evaluator:$rulesetVersion" +``` + +#### Maven + +```xml + + + com.rapatao.ruleset + rhino-evaluator + $rulesetVersion + +``` + +### GraalVM (JavaScript) engine implementation + +[GraalJS](https://www.graalvm.org/latest/reference-manual/js/) is a high-performance JavaScript engine. +This engine implementation supports using JavaScript expressions inside the rule operands and is particularly useful +when rules contain complex logic or when you want to leverage JavaScript's extensive library of functions. + +```kotlin +val engine = GraalJSEvalEngine() +``` -### Gradle +#### Gradle ```groovy -implementation "com.rapatao.ruleset:ruleset-engine:$rulesetVersion" +implementation "com.rapatao.ruleset:graaljs-evaluator:$rulesetVersion" ``` -### Maven +#### Maven ```xml com.rapatao.ruleset - ruleset-engine + graaljs-evaluator $rulesetVersion ``` -### Usage +## Get started + +After adding the desired engine as the application dependency, copy and past the following code, replacing +the `val engine: EvalEngine = ...` by the desired engine initialization instruction. + +The following example initializes an `Evaluator`, and check if the given `rule` is valid to the given `input` data, +printing the `result` in the default output. + +### Code example ```kotlin import com.rapatao.projects.ruleset.engine.Evaluator +import com.rapatao.projects.ruleset.engine.context.EvalEngine import com.rapatao.projects.ruleset.engine.types.builder.equalsTo val rule = "item.price" equalsTo 0 +val input = mapOf("item" to mapOf("price" to 0)) -// val engine = RhinoEvalEngine() // default engine -// val engine = KotlinEvalEngine() -val evaluator = Evaluator(/* engine = engine */) +val engine: EvalEngine = ... +val evaluator = Evaluator(engine = engine) -val result = evaluator.evaluate(rule, mapOf("item" to mapOf("price" to 0))) +val result = evaluator.evaluate(rule, input) println(result) // true @@ -171,7 +228,9 @@ Expression( ) ```` -## JSON Serialization +## Expression serialization + +### Jackson All provided operations supports serialization using [Jackson](https://github.com/FasterXML/jackson) with the definition of a Mixin. @@ -191,3 +250,6 @@ val asMatcher: Expression = mapper.readValue(json) ``` Serialized examples can be checked [here](JSON.md) + +> Although the example only uses `JSON` as reference, by using the given `Mix-in` class, it should support any +> serialization format provided by the Jackson library, like `YAML` and `XML`. diff --git a/build.gradle b/build.gradle index 5e54ff0..1eb7401 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ allprojects { withSourcesJar() withJavadocJar() toolchain { - languageVersion = JavaLanguageVersion.of(11) + languageVersion = JavaLanguageVersion.of(javaVersion) } } diff --git a/graaljs-evaluator/build.gradle b/graaljs-evaluator/build.gradle new file mode 100644 index 0000000..0b17475 --- /dev/null +++ b/graaljs-evaluator/build.gradle @@ -0,0 +1,38 @@ +import kotlinx.kover.gradle.plugin.dsl.MetricType + +dependencies { + api project(":core") + api("org.graalvm.polyglot:polyglot:24.0.1") + api("org.graalvm.polyglot:js-community:24.0.1") + + testImplementation project(":tests") +} + +koverReport { + verify { + rule { + enabled = true + bound { + enabled = true + metric = MetricType.BRANCH + minValue = 80 + } + bound { + enabled = true + metric = MetricType.LINE + minValue = 90 + } + bound { + enabled = true + metric = MetricType.INSTRUCTION + minValue = 90 + } + } + } + + defaults { + html { + onCheck = true + } + } +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSContext.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSContext.kt new file mode 100644 index 0000000..a009659 --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSContext.kt @@ -0,0 +1,45 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs + +import com.rapatao.projects.ruleset.engine.context.EvalContext +import com.rapatao.projects.ruleset.engine.types.Expression +import org.graalvm.polyglot.Context +import org.graalvm.polyglot.Source + +/** + * GraalJSContext is a class that implements the EvalContext interface. + * It provides the ability to process expressions using the Graal JS engine. + * + * @property context the GraalJS context object. + */ +class GraalJSContext( + private val context: Context, +) : EvalContext { + + /** + * Processes an expression. + * + * @param expression the expression to process + * @return true if the expression is successfully processed, false otherwise + * @throws Exception if the expression processing fails and onFailure is set to THROW + */ + override fun process(expression: Expression): Boolean { + return context.eval(expression.asScript()).asBoolean() + } + + /** + * Return the Graal JS context. + * + * @return the Graal JS context. + */ + fun context() = context + + private fun Expression.asScript(): Source { + val script = Parser.parse(this) + + return Source.newBuilder( + "js", + "true == ($script)", + script + ).buildLiteral() + } +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngine.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngine.kt new file mode 100644 index 0000000..f44fdfb --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngine.kt @@ -0,0 +1,50 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs + +import com.rapatao.projects.ruleset.engine.context.EvalContext +import com.rapatao.projects.ruleset.engine.context.EvalEngine +import com.rapatao.projects.ruleset.engine.evaluator.graaljs.parameters.MapInjector +import com.rapatao.projects.ruleset.engine.evaluator.graaljs.parameters.TypedInjector +import org.graalvm.polyglot.Context +import org.graalvm.polyglot.Engine +import org.graalvm.polyglot.HostAccess +import org.graalvm.polyglot.Value + +open class GraalJSEvalEngine( + private val engine: Engine = Engine.newBuilder() + .option("engine.WarnInterpreterOnly", "false") + .build(), + private val contextBuilder: Context.Builder = Context.newBuilder() + .engine(engine) + .option("js.ecmascript-version", "2023") + .allowHostAccess(HostAccess.ALL).allowHostClassLookup { true } + .option("js.nashorn-compat", "true").allowExperimentalOptions(true) +) : EvalEngine { + + override fun call(inputData: Any, block: EvalContext.() -> T): T = + createContext().let { + parseParameters( + it.getBindings("js"), + inputData, + ) + block(GraalJSContext(it)) + } + + private fun createContext(): Context { + return contextBuilder.build() + } + + override fun name(): String = "GraalJS" + + /** + * Parses parameters and injects them into the given scope based on the input data. + * + * @param bindings the values object where the parameters will be injected + * @param inputData the input data containing the parameters + */ + open fun parseParameters(bindings: Value, inputData: Any) { + when (inputData) { + is Map<*, *> -> MapInjector.inject(bindings, inputData) + else -> TypedInjector.inject(bindings, inputData) + } + } +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/Parser.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/Parser.kt new file mode 100644 index 0000000..a3df66a --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/Parser.kt @@ -0,0 +1,39 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs + +import com.rapatao.projects.ruleset.engine.types.Expression +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.STARTS_WITH -> "startsWith".formatWithOperation(expression) + Operator.ENDS_WITH -> "endsWith".formatWithOperation(expression) + Operator.CONTAINS -> formatContainsOperation(expression) + null -> error("when evaluation an expression, the operator cannot be null") + } + } + + private fun String.formatComparison(expression: Expression) = + "(${expression.left}) $this (${expression.right})" + + private fun String.formatWithOperation(expression: Expression) = + "${expression.left}.${this}(${expression.right})" + + private fun formatContainsOperation(expression: Expression) = + """ + (function() { + if (Array.isArray(${expression.left})) { + return ${expression.left}.includes(${expression.right}) + } else { + return ${expression.left}.indexOf(${expression.right}) !== -1 + } + })() + """.trimIndent() +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/MapInjector.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/MapInjector.kt new file mode 100644 index 0000000..b8b7ef4 --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/MapInjector.kt @@ -0,0 +1,12 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs.parameters + +import org.graalvm.polyglot.Value + +internal data object MapInjector : ParameterInjector> { + + override fun inject(bindings: Value, input: Map<*, *>) { + input.forEach { + set(bindings, it.key.toString(), it.value) + } + } +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/ParameterInjector.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/ParameterInjector.kt new file mode 100644 index 0000000..f59a67b --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/ParameterInjector.kt @@ -0,0 +1,12 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs.parameters + +import org.graalvm.polyglot.Value + +internal interface ParameterInjector { + + fun inject(bindings: Value, input: T) + + fun set(bindings: Value, name: String, value: Any?) { + bindings.putMember(name, value) + } +} diff --git a/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/TypedInjector.kt b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/TypedInjector.kt new file mode 100644 index 0000000..6ed15da --- /dev/null +++ b/graaljs-evaluator/src/main/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/parameters/TypedInjector.kt @@ -0,0 +1,13 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs.parameters + +import org.graalvm.polyglot.Value +import kotlin.reflect.full.memberProperties + +internal data object TypedInjector : ParameterInjector { + + override fun inject(bindings: Value, input: Any) { + input.javaClass.kotlin.memberProperties.forEach { + set(bindings, it.name, it.get(input)) + } + } +} diff --git a/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSBenchmark.kt b/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSBenchmark.kt new file mode 100644 index 0000000..b6388d7 --- /dev/null +++ b/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSBenchmark.kt @@ -0,0 +1,7 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs + +import com.rapatao.projects.ruleset.engine.BaseEngineBenchmark + +fun main(args: Array) { + BaseEngineBenchmark(GraalJSEvalEngine()).main(args) +} diff --git a/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngineTest.kt b/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngineTest.kt new file mode 100644 index 0000000..850a1d1 --- /dev/null +++ b/graaljs-evaluator/src/test/kotlin/com/rapatao/projects/ruleset/engine/evaluator/graaljs/GraalJSEvalEngineTest.kt @@ -0,0 +1,5 @@ +package com.rapatao.projects.ruleset.engine.evaluator.graaljs + +import com.rapatao.projects.ruleset.engine.BaseEvaluatorTest + +class GraalJSEvalEngineTest : BaseEvaluatorTest(GraalJSEvalEngine()) diff --git a/gradle.properties b/gradle.properties index b008d00..4e47dc8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,6 +4,7 @@ detektVersion=1.23.5 hamcrestVersion=2.2 jacksonVersion=2.17.0 jacocoVersion=0.8.7 +javaVersion=17 junitVersion=5.10.2 kotlinVersion=1.9.22 koverVersion=0.7.6 diff --git a/settings.gradle b/settings.gradle index 4814013..06ad0d1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,5 +3,6 @@ rootProject.name = 'ruleset-engine' include('core') include('rhino-evaluator') include('kotlin-evaluator') +include('graaljs-evaluator') include('jackson') include('tests') diff --git a/tests/src/main/kotlin/com/rapatao/projects/ruleset/engine/BaseEngineBenchmark.kt b/tests/src/main/kotlin/com/rapatao/projects/ruleset/engine/BaseEngineBenchmark.kt index 1012ecc..4ec5bd3 100644 --- a/tests/src/main/kotlin/com/rapatao/projects/ruleset/engine/BaseEngineBenchmark.kt +++ b/tests/src/main/kotlin/com/rapatao/projects/ruleset/engine/BaseEngineBenchmark.kt @@ -3,14 +3,24 @@ package com.rapatao.projects.ruleset.engine import com.rapatao.projects.ruleset.engine.cases.TestData import com.rapatao.projects.ruleset.engine.context.EvalEngine import com.rapatao.projects.ruleset.engine.types.Expression +import java.nio.file.Paths +import kotlin.io.path.appendText +import kotlin.io.path.createFile +import kotlin.io.path.exists +import kotlin.io.path.writeText import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.measureTimedValue class BaseEngineBenchmark(private val evalEngine: EvalEngine) { + + private val benchOut = Paths.get("bench_${evalEngine.name()}.txt") + @Suppress("MagicNumber") fun main(args: Array) { + cleanup() + val cases = TestData.cases() .map { it.get().first { arg -> arg is Expression } } .map { it as Expression } @@ -18,16 +28,16 @@ class BaseEngineBenchmark(private val evalEngine: EvalEngine) { val evaluator = Evaluator(engine = evalEngine) // ini: warmup - println("warmup ${evaluator.engine().name()}: start") + appendLine("warmup ${evaluator.engine().name()}: start") repeat(100) { cases.forEach { expression -> evaluator.evaluate(expression, TestData.inputData) } } - println("warmup ${evaluator.engine().name()}: done") + appendLine("warmup ${evaluator.engine().name()}: done") // end: warmup val times = mutableListOf() val iterations = args.firstOrNull()?.let { Integer.parseInt(it) } ?: 1000 - println() + appendLine() repeat(iterations) { val time = @@ -39,28 +49,44 @@ class BaseEngineBenchmark(private val evalEngine: EvalEngine) { times.add(time.duration) } - println() + appendLine() - println() + appendLine() val total = times.reduce { acc, duration -> acc + duration } - println("$evalEngine> iterations: $iterations") - println(" ops: " + (iterations * cases.size)) - println(" ops/s: " + ((iterations * cases.size) / total.toDouble(DurationUnit.SECONDS))) - println(" total: $total") - println(" max: " + times.max()) - println(" min: " + times.min()) - println(" avg: " + (total / times.size)) + appendLine("$evalEngine> iterations: $iterations") + appendLine(" ops: " + (iterations * cases.size)) + appendLine(" ops/s: " + ((iterations * cases.size) / total.toDouble(DurationUnit.SECONDS))) + appendLine(" total: $total") + appendLine(" max: " + times.max()) + appendLine(" min: " + times.min()) + appendLine(" avg: " + (total / times.size)) val sortedResults = times.sorted() listOf(0.50, 0.75, 0.90, 0.95, 0.99).forEach { p -> - println( + appendLine( " p${(p * 100).toInt()}: " + sortedResults[(sortedResults.size * p).toInt() .coerceAtMost(sortedResults.lastIndex)] ) } - println() + appendLine() + } + + private fun append(value: String) { + benchOut.appendText(value) + print(value) + } + + private fun appendLine(value: String? = "") { + append(value + "\n") + } + + private fun cleanup() { + if (!benchOut.exists()) { + benchOut.createFile() + } + benchOut.writeText("") } }