From 0269a039a132626f38b86d82595aacac31cf9190 Mon Sep 17 00:00:00 2001 From: S-Tim Date: Thu, 10 Oct 2024 14:29:19 +0200 Subject: [PATCH] Add date range operator for due date and follow-up date of tasks (#1060) * feat(#1059): added date range operator * feat(#1059): added tests * chore(#1059): fixed tests * chore(#1059): update checkout and upload-sarif versions also added github-actions to dependabot.yml --- .github/dependabot.yml | 21 +++++---- .github/workflows/codacy.yml | 4 +- .github/workflows/docs.yml | 2 +- docs/reference-guide/components/view-api.md | 1 + .../polyflow/view/jpa/SpecificationExt.kt | 16 +++++++ .../polyflow/view/jpa/task/TaskRepository.kt | 46 ++++++++++++++++++- .../jpa/JpaPolyflowViewServiceTaskITest.kt | 31 +++++++++++-- ...kWithDataEntriesRepositoryExtensionImpl.kt | 1 + .../view-api/src/main/kotlin/filter/Filter.kt | 19 +++++++- .../src/test/kotlin/filter/FilterTest.kt | 24 +++++++--- 10 files changed, 140 insertions(+), 25 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a22a00d6c..806c19e7a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,15 @@ version: 2 updates: -- package-ecosystem: maven - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 - labels: - - "Type: dependencies" - + - package-ecosystem: maven + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 19 + labels: + - "Type: dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + labels: + - "Type: dependencies" diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index d6a7104d0..371b2ef45 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -38,7 +38,7 @@ jobs: steps: # Checkout the repository to the GitHub Actions runner - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - name: Run Codacy Analysis CLI @@ -58,6 +58,6 @@ jobs: # Upload the SARIF file generated in the previous step - name: Upload SARIF results file - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v3 with: sarif_file: results.sarif diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 78df248a4..80849e997 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 diff --git a/docs/reference-guide/components/view-api.md b/docs/reference-guide/components/view-api.md index 87f473e7e..2b59b56b8 100644 --- a/docs/reference-guide/components/view-api.md +++ b/docs/reference-guide/components/view-api.md @@ -128,6 +128,7 @@ Following operations are supported: | `<` | Less than | all, payload | `followUpDate`, `dueDate` | none | all, payload | all, payload | | `>` | Greater than | all, payload | `followUpDate`, `dueDate` | none | all, payload | all, payload | | `=` | Equals | all, payload | payload, `businessKey`, `followUpDate`, `dueDate`, `priority` | `entryId`, `entryType`, `type`, payload, `processingState`, `userStatus` | all, payload | all, payload | +| `[]` | Between | comparable | `followUpDate`, `dueDate` | none | none | none | | `%` | Like | all, payload | `businessKey`, `name`, `description`, `processName`, `textSearch` | none | none | none | !!! info diff --git a/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/SpecificationExt.kt b/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/SpecificationExt.kt index 56e369a04..7560c83e2 100644 --- a/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/SpecificationExt.kt +++ b/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/SpecificationExt.kt @@ -20,9 +20,11 @@ import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasBusinessKey import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasDueDate import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasDueDateAfter import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasDueDateBefore +import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasDueDateBetween import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasFollowUpDate import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasFollowUpDateAfter import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasFollowUpDateBefore +import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasFollowUpDateBetween import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasPriority import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasProcessName import io.holunda.polyflow.view.jpa.task.TaskRepository.Companion.hasTaskOrDataEntryPayloadAttribute @@ -266,10 +268,24 @@ internal fun Criterion.TaskCriterion.toTaskSpecification(): Specification { + when (this.name) { + Task::dueDate.name -> hasDueDateBetween(this.value.toDatePair()) + Task::followUpDate.name -> hasFollowUpDateBetween(this.value.toDatePair()) + else -> throw IllegalArgumentException("JPA View found unsupported task attribute for [] (date range) comparison: ${this.name}.") + } + } + else -> throw IllegalArgumentException("JPA View found unsupported comparison ${this.operator} for attribute ${this.name}.") } } +private fun String.toDatePair(): Pair { + val dates = this.split("|") + require(dates.size == 2) { "Value does not contain exactly two dates separated by |." } + return dates.map { Instant.parse(it) }.let { it[0] to it[1] } +} + /** * Creates JPA Specification for query of payload attributes based on JSON paths. All criteria must have the same path * and will be composed by the logical OR operator. diff --git a/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/task/TaskRepository.kt b/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/task/TaskRepository.kt index db93b2d7e..dd12ce54e 100644 --- a/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/task/TaskRepository.kt +++ b/view/jpa/src/main/kotlin/io/holunda/polyflow/view/jpa/task/TaskRepository.kt @@ -131,7 +131,7 @@ interface TaskRepository : CrudRepository, JpaSpecificationE fun hasDueDateBefore(dueDate: Instant): Specification = Specification { task, _, builder -> builder.or( - builder.isNull(task.get(TaskEntity::followUpDate.name)), + builder.isNull(task.get(TaskEntity::dueDate.name)), builder.lessThan( task.get(TaskEntity::dueDate.name), dueDate @@ -145,7 +145,7 @@ interface TaskRepository : CrudRepository, JpaSpecificationE fun hasDueDateAfter(dueDate: Instant): Specification = Specification { task, _, builder -> builder.or( - builder.isNull(task.get(TaskEntity::followUpDate.name)), + builder.isNull(task.get(TaskEntity::dueDate.name)), builder.greaterThan( task.get(TaskEntity::dueDate.name), dueDate @@ -153,6 +153,27 @@ interface TaskRepository : CrudRepository, JpaSpecificationE ) } + /** + * Specification for checking if the due date is in the specified range. + */ + fun hasDueDateBetween(range: Pair): Specification = + Specification { task, _, builder -> + val (from, to) = range + builder.or( + builder.isNull(task.get(TaskEntity::dueDate.name)), + builder.and( + builder.greaterThan( + task.get(TaskEntity::dueDate.name), + from + ), + builder.lessThan( + task.get(TaskEntity::dueDate.name), + to + ), + ) + ) + } + /** * Specification for checking the follow-up date. */ @@ -192,6 +213,27 @@ interface TaskRepository : CrudRepository, JpaSpecificationE ) } + /** + * Specification for checking if the follow-up date is in the specified range. + */ + fun hasFollowUpDateBetween(range: Pair): Specification = + Specification { task, _, builder -> + val (from, to) = range + builder.or( + builder.isNull(task.get(TaskEntity::followUpDate.name)), + builder.and( + builder.greaterThan( + task.get(TaskEntity::followUpDate.name), + from + ), + builder.lessThan( + task.get(TaskEntity::followUpDate.name), + to + ), + ) + ) + } + /** * Specification for checking the name likeness. */ diff --git a/view/jpa/src/test/kotlin/io/holunda/polyflow/view/jpa/JpaPolyflowViewServiceTaskITest.kt b/view/jpa/src/test/kotlin/io/holunda/polyflow/view/jpa/JpaPolyflowViewServiceTaskITest.kt index f26d17792..058d069b0 100644 --- a/view/jpa/src/test/kotlin/io/holunda/polyflow/view/jpa/JpaPolyflowViewServiceTaskITest.kt +++ b/view/jpa/src/test/kotlin/io/holunda/polyflow/view/jpa/JpaPolyflowViewServiceTaskITest.kt @@ -34,10 +34,10 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import org.springframework.transaction.annotation.Transactional -import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset +import java.time.temporal.ChronoUnit import java.util.* import java.util.function.Predicate @@ -92,7 +92,8 @@ internal class JpaPolyflowViewServiceTaskITest { businessKey = "business-1", createTime = Date.from(Instant.now()), candidateUsers = setOf("kermit"), - candidateGroups = setOf("muppets") + candidateGroups = setOf("muppets"), + dueDate = Date.from(now) ), metaData = MetaData.emptyInstance() ) @@ -156,7 +157,8 @@ internal class JpaPolyflowViewServiceTaskITest { businessKey = "business-3", createTime = Date.from(Instant.now()), candidateUsers = setOf("luffy"), - candidateGroups = setOf("strawhats") + candidateGroups = setOf("strawhats"), + dueDate = Date.from(now.plus(1, ChronoUnit.DAYS)) ), metaData = MetaData.emptyInstance() ) @@ -200,7 +202,8 @@ internal class JpaPolyflowViewServiceTaskITest { businessKey = "business-4", createTime = Date.from(Instant.now()), candidateUsers = setOf("zoro"), - candidateGroups = setOf("strawhats") + candidateGroups = setOf("strawhats"), + dueDate = Date.from(now.plus(5, ChronoUnit.DAYS)) ), metaData = MetaData.emptyInstance() ) @@ -576,6 +579,26 @@ internal class JpaPolyflowViewServiceTaskITest { assertThat(namesOSH.elements).hasSize(0) } + @Test + fun `should find tasks by date range`() { + val range = jpaPolyflowViewService.query( + AllTasksQuery( + filters = listOf("task.dueDate[]${now.minus(1, ChronoUnit.DAYS)}|${now.plus(2, ChronoUnit.DAYS)}") + ) + ) + assertThat(range.elements).hasSize(2) + } + + @Test + fun `should not find tasks outsides of date range`() { + val range = jpaPolyflowViewService.query( + AllTasksQuery( + filters = listOf("task.dueDate[]${now.minus(5, ChronoUnit.DAYS)}|${now.minus(2, ChronoUnit.DAYS)}") + ) + ) + assertThat(range.elements).isEmpty() + } + private fun captureEmittedQueryUpdates(): List> { val queryTypeCaptor = argumentCaptor>() val predicateCaptor = argumentCaptor>() diff --git a/view/mongo/src/main/kotlin/io/holunda/polyflow/view/mongo/task/TaskWithDataEntriesRepositoryExtensionImpl.kt b/view/mongo/src/main/kotlin/io/holunda/polyflow/view/mongo/task/TaskWithDataEntriesRepositoryExtensionImpl.kt index 6947c65c5..82bd7fcf9 100644 --- a/view/mongo/src/main/kotlin/io/holunda/polyflow/view/mongo/task/TaskWithDataEntriesRepositoryExtensionImpl.kt +++ b/view/mongo/src/main/kotlin/io/holunda/polyflow/view/mongo/task/TaskWithDataEntriesRepositoryExtensionImpl.kt @@ -60,6 +60,7 @@ open class TaskWithDataEntriesRepositoryExtensionImpl( GREATER -> this.gt(it.typedValue()) LESS -> this.lt(it.typedValue()) // FIXME -> implement like + // FIXME -> implement BETWEEN else -> throw IllegalArgumentException("Unsupported operator ${it.operator}") } } diff --git a/view/view-api/src/main/kotlin/filter/Filter.kt b/view/view-api/src/main/kotlin/filter/Filter.kt index 2d2cebe9a..92196d6e8 100755 --- a/view/view-api/src/main/kotlin/filter/Filter.kt +++ b/view/view-api/src/main/kotlin/filter/Filter.kt @@ -17,6 +17,7 @@ const val EQUALS = "=" const val LIKE = "%" const val GREATER = ">" const val LESS = "<" +const val BETWEEN = "[]" const val TASK_PREFIX = "task." const val DATA_PREFIX = "data." @@ -25,7 +26,7 @@ const val DATA_PREFIX = "data." */ typealias CompareOperator = (Any, Any?) -> Boolean -val OPERATORS = Regex("[$EQUALS$LESS$GREATER$LIKE]") +val OPERATORS = listOf(EQUALS, LIKE, GREATER, LESS, BETWEEN) /** * Implemented comparison support for some data types. @@ -61,6 +62,19 @@ internal fun compareOperator(sign: String): CompareOperator = } } + BETWEEN -> { filter, actual -> + when(actual) { + is Comparable<*> -> { + val (from, to) = filter.toString().split("|") + compareOperator(GREATER) + .invoke(from, actual).and(compareOperator(LESS) + .invoke(to, actual)) + } + null -> true // match tasks where actual is null + else -> throw IllegalArgumentException("Unsupported actual type ${actual.javaClass.name} for between operator. Type must be comparable") + } + } + EQUALS -> { filter, actual -> filter.toString() == actual.toString() } else -> throw IllegalArgumentException("Unsupported operator $sign") @@ -235,7 +249,7 @@ internal fun toCriterion(filter: String): Criterion { require(filter.isNotBlank()) { "Failed to create criteria from empty filter '$filter'." } - if (!filter.contains(OPERATORS)) { + if (!OPERATORS.any { filter.contains(it) }) { return Criterion.EmptyCriterion } @@ -244,6 +258,7 @@ internal fun toCriterion(filter: String): Criterion { filter.contains(LIKE) -> filter.split(LIKE).plus(LIKE) filter.contains(GREATER) -> filter.split(GREATER).plus(GREATER) filter.contains(LESS) -> filter.split(LESS).plus(LESS) + filter.contains(BETWEEN) -> filter.split(BETWEEN).plus(BETWEEN) else -> listOf() }.map { it.trim() } diff --git a/view/view-api/src/test/kotlin/filter/FilterTest.kt b/view/view-api/src/test/kotlin/filter/FilterTest.kt index 8b6cb30e2..412200be6 100755 --- a/view/view-api/src/test/kotlin/filter/FilterTest.kt +++ b/view/view-api/src/test/kotlin/filter/FilterTest.kt @@ -14,21 +14,22 @@ import java.time.temporal.ChronoUnit class FilterTest { private val filtersList = listOf("task.name${EQUALS}myName", "task.assignee${EQUALS}kermit", "dataAttr1${EQUALS}value", "dataAttr2${EQUALS}another") + private val now = Instant.now() private val ref = ProcessReference("1", "2", "3", "4", "My Process", "myExample") // no match: task.assignee, dataAttr1, dataAttr2 // match: task.name - private val task1 = TaskWithDataEntries(Task("id", ref, "key", name = "myName", priority = 90), listOf()) + private val task1 = TaskWithDataEntries(Task("id1", ref, "key", name = "myName", priority = 90, dueDate = now), listOf()) // no match: task.name, dataAttr1, dataAttr2 // match: task.assignee - private val task2 = TaskWithDataEntries(Task("id", ref, "key", assignee = "kermit", priority = 91), listOf()) + private val task2 = TaskWithDataEntries(Task("id2", ref, "key", assignee = "kermit", priority = 91, dueDate = now.minus(1, ChronoUnit.DAYS)), listOf()) // no match: task.name, task.assignee, dataAttr2 // match: dataEntries[0].payload -> dataAttr1 private val task3 = TaskWithDataEntries( - Task("id", ref, "key", name = "foo", assignee = "gonzo", priority = 80), listOf( + Task("id3", ref, "key", name = "foo", assignee = "gonzo", priority = 80, dueDate = now.plus(1, ChronoUnit.DAYS)), listOf( DataEntry( entryType = "type", entryId = "4711", @@ -43,7 +44,7 @@ class FilterTest { // no match: task.name, task.assignee, dataAttr2 // match: dataEntries[0].payload -> dataAttr1 private val task4 = TaskWithDataEntries( - Task("id", ref, "key", name = "foo", assignee = "gonzo", priority = 78), listOf( + Task("id4", ref, "key", name = "foo", assignee = "gonzo", priority = 78, dueDate = now.plus(5, ChronoUnit.DAYS)), listOf( DataEntry( entryType = "type", entryId = "4711", @@ -58,7 +59,7 @@ class FilterTest { // no match: task.name, task.assignee, dataAttr1 // match: dataEntries[0].payload -> dataAttr2 private val task5 = TaskWithDataEntries( - Task("id", ref, "key", name = "foo", assignee = "gonzo", priority = 80), listOf( + Task("id5", ref, "key", name = "foo", assignee = "gonzo", priority = 80, dueDate = now.minus(4, ChronoUnit.DAYS)), listOf( DataEntry( entryType = "type", entryId = "4711", @@ -74,7 +75,7 @@ class FilterTest { // match: task.payload -> dataAttr2 private val task6 = TaskWithDataEntries( Task( - "id", ref, "key", name = "foo", assignee = "gonzo", priority = 1, + "id6", ref, "key", name = "foo", assignee = "gonzo", priority = 1, payload = Variables.createVariables().putValue("dataAttr2", "another").putValue("name", "myName") ), listOf() ) @@ -271,5 +272,16 @@ class FilterTest { assertThat(filtered).containsExactlyElementsOf(listOf(task2)) } + @Test + fun `should filter tasks by due date range`() { + val twoDaysAgo = now.minus(2, ChronoUnit.DAYS) + val inTwoDays = now.plus(2, ChronoUnit.DAYS) + val dueDateFilter = listOf("task.dueDate[]$twoDaysAgo|$inTwoDays") + + + val filtered = filter(dueDateFilter, listOf(task1, task2, task3, task4, task5, task6)) + // task1, task2 and task3 are in the date range. task6 does not have a dueDate which is counted as a match + assertThat(filtered).containsExactlyElementsOf(listOf(task1, task2, task3, task6)) + } }