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
5 changes: 3 additions & 2 deletions api/expression-parser.api
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ public abstract interface class org/hisp/dhis/lib/expression/ast/Typed {
public final class org/hisp/dhis/lib/expression/ast/Typed$Companion {
public final fun toBooleanTypeCoercion (Ljava/lang/Object;)Ljava/lang/Boolean;
public final fun toDateTypeCoercion (Ljava/lang/Object;)Lkotlinx/datetime/LocalDate;
public final fun toInstantTypeCoercion (Ljava/lang/Object;)Lkotlin/time/Instant;
public final fun toMixedTypeTypeCoercion (Ljava/lang/Object;)Ljava/lang/Object;
public final fun toNumberTypeCoercion (Ljava/lang/Object;)Ljava/lang/Double;
public final fun toStringTypeCoercion (Ljava/lang/Object;)Ljava/lang/String;
Expand Down Expand Up @@ -1412,7 +1413,7 @@ public abstract interface class org/hisp/dhis/lib/expression/spi/ExpressionFunct
public fun d2_length (Ljava/lang/String;)I
public fun d2_maxValue (Lorg/hisp/dhis/lib/expression/spi/VariableValue;)D
public fun d2_minValue (Lorg/hisp/dhis/lib/expression/spi/VariableValue;)D
public fun d2_minutesBetween (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I
public fun d2_minutesBetween (Lkotlin/time/Instant;Lkotlin/time/Instant;)D
public fun d2_modulus (Ljava/lang/Number;Ljava/lang/Number;)D
public fun d2_monthsBetween (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I
public fun d2_oizp (Ljava/lang/Number;)D
Expand Down Expand Up @@ -1475,7 +1476,7 @@ public final class org/hisp/dhis/lib/expression/spi/ExpressionFunctions$DefaultI
public static fun d2_length (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Ljava/lang/String;)I
public static fun d2_maxValue (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Lorg/hisp/dhis/lib/expression/spi/VariableValue;)D
public static fun d2_minValue (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Lorg/hisp/dhis/lib/expression/spi/VariableValue;)D
public static fun d2_minutesBetween (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I
public static fun d2_minutesBetween (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Lkotlin/time/Instant;Lkotlin/time/Instant;)D
public static fun d2_modulus (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Ljava/lang/Number;Ljava/lang/Number;)D
public static fun d2_monthsBetween (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I
public static fun d2_oizp (Lorg/hisp/dhis/lib/expression/spi/ExpressionFunctions;Ljava/lang/Number;)D
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repositories {
mavenCentral()
}

version = "1.4.3-SNAPSHOT"
version = "1.4.4-SNAPSHOT"
group = "org.hisp.dhis.lib.expression"

if (project.hasProperty("removeSnapshotSuffix")) {
Expand Down
33 changes: 33 additions & 0 deletions src/commonMain/kotlin/org/hisp/dhis/lib/expression/ast/Typed.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package org.hisp.dhis.lib.expression.ast

import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.atTime
import kotlinx.datetime.format.char
import org.hisp.dhis.lib.expression.spi.ValueType
import org.hisp.dhis.lib.expression.spi.VariableValue
import kotlin.time.Instant
Expand Down Expand Up @@ -40,6 +44,23 @@ fun interface Typed {
throw IllegalArgumentException("Count not coerce to date: '$value'")
}

fun toInstantTypeCoercion(value: Any?): Instant? {
if (value == null) return null
if (value is VariableValue) return toInstantTypeCoercion(toMixedTypeTypeCoercion(value))
if (value is LocalDate) return value.atTime(0, 0).toInstant(TimeZone.UTC)
if (value is String) {
return listOf(
{ Instant.parse(value) },
{ LocalDateTime.parse(value, dateTimeFormat).toInstant(TimeZone.UTC) },
{ toInstantTypeCoercion(LocalDate.parse(value)) }
).firstNotNullOfOrNull {
parser -> runCatching { parser() }.getOrNull()
} ?: throw IllegalArgumentException("Count not coerce to instant: '$value'")
}
if (value is Instant) return value
throw IllegalArgumentException("Count not coerce to instant: '$value'")
}

fun toStringTypeCoercion(value: Any?): String? {
if (value == null) return null
if (value is VariableValue) return toStringTypeCoercion(toMixedTypeTypeCoercion(value));
Expand All @@ -63,5 +84,17 @@ fun interface Typed {
private fun isNonFractionValue(value: Number): Boolean {
return value.toDouble() % 1.0 == 0.0
}

private val dateTimeFormat = LocalDateTime.Format {
year()
char('-')
monthNumber()
char('-')
day()
char(' ')
hour()
char(':')
minute()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.hisp.dhis.lib.expression.ast.*
import org.hisp.dhis.lib.expression.ast.Nodes.Utf8StringNode
import org.hisp.dhis.lib.expression.ast.UnaryOperator.Companion.negate
import org.hisp.dhis.lib.expression.spi.*
import kotlin.time.Instant

/**
* A [NodeInterpreter] that calculates the expression result value using a [ExpressionFunctions] to
Expand Down Expand Up @@ -114,8 +115,8 @@ internal class Calculator(
NamedFunction.d2_length -> functions.d2_length(evalToString(fn.child(0)))
NamedFunction.d2_maxValue -> functions.d2_maxValue(evalToVar(fn.child(0)))
NamedFunction.d2_minutesBetween -> functions.d2_minutesBetween(
evalToDate(fn.child(0)),
evalToDate(fn.child(1)))
evalToInstant(fn.child(0)),
evalToInstant(fn.child(1)))
NamedFunction.d2_minValue -> functions.d2_minValue(evalToVar(fn.child(0)))
NamedFunction.d2_modulus -> functions.d2_modulus(
evalToNumber(fn.child(0)),
Expand Down Expand Up @@ -326,6 +327,10 @@ internal class Calculator(
return eval(node, "Date", Typed::toDateTypeCoercion)
}

fun evalToInstant(node: Node<*>): Instant? {
return eval(node, "Instant", Typed::toInstantTypeCoercion)
}

private fun evalToInteger(node: Node<*>): Int? {
val num = evalToNumber(node) ?: return null
require(num % 1.0 == 0.0) { "Expected an integer but got a floating point for: $node" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.hisp.dhis.lib.expression.spi

import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlin.time.Instant
import kotlinx.datetime.*
import org.hisp.dhis.lib.expression.ast.BinaryOperator.Companion.modulo
import org.hisp.dhis.lib.expression.math.GS1Elements.Companion.fromKey
Expand Down Expand Up @@ -262,10 +263,10 @@ fun interface ExpressionFunctions {
else value.candidates.maxOfOrNull(String::toDouble) ?: Double.NaN
}

fun d2_minutesBetween(start: LocalDate?, end: LocalDate?): Int {
fun d2_minutesBetween(start: Instant?, end: Instant?): Double {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change in the return type, is it safe? I think this function was only used by program indicator expressions, so that's ok. If it was used by program rules, we might have a sync issue if there is a parser version mismatch between client and server: for example, if a new Capture/Android app has a newer version of the expression-parser and the backend has an old one, the client could evaluate to 4.3 and the backend to 4, and it might produce an error at sync.

@superskip superskip Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither Tracker Capture or the Capture web app had implemented d2:minutesBetween. We did add the Int version to the parser in January, however: #92. So you are right, this might cause a headache to anyone who is using a server version released between January and August..

@superskip superskip Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request also changes the implementation of d2:minutesBetween such that it no longer returns just multiples of 24 hours. I think this is a necessary change to make. It raises the same concern as with changing the return type, so we may take this opportunity to change both.

Not sure how we deal with the frontend / backend issue it will cause for certain backend versions though.

require(start != null) { "start parameter of d2:minutesBetween must not be null" }
require(end != null) { "end parameter of d2:minutesBetween must not be null" }
return d2_daysBetween(start, end).times(24 * 60)
return (end - start).inWholeSeconds.toDouble() / 60
}

fun d2_minValue(value: VariableValue?): Double {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,31 @@ internal class MinutesBetweenTest {

@Test
fun testMinutesBetween() {
val minPerDay = 60 * 24
assertEquals(6 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2020-01-07\")"))
assertEquals(31 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2020-02-01\")"))
assertEquals(29 * minPerDay, evaluate("d2:minutesBetween(\"2020-02-01\", \"2020-03-01\")"))
assertEquals(366 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2021-01-01\")"))
val minPerDay = 60 * 24.0
assertEquals(6 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2020-01-07\")") as Double, 0.01)
assertEquals(31 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2020-02-01\")") as Double, 0.01)
assertEquals(29 * minPerDay, evaluate("d2:minutesBetween(\"2020-02-01\", \"2020-03-01\")") as Double, 0.01)
assertEquals(366 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-01\", \"2021-01-01\")") as Double, 0.01)
}

@Test
fun testMinutesBetween_Negative() {
val minPerDay = 60 * 24
assertEquals(-6 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-07\", \"2020-01-01\")"))
val minPerDay = 60 * 24.0
assertEquals(-6 * minPerDay, evaluate("d2:minutesBetween(\"2020-01-07\", \"2020-01-01\")") as Double, 0.01)
}

@Test
fun testMinutesBetween_ISO8601() {
assertEquals(8.0, evaluate("d2:minutesBetween(\"2020-01-01T18:01:00Z\", \"2020-01-01T18:09:00Z\")") as Double, 0.01)
assertEquals(0.25, evaluate("d2:minutesBetween(\"2020-01-01T12:00:00Z\", \"2020-01-01T12:00:15Z\")") as Double, 0.01)
assertEquals(60.0, evaluate("d2:minutesBetween(\"2020-01-01\", \"2020-01-01T01:00:00Z\")") as Double, 0.01)
}

@Test
fun testMinutesBetween_dateTime() {
assertEquals(25.0, evaluate("d2:minutesBetween(\"2020-01-01 14:15\", \"2020-01-01 14:40\")") as Double, 0.01)
assertEquals(3.0, evaluate("d2:minutesBetween(\"2020-01-01 09:00\", \"2020-01-01 09:03\")") as Double, 0.01)
assertEquals(60 * 24.0, evaluate("d2:minutesBetween(\"2020-01-01 12:00\", \"2020-01-02 12:00\")") as Double, 0.01)
}

@Test
Expand Down