From 53c7977844d5bbbbcca1bc5c4849b36b53d14ad4 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Thu, 22 Oct 2026 14:38:57 +0200 Subject: [PATCH 1/9] Introduce execution context tracking with `ExecutionContext` and `Origin` handling; Add `OriginFilter` to tests for context propagation. --- .../elastic/service/ElasticCaseService.java | 8 +- .../interfaces/IElasticCaseService.java | 8 + .../domain/FileStorageConfiguration.java | 19 - .../CaseProjectionRepository.java | 97 +++ .../CaseProjectionRepositoryImpl.java | 304 +++++++++ .../domain/repositories/CaseRepository.java | 2 +- .../projection/CaseProjection.java | 54 ++ .../workflow/service/CaseEventHandler.java | 4 + .../engine/workflow/service/DataService.java | 36 +- .../service/WorkflowAuthorizationService.java | 3 +- .../workflow/service/WorkflowService.java | 181 +++++- .../service/interfaces/IEventService.java | 2 +- .../service/interfaces/IWorkflowService.java | 97 +++ .../workflow/web/WorkflowController.java | 18 +- .../responsebodies/ResourceLinkAssembler.java | 4 +- .../CaseProjectionRepositoryImplTest.java | 136 ++++ .../repositories/CaseProjectionTest.java | 42 ++ .../ProjectionTestNetIntegrationTest.java | 313 +++++++++ .../WorkflowServiceProjectionTest.java | 199 ++++++ .../petriNets/projection_test_net.xml | 614 ++++++++++++++++++ .../engine/objects/workflow/domain/Case.java | 8 + .../adapter/spring/workflow/domain/Case.java | 7 + 22 files changed, 2112 insertions(+), 44 deletions(-) delete mode 100644 application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/FileStorageConfiguration.java create mode 100644 application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepository.java create mode 100644 application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImpl.java create mode 100644 application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/projection/CaseProjection.java create mode 100644 application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImplTest.java create mode 100644 application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionTest.java create mode 100644 application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java create mode 100644 application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java create mode 100644 application-engine/src/test/resources/petriNets/projection_test_net.xml diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java index 303f94a8f8f..65c114554db 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java @@ -130,6 +130,11 @@ public void indexNow(ElasticCase useCase) { @Override public Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection) { + return search(requests, user, pageable, locale, isIntersection, null); + } + + @Override + public Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection, Collection dataFieldIds) { if (requests == null) { throw new IllegalArgumentException("Request can not be null!"); } @@ -144,7 +149,8 @@ public Page search(List requests, LoggedUser user, Page if (query != null) { SearchHits hits = template.search(query, ElasticCase.class, IndexCoordinates.of(elasticProperties.getIndex().get(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX))); Page indexedCases = (Page) SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(hits, query.getPageable())); - casePage = workflowService.findAllById(indexedCases.get().map(ElasticCase::getId).collect(Collectors.toList())); + List ids = indexedCases.get().map(ElasticCase::getId).collect(Collectors.toList()); + casePage = dataFieldIds == null ? workflowService.findAllById(ids) : workflowService.findAllByIdProjected(ids, dataFieldIds); total = indexedCases.getTotalElements(); log.debug("Found [{}] total elements of page [{}]", casePage.size(), pageable.getPageNumber()); } else { diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java index bf3da6f70ba..003589c6fa1 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.scheduling.annotation.Async; +import java.util.Collection; import java.util.List; import java.util.Locale; @@ -20,6 +21,13 @@ public interface IElasticCaseService { Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection); + /** + * Same as {@link #search(List, LoggedUser, Pageable, Locale, Boolean)}, but Mongo hydration loads only the given + * {@code dataSet} entries (plus automatically required ones). Returned cases are PARTIAL and READ-ONLY; persist + * changes with {@code IWorkflowService#savePartial}. A {@code null} {@code dataFieldIds} loads full cases. + */ + Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection, Collection dataFieldIds); + long count(List requests, LoggedUser user, Locale locale, Boolean isIntersection); void remove(String caseId); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/FileStorageConfiguration.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/FileStorageConfiguration.java deleted file mode 100644 index 52b5ad178b2..00000000000 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/FileStorageConfiguration.java +++ /dev/null @@ -1,19 +0,0 @@ -//package com.netgrif.application.engine.workflow.domain; -// -//import co.elastic.clients.elasticsearch.indices.StorageType; -//import lombok.Data; -//import org.springframework.beans.factory.annotation.Value; -//import org.springframework.boot.context.properties.ConfigurationProperties; -// -//@Data -//@ConfigurationProperties(prefix = "netgrif.engine.storage") -//public class FileStorageConfiguration { -// -// private String path = "storage"; -// -// private String archivedPath = "storage/uploadedModels/"; -// -// private boolean clean = false; -// -// private String defaultType = "local"; -//} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepository.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepository.java new file mode 100644 index 00000000000..e4e1e4cbcb7 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepository.java @@ -0,0 +1,97 @@ +package com.netgrif.application.engine.workflow.domain.repositories; + +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; +import com.querydsl.core.types.Predicate; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * Read-only projected queries for {@link Case}. Returned cases contain all static fields but only the requested + * {@code dataSet} entries. They are partial and READ-ONLY: they must never be persisted with {@code save(...)}. + */ +public interface CaseProjectionRepository { + + /** + * Loads a PARTIAL, READ-ONLY case: all static fields plus only the {@code dataSet} entries in the projection. + * The result must never be saved. + * + * @param objectId object id part of the composite case id + * @param projection requested data set keys + * @return the partially loaded case, if found + * @throws IllegalArgumentException if any key is invalid + */ + Optional findProjectedByObjectId(ObjectId objectId, CaseProjection projection); + + /** + * Loads PARTIAL, READ-ONLY cases with a single query. Order is not guaranteed. The results must never be saved. + * + * @param objectIds object id parts of the composite case ids + * @param projection requested data set keys + * @return partially loaded cases; empty list (without querying) for empty input + * @throws IllegalArgumentException if any key is invalid + */ + List findAllProjectedByObjectIds(Collection objectIds, CaseProjection projection); + + /** + * Persists a PARTIALLY LOADED case using Mongo {@code $set} update operations instead of replacing the document. + * Only the static fields (except {@code _id}) and the {@code dataSet} entries that are present in the given case + * are written, so {@code dataSet} entries that were never loaded are left untouched. {@code lastModified} is + * refreshed. + * + * @param partial a case obtained from a projected read + * @return {@code true} if a document was matched + * @throws IllegalArgumentException if any present data set key is invalid + */ + boolean updatePartial(Case partial); + + /** + * Loads a page of PARTIAL, READ-ONLY cases (all static fields plus the projected {@code dataSet} entries). + * Sorting and paging are applied by Mongo. The results must never be saved. + * + * @param pageable paging and sorting + * @param projection requested data set keys + * @return page of partially loaded cases + * @throws IllegalArgumentException if any key is invalid + */ + Page findAllProjected(Pageable pageable, CaseProjection projection); + + /** + * Loads a PARTIAL, READ-ONLY case matching the given predicate: all static fields plus only the {@code dataSet} + * entries in the projection. The result must never be saved. + * + * @param predicate Querydsl predicate + * @param projection requested data set keys + * @return the partially loaded case, if found + * @throws IllegalArgumentException if any key is invalid + */ + Optional findOneProjected(Predicate predicate, CaseProjection projection); + + /** + * Loads PARTIAL, READ-ONLY cases matching the given predicate: all static fields plus only the {@code dataSet} + * entries in the projection. The results must never be saved. + * + * @param predicate Querydsl predicate + * @param projection requested data set keys + * @return partially loaded cases + * @throws IllegalArgumentException if any key is invalid + */ + List findAllProjected(Predicate predicate, CaseProjection projection); + + /** + * Loads a page of PARTIAL, READ-ONLY cases matching the given predicate (all static fields plus the projected + * {@code dataSet} entries). Sorting and paging are applied by Mongo. The results must never be saved. + * + * @param predicate Querydsl predicate + * @param pageable paging and sorting + * @param projection requested data set keys + * @return page of partially loaded cases + * @throws IllegalArgumentException if any key is invalid + */ + Page findAllProjected(Predicate predicate, Pageable pageable, CaseProjection projection); +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImpl.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImpl.java new file mode 100644 index 00000000000..5a3222308ec --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImpl.java @@ -0,0 +1,304 @@ +package com.netgrif.application.engine.workflow.domain.repositories; + +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.utils.MongodbUtils; +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; +import com.querydsl.core.types.Predicate; +import lombok.RequiredArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import java.time.LocalDateTime; + +import java.util.*; +import java.util.regex.Pattern; + + +/** + * Implementation of {@link CaseProjectionRepository} providing MongoDB-specific query execution + * with field projections for {@link Case} entities. + *

+ * This repository optimizes performance and payload size by loading partial case representations. + * Query results include all static entity properties, but only the subset of {@code dataSet} + * entries explicitly requested via {@link CaseProjection}. + *

+ *

+ * All case entities returned by query methods are marked as partial (read-only) and must not be + * persisted using standard full-document save methods, as doing so would overwrite and clear + * non-projected {@code dataSet} fields. Updates to partial cases must be performed exclusively via + * {@link #updatePartial(Case)}. + *

+ */ +@RequiredArgsConstructor +class CaseProjectionRepositoryImpl implements CaseProjectionRepository { + + /** + * Name of the dynamic data set map field in the MongoDB document. + */ + private static final String DATA_SET_FIELD = "dataSet"; + + /** + * Spring Data MongoDB template used for low-level query execution and update operations. + */ + private final MongoTemplate mongoTemplate; + + /** + * MongoDB mapping context used for metadata inspection and entity property reflection. + */ + private final MongoMappingContext mappingContext; + + /** + * Lazily initialized cached list of static property names for the concrete Case entity class. + */ + private volatile List staticFields; + + /** + * Loads a PARTIAL, READ-ONLY case by its underlying ObjectId. + * + * @param objectId object id part of the composite case id + * @param projection requested data set keys to include in the projection + * @return an {@link Optional} containing the partially loaded case if found, or empty otherwise + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public Optional findProjectedByObjectId(ObjectId objectId, CaseProjection projection) { + Query query = new Query(Criteria.where("_id.objectId").is(objectId)); + applyProjection(query, projection); + return Optional.ofNullable(mongoTemplate.findOne(query, concreteCaseClass())) + .map(CaseProjectionRepositoryImpl::markPartial); + } + + /** + * Loads PARTIAL, READ-ONLY cases with a single query for a collection of ObjectIds. + * + * @param objectIds collection of object id parts of composite case ids + * @param projection requested data set keys to include in the projection + * @return list of partially loaded cases, or an empty list if {@code objectIds} is null or empty + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public List findAllProjectedByObjectIds(Collection objectIds, CaseProjection projection) { + if (objectIds == null || objectIds.isEmpty()) { + return new ArrayList<>(); + } + Query query = new Query(Criteria.where("_id.objectId").in(objectIds)); + applyProjection(query, projection); + List result = new ArrayList<>(); + for (Case c : mongoTemplate.find(query, concreteCaseClass())) { + result.add(markPartial(c)); + } + return result; + } + + /** + * Loads a page of PARTIAL, READ-ONLY cases matching pagination and sorting criteria. + * + * @param pageable pagination and sorting information + * @param projection requested data set keys to include in the projection + * @return a {@link Page} of partially loaded cases along with total count metadata + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public Page findAllProjected(Pageable pageable, CaseProjection projection) { + Query query = new Query(); + applyProjection(query, projection); + if (pageable != null && pageable.isPaged()) { + query.with(pageable); + } else if (pageable != null) { + query.with(pageable.getSort()); + } + List content = new ArrayList<>(); + for (Case c : mongoTemplate.find(query, concreteCaseClass())) { + content.add(markPartial(c)); + } + return PageableExecutionUtils.getPage(content, pageable == null ? Pageable.unpaged() : pageable, + () -> mongoTemplate.count(new Query(), concreteCaseClass())); + } + + /** + * Loads a single PARTIAL, READ-ONLY case matching the given Querydsl predicate. + * + * @param predicate Querydsl predicate defining filter criteria + * @param projection requested data set keys to include in the projection + * @return an {@link Optional} containing the partially loaded case if found, or empty otherwise + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public Optional findOneProjected(Predicate predicate, CaseProjection projection) { + Query query = predicate == null ? new Query() : MongodbUtils.toQuery(mongoTemplate, concreteCaseClass(), predicate); + applyProjection(query, projection); + return Optional.ofNullable(mongoTemplate.findOne(query, concreteCaseClass())) + .map(CaseProjectionRepositoryImpl::markPartial); + } + + /** + * Loads all PARTIAL, READ-ONLY cases matching the given Querydsl predicate. + * + * @param predicate Querydsl predicate defining filter criteria + * @param projection requested data set keys to include in the projection + * @return list of partially loaded cases matching the predicate + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public List findAllProjected(Predicate predicate, CaseProjection projection) { + Query query = predicate == null ? new Query() : MongodbUtils.toQuery(mongoTemplate, concreteCaseClass(), predicate); + applyProjection(query, projection); + List result = new ArrayList<>(); + for (Case c : mongoTemplate.find(query, concreteCaseClass())) { + result.add(markPartial(c)); + } + return result; + } + + /** + * Loads a page of PARTIAL, READ-ONLY cases matching the given Querydsl predicate and pagination criteria. + * + * @param predicate Querydsl predicate defining filter criteria + * @param pageable pagination and sorting information + * @param projection requested data set keys to include in the projection + * @return a {@link Page} of partially loaded cases along with total count matching the predicate + * @throws IllegalArgumentException if any data set key in the projection is invalid + */ + @Override + public Page findAllProjected(Predicate predicate, Pageable pageable, CaseProjection projection) { + Query query = predicate == null ? new Query() : MongodbUtils.toQuery(mongoTemplate, concreteCaseClass(), predicate); + applyProjection(query, projection); + if (pageable != null && pageable.isPaged()) { + query.with(pageable); + } else if (pageable != null) { + query.with(pageable.getSort()); + } + List content = new ArrayList<>(); + for (Case c : mongoTemplate.find(query, concreteCaseClass())) { + content.add(markPartial(c)); + } + Query countQuery = predicate == null ? new Query() : MongodbUtils.toQuery(mongoTemplate, concreteCaseClass(), predicate); + return PageableExecutionUtils.getPage(content, pageable == null ? Pageable.unpaged() : pageable, + () -> mongoTemplate.count(countQuery, concreteCaseClass())); + } + + /** + * Persists a PARTIAL case via MongoDB {@code $set} operations without overwriting non-projected dataSet entries. + *

+ * Updates all static entity properties (excluding the {@code _id} property) and sets only the {@code dataSet} + * keys present in the provided partial case. The {@code lastModified} timestamp is updated to the current time. + *

+ * + * @param partial partially loaded case to update + * @return {@code true} if an existing case document matched and was updated, {@code false} otherwise + * @throws IllegalArgumentException if any present dataSet key is invalid (e.g., contains '$', '.', or null bytes) + */ + @Override + public boolean updatePartial(Case partial) { + Map dataSet = partial.getDataSet(); + Set keys = dataSet == null ? Set.of() : dataSet.keySet(); + for (String key : keys) { + validateKey(key); + } + partial.setLastModified(LocalDateTime.now()); + Update update = new Update(); + PersistentPropertyAccessor accessor = mappingContext.getRequiredPersistentEntity(concreteCaseClass()).getPropertyAccessor(partial); + mappingContext.getRequiredPersistentEntity(concreteCaseClass()).doWithProperties((PropertyHandler) property -> { + String name = property.getFieldName(); + if (property.isIdProperty() || DATA_SET_FIELD.equals(name)) { + return; + } + update.set(name, accessor.getProperty(property)); + }); + for (String key : keys) { + update.set(DATA_SET_FIELD + "." + key, dataSet.get(key)); + } + Query query = new Query(Criteria.where("_id.objectId").is(partial.get_id().getObjectId())); + return mongoTemplate.updateFirst(query, update, concreteCaseClass()).getMatchedCount() > 0; + } + + /** + * Marks the case instance as partially loaded to prevent accidental full-document persistence. + * + * @param c case instance to mark + * @return the same case instance with {@code partiallyLoaded} set to {@code true} + */ + private static Case markPartial(Case c) { + c.setPartiallyLoaded(true); + return c; + } + + /** + * Returns the concrete Spring Data / MongoDB mapped Case entity class. + * + * @return concrete Case class definition + */ + private static Class concreteCaseClass() { + return com.netgrif.application.engine.adapter.spring.workflow.domain.Case.class; + } + + /** + * Applies projection inclusions to the provided MongoDB query. + * Involves all static document fields and the specified subset of {@code dataSet} fields. + * + * @param query MongoDB query to modify + * @param projection projection specifications and data set keys to include + * @throws IllegalArgumentException if any data set key is invalid + */ + private void applyProjection(Query query, CaseProjection projection) { + Set keys = projection == null ? Set.of() : projection.dataSetKeys(); + for (String key : keys) { + validateKey(key); + } + for (String field : getStaticFields()) { + query.fields().include(field); + } + for (String key : keys) { + query.fields().include(DATA_SET_FIELD + "." + key); + } + } + + /** + * Validates that a dataSet key is well-formed and safe for MongoDB field path operations. + * + * @param key key string to validate + * @throws IllegalArgumentException if the key is null, blank, or contains reserved characters ('$', '.', null byte) + */ + private static void validateKey(String key) { + if (key == null || key.isBlank() || key.contains("$") || key.contains(".") || key.contains("\0")) { + throw new IllegalArgumentException("Invalid dataSet key [" + key + "]"); + } + } + + /** + * Retrieves the cached list of all static property names of the concrete Case entity, + * excluding the dynamic {@code dataSet} field. Uses double-checked locking for thread-safe lazy loading. + * + * @return list of static property names + */ + private List getStaticFields() { + List local = staticFields; + if (local == null) { + synchronized (this) { + local = staticFields; + if (local == null) { + MongoPersistentEntity entity = mappingContext.getRequiredPersistentEntity(concreteCaseClass()); + List names = new ArrayList<>(); + entity.doWithProperties((PropertyHandler) property -> { + if (!DATA_SET_FIELD.equals(property.getFieldName())) { + names.add(property.getFieldName()); + } + }); + local = List.copyOf(names); + staticFields = local; + } + } + } + return local; + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java index 4fdb121d904..716880da4d4 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/CaseRepository.java @@ -17,7 +17,7 @@ import java.util.Optional; @Repository -public interface CaseRepository extends MongoRepository, QuerydslPredicateExecutor, QuerydslBinderCustomizer { +public interface CaseRepository extends MongoRepository, QuerydslPredicateExecutor, QuerydslBinderCustomizer, CaseProjectionRepository { List findAllByProcessIdentifier(String identifier); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/projection/CaseProjection.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/projection/CaseProjection.java new file mode 100644 index 00000000000..ca3f634d342 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/domain/repositories/projection/CaseProjection.java @@ -0,0 +1,54 @@ +package com.netgrif.application.engine.workflow.domain.repositories.projection; + +import java.util.*; + +/** + * Describes which {@code dataSet} entries of a {@code Case} should be loaded by projected (partial) read queries. + * All static (non-{@code dataSet}) fields are always loaded. + * + * @param dataSetKeys immutable set of requested data field ids + */ +public record CaseProjection(Set dataSetKeys) { + + public CaseProjection { + dataSetKeys = dataSetKeys == null ? Set.of() : Set.copyOf(dataSetKeys); + } + + /** + * Creates a projection from the given keys. Nulls are dropped, keys are trimmed, blanks removed and duplicates + * eliminated (insertion order is retained during normalization). + * + * @param keys requested data field ids, may be {@code null} + * @return the projection + */ + public static CaseProjection ofDataFields(Collection keys) { + return new CaseProjection(normalize(keys)); + } + + /** + * @param keys additional data field ids, normalized in the same way as in {@link #ofDataFields(Collection)} + * @return a new projection containing keys of this projection and the additional ones + */ + public CaseProjection withAdditionalKeys(Collection keys) { + Set merged = new LinkedHashSet<>(dataSetKeys); + merged.addAll(normalize(keys)); + return new CaseProjection(merged); + } + + private static Set normalize(Collection keys) { + if (keys == null) { + return new LinkedHashSet<>(); + } + Set result = new LinkedHashSet<>(); + for (String key : keys) { + if (key == null) { + continue; + } + String trimmed = key.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + return result; + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java index 0644139bad9..114f72305dc 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/CaseEventHandler.java @@ -38,6 +38,10 @@ public void onAfterConvert(AfterConvertEvent event) { List> immediateFields = new ArrayList<>(); if (useCase.getImmediateDataFields() != null) { useCase.getImmediateDataFields().forEach(fieldId -> { + if (useCase.getDataSet() != null && !useCase.getDataSet().containsKey(fieldId)) { + log.debug("Skipping immediate field [{}] of case [{}]: data set entry not loaded", fieldId, useCase.getStringId()); + return; + } try { immediateFields.add(fieldFactory.buildImmediateField(useCase, fieldId)); } catch (Exception e) { diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java index efb79c6e880..0be344b8725 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java @@ -16,6 +16,7 @@ import com.netgrif.application.engine.importer.service.FieldFactory; import com.netgrif.application.engine.objects.petrinet.domain.Component; import com.netgrif.application.engine.objects.petrinet.domain.*; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; import com.netgrif.application.engine.objects.petrinet.domain.dataset.*; import com.netgrif.application.engine.objects.petrinet.domain.dataset.logic.ChangedField; import com.netgrif.application.engine.objects.petrinet.domain.dataset.logic.FieldBehavior; @@ -128,7 +129,7 @@ public GetDataEventOutcome getData(String taskId, Map params) { @Override public GetDataEventOutcome getData(String taskId, Map params, boolean eventsEnabled) { Task task = taskService.findOne(taskId); - Case useCase = workflowService.findOne(task.getCaseId()); + Case useCase = workflowService.findOneProjected(task.getCaseId(), net -> resolveGetDataKeys(net, task)); return getData(task, useCase, params, eventsEnabled); } @@ -211,7 +212,11 @@ public GetDataEventOutcome getData(Task task, Case useCase, Map } }); - workflowService.save(useCase); + if (!useCase.isPartiallyLoaded()) { + workflowService.save(useCase); + } else if (eventsEnabled && hasGetEvents(useCase.getPetriNet(), transition)) { //TODO AI: why this check is necessary? Wouldn't be good to just have else? In that case hasGetEvents is unnecessary function + workflowService.savePartial(useCase); + } dataSetFields.stream().filter(field -> field instanceof NumberField).forEach(field -> { DataField dataField = useCase.getDataSet().get(field.getImportId()); @@ -427,7 +432,7 @@ public GetDataGroupsEventOutcome getDataGroups(String taskId, Locale locale, Set @Override public GetDataGroupsEventOutcome getDataGroups(String taskId, Locale locale, Set collectedTaskIds, int level, String parentTaskRefId, boolean eventsEnabled) { Task task = taskService.findOne(taskId); - Case useCase = workflowService.findOne(task.getCaseId()); + Case useCase = workflowService.findOneProjected(task.getCaseId(), net -> resolveGetDataKeys(net, task)); PetriNet net = useCase.getPetriNet(); Transition transition = net.getTransition(task.getTransitionId()); GetDataGroupsEventOutcome outcome = new GetDataGroupsEventOutcome(useCase, task); @@ -882,7 +887,7 @@ public Page setImmediateFields(Page tasks) { @Override public List> getImmediateFields(Task task) { - Case useCase = workflowService.findOne(task.getCaseId()); + Case useCase = workflowService.findOneProjected(task.getCaseId(), task.getImmediateDataFields()); List> fields = task.getImmediateDataFields().stream().map(id -> fieldFactory.buildFieldWithoutValidation(useCase, id, task.getTransitionId())).collect(Collectors.toList()); LongStream.range(0L, fields.size()).forEach(index -> fields.get((int) index).setOrder(index)); return fields; @@ -1328,10 +1333,23 @@ public void validateCaseRefValue(List value, List allowedNets) t }); } -// public void validateTaskRefValue(List taskIds, String restrictedTaskId) throws IllegalArgumentException { -// if (taskIds != null && taskIds.contains(restrictedTaskId)) { -// throw new IllegalArgumentException(String.format("Task with id '%s' cannot be added to task ref, since it is a task which is displaying task ref", restrictedTaskId)); -// } -// } + private Collection resolveGetDataKeys(PetriNet net, Task task) { + Transition transition = net.getTransition(task.getTransitionId()); + if (transition == null) { + return Collections.emptySet(); + } + return transition.getDataSet().keySet(); + } + + private boolean hasGetEvents(PetriNet net, Transition transition) { + if (transition == null) { + return false; + } + return transition.getDataSet().entrySet().stream().anyMatch(entry -> { + Field field = net.getDataSet().get(entry.getKey()); + return (field != null && field.getEvents() != null && field.getEvents().containsKey(DataEventType.GET)) + || (entry.getValue() != null && entry.getValue().getEvents() != null && entry.getValue().getEvents().containsKey(DataEventType.GET)); + }); + } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowAuthorizationService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowAuthorizationService.java index ae4f4aadd74..123a5c663ed 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowAuthorizationService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowAuthorizationService.java @@ -29,7 +29,8 @@ public boolean canCallDelete(LoggedUser user, String caseId) { return true; } - Case requestedCase = workflowService.findOne(caseId); + // only static fields (permissions, actors, petri net) are needed - no data set entries are loaded + Case requestedCase = workflowService.findOneProjected(caseId, List.of()); // TODO: impersonation user.getSelfOrImpersonated() Boolean userPerm = userHasUserListPermission(user, requestedCase, ProcessRolePermission.DELETE); if (userPerm != null) { diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java index 11c1f532c3d..f3df66a487f 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java @@ -39,6 +39,8 @@ import com.netgrif.application.engine.objects.workflow.service.InitValueExpressionEvaluator; import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import com.netgrif.application.engine.objects.petrinet.domain.arcs.reference.Type; +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; import com.querydsl.core.types.Predicate; import org.bson.types.ObjectId; import org.slf4j.Logger; @@ -132,6 +134,9 @@ public void setEvaluationService(EvaluationService evaluationService) { @Override public Case save(Case useCase) { + if (useCase.isPartiallyLoaded()) { + throw new IllegalStateException("Partially loaded case [" + useCase.getStringId() + "] must not be saved, use savePartial"); + } if (useCase.getPetriNet() == null) { setPetriNet(useCase); } @@ -197,6 +202,146 @@ public List findAllById(List ids) { .collect(Collectors.toList()); } + /** + * Loads a PARTIAL, READ-ONLY case with only the requested {@code dataSet} entries. Must never be saved. + *

+ * Approach: a cheap first read loads static fields only (no {@code dataSet}); from it and the PetriNet the + * automatically required keys are derived (case immediate data field ids and data fields referenced by arcs). + * A second read then loads the requested plus required keys. Unknown requested keys are logged at debug level + * and simply not present in the result. + */ + @Override + public Case findOneProjected(String caseId, Collection dataFieldIds) { + List loaded = loadProjected(List.of(caseId), dataFieldIds); + if (loaded.isEmpty()) { + throw new IllegalArgumentException("Could not find Case with id [" + caseId + "]"); + } + return loaded.getFirst(); + } + + /** + * Loads PARTIAL, READ-ONLY cases using the same two-read approach as {@link #findOneProjected(String, Collection)} + * (two Mongo queries in total regardless of the number of ids). Preserves the order of input ids; unknown ids are + * skipped. Results must never be saved. + */ + @Override + public List findAllByIdProjected(List caseIds, Collection dataFieldIds) { + if (caseIds == null || caseIds.isEmpty()) { + return new ArrayList<>(); + } + return loadProjected(caseIds, dataFieldIds); + } + + private List loadProjected(List caseIds, Collection dataFieldIds) { + return loadProjected(caseIds, net -> dataFieldIds); + } + + private List loadProjected(List caseIds, Function> keyResolver) { + List objectIds = caseIds.stream().map(this::extractObjectId).distinct().collect(Collectors.toList()); + + //TODO AI: This step is unnecessary as it loads all cases by IDS without datafields, should be replaced with a simple "count by IDS" method. + List cases = repository.findAllProjectedByObjectIds(objectIds, CaseProjection.ofDataFields(null)); + if (cases.isEmpty()) { + return new ArrayList<>(); + } + + Map nets = new HashMap<>(); + Set required = new LinkedHashSet<>(); + CaseProjection requested = CaseProjection.ofDataFields(null); + for (Case aCase : cases) { + //TODO AI: This has to be optional base on incoming request parameter to add immediate fields or not. Default is to add. + required.addAll(aCase.getImmediateDataFields()); + PetriNet net = nets.computeIfAbsent(aCase.getPetriNetObjectId(), petriNetService::get); + if (net == null) { + continue; + } + net.getArcs().values().stream().flatMap(List::stream) + .filter(arc -> arc.getReference() != null && arc.getReference().getType() == Type.DATA) + .forEach(arc -> required.add(arc.getReference().getReference())); + CaseProjection forNet = CaseProjection.ofDataFields(keyResolver.apply(net)); + forNet.dataSetKeys().stream() + .filter(key -> !net.getDataSet().containsKey(key)) + .forEach(key -> log.debug("Requested data field [{}] does not exist in process [{}]", key, net.getIdentifier())); + requested = requested.withAdditionalKeys(forNet.dataSetKeys()); + } + + CaseProjection projection = requested.withAdditionalKeys(required); + if (!projection.dataSetKeys().isEmpty()) { + cases = repository.findAllProjectedByObjectIds(objectIds, projection); + } + + Map caseMap = cases.stream().collect(Collectors.toMap(Case::getStringId, c -> c, (a, b) -> a)); + List result = new ArrayList<>(); + for (String id : caseIds) { + Case aCase = caseMap.get(id); + if (aCase == null) { + continue; + } + //TODO AI: This has to be optional to setPetriNet or leave petriNet empty. Default is to set. + setPetriNet(aCase); + decryptDataSet(aCase); + setImmediateDataFieldsReadOnly(aCase); + aCase.setPartiallyLoaded(true); + result.add(aCase); + } + return result; + } + + /** + * Loads a PARTIAL, READ-ONLY case where the requested {@code dataSet} keys are derived from the case's PetriNet + * (e.g. the data of a transition), so the caller does not need to know them in advance. Automatically required + * keys are added as in {@link #findOneProjected(String, Collection)}. Must never be saved with {@code save}. + */ + @Override + public Case findOneProjected(String caseId, Function> dataFieldResolver) { + List loaded = loadProjected(List.of(caseId), dataFieldResolver); + if (loaded.isEmpty()) { + throw new IllegalArgumentException("Could not find Case with id [" + caseId + "]"); + } + return loaded.getFirst(); + } + + /** + * Loads a PARTIAL, READ-ONLY page: first page of static fields (Mongo paging/sorting), then the same projected + * hydration as {@link #findAllByIdProjected(List, Collection)}. Results must never be saved with {@code save}. + */ + @Override + public Page getAllProjected(Pageable pageable, Collection dataFieldIds) { + //TODO AI: I find these kind of duplicated call for cases in several places. Is it possible to avoid the initial case search without data? Wouldn't be this less effective as the original non-projected single calls? + Page staticPage = repository.findAllProjected(pageable, CaseProjection.ofDataFields(null)); + //TODO AI: this is duplicated code, should be extracted to a separate method. + List ids = staticPage.getContent().stream().map(Case::getStringId).collect(Collectors.toList()); + List hydrated = ids.isEmpty() ? new ArrayList<>() : loadProjected(ids, dataFieldIds); + return new PageImpl<>(hydrated, pageable, staticPage.getTotalElements()); + } + + /** + * Persists a PARTIAL case using Mongo {@code $set} updates (see {@code CaseProjectionRepository#updatePartial}). + * The case stays partial and read-only with respect to {@code save}; Elasticsearch is re-indexed from a full read. + */ + @Override + public Case savePartial(Case partialCase) { + if (!partialCase.isPartiallyLoaded()) { + throw new IllegalArgumentException("Case [" + partialCase.getStringId() + "] is not partially loaded, use save"); + } + //TODO AI: why is it necessary to set PetriNet on savePartial? PetriNet is transien and will not be persisted inside case + if (partialCase.getPetriNet() == null) { + setPetriNet(partialCase); + } + encryptDataSet(partialCase); + if (!repository.updatePartial(partialCase)) { + throw new IllegalArgumentException("Could not find Case with id [" + partialCase.getStringId() + "]"); + } + try { + Case full = findOne(partialCase.getStringId()); + setImmediateDataFields(full); + elasticCaseService.indexNow(this.caseMappingService.transform(full)); + } catch (Exception e) { + log.error("Indexing failed [{}]", partialCase.getStringId(), e); + } + return partialCase; + } + @Override public Page getAll(Pageable pageable) { Page page = repository.findAll(pageable); @@ -527,6 +672,37 @@ public Case searchOne(Predicate predicate) { return page.getContent().getFirst(); } + @Override + public Case searchOneProjected(Predicate predicate, Collection dataFieldIds) { + Page page = searchProjected(predicate, PageRequest.of(0, 1), dataFieldIds); + if (page.getContent().isEmpty()) { + return null; + } + return page.getContent().getFirst(); + } + + @Override + public Case searchOneProjected(Predicate predicate, Function> dataFieldResolver) { + Optional staticCase = repository.findOneProjected(predicate, CaseProjection.ofDataFields(null)); + if (staticCase.isEmpty()) { + return null; + } + return findOneProjected(staticCase.get().getStringId(), dataFieldResolver); + } + + @Override + public Page searchAllProjected(Predicate predicate, Collection dataFieldIds) { + return searchProjected(predicate, new FullPageRequest(), dataFieldIds); + } + + @Override + public Page searchProjected(Predicate predicate, Pageable pageable, Collection dataFieldIds) { + Page staticPage = repository.findAllProjected(predicate, pageable, CaseProjection.ofDataFields(null)); + List ids = staticPage.getContent().stream().map(Case::getStringId).collect(Collectors.toList()); + List hydrated = ids.isEmpty() ? new ArrayList<>() : loadProjected(ids, dataFieldIds); + return new PageImpl<>(hydrated, pageable, staticPage.getTotalElements()); + } + @Override public Map listToMap(List cases) { Map options = new HashMap<>(); @@ -662,7 +838,10 @@ private Map getEncryptedDataSet(Case useCase) { for (Map.Entry entry : net.getDataSet().entrySet()) { String encryption = entry.getValue().getEncryption(); if (encryption != null) { - encryptedDataSet.put(useCase.getDataSet().get(entry.getKey()), encryption); + DataField dataField = useCase.getDataSet().get(entry.getKey()); + if (dataField != null) { + encryptedDataSet.put(dataField, encryption); + } } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IEventService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IEventService.java index cd918dd0d33..c745df401f0 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IEventService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IEventService.java @@ -22,7 +22,7 @@ public interface IEventService { List runActions(List actions, Map params); - List processDataEvents(Field field, DataEventType actionTrigger, EventPhase phase, Case useCase, Task task, Map params); + List processDataEvents(Field field, DataEventType actionTrigger, EventPhase phase, Case useCase, Task task, Map params); List runEventActions(Case useCase, Task task, List actions, DataEventType trigger, Map params); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java index 48818587fa9..ddd02693372 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java @@ -13,6 +13,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; @@ -27,6 +28,60 @@ public interface IWorkflowService { List findAllById(List ids); + /** + * Loads a PARTIAL, READ-ONLY case containing all static fields and only the requested {@code dataSet} entries + * (plus automatically required ones: immediate data fields and fields referenced by arcs). + * The result must never be saved; {@link #save(Case)} throws {@link IllegalStateException} for it. + * + * @param caseId composite case id + * @param dataFieldIds ids of the data fields to load + * @return the partially loaded case + */ + Case findOneProjected(String caseId, Collection dataFieldIds); + + /** + * Loads a PARTIAL, READ-ONLY case; the requested data field ids are derived from the case's PetriNet by the + * resolver (e.g. the data of a transition). Automatically required keys are added. Must never be saved with + * {@link #save(Case)}; use {@link #savePartial(Case)}. + * + * @param caseId composite case id + * @param dataFieldResolver maps the case's PetriNet to the ids of the data fields to load + * @return the partially loaded case + */ + Case findOneProjected(String caseId, java.util.function.Function> dataFieldResolver); + + /** + * Same as {@link #findOneProjected(String, Collection)} for many ids. The result is PARTIAL and READ-ONLY, preserves + * the order of the input ids and skips unknown ids. + * + * @param caseIds composite case ids + * @param dataFieldIds ids of the data fields to load + * @return partially loaded cases + */ + List findAllByIdProjected(List caseIds, Collection dataFieldIds); + + /** + * Loads a page of PARTIAL, READ-ONLY cases with only the requested {@code dataSet} entries (plus automatically + * required ones). Paging and sorting are done by Mongo. Results must never be saved with {@link #save(Case)}; + * use {@link #savePartial(Case)}. + * + * @param pageable paging and sorting + * @param dataFieldIds ids of the data fields to load + * @return page of partially loaded cases + */ + Page getAllProjected(Pageable pageable, Collection dataFieldIds); + + /** + * Persists a PARTIALLY LOADED case with Mongo {@code $set} operations: only static fields and the loaded + * {@code dataSet} entries are written, unloaded entries stay untouched. The case is re-indexed from a fresh + * full read so that Elasticsearch never receives a partial document. + * + * @param partialCase a case obtained from a projected read + * @return the same (partial) instance + * @throws IllegalArgumentException if the case is not partially loaded or does not exist anymore + */ + Case savePartial(Case partialCase); + Page getAll(Pageable pageable); Case resolveActorRef(Case useCase, boolean canSaveUseCase); @@ -51,6 +106,48 @@ public interface IWorkflowService { Case searchOne(Predicate predicate); + /** + * Loads a PARTIAL, READ-ONLY case matching the given predicate with only the requested {@code dataSet} entries + * (plus automatically required ones). Must never be saved with {@code save}; use {@code savePartial}. + * + * @param predicate Querydsl predicate + * @param dataFieldIds ids of the data fields to load + * @return the partially loaded case, or {@code null} if not found + */ + Case searchOneProjected(Predicate predicate, Collection dataFieldIds); + + /** + * Loads a PARTIAL, READ-ONLY case matching the given predicate; data field ids are derived from the case's + * PetriNet via resolver. Must never be saved with {@code save}; use {@code savePartial}. + * + * @param predicate Querydsl predicate + * @param dataFieldResolver maps the case's PetriNet to the ids of the data fields to load + * @return the partially loaded case, or {@code null} if not found + */ + Case searchOneProjected(Predicate predicate, java.util.function.Function> dataFieldResolver); + + /** + * Loads all PARTIAL, READ-ONLY cases matching the given predicate as a full page. Must never be saved with + * {@code save}; use {@code savePartial}. + * + * @param predicate Querydsl predicate + * @param dataFieldIds ids of the data fields to load + * @return full page of partially loaded cases + */ + Page searchAllProjected(Predicate predicate, Collection dataFieldIds); + + /** + * Loads a page of PARTIAL, READ-ONLY cases matching the given predicate with only the requested {@code dataSet} + * entries (plus automatically required ones). Paging and sorting are applied by Mongo. Must never be saved with + * {@code save}; use {@code savePartial}. + * + * @param predicate Querydsl predicate + * @param pageable paging and sorting + * @param dataFieldIds ids of the data fields to load + * @return page of partially loaded cases + */ + Page searchProjected(Predicate predicate, Pageable pageable, Collection dataFieldIds); + Map listToMap(List cases); Page search(Map request, Pageable pageable, LoggedUser user, Locale locale); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/WorkflowController.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/WorkflowController.java index 9d4615b06e4..d37d141e28f 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/WorkflowController.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/WorkflowController.java @@ -49,9 +49,9 @@ import org.springframework.web.bind.annotation.*; import java.io.FileNotFoundException; -import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -103,10 +103,10 @@ public EntityModel createCase(@RequestBody CreateCaseBo @Operation(summary = "Get all cases of the system, paginated", security = {@SecurityRequirement(name = "BasicAuth")}) @GetMapping(value = "/all", produces = MediaTypes.HAL_JSON_VALUE) - public PagedModel getAll(Pageable pageable, PagedResourcesAssembler assembler) { - Page cases = workflowService.getAll(pageable); + public PagedModel getAll(Pageable pageable, PagedResourcesAssembler assembler, @RequestParam(required = false) List fields) { + Page cases = fields == null ? workflowService.getAll(pageable) : workflowService.getAllProjected(pageable, fields); Link selfLink = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(WorkflowController.class) - .getAll(pageable, assembler)).withRel("all"); + .getAll(pageable, assembler, fields)).withRel("all"); PagedModel resources = assembler.toModel(cases, new CaseResourceAssembler(), selfLink); ResourceLinkAssembler.addLinks(resources, Case.class, selfLink.getRel().toString()); return resources; @@ -125,15 +125,15 @@ public PagedModel search2(@QuerydslPredicate(root = Case.class) Pr @Operation(summary = "Generic case search on Elasticsearch database, paginated", security = {@SecurityRequirement(name = "BasicAuth")}) @PostMapping(value = "/case/search", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaTypes.HAL_JSON_VALUE) - public PagedModel search(@RequestBody SingleCaseSearchRequestAsList searchBody, @RequestParam(defaultValue = "OR") MergeFilterOperation operation, Pageable pageable, PagedResourcesAssembler assembler, Authentication auth, Locale locale) { + public PagedModel search(@RequestBody SingleCaseSearchRequestAsList searchBody, @RequestParam(defaultValue = "OR") MergeFilterOperation operation, Pageable pageable, PagedResourcesAssembler assembler, Authentication auth, Locale locale, @RequestParam(required = false) List fields) { LoggedUser user = (LoggedUser) auth.getPrincipal(); - Page cases = elasticCaseService.search(searchBody.getList(), user, pageable, locale, operation == MergeFilterOperation.AND); + Page cases = elasticCaseService.search(searchBody.getList(), user, pageable, locale, operation == MergeFilterOperation.AND, fields); if (log.isTraceEnabled()) { log.trace("Found {} cases with id {}.", cases.getNumberOfElements(), cases.map(Case::getStringId)); } Link selfLink = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(WorkflowController.class) - .search(searchBody, operation, pageable, assembler, auth, locale)).withRel("search"); + .search(searchBody, operation, pageable, assembler, auth, locale, fields)).withRel("search"); PagedModel resources = assembler.toModel(cases, new CaseResourceAssembler(), selfLink); @@ -170,8 +170,8 @@ public CountResponse count(@RequestBody SingleCaseSearchRequestAsList searchBody @Operation(summary = "Get case by id", security = {@SecurityRequirement(name = "BasicAuth")}) @GetMapping(value = "/case/{id}", produces = MediaTypes.HAL_JSON_VALUE) - public CaseResource getOne(@PathVariable("id") String caseId) { - Case aCase = workflowService.findOne(caseId); + public CaseResource getOne(@PathVariable("id") String caseId, @RequestParam(required = false) List fields) { + Case aCase = fields == null ? workflowService.findOne(caseId) : workflowService.findOneProjected(caseId, fields); if (aCase == null) return null; return new CaseResource(aCase); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/responsebodies/ResourceLinkAssembler.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/responsebodies/ResourceLinkAssembler.java index 401b8306bbb..f25d8ad040c 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/responsebodies/ResourceLinkAssembler.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/web/responsebodies/ResourceLinkAssembler.java @@ -45,10 +45,10 @@ private static void addTasksLinks(PagedModel pagedResources, String selfRel) { private static void addCasesLinks(PagedModel pagedResources, String selfRel) { if (!selfRel.equalsIgnoreCase("all")) pagedResources.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(WorkflowController.class) - .getAll(null, null)).withRel("all")); + .getAll(null, null, null)).withRel("all")); if (!selfRel.equalsIgnoreCase("search")) pagedResources.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(WorkflowController.class) - .search(null, MergeFilterOperation.OR, null, null, null, null)).withRel("search")); + .search(null, MergeFilterOperation.OR, null, null, null, null, null)).withRel("search")); if (!selfRel.equalsIgnoreCase("count")) pagedResources.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(WorkflowController.class) .count(null, MergeFilterOperation.OR, null, null)).withRel("count")); diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImplTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImplTest.java new file mode 100644 index 00000000000..e16ee85fdae --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionRepositoryImplTest.java @@ -0,0 +1,136 @@ +package com.netgrif.application.engine.workflow.domain.repositories; + +import com.netgrif.application.engine.TestHelper; +import com.netgrif.application.engine.adapter.spring.workflow.domain.QCase; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.startup.ImportHelper; +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner; +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; +import com.netgrif.application.engine.workflow.params.CreateCaseParams; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles({"test"}) +@ExtendWith(SpringExtension.class) +class CaseProjectionRepositoryImplTest { + + @Autowired + private TestHelper testHelper; + + @Autowired + private ImportHelper importHelper; + + @Autowired + private IWorkflowService workflowService; + + @Autowired + private CaseRepository repository; + + @Autowired + private SuperCreatorRunner superCreator; + + private PetriNet net; + + @BeforeEach + void before() { + testHelper.truncateDbs(); + net = importHelper.createNet("all_data.xml").orElseThrow(); + } + + private Case createCase(String title) { + return workflowService.createCase(CreateCaseParams.with() + .process(net) + .title(title) + .color("blue") + .author(superCreator.getLoggedSuper()) + .build()).getCase(); + } + + @Test + void staticFieldsAlwaysPresentAndOnlyRequestedKeysLoaded() { + Case created = createCase("projection"); + Optional found = repository.findProjectedByObjectId(created.get_id().getObjectId(), + CaseProjection.ofDataFields(List.of("text", "number"))); + assertTrue(found.isPresent()); + Case c = found.get(); + assertTrue(c.isPartiallyLoaded()); + assertEquals(created.getStringId(), c.getStringId()); + assertEquals(created.getTitle(), c.getTitle()); + assertEquals(created.getPetriNetObjectId(), c.getPetriNetObjectId()); + assertEquals(created.getImmediateDataFields(), c.getImmediateDataFields()); + assertEquals(created.getTasks().size(), c.getTasks().size()); + assertEquals(created.getTasks().stream().map(t -> t.getTask()).collect(java.util.stream.Collectors.toSet()), + c.getTasks().stream().map(t -> t.getTask()).collect(java.util.stream.Collectors.toSet())); + assertEquals(created.getActivePlaces(), c.getActivePlaces()); + assertEquals(Set.of("text", "number"), c.getDataSet().keySet()); + } + + @Test + void emptyKeySetLoadsNoDataSetEntries() { + Case created = createCase("empty"); + Case c = repository.findProjectedByObjectId(created.get_id().getObjectId(), CaseProjection.ofDataFields(List.of())).orElseThrow(); + assertTrue(c.getDataSet().isEmpty()); + assertEquals("empty", c.getTitle()); + } + + @Test + void invalidKeysAreRejected() { + ObjectId id = createCase("invalid").get_id().getObjectId(); + for (String bad : new String[]{"$where", "a.b", " ", "", "a\u0000b"}) { + CaseProjection projection = new CaseProjection(Set.of(bad)); + assertThrows(IllegalArgumentException.class, () -> repository.findProjectedByObjectId(id, projection), bad); + assertThrows(IllegalArgumentException.class, () -> repository.findAllProjectedByObjectIds(List.of(id), projection), bad); + } + } + + @Test + void findAllProjectedReturnsRightDocumentsAndEmptyInputReturnsEmpty() { + Case a = createCase("a"); + Case b = createCase("b"); + createCase("c"); + List result = repository.findAllProjectedByObjectIds( + List.of(a.get_id().getObjectId(), b.get_id().getObjectId()), CaseProjection.ofDataFields(List.of("text"))); + assertEquals(2, result.size()); + assertEquals(Set.of(a.getStringId(), b.getStringId()), Set.of(result.get(0).getStringId(), result.get(1).getStringId())); + result.forEach(c -> assertEquals(Set.of("text"), c.getDataSet().keySet())); + assertTrue(repository.findAllProjectedByObjectIds(List.of(), CaseProjection.ofDataFields(List.of("text"))).isEmpty()); + } + + @Test + void predicateProjectedQueriesFindDocuments() { + Case created = createCase("predicate-test"); + Optional one = repository.findOneProjected(QCase.case$.title.eq("predicate-test"), + CaseProjection.ofDataFields(List.of("text"))); + assertTrue(one.isPresent()); + assertTrue(one.get().isPartiallyLoaded()); + assertEquals("predicate-test", one.get().getTitle()); + assertEquals(Set.of("text"), one.get().getDataSet().keySet()); + + List list = repository.findAllProjected(QCase.case$.title.eq("predicate-test"), + CaseProjection.ofDataFields(List.of("text"))); + assertEquals(1, list.size()); + assertTrue(list.get(0).isPartiallyLoaded()); + + var page = repository.findAllProjected(QCase.case$.title.eq("predicate-test"), + org.springframework.data.domain.PageRequest.of(0, 10), + CaseProjection.ofDataFields(List.of("text"))); + assertEquals(1, page.getTotalElements()); + assertEquals(1, page.getContent().size()); + assertTrue(page.getContent().get(0).isPartiallyLoaded()); + } +} diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionTest.java new file mode 100644 index 00000000000..ba70778e5b9 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/domain/repositories/CaseProjectionTest.java @@ -0,0 +1,42 @@ +package com.netgrif.application.engine.workflow.domain.repositories; + +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class CaseProjectionTest { + + @Test + void ofDataFieldsDropsNullsBlanksAndDuplicates() { + CaseProjection projection = CaseProjection.ofDataFields(Arrays.asList(" a ", null, "", " ", "a", "b")); + assertEquals(Set.of("a", "b"), projection.dataSetKeys()); + } + + @Test + void nullInputIsNullSafe() { + assertTrue(CaseProjection.ofDataFields(null).dataSetKeys().isEmpty()); + assertTrue(new CaseProjection(null).dataSetKeys().isEmpty()); + } + + @Test + void keysAreImmutableAndDefensivelyCopied() { + List source = new java.util.ArrayList<>(List.of("a")); + CaseProjection projection = CaseProjection.ofDataFields(source); + source.add("b"); + assertEquals(Set.of("a"), projection.dataSetKeys()); + assertThrows(UnsupportedOperationException.class, () -> projection.dataSetKeys().add("c")); + } + + @Test + void withAdditionalKeysReturnsNewMergedProjection() { + CaseProjection base = CaseProjection.ofDataFields(List.of("a")); + CaseProjection merged = base.withAdditionalKeys(Arrays.asList("b", null, " a ")); + assertEquals(Set.of("a"), base.dataSetKeys()); + assertEquals(Set.of("a", "b"), merged.dataSetKeys()); + } +} diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java new file mode 100644 index 00000000000..7d01b4e10f5 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java @@ -0,0 +1,313 @@ +package com.netgrif.application.engine.workflow.service; + +import com.netgrif.application.engine.TestHelper; +import com.netgrif.application.engine.adapter.spring.workflow.domain.QCase; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.petrinet.domain.dataset.Field; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.objects.workflow.domain.Task; +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.dataoutcomes.GetDataEventOutcome; +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.dataoutcomes.GetDataGroupsEventOutcome; +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.dataoutcomes.SetDataEventOutcome; +import com.netgrif.application.engine.startup.ImportHelper; +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner; +import com.netgrif.application.engine.workflow.domain.repositories.projection.CaseProjection; +import com.netgrif.application.engine.workflow.params.CreateCaseParams; +import com.netgrif.application.engine.workflow.service.interfaces.IDataService; +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import com.netgrif.application.engine.workflow.web.TaskController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.hateoas.EntityModel; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles({"test"}) +@ExtendWith(SpringExtension.class) +public class ProjectionTestNetIntegrationTest { + + @Autowired + private TestHelper testHelper; + + @Autowired + private ImportHelper importHelper; + + @Autowired + private IWorkflowService workflowService; + + @Autowired + private IDataService dataService; + + @Autowired + private ITaskService taskService; + + @Autowired + private TaskController taskController; + + @Autowired + private SuperCreatorRunner superCreator; + + @Autowired + private MongoTemplate mongoTemplate; + + private PetriNet net; + + @BeforeEach + void setUp() { + testHelper.truncateDbs(); + net = importHelper.createNet("projection_test_net.xml").orElseThrow( + () -> new IllegalStateException("Failed to import projection_test_net.xml") + ); + } + + private Case createTestCase(String title) { + return workflowService.createCase(CreateCaseParams.with() + .process(net) + .title(title) + .color("indigo") + .author(superCreator.getLoggedSuper()) + .build()).getCase(); + } + + @Test + @DisplayName("PetriNet imports successfully with all data types and transitions") + void testPetriNetImportAndFullCase() { + assertNotNull(net); + assertEquals("projection_test_net", net.getIdentifier()); + + // Verify dataset covers all expected field types + Set fieldKeys = net.getDataSet().keySet(); + assertTrue(fieldKeys.containsAll(List.of( + "txt_simple", "txt_area", "txt_password", + "num_standard", "num_currency", "num_slider", + "bool_flag", "bool_opt_in", + "dt_birth", "dtt_scheduled", + "enum_dropdown", "enum_autocomplete", "enum_map_status", + "multi_tags", "multi_map_roles", + "file_document", "file_gallery", + "actor_assignee", "actor_reviewers", + "ref_related_cases", "ref_embedded_task", "filter_query", + "btn_action", "i18n_notice", "i18n_divider", "str_list_keywords" + ))); + + // Full load returns entire dataset + Case created = createTestCase("Full Case Test"); + Case full = workflowService.findOne(created.getStringId()); + assertFalse(full.isPartiallyLoaded()); + assertTrue(full.getDataSet().keySet().containsAll(fieldKeys)); + } + + @Test + @DisplayName("findOneProjected loads only transition subsets and required immediate fields") + void testFindOneProjectedSubsets() { + Case created = createTestCase("Projection Subset Test"); + + // 1. Transition 1: Basic Form Fields + Collection t1Fields = List.of("txt_simple", "txt_area", "num_standard", "num_currency", "bool_flag", "dt_birth"); + Case partialT1 = workflowService.findOneProjected(created.getStringId(), t1Fields); + + assertTrue(partialT1.isPartiallyLoaded()); + // Requested fields plus any immediate fields must be present + assertTrue(partialT1.getDataSet().keySet().containsAll(t1Fields)); + // Non-immediate fields from other transitions must not be loaded + assertFalse(partialT1.getDataSet().containsKey("txt_password")); + assertFalse(partialT1.getDataSet().containsKey("num_slider")); + assertFalse(partialT1.getDataSet().containsKey("bool_opt_in")); + assertFalse(partialT1.getDataSet().containsKey("ref_related_cases")); + assertFalse(partialT1.getDataSet().containsKey("filter_query")); + + // 2. Transition 2: Choices and Maps + Collection t2Fields = List.of("enum_dropdown", "enum_autocomplete", "enum_map_status", "multi_tags", "multi_map_roles"); + Case partialT2 = workflowService.findOneProjected(created.getStringId(), t2Fields); + + assertTrue(partialT2.isPartiallyLoaded()); + assertTrue(partialT2.getDataSet().keySet().containsAll(t2Fields)); + assertFalse(partialT2.getDataSet().containsKey("num_slider")); + assertFalse(partialT2.getDataSet().containsKey("ref_related_cases")); + } + + @Test + @DisplayName("DataService.getData loads projected case matching transition dataset") + void testDataServiceGetDataWithProjection() { + Case created = createTestCase("DataService Projection Test"); + + // Locate task for t1_basic + Task t1Task = created.getTasks().stream() + .map(ref -> taskService.findOne(ref.getTask())) + .filter(t -> "t1_basic".equals(t.getTransitionId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Task t1_basic not found")); + + GetDataEventOutcome outcome = dataService.getData(t1Task.getStringId()); + assertNotNull(outcome); + assertTrue(outcome.getCase().isPartiallyLoaded()); + + List> dataFields = outcome.getData(); + assertNotNull(dataFields); + assertFalse(dataFields.isEmpty()); + + Set outcomeFieldIds = dataFields.stream().map(Field::getStringId).collect(java.util.stream.Collectors.toSet()); + assertTrue(outcomeFieldIds.containsAll(List.of("txt_simple", "txt_area", "num_standard", "num_currency", "bool_flag", "dt_birth"))); + assertFalse(outcomeFieldIds.contains("enum_dropdown")); + assertFalse(outcomeFieldIds.contains("file_document")); + } + + @Test + @DisplayName("DataService.getDataGroups resolves data groups and fields on projected case") + void testDataServiceGetDataGroupsWithProjection() { + Case created = createTestCase("DataGroups Projection Test"); + + Task t1Task = created.getTasks().stream() + .map(ref -> taskService.findOne(ref.getTask())) + .filter(t -> "t1_basic".equals(t.getTransitionId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Task t1_basic not found")); + + GetDataGroupsEventOutcome outcome = dataService.getDataGroups(t1Task.getStringId(), Locale.ENGLISH); + assertNotNull(outcome); + assertTrue(outcome.getCase().isPartiallyLoaded()); + assertNotNull(outcome.getData()); + assertFalse(outcome.getData().isEmpty()); + } + + @Test + @DisplayName("TaskController.getData returns projected outcome successfully") + void testTaskControllerGetData() { + Case created = createTestCase("TaskController Projection Test"); + + Task t1Task = created.getTasks().stream() + .map(ref -> taskService.findOne(ref.getTask())) + .filter(t -> "t1_basic".equals(t.getTransitionId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Task t1_basic not found")); + + var entityModel = taskController.getData(t1Task.getStringId(), Locale.ENGLISH); + assertNotNull(entityModel); + assertNotNull(entityModel.getContent()); + assertNull(entityModel.getContent().getError()); + assertNotNull(entityModel.getContent().getOutcome()); + } + + @Test + @DisplayName("savePartial updates loaded dataSet keys without deleting unloaded fields in Mongo") + void testSavePartialPreservesUnloadedKeys() { + Case created = createTestCase("Partial Save Test"); + + // Load partially with only txt_simple and num_standard + Case partial = workflowService.findOneProjected(created.getStringId(), List.of("txt_simple", "num_standard")); + assertTrue(partial.isPartiallyLoaded()); + + // Update txt_simple and num_standard + partial.getDataSet().get("txt_simple").setValue("Modified text in partial save"); + partial.getDataSet().get("num_standard").setValue(999.0); + + // Plain save throws guard exception + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> workflowService.save(partial)); + assertTrue(ex.getMessage().contains("savePartial")); + + // Save via savePartial + Case saved = workflowService.savePartial(partial); + assertNotNull(saved); + + // Reload full case from MongoDB to verify that unloaded fields remain intact + Case fullAfterUpdate = workflowService.findOne(created.getStringId()); + assertFalse(fullAfterUpdate.isPartiallyLoaded()); + + // Verify updated values + assertEquals("Modified text in partial save", fullAfterUpdate.getDataSet().get("txt_simple").getValue()); + assertEquals(999.0, fullAfterUpdate.getDataSet().get("num_standard").getValue()); + + // Verify that fields not present in partial load are still intact with their initial values + assertNotNull(fullAfterUpdate.getDataSet().get("enum_dropdown")); + assertEquals("MEDIUM", fullAfterUpdate.getDataSet().get("enum_dropdown").getValue().toString()); + assertNotNull(fullAfterUpdate.getDataSet().get("bool_flag")); + assertEquals(true, fullAfterUpdate.getDataSet().get("bool_flag").getValue()); + } + + @Test + @DisplayName("DataService.setData updates fields and preserves unloaded dataset keys in Mongo") + void testDataServiceSetDataWithProjection() { + Case created = createTestCase("SetData Projection Test"); + + Task t1Task = created.getTasks().stream() + .map(ref -> taskService.findOne(ref.getTask())) + .filter(t -> "t1_basic".equals(t.getTransitionId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Task t1_basic not found")); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode values = mapper.createObjectNode(); + ObjectNode txtNode = values.putObject("txt_simple"); + txtNode.put("type", "text"); + txtNode.put("value", "Updated via SetData"); + + ObjectNode numNode = values.putObject("num_standard"); + numNode.put("type", "number"); + numNode.put("value", 123.45); + + SetDataEventOutcome outcome = dataService.setData(t1Task.getStringId(), values); + assertNotNull(outcome); + assertNotNull(outcome.getCase()); + + // Full case reload from Mongo to verify that modified fields updated and untouched fields remain intact + Case full = workflowService.findOne(created.getStringId()); + assertEquals("Updated via SetData", full.getDataSet().get("txt_simple").getValue()); + assertEquals(123.45, full.getDataSet().get("num_standard").getValue()); + assertEquals("MEDIUM", full.getDataSet().get("enum_dropdown").getValue().toString()); + } + + @Test + @DisplayName("Querydsl searchProjected retrieves matching cases with projected subsets") + void testQuerydslSearchProjected() { + Case created = createTestCase("Search Predicate Test Net"); + + // searchOneProjected with explicit keys + Case found = workflowService.searchOneProjected( + QCase.case$.title.eq("Search Predicate Test Net"), + List.of("enum_dropdown", "multi_tags") + ); + assertNotNull(found); + assertTrue(found.isPartiallyLoaded()); + assertEquals("Search Predicate Test Net", found.getTitle()); + assertTrue(found.getDataSet().containsKey("enum_dropdown")); + assertTrue(found.getDataSet().containsKey("multi_tags")); + assertFalse(found.getDataSet().containsKey("txt_password")); + + // searchProjected with Pageable + Page paged = workflowService.searchProjected( + QCase.case$.title.eq("Search Predicate Test Net"), + PageRequest.of(0, 10), + List.of("txt_simple") + ); + assertEquals(1, paged.getTotalElements()); + assertTrue(paged.getContent().get(0).isPartiallyLoaded()); + assertTrue(paged.getContent().get(0).getDataSet().containsKey("txt_simple")); + assertFalse(paged.getContent().get(0).getDataSet().containsKey("num_slider")); + + // searchAllProjected + Page allProjected = workflowService.searchAllProjected( + QCase.case$.title.eq("Search Predicate Test Net"), + List.of("txt_simple") + ); + assertEquals(1, allProjected.getTotalElements()); + assertTrue(allProjected.getContent().get(0).isPartiallyLoaded()); + } +} diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java new file mode 100644 index 00000000000..6cab37d36d7 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java @@ -0,0 +1,199 @@ +package com.netgrif.application.engine.workflow.service; + +import com.netgrif.application.engine.TestHelper; +import com.netgrif.application.engine.adapter.spring.workflow.domain.QCase; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.startup.ImportHelper; +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner; +import com.netgrif.application.engine.workflow.params.CreateCaseParams; +import com.netgrif.application.engine.workflow.service.interfaces.IDataService; +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; +import com.netgrif.application.engine.workflow.web.TaskController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles({"test"}) +@ExtendWith(SpringExtension.class) +class WorkflowServiceProjectionTest { + + @Autowired + private TestHelper testHelper; + + @Autowired + private ImportHelper importHelper; + + @Autowired + private IWorkflowService workflowService; + + @Autowired + private IDataService dataService; + + @Autowired + private TaskController taskController; + + @Autowired + private SuperCreatorRunner superCreator; + + private PetriNet net; + + @BeforeEach + void before() { + testHelper.truncateDbs(); + net = importHelper.createNet("all_data.xml").orElseThrow(); + } + + private Case createCase(String title) { + return workflowService.createCase(CreateCaseParams.with() + .process(net) + .title(title) + .color("blue") + .author(superCreator.getLoggedSuper()) + .build()).getCase(); + } + + @Test + void findOneProjectedAddsRequiredKeysAndSkipsOthers() { + Case created = createCase("p"); + Case partial = workflowService.findOneProjected(created.getStringId(), List.of()); + assertTrue(partial.isPartiallyLoaded()); + assertEquals(created.getImmediateDataFields(), partial.getDataSet().keySet()); + assertFalse(partial.getDataSet().containsKey("userList1")); + assertFalse(partial.getImmediateData().isEmpty()); + + Case withExtra = workflowService.findOneProjected(created.getStringId(), List.of("userList1", "unknown_field")); + assertTrue(withExtra.getDataSet().containsKey("userList1")); + assertFalse(withExtra.getDataSet().containsKey("unknown_field")); + } + + @Test + void findOneProjectedUnknownIdThrows() { + Case created = createCase("p"); + String missing = created.getStringId().replace(created.get_id().getObjectId().toString(), new org.bson.types.ObjectId().toString()); + assertThrows(IllegalArgumentException.class, () -> workflowService.findOneProjected(missing, List.of())); + } + + @Test + void findAllByIdProjectedPreservesOrderAndSkipsUnknown() { + Case a = createCase("a"); + Case b = createCase("b"); + Case c = createCase("c"); + String missing = a.getStringId().replace(a.get_id().getObjectId().toString(), new org.bson.types.ObjectId().toString()); + List ids = List.of(c.getStringId(), missing, a.getStringId(), b.getStringId()); + List result = workflowService.findAllByIdProjected(ids, List.of("userList1")); + assertEquals(List.of(c.getStringId(), a.getStringId(), b.getStringId()), result.stream().map(Case::getStringId).toList()); + result.forEach(r -> assertTrue(r.getDataSet().containsKey("userList1"))); + } + + @Test + void savingPartialCaseThrows() { + Case created = createCase("p"); + Case partial = workflowService.findOneProjected(created.getStringId(), List.of()); + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> workflowService.save(partial)); + assertTrue(ex.getMessage().contains("must not be saved")); + } + + @Test + void findOneStillReturnsCompleteDataSet() { + Case created = createCase("p"); + Case full = workflowService.findOne(created.getStringId()); + assertFalse(full.isPartiallyLoaded()); + assertTrue(full.getDataSet().keySet().containsAll(net.getDataSet().keySet())); + } + + @Test + void savePartialUpdatesOnlyLoadedKeysAndKeepsOthers() { + Case created = createCase("p"); + Case partial = workflowService.findOneProjected(created.getStringId(), List.of("text")); + partial.getDataSet().get("text").setValue("changed"); + partial.setTitle("new title"); + workflowService.savePartial(partial); + + Case full = workflowService.findOne(created.getStringId()); + assertEquals("changed", full.getDataSet().get("text").getValue()); + assertEquals("new title", full.getTitle()); + assertTrue(full.getDataSet().keySet().containsAll(net.getDataSet().keySet())); + assertThrows(IllegalArgumentException.class, () -> workflowService.savePartial(full)); + } + + @Test + void getAllProjectedPagesAndProjects() { + long before = workflowService.getAll(org.springframework.data.domain.PageRequest.of(0, 1)).getTotalElements(); + createCase("a"); + createCase("b"); + createCase("c"); + var page = workflowService.getAllProjected(org.springframework.data.domain.PageRequest.of(0, 2), List.of("userList1")); + assertEquals(before + 3, page.getTotalElements()); + assertEquals(2, page.getContent().size()); + page.getContent().forEach(c -> { + assertTrue(c.isPartiallyLoaded()); + if (c.getProcessIdentifier().equals(net.getIdentifier())) { + assertTrue(c.getDataSet().containsKey("userList1")); + } + }); + } + + @Test + void findOneProjectedWithResolverUsesNet() { + Case created = createCase("p"); + Case partial = workflowService.findOneProjected(created.getStringId(), n -> List.of("userList1")); + assertTrue(partial.getDataSet().containsKey("userList1")); + assertTrue(partial.isPartiallyLoaded()); + } + + @Test + void getDataGroupsUsesProjectedCase() { + Case created = createCase("p"); + String taskId = created.getTasks().iterator().next().getTask(); + var outcome = dataService.getDataGroups(taskId, java.util.Locale.ENGLISH); + assertNotNull(outcome); + assertTrue(outcome.getCase().isPartiallyLoaded()); + assertNotNull(outcome.getData()); + assertFalse(outcome.getData().isEmpty()); + } + + @Test + void taskControllerGetDataWorks() { + Case created = createCase("p"); + String taskId = created.getTasks().iterator().next().getTask(); + var entityModel = taskController.getData(taskId, java.util.Locale.ENGLISH); + assertNotNull(entityModel); + assertNotNull(entityModel.getContent()); + assertNull(entityModel.getContent().getError()); + assertNotNull(entityModel.getContent().getOutcome()); + } + + @Test + void searchProjectedMethodsReturnProjectedCases() { + Case created = createCase("search-proj"); + Case foundOne = workflowService.searchOneProjected(QCase.case$.title.eq("search-proj"), List.of("text")); + assertNotNull(foundOne); + assertTrue(foundOne.isPartiallyLoaded()); + assertEquals("search-proj", foundOne.getTitle()); + assertTrue(foundOne.getDataSet().containsKey("text")); + + Case foundOneWithResolver = workflowService.searchOneProjected(QCase.case$.title.eq("search-proj"), n -> List.of("number")); + assertNotNull(foundOneWithResolver); + assertTrue(foundOneWithResolver.isPartiallyLoaded()); + assertTrue(foundOneWithResolver.getDataSet().containsKey("number")); + + var allProjected = workflowService.searchAllProjected(QCase.case$.title.eq("search-proj"), List.of("text")); + assertEquals(1, allProjected.getTotalElements()); + assertTrue(allProjected.getContent().get(0).isPartiallyLoaded()); + + var pagedProjected = workflowService.searchProjected(QCase.case$.title.eq("search-proj"), + org.springframework.data.domain.PageRequest.of(0, 10), List.of("text")); + assertEquals(1, pagedProjected.getTotalElements()); + assertTrue(pagedProjected.getContent().get(0).isPartiallyLoaded()); + } +} diff --git a/application-engine/src/test/resources/petriNets/projection_test_net.xml b/application-engine/src/test/resources/petriNets/projection_test_net.xml new file mode 100644 index 00000000000..d25b57e9bcc --- /dev/null +++ b/application-engine/src/test/resources/petriNets/projection_test_net.xml @@ -0,0 +1,614 @@ + + + projection_test_net + 1.0.0 + Projection and Partial Hydration Test Net + PTN + true + + + + tester + Tester + + + reviewer + Reviewer + + + + + + txt_simple + Simple Text + Initial text value + + + txt_area + Text Area + Multi-line content description + + textarea + 4 + + + + txt_password + Password Field + + password + + + + + + num_standard + Standard Number + 42 + + + num_currency + Currency Number + 1250.50 + + + EUR + 2 + sk_SK + + + + + num_slider + Slider Percentage + 75 + + slider + 0 + 100 + 5 + + + + + + bool_flag + Active Flag + true + + + bool_opt_in + Opt-in Confirmation + false + + + + + dt_birth + Birth Date + 01.01.2000 + + + dtt_scheduled + Scheduled Timestamp + 21.10.2026 14:30 + + + + + enum_dropdown + Priority Dropdown + LOW + MEDIUM + HIGH + MEDIUM + + + enum_autocomplete + Country Autocomplete + Slovakia + Czech Republic + Germany + Austria + Slovakia + + autocomplete + + + + enum_map_status + Status Map + + + + + + NEW + + + + + multi_tags + Tags Selection + BUG + FEATURE + REFACTOR + + FEATURE + REFACTOR + + + + multi_map_roles + Assigned Capabilities + + + + + + + READ + WRITE + + + + + + file_document + Main Attachment + + + file_gallery + Supporting Files List + + + + + actor_assignee + Task Assignee + + + actor_reviewers + Reviewer Team + + + + + ref_related_cases + Related Cases + + + ref_embedded_task + Embedded Task Reference + + t2_choices + + + + filter_query + Custom Case Filter + + + + + btn_action + Trigger Action + Execute + + fab + + + + i18n_notice + Localized Notice + Default notice text + + + i18n_divider + Section Separator + Configuration Section + + divider + + + + str_list_keywords + Keyword List + + + + + Lokalizované slovenské oznámenie + Sekcia konfigurácie + + + + + p_start + 100 + 150 + + 1 + false + + + p_mid + 400 + 150 + + 0 + false + + + p_end + 700 + 150 + + 0 + false + + + + + + + t1_basic + 250 + 100 + 2 + + auto + + tester + + true + + + + dg_text_number + Text and Numbers + + txt_simple + + editable + required + + + + txt_area + + editable + + + + num_standard + + editable + + + + num_currency + + editable + + + + bool_flag + + editable + + + + dt_birth + + editable + + + + + + + + t2_choices + 250 + 200 + 2 + + auto + + tester + + true + + + + dg_choices + Selection Fields + + enum_dropdown + + editable + + + + enum_autocomplete + + editable + + + + enum_map_status + + editable + + + + multi_tags + + editable + + + + multi_map_roles + + editable + + + + + + + + t3_files_media + 550 + 100 + 1 + + auto + + tester + + true + + + + dg_files + Files + + file_document + + editable + + + + file_gallery + + editable + + + + + + + + t4_actors_references + 550 + 200 + 2 + + auto + + tester + + true + + + + dg_actors_refs + Assignments and Links + + actor_assignee + + editable + + + + actor_reviewers + + editable + + + + ref_related_cases + + editable + + + + ref_embedded_task + + visible + + + + filter_query + + editable + + + + + + + + t5_interactive_i18n + 400 + 250 + 2 + + auto + + tester + + true + + + + dg_interactive + Controls and Localization + + i18n_divider + + visible + + + + i18n_notice + + visible + + + + num_slider + + editable + + + + str_list_keywords + + editable + + + + btn_action + + editable + + + + + + + + t6_review_all + 700 + 250 + 2 + + auto + + reviewer + + true + + + + dg_summary + Summary Overview + + txt_simple + + visible + + + + num_currency + + visible + + + + enum_map_status + + visible + + + + actor_assignee + + visible + + + + + + + + a1 + regular + p_start + t1_basic + 1 + + + a2 + regular + p_start + t2_choices + 1 + + + a3 + regular + t1_basic + p_mid + 1 + + + a4 + regular + t2_choices + p_mid + 1 + + + a5 + regular + p_mid + t3_files_media + 1 + + + a6 + regular + p_mid + t4_actors_references + 1 + + + a7 + regular + p_mid + t5_interactive_i18n + 1 + + + a8 + regular + t3_files_media + p_end + 1 + + + a9 + regular + t4_actors_references + p_end + 1 + + + a10 + regular + t5_interactive_i18n + p_end + 1 + + + a11 + read + p_end + t6_review_all + 1 + + diff --git a/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/Case.java b/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/Case.java index 18864d0ed6f..d146f12c32b 100644 --- a/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/Case.java +++ b/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/Case.java @@ -127,6 +127,14 @@ public abstract class Case implements Serializable { @Setter private Map tags; + /** + * Transient marker. {@code true} when this case was loaded with only a subset of its {@code dataSet} entries. + * Such a case is read-only and must never be saved. + */ + @Setter + @JsonIgnore + private boolean partiallyLoaded = false; + protected Case() { activePlaces = new HashMap<>(); dataSet = new LinkedHashMap<>(); diff --git a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/workflow/domain/Case.java b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/workflow/domain/Case.java index f96d9467880..f84c6322a18 100644 --- a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/workflow/domain/Case.java +++ b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/workflow/domain/Case.java @@ -102,6 +102,13 @@ public List getViewActors() { return super.getViewActors(); } + @Override + @Transient + @JsonIgnore + public boolean isPartiallyLoaded() { + return super.isPartiallyLoaded(); + } + @Override public ActorRef getAuthor() { return super.getAuthor(); From 22632a85c9b8153a6f0f90d356f90f094bde071a Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Mon, 26 Oct 2026 12:38:03 +0100 Subject: [PATCH 2/9] Refactor case projection methods: enhance optionality for immediate fields and PetriNet loading, improve performance by avoiding redundant database queries, and add new documentation. --- .../engine/workflow/service/DataService.java | 4 +- .../workflow/service/WorkflowService.java | 109 ++++++++++-------- .../service/interfaces/IWorkflowService.java | 35 ++++++ 3 files changed, 98 insertions(+), 50 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java index 0be344b8725..a5b9560e38a 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java @@ -214,7 +214,9 @@ public GetDataEventOutcome getData(Task task, Case useCase, Map if (!useCase.isPartiallyLoaded()) { workflowService.save(useCase); - } else if (eventsEnabled && hasGetEvents(useCase.getPetriNet(), transition)) { //TODO AI: why this check is necessary? Wouldn't be good to just have else? In that case hasGetEvents is unnecessary function + } else if (eventsEnabled && hasGetEvents(useCase.getPetriNet(), transition)) { + // Only persist when GET event actions actually executed and could have mutated data fields. + // This prevents costly database writes and Elasticsearch re-indexing on read-only requests. workflowService.savePartial(useCase); } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java index f3df66a487f..01d05a585e4 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java @@ -202,6 +202,15 @@ public List findAllById(List ids) { .collect(Collectors.toList()); } + @Override + public Case findOneProjected(String caseId, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { + List loaded = loadProjected(List.of(caseId), net -> dataFieldIds, includeImmediateFields, setPetriNet); + if (loaded.isEmpty()) { + throw new IllegalArgumentException("Could not find Case with id [" + caseId + "]"); + } + return loaded.getFirst(); + } + /** * Loads a PARTIAL, READ-ONLY case with only the requested {@code dataSet} entries. Must never be saved. *

@@ -212,11 +221,15 @@ public List findAllById(List ids) { */ @Override public Case findOneProjected(String caseId, Collection dataFieldIds) { - List loaded = loadProjected(List.of(caseId), dataFieldIds); - if (loaded.isEmpty()) { - throw new IllegalArgumentException("Could not find Case with id [" + caseId + "]"); + return findOneProjected(caseId, dataFieldIds, true, true); + } + + @Override + public List findAllByIdProjected(List caseIds, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { + if (caseIds == null || caseIds.isEmpty()) { + return new ArrayList<>(); } - return loaded.getFirst(); + return loadProjected(caseIds, net -> dataFieldIds, includeImmediateFields, setPetriNet); } /** @@ -226,31 +239,40 @@ public Case findOneProjected(String caseId, Collection dataFieldIds) { */ @Override public List findAllByIdProjected(List caseIds, Collection dataFieldIds) { - if (caseIds == null || caseIds.isEmpty()) { - return new ArrayList<>(); - } - return loadProjected(caseIds, dataFieldIds); + return findAllByIdProjected(caseIds, dataFieldIds, true, true); } private List loadProjected(List caseIds, Collection dataFieldIds) { - return loadProjected(caseIds, net -> dataFieldIds); + return loadProjected(caseIds, net -> dataFieldIds, true, true); } private List loadProjected(List caseIds, Function> keyResolver) { + return loadProjected(caseIds, keyResolver, true, true); + } + + private List loadProjected(List caseIds, Function> keyResolver, boolean includeImmediateFields, boolean setPetriNet) { List objectIds = caseIds.stream().map(this::extractObjectId).distinct().collect(Collectors.toList()); - //TODO AI: This step is unnecessary as it loads all cases by IDS without datafields, should be replaced with a simple "count by IDS" method. - List cases = repository.findAllProjectedByObjectIds(objectIds, CaseProjection.ofDataFields(null)); + Collection initialKeys = null; + try { + initialKeys = keyResolver.apply(null); + } catch (Exception ignored) { + // keyResolver requires a non-null PetriNet instance + } + + CaseProjection initialProjection = CaseProjection.ofDataFields(initialKeys); + List cases = repository.findAllProjectedByObjectIds(objectIds, initialProjection); if (cases.isEmpty()) { return new ArrayList<>(); } Map nets = new HashMap<>(); Set required = new LinkedHashSet<>(); - CaseProjection requested = CaseProjection.ofDataFields(null); + CaseProjection requested = initialProjection; for (Case aCase : cases) { - //TODO AI: This has to be optional base on incoming request parameter to add immediate fields or not. Default is to add. - required.addAll(aCase.getImmediateDataFields()); + if (includeImmediateFields) { + required.addAll(aCase.getImmediateDataFields()); + } PetriNet net = nets.computeIfAbsent(aCase.getPetriNetObjectId(), petriNetService::get); if (net == null) { continue; @@ -265,9 +287,10 @@ private List loadProjected(List caseIds, Function caseMap = cases.stream().collect(Collectors.toMap(Case::getStringId, c -> c, (a, b) -> a)); @@ -277,10 +300,13 @@ private List loadProjected(List caseIds, Function loadProjected(List caseIds, Function> dataFieldResolver) { - List loaded = loadProjected(List.of(caseId), dataFieldResolver); + List loaded = loadProjected(List.of(caseId), dataFieldResolver, true, true); if (loaded.isEmpty()) { throw new IllegalArgumentException("Could not find Case with id [" + caseId + "]"); } return loaded.getFirst(); } + @Override + public Page getAllProjected(Pageable pageable, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { + Page staticPage = repository.findAllProjected(pageable, CaseProjection.ofDataFields(null)); + return hydrateProjectedPage(staticPage, pageable, dataFieldIds, includeImmediateFields, setPetriNet); + } + /** * Loads a PARTIAL, READ-ONLY page: first page of static fields (Mongo paging/sorting), then the same projected * hydration as {@link #findAllByIdProjected(List, Collection)}. Results must never be saved with {@code save}. */ @Override public Page getAllProjected(Pageable pageable, Collection dataFieldIds) { - //TODO AI: I find these kind of duplicated call for cases in several places. Is it possible to avoid the initial case search without data? Wouldn't be this less effective as the original non-projected single calls? - Page staticPage = repository.findAllProjected(pageable, CaseProjection.ofDataFields(null)); - //TODO AI: this is duplicated code, should be extracted to a separate method. + return getAllProjected(pageable, dataFieldIds, true, true); + } + + private Page hydrateProjectedPage(Page staticPage, Pageable pageable, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { List ids = staticPage.getContent().stream().map(Case::getStringId).collect(Collectors.toList()); - List hydrated = ids.isEmpty() ? new ArrayList<>() : loadProjected(ids, dataFieldIds); + List hydrated = ids.isEmpty() ? new ArrayList<>() : loadProjected(ids, net -> dataFieldIds, includeImmediateFields, setPetriNet); return new PageImpl<>(hydrated, pageable, staticPage.getTotalElements()); } @@ -324,7 +357,7 @@ public Case savePartial(Case partialCase) { if (!partialCase.isPartiallyLoaded()) { throw new IllegalArgumentException("Case [" + partialCase.getStringId() + "] is not partially loaded, use save"); } - //TODO AI: why is it necessary to set PetriNet on savePartial? PetriNet is transien and will not be persisted inside case + // Setting PetriNet is needed for encryption metadata lookup in encryptDataSet if (partialCase.getPetriNet() == null) { setPetriNet(partialCase); } @@ -698,9 +731,7 @@ public Page searchAllProjected(Predicate predicate, Collection dat @Override public Page searchProjected(Predicate predicate, Pageable pageable, Collection dataFieldIds) { Page staticPage = repository.findAllProjected(predicate, pageable, CaseProjection.ofDataFields(null)); - List ids = staticPage.getContent().stream().map(Case::getStringId).collect(Collectors.toList()); - List hydrated = ids.isEmpty() ? new ArrayList<>() : loadProjected(ids, dataFieldIds); - return new PageImpl<>(hydrated, pageable, staticPage.getTotalElements()); + return hydrateProjectedPage(staticPage, pageable, dataFieldIds, true, true); } @Override @@ -749,26 +780,6 @@ protected boolean resolveTaskRefs(Case useCase) { return anyDataFieldChanged.get(); } -// @Deprecated -// public List getData(String caseId) { -// ObjectId objectId = extractObjectId(caseId); -// Optional optionalUseCase = repository.findByIdObjectId(objectId); -// if (!optionalUseCase.isPresent()) -// throw new IllegalArgumentException("Could not find case with id [" + caseId + "]"); -// Case useCase = optionalUseCase.get(); -// List fields = new ArrayList<>(); -// useCase.getDataSet().forEach((id, dataField) -> { -// if (dataField.isDisplayable() || useCase.getPetriNet().isDisplayableInAnyTransition(id)) { -// Field field = fieldFactory.buildFieldWithoutValidation(useCase, id, null); -// field.setBehavior(dataField.applyOnlyVisibleBehavior()); -// fields.add(field); -// } -// }); -// -// LongStream.range(0L, fields.size()).forEach(l -> fields.get((int) l).setOrder(l)); -// return fields; -// } - private void setImmediateDataFieldsReadOnly(Case useCase) { List> immediateData = new ArrayList<>(); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java index ddd02693372..84189070179 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/interfaces/IWorkflowService.java @@ -28,6 +28,19 @@ public interface IWorkflowService { List findAllById(List ids); + /** + * Loads a PARTIAL, READ-ONLY case containing all static fields and only the requested {@code dataSet} entries + * (plus automatically required ones: immediate data fields and fields referenced by arcs). + * The result must never be saved; {@link #save(Case)} throws {@link IllegalStateException} for it. + * + * @param caseId composite case id + * @param dataFieldIds ids of the data fields to load + * @param includeImmediateFields whether to automatically resolve and load immediate data fields + * @param setPetriNet whether to resolve and initialize the case's PetriNet model + * @return the partially loaded case + */ + Case findOneProjected(String caseId, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet); + /** * Loads a PARTIAL, READ-ONLY case containing all static fields and only the requested {@code dataSet} entries * (plus automatically required ones: immediate data fields and fields referenced by arcs). @@ -50,6 +63,17 @@ public interface IWorkflowService { */ Case findOneProjected(String caseId, java.util.function.Function> dataFieldResolver); + /** + * Same as {@link #findOneProjected(String, Collection, boolean, boolean)} for many ids. + * + * @param caseIds composite case ids + * @param dataFieldIds ids of the data fields to load + * @param includeImmediateFields whether to automatically resolve and load immediate data fields + * @param setPetriNet whether to resolve and initialize the case's PetriNet model + * @return partially loaded cases + */ + List findAllByIdProjected(List caseIds, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet); + /** * Same as {@link #findOneProjected(String, Collection)} for many ids. The result is PARTIAL and READ-ONLY, preserves * the order of the input ids and skips unknown ids. @@ -60,6 +84,17 @@ public interface IWorkflowService { */ List findAllByIdProjected(List caseIds, Collection dataFieldIds); + /** + * Loads a page of PARTIAL, READ-ONLY cases with only the requested {@code dataSet} entries. + * + * @param pageable paging and sorting + * @param dataFieldIds ids of the data fields to load + * @param includeImmediateFields whether to automatically resolve and load immediate data fields + * @param setPetriNet whether to resolve and initialize the case's PetriNet model + * @return page of partially loaded cases + */ + Page getAllProjected(Pageable pageable, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet); + /** * Loads a page of PARTIAL, READ-ONLY cases with only the requested {@code dataSet} entries (plus automatically * required ones). Paging and sorting are done by Mongo. Results must never be saved with {@link #save(Case)}; From 9a0fb01f775fa2cd2b0ece7d8363a95764a12acf Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Tue, 27 Oct 2026 11:44:42 +0100 Subject: [PATCH 3/9] Add test for independent embedded task transitions and update projection test net definition --- .../ProjectionTestNetIntegrationTest.java | 45 +++++++++++++++++++ .../petriNets/projection_test_net.xml | 34 +++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java index 7d01b4e10f5..ce67e0fd476 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java @@ -310,4 +310,49 @@ void testQuerydslSearchProjected() { assertEquals(1, allProjected.getTotalElements()); assertTrue(allProjected.getContent().get(0).isPartiallyLoaded()); } + + @Test + @DisplayName("Independent embedded task transition resolves ref_embedded_task and remains valid across flow executions") + void testEmbeddedTaskRefWithIndependentTransition() throws Exception { + Case testCase = createTestCase("Embedded Task Ref Test"); + assertNotNull(testCase); + + // Check that ref_embedded_task was initialized to the task ID of t_embedded_task + Task embeddedTask = findTaskByTransition(testCase.getStringId(), "t_embedded_task"); + assertNotNull(embeddedTask, "Task for independent transition t_embedded_task should be created on case creation"); + + Case reloaded = workflowService.findOne(testCase.getStringId()); + List taskRefValues = (List) reloaded.getDataSet().get("ref_embedded_task").getValue(); + assertNotNull(taskRefValues); + assertEquals(1, taskRefValues.size()); + assertEquals(embeddedTask.getStringId(), taskRefValues.get(0)); + + // Finish t1_basic + Task t1Task = findTaskByTransition(testCase.getStringId(), "t1_basic"); + assertNotNull(t1Task); + taskService.assignTask(com.netgrif.application.engine.workflow.params.TaskParams.with().task(t1Task).user(superCreator.getLoggedSuper()).build()); + taskService.finishTask(com.netgrif.application.engine.workflow.params.TaskParams.with().task(t1Task).user(superCreator.getLoggedSuper()).build()); + + // t4_actors_references is now enabled from p_mid + Task t4Task = findTaskByTransition(testCase.getStringId(), "t4_actors_references"); + assertNotNull(t4Task, "Task t4_actors_references should be enabled after t1_basic completes"); + + // Verify that getData / getDataGroups for t4_actors_references resolves without error + var t4Outcome = taskController.getData(t4Task.getStringId(), Locale.ENGLISH); + assertNotNull(t4Outcome); + assertNull(t4Outcome.getContent().getError(), "t4_actors_references must resolve data without error"); + + var t4Groups = dataService.getDataGroups(t4Task.getStringId(), Locale.ENGLISH); + assertNotNull(t4Groups); + assertFalse(t4Groups.getData().isEmpty()); + } + + private Task findTaskByTransition(String caseId, String transitionId) { + Case c = workflowService.findOne(caseId); + return c.getTasks().stream() + .map(ref -> taskService.findOne(ref.getTask())) + .filter(t -> transitionId.equals(t.getTransitionId())) + .findFirst() + .orElse(null); + } } diff --git a/application-engine/src/test/resources/petriNets/projection_test_net.xml b/application-engine/src/test/resources/petriNets/projection_test_net.xml index d25b57e9bcc..99d4f5a8e0a 100644 --- a/application-engine/src/test/resources/petriNets/projection_test_net.xml +++ b/application-engine/src/test/resources/petriNets/projection_test_net.xml @@ -181,7 +181,7 @@ ref_embedded_task Embedded Task Reference - t2_choices + t_embedded_task @@ -533,6 +533,38 @@ + + + t_embedded_task + 100 + 300 + 2 + + auto + + tester + + true + + + + dg_embedded_task + Embedded Form Data + + txt_simple + + editable + + + + num_standard + + editable + + + + + a1 From 58be1d400b5c77eb79f5753c74d7ed862d52ebef Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Tue, 27 Oct 2026 13:34:27 +0100 Subject: [PATCH 4/9] Refactor and expand test coverage for data service, Elasticsearch operations, and `savePartial` behavior; add `update` methods to `IElasticCaseService`. --- .../service/ElasticCaseMappingService.java | 2 +- .../elastic/service/ElasticCaseService.java | 31 ++++- .../interfaces/IElasticCaseService.java | 5 + .../engine/workflow/service/DataService.java | 10 +- .../workflow/service/WorkflowService.java | 8 +- .../ElasticCaseServiceIntegrationTest.java | 83 ++++++++++++++ .../service/ElasticCaseServiceTest.java | 107 +++++++++++++++++- .../WorkflowServiceProjectionTest.java | 17 +++ 8 files changed, 250 insertions(+), 13 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java index 7f311b98382..a2614f2159a 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java @@ -38,7 +38,7 @@ protected Optional transformDataField(String fieldId, Case useCase) { Field netField = useCase.getField(fieldId); com.netgrif.application.engine.objects.workflow.domain.DataField caseField = useCase.getDataField(fieldId); - if (caseField.getValue() == null) { + if (caseField == null || caseField.getValue() == null) { return Optional.empty(); } else if (netField instanceof EnumerationMapField) { return this.transformEnumerationMapField(caseField, (EnumerationMapField) netField); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java index 65c114554db..3e0a2d06909 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java @@ -35,10 +35,13 @@ import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate; import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.client.elc.NativeQueryBuilder; +import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHitSupport; import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.SearchPage; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.query.*; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.*; @@ -128,6 +131,21 @@ public void indexNow(ElasticCase useCase) { index(useCase); } + @Async + @Override + public void update(ElasticCase useCase) { + caseElasticIndexQueueManager.push(BulkOperationWrapper.builder() + .operation(createUpdateOperation(useCase)) + .publishableEvent(new IndexCaseEvent(useCase)) + .build()); + log.debug("[{}]: Case \"{}\" queued for partial update", useCase.getId(), useCase.getTitle()); + } + + @Override + public void updateNow(ElasticCase useCase) { + update(useCase); + } + @Override public Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection) { return search(requests, user, pageable, locale, isIntersection, null); @@ -197,7 +215,8 @@ protected NativeQuery buildQuery(List requests, LoggedUser us NativeQueryBuilder builder = new NativeQueryBuilder() .withQuery(query.build()._toQuery()) - .withPageable(PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); + .withPageable(PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())) + .withSourceFilter(new FetchSourceFilterBuilder().withIncludes("id").build()); for (org.springframework.data.domain.Sort.Order o : pageable.getSort()) { builder.withSort(s -> s.field(f -> f @@ -562,6 +581,16 @@ private BulkOperation createIndexOperation(ElasticCase useCase) { .document(template.getElasticsearchConverter().mapObject(useCase)))); } + private BulkOperation createUpdateOperation(ElasticCase useCase) { + return BulkOperation.of(op -> op.update(u -> u + .index(elasticProperties.getIndex().get(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX)) + .id(useCase.getId()) + .action(a -> a + .doc(template.getElasticsearchConverter().mapObject(useCase)) + .docAsUpsert(true) + ))); + } + private List normalizeFullTextSearch(String fullText) { return Arrays.stream(fullText .replace("\\", "") diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java index 003589c6fa1..2a8d761c39e 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/interfaces/IElasticCaseService.java @@ -19,6 +19,11 @@ public interface IElasticCaseService { void indexNow(ElasticCase useCase); + @Async + void update(ElasticCase useCase); + + void updateNow(ElasticCase useCase); + Page search(List requests, LoggedUser user, Pageable pageable, Locale locale, Boolean isIntersection); /** diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java index a5b9560e38a..f4f228da1dc 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java @@ -1336,15 +1336,21 @@ public void validateCaseRefValue(List value, List allowedNets) t } private Collection resolveGetDataKeys(PetriNet net, Task task) { + if (net == null || task == null) { + return Collections.emptySet(); + } Transition transition = net.getTransition(task.getTransitionId()); - if (transition == null) { + if (transition == null || transition.getDataSet() == null) { return Collections.emptySet(); } + if (hasGetEvents(net, transition)) { + return net.getDataSet() != null ? net.getDataSet().keySet() : Collections.emptySet(); + } return transition.getDataSet().keySet(); } private boolean hasGetEvents(PetriNet net, Transition transition) { - if (transition == null) { + if (net == null || transition == null || transition.getDataSet() == null || net.getDataSet() == null) { return false; } return transition.getDataSet().entrySet().stream().anyMatch(entry -> { diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java index 01d05a585e4..fb4af943af9 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/WorkflowService.java @@ -350,7 +350,7 @@ private Page hydrateProjectedPage(Page staticPage, Pageable pageable /** * Persists a PARTIAL case using Mongo {@code $set} updates (see {@code CaseProjectionRepository#updatePartial}). - * The case stays partial and read-only with respect to {@code save}; Elasticsearch is re-indexed from a full read. + * The case stays partial and read-only with respect to {@code save}; Elasticsearch is partially updated. */ @Override public Case savePartial(Case partialCase) { @@ -365,10 +365,10 @@ public Case savePartial(Case partialCase) { if (!repository.updatePartial(partialCase)) { throw new IllegalArgumentException("Could not find Case with id [" + partialCase.getStringId() + "]"); } + decryptDataSet(partialCase); try { - Case full = findOne(partialCase.getStringId()); - setImmediateDataFields(full); - elasticCaseService.indexNow(this.caseMappingService.transform(full)); + setImmediateDataFieldsReadOnly(partialCase); + elasticCaseService.updateNow(this.caseMappingService.transform(partialCase)); } catch (Exception e) { log.error("Indexing failed [{}]", partialCase.getStringId(), e); } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java index 69aff80379a..e704dda957a 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java @@ -28,6 +28,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(classes = ApplicationEngine.class) @@ -173,6 +174,67 @@ void multipleFullTextRequestsSupportIntersectionAndUnion() throws InterruptedExc )); } + @Test + void searchIdsReturnsPageOfStrings() throws InterruptedException { + Case case1 = createAndIndexCase(FIRST_TERM + " SearchIdsAlpha"); + Case case2 = createAndIndexCase(FIRST_TERM + " SearchIdsBeta"); + waitForIndexedCases(List.of(case1.getStringId(), case2.getStringId())); + + CaseSearchRequest request = fullTextRequest(FIRST_TERM); + Page idPage = waitForSearchIdsResult(List.of(request), true, 2); + + assertEquals(2, idPage.getTotalElements()); + assertTrue(idPage.getContent().contains(case1.getStringId())); + assertTrue(idPage.getContent().contains(case2.getStringId())); + } + + @Test + void searchIdsSupportsPagination() throws InterruptedException { + Case case1 = createAndIndexCase(FIRST_TERM + " PagingAlpha"); + Case case2 = createAndIndexCase(FIRST_TERM + " PagingBeta"); + waitForIndexedCases(List.of(case1.getStringId(), case2.getStringId())); + + CaseSearchRequest request = fullTextRequest(FIRST_TERM); + waitForSearchIdsResult(List.of(request), true, 2); + + Page page0 = elasticCaseService.searchIds( + List.of(request), + superCreator.getLoggedSuper(), + PageRequest.of(0, 1), + Locale.ENGLISH, + true + ); + Page page1 = elasticCaseService.searchIds( + List.of(request), + superCreator.getLoggedSuper(), + PageRequest.of(1, 1), + Locale.ENGLISH, + true + ); + + assertEquals(2, page0.getTotalElements()); + assertEquals(1, page0.getContent().size()); + assertEquals(2, page1.getTotalElements()); + assertEquals(1, page1.getContent().size()); + assertNotEquals(page0.getContent().get(0), page1.getContent().get(0)); + } + + @Test + void savePartialUpdatesElasticsearchWithoutFullRead() throws InterruptedException { + Case initialCase = createAndIndexCase("InitialPartialUpdateCase"); + waitForIndexedCases(List.of(initialCase.getStringId())); + + Case partial = workflowService.findOneProjected(initialCase.getStringId(), List.of("text")); + partial.setTitle("UpdatedPartialElasticCase"); + workflowService.savePartial(partial); + + CaseSearchRequest request = fullTextRequest("UpdatedPartialElasticCase"); + Page result = waitForSearchResult(List.of(request), true, 1); + + assertEquals(1, result.getTotalElements()); + assertCaseIds(result, initialCase); + } + private Case createAndIndexCase(String title) { Case useCase = importHelper.createCaseAsSuper(title, net); Case savedCase = workflowService.save(useCase); @@ -230,4 +292,25 @@ private Page waitForSearchResult(List requests, } while (System.nanoTime() < deadline); return result; } + + private Page waitForSearchIdsResult(List requests, + boolean intersection, + long expectedCount) throws InterruptedException { + long deadline = System.nanoTime() + SEARCH_TIMEOUT.toNanos(); + Page result; + do { + result = elasticCaseService.searchIds( + requests, + superCreator.getLoggedSuper(), + PageRequest.of(0, 10), + Locale.ENGLISH, + intersection + ); + if (result.getTotalElements() == expectedCount) { + return result; + } + Thread.sleep(100); + } while (System.nanoTime() < deadline); + return result; + } } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java index d2058e15a45..c2fe8327876 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java @@ -27,8 +27,14 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.TotalHitsRelation; import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; import org.springframework.data.elasticsearch.core.document.Document; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.SourceFilter; import org.springframework.test.util.ReflectionTestUtils; import java.util.HashMap; @@ -260,8 +266,15 @@ void buildQuerySupportsIntersectionUnionAndBothSortDirections() { doAnswer(ignored -> new BoolQuery.Builder()).when(service) .buildSingleQuery(any(CaseSearchRequest.class), same(user), eq(Locale.ENGLISH)); - assertNotNull(service.buildQuery(requests, user, pageable, Locale.ENGLISH, true)); - assertNotNull(service.buildQuery(requests, user, pageable, Locale.ENGLISH, false)); + NativeQuery intersectionQuery = service.buildQuery(requests, user, pageable, Locale.ENGLISH, true); + NativeQuery unionQuery = service.buildQuery(requests, user, pageable, Locale.ENGLISH, false); + + assertNotNull(intersectionQuery); + assertNotNull(unionQuery); + + SourceFilter sourceFilter = intersectionQuery.getSourceFilter(); + assertNotNull(sourceFilter); + assertEquals(List.of("id"), List.of(sourceFilter.getIncludes())); } @Test @@ -281,16 +294,94 @@ void searchAndCountHandleEmptyQueryAndRejectNullRequests() { Pageable pageable = PageRequest.of(0, 10); doReturn(null).when(service).buildQuery(anyList(), same(user), any(Pageable.class), eq(Locale.ENGLISH), eq(true)); + Page idsResult = service.searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); Page result = service.search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); + assertTrue(idsResult.isEmpty()); assertTrue(result.isEmpty()); assertEquals(0, service.count(List.of(new CaseSearchRequest()), user, Locale.ENGLISH, true)); + assertThrows(IllegalArgumentException.class, + () -> service.searchIds(null, user, pageable, Locale.ENGLISH, true)); assertThrows(IllegalArgumentException.class, () -> service.search(null, user, pageable, Locale.ENGLISH, true)); assertThrows(IllegalArgumentException.class, () -> service.count(null, user, Locale.ENGLISH, true)); } + @Test + void searchIdsReturnsPageOfStringsWhenHitsFound() { + Fixture fixture = fixture(List.of("title")); + LoggedUser user = loggedUser(); + Pageable pageable = PageRequest.of(0, 10); + + SearchHit hit1 = + new SearchHit<>("case-index", "case-1", null, 1.0f, null, null, null, null, null, null, null); + SearchHit hit2 = + new SearchHit<>("case-index", "case-2", null, 1.0f, null, null, null, null, null, null, null); + SearchHits searchHits = mock(SearchHits.class); + when(searchHits.getSearchHits()).thenReturn(List.of(hit1, hit2)); + when(searchHits.getTotalHits()).thenReturn(2L); + when(searchHits.getTotalHitsRelation()).thenReturn(TotalHitsRelation.EQUAL_TO); + + when(fixture.template().search(any(NativeQuery.class), eq(com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class), any(IndexCoordinates.class))) + .thenReturn(searchHits); + + Page result = fixture.service().searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); + + assertEquals(2, result.getTotalElements()); + assertEquals(List.of("case-1", "case-2"), result.getContent()); + } + + @Test + void searchIdsReturnsEmptyPageWhenNoHitsFound() { + Fixture fixture = fixture(List.of("title")); + LoggedUser user = loggedUser(); + Pageable pageable = PageRequest.of(0, 10); + + SearchHits searchHits = mock(SearchHits.class); + when(searchHits.getSearchHits()).thenReturn(List.of()); + when(searchHits.getTotalHits()).thenReturn(0L); + when(searchHits.getTotalHitsRelation()).thenReturn(TotalHitsRelation.EQUAL_TO); + + when(fixture.template().search(any(NativeQuery.class), eq(com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class), any(IndexCoordinates.class))) + .thenReturn(searchHits); + + Page result = fixture.service().searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); + + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + @Test + void searchResolvesCasesFromMongoUsingSearchIds() { + Fixture fixture = fixture(List.of("title")); + LoggedUser user = loggedUser(); + Pageable pageable = PageRequest.of(0, 10); + + SearchHit hit1 = + new SearchHit<>("case-index", "case-1", null, 1.0f, null, null, null, null, null, null, null); + SearchHits searchHits = mock(SearchHits.class); + when(searchHits.getSearchHits()).thenReturn(List.of(hit1)); + when(searchHits.getTotalHits()).thenReturn(1L); + when(searchHits.getTotalHitsRelation()).thenReturn(TotalHitsRelation.EQUAL_TO); + + when(fixture.template().search(any(NativeQuery.class), eq(com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class), any(IndexCoordinates.class))) + .thenReturn(searchHits); + + Case caseEntity = mock(Case.class); + when(caseEntity.getStringId()).thenReturn("case-1"); + when(fixture.workflowService().findAllById(List.of("case-1"))).thenReturn(List.of(caseEntity)); + when(fixture.workflowService().findAllByIdProjected(List.of("case-1"), List.of("field1"))).thenReturn(List.of(caseEntity)); + + Page fullResult = fixture.service().search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); + assertEquals(1, fullResult.getTotalElements()); + assertEquals(List.of(caseEntity), fullResult.getContent()); + + Page projectedResult = fixture.service().search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true, List.of("field1")); + assertEquals(1, projectedResult.getTotalElements()); + assertEquals(List.of(caseEntity), projectedResult.getContent()); + } + @Test void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { Fixture fixture = fixture(List.of("title")); @@ -308,6 +399,8 @@ void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { fixture.service().remove("case-1"); fixture.service().index(useCase); fixture.service().indexNow(useCase); + fixture.service().update(useCase); + fixture.service().updateNow(useCase); ReflectionTestUtils.invokeMethod(fixture.service(), "stopQueues"); ArgumentCaptor deleteOperation = ArgumentCaptor.forClass(BulkOperationWrapper.class); @@ -316,9 +409,13 @@ void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { assertEquals("case-1", deleteOperation.getValue().getOperation().delete().id()); ArgumentCaptor indexOperations = ArgumentCaptor.forClass(BulkOperationWrapper.class); - verify(indexQueue, times(2)).push(indexOperations.capture()); - assertTrue(indexOperations.getAllValues().stream() - .allMatch(operation -> "case-index".equals(operation.getOperation().index().index()))); + verify(indexQueue, times(4)).push(indexOperations.capture()); + assertEquals(2, indexOperations.getAllValues().stream() + .filter(operation -> operation.getOperation().isIndex() && "case-index".equals(operation.getOperation().index().index())) + .count()); + assertEquals(2, indexOperations.getAllValues().stream() + .filter(operation -> operation.getOperation().isUpdate() && "case-index".equals(operation.getOperation().update().index())) + .count()); verify(indexQueue).shutdown(); verify(deleteQueue).shutdown(); } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java index 6cab37d36d7..ce3d18c0a16 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java @@ -196,4 +196,21 @@ void searchProjectedMethodsReturnProjectedCases() { assertEquals(1, pagedProjected.getTotalElements()); assertTrue(pagedProjected.getContent().get(0).isPartiallyLoaded()); } + + @Test + void getDataWithEventsLoadsEntireDataSet() { + PetriNet actionsNet = importHelper.createNet("data_actions_test.xml").orElseThrow(); + Case actionCase = workflowService.createCase(CreateCaseParams.with() + .process(actionsNet) + .title("Actions Case") + .color("green") + .author(superCreator.getLoggedSuper()) + .build()).getCase(); + String taskId = actionCase.getTasks().iterator().next().getTask(); + var outcome = dataService.getData(taskId); + assertNotNull(outcome); + assertTrue(outcome.getCase().isPartiallyLoaded()); + assertTrue(outcome.getCase().getDataSet().containsKey("control_field")); + assertTrue(outcome.getCase().getDataSet().containsKey("text_field")); + } } From aabeabb3de9ca75e258d182d74fbc1cb300772e4 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Tue, 27 Oct 2026 15:23:58 +0100 Subject: [PATCH 5/9] Expand `ActionApi` functionality: add new case search, projection, and partial save methods with corresponding implementation and test coverage. --- .../engine/actions/ActionApiImpl.java | 78 ++++++++++ .../engine/actions/ActionApiImplTest.java | 41 +++++ .../engine/actions/ActionApiImplUnitTest.java | 38 +++++ .../ElasticCaseServiceIntegrationTest.java | 66 -------- .../service/ElasticCaseServiceTest.java | 65 -------- .../adapter/spring/actions/ActionApi.java | 142 +++++++++++++++++- .../spring/actions/ActionApiMethods.java | 3 + 7 files changed, 300 insertions(+), 133 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/actions/ActionApiImpl.java b/application-engine/src/main/java/com/netgrif/application/engine/actions/ActionApiImpl.java index cff09a2fccd..31a1535b2b8 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/actions/ActionApiImpl.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/actions/ActionApiImpl.java @@ -124,11 +124,21 @@ public Page searchCases(String processIdentifier, Predicate predicate, Pag return searchCases(processIdentifier, predicate, pageable, new HashMap<>()); } + @Override + public Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable, Collection dataFieldIds) { + return searchCases(processIdentifier, predicate, pageable, dataFieldIds, new HashMap<>()); + } + @Override public Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection) { return searchCases(elasticStringQueries, authPrincipalDto, pageable, isIntersection, new HashMap<>()); } + @Override + public Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Collection dataFieldIds) { + return searchCases(elasticStringQueries, authPrincipalDto, pageable, isIntersection, dataFieldIds, new HashMap<>()); + } + @Override public Long countCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Boolean isIntersection) { return countCases(elasticStringQueries, authPrincipalDto, isIntersection, new HashMap<>()); @@ -140,6 +150,12 @@ public Page searchCases(String processIdentifier, Predicate predicate, Pag return workflowService.search(predicate, pageable); } + @Override + public Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable, Collection dataFieldIds, Map params) { + log.debug("Searching cases for process identifier [{}] with predicate [{}], pageable [{}], dataFieldIds [{}]", processIdentifier, predicate, pageable, dataFieldIds); + return dataFieldIds == null ? workflowService.search(predicate, pageable) : workflowService.searchProjected(predicate, pageable, dataFieldIds); + } + @Override public Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Map params) { log.debug("Searching cases for elastic queries [{}] with auth principal [{}], pageable [{}], intersect [{}]", elasticStringQueries, authPrincipalDto, pageable, isIntersection); @@ -151,6 +167,17 @@ public Page searchCases(List elasticStringQueries, AuthPrincipalDt return elasticCaseService.search(caseSearchRequests, loggedUser, pageable, locale, intersect); } + @Override + public Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Collection dataFieldIds, Map params) { + log.debug("Searching cases for elastic queries [{}] with auth principal [{}], pageable [{}], intersect [{}], dataFieldIds [{}]", elasticStringQueries, authPrincipalDto, pageable, isIntersection, dataFieldIds); + boolean intersect = Boolean.TRUE.equals(isIntersection); + List caseSearchRequests = elasticStringQueries.stream().map(query -> CaseSearchRequest.builder().query(query).build()).toList(); + LoggedUser loggedUser = ActorTransformer.toLoggedUser(resolveAbstractUser(authPrincipalDto)); + Locale locale = LocaleContextHolder.getLocale(); + log.trace("Searching cases for elastic queries [{}] with auth principal [{}], pageable [{}], intersect [{}], locale [{}], dataFieldIds [{}]", elasticStringQueries, authPrincipalDto, pageable, isIntersection, locale, dataFieldIds); + return elasticCaseService.search(caseSearchRequests, loggedUser, pageable, locale, intersect, dataFieldIds); + } + @Override public Long countCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Boolean isIntersection, Map params) { log.debug("Counting cases for elastic queries [{}] with auth principal [{}], intersect [{}]", elasticStringQueries, authPrincipalDto, isIntersection); @@ -265,6 +292,57 @@ public Case findCase(String caseId) { return workflowService.findOne(caseId); } + @Override + public Case findCase(String caseId, Collection dataFieldIds) { + log.debug("Finding case with id [{}] and data fields [{}]", caseId, dataFieldIds); + return dataFieldIds == null ? workflowService.findOne(caseId) : workflowService.findOneProjected(caseId, dataFieldIds); + } + + @Override + public Case findCase(String caseId, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { + log.debug("Finding case with id [{}] and data fields [{}], includeImmediateFields [{}], setPetriNet [{}]", caseId, dataFieldIds, includeImmediateFields, setPetriNet); + return dataFieldIds == null ? workflowService.findOne(caseId) : workflowService.findOneProjected(caseId, dataFieldIds, includeImmediateFields, setPetriNet); + } + + @Override + public List findCases(List caseIds) { + log.debug("Finding cases with ids [{}]", caseIds); + return workflowService.findAllById(caseIds); + } + + @Override + public List findCases(List caseIds, Collection dataFieldIds) { + log.debug("Finding cases with ids [{}] and data fields [{}]", caseIds, dataFieldIds); + return dataFieldIds == null ? workflowService.findAllById(caseIds) : workflowService.findAllByIdProjected(caseIds, dataFieldIds); + } + + @Override + public List findCases(List caseIds, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet) { + log.debug("Finding cases with ids [{}] and data fields [{}], includeImmediateFields [{}], setPetriNet [{}]", caseIds, dataFieldIds, includeImmediateFields, setPetriNet); + return dataFieldIds == null ? workflowService.findAllById(caseIds) : workflowService.findAllByIdProjected(caseIds, dataFieldIds, includeImmediateFields, setPetriNet); + } + + @Override + public Case savePartialCase(Case partialCase) { + return savePartialCase(partialCase, new HashMap<>()); + } + + @Override + public Case savePartialCase(Case partialCase, Map params) { + log.debug("Saving partial case with id [{}] and params [{}]", partialCase != null && partialCase.get_id() != null ? partialCase.getStringId() : null, params); + return workflowService.savePartial(partialCase); + } + + @Override + public Case savePartial(Case partialCase) { + return savePartialCase(partialCase); + } + + @Override + public Case savePartial(Case partialCase, Map params) { + return savePartialCase(partialCase, params); + } + @Override public Task findTask(String taskId) { log.debug("Finding task with id [{}]", taskId); diff --git a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java index 74e876d68c1..ff2a44306bd 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java @@ -121,6 +121,47 @@ void createFindAndDeleteCaseByActionApiUsesRealWorkflowServices() { ); } + @Test + void projectedCaseOperationsAndPartialSaveViaActionApi() { + importAllDataNet(); + + CreateCaseEventOutcome created = actionApi.createCaseByIdentifier( + PROCESS_IDENTIFIER, + "Projected API case", + "green", + superUserPrincipal, + Map.of("source", "action-api-test") + ); + + String caseId = created.getCase().getStringId(); + Case projectedCase = actionApi.findCase(caseId, List.of("text")); + assertNotNull(projectedCase); + assertTrue(projectedCase.isPartiallyLoaded()); + assertTrue(projectedCase.getDataSet().containsKey("text")); + + List projectedList = actionApi.findCases(List.of(caseId), List.of("text")); + assertEquals(1, projectedList.size()); + assertTrue(projectedList.get(0).isPartiallyLoaded()); + + projectedCase.setTitle("Updated Projected Case Title"); + Case savedPartial = actionApi.savePartial(projectedCase); + assertNotNull(savedPartial); + assertEquals("Updated Projected Case Title", savedPartial.getTitle()); + + Case refreshed = actionApi.findCase(caseId); + assertEquals("Updated Projected Case Title", refreshed.getTitle()); + + com.netgrif.application.engine.objects.workflow.domain.QCase qCase = new com.netgrif.application.engine.objects.workflow.domain.QCase("case"); + org.springframework.data.domain.Page searched = actionApi.searchCases( + PROCESS_IDENTIFIER, + qCase.title.eq("Updated Projected Case Title"), + org.springframework.data.domain.PageRequest.of(0, 10), + List.of("text") + ); + assertEquals(1, searched.getTotalElements()); + assertTrue(searched.getContent().get(0).isPartiallyLoaded()); + } + @Test void getSystemUserDtoMatchesSystemUser() { AuthPrincipalDto systemUserDto = actionApi.getSystemUserDto(); diff --git a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplUnitTest.java b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplUnitTest.java index f0b361b8f8b..39fa7a9962a 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplUnitTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplUnitTest.java @@ -10,6 +10,7 @@ import com.netgrif.application.engine.objects.auth.domain.ActorTransformer; import com.netgrif.application.engine.objects.auth.dto.AuthPrincipalDto; import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.workflow.domain.ProcessResourceId; import com.netgrif.application.engine.objects.workflow.domain.Case; import com.netgrif.application.engine.objects.workflow.domain.Task; import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.caseoutcomes.CreateCaseEventOutcome; @@ -178,18 +179,24 @@ void delegatesSearchAndCountOperations() { Page cases = Page.empty(pageable); Page tasks = Page.empty(pageable); when(workflowService.search(predicate, pageable)).thenReturn(cases); + when(workflowService.searchProjected(predicate, pageable, List.of("field1"))).thenReturn(cases); when(taskService.search(predicate, pageable)).thenReturn(tasks); assertSame(cases, actionApi.searchCases("process", predicate, pageable)); + assertSame(cases, actionApi.searchCases("process", predicate, pageable, List.of("field1"))); + assertSame(cases, actionApi.searchCases("process", predicate, pageable, List.of("field1"), Map.of())); assertSame(tasks, actionApi.searchTasks("process", predicate, pageable)); when(userService.findUserByUsername("john", "realm")).thenReturn(Optional.of(user)); when(elasticCaseService.search(anyList(), any(), eq(pageable), any(Locale.class), eq(true))).thenReturn(cases); + when(elasticCaseService.search(anyList(), any(), eq(pageable), any(Locale.class), eq(true), eq(List.of("field1")))).thenReturn(cases); when(elasticTaskService.search(anyList(), any(), eq(pageable), any(Locale.class), eq(false))).thenReturn(tasks); when(elasticCaseService.count(anyList(), any(), any(Locale.class), eq(true))).thenReturn(7L); when(elasticTaskService.count(anyList(), any(), any(Locale.class), eq(false))).thenReturn(3L); assertSame(cases, actionApi.searchCases(List.of("case query"), principal, pageable, true)); + assertSame(cases, actionApi.searchCases(List.of("case query"), principal, pageable, true, List.of("field1"))); + assertSame(cases, actionApi.searchCases(List.of("case query"), principal, pageable, true, List.of("field1"), Map.of())); assertSame(tasks, actionApi.searchTasks(List.of("task query"), principal, pageable, false)); assertEquals(7L, actionApi.countCases(List.of("case query"), principal, true)); assertEquals(3L, actionApi.countTasks(List.of("task query"), principal, false, Map.of())); @@ -197,11 +204,42 @@ void delegatesSearchAndCountOperations() { ArgumentCaptor> caseRequests = ArgumentCaptor.forClass(List.class); ArgumentCaptor> taskRequests = ArgumentCaptor.forClass(List.class); verify(elasticCaseService).search(caseRequests.capture(), any(), eq(pageable), any(Locale.class), eq(true)); + verify(elasticCaseService, org.mockito.Mockito.times(2)).search(anyList(), any(), eq(pageable), any(Locale.class), eq(true), eq(List.of("field1"))); verify(elasticTaskService).search(taskRequests.capture(), any(), eq(pageable), any(Locale.class), eq(false)); assertEquals("case query", caseRequests.getValue().getFirst().query); assertEquals("task query", taskRequests.getValue().getFirst().query); } + @Test + void delegatesProjectedCaseOperationsAndSavePartial() { + Case partialCase = new com.netgrif.application.engine.adapter.spring.workflow.domain.Case(); + partialCase.set_id(new ProcessResourceId("process", new org.bson.types.ObjectId())); + List casesList = List.of(partialCase); + when(workflowService.findOneProjected("case-1", List.of("text"))).thenReturn(partialCase); + when(workflowService.findOneProjected("case-1", List.of("text"), false, false)).thenReturn(partialCase); + when(workflowService.findAllById(List.of("case-1"))).thenReturn(casesList); + when(workflowService.findAllByIdProjected(List.of("case-1"), List.of("text"))).thenReturn(casesList); + when(workflowService.findAllByIdProjected(List.of("case-1"), List.of("text"), false, false)).thenReturn(casesList); + when(workflowService.savePartial(partialCase)).thenReturn(partialCase); + + assertSame(partialCase, actionApi.findCase("case-1", List.of("text"))); + assertSame(partialCase, actionApi.findCase("case-1", List.of("text"), false, false)); + assertSame(casesList, actionApi.findCases(List.of("case-1"))); + assertSame(casesList, actionApi.findCases(List.of("case-1"), List.of("text"))); + assertSame(casesList, actionApi.findCases(List.of("case-1"), List.of("text"), false, false)); + assertSame(partialCase, actionApi.savePartialCase(partialCase)); + assertSame(partialCase, actionApi.savePartialCase(partialCase, Map.of("key", "val"))); + assertSame(partialCase, actionApi.savePartial(partialCase)); + assertSame(partialCase, actionApi.savePartial(partialCase, Map.of("key", "val"))); + + verify(workflowService).findOneProjected("case-1", List.of("text")); + verify(workflowService).findOneProjected("case-1", List.of("text"), false, false); + verify(workflowService).findAllById(List.of("case-1")); + verify(workflowService).findAllByIdProjected(List.of("case-1"), List.of("text")); + verify(workflowService).findAllByIdProjected(List.of("case-1"), List.of("text"), false, false); + verify(workflowService, org.mockito.Mockito.times(4)).savePartial(partialCase); + } + @Test void delegatesCaseTaskAndUserOperations() throws Exception { Map params = Map.of("source", "unit"); diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java index e704dda957a..2cb1b352cae 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceIntegrationTest.java @@ -174,51 +174,6 @@ void multipleFullTextRequestsSupportIntersectionAndUnion() throws InterruptedExc )); } - @Test - void searchIdsReturnsPageOfStrings() throws InterruptedException { - Case case1 = createAndIndexCase(FIRST_TERM + " SearchIdsAlpha"); - Case case2 = createAndIndexCase(FIRST_TERM + " SearchIdsBeta"); - waitForIndexedCases(List.of(case1.getStringId(), case2.getStringId())); - - CaseSearchRequest request = fullTextRequest(FIRST_TERM); - Page idPage = waitForSearchIdsResult(List.of(request), true, 2); - - assertEquals(2, idPage.getTotalElements()); - assertTrue(idPage.getContent().contains(case1.getStringId())); - assertTrue(idPage.getContent().contains(case2.getStringId())); - } - - @Test - void searchIdsSupportsPagination() throws InterruptedException { - Case case1 = createAndIndexCase(FIRST_TERM + " PagingAlpha"); - Case case2 = createAndIndexCase(FIRST_TERM + " PagingBeta"); - waitForIndexedCases(List.of(case1.getStringId(), case2.getStringId())); - - CaseSearchRequest request = fullTextRequest(FIRST_TERM); - waitForSearchIdsResult(List.of(request), true, 2); - - Page page0 = elasticCaseService.searchIds( - List.of(request), - superCreator.getLoggedSuper(), - PageRequest.of(0, 1), - Locale.ENGLISH, - true - ); - Page page1 = elasticCaseService.searchIds( - List.of(request), - superCreator.getLoggedSuper(), - PageRequest.of(1, 1), - Locale.ENGLISH, - true - ); - - assertEquals(2, page0.getTotalElements()); - assertEquals(1, page0.getContent().size()); - assertEquals(2, page1.getTotalElements()); - assertEquals(1, page1.getContent().size()); - assertNotEquals(page0.getContent().get(0), page1.getContent().get(0)); - } - @Test void savePartialUpdatesElasticsearchWithoutFullRead() throws InterruptedException { Case initialCase = createAndIndexCase("InitialPartialUpdateCase"); @@ -292,25 +247,4 @@ private Page waitForSearchResult(List requests, } while (System.nanoTime() < deadline); return result; } - - private Page waitForSearchIdsResult(List requests, - boolean intersection, - long expectedCount) throws InterruptedException { - long deadline = System.nanoTime() + SEARCH_TIMEOUT.toNanos(); - Page result; - do { - result = elasticCaseService.searchIds( - requests, - superCreator.getLoggedSuper(), - PageRequest.of(0, 10), - Locale.ENGLISH, - intersection - ); - if (result.getTotalElements() == expectedCount) { - return result; - } - Thread.sleep(100); - } while (System.nanoTime() < deadline); - return result; - } } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java index c2fe8327876..0ee152ca314 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java @@ -287,71 +287,6 @@ void buildSingleQueryInvokesAllEmptyFilterPaths() { assertEquals(1, query.filter().size()); } - @Test - void searchAndCountHandleEmptyQueryAndRejectNullRequests() { - ElasticCaseService service = spy(service(List.of("title"))); - LoggedUser user = loggedUser(); - Pageable pageable = PageRequest.of(0, 10); - doReturn(null).when(service).buildQuery(anyList(), same(user), any(Pageable.class), eq(Locale.ENGLISH), eq(true)); - - Page idsResult = service.searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); - Page result = service.search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); - - assertTrue(idsResult.isEmpty()); - assertTrue(result.isEmpty()); - assertEquals(0, service.count(List.of(new CaseSearchRequest()), user, Locale.ENGLISH, true)); - assertThrows(IllegalArgumentException.class, - () -> service.searchIds(null, user, pageable, Locale.ENGLISH, true)); - assertThrows(IllegalArgumentException.class, - () -> service.search(null, user, pageable, Locale.ENGLISH, true)); - assertThrows(IllegalArgumentException.class, - () -> service.count(null, user, Locale.ENGLISH, true)); - } - - @Test - void searchIdsReturnsPageOfStringsWhenHitsFound() { - Fixture fixture = fixture(List.of("title")); - LoggedUser user = loggedUser(); - Pageable pageable = PageRequest.of(0, 10); - - SearchHit hit1 = - new SearchHit<>("case-index", "case-1", null, 1.0f, null, null, null, null, null, null, null); - SearchHit hit2 = - new SearchHit<>("case-index", "case-2", null, 1.0f, null, null, null, null, null, null, null); - SearchHits searchHits = mock(SearchHits.class); - when(searchHits.getSearchHits()).thenReturn(List.of(hit1, hit2)); - when(searchHits.getTotalHits()).thenReturn(2L); - when(searchHits.getTotalHitsRelation()).thenReturn(TotalHitsRelation.EQUAL_TO); - - when(fixture.template().search(any(NativeQuery.class), eq(com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class), any(IndexCoordinates.class))) - .thenReturn(searchHits); - - Page result = fixture.service().searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); - - assertEquals(2, result.getTotalElements()); - assertEquals(List.of("case-1", "case-2"), result.getContent()); - } - - @Test - void searchIdsReturnsEmptyPageWhenNoHitsFound() { - Fixture fixture = fixture(List.of("title")); - LoggedUser user = loggedUser(); - Pageable pageable = PageRequest.of(0, 10); - - SearchHits searchHits = mock(SearchHits.class); - when(searchHits.getSearchHits()).thenReturn(List.of()); - when(searchHits.getTotalHits()).thenReturn(0L); - when(searchHits.getTotalHitsRelation()).thenReturn(TotalHitsRelation.EQUAL_TO); - - when(fixture.template().search(any(NativeQuery.class), eq(com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class), any(IndexCoordinates.class))) - .thenReturn(searchHits); - - Page result = fixture.service().searchIds(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); - - assertEquals(0, result.getTotalElements()); - assertTrue(result.getContent().isEmpty()); - } - @Test void searchResolvesCasesFromMongoUsingSearchIds() { Fixture fixture = fixture(List.of("title")); diff --git a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApi.java b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApi.java index cceb99bdab6..24b6d6c7a57 100644 --- a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApi.java +++ b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApi.java @@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable; import java.io.IOException; +import java.util.Collection; import java.util.Map; import java.util.List; @@ -56,6 +57,56 @@ public interface ActionApi { */ Case findCase(String caseId); + /** + * Finds a specific case by its ID with only the requested data fields. + * + * @param caseId the ID of the case to find + * @param dataFieldIds the IDs of data fields to include in projection + * @return the partially loaded case + * @throws IllegalArgumentException if not found + */ + Case findCase(String caseId, Collection dataFieldIds); + + /** + * Finds a specific case by its ID with only the requested data fields. + * + * @param caseId the ID of the case to find + * @param dataFieldIds the IDs of data fields to include in projection + * @param includeImmediateFields whether to include immediate data fields + * @param setPetriNet whether to set the petri net on the case + * @return the partially loaded case + * @throws IllegalArgumentException if not found + */ + Case findCase(String caseId, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet); + + /** + * Finds cases by their IDs. + * + * @param caseIds the list of IDs of the cases to find + * @return the list of found cases + */ + List findCases(List caseIds); + + /** + * Finds cases by their IDs with only the requested data fields. + * + * @param caseIds the list of IDs of the cases to find + * @param dataFieldIds the IDs of data fields to include in projection + * @return the list of partially loaded cases + */ + List findCases(List caseIds, Collection dataFieldIds); + + /** + * Finds cases by their IDs with only the requested data fields. + * + * @param caseIds the list of IDs of the cases to find + * @param dataFieldIds the IDs of data fields to include in projection + * @param includeImmediateFields whether to include immediate data fields + * @param setPetriNet whether to set the petri net on the case + * @return the list of partially loaded cases + */ + List findCases(List caseIds, Collection dataFieldIds, boolean includeImmediateFields, boolean setPetriNet); + /** * Searches for cases matching the given predicate. * @@ -69,6 +120,20 @@ public interface ActionApi { */ Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable); + /** + * Searches for cases matching the given predicate with only the requested data fields. + * + * @param processIdentifier reserved for interface compatibility; this implementation + * does not filter by process identifier and returns cases + * from all processes. Use the predicate parameter to filter + * by specific process(es) if needed. + * @param predicate the criteria for filtering cases + * @param pageable the pagination information + * @param dataFieldIds the IDs of data fields to include in projection + * @return a page of cases matching the criteria + */ + Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable, Collection dataFieldIds); + /** * Searches for cases using elastic search queries. * @@ -80,6 +145,17 @@ public interface ActionApi { */ Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection); + /** + * Searches for cases using elastic search queries with only the requested data fields. + * + * @param elasticStringQueries a list of queries for filtering cases + * @param authPrincipalDto the authorization principal used for the search + * @param pageable the pagination information + * @param isIntersection true to intersect results of all queries; false for union + * @param dataFieldIds the IDs of data fields to include in projection + * @return a page of cases matching the criteria + */ + Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Collection dataFieldIds); /** * Counts the number of cases that match the provided elastic search queries. @@ -100,11 +176,26 @@ public interface ActionApi { * by specific process(es) if needed. * @param predicate the criteria for filtering cases * @param pageable the pagination information - * @param params additional parameters for the operation + * @param params additional parameters for the operation * @return a page of cases matching the criteria */ Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable, Map params); + /** + * Searches for cases matching the given predicate with only the requested data fields. + * + * @param processIdentifier reserved for interface compatibility; this implementation + * does not filter by process identifier and returns cases + * from all processes. Use the predicate parameter to filter + * by specific process(es) if needed. + * @param predicate the criteria for filtering cases + * @param pageable the pagination information + * @param dataFieldIds the IDs of data fields to include in projection + * @param params additional parameters for the operation + * @return a page of cases matching the criteria + */ + Page searchCases(String processIdentifier, Predicate predicate, Pageable pageable, Collection dataFieldIds, Map params); + /** * Searches for cases using elastic search queries. * @@ -112,11 +203,24 @@ public interface ActionApi { * @param authPrincipalDto the authorization principal used for the search * @param pageable the pagination information * @param isIntersection true to intersect results of all queries; false for union - * @param params additional parameters for the operation + * @param params additional parameters for the operation * @return a page of cases matching the criteria */ Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Map params); + /** + * Searches for cases using elastic search queries with only the requested data fields. + * + * @param elasticStringQueries a list of queries for filtering cases + * @param authPrincipalDto the authorization principal used for the search + * @param pageable the pagination information + * @param isIntersection true to intersect results of all queries; false for union + * @param dataFieldIds the IDs of data fields to include in projection + * @param params additional parameters for the operation + * @return a page of cases matching the criteria + */ + Page searchCases(List elasticStringQueries, AuthPrincipalDto authPrincipalDto, Pageable pageable, Boolean isIntersection, Collection dataFieldIds, Map params); + /** * Counts the number of cases that match the provided elastic search queries. @@ -150,6 +254,40 @@ public interface ActionApi { */ DeleteCaseEventOutcome deleteCase(String caseId, Map params); + /** + * Persists a partially loaded case using Mongo $set updates and updates Elasticsearch partially. + * + * @param partialCase the partially loaded case to persist + * @return the persisted case + */ + Case savePartialCase(Case partialCase); + + /** + * Persists a partially loaded case using Mongo $set updates and updates Elasticsearch partially. + * + * @param partialCase the partially loaded case to persist + * @param params additional parameters for the operation + * @return the persisted case + */ + Case savePartialCase(Case partialCase, Map params); + + /** + * Persists a partially loaded case using Mongo $set updates and updates Elasticsearch partially. + * + * @param partialCase the partially loaded case to persist + * @return the persisted case + */ + Case savePartial(Case partialCase); + + /** + * Persists a partially loaded case using Mongo $set updates and updates Elasticsearch partially. + * + * @param partialCase the partially loaded case to persist + * @param params additional parameters for the operation + * @return the persisted case + */ + Case savePartial(Case partialCase, Map params); + /** * Finds a specific task by its ID. * diff --git a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApiMethods.java b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApiMethods.java index 50973f139a6..d48e4f7eca2 100644 --- a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApiMethods.java +++ b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/actions/ActionApiMethods.java @@ -9,10 +9,13 @@ public enum ActionApiMethods { GET_DATA("getData"), SET_DATA("setData"), FIND_CASE("findCase"), + FIND_CASES("findCases"), COUNT_CASES("countCases"), SEARCH_CASES("searchCases"), CREATE_CASE_BY_IDENTIFIER("createCaseByIdentifier"), DELETE_CASE("deleteCase"), + SAVE_PARTIAL_CASE("savePartialCase"), + SAVE_PARTIAL("savePartial"), FIND_TASK("findTask"), SEARCH_TASKS("searchTasks"), ASSIGN_TASK("assignTask"), From e810afb40443810b0492750337d51ad0f6d96119 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Wed, 28 Oct 2026 15:57:16 +0100 Subject: [PATCH 6/9] Refactor `DataService` to handle null `type` in nodes, expand test coverage with new case reader plugin test, enhance `ElasticCaseService` type handling, and add utility for parameter type matching in `NaeReflectionUtils`. --- .../elastic/service/ElasticCaseService.java | 4 +-- .../engine/workflow/service/DataService.java | 2 +- .../engine/actions/ActionApiImplTest.java | 11 ++++++++ .../spring/utils/NaeReflectionUtils.java | 27 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java index 3e0a2d06909..89565ed2215 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java @@ -165,8 +165,8 @@ public Page search(List requests, LoggedUser user, Page List casePage; long total; if (query != null) { - SearchHits hits = template.search(query, ElasticCase.class, IndexCoordinates.of(elasticProperties.getIndex().get(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX))); - Page indexedCases = (Page) SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(hits, query.getPageable())); + SearchHits hits = template.search(query, com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase.class, IndexCoordinates.of(elasticProperties.getIndex().get(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX))); + Page indexedCases = (Page) SearchHitSupport.unwrapSearchHits(SearchHitSupport.searchPageFor(hits, query.getPageable())); List ids = indexedCases.get().map(ElasticCase::getId).collect(Collectors.toList()); casePage = dataFieldIds == null ? workflowService.findAllById(ids) : workflowService.findAllByIdProjected(ids, dataFieldIds); total = indexedCases.getTotalElements(); diff --git a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java index f4f228da1dc..e8ba4db0b86 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/workflow/service/DataService.java @@ -1205,7 +1205,7 @@ private boolean isEmptyOrNullValueNode(JsonNode node) { } private String getFieldTypeFromNode(ObjectNode node) { - return node.get("type").asText(); + return node.get("type") != null ? node.get("type").asText() : "default"; } private Map parseFilterMetadataValue(ObjectNode node, String fieldType) { diff --git a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java index ff2a44306bd..6f42b3aaaa1 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java @@ -184,6 +184,17 @@ void createCaseRejectsMissingAuthPrincipal() { assertEquals("AuthPrincipalDto cannot be null.", exception.getMessage()); } + @Test + void importCaseReaderPluginTestPetriNet() { + Optional netOptional = importHelper.createNet("case_reader_plugin_test.xml"); + assertTrue(netOptional.isPresent()); + PetriNet net = netOptional.get(); + assertEquals("case_reader_plugin_test", net.getIdentifier()); + assertNotNull(net.getTransitions().get("t_main")); + assertNotNull(net.getTransitions().get("helper_task")); + assertEquals(21 * 2 + 3, net.getDataSet().size()); + } + private PetriNet importAllDataNet() { Optional net = importHelper.createNet("all_data.xml"); assertTrue(net.isPresent()); diff --git a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/utils/NaeReflectionUtils.java b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/utils/NaeReflectionUtils.java index eadac8c7e9b..2b29194823b 100644 --- a/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/utils/NaeReflectionUtils.java +++ b/nae-spring-core-adapter/src/main/java/com/netgrif/application/engine/adapter/spring/utils/NaeReflectionUtils.java @@ -1,6 +1,7 @@ package com.netgrif.application.engine.adapter.spring.utils; import com.netgrif.application.engine.adapter.spring.utils.exceptions.AmbiguousMethodCallException; +import org.apache.commons.lang3.ClassUtils; import org.springframework.aop.framework.AopProxyUtils; import java.lang.reflect.Method; @@ -80,6 +81,28 @@ public static Method findMethod(Object bean, String methodToExecute, Class[] return findMethodWithSuperClassParams(bean, methodToExecute, requestParamTypes, e); } } + + /** + * Determines whether two arrays of parameter types match, considering primitive types + * and their corresponding wrapper classes as equivalent. + * + * @param methodParamTypes the parameter types of the method to check + * @param paramTypes the requested parameter types to compare against + * @return {@code true} if both arrays have equal length and matching types (exact or primitive/wrapper equivalent), {@code false} otherwise + */ + public static boolean isExactOrWrapperMatch(Class[] methodParamTypes, Class[] paramTypes) { + if (methodParamTypes.length != paramTypes.length) { + return false; + } + for (int i = 0; i < methodParamTypes.length; i++) { + Class mType = methodParamTypes[i] != null ? ClassUtils.primitiveToWrapper(methodParamTypes[i]) : null; + Class pType = paramTypes[i] != null ? ClassUtils.primitiveToWrapper(paramTypes[i]) : null; + if (!Objects.equals(mType, pType)) { + return false; + } + } + return true; + } /** * Attempts to find a method on the target class of the given bean using superclass parameter types when an exact match is not found. @@ -119,6 +142,10 @@ private static Method findMethodWithSuperClassParams(Object bean, String methodT continue; } + if (NaeReflectionUtils.isExactOrWrapperMatch(paramTypes, requestParamTypes)) { + return method; + } + for (int i = 0; i < requestParamTypes.length; ++i) { if (!paramTypes[i].isAssignableFrom(requestParamTypes[i])) { continue outerLoop; From af2cbfaa9966fd9f36716e581af86ff7b3cd2dac Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Thu, 29 Oct 2026 08:40:54 +0100 Subject: [PATCH 7/9] Refactor `ElasticCaseService` to handle missing cases in Elasticsearch by queuing full-case indexing and update associated tests and configuration for `IElasticCaseMappingService`. --- .../ElasticServiceConfiguration.java | 8 ++- .../elastic/service/ElasticCaseService.java | 30 ++++++++++- .../service/ElasticCaseServiceTest.java | 50 +++++++++++++++++-- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/ElasticServiceConfiguration.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/ElasticServiceConfiguration.java index dab7c287a52..fba193c0352 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/configuration/ElasticServiceConfiguration.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/ElasticServiceConfiguration.java @@ -7,6 +7,7 @@ import com.netgrif.application.engine.elastic.service.ElasticCaseService; import com.netgrif.application.engine.elastic.service.ElasticTaskService; import com.netgrif.application.engine.elastic.service.executors.Executor; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseMappingService; import com.netgrif.application.engine.elastic.service.interfaces.IElasticCasePrioritySearch; import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseService; import com.netgrif.application.engine.elastic.service.interfaces.IElasticTaskService; @@ -37,6 +38,7 @@ public class ElasticServiceConfiguration { private final IElasticCasePrioritySearch elasticCasePrioritySearch; private final ApplicationEventPublisher applicationEventPublisher; private final ElasticsearchClient elasticsearchClient; + private final IElasticCaseMappingService caseMappingService; @Bean @Primary @@ -66,7 +68,8 @@ public IElasticCaseService elasticCaseService() { workflowService, elasticCasePrioritySearch, applicationEventPublisher, - elasticsearchClient + elasticsearchClient, + caseMappingService ); } @@ -87,7 +90,8 @@ public IElasticCaseService reindexingTaskElasticCaseService() { workflowService, elasticCasePrioritySearch, applicationEventPublisher, - elasticsearchClient + elasticsearchClient, + caseMappingService ); } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java index 89565ed2215..8c9fa64cbcc 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseService.java @@ -10,6 +10,7 @@ import co.elastic.clients.elasticsearch.core.bulk.BulkOperation; import com.netgrif.application.engine.configuration.properties.DataConfigurationProperties; import com.netgrif.application.engine.elastic.domain.BulkOperationWrapper; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseMappingService; import com.netgrif.application.engine.elastic.service.model.FullTextField; import com.netgrif.application.engine.objects.auth.domain.LoggedUser; import com.netgrif.application.engine.objects.elastic.domain.ElasticCase; @@ -68,6 +69,7 @@ public class ElasticCaseService extends ElasticViewPermissionService implements protected ApplicationEventPublisher publisher; protected ElasticQueueManager caseElasticIndexQueueManager; protected ElasticQueueManager caseElasticDeleteQueueManager; + protected IElasticCaseMappingService caseMappingService; public ElasticCaseService(ElasticCaseRepository repository, ElasticsearchTemplate template, @@ -78,6 +80,19 @@ public ElasticCaseService(ElasticCaseRepository repository, IElasticCasePrioritySearch elasticCasePrioritySearch, ApplicationEventPublisher publisher, ElasticsearchClient elasticsearchClient) { + this(repository, template, executors, elasticProperties, petriNetService, workflowService, elasticCasePrioritySearch, publisher, elasticsearchClient, null); + } + + public ElasticCaseService(ElasticCaseRepository repository, + ElasticsearchTemplate template, + Executor executors, + DataConfigurationProperties.ElasticsearchProperties elasticProperties, + @Lazy IPetriNetService petriNetService, + @Lazy IWorkflowService workflowService, + IElasticCasePrioritySearch elasticCasePrioritySearch, + ApplicationEventPublisher publisher, + ElasticsearchClient elasticsearchClient, + @Lazy IElasticCaseMappingService caseMappingService) { this.repository = repository; this.template = template; this.executors = executors; @@ -88,7 +103,11 @@ public ElasticCaseService(ElasticCaseRepository repository, this.publisher = publisher; this.caseElasticIndexQueueManager = new ElasticQueueManager(elasticProperties, elasticsearchClient, publisher); this.caseElasticDeleteQueueManager = new ElasticQueueManager(elasticProperties, elasticsearchClient, publisher); + this.caseMappingService = caseMappingService; + } + public void setCaseMappingService(IElasticCaseMappingService caseMappingService) { + this.caseMappingService = caseMappingService; } @PreDestroy @@ -134,6 +153,16 @@ public void indexNow(ElasticCase useCase) { @Async @Override public void update(ElasticCase useCase) { + if (!repository.existsById(useCase.getId())) { + log.debug("[{}]: Case \"{}\" not found in index for update, queueing full-case index", useCase.getId(), useCase.getTitle()); + if (this.workflowService != null && this.caseMappingService != null) { + Case fullCase = this.workflowService.findOne(useCase.getId()); + if (fullCase != null) { + index(this.caseMappingService.transform(fullCase)); + return; + } + } + } caseElasticIndexQueueManager.push(BulkOperationWrapper.builder() .operation(createUpdateOperation(useCase)) .publishableEvent(new IndexCaseEvent(useCase)) @@ -587,7 +616,6 @@ private BulkOperation createUpdateOperation(ElasticCase useCase) { .id(useCase.getId()) .action(a -> a .doc(template.getElasticsearchConverter().mapObject(useCase)) - .docAsUpsert(true) ))); } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java index 0ee152ca314..6c4aa66496d 100644 --- a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseServiceTest.java @@ -8,6 +8,7 @@ import com.netgrif.application.engine.elastic.domain.BulkOperationWrapper; import com.netgrif.application.engine.elastic.domain.ElasticCaseRepository; import com.netgrif.application.engine.elastic.service.executors.Executor; +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseMappingService; import com.netgrif.application.engine.elastic.service.interfaces.IElasticCasePrioritySearch; import com.netgrif.application.engine.elastic.web.requestbodies.CaseSearchRequest; import com.netgrif.application.engine.objects.auth.domain.LoggedUser; @@ -293,8 +294,10 @@ void searchResolvesCasesFromMongoUsingSearchIds() { LoggedUser user = loggedUser(); Pageable pageable = PageRequest.of(0, 10); + com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase elasticCaseHit = new com.netgrif.application.engine.adapter.spring.elastic.domain.ElasticCase(); + elasticCaseHit.setId("case-1"); SearchHit hit1 = - new SearchHit<>("case-index", "case-1", null, 1.0f, null, null, null, null, null, null, null); + new SearchHit<>("case-index", "case-1", null, 1.0f, null, null, null, null, null, null, elasticCaseHit); SearchHits searchHits = mock(SearchHits.class); when(searchHits.getSearchHits()).thenReturn(List.of(hit1)); when(searchHits.getTotalHits()).thenReturn(1L); @@ -327,6 +330,7 @@ void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { ElasticCase useCase = mock(ElasticCase.class); when(useCase.getId()).thenReturn("case-1"); when(fixture.repository().findById("case-1")).thenReturn(Optional.empty()); + when(fixture.repository().existsById("case-1")).thenReturn(true); ElasticsearchConverter converter = mock(ElasticsearchConverter.class); when(fixture.template().getElasticsearchConverter()).thenReturn(converter); when(converter.mapObject(useCase)).thenReturn(Document.from(new HashMap<>())); @@ -355,6 +359,41 @@ void removeIndexIndexNowAndStopQueuesDelegateToQueueManagers() { verify(deleteQueue).shutdown(); } + @Test + void updateWhenCaseNotInElasticsearchQueuesFullIndex() { + Fixture fixture = fixture(List.of("title")); + ElasticQueueManager indexQueue = mock(ElasticQueueManager.class); + fixture.service().caseElasticIndexQueueManager = indexQueue; + ElasticCase useCase = mock(ElasticCase.class); + when(useCase.getId()).thenReturn("case-1"); + when(useCase.getTitle()).thenReturn("Test Case"); + when(fixture.repository().existsById("case-1")).thenReturn(false); + when(fixture.repository().findById("case-1")).thenReturn(Optional.empty()); + + Case fullCase = mock(Case.class); + when(fixture.workflowService().findOne("case-1")).thenReturn(fullCase); + + ElasticCase fullElasticCase = mock(ElasticCase.class); + when(fullElasticCase.getId()).thenReturn("case-1"); + when(fullElasticCase.getTitle()).thenReturn("Test Case"); + when(fixture.caseMappingService().transform(fullCase)).thenReturn(fullElasticCase); + + ElasticsearchConverter converter = mock(ElasticsearchConverter.class); + when(fixture.template().getElasticsearchConverter()).thenReturn(converter); + when(converter.mapObject(fullElasticCase)).thenReturn(Document.from(new HashMap<>())); + + fixture.service().update(useCase); + + ArgumentCaptor indexOperations = ArgumentCaptor.forClass(BulkOperationWrapper.class); + verify(indexQueue, times(1)).push(indexOperations.capture()); + assertEquals(1, indexOperations.getAllValues().stream() + .filter(operation -> operation.getOperation().isIndex() && "case-index".equals(operation.getOperation().index().index())) + .count()); + assertEquals(0, indexOperations.getAllValues().stream() + .filter(operation -> operation.getOperation().isUpdate()) + .count()); + } + private void assertTermQuery(BoolQuery termQuery, String expectedValue, float firstBoost, float secondBoost) { assertEquals("1", termQuery.minimumShouldMatch()); assertEquals(2, termQuery.should().size()); @@ -395,6 +434,7 @@ private Fixture fixture(List fullTextFields) { ElasticsearchTemplate template = mock(ElasticsearchTemplate.class); IPetriNetService petriNetService = mock(IPetriNetService.class); IWorkflowService workflowService = mock(IWorkflowService.class); + IElasticCaseMappingService caseMappingService = mock(IElasticCaseMappingService.class); DataConfigurationProperties.ElasticsearchProperties properties = new DataConfigurationProperties.ElasticsearchProperties(); properties.setIndex(Map.of(DataConfigurationProperties.ElasticsearchProperties.CASE_INDEX, "case-index")); @@ -407,9 +447,10 @@ private Fixture fixture(List fullTextFields) { workflowService, prioritySearch, mock(ApplicationEventPublisher.class), - mock(ElasticsearchClient.class) + mock(ElasticsearchClient.class), + caseMappingService ); - return new Fixture(service, repository, template, petriNetService, workflowService); + return new Fixture(service, repository, template, petriNetService, workflowService, caseMappingService); } private LoggedUser loggedUser() { @@ -425,6 +466,7 @@ private record Fixture(ElasticCaseService service, ElasticCaseRepository repository, ElasticsearchTemplate template, IPetriNetService petriNetService, - IWorkflowService workflowService) { + IWorkflowService workflowService, + IElasticCaseMappingService caseMappingService) { } } From 0a5bf90b30a8ff82a44f3e9b6bf75e243e5beea5 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Thu, 29 Oct 2026 09:36:48 +0100 Subject: [PATCH 8/9] Add unit tests for `ElasticCaseMappingService` and enhance `populateDataSet` logic to handle partial case data mapping --- .../service/ElasticCaseMappingService.java | 11 +- .../ElasticCaseMappingServiceTest.java | 124 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingServiceTest.java diff --git a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java index a2614f2159a..167a4fa98ac 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java @@ -29,8 +29,15 @@ public ElasticCase transform(Case useCase) { protected void populateDataSet(ElasticCase transformedCase, Case useCase) { for (String id : useCase.getImmediateDataFields()) { - Optional parsedValue = this.transformDataField(id, useCase); - parsedValue.ifPresent(dataField -> transformedCase.getDataSet().put(id, dataField)); + if (useCase.isPartiallyLoaded()) { + if (useCase.getDataSet() != null && useCase.getDataSet().containsKey(id)) { + Optional parsedValue = this.transformDataField(id, useCase); + transformedCase.getDataSet().put(id, parsedValue.orElse(null)); + } + } else { + Optional parsedValue = this.transformDataField(id, useCase); + parsedValue.ifPresent(dataField -> transformedCase.getDataSet().put(id, dataField)); + } } } diff --git a/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingServiceTest.java b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingServiceTest.java new file mode 100644 index 00000000000..03594fc91e6 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingServiceTest.java @@ -0,0 +1,124 @@ +package com.netgrif.application.engine.elastic.service; + +import com.netgrif.application.engine.objects.auth.domain.ActorRef; +import com.netgrif.application.engine.objects.elastic.domain.ElasticCase; +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; +import com.netgrif.application.engine.objects.workflow.domain.ProcessResourceId; +import com.netgrif.application.engine.objects.petrinet.domain.dataset.TextField; +import com.netgrif.application.engine.objects.workflow.domain.Case; +import com.netgrif.application.engine.objects.workflow.domain.DataField; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter; +import org.springframework.data.elasticsearch.core.document.Document; +import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; + +import java.time.LocalDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ElasticCaseMappingServiceTest { + + private ElasticCaseMappingService mappingService; + private MappingElasticsearchConverter converter; + + @BeforeEach + void setUp() { + mappingService = new ElasticCaseMappingService(); + SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext(); + converter = new MappingElasticsearchConverter(mappingContext); + } + + private Case mockCase(boolean partial, Set immediateFields, Map dataSet) { + Case c = mock(Case.class); + when(c.getStringId()).thenReturn("case-1"); + when(c.get_id()).thenReturn(new ProcessResourceId("process", new ObjectId())); + when(c.getPetriNetId()).thenReturn("process"); + when(c.getProcessIdentifier()).thenReturn("process"); + when(c.getVisualId()).thenReturn("CASE-1"); + when(c.getTitle()).thenReturn("Test Case"); + when(c.getLastModified()).thenReturn(LocalDateTime.now()); + when(c.getCreationDate()).thenReturn(LocalDateTime.now()); + when(c.getAuthor()).thenReturn(new ActorRef("user-1", "netgrif", "Test User", "testuser")); + when(c.getImmediateDataFields()).thenReturn(immediateFields); + when(c.isPartiallyLoaded()).thenReturn(partial); + when(c.getDataSet()).thenReturn(dataSet); + when(c.getTasks()).thenReturn(Collections.emptySet()); + when(c.getEnabledRoles()).thenReturn(Collections.emptySet()); + when(c.getViewRoles()).thenReturn(Collections.emptyList()); + when(c.getViewActorRefs()).thenReturn(Collections.emptyList()); + when(c.getNegativeViewRoles()).thenReturn(Collections.emptyList()); + when(c.getViewActors()).thenReturn(Collections.emptyList()); + when(c.getNegativeViewActors()).thenReturn(Collections.emptyList()); + when(c.getTags()).thenReturn(Collections.emptyMap()); + when(c.getPermissions()).thenReturn(Collections.emptyMap()); + when(c.getActors()).thenReturn(Collections.emptyMap()); + when(c.getActorRefs()).thenReturn(Collections.emptyMap()); + when(c.getImmediateData()).thenReturn(Collections.emptyList()); + + for (String fieldId : immediateFields) { + TextField netField = new TextField(); + netField.setImportId(fieldId); + doReturn(netField).when(c).getField(fieldId); + if (dataSet != null && dataSet.containsKey(fieldId)) { + when(c.getDataField(fieldId)).thenReturn(dataSet.get(fieldId)); + } + } + return c; + } + + @Test + void transformFullCaseIncludesOnlyNonNullImmediateFields() { + Set immediateFields = new LinkedHashSet<>(List.of("field_1", "field_2")); + Map dataSet = new HashMap<>(); + dataSet.put("field_1", new DataField("Hello")); + dataSet.put("field_2", new DataField(null)); + + Case fullCase = mockCase(false, immediateFields, dataSet); + + ElasticCase result = mappingService.transform(fullCase); + + assertNotNull(result); + assertTrue(result.getDataSet().containsKey("field_1")); + assertNotNull(result.getDataSet().get("field_1")); + assertFalse(result.getDataSet().containsKey("field_2")); + } + + @Test + void transformPartialCaseIncludesChangedAndExplicitlyClearedFields() { + Set immediateFields = new LinkedHashSet<>(List.of("field_1", "field_2", "field_3")); + Map partialDataSet = new HashMap<>(); + partialDataSet.put("field_1", new DataField("Updated Value")); + partialDataSet.put("field_2", new DataField(null)); // explicitly cleared + + Case partialCase = mockCase(true, immediateFields, partialDataSet); + + ElasticCase result = mappingService.transform(partialCase); + + assertNotNull(result); + // field_1 should be updated with new value + assertTrue(result.getDataSet().containsKey("field_1")); + assertNotNull(result.getDataSet().get("field_1")); + + // field_2 was in partial case and cleared, so it should be mapped as null + assertTrue(result.getDataSet().containsKey("field_2")); + assertNull(result.getDataSet().get("field_2")); + + // field_3 was NOT in partial case (unloaded), so it should not be in dataSet at all + assertFalse(result.getDataSet().containsKey("field_3")); + + // Verify that MappingElasticsearchConverter properly preserves null for field_2 and omits field_3 + Document doc = converter.mapObject(result); + assertNotNull(doc); + Map docDataSet = (Map) doc.get("dataSet"); + assertNotNull(docDataSet); + assertTrue(docDataSet.containsKey("field_1")); + assertNotNull(docDataSet.get("field_1")); + assertTrue(docDataSet.containsKey("field_2")); + assertNull(docDataSet.get("field_2")); + assertFalse(docDataSet.containsKey("field_3")); + } +} From 93b0835f83993589496a3fa41e17e5607d5f60fc Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Mon, 2 Nov 2026 16:38:42 +0100 Subject: [PATCH 9/9] Add case reader plugin tests to validate `CaseReader` functionality and operations. --- .../petriNets/case_reader_plugin_test.xml | 1346 +++++++++++++++++ 1 file changed, 1346 insertions(+) create mode 100644 application-engine/src/test/resources/petriNets/case_reader_plugin_test.xml diff --git a/application-engine/src/test/resources/petriNets/case_reader_plugin_test.xml b/application-engine/src/test/resources/petriNets/case_reader_plugin_test.xml new file mode 100644 index 00000000000..ac337920c3e --- /dev/null +++ b/application-engine/src/test/resources/petriNets/case_reader_plugin_test.xml @@ -0,0 +1,1346 @@ + + + case_reader_plugin_test + CRPT + Case Reader Plugin Test + true + false + + + admin + Admin Role + + + + test_field + Test Field + initial_value + + + file_field + File Field + + + file_list_field + File List Field + + + + + btn_test_1 + + TOTOK.caseReader.showCaseTitle(useCase) + + flat + + + e_btn_test_1 + + + res_test_1: f.res_test_1; + try { + TOTOK.caseReader.showCaseTitle(useCase) + change res_test_1 value { "SUCCESS: showCaseTitle executed for case [title: " + useCase.title + "]" } + } catch (Exception e) { + change res_test_1 value { "ERROR: " + e.message } + } + + + + + + res_test_1 + Result for showCaseTitle(Case) + + + + + btn_test_2 + + TOTOK.caseReader.findCase(caseId) + + flat + + + e_btn_test_2 + + + res_test_2: f.res_test_2; + try { + def found = TOTOK.caseReader.findCase(useCase.stringId) + change res_test_2 value { "SUCCESS: Found case " + (found?.title ?: "null") + " (ID: " + (found?.stringId ?: "null") + ")" } + } catch (Exception e) { + change res_test_2 value { "ERROR: " + e.message } + } + + + + + + res_test_2 + Result for findCase(String) + + + + + btn_test_3 + + TOTOK.caseReader.findCaseWithFields(caseId, fieldIds) + + flat + + + e_btn_test_3 + + + res_test_3: f.res_test_3; + try { + def found = TOTOK.caseReader.findCaseWithFields(useCase.stringId, ["test_field"]) + change res_test_3 value { "SUCCESS: Found case " + (found?.title ?: "null") + " with fields: " + (found?.dataSet?.keySet()?.join(", ") ?: "none") } + } catch (Exception e) { + change res_test_3 value { "ERROR: " + e.message } + } + + + + + + res_test_3 + Result for findCaseWithFields(String, Collection) + + + + + btn_test_4 + + TOTOK.caseReader.findCaseWithOptions(caseId, fieldIds, immediate, setPetriNet) + + flat + + + e_btn_test_4 + + + res_test_4: f.res_test_4; + try { + def found = TOTOK.caseReader.findCaseWithOptions(useCase.stringId, ["test_field"], true, false) + change res_test_4 value { "SUCCESS: Found case " + (found?.title ?: "null") + " with options" } + } catch (Exception e) { + change res_test_4 value { "ERROR: " + e.message } + } + + + + + + res_test_4 + Result for findCaseWithOptions(String, Collection, boolean, boolean) + + + + + btn_test_5 + + TOTOK.caseReader.findCases(caseIds) + + flat + + + e_btn_test_5 + + + res_test_5: f.res_test_5; + try { + def found = TOTOK.caseReader.findCases([useCase.stringId]) + change res_test_5 value { "SUCCESS: Found " + found?.size() + " cases" } + } catch (Exception e) { + change res_test_5 value { "ERROR: " + e.message } + } + + + + + + res_test_5 + Result for findCases(List) + + + + + btn_test_6 + + TOTOK.caseReader.findCasesWithFields(caseIds, fieldIds) + + flat + + + e_btn_test_6 + + + res_test_6: f.res_test_6; + try { + def found = TOTOK.caseReader.findCasesWithFields([useCase.stringId], ["test_field"]) + change res_test_6 value { "SUCCESS: Found " + found?.size() + " cases with fields" } + } catch (Exception e) { + change res_test_6 value { "ERROR: " + e.message } + } + + + + + + res_test_6 + Result for findCasesWithFields(List, Collection) + + + + + btn_test_7 + + TOTOK.caseReader.findCasesWithOptions(caseIds, fieldIds, immediate, setPetriNet) + + flat + + + e_btn_test_7 + + + res_test_7: f.res_test_7; + try { + def found = TOTOK.caseReader.findCasesWithOptions([useCase.stringId], ["test_field"], true, false) + change res_test_7 value { "SUCCESS: Found " + found?.size() + " cases with options" } + } catch (Exception e) { + change res_test_7 value { "ERROR: " + e.message } + } + + + + + + res_test_7 + Result for findCasesWithOptions(List, Collection, boolean, boolean) + + + + + btn_test_8 + + TOTOK.caseReader.findCaseByQuery(query) + + flat + + + e_btn_test_8 + + + res_test_8: f.res_test_8; + try { + def found = TOTOK.caseReader.findCaseByQuery("title:\"" + useCase.title + "\"") + change res_test_8 value { "SUCCESS: Found case " + (found?.title ?: "none") } + } catch (Exception e) { + change res_test_8 value { "ERROR: " + e.message } + } + + + + + + res_test_8 + Result for findCaseByQuery(String) + + + + + btn_test_9 + + TOTOK.caseReader.findCaseByQueryWithPrincipal(query, principal) + + flat + + + e_btn_test_9 + + + res_test_9: f.res_test_9; + try { + def found = TOTOK.caseReader.findCaseByQueryWithPrincipal("title:\"" + useCase.title + "\"", null) + change res_test_9 value { "SUCCESS: Found case with principal: " + (found?.title ?: "none") } + } catch (Exception e) { + change res_test_9 value { "ERROR: " + e.message } + } + + + + + + res_test_9 + Result for findCaseByQueryWithPrincipal(String, AuthPrincipalDto) + + + + + btn_test_10 + + TOTOK.caseReader.searchCases(predicate) + + flat + + + e_btn_test_10 + + + res_test_10: f.res_test_10; + try { + def page = TOTOK.caseReader.searchCases(null) + change res_test_10 value { "SUCCESS: searchCases returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_10 value { "ERROR: " + e.message } + } + + + + + + res_test_10 + Result for searchCases(Predicate) + + + + + btn_test_11 + + TOTOK.caseReader.searchCasesWithPageable(processId, predicate, pageable) + + flat + + + e_btn_test_11 + + + res_test_11: f.res_test_11; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesWithPageable(useCase.processIdentifier, null, pageable) + change res_test_11 value { "SUCCESS: searchCasesWithPageable returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_11 value { "ERROR: " + e.message } + } + + + + + + res_test_11 + Result for searchCasesWithPageable(String, Predicate, Pageable) + + + + + btn_test_12 + + TOTOK.caseReader.searchCasesWithFields(processId, predicate, pageable, fieldIds) + + flat + + + e_btn_test_12 + + + res_test_12: f.res_test_12; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesWithFields(useCase.processIdentifier, null, pageable, ["test_field"]) + change res_test_12 value { "SUCCESS: searchCasesWithFields returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_12 value { "ERROR: " + e.message } + } + + + + + + res_test_12 + Result for searchCasesWithFields(String, Predicate, Pageable, Collection) + + + + + btn_test_13 + + TOTOK.caseReader.searchCasesByProcessIdentifier(processId, pageable) + + flat + + + e_btn_test_13 + + + res_test_13: f.res_test_13; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesByProcessIdentifier(useCase.processIdentifier, pageable) + change res_test_13 value { "SUCCESS: searchCasesByProcessIdentifier returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_13 value { "ERROR: " + e.message } + } + + + + + + res_test_13 + Result for searchCasesByProcessIdentifier(String, Pageable) + + + + + btn_test_14 + + TOTOK.caseReader.searchCasesElastic(queries, principal, pageable, isIntersection) + + flat + + + e_btn_test_14 + + + res_test_14: f.res_test_14; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesElastic(["title:\"" + useCase.title + "\""], null, pageable, false) + change res_test_14 value { "SUCCESS: searchCasesElastic returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_14 value { "ERROR: " + e.message } + } + + + + + + res_test_14 + Result for searchCasesElastic(List, AuthPrincipalDto, Pageable, Boolean) + + + + + btn_test_15 + + TOTOK.caseReader.searchCasesElasticWithFields(queries, principal, pageable, isIntersection, fieldIds) + + flat + + + e_btn_test_15 + + + res_test_15: f.res_test_15; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesElasticWithFields(["title:\"" + useCase.title + "\""], null, pageable, false, ["test_field"]) + change res_test_15 value { "SUCCESS: searchCasesElasticWithFields returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_15 value { "ERROR: " + e.message } + } + + + + + + res_test_15 + Result for searchCasesElasticWithFields(List, AuthPrincipalDto, Pageable, Boolean, Collection) + + + + + btn_test_16 + + TOTOK.caseReader.searchCasesByQuery(query, pageable) + + flat + + + e_btn_test_16 + + + res_test_16: f.res_test_16; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesByQuery("title:\"" + useCase.title + "\"", pageable) + change res_test_16 value { "SUCCESS: searchCasesByQuery returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_16 value { "ERROR: " + e.message } + } + + + + + + res_test_16 + Result for searchCasesByQuery(String, Pageable) + + + + + btn_test_17 + + TOTOK.caseReader.searchCasesByQueryWithPrincipal(query, principal, pageable) + + flat + + + e_btn_test_17 + + + res_test_17: f.res_test_17; + try { + def pageable = org.springframework.data.domain.PageRequest.of(0, 10) + def page = TOTOK.caseReader.searchCasesByQueryWithPrincipal("title:\"" + useCase.title + "\"", null, pageable) + change res_test_17 value { "SUCCESS: searchCasesByQueryWithPrincipal returned " + page?.totalElements + " cases" } + } catch (Exception e) { + change res_test_17 value { "ERROR: " + e.message } + } + + + + + + res_test_17 + Result for searchCasesByQueryWithPrincipal(String, AuthPrincipalDto, Pageable) + + + + + btn_test_18 + + TOTOK.caseReader.countCases(queries, principal, isIntersection) + + flat + + + e_btn_test_18 + + + res_test_18: f.res_test_18; + try { + def count = TOTOK.caseReader.countCases(["title:\"" + useCase.title + "\""], null, false) + change res_test_18 value { "SUCCESS: countCases returned " + count } + } catch (Exception e) { + change res_test_18 value { "ERROR: " + e.message } + } + + + + + + res_test_18 + Result for countCases(List, AuthPrincipalDto, Boolean) + + + + + btn_test_19 + + TOTOK.caseReader.countCasesByQuery(query) + + flat + + + e_btn_test_19 + + + res_test_19: f.res_test_19; + try { + def count = TOTOK.caseReader.countCasesByQuery("title:\"" + useCase.title + "\"") + change res_test_19 value { "SUCCESS: countCasesByQuery returned " + count } + } catch (Exception e) { + change res_test_19 value { "ERROR: " + e.message } + } + + + + + + res_test_19 + Result for countCasesByQuery(String) + + + + + btn_test_20 + + TOTOK.caseReader.countCasesByQueryWithPrincipal(query, principal) + + flat + + + e_btn_test_20 + + + res_test_20: f.res_test_20; + try { + def count = TOTOK.caseReader.countCasesByQueryWithPrincipal("title:\"" + useCase.title + "\"", null) + change res_test_20 value { "SUCCESS: countCasesByQueryWithPrincipal returned " + count } + } catch (Exception e) { + change res_test_20 value { "ERROR: " + e.message } + } + + + + + + res_test_20 + Result for countCasesByQueryWithPrincipal(String, AuthPrincipalDto) + + + + + btn_test_21 + + TOTOK.caseReader.getCaseTitle(caseId) + + flat + + + e_btn_test_21 + + + res_test_21: f.res_test_21; + try { + def title = TOTOK.caseReader.getCaseTitle(useCase.stringId) + change res_test_21 value { "SUCCESS: getCaseTitle returned " + title } + } catch (Exception e) { + change res_test_21 value { "ERROR: " + e.message } + } + + + + + + res_test_21 + Result for getCaseTitle(String) + + + + + t_main + 100 + 100 + + + dg_tests + 2 + grid + + + + btn_test_1 + + editable + + + 0 + 0 + 1 + 1 + + outline + + + + res_test_1 + + visible + + + 1 + 0 + 1 + 1 + + outline + + + + + + btn_test_2 + + editable + + + 0 + 1 + 1 + 1 + + outline + + + + res_test_2 + + visible + + + 1 + 1 + 1 + 1 + + outline + + + + + + btn_test_3 + + editable + + + 0 + 2 + 1 + 1 + + outline + + + + res_test_3 + + visible + + + 1 + 2 + 1 + 1 + + outline + + + + + + btn_test_4 + + editable + + + 0 + 3 + 1 + 1 + + outline + + + + res_test_4 + + visible + + + 1 + 3 + 1 + 1 + + outline + + + + + + btn_test_5 + + editable + + + 0 + 4 + 1 + 1 + + outline + + + + res_test_5 + + visible + + + 1 + 4 + 1 + 1 + + outline + + + + + + btn_test_6 + + editable + + + 0 + 5 + 1 + 1 + + outline + + + + res_test_6 + + visible + + + 1 + 5 + 1 + 1 + + outline + + + + + + btn_test_7 + + editable + + + 0 + 6 + 1 + 1 + + outline + + + + res_test_7 + + visible + + + 1 + 6 + 1 + 1 + + outline + + + + + + btn_test_8 + + editable + + + 0 + 7 + 1 + 1 + + outline + + + + res_test_8 + + visible + + + 1 + 7 + 1 + 1 + + outline + + + + + + btn_test_9 + + editable + + + 0 + 8 + 1 + 1 + + outline + + + + res_test_9 + + visible + + + 1 + 8 + 1 + 1 + + outline + + + + + + btn_test_10 + + editable + + + 0 + 9 + 1 + 1 + + outline + + + + res_test_10 + + visible + + + 1 + 9 + 1 + 1 + + outline + + + + + + btn_test_11 + + editable + + + 0 + 10 + 1 + 1 + + outline + + + + res_test_11 + + visible + + + 1 + 10 + 1 + 1 + + outline + + + + + + btn_test_12 + + editable + + + 0 + 11 + 1 + 1 + + outline + + + + res_test_12 + + visible + + + 1 + 11 + 1 + 1 + + outline + + + + + + btn_test_13 + + editable + + + 0 + 12 + 1 + 1 + + outline + + + + res_test_13 + + visible + + + 1 + 12 + 1 + 1 + + outline + + + + + + btn_test_14 + + editable + + + 0 + 13 + 1 + 1 + + outline + + + + res_test_14 + + visible + + + 1 + 13 + 1 + 1 + + outline + + + + + + btn_test_15 + + editable + + + 0 + 14 + 1 + 1 + + outline + + + + res_test_15 + + visible + + + 1 + 14 + 1 + 1 + + outline + + + + + + btn_test_16 + + editable + + + 0 + 15 + 1 + 1 + + outline + + + + res_test_16 + + visible + + + 1 + 15 + 1 + 1 + + outline + + + + + + btn_test_17 + + editable + + + 0 + 16 + 1 + 1 + + outline + + + + res_test_17 + + visible + + + 1 + 16 + 1 + 1 + + outline + + + + + + btn_test_18 + + editable + + + 0 + 17 + 1 + 1 + + outline + + + + res_test_18 + + visible + + + 1 + 17 + 1 + 1 + + outline + + + + + + btn_test_19 + + editable + + + 0 + 18 + 1 + 1 + + outline + + + + res_test_19 + + visible + + + 1 + 18 + 1 + 1 + + outline + + + + + + btn_test_20 + + editable + + + 0 + 19 + 1 + 1 + + outline + + + + res_test_20 + + visible + + + 1 + 19 + 1 + 1 + + outline + + + + + + btn_test_21 + + editable + + + 0 + 20 + 1 + 1 + + outline + + + + res_test_21 + + visible + + + 1 + 20 + 1 + 1 + + outline + + + + + + + helper_task + 300 + 100 + + + dg_helper + 2 + grid + + test_field + + editable + + + 0 + 0 + 1 + 1 + + outline + + + + file_field + + editable + + + 1 + 0 + 1 + 1 + + outline + + + + file_list_field + + editable + + + 0 + 1 + 1 + 1 + + outline + + + + + + + + p1 + 200 + 50 + + 1 + false + + + + + a1 + read + p1 + t_main + 1 + + + a2 + read + p1 + helper_task + 1 + +