Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions api/rule-engine.api
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ public final class org/hisp/dhis/rules/DateUtils$Companion {
}

public final class org/hisp/dhis/rules/Logger {
public fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public final fun component1 ()Lkotlin/jvm/functions/Function1;
public final fun component2 ()Lkotlin/jvm/functions/Function1;
public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/hisp/dhis/rules/Logger;
public static synthetic fun copy$default (Lorg/hisp/dhis/rules/Logger;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/hisp/dhis/rules/Logger;
public final fun component3 ()Lkotlin/jvm/functions/Function1;
public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/hisp/dhis/rules/Logger;
public static synthetic fun copy$default (Lorg/hisp/dhis/rules/Logger;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/hisp/dhis/rules/Logger;
public fun equals (Ljava/lang/Object;)Z
public final fun getFine ()Lkotlin/jvm/functions/Function1;
public final fun getSevere ()Lkotlin/jvm/functions/Function1;
public final fun getWarning ()Lkotlin/jvm/functions/Function1;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
Expand Down Expand Up @@ -156,6 +158,42 @@ public final class org/hisp/dhis/rules/engine/RuleVariableValue {
public final fun toVariableValue ()Lorg/hisp/dhis/lib/expression/spi/VariableValue;
}

public abstract class org/hisp/dhis/rules/models/AssignTarget {
}

public final class org/hisp/dhis/rules/models/AssignTarget$Field : org/hisp/dhis/rules/models/AssignTarget {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lorg/hisp/dhis/rules/models/AssignTarget$Field;
public static synthetic fun copy$default (Lorg/hisp/dhis/rules/models/AssignTarget$Field;Ljava/lang/String;ILjava/lang/Object;)Lorg/hisp/dhis/rules/models/AssignTarget$Field;
public fun equals (Ljava/lang/Object;)Z
public final fun getField ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class org/hisp/dhis/rules/models/AssignTarget$Invalid : org/hisp/dhis/rules/models/AssignTarget {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lorg/hisp/dhis/rules/models/AssignTarget$Invalid;
public static synthetic fun copy$default (Lorg/hisp/dhis/rules/models/AssignTarget$Invalid;Ljava/lang/String;ILjava/lang/Object;)Lorg/hisp/dhis/rules/models/AssignTarget$Invalid;
public fun equals (Ljava/lang/Object;)Z
public final fun getReason ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class org/hisp/dhis/rules/models/AssignTarget$Variable : org/hisp/dhis/rules/models/AssignTarget {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lorg/hisp/dhis/rules/models/AssignTarget$Variable;
public static synthetic fun copy$default (Lorg/hisp/dhis/rules/models/AssignTarget$Variable;Ljava/lang/String;ILjava/lang/Object;)Lorg/hisp/dhis/rules/models/AssignTarget$Variable;
public fun equals (Ljava/lang/Object;)Z
public final fun getName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class org/hisp/dhis/rules/models/AttributeType : java/lang/Enum {
public static final field DATA_ELEMENT Lorg/hisp/dhis/rules/models/AttributeType;
public static final field TRACKED_ENTITY_ATTRIBUTE Lorg/hisp/dhis/rules/models/AttributeType;
Expand Down Expand Up @@ -203,8 +241,12 @@ public final class org/hisp/dhis/rules/models/Rule : java/lang/Comparable {
}

public final class org/hisp/dhis/rules/models/RuleAction : java/lang/Comparable {
public static final field ASSIGN Ljava/lang/String;
public static final field Companion Lorg/hisp/dhis/rules/models/RuleAction$Companion;
public static final field ERROR Ljava/lang/String;
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/Integer;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun assignTarget ()Lorg/hisp/dhis/rules/models/AssignTarget;
public final fun attributeType ()Ljava/lang/String;
public synthetic fun compareTo (Ljava/lang/Object;)I
public fun compareTo (Lorg/hisp/dhis/rules/models/RuleAction;)I
Expand All @@ -225,6 +267,9 @@ public final class org/hisp/dhis/rules/models/RuleAction : java/lang/Comparable
public fun toString ()Ljava/lang/String;
}

public final class org/hisp/dhis/rules/models/RuleAction$Companion {
}

public final class org/hisp/dhis/rules/models/RuleAttributeValue {
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
Expand Down Expand Up @@ -575,8 +620,3 @@ public final class org/hisp/dhis/rules/models/TriggerEnvironment : java/lang/Enu
public static fun values ()[Lorg/hisp/dhis/rules/models/TriggerEnvironment;
}

public final class org/hisp/dhis/rules/utils/UtilsKt {
public static final fun currentDate ()Lkotlinx/datetime/LocalDate;
public static final fun unwrapVariableName (Ljava/lang/String;)Ljava/lang/String;
}

2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ repositories {
maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") }
}

version = "3.8.1-SNAPSHOT"
version = "3.8.2-SNAPSHOT"
group = "org.hisp.dhis.rules"

if (project.hasProperty("removeSnapshotSuffix")) {
Expand Down
1 change: 1 addition & 0 deletions src/commonMain/kotlin/org/hisp/dhis/rules/Logger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.hisp.dhis.rules

data class Logger(
val severe: (String) -> Unit,
val warning: (String) -> Unit,
val fine: (String) -> Unit,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class DefaultRuleEngine : RuleEngine {
ruleEnrollment,
target,
)
return RuleConditionEvaluator().getRuleEffects(
return RuleConditionEvaluator(executionContext.ruleVariables).getRuleEffects(
TrackerObjectType.EVENT,
target.event,
valueMap,
Expand All @@ -50,7 +50,7 @@ internal class DefaultRuleEngine : RuleEngine {
ruleEvents.toSet(),
target,
)
return RuleConditionEvaluator().getRuleEffects(
return RuleConditionEvaluator(executionContext.ruleVariables).getRuleEffects(
TrackerObjectType.ENROLLMENT,
target.enrollment,
valueMap,
Expand All @@ -70,6 +70,7 @@ internal class DefaultRuleEngine : RuleEngine {
.multipleBuild(executionContext.constantsValues, executionContext.ruleVariables, eventsTarget.toSet(), enrollmentTarget)
return RuleEngineMultipleExecution().execute(
executionContext.rules,
executionContext.ruleVariables,
valueMap,
executionContext.ruleSupplementaryData,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@ import org.hisp.dhis.rules.engine.RuleEvaluationResult.Companion.errorRule
import org.hisp.dhis.rules.engine.RuleEvaluationResult.Companion.evaluatedResult
import org.hisp.dhis.rules.engine.RuleEvaluationResult.Companion.notEvaluatedResult
import org.hisp.dhis.rules.models.*
import org.hisp.dhis.rules.utils.unwrapVariableName

internal class RuleConditionEvaluator {
internal class RuleConditionEvaluator(
ruleVariables: List<RuleVariable> = emptyList(),
) {
private val ruleVariablesByName: Map<String, RuleVariable> = ruleVariables.associateBy { it.name }

fun getEvaluatedAndErrorRuleEffects(
targetType: TrackerObjectType,
targetUid: String,
valueMap: MutableMap<String, VariableValue>,
supplementaryMap: Map<String, List<String>>,
rules: List<Rule>,
attributeType: AttributeType,
hasEnrollmentPass: Boolean = false,
): List<RuleEffect> {
val ruleEvaluationResults = getRuleEvaluationResults(targetType, targetUid, valueMap, supplementaryMap, rules, attributeType)
val ruleEvaluationResults =
getRuleEvaluationResults(targetType, targetUid, valueMap, supplementaryMap, rules, attributeType, hasEnrollmentPass)
return ruleEvaluationResults
.flatMap { result -> result.ruleEffects }
}
Expand All @@ -49,13 +54,20 @@ internal class RuleConditionEvaluator {
.flatMap { result -> result.ruleEffects }
}

/**
* [hasEnrollmentPass] tells this pass whether the surrounding execution also evaluates
* the enrollment, which owns the effects for assignments to attribute-backed variables;
* when it does not (single-target evaluation, executions without an enrollment), the
* event pass emits them instead.
*/
fun getRuleEvaluationResults(
targetType: TrackerObjectType,
targetUid: String,
valueMap: MutableMap<String, VariableValue>,
supplementaryMap: Map<String, List<String>>,
rules: List<Rule>,
attributeType: AttributeType,
hasEnrollmentPass: Boolean = false,
): List<RuleEvaluationResult> {
val ruleEvaluationResults: MutableList<RuleEvaluationResult> = ArrayList()
for (rule in rules) {
Expand All @@ -70,20 +82,35 @@ internal class RuleConditionEvaluator {
) {
for (action in if (attributeType == AttributeType.DATA_ELEMENT) rule.actionsForDataElement else rule.actionsForEnrollment) {
try {
// Check if action is assigning value to calculated variable
if (isAssignToCalculatedValue(action)) {
updateValueMap(
unwrapVariableName(action.content()!!),
VariableValue(
ValueType.STRING,
process(action.dataExpression, valueMap, supplementaryMap),
listOf(),
null,
),
valueMap,
)
} else {
ruleEffects.add(create(rule, action, valueMap, supplementaryMap))
// Assignments to program rule variables mutate the value map for
// subsequent rules; when the variable is backed by a data element or
// attribute, an effect targeting the backing field is emitted as well
when (val target = action.assignTarget()) {
is AssignTarget.Variable -> {
// Assigning to a name without a program rule variable is
// allowed: the value is visible to subsequent rules through
// the value map, but there is no backing field to emit an
// effect for, which is worth a warning as it may be a typo
val variable = ruleVariablesByName[target.name]
if (variable == null) {
log.warning(
"ASSIGN action in rule " + rule.uid + " assigns to '" + target.name +
"' which is not a defined program rule variable; the value is" +
" visible to subsequent rules but no effect is emitted",
)
}
val data = processRuleAction(rule, action, valueMap, supplementaryMap)
updateValueMap(
target.name,
VariableValue(ValueType.STRING, data, listOf(), null),
valueMap,
)
variable
?.let { createVariableAssignEffect(rule, action, it, data, attributeType, hasEnrollmentPass) }
?.let { ruleEffects.add(it) }
}
is AssignTarget.Invalid -> throw RuleConfigurationException(target.reason)
else -> ruleEffects.add(create(rule, action, valueMap, supplementaryMap))
}
} catch (e: Exception) {
addRuleErrorResult(rule, action, e, targetType, targetUid, ruleEvaluationResults)
Expand Down Expand Up @@ -116,31 +143,21 @@ internal class RuleConditionEvaluator {
targetUid: String,
ruleEvaluationResults: MutableList<RuleEvaluationResult>,
) {
val errorMessage: String
errorMessage =
if (ruleAction != null && e is IllegalExpressionException) {
"Action " + ruleAction::class.simpleName +
" from rule " + rule.name + " with id " + rule.uid +
" executed for " + targetType.name + "(" + targetUid + ")" +
" with condition (" + rule.condition + ")" +
" raised an error: " + e.message
} else if (ruleAction != null) {
"Action " + ruleAction::class.simpleName +
" from rule " + rule.name + " with id " + rule.uid +
" executed for " + targetType.name + "(" + targetUid + ")" +
" with condition (" + rule.condition + ")" +
" raised an unexpected exception: " + e.message
} else if (e is IllegalExpressionException) {
"Rule " + rule.name + " with id " + rule.uid +
" executed for " + targetType.name + "(" + targetUid + ")" +
" with condition (" + rule.condition + ")" +
" raised an error: " + e.message
// Expression and rule-configuration errors are reported as errors; anything else as
// an unexpected exception
val isConfigurationError = e is IllegalExpressionException || e is RuleConfigurationException
val subject =
if (ruleAction != null) {
"Action " + ruleAction::class.simpleName + " from rule "
} else {
"Rule " + rule.name + " with id " + rule.uid +
" executed for " + targetType.name + "(" + targetUid + ")" +
" with condition (" + rule.condition + ")" +
" raised an unexpected exception: " + e.message
"Rule "
}
val failure = if (isConfigurationError) "an error" else "an unexpected exception"
val errorMessage =
subject + rule.name + " with id " + rule.uid +
" executed for " + targetType.name + "(" + targetUid + ")" +
" with condition (" + rule.condition + ")" +
" raised " + failure + ": " + e.message
log.severe(errorMessage)
ruleEvaluationResults.add(errorRule(rule, errorMessage))
}
Expand Down Expand Up @@ -175,7 +192,48 @@ internal class RuleConditionEvaluator {
result
}

private fun isAssignToCalculatedValue(ruleAction: RuleAction): Boolean = ruleAction.type == "ASSIGN" && ruleAction.field().isNullOrEmpty()
/**
* Builds the effect for an assignment to a program rule variable, so the caller can apply
* the value to the data element or attribute the variable is backed by. The backing field
* and its attribute type are injected into a copy of the action, making the effect
* self-contained for consumers that read `field`; the copy resolves to
* [AssignTarget.Field] and compares unequal to the configured action.
*
* Returns null when this pass does not emit the effect: calculated values are not backed
* by a field (their `field` is the variable's own uid), data-element backed effects are
* only emitted during event evaluation, and attribute backed effects are emitted during
* enrollment evaluation when an enrollment pass evaluates the rule — otherwise
* (single-event evaluation, executions without an enrollment, rules scoped to a program
* stage and thus excluded from the enrollment pass) the event pass emits them, so the
* assignment is never silently dropped.
*/
private fun createVariableAssignEffect(
rule: Rule,
ruleAction: RuleAction,
variable: RuleVariable,
data: String?,
attributeType: AttributeType,
hasEnrollmentPass: Boolean,
): RuleEffect? {
if (variable is RuleVariableCalculatedValue || variable.field.isEmpty()) return null
val backingType =
if (variable is RuleVariableAttribute) {
AttributeType.TRACKED_ENTITY_ATTRIBUTE
} else {
AttributeType.DATA_ELEMENT
}
val emitted =
when (backingType) {
attributeType -> true
AttributeType.TRACKED_ENTITY_ATTRIBUTE ->
!(hasEnrollmentPass && rule.programStage.isNullOrEmpty())

else -> false
}
if (!emitted) return null
val effectAction = ruleAction.withAssignBacking(variable.field, backingType.name)
return RuleEffect(rule.uid, effectAction, if (data.isNullOrEmpty()) null else data)
}

private fun updateValueMap(
variable: String,
Expand All @@ -191,7 +249,7 @@ internal class RuleConditionEvaluator {
valueMap: MutableMap<String, VariableValue>,
supplementaryMap: Map<String, List<String>>,
): RuleEffect {
if (ruleAction.type == "ASSIGN") {
if (ruleAction.type == RuleAction.ASSIGN) {
val data = processRuleAction(rule, ruleAction, valueMap, supplementaryMap)
updateValueMap(ruleAction.field()!!, VariableValue(ValueType.STRING, data, listOf(), null), valueMap)
return if (data.isNullOrEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.hisp.dhis.rules.engine

/**
* Signals a rule-configuration problem detected while executing an action, such as a
* malformed ASSIGN target. Reported as an error effect like an expression error, rather
* than as an unexpected exception.
*/
internal class RuleConfigurationException(
message: String,
) : IllegalArgumentException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import org.hisp.dhis.rules.utils.filterRules
internal class RuleEngineMultipleExecution {
fun execute(
rules: List<Rule>,
ruleVariables: List<RuleVariable>,
ruleVariableValueMap: RuleVariableValueMap,
ruleSupplementaryData: RuleSupplementaryData,
): List<RuleEffects> {
val supplementaryMap = RuleConditionEvaluator.convertSupplementaryData(ruleSupplementaryData)
val evaluator = RuleConditionEvaluator()
val evaluator = RuleConditionEvaluator(ruleVariables)
val hasEnrollmentPass = ruleVariableValueMap.enrollmentMap.isNotEmpty()
val ruleEffects: MutableList<RuleEffects> = ArrayList()
val enrollmentRules: List<Rule> = filterRules(rules).sorted()
val rulesByStage: Map<String, List<Rule>> = rules
Expand Down Expand Up @@ -49,6 +51,7 @@ internal class RuleEngineMultipleExecution {
supplementaryMap,
eventRules,
AttributeType.DATA_ELEMENT,
hasEnrollmentPass,
),
),
)
Expand Down
Loading