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/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/ElasticCaseMappingService.java b/application-engine/src/main/java/com/netgrif/application/engine/elastic/service/ElasticCaseMappingService.java index 7f311b98382..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)); + } } } @@ -38,7 +45,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 303f94a8f8f..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; @@ -35,10 +36,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.*; @@ -65,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, @@ -75,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; @@ -85,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 @@ -128,8 +150,38 @@ public void indexNow(ElasticCase useCase) { index(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)) + .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); + } + + @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!"); } @@ -142,9 +194,10 @@ 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())); - casePage = workflowService.findAllById(indexedCases.get().map(ElasticCase::getId).collect(Collectors.toList())); + 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(); log.debug("Found [{}] total elements of page [{}]", casePage.size(), pageable.getPageNumber()); } else { @@ -191,7 +244,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 @@ -556,6 +610,15 @@ 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)) + ))); + } + 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 bf3da6f70ba..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 @@ -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; @@ -18,8 +19,20 @@ 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); + /** + * 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..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 @@ -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,13 @@ public GetDataEventOutcome getData(Task task, Case useCase, Map } }); - workflowService.save(useCase); + if (!useCase.isPartiallyLoaded()) { + workflowService.save(useCase); + } 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); + } dataSetFields.stream().filter(field -> field instanceof NumberField).forEach(field -> { DataField dataField = useCase.getDataSet().get(field.getImportId()); @@ -427,7 +434,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 +889,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; @@ -1198,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) { @@ -1328,10 +1335,29 @@ 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) { + if (net == null || task == null) { + return Collections.emptySet(); + } + Transition transition = net.getTransition(task.getTransitionId()); + 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 (net == null || transition == null || transition.getDataSet() == null || net.getDataSet() == 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..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 @@ -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,179 @@ 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. + *

+ * 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) { + 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 loadProjected(caseIds, net -> dataFieldIds, includeImmediateFields, setPetriNet); + } + + /** + * 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) { + return findAllByIdProjected(caseIds, dataFieldIds, true, true); + } + + private List loadProjected(List caseIds, Collection 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()); + + 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 = initialProjection; + for (Case aCase : cases) { + if (includeImmediateFields) { + 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 finalProjection = requested.withAdditionalKeys(required); + // Only execute a second query if additional keys were required that were not in the initial projection + if (!initialProjection.dataSetKeys().containsAll(finalProjection.dataSetKeys())) { + cases = repository.findAllProjectedByObjectIds(objectIds, finalProjection); + } + + 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; + } + if (setPetriNet) { + setPetriNet(aCase); + } + decryptDataSet(aCase); + if (includeImmediateFields) { + 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, 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) { + 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, net -> dataFieldIds, includeImmediateFields, setPetriNet); + 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 partially updated. + */ + @Override + public Case savePartial(Case partialCase) { + if (!partialCase.isPartiallyLoaded()) { + throw new IllegalArgumentException("Case [" + partialCase.getStringId() + "] is not partially loaded, use save"); + } + // Setting PetriNet is needed for encryption metadata lookup in encryptDataSet + if (partialCase.getPetriNet() == null) { + setPetriNet(partialCase); + } + encryptDataSet(partialCase); + if (!repository.updatePartial(partialCase)) { + throw new IllegalArgumentException("Could not find Case with id [" + partialCase.getStringId() + "]"); + } + decryptDataSet(partialCase); + try { + setImmediateDataFieldsReadOnly(partialCase); + elasticCaseService.updateNow(this.caseMappingService.transform(partialCase)); + } 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 +705,35 @@ 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)); + return hydrateProjectedPage(staticPage, pageable, dataFieldIds, true, true); + } + @Override public Map listToMap(List cases) { Map options = new HashMap<>(); @@ -573,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<>(); @@ -662,7 +849,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..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 @@ -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,95 @@ 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). + * 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, 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. + * + * @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. + * + * @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)}; + * 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 +141,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/actions/ActionApiImplTest.java b/application-engine/src/test/java/com/netgrif/application/engine/actions/ActionApiImplTest.java index 74e876d68c1..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 @@ -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(); @@ -143,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/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/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")); + } +} 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..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 @@ -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,22 @@ void multipleFullTextRequestsSupportIntersectionAndUnion() throws InterruptedExc )); } + @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); 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..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; @@ -27,8 +28,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 +267,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 @@ -275,20 +289,35 @@ void buildSingleQueryInvokesAllEmptyFilterPaths() { } @Test - void searchAndCountHandleEmptyQueryAndRejectNullRequests() { - ElasticCaseService service = spy(service(List.of("title"))); + void searchResolvesCasesFromMongoUsingSearchIds() { + Fixture fixture = fixture(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 result = service.search(List.of(new CaseSearchRequest()), user, pageable, Locale.ENGLISH, true); - assertTrue(result.isEmpty()); - assertEquals(0, service.count(List.of(new CaseSearchRequest()), user, Locale.ENGLISH, true)); - assertThrows(IllegalArgumentException.class, - () -> service.search(null, user, pageable, Locale.ENGLISH, true)); - assertThrows(IllegalArgumentException.class, - () -> service.count(null, user, Locale.ENGLISH, true)); + 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, elasticCaseHit); + 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 @@ -301,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<>())); @@ -308,6 +338,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,13 +348,52 @@ 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(); } + @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()); @@ -363,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")); @@ -375,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() { @@ -393,6 +466,7 @@ private record Fixture(ElasticCaseService service, ElasticCaseRepository repository, ElasticsearchTemplate template, IPetriNetService petriNetService, - IWorkflowService workflowService) { + IWorkflowService workflowService, + IElasticCaseMappingService caseMappingService) { } } 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..ce67e0fd476 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/ProjectionTestNetIntegrationTest.java @@ -0,0 +1,358 @@ +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()); + } + + @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/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..ce3d18c0a16 --- /dev/null +++ b/application-engine/src/test/java/com/netgrif/application/engine/workflow/service/WorkflowServiceProjectionTest.java @@ -0,0 +1,216 @@ +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()); + } + + @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")); + } +} 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 + + 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..99d4f5a8e0a --- /dev/null +++ b/application-engine/src/test/resources/petriNets/projection_test_net.xml @@ -0,0 +1,646 @@ + + + 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 + + t_embedded_task + + + + 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 + + + + + + + + t_embedded_task + 100 + 300 + 2 + + auto + + tester + + true + + + + dg_embedded_task + Embedded Form Data + + txt_simple + + editable + + + + num_standard + + editable + + + + + + + + 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/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"), 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; 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();