From 5a2d6858d58490ae49a8b4e74a4ffb94401ec66c Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sat, 14 Dec 2024 00:27:39 +0100 Subject: [PATCH] Added a versatile approach to eval expressions with AND and OR --- .../java/org/odk/collect/db/sqlite/Query.kt | 6 ++ entities/build.gradle.kts | 1 + .../filter/LocalEntitiesFilterStrategy.kt | 94 +++++++++++++++---- .../storage/InMemEntitiesRepository.kt | 56 ++++++++--- .../filter/LocalEntitiesFilterStrategyTest.kt | 38 ++++++++ gradle/libs.versions.toml | 2 +- 6 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 db/src/main/java/org/odk/collect/db/sqlite/Query.kt diff --git a/db/src/main/java/org/odk/collect/db/sqlite/Query.kt b/db/src/main/java/org/odk/collect/db/sqlite/Query.kt new file mode 100644 index 00000000000..7b932fa686c --- /dev/null +++ b/db/src/main/java/org/odk/collect/db/sqlite/Query.kt @@ -0,0 +1,6 @@ +package org.odk.collect.db.sqlite + +data class Query( + val selection: String, + val selectionArgs: Array +) diff --git a/entities/build.gradle.kts b/entities/build.gradle.kts index 19c3db2f5bc..7e919726bbc 100644 --- a/entities/build.gradle.kts +++ b/entities/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(project(":material")) implementation(project(":async")) implementation(project(":lists")) + implementation(project(":db")) implementation(libs.kotlinStdlib) implementation(libs.javarosa) { diff --git a/entities/src/main/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategy.kt b/entities/src/main/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategy.kt index e21737a1990..8757e421fa1 100644 --- a/entities/src/main/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategy.kt +++ b/entities/src/main/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategy.kt @@ -5,8 +5,10 @@ import org.javarosa.core.model.condition.EvaluationContext import org.javarosa.core.model.condition.FilterStrategy import org.javarosa.core.model.instance.DataInstance import org.javarosa.core.model.instance.TreeReference +import org.javarosa.xpath.expr.XPathBoolExpr import org.javarosa.xpath.expr.XPathEqExpr import org.javarosa.xpath.expr.XPathExpression +import org.odk.collect.db.sqlite.Query import org.odk.collect.entities.javarosa.intance.LocalEntitiesInstanceAdapter import org.odk.collect.entities.javarosa.intance.LocalEntitiesInstanceProvider import org.odk.collect.entities.storage.EntitiesRepository @@ -36,30 +38,86 @@ class LocalEntitiesFilterStrategy(entitiesRepository: EntitiesRepository) : return next.get() } + val query = xPathExpressionToQuery(predicate, sourceInstance, evaluationContext) + + return if (query != null) { + queryToTreeReferences(query, sourceInstance, next) + } else { + next.get() + } + } + + private fun xPathExpressionToQuery( + predicate: XPathExpression, + sourceInstance: DataInstance<*>, + evaluationContext: EvaluationContext, + ): Query? { + return when (predicate) { + is XPathBoolExpr -> xPathBoolExprToQuery(predicate, sourceInstance, evaluationContext) + is XPathEqExpr -> xPathEqExprToQuery(predicate, sourceInstance, evaluationContext) + else -> null + } + } + + private fun xPathBoolExprToQuery( + predicate: XPathBoolExpr, + sourceInstance: DataInstance<*>, + evaluationContext: EvaluationContext, + ): Query? { + val queryA = xPathExpressionToQuery(predicate.a, sourceInstance, evaluationContext) + val queryB = xPathExpressionToQuery(predicate.b, sourceInstance, evaluationContext) + + return if (queryA != null && queryB != null) { + val selection = if (predicate.op == XPathBoolExpr.AND) { + "${queryA.selection} AND ${queryB.selection}" + } else { + "${queryA.selection} OR ${queryB.selection}" + } + + return Query(selection, arrayOf(*queryA.selectionArgs, *queryB.selectionArgs)) + } else { + null + } + } + + private fun xPathEqExprToQuery( + predicate: XPathEqExpr, + sourceInstance: DataInstance<*>, + evaluationContext: EvaluationContext, + ): Query? { val candidate = CompareToNodeExpression.parse(predicate) - return when (val original = candidate?.original) { - is XPathEqExpr -> { - val child = candidate.nodeSide.steps[0].name.name - val value = candidate.evalContextSide(sourceInstance, evaluationContext) as String - val selection = if (original.isEqual) { - "$child = ?" - } else { - "$child != ?" - } - val selectionArgs = arrayOf(value) + return if (candidate != null) { + val child = candidate.nodeSide.steps[0].name.name + val value = candidate.evalContextSide(sourceInstance, evaluationContext) as String - val results = instanceAdapter.query(sourceInstance.instanceId, selection, selectionArgs) - sourceInstance.replacePartialElements(results) - results.map { - it.parent = sourceInstance.root - it.ref - } + val selection = if (predicate.isEqual) { + "$child = ?" + } else { + "$child != ?" } + val selectionArgs = arrayOf(value) - else -> { - next.get() + Query(selection, selectionArgs) + } else { + null + } + } + + private fun queryToTreeReferences( + query: Query?, + sourceInstance: DataInstance<*>, + next: Supplier> + ): List { + return if (query != null) { + val results = instanceAdapter.query(sourceInstance.instanceId, query.selection, query.selectionArgs) + sourceInstance.replacePartialElements(results) + results.map { + it.parent = sourceInstance.root + it.ref } + } else { + next.get() } } } diff --git a/entities/src/main/java/org/odk/collect/entities/storage/InMemEntitiesRepository.kt b/entities/src/main/java/org/odk/collect/entities/storage/InMemEntitiesRepository.kt index b80e7b86945..046eb83a234 100644 --- a/entities/src/main/java/org/odk/collect/entities/storage/InMemEntitiesRepository.kt +++ b/entities/src/main/java/org/odk/collect/entities/storage/InMemEntitiesRepository.kt @@ -51,23 +51,51 @@ class InMemEntitiesRepository : EntitiesRepository { selection: String, selectionArgs: Array ): List { + val conditions = selection.split("AND", "OR").map { it.trim() } + val operators = Regex("(AND|OR)").findAll(selection).map { it.value }.toList() + return getEntities(list).filter { entity -> - val (fieldName, operator, _) = selection.split(" ").map { it } - val value = selectionArgs.first() - - val fieldValue = when (fieldName) { - "name" -> entity.id - "label" -> entity.label - "__version" -> entity.version - else -> entity.properties.find { it.first == fieldName }?.second - }.toString() - - when (operator) { - "=" -> fieldValue == value - "!=" -> fieldValue != value - else -> false + val results = conditions.mapIndexed { index, condition -> + val (fieldName, operator, _) = condition.split(" ").map { it } + val value = selectionArgs.getOrNull(index) ?: "" + + evaluateCondition(entity, fieldName, operator, value) + } + combineResults(results, operators) + } + } + + private fun evaluateCondition( + entity: Entity.Saved, + fieldName: String, + operator: String, + value: String + ): Boolean { + val fieldValue = when (fieldName) { + "name" -> entity.id + "label" -> entity.label + "__version" -> entity.version + else -> entity.properties.find { it.first == fieldName }?.second + }.toString() + + return when (operator) { + "=" -> fieldValue == value + "!=" -> fieldValue != value + else -> false + } + } + + private fun combineResults(results: List, operators: List): Boolean { + var combinedResult = results.firstOrNull() ?: false + + for (i in 1 until results.size) { + when (operators.getOrNull(i - 1)) { + "AND" -> combinedResult = combinedResult && results[i] + "OR" -> combinedResult = combinedResult || results[i] } } + + return combinedResult } override fun getById(list: String, id: String): Entity.Saved? { diff --git a/entities/src/test/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategyTest.kt b/entities/src/test/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategyTest.kt index a39620c8d66..d7448eb6212 100644 --- a/entities/src/test/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategyTest.kt +++ b/entities/src/test/java/org/odk/collect/entities/javarosa/filter/LocalEntitiesFilterStrategyTest.kt @@ -480,6 +480,44 @@ class LocalEntitiesFilterStrategyTest { val choices = scenario.choicesOf("/data/question").map { it.value } assertThat(choices, containsInAnyOrder("thing1")) } + + @Test + fun `x`() { + entitiesRepository.save("things", Entity.New("thing1", "Thing1", properties = listOf("foo" to "1", "bar" to "1"))) + entitiesRepository.save("things", Entity.New("thing2", "Thing2", properties = listOf("foo" to "1", "bar" to "2"))) + entitiesRepository.save("things", Entity.New("thing3", "Thing3", properties = listOf("foo" to "2", "bar" to "3"))) + + val scenario = Scenario.init( + "Secondary instance form", + html( + head( + title("Secondary instance form"), + model( + mainInstance( + t( + "data id=\"create-entity-form\"", + t("question"), + ) + ), + t("instance id=\"things\" src=\"jr://file-csv/things.csv\""), + bind("/data/question").type("string") + ) + ), + body( + select1Dynamic( + "/data/question", + "instance('things')/root/item[foo='1' and bar='2']", + "name", + "label" + ) + ) + ), + controllerSupplier + ) + + val choices = scenario.choicesOf("/data/question").map { it.value } + assertThat(choices, containsInAnyOrder("thing2")) + } } private class FallthroughFilterStrategy : FilterStrategy { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20ed0d059bc..ba0ada3acbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ danlewAndroidJoda = { group = "net.danlew", name = "android.joda", version = "2. rarepebbleColorpicker = { group = "com.github.martin-stone", name = "hsv-alpha-color-picker-android", version = "3.1.0" } commonsIo = { group = "commons-io", name = "commons-io", version = "2.5" } # Commons 2.6+ introduce java.nio usage that we can't access until our minSdkVersion >= 26 (https://developer.android.com/reference/java/io/File#toPath()) opencsv = { group = "com.opencsv", name = "opencsv", version = "5.9" } -javarosa = { group = "org.getodk", name = "javarosa", version = "5.0.0" } # Online +javarosa = { group = "org.getodk", name = "javarosa", version = "5.1.0-SNAPSHOT-ab0e8f4" } # Online # javarosa = { group = "org.getodk", name = "javarosa", version = "local" } # Local karumiDexter = { group = "com.karumi", name = "dexter", version = "6.2.3" } zxingAndroidEmbedded = { group = "com.journeyapps", name = "zxing-android-embedded", version = "4.3.0" }