diff --git a/lib/taskana-core-test/src/test/java/acceptance/task/query/TaskQueryImplAccTest.java b/lib/taskana-core-test/src/test/java/acceptance/task/query/TaskQueryImplAccTest.java index 31d910099..308fb272f 100644 --- a/lib/taskana-core-test/src/test/java/acceptance/task/query/TaskQueryImplAccTest.java +++ b/lib/taskana-core-test/src/test/java/acceptance/task/query/TaskQueryImplAccTest.java @@ -1,6 +1,7 @@ package acceptance.task.query; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static pro.taskana.task.api.CallbackState.CALLBACK_PROCESSING_REQUIRED; import static pro.taskana.testapi.DefaultTestEntities.defaultTestClassification; import static pro.taskana.testapi.DefaultTestEntities.defaultTestObjectReference; @@ -10,19 +11,26 @@ import java.security.PrivilegedActionException; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.function.ThrowingConsumer; import pro.taskana.classification.api.ClassificationService; import pro.taskana.classification.api.models.ClassificationSummary; import pro.taskana.common.api.KeyDomain; import pro.taskana.common.api.TimeInterval; import pro.taskana.common.api.security.CurrentUserContext; +import pro.taskana.common.internal.util.Pair; import pro.taskana.task.api.CallbackState; import pro.taskana.task.api.TaskCustomField; +import pro.taskana.task.api.TaskQuery; import pro.taskana.task.api.TaskService; import pro.taskana.task.api.TaskState; import pro.taskana.task.api.WildcardSearchField; @@ -2569,6 +2577,132 @@ class TaskQueryImplAccTest { } } + @Nested + @TestInstance(Lifecycle.PER_CLASS) + class WithoutAttachment { + WorkbasketSummary wb; + TaskSummary taskSummary1; + TaskSummary taskSummary2; + TaskSummary taskSummary3; + TaskSummary taskSummary4; + + @WithAccessId(user = "user-1-1") + @BeforeAll + void setup() throws Exception { + wb = createWorkbasketWithPermission(); + Attachment attachment = createAttachment().build(); + taskSummary1 = + taskInWorkbasket(wb).attachments(attachment).buildAndStoreAsSummary(taskService); + taskSummary2 = + taskInWorkbasket(wb) + .attachments(attachment.copy(), attachment.copy()) + .buildAndStoreAsSummary(taskService); + taskSummary3 = taskInWorkbasket(wb).buildAndStoreAsSummary(taskService); + taskSummary4 = taskInWorkbasket(wb).buildAndStoreAsSummary(taskService); + } + + @WithAccessId(user = "user-1-1") + @Test + void should_ReturnTasksThatDontHaveAttachment() { + List list = + taskService.createTaskQuery().workbasketIdIn(wb.getId()).withoutAttachment().list(); + + assertThat(list).containsExactlyInAnyOrder(taskSummary3, taskSummary4); + } + + @WithAccessId(user = "user-1-1") + @TestFactory + Stream + should_ThrowException_When_UsingWithoutAttachmentWithAnyOtherAttachmentFilter() { + List>> testCases = + List.of( + Pair.of("AttachmentChannelIn", query -> query.attachmentChannelIn("not important")), + Pair.of( + "AttachmentChannelLike", query -> query.attachmentChannelLike("not important")), + Pair.of( + "AttachmentChannelNotIn", + query -> query.attachmentChannelNotIn("not important")), + Pair.of( + "AttachmentChannelNotLike", + query -> query.attachmentChannelNotLike("not important")), + Pair.of( + "AttachmentClassificationIdIn", + query -> query.attachmentClassificationIdIn("not important")), + Pair.of( + "AttachmentClassificationIdNotIn", + query -> query.attachmentClassificationIdNotIn("not important")), + Pair.of( + "AttachmentClassificationKeyIn", + query -> query.attachmentClassificationKeyIn("not important")), + Pair.of( + "AttachmentClassificationKeyIn", + query -> query.attachmentClassificationKeyIn("not important")), + Pair.of( + "AttachmentClassificationKeyNotIn", + query -> query.attachmentClassificationKeyNotIn("not important")), + Pair.of( + "AttachmentClassificationKeyNotLike", + query -> query.attachmentClassificationKeyNotLike("not important")), + Pair.of( + "AttachmentClassificationNameIn", + query -> query.attachmentClassificationNameIn("not important")), + Pair.of( + "AttachmentClassificationNameLike", + query -> query.attachmentClassificationNameLike("not important")), + Pair.of( + "AttachmentClassificationNameNotIn", + query -> query.attachmentClassificationNameNotIn("not important")), + Pair.of( + "AttachmentClassificationNameNotLike", + query -> query.attachmentClassificationNameNotLike("not important")), + Pair.of( + "AttachmentReceivedWithin", + query -> + query.attachmentReceivedWithin( + new TimeInterval(Instant.now(), Instant.now()))), + Pair.of( + "AttachmentReceivedNotWithin", + query -> + query.attachmentNotReceivedWithin( + new TimeInterval(Instant.now(), Instant.now()))), + Pair.of( + "AttachmentReferenceValueIn", + query -> query.attachmentReferenceValueIn("not important")), + Pair.of( + "AttachmentReferenceValueLike", + query -> query.attachmentReferenceValueLike("not important")), + Pair.of( + "AttachmentReferenceValueNotIn", + query -> query.attachmentReferenceValueNotIn("not important")), + Pair.of( + "AttachmentReferenceValueNotLike", + query -> query.attachmentReferenceValueNotLike("not important")), + Pair.of( + "AttachmentReferenceValueNotLike, AttachmentClassificationKeyNotLike", + query -> + query + .attachmentReferenceValueNotLike("not important") + .attachmentClassificationKeyNotLike("not important"))); + + ThrowingConsumer>> test = + p -> { + Function addAttachmentFilter = p.getRight(); + + TaskQuery query = + taskService.createTaskQuery().workbasketIdIn(wb.getId()).withoutAttachment(); + query = addAttachmentFilter.apply(query); + + assertThatThrownBy(query::list) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "The param \"withoutAttachment\" can only be used " + + "without adding attributes of attachment as filter parameter"); + }; + + return DynamicTest.stream(testCases.iterator(), Pair::getLeft, test); + } + } + @Nested @TestInstance(Lifecycle.PER_CLASS) class QueryingObjectReferenceCombinations { diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskQuery.java b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskQuery.java index 3a3d00662..71cc030e1 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskQuery.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskQuery.java @@ -4,6 +4,7 @@ import pro.taskana.common.api.BaseQuery; import pro.taskana.common.api.KeyDomain; import pro.taskana.common.api.TimeInterval; import pro.taskana.common.api.exceptions.InvalidArgumentException; +import pro.taskana.task.api.models.Attachment; import pro.taskana.task.api.models.ObjectReference; import pro.taskana.task.api.models.Task; import pro.taskana.task.api.models.TaskSummary; @@ -1458,6 +1459,17 @@ public interface TaskQuery extends BaseQuery { */ TaskQuery orderByAttachmentReceived(SortDirection sortDirection); + // endregion + // region withoutAttachment + + /** + * This method adds the condition that only {@linkplain Task Tasks} that don't have any + * {@linkplain Attachment Attachments} will be included in the {@linkplain TaskQuery}. + * + * @return the {@linkplain TaskQuery} + */ + TaskQuery withoutAttachment(); + // endregion // region secondaryObjectReference diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java index 0b16f238b..8b12aeac5 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java @@ -256,6 +256,9 @@ public class TaskQueryImpl implements TaskQuery { private TimeInterval[] attachmentReceivedWithin; private TimeInterval[] attachmentReceivedNotWithin; // endregion + // region withoutAttachment + private boolean withoutAttachment; + // endregion // region secondaryObjectReferences private ObjectReference[] secondaryObjectReferences; // endregion @@ -391,6 +394,7 @@ public class TaskQueryImpl implements TaskQuery { this.taskService = (TaskServiceImpl) taskanaEngine.getEngine().getTaskService(); this.orderBy = new ArrayList<>(); this.filterByAccessIdIn = true; + this.withoutAttachment = false; this.joinWithUserInfo = taskanaEngine.getEngine().getConfiguration().getAddAdditionalUserInfo(); } @@ -1393,6 +1397,16 @@ public class TaskQueryImpl implements TaskQuery { : addOrderCriteria("a.RECEIVED", sortDirection); } + // endregion + // region withoutAttachment + + @Override + public TaskQuery withoutAttachment() { + this.joinWithAttachments = true; + this.withoutAttachment = true; + return this; + } + // endregion public String[] getOwnerLongNameIn() { @@ -2082,6 +2096,31 @@ public class TaskQueryImpl implements TaskQuery { "The params \"wildcardSearchFieldIn\" and \"wildcardSearchValueLike\"" + " must be used together!"); } + if (withoutAttachment + && (attachmentChannelIn != null + || attachmentChannelLike != null + || attachmentChannelNotIn != null + || attachmentChannelNotLike != null + || attachmentClassificationIdIn != null + || attachmentClassificationIdNotIn != null + || attachmentClassificationKeyIn != null + || attachmentClassificationKeyLike != null + || attachmentClassificationKeyNotIn != null + || attachmentClassificationKeyNotLike != null + || attachmentClassificationNameIn != null + || attachmentClassificationNameLike != null + || attachmentClassificationNameNotIn != null + || attachmentClassificationNameNotLike != null + || attachmentReceivedWithin != null + || attachmentReceivedNotWithin != null + || attachmentReferenceIn != null + || attachmentReferenceLike != null + || attachmentReferenceNotIn != null + || attachmentReferenceNotLike != null)) { + throw new IllegalArgumentException( + "The param \"withoutAttachment\" can only be used " + + "without adding attributes of attachment as filter parameter"); + } } private String getDatabaseId() { @@ -2453,6 +2492,8 @@ public class TaskQueryImpl implements TaskQuery { + Arrays.toString(attachmentReceivedWithin) + ", attachmentReceivedNotWithin=" + Arrays.toString(attachmentReceivedNotWithin) + + ", withoutAttachment=" + + withoutAttachment + ", secondaryObjectReferences=" + Arrays.toString(secondaryObjectReferences) + ", sorCompanyIn=" diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQuerySqlProvider.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQuerySqlProvider.java index f5ddb41a7..4c8630539 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQuerySqlProvider.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQuerySqlProvider.java @@ -429,6 +429,7 @@ public class TaskQuerySqlProvider { + "LIKE #{wildcardSearchValueLike}" + ")" + " "); + sb.append(" AND a.ID IS NULL "); sb.append(commonTaskObjectReferenceWhereStatement()); sb.append(commonTaskSecondaryObjectReferencesWhereStatement()); return sb; diff --git a/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskQueryFilterParameter.java b/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskQueryFilterParameter.java index 01f00f58a..a85cb832f 100644 --- a/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskQueryFilterParameter.java +++ b/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskQueryFilterParameter.java @@ -1092,6 +1092,14 @@ public class TaskQueryFilterParameter implements QueryParameter @JsonProperty("attachment-received-not") private final Instant[] attachmentReceivedNotWithin; // endregion + // region withoutAttachment + /** + * In order to filter Tasks that don't have any Attachments, set 'without-attachment' to 'true'. + * Any other value for 'without-attachment' is invalid. + */ + @JsonProperty("without-attachment") + private final Boolean withoutAttachment; + // endregion // region customAttributes /** Filter by the value of the field custom1 of the Task. This is an exact match. */ @JsonProperty("custom-1") @@ -1645,6 +1653,7 @@ public class TaskQueryFilterParameter implements QueryParameter "attachment-reference-not-like", "attachment-received", "attachment-received-not", + "without-attachment", "custom-1", "custom-1-not", "custom-1-like", @@ -1854,6 +1863,7 @@ public class TaskQueryFilterParameter implements QueryParameter String[] attachmentReferenceNotLike, Instant[] attachmentReceivedWithin, Instant[] attachmentReceivedNotWithin, + Boolean withoutAttachment, String[] custom1In, String[] custom1NotIn, String[] custom1Like, @@ -2062,6 +2072,7 @@ public class TaskQueryFilterParameter implements QueryParameter this.attachmentReferenceNotLike = attachmentReferenceNotLike; this.attachmentReceivedWithin = attachmentReceivedWithin; this.attachmentReceivedNotWithin = attachmentReceivedNotWithin; + this.withoutAttachment = withoutAttachment; this.custom1In = custom1In; this.custom1NotIn = custom1NotIn; this.custom1Like = custom1Like; @@ -2460,6 +2471,10 @@ public class TaskQueryFilterParameter implements QueryParameter .map(this::extractTimeIntervals) .ifPresent(query::attachmentNotReceivedWithin); + if (Boolean.TRUE.equals(withoutAttachment)) { + query.withoutAttachment(); + } + Stream.of( Pair.of(CUSTOM_1, of(custom1In, custom1NotIn, custom1Like, custom1NotLike)), Pair.of(CUSTOM_2, of(custom2In, custom2NotIn, custom2Like, custom2NotLike)), @@ -2660,5 +2675,10 @@ public class TaskQueryFilterParameter implements QueryParameter throw new InvalidArgumentException( "provided length of the property 'attachment-not-received' is not dividable by 2"); } + + if (withoutAttachment != null && !withoutAttachment) { + throw new InvalidArgumentException( + "provided value of the property 'without-attachment' must be 'true'"); + } } } diff --git a/rest/taskana-rest-spring/src/test/java/pro/taskana/task/rest/TaskControllerIntTest.java b/rest/taskana-rest-spring/src/test/java/pro/taskana/task/rest/TaskControllerIntTest.java index 02e38cc7d..ae2f83f30 100644 --- a/rest/taskana-rest-spring/src/test/java/pro/taskana/task/rest/TaskControllerIntTest.java +++ b/rest/taskana-rest-spring/src/test/java/pro/taskana/task/rest/TaskControllerIntTest.java @@ -639,6 +639,30 @@ class TaskControllerIntTest { assertThat(repModel.getAttachments()).isNotEmpty(); } + @Test + void should_ReturnFilteredTasks_When_GettingTaskWithoutAttachments() { + String url = restHelper.toUrl(RestEndpoints.URL_TASKS) + "?without-attachment=true"; + HttpEntity auth = new HttpEntity<>(RestHelper.generateHeadersForUser("admin")); + + ResponseEntity response = + TEMPLATE.exchange(url, HttpMethod.GET, auth, TASK_SUMMARY_PAGE_MODEL_TYPE); + + assertThat(response.getBody()).isNotNull(); + assertThat((response.getBody()).getLink(IanaLinkRelations.SELF)).isNotNull(); + assertThat(response.getBody().getContent()).hasSize(83); + } + + @Test + void should_ThrowException_When_WithoutAttachmentsIsSetToFalse() { + String url = restHelper.toUrl(RestEndpoints.URL_TASKS) + "?without-attachment=false"; + HttpEntity auth = new HttpEntity<>(RestHelper.generateHeadersForUser("admin")); + + assertThatThrownBy( + () -> TEMPLATE.exchange(url, HttpMethod.GET, auth, TASK_SUMMARY_PAGE_MODEL_TYPE)) + .isInstanceOf(HttpStatusCodeException.class) + .hasMessageContaining("provided value of the property 'without-attachment' must be 'true'"); + } + @Test void should_NotGetEmptyObjectReferencesList_When_GettingTaskWithObjectReferences() { String url =